Merge pull request #120 from CypherNaught-0x/feat/timeline-inspect

timeline.inspect shows a feature's editable fields; the timeline.edit format is documented
This commit is contained in:
bherbruck authored and GitHub committed 2026-10-11 07:11:49 -04:00
commit f9d61590f3
4 files changed
+137 -2

No files matched your search

+53 -2
View File
@@ -35,8 +35,13 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("feature: id|name → features deleted with it, and features that would fail without it"), .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.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"),
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params( CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params(
"feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}} → recomputed: features rebuilt, \ "feature: id|name, set: {fields to change, merged into the feature's definition as timeline.inspect returns it, \
restored: features whose earlier result for the same inputs was reused", 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) CommandSpec::new("select.set", "Select", select_set)
.noundo() .noundo()
@@ -190,6 +195,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<Value> {
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<Value> = 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<Value> { fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.edit"; let cmd = "timeline.edit";
let id = feature_id(s, p.get("feature"), cmd)?; let id = feature_id(s, p.get("feature"), cmd)?;
+40
View File
@@ -419,6 +419,46 @@ fn sample_edges_chain_and_faces() {
assert!(m.edge_faces.iter().all(|f| !f.is_empty())); 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] #[test]
fn timeline_edits_reresolve_references() { fn timeline_edits_reresolve_references() {
let mut s = Session::default(); let mut s = Session::default();
+39
View File
@@ -52,6 +52,45 @@ 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 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. 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, 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. canvases). It runs as one undoable step and lists the features that would go with it or fail.
+5
View File
@@ -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. | | `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`). | | `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` Resources: `solvecraft://design` (inspect JSON with measurements) and `solvecraft://commands`
(the catalog). (the catalog).