View analyses: one toggle group, its state in the session

Zebra, Draft, Curvature Map, Environment Map, Accessibility, Continuity,
Isocurve and Curvature Comb are one group: turning one on turns the one on
before off (Session::analyses_off), and the session says which is on
(Session::active_analysis). The new inspect.analysis names it and off: true
turns it off (typed contract and reply). The toolbar shows the active one's
button pressed, its tool running or not. Curvature Comb's button toggles:
on, the tool combs the edges clicked; again, off and the combs go; Esc or a
right click in the tool turns it off; opening another dialog leaves it on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-30 07:39:30 -04:00
committed by Irakli Kourtis
parent 0b7468729f
commit 6ab96c6583
9 changed files with 271 additions and 17 deletions
@@ -0,0 +1,17 @@
{
"en": {
"cmd.inspect_analysis": "View Analysis"
},
"ar": {
"cmd.inspect_analysis": "تحليل العرض"
},
"he": {
"cmd.inspect_analysis": "ניתוח תצוגה"
},
"ja": {
"cmd.inspect_analysis": "表示解析"
},
"zh": {
"cmd.inspect_analysis": "视图分析"
}
}
+2 -2
View File
@@ -231,8 +231,8 @@ fn curvature_comb(s: &mut Session, p: &Value) -> Result<Value> {
value
})
.collect();
s.analyses_off(Some("inspect.curvature_comb"));
s.comb = Some(CombView { picks: kept, density, length, fixed_scale, scale, teeth: drawn });
s.revision += 1;
Ok(json!({"combs": out, "scale": scale}))
}
@@ -426,7 +426,7 @@ fn isocurves(s: &mut Session, p: &Value) -> Result<Value> {
s.isocurves.clear();
}
s.isocurves.extend(picks.into_iter().map(|(_, k)| k));
s.revision += 1;
s.analyses_off(Some("inspect.isocurve"));
Ok(json!({"curves": curves, "faces": shown}))
}
+83 -1
View File
@@ -1,5 +1,7 @@
//! Surface analyses: zebra stripes, draft angle and curvature map shading of the model's faces.
//! View state only: no geometry changes and no undo step.
//! View state only: no geometry changes and no undo step. The view analyses (these shadings,
//! continuity seams, isocurves and curvature combs) are one group: one at a time is on, and
//! `inspect.analysis` says which or turns it off.
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
@@ -38,6 +40,18 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("curvature_map")
.noundo()
.params("radius?: mm shown red (default: a tenth of the model's size); clear?: true turns the analysis off"),
CommandSpec::new("inspect.analysis", "View Analysis", crate::checked!("inspect.analysis", analysis))
.icon("zebra")
.noundo()
.doc_only()
.contract(&ANALYSIS_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::surface_analysis::AnalysisState>)
.params(
"off?: true turns the view analysis on now off. → active: the command id of the view analysis on (inspect.zebra, \
inspect.draft, inspect.curvature_map, inspect.environment_map, inspect.accessibility, inspect.continuity, \
inspect.isocurve or inspect.curvature_comb), or null. One is on at a time: turning one on turns the one on before \
off. Read-only without `off`",
),
CommandSpec::new("inspect.continuity", "Continuity Analysis", continuity).at("SURFACE", "INSPECT").icon("continuity").noundo().params(
"body?: name (default: the visible bodies); edges?: [x,y,z] points or {\"index\": i} (default: every edge of the body; \
a pick must match one body); samples?: 3…257 stations along each edge, ends included (default 33); \
@@ -52,10 +66,75 @@ pub static COMMANDS: &[CommandSpec] = &[
),
];
const ANALYSIS_CONTRACT: crate::schema::TypedContract<crate::schema::surface_analysis::AnalysisParams> = crate::schema::TypedContract::new();
/// The view analyses, by the command that turns each on: the shadings, the continuity seams, the
/// isocurves and the curvature combs. One at a time is on.
impl Session {
/// The view analysis on now, by its command id, or `None`.
pub fn active_analysis(&self) -> Option<&'static str> {
if let Some(a) = self.analysis {
Some(a.command())
} else if self.comb.is_some() {
Some("inspect.curvature_comb")
} else if !self.isocurves.is_empty() {
Some("inspect.isocurve")
} else if !self.continuity.is_empty() {
Some("inspect.continuity")
} else {
None
}
}
/// Turn every view analysis off but the one command `keep` turns on (`None`: all of them).
pub fn analyses_off(&mut self, keep: Option<&str>) {
if self.analysis.is_some_and(|a| Some(a.command()) != keep) {
self.analysis = None;
}
if keep != Some("inspect.curvature_comb") {
self.comb = None;
}
if keep != Some("inspect.isocurve") {
self.isocurves.clear();
}
if keep != Some("inspect.continuity") {
self.continuity.clear();
}
self.revision += 1;
}
}
impl SurfaceAnalysis {
/// The command that turns this analysis on.
pub fn command(self) -> &'static str {
match self {
SurfaceAnalysis::Zebra { .. } => "inspect.zebra",
SurfaceAnalysis::Draft { .. } => "inspect.draft",
SurfaceAnalysis::Curvature { .. } => "inspect.curvature_map",
SurfaceAnalysis::Environment => "inspect.environment_map",
SurfaceAnalysis::Access { .. } => "inspect.accessibility",
}
}
}
/// `inspect.analysis`: which view analysis is on, and `off` turns it off.
fn analysis(s: &mut Session, p: &Value) -> Result<Value> {
let off = match p.get("off") {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad("inspect.analysis", "`off` must be true or false")),
};
if off {
s.analyses_off(None);
}
Ok(json!({"active": s.active_analysis()}))
}
fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result<Value> {
if bool_(p, "clear").unwrap_or(false) {
s.analysis = None;
} else {
s.analyses_off(Some(a.command()));
s.analysis = Some(a);
}
s.revision += 1;
@@ -213,6 +292,9 @@ fn continuity(s: &mut Session, p: &Value) -> Result<Value> {
} else {
s.continuity = seams;
}
if !s.continuity.is_empty() {
s.analyses_off(Some("inspect.continuity"));
}
s.revision += 1;
Ok(json!({
"analysis": "continuity",
+1
View File
@@ -58,6 +58,7 @@ pub mod browser;
pub mod diagnostics;
pub mod features;
pub mod inspect;
pub mod surface_analysis;
pub use check::{Contract, Issue, ParamCheck, TypedContract, input_schema_for, output_schema_for, run_checked, run_checked_as};
pub use types::*;
@@ -0,0 +1,35 @@
//! Contracts and result types for the commands in `cmd/surface_analysis.rs`.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// `true`, `false` or null, with the handler's message for anything else.
fn flag<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
match Value::deserialize(d)? {
Value::Null => Ok(None),
Value::Bool(b) => Ok(Some(b)),
_ => Err(serde::de::Error::custom("must be true or false")),
}
}
/// Say which view analysis is on (zebra, draft, curvature map, environment map, accessibility,
/// continuity, isocurves or curvature combs: one at a time), or turn it off.
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AnalysisParams {
/// Turn the view analysis on now off.
#[serde(default, deserialize_with = "flag")]
pub off: Option<bool>,
}
/// What `inspect.analysis` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct AnalysisState {
/// The command id of the view analysis on (after `off`), or null when none is.
pub active: Option<String>,
}
#[cfg(test)]
#[path = "surface_analysis_tests.rs"]
mod tests;
@@ -0,0 +1,45 @@
use serde_json::json;
use super::AnalysisParams;
use crate::Session;
use crate::schema::input_schema_for;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<AnalysisParams>(), &["off"]);
}
#[test]
fn strict_keeps_todays_errors() {
assert_strict_keeps_todays_errors(&[("inspect.analysis", json!({"off": "yes"})), ("inspect.analysis", json!({"off": 1}))]);
}
/// The view analyses are one group: turning one on turns the one on before off, and
/// `inspect.analysis` names the one on and turns it off.
#[test]
fn one_view_analysis_is_on_at_a_time() {
let mut s = Session::default();
s.execute("solid.cylinder", &json!({"radius": 5, "height": 10})).unwrap();
let active = |s: &mut Session| s.execute("inspect.analysis", &json!({})).unwrap()["active"].clone();
assert_eq!(active(&mut s), json!(null));
s.execute("inspect.zebra", &json!({})).unwrap();
assert_eq!(active(&mut s), json!("inspect.zebra"));
s.execute("inspect.curvature_comb", &json!({"edges": [[0, 5, 10]]})).unwrap();
assert_eq!(active(&mut s), json!("inspect.curvature_comb"));
assert!(s.analysis.is_none(), "the zebra stripes went off");
s.execute("inspect.isocurve", &json!({"faces": [[0, 5, 5]]})).unwrap();
assert_eq!(active(&mut s), json!("inspect.isocurve"));
assert!(s.comb.is_none(), "the combs went off");
s.execute("inspect.continuity", &json!({})).unwrap();
assert_eq!(active(&mut s), json!("inspect.continuity"));
assert!(s.isocurves.is_empty(), "the isocurves went off");
s.execute("inspect.draft", &json!({})).unwrap();
assert_eq!(active(&mut s), json!("inspect.draft"));
assert!(s.continuity.is_empty(), "the seams went off");
// Turning on the comb with nothing picked yet.
s.execute("inspect.curvature_comb", &json!({"add": true})).unwrap();
assert_eq!(active(&mut s), json!("inspect.curvature_comb"));
assert_eq!(s.execute("inspect.analysis", &json!({"off": true})).unwrap()["active"], json!(null));
assert!(s.comb.is_none() && s.analysis.is_none());
}
+17 -4
View File
@@ -325,6 +325,18 @@ pub fn start_hook(app: &mut SolveApp, id: &str) -> bool {
app.set_status(format!("Continuity: {} (click the button again to turn it off)", counts.join(", ")), false);
return true;
}
if id == "inspect.curvature_comb" {
// With combs shown the button turns the analysis off; otherwise it turns it on (any other
// view analysis off) and the tool combs the curves clicked.
if app.session.comb.is_some() {
if app.run(id, json!({"clear": true})).is_ok() {
app.set_status("Analysis off", false);
}
return false;
}
comb(app, json!({}));
return app.session.comb.is_some();
}
if id == "inspect.isocurve" && !app.session.isocurves.is_empty() {
// With curves shown, the button turns the analysis off; otherwise the tool picks faces.
if app.run(id, json!({"clear": true})).is_ok() {
@@ -1203,6 +1215,11 @@ fn run_entities(app: &mut SolveApp, t: &Tool) {
/// Right-click with an extra tool: finish a chain or move to the next stage. Returns false
/// when the tool should be dropped.
pub fn finish(app: &mut SolveApp, tool: &mut Tool) -> bool {
// Esc or a right click ends the comb tool and turns its analysis off.
if tool.cmd == "inspect.curvature_comb" {
let _ = app.run(tool.cmd, json!({"clear": true}));
return false;
}
let Some(m) = mode(tool.cmd) else { return false };
match m {
Mode::Points(0) if tool.pts.len() >= 2 => {
@@ -1613,10 +1630,6 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
SHIFT.with(|c| c.set(iso.contains(ModAction::IsoCurveAlternate)));
glyphs(app, painter, proj, ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(proj.rect)));
snap_hint(app, painter, proj);
// The combs go with the comb tool.
if app.session.comb.is_some() && app.tool.as_ref().map(|t| t.cmd) != Some("inspect.curvature_comb") {
let _ = app.run("inspect.curvature_comb", json!({"clear": true}));
}
combs(app, painter, proj);
comb_options_card(app, ui.ctx(), proj);
if DEFERRED.with(|d| d.replace(false)) {
+19 -10
View File
@@ -208,6 +208,23 @@ fn help_x(report: Rect) -> f32 {
report.left() - 18.0
}
/// The command whose tool or dialog is open right now, shown pressed in the toolbar.
fn running_command(app: &SolveApp) -> Option<String> {
match (&app.tool, &app.dialog) {
(Some(tool), _) => Some(tool.cmd.to_string()),
// Create Sketch never lands in last_command (it is not repeatable).
(None, Some(d)) if matches!(d.kind, crate::dialogs::Kind::Sketch) => Some("sketch.create".to_string()),
(None, Some(_)) => app.last_command.as_ref().map(|(id, _)| id.clone()),
_ => None,
}
}
/// Whether the toolbar shows command `id` pressed: its tool or dialog is open, or it turned on
/// the view analysis on now (one at a time, `Session::active_analysis`), its tool running or not.
pub(crate) fn shows_pressed(app: &SolveApp, id: &str) -> bool {
running_command(app).as_deref() == Some(id) || app.session.active_analysis() == Some(id)
}
/// The Report a bug button: labelled when the layout asks for it and there is room for its text
/// (`label_width`, measured in the current language), otherwise just the icon. An icon-only
/// layout gives it a square the size of Settings and Help.
@@ -1245,14 +1262,6 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
ribbon_tabs(app, ui, Rect::from_min_max(pos2(ribbon_left, r.top()), pos2(r.right(), r.top() + TAB_ROW_HEIGHT)));
// Panels.
let tab = app.ui.tab.clone();
// The command whose tool or dialog is open right now, shown pressed in the toolbar.
let running: Option<String> = match (&app.tool, &app.dialog) {
(Some(tool), _) => Some(tool.cmd.to_string()),
// Create Sketch never lands in last_command (it is not repeatable).
(None, Some(d)) if matches!(d.kind, crate::dialogs::Kind::Sketch) => Some("sketch.create".to_string()),
(None, Some(_)) => app.last_command.as_ref().map(|(id, _)| id.clone()),
_ => None,
};
let mut px = ribbon_left;
let top = r.top() + TAB_ROW_HEIGHT;
let mut drop: Option<(&'static str, &'static str, Option<&'static str>)> = None;
@@ -1432,7 +1441,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
}
let accent = crate::theme::TabAccent::for_tab(&t, &tab);
let family_open = family.is_some_and(|f| egui::Popup::is_id_open(ui.ctx(), ui.id().with(("family", f.label)).with("popup")));
if !finish && (resp.is_pointer_button_down_on() || running.as_deref() == Some(selected.id) || family_open) {
if !finish && (resp.is_pointer_button_down_on() || shows_pressed(app, selected.id) || family_open) {
painter.rect(whole, t.radius_sm, accent.soft, Stroke::new(1.0, accent.color), egui::StrokeKind::Inside);
} else if resp.hovered() && info.enabled {
painter.rect_filled(whole, t.radius_sm, t.hover);
@@ -1499,7 +1508,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
);
let info = crate::render_workspace::interactive_command_info(app, c);
let resp = ui.interact(cell, ui.id().with(("cmd", c.id)), Sense::click_and_drag());
let active = running.as_deref() == Some(c.id);
let active = shows_pressed(app, c.id);
let accent = crate::theme::TabAccent::for_tab(&t, &tab);
painter.rect(
cell,
@@ -649,6 +649,7 @@ fn comb_options_draw_the_combs_shown_again() {
h.step(&json!({"key": "Escape"})).unwrap();
h.frames(2);
assert!(shown_combs(&h.app).is_empty() && crate::scenario::rect_of("comb_options").is_none());
assert!(!crate::toolbar::shows_pressed(&h.app, "inspect.curvature_comb"), "Esc turned the analysis off");
}
/// Every comb shown shares one scale: the tool combs each new pick together with those already
@@ -677,3 +678,54 @@ fn combs_shown_together_share_one_scale() {
assert!((ratio(&h) - 2.0).abs() < 1e-9, "{}", ratio(&h));
assert_eq!(shown_combs(&h.app)[1].len(), 12);
}
/// The view analysis buttons are one toggle group, reflecting the session's one analysis on:
/// Curvature Comb on shows it pressed (through another dialog too); its button again turns it off
/// and clears the combs; Zebra on turns the comb off, and Zebra again leaves none on.
#[test]
fn view_analysis_buttons_are_one_toggle_group() {
use crate::sketch_tools::shown_combs;
use crate::toolbar::shows_pressed;
let mut h = Harness::new();
h.app.home.open = false;
h.app.run("solid.cylinder", json!({"radius": 10, "height": 20})).unwrap();
h.settle();
let (comb, zebra) = ("inspect.curvature_comb", "inspect.zebra");
let active = |h: &mut Harness| h.app.run("inspect.analysis", json!({})).unwrap()["active"].clone();
// Comb on, then off with its button again.
h.step(&json!({"start": comb})).unwrap();
h.frame();
assert!(shows_pressed(&h.app, comb) && !shows_pressed(&h.app, zebra));
assert_eq!(active(&mut h), json!(comb));
h.step(&json!({"click": {"world": [0, 10, 20]}})).unwrap();
assert_eq!(shown_combs(&h.app).len(), 1, "{:?}", h.app.status);
h.step(&json!({"start": comb})).unwrap();
h.frame();
assert!(shown_combs(&h.app).is_empty() && h.app.tool.is_none() && !shows_pressed(&h.app, comb));
assert_eq!(active(&mut h), json!(null));
// Comb on again; it stays on, pressed, while another dialog is open.
h.step(&json!({"start": comb})).unwrap();
h.step(&json!({"click": {"world": [0, 10, 20]}})).unwrap();
h.step(&json!({"start": "solid.fillet"})).unwrap();
h.frame();
assert!(h.app.dialog.is_some() && h.app.tool.is_none());
assert!(shows_pressed(&h.app, comb) && shown_combs(&h.app).len() == 1);
h.step(&json!({"key": "Escape"})).unwrap();
h.frame();
// Zebra on: the comb goes off and its combs go.
h.step(&json!({"start": zebra})).unwrap();
h.frame();
assert!(shows_pressed(&h.app, zebra) && !shows_pressed(&h.app, comb));
assert!(shown_combs(&h.app).is_empty());
assert_eq!(active(&mut h), json!(zebra));
// Comb on: Zebra goes off.
h.step(&json!({"start": comb})).unwrap();
h.frame();
assert!(shows_pressed(&h.app, comb) && !shows_pressed(&h.app, zebra) && h.app.session.analysis.is_none());
// Zebra on, then off: none is on.
h.step(&json!({"start": zebra})).unwrap();
h.step(&json!({"start": zebra})).unwrap();
h.frame();
assert!(!shows_pressed(&h.app, zebra) && !shows_pressed(&h.app, comb));
assert_eq!(active(&mut h), json!(null));
}