From e7aff4fef07a60b07e183790bdcf1d7845062523 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 10:43:55 +0000 Subject: [PATCH] Persistent naming: shell, draft, offset-face and replace-face faces and hole placements store their faces' names (Feature.face_names) and are moved onto the named face before evaluating (a split face: the nearest piece, with a warning; a vanished face: the picked point, with a warning) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/document.rs | 22 +++++++++++++++++ crates/doc/src/eval.rs | 27 ++++++++++++++++++++ crates/doc/src/naming.rs | 41 +++++++++++++++++++++++++++++++ crates/engine/src/cmd/edit.rs | 4 +++ crates/engine/src/cmd/features.rs | 5 ++++ crates/engine/src/tests.rs | 15 +++++++++++ 6 files changed, 114 insertions(+) diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 4a42c706..290b7222 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -885,6 +885,24 @@ impl FeatureKind { } } /// Every expression the feature uses. + /// Points on faces the feature refers to (shell, draft, offset and replace faces, a hole's + /// placement), for persistent face names. + pub fn face_points_mut(&mut self) -> Vec<&mut Vec3> { + match self { + FeatureKind::Shell { faces, .. } + | FeatureKind::Draft { faces, .. } + | FeatureKind::OffsetFace { faces, .. } + | FeatureKind::ReplaceFace { faces, .. } => faces.iter_mut().collect(), + FeatureKind::Hole { position, points: None, .. } => vec![position], + _ => Vec::new(), + } + } + + pub fn face_points(&self) -> Vec { + let mut k = self.clone(); + k.face_points_mut().into_iter().map(|p| *p).collect() + } + pub fn expressions(&self) -> Vec<&str> { let mut v: Vec<&str> = Vec::new(); match self { @@ -1024,6 +1042,9 @@ pub struct Feature { /// Persistent names of the picked edges (resolved first; see `naming`). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub edge_names: Vec, + /// Persistent names of the faces at the feature's face points (`FeatureKind::face_points`). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub face_names: Vec, #[serde(flatten)] pub kind: FeatureKind, } @@ -1293,6 +1314,7 @@ impl Document { component: 0, edge_refs: Vec::new(), edge_names: Vec::new(), + face_names: Vec::new(), kind, }; match self.marker { diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 2fa17333..a6712bd8 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -517,6 +517,33 @@ impl Model { let t0 = now(); let mut next = (*state).clone(); let mut warning = None; + // Face references by name: move each face point onto its named face. + let renamed; + let f = if f.face_names.iter().any(|n| !n.is_empty()) { + let mut g = f.clone(); + let mut notes = Vec::new(); + for (p, n) in g.kind.face_points_mut().into_iter().zip(&f.face_names) { + if n.is_empty() { + continue; + } + match crate::naming::point_on_face(&state, n, *p) { + Some((q, split)) => { + if split { + notes.push(format!("face `{n}` was split; using the piece nearest the picked point")); + } + *p = q; + } + None => notes.push(format!("face `{n}` no longer exists; using the face at the picked point")), + } + } + if !notes.is_empty() { + warning = Some(notes.join("; ")); + } + renamed = g; + &renamed + } else { + f + }; // A parameter with an error fails the features using it, with that error. let bad_param = param_names(f).into_iter().find_map(|n| self.param_errors.get(&n).map(|e| format!("parameter `{n}`: {e}"))); let r = match bad_param { diff --git a/crates/doc/src/naming.rs b/crates/doc/src/naming.rs index 531bbfd2..091adef2 100644 --- a/crates/doc/src/naming.rs +++ b/crates/doc/src/naming.rs @@ -533,3 +533,44 @@ pub fn edge_names_at(b: &ModelBody, pts: &[Vec3]) -> Vec { }) .collect() } + +/// Names of the faces holding each point (any body; empty when none does). +pub fn face_names_at(st: &ModelState, pts: &[Vec3]) -> Vec { + pts.iter() + .map(|p| { + for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) { + let tol = (b.body.size() * 1e-3).max(1e-3); + let idx = FaceIndex::new(&b.mesh(), face_count(b)); + if let Some(f) = idx.face_at(*p, tol) { + return face_names(b).get(f).cloned().unwrap_or_default(); + } + } + String::new() + }) + .collect() +} + +/// The point on the face with this name nearest `p` (a split face: its nearest piece, with +/// `true` for "it was split"); `None` when no face has the name. +pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool)> { + let stem = strip_piece(name); + let mut best: Option<(f64, Vec3, bool)> = None; + for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) { + let names = face_names(b); + let m = b.mesh(); + for (t, f) in m.triangles.iter().zip(&m.tri_face) { + let Some(n) = names.get(*f as usize) else { continue }; + let exact = n == name; + if !exact && strip_piece(n) != stem { + continue; + } + let Some([a, bb, c]) = m.tri(t) else { continue }; + let q = closest(p, a, bb, c); + let d = q.dist(p) - if exact { 1e-9 } else { 0.0 }; + if best.is_none_or(|(bd, _, _)| d < bd) { + best = Some((d, q, !exact)); + } + } + } + best.map(|(_, q, split)| (q, split)) +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index b50a70a4..849e8742 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -193,6 +193,10 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result { nf.edge_refs = solvecraft_doc::edge_refs(&before, body, a); nf.edge_names = solvecraft_doc::edge_names_for(&before, body, a); } + // New face points: their faces' names now. + if nf.kind.face_points() != f.kind.face_points() { + nf.face_names = solvecraft_doc::naming::face_names_at(&s.model.state_before(id), &nf.kind.face_points()); + } if let Some(slot) = s.doc_mut().feature_mut(id) { *slot = nf; } diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index b55602d8..f2a863ab 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -251,10 +251,15 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu } _ => (Vec::new(), Vec::new()), }; + let face_names = { + let pts = kind.face_points(); + if pts.is_empty() { Vec::new() } else { solvecraft_doc::naming::face_names_at(&s.model.state(), &pts) } + }; let id = s.doc_mut().add_feature(kind, name)?; if let Some(f) = s.doc_mut().feature_mut(id) { f.edge_refs = refs; f.edge_names = names; + f.face_names = face_names; } s.doc_mut().name_feature_inputs(id); let comp = s.active_component; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4748e9c9..ea2f1a48 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1325,4 +1325,19 @@ mod naming { 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)); } + + #[test] + fn shell_and_hole_faces_follow_by_name() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 20, "name": "Base"})); + run(&mut s, "FusionShellBodyCommand", json!({"faces": [[20, 15, 20]], "thickness": 2, "name": "Hollow"})); + let shell = |h: f64| 40.0 * 30.0 * h - 36.0 * 26.0 * (h - 2.0); + assert!((volume(&mut s) - shell(20.0)).abs() < 0.5, "{}", volume(&mut s)); + // Taller: the picked point is 5 mm under the open face now; its name still finds it. + run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"height": "25"}})); + assert!((volume(&mut s) - shell(25.0)).abs() < 0.5, "{}", volume(&mut s)); + assert_eq!(warning(&s, "Hollow"), None); + let h = s.doc.find_feature("Hollow").map(|f| f.face_names.clone()).unwrap_or_default(); + assert_eq!(h, vec!["F1:+z".to_string()]); + } }