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:
4 files changed
+137
-2
No files matched your search
@@ -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()
|
||||
@@ -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> {
|
||||
let cmd = "timeline.edit";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
|
||||
@@ -419,6 +419,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();
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
Reference in new issue
Block a user