From ffb71de6e1d08d7698e08cf4a196d43f5a8462a5 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:31:26 -0400 Subject: [PATCH] Engine: geometry.bodies lists each body's named edges Each edge with its persistent name and length. A reference to an edge name selects its numbered pieces (`~k`), as a face name selects its `#k` pieces. Part of #230. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/cmd/geometry.rs | 4 +++- crates/engine/src/lib.rs | 6 ++++++ crates/mcp/src/tools.rs | 3 ++- crates/model/src/geometry.rs | 11 +++++++++-- docs/mcp.md | 2 +- 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/geometry.rs b/crates/engine/src/cmd/geometry.rs index 0187835..62d9dd7 100644 --- a/crates/engine/src/cmd/geometry.rs +++ b/crates/engine/src/cmd/geometry.rs @@ -25,7 +25,7 @@ pub const OPEN: CommandSpec = CommandSpec { pub const BODIES: CommandSpec = CommandSpec { id: "geometry.bodies", label: "Bodies", - params: "{} — the open design's bodies: measures and persistent face names (by face index)", + params: "{} — the open design's bodies: measures, persistent face names (by face index) and edges (persistent name `|`, `~k` when several separate the same faces, and length)", form: Some(&[]), kind: CommandKind::Model, requires: &[], // No design is an answer too: no bodies. @@ -126,6 +126,7 @@ fn bodies(s: &mut Session, _: &Value) -> Result { let mut out = Vec::with_capacity(design.bodies.len()); for b in &design.bodies { let m = b.measure()?; + let edges: Vec = b.edges()?.iter().map(|e| json!({"name": e.name, "length": e.length()})).collect(); out.push(json!({ "name": b.name, "volume": m.volume, @@ -133,6 +134,7 @@ fn bodies(s: &mut Session, _: &Value) -> Result { "centroid": m.centroid, "bbox": [m.bbox_min, m.bbox_max], "faces": b.faces().iter().map(|f| json!({"name": f.name, "exact": f.exact})).collect::>(), + "edges": edges, })); } Ok(Value::Array(out)) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0a45ea3..6221cdc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -645,6 +645,10 @@ mod tests { assert!((volume - expect).abs() < 1e-3 * expect, "{volume}"); let faces = bodies[0]["faces"].as_array().unwrap(); assert_eq!(faces.len(), 8); + let edges = bodies[0]["edges"].as_array().unwrap(); + assert_eq!(edges.len(), 18); + // Through the thickness: four box corners, the two hole seams. + assert_eq!(edges.iter().filter(|e| (e["length"].as_f64().unwrap() - 10.0).abs() < 1e-6).count(), 6); let t = s.execute("geometry.tessellate", &json!({"body": "Body1", "tolerance": 0.05})).unwrap(); let per_face: Vec = t["faces"].as_array().unwrap().iter().map(|f| f["triangles"].as_u64().unwrap()).collect(); @@ -662,6 +666,8 @@ mod tests { let wider = s.execute("geometry.bodies", &json!({})).unwrap(); assert!(wider[0]["volume"].as_f64().unwrap() > volume); assert_eq!(wider[0]["faces"], bodies[0]["faces"]); + let names = |b: &Value| b[0]["edges"].as_array().unwrap().iter().map(|e| e["name"].clone()).collect::>(); + assert_eq!(names(&wider), names(&bodies)); assert_eq!(s.journal.len(), 2); } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 5bfebbe..eb2a066 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -126,7 +126,8 @@ pub fn tool_definitions() -> Vec { "Inspect the model", "The model: node and element counts, the unit system and the definitions by kind (model.info); the step \ table (which boundary conditions and loads each step has, model.activity); the open design's bodies with \ - their measures and persistent face names (geometry.bodies), which sets name as {body, face}.", + their measures, persistent face names (geometry.bodies), which sets name as {body, face}, and persistent \ + edge names `|` with lengths, which sets name as {body, edge}.", obj(json!({}), &[]), true, false, diff --git a/crates/model/src/geometry.rs b/crates/model/src/geometry.rs index 25746d5..48bcaf7 100644 --- a/crates/model/src/geometry.rs +++ b/crates/model/src/geometry.rs @@ -140,17 +140,20 @@ impl GeometryRef { /// Whether this reference (from a set) selects the tagged entity `tag`: the same body and /// kind, and the same name or the name of one of its pieces. A B-rep face the kernel splits /// into pieces (a full cylinder is two half-cylinders) carries the face's name with `#0`, - /// `#1`, … appended; a reference to the plain name selects all of them. + /// `#1`, … appended; a reference to the plain name selects all of them. Edges separating the + /// same faces are numbered `~0`, `~1`, … the same way. pub fn selects(&self, tag: &GeometryRef) -> bool { if self.body != tag.body || self.entity.kind() != tag.entity.kind() { return false; } match (self.entity.name(), tag.entity.name()) { (Some(want), Some(have)) => { + // A face's pieces are numbered `#k`, an edge's `~k` (edges separating the same faces). + let mark = if matches!(self.entity, Entity::Edge(_)) { '~' } else { '#' }; have == want || have .strip_prefix(want) - .and_then(|r| r.strip_prefix('#')) + .and_then(|r| r.strip_prefix(mark)) .is_some_and(|k| !k.is_empty() && k.bytes().all(|b| b.is_ascii_digit())) } _ => true, @@ -354,6 +357,10 @@ mod tests { assert!(!r.selects(&GeometryRef::face("Body2", "F2:side").unwrap())); assert!(!r.selects(&GeometryRef::new("Body1", Entity::Edge("F2:side".into())).unwrap())); assert!(GeometryRef::body("Body1").unwrap().selects(&GeometryRef::body("Body1").unwrap())); + let edge = |n: &str| GeometryRef::new("Body1", Entity::Edge(n.into())).unwrap(); + assert!(edge("A|B").selects(&edge("A|B~1"))); + assert!(!edge("A|B").selects(&edge("A|B#1"))); + assert!(!edge("A|B~0").selects(&edge("A|B~1"))); for bad in [ serde_json::json!({"body": ""}), diff --git a/docs/mcp.md b/docs/mcp.md index f2efcd0..1b03733 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -94,7 +94,7 @@ software on the computer. | `list_commands` | `filter?` | Every command with its label, parameter documentation and `schema` (its parameters as JSON Schema: types, units as `x-dimension`, defaults, required fields, one-of variants) and `requires` (what the session must hold first: `design`, `geometry`, `mesh`, `material`, `step`, `result`, …); `filter` matches id and label. | | `execute` | `command`, `params?` | Run any command (`list_commands`). A failure leaves the model unchanged and names what to fix; a command whose `requires` are not met fails before doing anything, naming each missing piece and the command that provides it. | | `batch` | `commands: [{command, params}]` | Run steps in order, stop at the first error; the reply has every result and the failing step (`failed.index`, `failed.command`, `failed.error`). Steps before it stay applied (`undo` takes them back). | -| `inspect_model` | — | `model.info` (counts, units, definitions), `model.activity` (the step table) and `geometry.bodies` (bodies with measures and persistent face names, which sets name as `{body, face}`). | +| `inspect_model` | — | `model.info` (counts, units, definitions), `model.activity` (the step table) and `geometry.bodies` (bodies with measures, persistent face names, which sets name as `{body, face}`, and persistent edge names `|` with lengths, which sets name as `{body, edge}`). | | `check_model` | — | `model.check`: errors and warnings, each naming its item. | | `open` | `path`, `model?` | By extension: `.fieldcraft` (`file.open`), `.solidcraft` (`geometry.open`), `.3mf` (`geometry.import.mesh`). For a design, `model` is `keep` (default: the mesh and definitions stay, as a reopen after an edit wants) or `new` (a new model with the design); the reply's `related` is false when the design has none of the bodies the model refers to, and `stale` lists the mesh bodies and sets that no longer resolve. | | `save` | `path?` | `file.save`: to `path` or the model's own file. |