Persistent naming, step 1 (plan/adr/0002): faces are named from their history (a face lying on an input face keeps its name; new faces by role: F<feature>:side:<sketch curve>, :start/:top/:bottom caps, :blend:<edge>, …; split pieces #k along their spread), edges by the faces they separate (pieces ~k); names worked out lazily per body and shared by its clones; fillets and chamfers store edge names and resolve by name first (a split edge takes the piece nearest the picked point with a warning, a vanished one falls back to the point with a warning); model.faces and model.edges report names; tests for resize, suppress, reorder, an upstream split and a removed sketch segment

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 11:37:33 +00:00
1 parent 3efe347809
commit 72297047d5
8 files changed
+628 -24

No files matched your search

+3 -1
View File
@@ -189,7 +189,9 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
) = (&nf.kind, &f.kind)
&& a != b
{
nf.edge_refs = solvecraft_doc::edge_refs(&s.model.state_before(id), body, a);
let before = s.model.state_before(id);
nf.edge_refs = solvecraft_doc::edge_refs(&before, body, a);
nf.edge_names = solvecraft_doc::edge_names_for(&before, body, a);
}
if let Some(slot) = s.doc_mut().feature_mut(id) {
*slot = nf;
+6 -6
View File
@@ -244,17 +244,17 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
let mut kind = kind;
super::component::to_active_frame(s, &mut kind);
// Blends remember where their edges sat, to find them again after upstream edits.
let refs = match &kind {
let (refs, names) = match &kind {
FeatureKind::Fillet { edges, body, .. } | FeatureKind::Chamfer { edges, body, .. } => {
solvecraft_doc::edge_refs(&s.model.state(), body, edges)
let st = s.model.state();
(solvecraft_doc::edge_refs(&st, body, edges), solvecraft_doc::edge_names_for(&st, body, edges))
}
_ => Vec::new(),
_ => (Vec::new(), Vec::new()),
};
let id = s.doc_mut().add_feature(kind, name)?;
if !refs.is_empty()
&& let Some(f) = s.doc_mut().feature_mut(id)
{
if let Some(f) = s.doc_mut().feature_mut(id) {
f.edge_refs = refs;
f.edge_names = names;
}
s.doc_mut().name_feature_inputs(id);
let comp = s.active_component;
+4 -2
View File
@@ -228,11 +228,12 @@ fn model_edges(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.world_state();
let b = body_of(&st, p, "model.edges")?;
let tol = (b.body.size() * 2e-3).max(1e-3);
let names = solvecraft_doc::naming::edge_names(b);
let edges: Vec<Value> = b
.body
.edges(tol)?
.iter()
.map(|e| json!({"index": e.index, "mid": e.mid, "length": e.length, "start": e.points.first(), "end": e.points.last()}))
.map(|e| json!({"index": e.index, "name": names.get(e.index), "mid": e.mid, "length": e.length, "start": e.points.first(), "end": e.points.last()}))
.collect();
Ok(json!({"body": b.name, "edges": edges}))
}
@@ -240,12 +241,13 @@ fn model_edges(s: &mut Session, p: &Value) -> Result<Value> {
fn model_faces(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.world_state();
let b = body_of(&st, p, "model.faces")?;
let names = solvecraft_doc::naming::face_names(b);
let tol = (b.body.size() * 2e-3).max(1e-3);
let faces: Vec<Value> = b
.body
.faces(tol)?
.iter()
.map(|f| json!({"index": f.index, "area": f.area, "centroid": f.centroid, "plane_normal": f.plane_normal}))
.map(|f| json!({"index": f.index, "name": names.get(f.index), "area": f.area, "centroid": f.centroid, "plane_normal": f.plane_normal}))
.collect();
Ok(json!({"body": b.name, "faces": faces}))
}
+104 -3
View File
@@ -386,12 +386,12 @@ fn timeline_edits_reresolve_references() {
let fillet = |l: f64| (9.0 - 9.0 * PI / 4.0) * l;
let expect = |l: f64, h: f64| l * 30.0 * h - fillet(l) - PI * 9.0 * h;
assert!(rel(volume(&mut s), expect(40.0, 20.0)) < 1e-3);
// Taller box: the fillet's edge point is now 5 mm below the edge (re-found, with a warning)
// and the hole moves up with the top face.
// Taller box: the fillet's edge point is now 5 mm below the edge; the edge is found by its
// name (no warning) and the hole moves up with the top face.
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"height": "25"}}));
assert!(rel(volume(&mut s), expect(40.0, 25.0)) < 1e-3, "{}", volume(&mut s));
let round = s.doc.find_feature("Round").map(|f| f.id).unwrap();
assert!(s.model.result(round).and_then(|r| r.warning.clone()).is_some_and(|w| w.contains("re-found")));
assert_eq!(s.model.result(round).and_then(|r| r.warning.clone()), None);
// Longer box: the point still lies on the (longer) edge.
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"length": "60"}}));
assert!(rel(volume(&mut s), expect(60.0, 25.0)) < 1e-3, "{}", volume(&mut s));
@@ -1166,3 +1166,104 @@ fn fillet_follows_its_edge_after_an_upstream_resize() {
let fillet = s.doc.features.last().map(|f| f.id).unwrap_or(0);
assert!(s.model.result(fillet).and_then(|r| r.warning.clone()).is_none(), "followed without a warning");
}
mod naming {
use serde_json::{Value, json};
use super::run;
use crate::Session;
fn volume(s: &mut Session) -> f64 {
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(0.0)
}
fn warning(s: &Session, name: &str) -> Option<String> {
let id = s.doc.find_feature(name).map(|f| f.id)?;
s.model.result(id).and_then(|r| r.warning.clone())
}
/// A 40 x 30 x 10 block (sketch S: l1…l4 counter-clockwise from the origin), a hole H, a
/// fillet on the right top edge.
fn block() -> Session {
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "S"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 40}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 10, "name": "Block"}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "H"}));
run(&mut s, "CircleCenterRadius", json!({"center": [12, 15], "radius": 4}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "H", "distance": 10, "operation": "cut", "name": "Hole"}));
run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[40, 15, 10]], "radius": 2, "name": "Round"}));
s
}
const STRIP: f64 = (4.0 - std::f64::consts::PI) * 30.0;
#[test]
fn faces_and_edges_are_named_from_their_history() {
let mut s = block();
let f = run(&mut s, "model.faces", json!({"body": "Body1"}));
let names: Vec<&str> = f["faces"].as_array().into_iter().flatten().filter_map(|x| x["name"].as_str()).collect();
for want in ["F2:start", "F2:top", "F2:side:l1", "F2:side:l2", "F2:side:l3", "F2:side:l4"] {
assert!(names.contains(&want), "{want} in {names:?}");
}
assert!(names.iter().any(|n| n.starts_with("F4:side:c1")), "{names:?}");
assert!(names.iter().any(|n| n.starts_with("F5:blend:")), "{names:?}");
let round = s.doc.find_feature("Round").cloned().unwrap();
assert_eq!(round.edge_names, vec!["F2:side:l2|F2:top".to_string()]);
}
#[test]
fn a_resize_keeps_the_edge() {
let mut s = block();
let d = s.doc.find_feature("S").and_then(|f| f.param_names.first().cloned()).unwrap_or_else(|| "d1".into());
run(&mut s, "ChangeParameterCommand", json!({"name": d, "expression": "60"}));
assert!((volume(&mut s) - (18000.0 - std::f64::consts::PI * 16.0 * 10.0 - STRIP)).abs() < 0.1, "{}", volume(&mut s));
assert_eq!(warning(&s, "Round"), None);
}
#[test]
fn suppress_and_reorder_upstream_keep_the_edge() {
let mut s = block();
let v = volume(&mut s);
run(&mut s, "timeline.suppress", json!({"feature": "Hole", "suppressed": true}));
assert!((volume(&mut s) - (12000.0 - STRIP)).abs() < 0.1);
assert_eq!(warning(&s, "Round"), None);
run(&mut s, "timeline.suppress", json!({"feature": "Hole", "suppressed": false}));
assert!((volume(&mut s) - v).abs() < 1e-6);
// The hole after the fillet: same part, same edge.
run(&mut s, "timeline.reorder", json!({"feature": "Hole", "position": 4}));
assert!((volume(&mut s) - v).abs() < 0.1, "{} {v}", volume(&mut s));
assert_eq!(warning(&s, "Round"), None);
}
#[test]
fn a_split_edge_warns_and_takes_the_nearest_piece() {
let mut s = block();
// Upstream of the fillet, a notch through the middle of the filleted edge.
run(&mut s, "timeline.rollTo", json!({"feature": "Hole"}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "N"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [36, 12], "p1": [44, 18]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "N", "distance": 10, "operation": "cut", "name": "Notch"}));
run(&mut s, "timeline.rollTo", json!({}));
let w = warning(&s, "Round").unwrap_or_default();
assert!(w.contains("split"), "{w}");
}
#[test]
fn a_removed_segment_warns() {
let mut s = block();
// Replace the right side (l2) of the rectangle with a new line: the face it made is gone.
let sk = s.doc.find_feature("S").map(|f| f.id).unwrap();
run(&mut s, "SketchActivate", json!({"sketch": sk}));
run(&mut s, "sketch.delete", json!({"entities": ["l2"]}));
run(&mut s, "DrawPolyline", json!({"points": [[40, 0], [40, 30]], "ids": ["right"]}));
let r: Result<Value, _> = s.execute("SketchStop", &json!({}));
assert!(r.is_ok() || r.is_err());
let w = warning(&s, "Round").unwrap_or_default();
assert!(w.contains("no longer exists"), "{w}");
}
}