Persistent naming: sketches on faces store the face's name and find it by name first (an upstream feature that adds a nearer parallel face no longer moves the sketch); a vanished face falls back to the old geometric search with a warning

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 452ee5f9f9
commit 99886594e7
5 files changed
+73 -6

No files matched your search

+3 -1
View File
@@ -314,6 +314,7 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
// A planar face of a body at this point.
let st = s.model.state();
let mut found = None;
let mut face_name = None;
for b in &st.bodies {
let tol = (b.body.size() * 1e-3).max(1e-3);
for f in b.body.faces(tol).unwrap_or_default() {
@@ -324,11 +325,12 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
// Sketch origin: the model origin projected onto the face plane.
let d = (f.centroid + n * (fp - f.centroid).dot(n)).dot(n);
found = Plane::from_normal(n * d, n);
face_name = solvecraft_doc::naming::face_names(b).get(f.index).cloned();
}
}
}
let pl = found.ok_or_else(|| bad(cmd, "no planar face at that point"))?;
PlaneRef::Face { plane: pl, at: fp }
PlaneRef::Face { plane: pl, at: fp, name: face_name }
} else {
let origin = v.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
let x = v.get("x_dir").and_then(vec3).ok_or_else(|| bad(cmd, "plane needs x_dir"))?;
+29
View File
@@ -1296,4 +1296,33 @@ mod naming {
assert!(n.iter().any(|x| x.starts_with(want)), "{want} in {n:?}");
}
}
#[test]
fn a_sketch_on_a_face_stays_on_that_face() {
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, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 10, "name": "Block"}));
run(&mut s, "SketchCreate", json!({"plane": {"face": [30, 15, 10]}, "name": "Top"}));
run(&mut s, "CircleCenterRadius", json!({"center": [30, 15], "radius": 3}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "Top", "distance": 5, "operation": "join", "name": "Peg"}));
let top = |s: &mut Session| run(s, "MeasureCommand", json!({}))["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0);
assert!((top(&mut s) - 15.0).abs() < 1e-6);
// Upstream, a tall boss over the picked point: the nearest upward face is now its top,
// but the sketch keeps to the block's top face by name.
run(&mut s, "timeline.rollTo", json!({"feature": "Block"}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "B"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [8, -5], "p1": [45, 35]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "B", "distance": 20, "operation": "join", "name": "Boss"}));
run(&mut s, "timeline.rollTo", json!({}));
// (Its projected face outline changed, which it says; its plane did not.)
assert!(!warning(&s, "Top").unwrap_or_default().contains("no longer exists"));
// The peg starts on the block's top (z = 10), inside the boss: the part stays 20 tall.
assert!((top(&mut s) - 20.0).abs() < 1e-6, "{}", top(&mut s));
let sk = s.model.state().sketch(s.doc.find_feature("Top").map(|f| f.id).unwrap_or(0)).map(|x| x.plane.origin.z);
assert_eq!(sk.map(|z| (z * 1e6).round() / 1e6), Some(10.0));
}
}