From adb1e5fec5ae06ce5f7068e3efb1da9f47c595be Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:01:44 +0200 Subject: [PATCH] =?UTF-8?q?timeline.inspect=20returns=20a=20feature's=20de?= =?UTF-8?q?finition=20in=20the=20shape=20timeline.edit's=20set=20takes,=20?= =?UTF-8?q?so=20agents=20no=20longer=20guess=20field=20names:=20timeline.e?= =?UTF-8?q?dit=20had=20no=20documented=20format=20({"extent":=20{"distance?= =?UTF-8?q?":=20=E2=80=A6}}=20worked,=20but=20only=20by=20trying).=20The?= =?UTF-8?q?=20new=20read-only=20command=20(feature:=20id=20or=20name)=20re?= =?UTF-8?q?turns=20type,=20definition=20(the=20editable=20fields=20and=20t?= =?UTF-8?q?heir=20current=20values;=20bookkeeping=20and=20large=20payloads?= =?UTF-8?q?=20such=20as=20sketch=20geometry=20and=20imported=20STEP=20text?= =?UTF-8?q?=20are=20left=20out=20and=20listed=20under=20omitted),=20inputs?= =?UTF-8?q?=20(label,=20parameter=20name,=20expression,=20unit)=20and=20th?= =?UTF-8?q?e=20feature's=20error=20and=20warning.=20timeline.edit's=20para?= =?UTF-8?q?ms=20doc=20points=20to=20it,=20and=20the=20book's=20Solid=20fea?= =?UTF-8?q?tures=20chapter=20documents=20the=20edit=20format=20with=20the?= =?UTF-8?q?=20fields=20of=20the=20common=20features.=20Tests:=20inspect,?= =?UTF-8?q?=20edit=20by=20the=20field=20names=20it=20shows,=20inspect=20ag?= =?UTF-8?q?ain=20(extrude,=20fillet,=20sketch);=20no=20undo=20step;=20host?= =?UTF-8?q?ile=20feature=20arguments=20are=20refused?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/cmd/edit.rs | 55 +++++++++++++++++++++++++++++++-- crates/engine/src/tests.rs | 40 ++++++++++++++++++++++++ docs/book/src/guide/features.md | 39 +++++++++++++++++++++++ docs/mcp.md | 5 +++ 4 files changed, 137 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index cdfcd4cc..a4b55195 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -35,8 +35,13 @@ pub static COMMANDS: &[CommandSpec] = &[ .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\"}}} → recomputed: features rebuilt, \ - restored: features whose earlier result for the same inputs was reused", + "feature: id|name, set: {fields to change, merged into the feature's definition as timeline.inspect returns it, \ + e.g. {\"extent\": {\"distance\": \"30\"}} (extrude), {\"angle\": \"90 deg\"} (revolve); \ + expressions are text} → recomputed: features rebuilt, restored: features whose earlier result for the same inputs was reused", + ), + CommandSpec::new("timeline.inspect", "Inspect Feature", inspect_feature).noundo().params( + "feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \ + inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning", ), CommandSpec::new("select.set", "Select", select_set) .noundo() @@ -188,6 +193,52 @@ fn merge(dst: &mut Value, src: &Value, depth: usize) { } } +/// Fields of a stored feature left out of its definition: bookkeeping timeline.edit recomputes, and +/// the name and suppression, which timeline.rename and timeline.suppress change. +const NOT_EDITABLE: &[&str] = &["id", "type", "name", "suppressed", "param_names", "edge_refs", "edge_names", "face_names"]; + +fn inspect_feature(s: &mut Session, p: &Value) -> Result { + let cmd = "timeline.inspect"; + let id = feature_id(s, p.get("feature"), cmd)?; + let f = s.doc.feature(id).ok_or_else(|| bad(cmd, "feature"))?; + let mut def = serde_json::to_value(f).map_err(|e| EngineError::Other(e.to_string()))?; + let mut omitted = Vec::new(); + if let Some(o) = def.as_object_mut() { + for k in NOT_EDITABLE { + o.remove(*k); + } + // Large payloads that timeline.edit cannot change. + let heavy: &[(&str, &str)] = match &f.kind { + FeatureKind::Sketch { .. } => &[("sketch", "the sketch geometry (sketch.inspect; edit it with the sketch commands)")], + FeatureKind::Import { .. } => &[("step", "the imported STEP text"), ("components", "the imported product tree")], + FeatureKind::MeshImport { .. } => &[("meshes", "the imported meshes")], + _ => &[], + }; + for (k, why) in heavy { + if o.remove(*k).is_some() { + omitted.push(json!({"field": k, "what": why})); + } + } + } + let inputs: Vec = f + .kind + .inputs() + .into_iter() + .enumerate() + .map(|(i, (label, e, k))| { + json!({"label": label, "param": f.param_names.get(i).filter(|n| !n.is_empty()), "expression": e, "unit": k.default_unit()}) + }) + .collect(); + let marker = s.doc.marker.unwrap_or(usize::MAX); + let rolled_back = s.doc.features.iter().position(|x| x.id == id).is_some_and(|i| i >= marker); + let r = s.model.result(id); + Ok(json!({ + "id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": rolled_back, + "definition": def, "omitted": omitted, "inputs": inputs, + "error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()), + })) +} + fn edit_feature(s: &mut Session, p: &Value) -> Result { let cmd = "timeline.edit"; let id = feature_id(s, p.get("feature"), cmd)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e156d671..3c274c8f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -407,6 +407,46 @@ fn sample_edges_chain_and_faces() { assert!(m.edge_faces.iter().all(|f| !f.is_empty())); } +/// timeline.inspect returns a feature's definition in the shape timeline.edit's `set` takes, so +/// an agent can read the field names instead of guessing them. +#[test] +fn timeline_inspect_shows_what_timeline_edit_changes() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 10, "name": "Slab"})); + run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 10]], "radius": 2, "name": "Round"})); + let i = run(&mut s, "timeline.inspect", json!({"feature": "Slab"})); + assert_eq!(i["type"], "ExtrudeFeature", "{i}"); + assert_eq!(i["definition"]["extent"]["distance"], "10", "{i}"); + assert_eq!(i["definition"]["operation"], "new_body", "{i}"); + assert!(i["definition"].get("type").is_none() && i["definition"].get("param_names").is_none(), "{i}"); + assert_eq!(i["inputs"][0]["label"], "Distance", "{i}"); + assert_eq!(i["inputs"][0]["unit"], "mm", "{i}"); + let d = i["inputs"][0]["param"].as_str().unwrap().to_string(); + // The definition's field names are what timeline.edit takes. + run(&mut s, "timeline.edit", json!({"feature": "Slab", "set": {"extent": {"distance": "12"}}})); + let i = run(&mut s, "timeline.inspect", json!({"feature": i["id"]})); + assert_eq!(i["definition"]["extent"]["distance"], "12", "{i}"); + assert_eq!(i["inputs"][0]["param"], d.as_str(), "{i}"); + let f = run(&mut s, "timeline.inspect", json!({"feature": "Round"})); + assert_eq!(f["definition"]["radius"], "2", "{f}"); + run(&mut s, "timeline.edit", json!({"feature": "Round", "set": {"radius": "3"}})); + assert_eq!(run(&mut s, "timeline.inspect", json!({"feature": "Round"}))["definition"]["radius"], "3"); + // A sketch leaves its geometry out (sketch.inspect has it) and says so. + let k = run(&mut s, "timeline.inspect", json!({"feature": "Base"})); + assert!(k["definition"].get("sketch").is_none() && k["definition"].get("plane").is_some(), "{k}"); + assert_eq!(k["omitted"][0]["field"], "sketch", "{k}"); + // Read-only: no undo step; bad input is refused. + let undo = s.undo.len(); + run(&mut s, "timeline.inspect", json!({"feature": "Slab"})); + assert_eq!(s.undo.len(), undo); + for bad in [json!({}), json!({"feature": "Nope"}), json!({"feature": [1]}), json!({"feature": 1e300}), json!({"feature": null})] { + assert!(s.execute("timeline.inspect", &bad).is_err(), "{bad}"); + } +} + #[test] fn timeline_edits_reresolve_references() { let mut s = Session::default(); diff --git a/docs/book/src/guide/features.md b/docs/book/src/guide/features.md index 555f0561..4250411d 100644 --- a/docs/book/src/guide/features.md +++ b/docs/book/src/guide/features.md @@ -26,5 +26,44 @@ Feature references to faces and edges are stored by name, so an upstream edit th keeps them on the right face (see [Persistent naming](../dev/naming.md)). When a reference can't be found, the feature warns instead of silently picking another face. +## Editing a feature from a script + +`timeline.inspect {feature}` returns a feature's `definition`: the fields `timeline.edit` changes, +with their current values. `timeline.edit {feature, set}` merges `set` into that definition +(objects are merged field by field, anything else replaces the value), rebuilds the feature and +reports `recomputed` and `restored`. The feature's type cannot change, and sketch geometry is +edited with the sketch commands. Expressions are text (`"30"`, `"30 mm"`, `"width / 2"`, +`"45 deg"`); a bare number is a length in the design's units, or degrees for an angle. + +```json +{"command": "timeline.inspect", "params": {"feature": "Slab"}} +→ {"type": "ExtrudeFeature", + "definition": {"sketch": 1, "profiles": {"type": "all"}, "operation": "new_body", + "extent": {"distance": "10", "direction": "positive"}}, + "inputs": [{"label": "Distance", "param": "d1", "expression": "10", "unit": "mm"}], …} +{"command": "timeline.edit", "params": {"feature": "Slab", "set": {"extent": {"distance": "12"}}}} +``` + +`inputs` lists the feature's named inputs. `parameters.change {name: "d1", expression}` sets one +of those directly. Optional fields that are not set do not appear in the definition; they can +still be added through `set`. The fields of the common features: + +| Feature | Fields | +|---|---| +| Extrude | `extent`: {`distance`, `direction`: positive\|negative\|symmetric, `distance2`, `start_offset`, `through_all`, `taper`, `to`, `to_offset`, `from`}; `operation`: new_body\|join\|cut\|intersect; `targets`; `profiles`; `sketch` | +| Revolve | `angle`, `angle2`, `symmetric`, `axis`, `to`, `operation`, `targets`, `profiles`, `sketch` | +| Fillet | `radius`, `edges` (a point on each edge), `body`, `style` | +| Chamfer | `distance`, `distance2` or `angle`, `flip`, `edges`, `body` | +| Box | `length`, `width`, `height`, `corner`, `operation` | +| Cylinder | `radius`, `height`, `base`, `axis`, `operation` | +| Hole | `diameter`, `depth`, `position`, `direction`, `hole` (simple, drilled, counterbore, countersink), `thread`, `to` | +| Shell | `thickness`, `faces`, `direction`, `body`, `tangent_chain` | +| Draft | `angle`, `faces`, `neutral`, `pull`, `body` | +| Offset face | `distance`, `faces`, `body` | +| Pattern | `pattern` (its kind and counts/spacing), `features`, `bodies` | +| Mirror | `plane`, `features`, `bodies`, `combine` | + +For any other feature, `timeline.inspect` shows the fields. + Press **Delete** on anything selected (bodies, faces, sketches, features, planes, components, canvases). It runs as one undoable step and lists the features that would go with it or fail. diff --git a/docs/mcp.md b/docs/mcp.md index 63370d71..d8bb0c49 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -62,6 +62,11 @@ Claude Desktop (`claude_desktop_config.json`), bridged to a running app: | `open` | `path` | Open a `.solvecraft` design, a STEP file (`.step`/`.stp`) as a new design holding its bodies (an Import base feature), or a 3MF/STL file as mesh bodies. | | `save` | `path?` | Save the design (to its current file without `path`). | +To change a feature after it is made, read its definition with `execute` `timeline.inspect` +(`{feature}`: id or name) and change fields with `timeline.edit` (`{feature, set}`; e.g. +`{"extent": {"distance": "30"}}`). The fields per feature type are listed in the book's +*Solid features* chapter. + Resources: `solvecraft://design` (inspect JSON with measurements) and `solvecraft://commands` (the catalog).