From e7a85b30f7d6847d275099cc7eeba61e9be12457 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 10:46:54 +0000 Subject: [PATCH] Persistent naming: joint origins on faces and circular ends, and face appearances, store the face's name and find it by name first (a joint whose face is gone reports it instead of attaching elsewhere) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/appearance.rs | 19 ++++++++++++++++++- crates/doc/src/joints.rs | 32 +++++++++++++++++++++++++++----- crates/engine/src/cmd/inspect.rs | 13 ++++++++++++- crates/engine/src/cmd/joints.rs | 7 +++++-- 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 766d4dd3..f9a10238 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -42,6 +42,9 @@ pub struct FaceLook { pub body: String, pub point: Vec3, pub look: Look, + /// The face's persistent name (found first). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, } /// The design's appearances. @@ -123,7 +126,21 @@ impl Document { /// assignment to the same face wins. pub fn face_colors(&self, b: &crate::ModelBody) -> Vec<(usize, Look)> { let mut out: Vec<(usize, Look)> = Vec::new(); - for (p, look) in self.face_looks(&b.name) { + let names = crate::naming::face_names(b); + for f in self.appearances.faces.iter().filter(|f| f.body == b.name) { + let (p, look) = (f.point, f.look.clone()); + // By name (every piece of a split face), else by the point. + if let Some(n) = &f.name { + let stem = crate::naming::strip_piece(n); + let hits: Vec = (0..names.len()).filter(|i| names.get(*i).is_some_and(|x| crate::naming::strip_piece(x) == stem)).collect(); + if !hits.is_empty() { + for i in hits { + out.retain(|(j, _)| *j != i); + out.push((i, look.clone())); + } + continue; + } + } if let Some(i) = face_index_at(b, p) { out.retain(|(j, _)| *j != i); out.push((i, look)); diff --git a/crates/doc/src/joints.rs b/crates/doc/src/joints.rs index 178c7738..5c4a6eee 100644 --- a/crates/doc/src/joints.rs +++ b/crates/doc/src/joints.rs @@ -30,11 +30,20 @@ pub enum Snap { #[serde(default, skip_serializing_if = "Option::is_none")] x: Option, }, - /// The centre of the planar face nearest `pick`, z along its outward normal. - FaceCenter { pick: Vec3 }, + /// The centre of the planar face nearest `pick`, z along its outward normal. `face` is the + /// face's persistent name (found first). + FaceCenter { + pick: Vec3, + #[serde(default, skip_serializing_if = "Option::is_none")] + face: Option, + }, /// The centre of the circular end nearest `pick` of the cylindrical face there, z along its /// axis (pointing out of the face's end). - CircleCenter { pick: Vec3 }, + CircleCenter { + pick: Vec3, + #[serde(default, skip_serializing_if = "Option::is_none")] + face: Option, + }, /// A point, z along `z` (default +Z). Point { point: Vec3, @@ -225,12 +234,24 @@ fn component_of(doc: &Document, occ: u64) -> Result { } /// Resolve a joint origin to a frame in its component's coordinates. +/// A snap's pick point moved onto its named face (an error when the face is gone: a joint must +/// not quietly attach elsewhere). +fn named_pick(st: &ModelState, pick: Vec3, face: Option<&str>) -> Result { + match face { + Some(n) => crate::naming::point_on_face(st, n, pick) + .map(|(q, _)| q) + .ok_or_else(|| DocError::Invalid(format!("joint origin: the face `{n}` no longer exists (edit the joint to pick again)"))), + None => Ok(pick), + } +} + pub fn resolve_origin(doc: &Document, st: &ModelState, o: &JointOrigin) -> Result { let bad = || DocError::Invalid("joint origin: degenerate frame".into()); match &o.snap { Snap::Frame { origin, z, x } => frame(*origin, *z, *x).ok_or_else(bad), Snap::Point { point, z } => frame(*point, z.unwrap_or(Vec3::Z), None).ok_or_else(bad), - Snap::FaceCenter { pick } => { + Snap::FaceCenter { pick, face } => { + let pick = &named_pick(st, *pick, face.as_deref())?; let comp = component_of(doc, o.occurrence)?; let mut best: Option<(f64, Vec3, Vec3)> = None; for b in component_bodies(doc, st, comp) { @@ -246,7 +267,8 @@ pub fn resolve_origin(doc: &Document, st: &ModelState, o: &JointOrigin) -> Resul let (_, c, n) = best.ok_or_else(|| DocError::Invalid("joint origin: no planar face there".into()))?; frame(c, n, None).ok_or_else(bad) } - Snap::CircleCenter { pick } => { + Snap::CircleCenter { pick, face } => { + let pick = &named_pick(st, *pick, face.as_deref())?; let comp = component_of(doc, o.occurrence)?; let cyl = component_bodies(doc, st, comp) .into_iter() diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 38bbb006..6d1b0d3b 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -354,6 +354,16 @@ fn appearance(s: &mut Session, p: &Value) -> Result { if bodies.is_empty() && faces.is_empty() && comps.is_empty() { return Err(bad(cmd, "give `bodies`, `faces` or `components`")); } + // Persistent names of the picked faces. + let st_names: Vec<(String, solvecraft_geom::Vec3, Option)> = faces + .iter() + .map(|(b, pt)| { + let n = st.body(b).and_then(|mb| { + solvecraft_doc::appearance::face_index_at(mb, *pt).and_then(|i| solvecraft_doc::naming::face_names(mb).get(i).cloned()) + }); + (b.clone(), *pt, n) + }) + .collect(); let a = &mut s.doc_mut().appearances; for b in &bodies { match &look { @@ -368,7 +378,8 @@ fn appearance(s: &mut Session, p: &Value) -> Result { if a.faces.len() >= 100_000 { return Err(bad(cmd, "too many face appearances")); } - a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone() }); + let name = st_names.iter().find(|(b, p, _)| b == body && p.dist(*pt) < 1e-12).and_then(|(_, _, n)| n.clone()); + a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone(), name }); } } for c in &comps { diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index 5284f613..292091fa 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -131,8 +131,11 @@ fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result Snap::FaceCenter { pick: local(world) }, - "circle" => Snap::CircleCenter { pick: local(world) }, + "face" | "circle" => { + let pick = local(world); + let face = solvecraft_doc::naming::face_names_at(&s.model.state(), &[pick]).into_iter().next().filter(|n| !n.is_empty()); + if what == "face" { Snap::FaceCenter { pick, face } } else { Snap::CircleCenter { pick, face } } + } "point" => Snap::Point { point: local(world), z: v.get("z").and_then(vec3).map(local_dir) }, _ => { let f = v.get("frame").cloned().unwrap_or_default();