diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index fcf41553..a7229d47 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -377,6 +377,10 @@ pub struct Model { empty: Arc, /// Features recomputed by the last `evaluate`. pub last_recomputed: usize, + /// Features whose result the last `evaluate` took from the cache instead of recomputing: the + /// same feature on the same inputs was evaluated before (an edit set back, undo, redo, a + /// configuration row). Their geometry did change since the previous evaluation. + pub last_restored: usize, /// Results by inputs, shared with this model's copies. pub cache: Arc>, /// Evaluate everything from scratch (no reuse; tests compare the two). @@ -505,6 +509,7 @@ impl Model { let mut out: Vec = Vec::with_capacity(doc.features.len()); let mut reuse = !self.no_cache; let mut recomputed = 0; + let mut restored = 0; let marker = doc.marker.unwrap_or(usize::MAX); let ctx = context_digest(doc, &vals); for (i, f) in doc.features.iter().enumerate() { @@ -569,6 +574,7 @@ impl Model { if !self.no_cache && let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key)) { + restored += 1; if c.error.is_none() { state = c.state.clone(); } @@ -652,6 +658,7 @@ impl Model { } self.results = out; self.last_recomputed = recomputed; + self.last_restored = restored; } } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a11dd964..b74f6da1 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -81,6 +81,26 @@ fn parametric_plate_reevaluates() { assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 50.0 * 30.0 * 10.0) < 1e-9); } +/// Setting a parameter back takes the earlier results from the cache: nothing is recomputed, but +/// the evaluation reports the features it restored (their geometry did change back). +#[test] +fn set_back_reports_restored_features() { + let (mut doc, _) = plate_doc(); + let mut m = Model::new(); + m.evaluate(&doc); + assert_eq!((m.last_recomputed, m.last_restored), (2, 0)); + doc.set_param("width", "50 mm", None, None).unwrap(); + m.evaluate(&doc); + assert_eq!((m.last_recomputed, m.last_restored), (2, 0)); + doc.set_param("width", "40 mm", None, None).unwrap(); + m.evaluate(&doc); + assert_eq!((m.last_recomputed, m.last_restored), (0, 2)); + assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 24000.0) < 1e-9); + // No change: nothing recomputed and nothing restored. + m.evaluate(&doc); + assert_eq!((m.last_recomputed, m.last_restored), (0, 0)); +} + #[test] fn box_fillet_cut_timeline() { let (mut doc, _) = plate_doc(); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 64bd563e..08eb8695 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -28,8 +28,10 @@ pub static COMMANDS: &[CommandSpec] = &[ .noundo() .params("feature: id|name → features deleted with it, and features that would fail without it"), CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"), - CommandSpec::new("timeline.edit", "Edit Feature", edit_feature) - .params("feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}}"), + CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params( + "feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}} → recomputed: features rebuilt, \ + restored: features whose earlier result for the same inputs was reused", + ), CommandSpec::new("select.set", "Select", select_set) .noundo() .params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool"), @@ -204,7 +206,7 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result { if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) { return Err(EngineError::Other(e)); } - Ok(json!({"feature": id, "recomputed": s.model.last_recomputed})) + Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored})) } fn select_set(s: &mut Session, p: &Value) -> Result { @@ -307,7 +309,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result { if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) { return Err(EngineError::Other(e)); } - Ok(json!({"feature": id, "recomputed": s.model.last_recomputed})) + Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored})) } fn dependents(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index dd7890be..fbdb8848 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -581,7 +581,7 @@ fn press_pull(s: &mut Session, p: &Value) -> Result { let new = format!("({old}) + ({distance})"); let r = super::find_command("timeline.edit").ok_or_else(|| bad(cmd, "timeline.edit"))?; let out = (r.run)(s, &json!({"feature": f.id, "set": {key: new}}))?; - return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed")})); + return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed"), "restored": out.get("restored")})); } // Another curved face moves along its normal. let r = super::find_command("solid.offset_face").ok_or_else(|| bad(cmd, "solid.offset_face"))?; diff --git a/crates/engine/src/cmd/parameters.rs b/crates/engine/src/cmd/parameters.rs index 82b8759d..5437627f 100644 --- a/crates/engine/src/cmd/parameters.rs +++ b/crates/engine/src/cmd/parameters.rs @@ -12,7 +12,8 @@ use crate::{EngineError, Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("parameters.change", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params( "name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\" (new parameters default to their value's unit), comment?, \ - favorite?: bool, new_name?: rename (references follow); or delete: name", + favorite?: bool, new_name?: rename (references follow); or delete: name → recomputed: features rebuilt, \ + restored: features whose earlier result for the same inputs was reused (e.g. a value set back)", ), CommandSpec::new("parameters.list", "List Parameters", list).noundo().params( "favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \ @@ -107,6 +108,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result { out["value"] = json!(vals.get(&name).map(|v| v.v)); out["display_value"] = json!(s.doc.param_display_value(&vals, &name)); out["recomputed"] = json!(s.model.last_recomputed); + out["restored"] = json!(s.model.last_restored); out["errors"] = json!(failing(s)); Ok(out) } diff --git a/crates/engine/src/cmd/parameters_tests.rs b/crates/engine/src/cmd/parameters_tests.rs index 18983408..db8ae158 100644 --- a/crates/engine/src/cmd/parameters_tests.rs +++ b/crates/engine/src/cmd/parameters_tests.rs @@ -67,6 +67,25 @@ fn width_follows_an_expression() { assert!(e.contains("expected a length, got an angle"), "{e}"); } +/// Setting a parameter back rebuilds nothing (the earlier results are reused) and says so: the +/// features it restored are reported next to `recomputed`, so `recomputed: 0` is not mistaken +/// for "nothing changed". timeline.edit reports the same. +#[test] +fn setting_a_parameter_back_reports_restored_features() { + let mut s = Session::default(); + block(&mut s); + let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"})); + assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(2), Some(0)), "{r}"); + let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "60 mm"})); + assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(2)), "{r}"); + assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9); + let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "20"}}})); + assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(0)), "{r}"); + let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "10"}}})); + assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(1)), "{r}"); + assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9); +} + #[test] fn feature_inputs_are_named_and_editable() { let mut s = Session::default(); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c307b284..0d1e993a 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -129,7 +129,8 @@ pub fn tool_definitions() -> Vec { "set_parameter", "Set a parameter", "Create or change a user parameter and re-evaluate the timeline. `value` is a number (mm) or an expression \ - (\"40 mm\", \"width * 2\", \"30 deg\"). Returns the new value, the features recomputed and any features that now fail.", + (\"40 mm\", \"width * 2\", \"30 deg\"). Returns the new value, the features recomputed (`recomputed`), the features whose earlier \ + result for the same inputs was reused instead (`restored`, e.g. after setting a value back) and any features that now fail.", obj( json!({ "name": string("Parameter name"), diff --git a/docs/mcp.md b/docs/mcp.md index c6a996c0..3aec985e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -54,7 +54,7 @@ Claude Desktop (`claude_desktop_config.json`), bridged to a running app: | `inspect_design` | `measure?` (default true), `sketches?` (default true) | Parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible), sketches (curves, points, constraints, profiles, DOF, solve status, visible), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`). | | `measure` | `body?` | Volume (mm³), area (mm²), centre of mass, bbox and topology counts per body, with totals. | | `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, area, centroid, normal). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. | -| `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports features that now fail. | +| `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports `recomputed` (features rebuilt), `restored` (features whose earlier result for the same inputs was reused, e.g. a value set back) and features that now fail. | | `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit), `path?`, `width?`, `height?`, `source?` (window, model) | A PNG image content block. Headless: CPU render fitted to the model. Bridged: the app window (default) or a model render with the app's camera. | | `export` | `path`, `format?` (step, 3mf, stl, stla, obj), `bodies?` | STEP, 3MF or mesh export. | | `undo`, `redo` | — | Undo / redo one change. |