MCP: measure summary and body_topology faces: cylinders for small verification replies (refs #3380) (#9853)

* MCP: measure summary and body_topology faces: cylinders for small verification replies (refs #3380)

`measure {"summary": true}` replies one short object per body (name,
volume_mm3, bbox, faces, closed) with body_count and total.volume_mm3,
as a tool and as a batch step. `body_topology {"faces": "cylinders"}`
replies only the cylindrical faces, the kernel's split halves merged
by radius and axis line into {radius, origin, axis, extent, names},
and no edges. Both defaults are unchanged. The merge uses only what
model.faces and model.edges already reply.

Measured on a 13-feature enclosure, body_topology is 74 KB and measure
1.1 KB per body; agents filter them with scripts, and seven of 29
benchmark runs asked for exactly a cylinders-only listing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* MCP: keep body_topology's cylinder merge next to check_parameters' without a name clash

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: bherbruck <tenbee91@gmail.com>
This commit is contained in:
froglemonade2
2026-10-31 08:56:37 +00:00
committed by GitHub
parent 5281734a22
commit e16647a63f
4 changed files with 315 additions and 23 deletions
+109
View File
@@ -326,6 +326,115 @@ fn agent_builds_box_fillet_hole() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A 60 × 40 × 10 plate with two Ø6 holes at x 15 and 45 and a Ø10 × 8 boss in the middle, all
/// on y 20 and along Z: three cylinders, the halves the kernel splits them into merged.
fn plate_with_holes_and_boss(s: &mut Server) {
let r = tool(
s,
"batch",
json!({"commands": [
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [60, 40]}},
{"command": "sketch.finish"},
{"command": "solid.extrude", "params": {"distance": 10}},
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.circle.center", "params": {"center": [15, 20], "radius": 3}},
{"command": "sketch.circle.center", "params": {"center": [45, 20], "radius": 3}},
{"command": "sketch.finish"},
{"command": "solid.extrude", "params": {"distance": 10, "operation": "cut"}},
{"command": "solid.cylinder", "params": {"radius": 5, "height": 8, "base": [30, 20, 10], "operation": "join"}},
]}),
);
assert_eq!(r["completed"], 10, "{r}");
}
/// `body_topology {faces: "cylinders"}` answers where the holes and bosses are in a few lines:
/// one entry per cylinder with its radius, axis line and extent, and no edges (#3380).
#[test]
fn body_topology_lists_merged_cylinders_with_their_extent() {
let mut s = server();
plate_with_holes_and_boss(&mut s);
let full = tool(&mut s, "body_topology", json!({"body": "Body1"}));
let halves = full["faces"].as_array().unwrap().iter().filter(|f| f["cylinder"].is_object()).count();
assert!(halves >= 3, "{full}");
let topo = tool(&mut s, "body_topology", json!({"body": "Body1", "faces": "cylinders"}));
assert_eq!(topo["body"], "Body1", "{topo}");
assert!(topo.get("edges").is_none() && topo.get("faces").is_none(), "{topo}");
let found = topo["cylinders"].as_array().unwrap();
assert_eq!(found.len(), 3, "{topo}");
assert_eq!(found.iter().map(|c| c["names"].as_array().unwrap().len()).sum::<usize>(), halves, "{topo}");
assert!(topo.to_string().len() * 4 < full.to_string().len(), "{} vs {} bytes", topo.to_string().len(), full.to_string().len());
// Each cylinder: (x of its axis, radius, the z its extent spans), its axis along Z.
let mut seen: Vec<(i64, i64, i64, i64)> = found
.iter()
.map(|c| {
let axis: Vec<f64> = c["axis"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
let origin: Vec<f64> = c["origin"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
assert!(axis[2].abs() > 1.0 - 1e-6, "{c}");
assert!((origin[1] - 20.0).abs() < 1e-3, "{c}");
let extent: Vec<f64> = c["extent"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
let mut z: Vec<f64> = extent.iter().map(|t| origin[2] + t * axis[2]).collect();
z.sort_by(|a, b| a.total_cmp(b));
let mm = |v: f64| (v * 1000.0).round() as i64;
(mm(origin[0]), mm(c["radius"].as_f64().unwrap()), mm(z[0]), mm(z[1]))
})
.collect();
seen.sort_unstable();
assert_eq!(seen, vec![(15000, 3000, 0, 10000), (30000, 5000, 10000, 18000), (45000, 3000, 0, 10000)], "{topo}");
// Every merged half is a real face name, so the entry picks the face too.
let names: Vec<&str> = found.iter().flat_map(|c| c["names"].as_array().unwrap()).filter_map(Value::as_str).collect();
let real: Vec<&str> = full["faces"].as_array().unwrap().iter().filter_map(|f| f["name"].as_str()).collect();
assert!(!names.is_empty() && names.iter().all(|n| real.contains(n)), "{names:?} in {real:?}");
// The default is unchanged, and a value outside the enum is refused by argument checking.
let same = tool(&mut s, "body_topology", json!({"body": "Body1", "faces": "all"}));
assert_eq!(same, full);
let r = raw_tool(&mut s, "body_topology", json!({"body": "Body1", "faces": "planes"}));
assert_eq!(r["error"]["code"], -32602, "{r}");
assert!(r["error"]["message"].as_str().unwrap().contains("cylinders"), "{r}");
}
/// `measure {summary: true}` keeps what a stage check needs, as a tool and as a batch step (#3380).
#[test]
fn measure_summary_keeps_only_the_check_fields() {
let mut s = server();
plate_with_holes_and_boss(&mut s);
let full = tool(&mut s, "measure", json!({}));
let m = tool(&mut s, "measure", json!({"summary": true}));
let keys = |v: &Value| -> Vec<String> { v.as_object().unwrap().keys().cloned().collect() };
assert_eq!(keys(&m), ["bodies", "body_count", "total"], "{m}");
assert_eq!(keys(&m["total"]), ["volume_mm3"], "{m}");
assert_eq!(m["body_count"], 1, "{m}");
let b = &m["bodies"][0];
assert_eq!(keys(b), ["bbox", "closed", "faces", "name", "volume_mm3"], "{m}");
assert_eq!(b["name"], "Body1");
assert_eq!(b["closed"], true);
assert_eq!(b["faces"], full["bodies"][0]["faces"]);
assert_eq!(b["bbox"], full["bodies"][0]["bbox"]);
let expect = 60.0 * 40.0 * 10.0 - 2.0 * PI * 9.0 * 10.0 + PI * 25.0 * 8.0;
let v = m["total"]["volume_mm3"].as_f64().unwrap();
assert_eq!(v, full["total"]["volume_mm3"].as_f64().unwrap(), "{m}");
assert_eq!(b["volume_mm3"].as_f64().unwrap(), full["bodies"][0]["volume_mm3"].as_f64().unwrap(), "{m}");
assert!(rel(v, expect) < 1e-3, "{v} vs {expect}");
assert!(m.to_string().len() * 2 < full.to_string().len(), "{} vs {} bytes", m.to_string().len(), full.to_string().len());
// One body by name, and the default unchanged.
let one = tool(&mut s, "measure", json!({"body": "Body1", "summary": true}));
assert_eq!(one, m);
assert_eq!(tool(&mut s, "measure", json!({"summary": false})), full);
// As a batch step, the same reply.
let r = tool(
&mut s,
"batch",
json!({"commands": [{"command": "measure", "params": {"summary": true}}, {"command": "measure", "params": {"body": "Body1"}}]}),
);
assert_eq!(r["completed"], 2, "{r}");
assert_eq!(r["results"][0], m, "{r}");
assert_eq!(r["results"][1], full, "{r}");
assert_eq!(raw_tool(&mut s, "measure", json!({"summary": "yes"}))["error"]["code"], -32602);
}
/// `open` hands `up` and `names` to the STEP import, and the tool's schema refuses other values.
#[test]
fn open_stands_a_y_up_step_file_up() {
+200 -18
View File
@@ -163,11 +163,14 @@ pub fn tool_definitions() -> Vec<Value> {
bodies, with totals. With `items` (one or two selections, as `execute` takes them: faces, edges, vertices, \
sketch curves, profiles): one item's length, area or position, a circle's, arc's or cylinder's `diameter` and \
`radius` (with `circumference`, or an arc's `arc_length` and `angle_deg`), a slot's `slot` sizes; two items' \
`min` and `max` distance and, where one is round, `center_distance` (centre to centre, or from its axis).",
`min` and `max` distance and, where one is round, `center_distance` (centre to centre, or from its axis). \
`summary: true` keeps one short object per body (name, volume_mm3, bbox, faces, closed) and the total volume: \
enough to check a stage against the numbers you computed, at a fraction of the size.",
obj(
json!({
"body": string("Body name (default: all bodies)"),
"items": {"type": "array", "items": {"type": "object"}, "description": "One or two selections to measure instead of bodies, e.g. {\"type\": \"face\", \"body\": \"Body1\", \"index\": 3, \"point\": [x,y,z]}"},
"summary": {"type": "boolean", "description": "Per body only name, volume_mm3, bbox {min, max}, faces and closed, and total.volume_mm3 (default false: the full reply)"},
}),
&[],
),
@@ -178,8 +181,17 @@ pub fn tool_definitions() -> Vec<Value> {
"List edges and faces",
"The edges (index, midpoint, length, endpoints) and faces (index, name, area, centroid, plane normal, a `point` that lies \
on the face, and for a cylindrical face its `cylinder`: origin, axis, radius) of a body. Use an edge midpoint as the \
[x,y,z] point that picks the edge for fillet and chamfer; a face's point or its name picks the face for the face commands.",
obj(json!({"body": string("Body name, e.g. \"Body1\"")}), &["body"]),
[x,y,z] point that picks the edge for fillet and chamfer; a face's point or its name picks the face for the face commands. \
`faces: \"cylinders\"` lists only the cylindrical faces, as `cylinders`, with the halves the kernel splits one cylinder \
into merged: {radius, origin, axis, extent, names}, `extent` the [min, max] distance along `axis` from `origin` that \
the cylinder spans, and no edges. That is enough to check where every hole and boss is, at a fraction of the size.",
obj(
json!({
"body": string("Body name, e.g. \"Body1\""),
"faces": {"type": "string", "enum": ["all", "cylinders"], "description": "\"all\" (default): every edge and face. \"cylinders\": only the cylindrical faces, merged, with their extent along the axis, and no edges"},
}),
&["body"],
),
Effect::ReadOnly,
),
tool(
@@ -571,13 +583,17 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<V
Some(body) => json!({"bodies": [body]}),
None => json!({}),
};
exec(b, "inspect.measure", p)
let m = exec(b, "inspect.measure", p)?;
Ok(if a.get("summary").and_then(Value::as_bool).unwrap_or(false) { measure_summary(m) } else { m })
}
"body_topology" => {
let p = pick(a, &["body"]);
let edges = exec(b, "model.edges", p.clone())?;
let faces = exec(b, "model.faces", p)?;
Ok(json!({"body": a.get("body"), "edges": edges["edges"], "faces": faces["faces"]}))
Ok(match str_arg(a, "faces") {
Some("cylinders") => json!({"body": a.get("body"), "cylinders": topology_cylinders(&faces["faces"], &edges["edges"])}),
_ => json!({"body": a.get("body"), "edges": edges["edges"], "faces": faces["faces"]}),
})
}
"set_parameter" => {
let mut p = pick(a, &["name", "unit", "comment"]);
@@ -634,18 +650,35 @@ fn pending(b: &mut dyn Backend) -> Result<Value, String> {
Ok(json!({"pending": what, "done": false, "running_s": p.get("running_s"), "note": "still running: call pending again to keep waiting"}))
}
/// How a tool step's result is cut down to what the tool itself would reply.
enum Narrow {
/// A `list_commands` step's catalog, filtered as the tool filters it.
Commands(String),
/// A `measure {summary: true}` step's reply, as `measure_summary` shapes it.
MeasureSummary,
}
impl Narrow {
fn apply(&self, v: Value) -> Value {
match self {
Narrow::Commands(f) => filter_commands(v, Some(f)),
Narrow::MeasureSummary => measure_summary(v),
}
}
}
/// A `batch` step as the engine command it runs: an engine command as it is, or one of these
/// tools' names (an agent writes the tool it knows) with that tool's arguments as its params.
/// `Ok((command, params, filter))`: `filter` narrows a `list_commands` step's catalog; an empty
/// `Ok((command, params, narrow))`: `narrow` cuts the step's result down as the tool would; an empty
/// `command` means the step is an engine command already.
fn batch_step(step: &Value) -> Result<(String, Value, Option<String>), String> {
fn batch_step(step: &Value) -> Result<(String, Value, Option<Narrow>), String> {
let command = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).ok_or("missing `command`")?;
let empty = Map::new();
let params = step.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({}));
let a = params.as_object().unwrap_or(&empty);
let mapped = |c: &str, p: Value| Ok((c.to_string(), p, None));
match command {
"list_commands" => Ok(("engine.commands".into(), json!({}), str_arg(a, "filter").map(str::to_string))),
"list_commands" => Ok(("engine.commands".into(), json!({}), str_arg(a, "filter").map(|f| Narrow::Commands(f.to_string())))),
"set_parameter" => {
let mut p = pick(a, &["name", "unit", "comment"]);
p["expression"] = parameter_value(a)?;
@@ -661,7 +694,11 @@ fn batch_step(step: &Value) -> Result<(String, Value, Option<String>), String> {
}
"measure" => match a.get("items") {
Some(items) => mapped("inspect.measure", json!({"items": items})),
None => mapped("inspect.measure", str_arg(a, "body").map_or(json!({}), |body| json!({"bodies": [body]}))),
None => {
let p = str_arg(a, "body").map_or(json!({}), |body| json!({"bodies": [body]}));
let summary = a.get("summary").and_then(Value::as_bool).unwrap_or(false);
Ok(("inspect.measure".into(), p, summary.then_some(Narrow::MeasureSummary)))
}
},
"inspect_design" => mapped("document.inspect", json!({"measure": a.get("measure").and_then(Value::as_bool).unwrap_or(true)})),
"undo" => mapped("edit.undo", json!({})),
@@ -796,24 +833,24 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
// Steps that name a tool run the engine command it stands for. One that can't be a step
// fails the batch before anything is sent.
let mut mapped: Vec<Value> = Vec::with_capacity(steps.len());
let mut filters: Vec<(usize, String)> = Vec::new();
let mut narrows: Vec<(usize, Narrow)> = Vec::new();
for (i, step) in steps.iter().enumerate() {
// The window goes to another design's tab for these, however the step names them: the
// batch's later steps and its rollback would land in the wrong design.
let step_result = batch_step(step).and_then(|(command, params, filter)| {
let step_result = batch_step(step).and_then(|(command, params, narrow)| {
let id = if command.is_empty() { step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).unwrap_or("") } else { &command };
match solidcraft_engine::find_command(id).map(|spec| spec.id) {
Some(c @ ("component.edit_in_place" | "component.finish_edit")) => {
Err(format!("`{c}` goes to another design's tab: send it on its own, not in a batch"))
}
_ => Ok((command, params, filter)),
_ => Ok((command, params, narrow)),
}
});
match step_result {
Ok((command, _, _)) if command.is_empty() => mapped.push(step.clone()),
Ok((command, params, filter)) => {
if let Some(f) = filter {
filters.push((i, f));
Ok((command, params, narrow)) => {
if let Some(n) = narrow {
narrows.push((i, n));
}
mapped.push(json!({"command": command, "params": params}));
}
@@ -853,9 +890,10 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
}
return text_result(&reply, true);
}
// A `list_commands` step's catalog, filtered as the tool filters it.
let narrow = |i: usize, v: Value| match filters.iter().find(|(j, _)| *j == i) {
Some((_, f)) => filter_commands(v, Some(f)),
// A tool step's result as the tool would reply it: a `list_commands` step's catalog filtered,
// a `measure {summary: true}` step's reply summarised.
let narrow = |i: usize, v: Value| match narrows.iter().find(|(j, _)| *j == i) {
Some((_, n)) => n.apply(v),
None => v,
};
// An app that runs batches itself answers the whole batch in one request, so one frame
@@ -1030,6 +1068,150 @@ fn batch_reply(r: &Value, len: usize, rollback: bool, verbose: bool, narrow: &dy
text_result(&reply, error)
}
/// `measure {summary: true}`: an `inspect.measure` reply cut down to what checks a stage against
/// computed numbers: per body `name`, `volume_mm3`, `bbox`, `faces` and `closed` (a body that
/// could not be measured keeps its `error`), `body_count` and the total volume. An `items`
/// reply is small already and goes through as it is.
fn measure_summary(v: Value) -> Value {
let Some(o) = v.as_object() else { return v };
if !o.contains_key("bodies") {
return v;
}
let bodies: Vec<Value> = o
.get("bodies")
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(|b| {
let mut s = Map::new();
let keys: &[&str] = if b.get("error").is_some() { &["name", "error"] } else { &["name", "volume_mm3", "bbox", "faces", "closed"] };
for k in keys {
s.insert((*k).to_string(), b.get(*k).cloned().unwrap_or(Value::Null));
}
Value::Object(s)
})
.collect();
json!({
"body_count": o.get("body_count").cloned().unwrap_or_else(|| json!(bodies.len())),
"bodies": bodies,
"total": {"volume_mm3": o.get("total").and_then(|t| t.get("volume_mm3")).cloned().unwrap_or(Value::Null)},
})
}
/// A `[x, y, z]` JSON array as numbers.
fn xyz(v: &Value) -> Option<[f64; 3]> {
let a = v.as_array()?;
if a.len() != 3 {
return None;
}
let p = [a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?];
p.iter().all(|c| c.is_finite()).then_some(p)
}
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
/// One cylinder as `body_topology {faces: "cylinders"}` reports it: the faces that lie on it,
/// merged by equal radius and the same axis line.
struct TopoCylinder {
radius: f64,
origin: [f64; 3],
/// Unit length.
axis: [f64; 3],
/// Distances along `axis` from `origin` of the points found on the cylinder so far.
extent: Option<(f64, f64)>,
names: Vec<Value>,
}
/// How far a point lies from the cylinder's axis line, and how far along it from the origin.
fn axial(c: &TopoCylinder, p: [f64; 3]) -> (f64, f64) {
let d = sub(p, c.origin);
let along = dot(d, c.axis);
let off = cross(d, c.axis);
(dot(off, off).sqrt(), along)
}
/// Does `p` lie on the cylinder's surface, within `tol`?
fn on_cylinder(c: &TopoCylinder, p: [f64; 3], tol: f64) -> bool {
(axial(c, p).0 - c.radius).abs() <= tol
}
fn widen(c: &mut TopoCylinder, p: [f64; 3]) {
let t = axial(c, p).1;
c.extent = Some(match c.extent {
Some((lo, hi)) => (lo.min(t), hi.max(t)),
None => (t, t),
});
}
/// Positions and axes that agree within this are one cylinder (mm).
const CYLINDER_TOL: f64 = 1e-3;
/// `body_topology {faces: "cylinders"}`: the cylindrical faces of `model.faces`, the halves the
/// kernel splits one cylinder into merged (equal radius, the same axis line: origin on the axis,
/// direction up to sign), each with the extent along its axis that the body's own points on the
/// cylinder span (the faces' `point`s and the ends and midpoints of the edges lying on it). Built
/// from what `model.faces` and `model.edges` already reply, so it needs nothing of the kernel.
fn topology_cylinders(faces: &Value, edges: &Value) -> Value {
let mut found: Vec<TopoCylinder> = Vec::new();
for f in faces.as_array().into_iter().flatten() {
let c = &f["cylinder"];
let (Some(radius), Some(origin), Some(axis)) = (c["radius"].as_f64(), xyz(&c["origin"]), xyz(&c["axis"])) else { continue };
let len = dot(axis, axis).sqrt();
if !radius.is_finite() || radius <= 0.0 || !len.is_finite() || len < 1e-9 {
continue;
}
let axis = [axis[0] / len, axis[1] / len, axis[2] / len];
// The same cylinder: the same radius and axis direction (either way round), and this
// face's origin on that axis line.
let same = |k: &TopoCylinder| {
let off = cross(sub(origin, k.origin), k.axis);
(k.radius - radius).abs() <= CYLINDER_TOL && dot(k.axis, axis).abs() >= 1.0 - 1e-6 && dot(off, off).sqrt() <= CYLINDER_TOL
};
let name = f.get("name").cloned().unwrap_or(Value::Null);
let at = match found.iter().position(same) {
Some(i) => i,
None => {
found.push(TopoCylinder { radius, origin, axis, extent: None, names: Vec::new() });
found.len() - 1
}
};
if let Some(k) = found.get_mut(at) {
k.names.push(name);
if let Some(p) = xyz(&f["point"]) {
widen(k, p);
}
}
}
// The ends of the cylinder: the vertices of the edges that lie on it (a circle's ends and
// midpoint are on it; an edge of another face at most touches it in two points).
for e in edges.as_array().into_iter().flatten() {
let (Some(start), Some(mid), Some(end)) = (xyz(&e["start"]), xyz(&e["mid"]), xyz(&e["end"])) else { continue };
for c in &mut found {
let tol = CYLINDER_TOL.max(c.radius * 1e-4);
if [start, mid, end].iter().all(|p| on_cylinder(c, *p, tol)) {
widen(c, start);
widen(c, end);
}
}
}
found
.into_iter()
.map(|c| {
let mut v = json!({"radius": c.radius, "origin": c.origin, "axis": c.axis, "names": c.names});
if let (Some((lo, hi)), Some(o)) = (c.extent, v.as_object_mut()) {
o.insert("extent".into(), json!([lo, hi]));
}
v
})
.collect()
}
/// A command's result without what the design already had (`execute` or `batch` with `verbose: false`): a
/// feature's full body list (its created, changed and removed bodies stay), and the sketch ids
/// a sketch command repeats outside `created`.
+3 -3
View File
@@ -288,10 +288,10 @@ The `skill_reference` tool returns these documents from the copies built into So
| `list_commands` | `filter?` | The command catalog: id, label, tab/panel, shortcut, parameter docs, enabled now. `filter` matches id, label, tab and panel. |
| `describe_command` | `id` | One command's parameter docs (`params`) and, for commands with a typed contract (every new command, and more of the older ones as they are converted), the JSON Schemas (2020-12) of its parameters (`input`) and of a successful result (`output`); `null` for the others. The same as `execute` `engine.describe {command}`. |
| `execute` | `command`, `params?`, `verbose?` (default true) | Run any command with JSON parameters (`list_commands` shows them). Failures leave the design unchanged. A feature command replies with the feature (`feature`, `name`), every body (`bodies`) and the bodies it created, changed and removed (`created_bodies`, `changed_bodies`, `removed_bodies`). `verbose: false` uses the same compact reply as `batch`. |
| `batch` | `commands: [{command, params}]`, `rollback?` (default true), `verbose?` (default true), `timings?`, `profile?`, `coalesce_parameters?` (default false) | Run steps in order, stop at the first error and undo the completed steps. The reply names the failing step (`failed.index`, `failed.command`, `failed.error`), every result before it, `rolled_back` (true only when the design really went back to where the batch started), `undone`, `applied` (the indices of the steps whose changes are in the design) and `not_run` (the steps never sent). A step may name a tool instead of a command, with that tool's arguments as `params`: `set_parameter` (runs `parameters.change`; `value` or `expression`), `list_commands` (`engine.commands`, filtered by `filter` as the tool filters it), `describe_command` (`engine.describe`), `execute` (`{command, params}`), `measure`, `inspect_design` (`document.inspect`: each sketch as its counts, not its full contents, and no joints), `undo`, `redo`, `export`, `save`, `open`, `new_design` and `crash_reports`. `screenshot`, `body_topology` (use `model.edges` and `model.faces`), `batch`, `check_parameters`, `pending`, `running` and `skill_reference` can't be steps, nor can `component.edit_in_place` and `component.finish_edit`, however a step names them, as they go to another design's tab (send them with the `execute` tool on their own): such a batch fails before anything is sent. So does a step the batch would certainly refuse at its turn (#5853): a step without a command, an unknown command id (the error names the closest ids) or, with `--param-check strict`, parameters that break a command's contract. A headless session checks this itself; an app checks it against its own commands when it gets the batch, and a bridged server at `--param-check strict` checks the contracts before it sends the batch. `verbose: false` keeps the reply small in large designs: feature steps leave out the full body list (`created_bodies`, `changed_bodies`, `removed_bodies` stay) and sketch steps the ids they repeat outside `created`. Bridged to an app that has `engine.batch`, the whole batch is one control request (one frame of the app). Older apps, and apps showing the agent cursor, get the steps one by one. `timings: true` adds `timings: [{index, command, ms}]` for every step run, the failing one included, and `total_ms`. `profile: true` also adds each step's `profile: {boolean_ms, tessellate_ms, other_ms}`, its time in kernel booleans, in tessellation and the rest. Steps are timed where they run: in the app when bridged. An app without `engine.batch` is timed step by step, without `profile`. `coalesce_parameters: true` sets each run of consecutive plain parameter edits (`parameters.change` or `set_parameter` with a name and a value, or `changes`) together, so the design rebuilds once per run instead of once per edit. Steps between runs see the design up to date. Each step keeps its own result (`coalesced`: the run's length), and the run is one undo step. The run skips the states between its edits, so an edit refused on its own can go through with the run's later ones. A refused run goes step by step, naming the refused step. |
| `batch` | `commands: [{command, params}]`, `rollback?` (default true), `verbose?` (default true), `timings?`, `profile?`, `coalesce_parameters?` (default false) | Run steps in order, stop at the first error and undo the completed steps. The reply names the failing step (`failed.index`, `failed.command`, `failed.error`), every result before it, `rolled_back` (true only when the design really went back to where the batch started), `undone`, `applied` (the indices of the steps whose changes are in the design) and `not_run` (the steps never sent). A step may name a tool instead of a command, with that tool's arguments as `params`: `set_parameter` (runs `parameters.change`; `value` or `expression`), `list_commands` (`engine.commands`, filtered by `filter` as the tool filters it), `describe_command` (`engine.describe`), `execute` (`{command, params}`), `measure` (`summary: true` summarised as the tool summarises it), `inspect_design` (`document.inspect`: each sketch as its counts, not its full contents, and no joints), `undo`, `redo`, `export`, `save`, `open`, `new_design` and `crash_reports`. `screenshot`, `body_topology` (use `model.edges` and `model.faces`), `batch`, `check_parameters`, `pending`, `running` and `skill_reference` can't be steps, nor can `component.edit_in_place` and `component.finish_edit`, however a step names them, as they go to another design's tab (send them with the `execute` tool on their own): such a batch fails before anything is sent. So does a step the batch would certainly refuse at its turn (#5853): a step without a command, an unknown command id (the error names the closest ids) or, with `--param-check strict`, parameters that break a command's contract. A headless session checks this itself; an app checks it against its own commands when it gets the batch, and a bridged server at `--param-check strict` checks the contracts before it sends the batch. `verbose: false` keeps the reply small in large designs: feature steps leave out the full body list (`created_bodies`, `changed_bodies`, `removed_bodies` stay) and sketch steps the ids they repeat outside `created`. Bridged to an app that has `engine.batch`, the whole batch is one control request (one frame of the app). Older apps, and apps showing the agent cursor, get the steps one by one. `timings: true` adds `timings: [{index, command, ms}]` for every step run, the failing one included, and `total_ms`. `profile: true` also adds each step's `profile: {boolean_ms, tessellate_ms, other_ms}`, its time in kernel booleans, in tessellation and the rest. Steps are timed where they run: in the app when bridged. An app without `engine.batch` is timed step by step, without `profile`. `coalesce_parameters: true` sets each run of consecutive plain parameter edits (`parameters.change` or `set_parameter` with a name and a value, or `changes`) together, so the design rebuilds once per run instead of once per edit. Steps between runs see the design up to date. Each step keeps its own result (`coalesced`: the run's length), and the run is one undo step. The run skips the states between its edits, so an edit refused on its own can go through with the run's later ones. A refused run goes step by step, naming the refused step. |
| `inspect_design` | `measure?` (default true), `sketches?` (default true), `joints?` (default true) | Design properties (`metadata`: project, author… and other tools' keys; set them with `execute` `document.metadata.set`), parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible; `removed_later: true` while a sketch is being edited on a rolled-back timeline and a later feature deletes the body, which is then not shown), sketches (curves, points, constraints, profiles, DOF, solve status, visible), joints when the design has any (as `joint.inspect` gives them: each origin's occurrence, component and path, the snap as saved, the feature, face, edge or sketch curve (`sketch: {id, name, curve}`) its persistent names refer to, `resolves` and the `error` when it is lost; kind, offset, angle, flip, values, limits, expressions, motion links; `execute` `joint.inspect {joint}` gives one joint), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`), and joint glyphs with `{joints: [id or name], visible}` or `{folder: "joints", visible}` (a hidden joint still constrains and drives; `joint.list` reports each joint's `visible`). |
| `measure` | `body?` or `items?` | Area (mm²), bbox, topology counts and `closed` per body. Volume (mm³), volume centre of mass, density and mass are reported only for closed, consistently oriented bodies (null or omitted for open surfaces). Volume/mass totals skip open bodies; `total.open_bodies_skipped` gives their count. Area and topology totals include all bodies. With `items` (one or two selections as `execute` takes them: `face`, `edge`, `vertex`, `sketch_curve`, `profile`), the picked geometry instead: faces and edges need only `type`, `body` and `point`; `index` is optional. See below. |
| `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, name, area, centroid, normal, `point`, and for a cylindrical face its `cylinder`: `origin`, `axis`, `radius`). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. A face's `point` lies on it and picks it (its centroid may not: that of a face with a hole is in the hole); a face's `name` picks it for the commands that take faces (`face.delete`, `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face`, `solid.replace_face`, and `face` of `sheet.convert`). |
| `measure` | `body?` or `items?`, `summary?` (default false) | Area (mm²), bbox, topology counts and `closed` per body. Volume (mm³), volume centre of mass, density and mass are reported only for closed, consistently oriented bodies (null or omitted for open surfaces). Volume/mass totals skip open bodies; `total.open_bodies_skipped` gives their count. Area and topology totals include all bodies. With `items` (one or two selections as `execute` takes them: `face`, `edge`, `vertex`, `sketch_curve`, `profile`), the picked geometry instead: faces and edges need only `type`, `body` and `point`; `index` is optional. See below. `summary: true` keeps one short object per body (`name`, `volume_mm3`, `bbox: {min, max}`, `faces`, `closed`; a body that could not be measured keeps its `error`), `body_count` and `total.volume_mm3`, nothing else: what a stage check against computed numbers needs, at a fraction of the size. It applies to the bodies reply, not to `items`. |
| `body_topology` | `body`, `faces?` (`all`, `cylinders`; default `all`) | Edges (index, midpoint, length, ends) and faces (index, name, area, centroid, normal, `point`, and for a cylindrical face its `cylinder`: `origin`, `axis`, `radius`). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. A face's `point` lies on it and picks it (its centroid may not: that of a face with a hole is in the hole); a face's `name` picks it for the commands that take faces (`face.delete`, `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face`, `solid.replace_face`, and `face` of `sheet.convert`). `faces: "cylinders"` replies `cylinders` instead of `edges` and `faces`: one entry per cylinder, `{radius, origin, axis, extent, names}`, the halves the kernel splits a cylinder into merged (same radius, same axis line; `origin` and `axis` as the first half's `cylinder` gives them), `extent` the `[min, max]` distance along `axis` from `origin` that the body's points on the cylinder span (the faces' `point`s and the ends of the edges lying on it; left out when none were found), and `names` the merged faces' names, any of which picks the face. Enough to check where every hole and boss is, in a few lines instead of the whole topology. |
| `set_parameter` | `name`, `value` or `expression` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute. `expression` is another name for `value` (the one `parameters.change` uses): give one of them, or both only when they agree; neither, or two that differ, is an error. 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. An expression `new_name = expression` renames the parameter as it sets it; in any other tool's expression parameter it adds the user parameter `new_name` and the input uses it (`defined`, `definitions` in the reply). |
| `check_parameters` | `set: {name: value}` (a number or an expression each, as `set_parameter`'s `value`), `report?` (`measure`, `cylinders`, `errors`; default all three) | Try parameter values without keeping them: one `edit.checkpoint`, every value set in one `parameters.change {changes}` (one rebuild; an app without `changes` gets one change per name), the reports read, then `edit.restore_checkpoint`, so the design, its parameters and its undo history are exactly as before. The reply has `values` (each parameter's value with the tried expressions in), `recomputed`, `measure` (volume, bbox, bodies and faces per body, as the `measure` tool reports them), `errors` and `warnings` (the features that fail or warn: `{feature, name, error}`) with `parameter_errors` (the parameters that don't evaluate), `cylinders` (every cylindrical face of every body as `{body, radius, origin, axis, faces, name, area}`; the halves a kernel splits one cylinder into are one entry, `origin` is the axis point closest to the world origin and `axis` points so that its first non-zero component is positive) and `restored: true`. A name the design has no parameter of, a value that is not a number or a string, an expression that does not evaluate or an unknown report item is an error, and nothing changes. If the checkpoint cannot be restored the call is an error with `restored: false`, `restore_error` and a note that the tried values are still in the design: set them back before saving. Replaces the loop `set_parameter`, `measure`, `body_topology`, `set_parameter` back, which cost every agent session three to five calls and up to 16 s of rebuilds. Not a `batch` step. |
| `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit, or a named view saved with `view.save`), `camera?` (`{target, yaw, pitch, distance, fov?}`, as `view.apply`), `path?`, `width?`, `height?`, `source?` (window, model), `grid?`, `axes?` | A PNG image content block. Headless: a render with the viewport's pipelines on a headless GPU device (see `solidcraft-cli gpu`; with no adapter, the CPU rasterizer, without analysis colours), fitted to the model, or from the named view or camera; the reply's `renderer` says which (`gpu`, `gpu-software`, `cpu`, with a `warning` for `cpu`). Bridged: the app window (default) or a model render with the app's camera; a named view or camera moves the app's viewport there first (`execute` `view.apply` does the same without a screenshot). `grid: false` and `axes: false` leave the ground grid (it lies on Z 0 and cuts through a part that straddles it) and the origin's axes out of a model render; axes hidden with `browser.visibility` stay out whatever `axes` says. Bridged, a screenshot that gives `grid` or `axes` is a model render, which follows the app's Grid and Origin switches for what it does not give; `source: "window"` with either is an error. A window that is minimised or on another macOS Space presents no frame: with no `source` the reply is a model render and says so (`fallback`); `source: "window"` is an error saying why, at once. Agents that don't need the app's chrome can ask for `source: "model"`. |
+3 -2
View File
@@ -47,13 +47,14 @@ Work the part out as numbers first. Then build it in a few large `batch` calls,
- A fillet between two curved faces, such as a sphere joined to a cylinder: the shell then fails with `offset: only bodies with planes, cylinders, cones, spheres and tori`. Shell first. The inside corner then stays sharp, so the wall there is thinner than `thickness`.
## Verify
- **Each stage:** run `measure {}` (about 0.9 KB per body) and compare the volume, bbox and face count with your numbers. A cut or hole that leaves the volume unchanged has missed, even though it reported success.
- **Each stage:** run `measure {}` (about 0.9 KB per body; `{"summary":true}` keeps only name, volume, bbox, face count and `closed` per body) and compare the volume, bbox and face count with your numbers. A cut or hole that leaves the volume unchanged has missed, even though it reported success.
- **Timeline health:** `inspect_design {"measure":false,"sketches":false}` shows each feature's `error` and `warning`.
- **Parameter test:** run `set_parameter`. Expect `errors: []`, then `measure` again; the COM and bbox should move by the amounts you computed. To try values without keeping them, `check_parameters {"set": {"L": "140 mm"}}` reports the volume, errors and cylinder axes and puts the design back.
- **Positions:** `measure` does not say where a feature sits.
- On an asymmetric part, check the COM shift you computed.
- On a symmetric part (a mirrored or patterned layout) the COM does not move, so take a top-view screenshot and compare distances in pixels against a known size.
- `body_topology` is a last resort: it costs 25-45 KB and splits cylinders into half faces.
- For holes and bosses, `body_topology {"body":"Body1","faces":"cylinders"}`: one entry per cylinder (`radius`, `origin`, `axis`, `extent` along the axis, the merged faces' `names`), a few lines.
- The full `body_topology` is a last resort: it costs 25-45 KB and splits cylinders into half faces.
- **Final look:** one `screenshot {"source":"model","view":"iso","width":480,"height":320}`. Use `"view":"top"` for features inside walls, which the iso view hides.
- `width` and `height` apply only to model renders.
- When the server is bridged, `source` defaults to the whole app window and `view` also turns the user's camera, so pass `"source":"model"` and leave out `view` unless the user wants the camera moved.