From 72297047d594401a6e84f1d693a1333acb81bac2 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 10:07:11 +0000 Subject: [PATCH] =?UTF-8?q?Persistent=20naming,=20step=201=20(plan/adr/000?= =?UTF-8?q?2):=20faces=20are=20named=20from=20their=20history=20(a=20face?= =?UTF-8?q?=20lying=20on=20an=20input=20face=20keeps=20its=20name;=20new?= =?UTF-8?q?=20faces=20by=20role:=20F:side:,=20:st?= =?UTF-8?q?art/:top/:bottom=20caps,=20:blend:,=20=E2=80=A6;=20split?= =?UTF-8?q?=20pieces=20#k=20along=20their=20spread),=20edges=20by=20the=20?= =?UTF-8?q?faces=20they=20separate=20(pieces=20~k);=20names=20worked=20out?= =?UTF-8?q?=20lazily=20per=20body=20and=20shared=20by=20its=20clones;=20fi?= =?UTF-8?q?llets=20and=20chamfers=20store=20edge=20names=20and=20resolve?= =?UTF-8?q?=20by=20name=20first=20(a=20split=20edge=20takes=20the=20piece?= =?UTF-8?q?=20nearest=20the=20picked=20point=20with=20a=20warning,=20a=20v?= =?UTF-8?q?anished=20one=20falls=20back=20to=20the=20point=20with=20a=20wa?= =?UTF-8?q?rning);=20model.faces=20and=20model.edges=20report=20names;=20t?= =?UTF-8?q?ests=20for=20resize,=20suppress,=20reorder,=20an=20upstream=20s?= =?UTF-8?q?plit=20and=20a=20removed=20sketch=20segment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/document.rs | 15 +- crates/doc/src/eval.rs | 77 +++++- crates/doc/src/lib.rs | 4 +- crates/doc/src/naming.rs | 427 ++++++++++++++++++++++++++++++ crates/engine/src/cmd/edit.rs | 4 +- crates/engine/src/cmd/features.rs | 12 +- crates/engine/src/cmd/inspect.rs | 6 +- crates/engine/src/tests.rs | 107 +++++++- 8 files changed, 628 insertions(+), 24 deletions(-) create mode 100644 crates/doc/src/naming.rs diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index bf736245..ab198abe 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1015,6 +1015,9 @@ pub struct Feature { /// is found again when earlier edits move or resize the body. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub edge_refs: Vec, + /// Persistent names of the picked edges (resolved first; see `naming`). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub edge_names: Vec, #[serde(flatten)] pub kind: FeatureKind, } @@ -1275,7 +1278,17 @@ impl Document { Some(n) if !n.trim().is_empty() => n.trim().to_string(), _ => self.unique_name(kind.base_name()), }; - let f = Feature { id, name, suppressed: false, body_names: Vec::new(), param_names: Vec::new(), component: 0, edge_refs: Vec::new(), kind }; + let f = Feature { + id, + name, + suppressed: false, + body_names: Vec::new(), + param_names: Vec::new(), + component: 0, + edge_refs: Vec::new(), + edge_names: Vec::new(), + kind, + }; match self.marker { Some(m) if m < self.features.len() => { self.features.insert(m, f); diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index adc68f87..1e975115 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -23,11 +23,13 @@ pub struct ModelBody { /// Identity of this body's content for the evaluation cache: the key of the evaluation /// that produced it (kept while later features pass it through unchanged). key: u64, + /// Persistent face names (worked out on first use). + pub(crate) names: crate::naming::NameCell, } impl ModelBody { pub fn new(name: String, body: Body, feature: u64) -> Self { - ModelBody { name, body, feature, mesh: OnceLock::new(), key: 0 } + ModelBody { name, body, feature, mesh: OnceLock::new(), key: 0, names: Default::default() } } /// Display mesh (chord tolerance 1/1000 of the body size), computed once. pub fn mesh(&self) -> Arc { @@ -216,14 +218,24 @@ impl ModelState { /// After a feature evaluated to this state from `before` with cache key `key`: bodies it /// made or changed, and sketches it made, take that key; the rest keep theirs. - fn stamp(&mut self, before: &ModelState, feature: u64, key: u64) { + fn stamp(&mut self, before: &Arc, f: &Feature, key: u64) { + let feature = f.id; + let depth = before.bodies.iter().filter_map(|b| b.names.origin.as_ref().map(|o| o.depth)).max().unwrap_or(0) + 1; + let origin = Arc::new(crate::naming::NamingOrigin { feature: f.clone(), before: before.clone(), depth }); for b in &mut self.bodies { - let kept = before.bodies.iter().find(|x| x.name == b.name && x.body.same(&b.body)).map(|x| x.key); - b.key = kept.unwrap_or_else(|| { - let mut h = std::collections::hash_map::DefaultHasher::new(); - (key, &b.name).hash(&mut h); - h.finish() - }); + let kept = before.bodies.iter().find(|x| x.name == b.name && x.body.same(&b.body)); + match kept { + Some(x) => { + b.key = x.key; + b.names = x.names.clone(); + } + None => { + let mut h = std::collections::hash_map::DefaultHasher::new(); + (key, &b.name).hash(&mut h); + b.key = h.finish(); + b.names = crate::naming::NameCell::new(Some(origin.clone())); + } + } } if self.sketches.iter().any(|s| s.feature == feature) { self.sketch_keys.insert(feature, key); @@ -516,7 +528,7 @@ impl Model { let ms = t0.elapsed().as_secs_f64() * 1000.0; let error = match r { Ok(()) => { - next.stamp(&state, f.id, key); + next.stamp(&state, f, key); state = Arc::new(next); None } @@ -902,6 +914,14 @@ pub fn edge_refs(st: &ModelState, body: &Option, pts: &[Vec3]) -> Vec, pts: &[Vec3]) -> Vec { + match blend_target(st, body, pts).ok().and_then(|i| st.bodies.get(i)) { + Some(b) => crate::naming::edge_names_at(b, pts), + None => Vec::new(), + } +} + /// Edge reference points re-found on the body: points on an edge stay; a point that moved off /// (an upstream edit) is carried with the body (its place in the body's bounding box then and /// now) to an edge running the same way; failing that it goes to the nearest edge with a @@ -1919,7 +1939,44 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: let r = val(vals, radius, Kind::Length)?; let ti = blend_target(st, body, edges)?; let Some(mb) = st.bodies.get(ti) else { return Err(DocError::Invalid("body".into())) }; - let edges = resolve_edges(&mb.body, edges, &f.edge_refs, warning)?; + let edges = if f.edge_names.iter().any(|n| !n.is_empty()) { + // By name first; a name that is gone falls back to the point, with a warning. + let (mut out, mut notes) = (Vec::new(), Vec::new()); + for (i, p) in edges.iter().enumerate() { + match f.edge_names.get(i).filter(|n| !n.is_empty()).map(|n| (n, crate::naming::find_edge(mb, n, *p))) { + Some((_, Ok((q, false)))) => out.push(q), + Some((n, Ok((q, true)))) => { + notes.push(format!("edge `{n}` was split; using the piece nearest the picked point")); + out.push(q); + } + Some((_, Err(_))) | None => { + let mut w = None; + let refs: Vec = f.edge_refs.get(i).cloned().into_iter().collect(); + if let Ok(q) = resolve_edges(&mb.body, &[*p], &refs, &mut w) { + out.extend(q); + notes.push(match f.edge_names.get(i) { + Some(n) if !n.is_empty() => format!("edge `{n}` no longer exists; using the edge at the picked point"), + _ => "an edge reference without a name was found by its point".into(), + }); + } else { + notes.push(format!( + "edge `{}` no longer exists and was skipped", + f.edge_names.get(i).map(String::as_str).unwrap_or("?") + )); + } + } + } + } + if out.is_empty() { + return Err(DocError::Invalid("none of the selected edges exist any more".into())); + } + if !notes.is_empty() { + *warning = Some(notes.join("; ")); + } + out + } else { + resolve_edges(&mb.body, edges, &f.edge_refs, warning)? + }; let nb = if matches!(f.kind, FeatureKind::Fillet { .. }) { kernel::fillet(&mb.body, &edges, r)? } else { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index fee92956..067f6a65 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -16,6 +16,7 @@ pub mod expr; pub mod format; pub mod joints; pub mod motion; +pub mod naming; mod params; pub mod plastic; pub mod project; @@ -26,7 +27,8 @@ pub mod sheet; pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, mat_inverse, mat_mul, rigid}; pub use document::*; pub use eval::{ - EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_refs, parse_metric_thread, pattern_matrices, world_state, + EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices, + world_state, }; pub use params::{ParamRow, ParamUser, cycles}; pub use solvecraft_kernel as kernel; diff --git a/crates/doc/src/naming.rs b/crates/doc/src/naming.rs new file mode 100644 index 00000000..d5c237ce --- /dev/null +++ b/crates/doc/src/naming.rs @@ -0,0 +1,427 @@ +//! Persistent names of faces and edges (plan/adr/0002-persistent-naming.md). +//! +//! Every face of an evaluated body is named after how it was made: a face that lies on a face +//! of the feature's input keeps that face's name; a face new to the feature is named by its role +//! (`F:side:`, `F:start`/`end`, `F:blend:`, …). +//! Pieces of a split face get `#1`, `#2`, … along the longest axis. Edges are named by the faces +//! they separate. Names are worked out lazily, on first request, from the body, the feature +//! that made it and the model before that feature. + +use std::sync::{Arc, OnceLock}; + +use solvecraft_geom::{Mesh, Seg2, Vec2, Vec3}; + +use crate::{Feature, FeatureKind, ModelBody, ModelState}; + +/// How a body came to be: the feature and the model just before it. +#[derive(Debug)] +pub struct NamingOrigin { + pub feature: Feature, + pub before: Arc, + pub depth: usize, +} + +/// The lazily computed names of a body, shared by its clones. +#[derive(Clone, Debug, Default)] +pub struct NameCell { + pub origin: Option>, + names: Arc>>>, +} + +impl NameCell { + pub fn new(origin: Option>) -> Self { + NameCell { origin, names: Arc::new(OnceLock::new()) } + } +} + +/// Deeper chains than this are named by position only (bounded work and stack). +const MAX_DEPTH: usize = 400; + +/// Up to this many sample points per face. +const SAMPLES: usize = 9; + +/// Unit normals of each face's triangles. +fn normals(m: &Mesh, nf: usize) -> Vec> { + let mut per: Vec> = vec![Vec::new(); nf]; + for (t, f) in m.triangles.iter().zip(&m.tri_face) { + if let (Some(v), Some([a, b, c])) = (per.get_mut(*f as usize), m.tri(t)) + && let Some(n) = (b - a).cross(c - a).normalized() + { + v.push(n); + } + } + per +} + +/// Triangle centroids spread over each face: face → points. +fn samples(m: &Mesh, nf: usize) -> Vec> { + let mut per: Vec> = vec![Vec::new(); nf]; + for (t, f) in m.triangles.iter().zip(&m.tri_face) { + if let (Some(v), Some([a, b, c])) = (per.get_mut(*f as usize), m.tri(t)) { + v.push((a + b + c) * (1.0 / 3.0)); + } + } + per.into_iter() + .map(|v| { + if v.len() <= SAMPLES { + return v; + } + let step = v.len() as f64 / SAMPLES as f64; + (0..SAMPLES).filter_map(|i| v.get((i as f64 * step) as usize).copied()).collect() + }) + .collect() +} + +fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 { + let (ab, ac, ap) = (b - a, c - a, p - a); + let (d1, d2) = (ab.dot(ap), ac.dot(ap)); + if d1 <= 0.0 && d2 <= 0.0 { + return a; + } + let bp = p - b; + let (d3, d4) = (ab.dot(bp), ac.dot(bp)); + if d3 >= 0.0 && d4 <= d3 { + return b; + } + let vc = d1 * d4 - d3 * d2; + if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 { + return a + ab * (d1 / (d1 - d3)); + } + let cp = p - c; + let (d5, d6) = (ab.dot(cp), ac.dot(cp)); + if d6 >= 0.0 && d5 <= d6 { + return c; + } + let vb = d5 * d2 - d1 * d6; + if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 { + return a + ac * (d2 / (d2 - d6)); + } + let va = d3 * d6 - d5 * d4; + if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 { + return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6))); + } + let den = va + vb + vc; + if den.abs() < 1e-300 { + return a; + } + a + ab * (vb / den) + ac * (vc / den) +} + +/// A body's faces as triangle lists with bounding boxes, for "does this point lie on face f". +struct FaceIndex { + faces: Vec<(Vec3, Vec3, Vec<[Vec3; 3]>)>, +} + +impl FaceIndex { + fn new(m: &Mesh, nf: usize) -> FaceIndex { + let mut faces: Vec<(Vec3, Vec3, Vec<[Vec3; 3]>)> = + vec![(Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN), Vec::new()); nf]; + for (t, f) in m.triangles.iter().zip(&m.tri_face) { + if let (Some(slot), Some(tri)) = (faces.get_mut(*f as usize), m.tri(t)) { + for p in tri { + slot.0 = Vec3::new(slot.0.x.min(p.x), slot.0.y.min(p.y), slot.0.z.min(p.z)); + slot.1 = Vec3::new(slot.1.x.max(p.x), slot.1.y.max(p.y), slot.1.z.max(p.z)); + } + slot.2.push(tri); + } + } + FaceIndex { faces } + } + + /// The face a point lies on (within `tol`). + fn face_at(&self, p: Vec3, tol: f64) -> Option { + let mut best: Option<(f64, usize)> = None; + for (i, (lo, hi, tris)) in self.faces.iter().enumerate() { + if p.x < lo.x - tol || p.y < lo.y - tol || p.z < lo.z - tol || p.x > hi.x + tol || p.y > hi.y + tol || p.z > hi.z + tol { + continue; + } + let d = tris.iter().map(|[a, b, c]| closest(p, *a, *b, *c).dist(p)).fold(f64::MAX, f64::min); + if d <= tol && best.is_none_or(|(bd, _)| d < bd) { + best = Some((d, i)); + } + } + best.map(|(_, i)| i) + } +} + +fn face_count(b: &ModelBody) -> usize { + b.mesh().tri_face.iter().map(|f| *f as usize + 1).max().unwrap_or(0) +} + +/// Names of a body's faces (by B-rep face index). +pub fn face_names(b: &ModelBody) -> Arc> { + b.names + .names + .get_or_init(|| { + let nf = face_count(b); + let names = match b.names.origin.as_ref() { + Some(o) if o.depth < MAX_DEPTH => derive(b, o, nf), + _ => (0..nf).map(|k| format!("{}:face:{k}", b.name)).collect(), + }; + Arc::new(names) + }) + .clone() +} + +/// Work out a body's face names from the feature that made it and the model before it. +fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec { + let m = b.mesh(); + let size = b.body.size().max(1e-6); + let tol = (size * 1e-4).max(1e-6); + let pts = samples(&m, nf); + let bb = m.bounds(); + // Inputs that overlap this body. + let overlaps = |x: &ModelBody| { + let xb = x.mesh().bounds(); + !(xb.max.x < bb.min.x - tol + || xb.min.x > bb.max.x + tol + || xb.max.y < bb.min.y - tol + || xb.min.y > bb.max.y + tol + || xb.max.z < bb.min.z - tol + || xb.min.z > bb.max.z + tol) + }; + let inputs: Vec<(&ModelBody, FaceIndex, Arc>)> = o + .before + .bodies + .iter() + .filter(|x| !x.body.is_mesh() && overlaps(x)) + .take(64) + .map(|x| { + let n = face_count(x); + (x, FaceIndex::new(&x.mesh(), n), face_names(x)) + }) + .collect(); + let fid = o.feature.id; + let ns = normals(&m, nf); + let mut names: Vec = Vec::with_capacity(nf); + for (fi, ps) in pts.iter().enumerate() { + // Vote: the input face most samples lie on, if it holds at least half of them. + let mut votes: Vec<(String, usize)> = Vec::new(); + for p in ps { + for (_, idx, nm) in &inputs { + if let Some(f) = idx.face_at(*p, tol) + && let Some(n) = nm.get(f) + { + match votes.iter_mut().find(|(v, _)| v == n) { + Some(v) => v.1 += 1, + None => votes.push((n.clone(), 1)), + } + break; + } + } + } + votes.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + let inherited = votes.first().filter(|(_, n)| *n * 2 >= ps.len().max(1)).map(|(n, _)| strip_piece(n).to_string()); + let empty = Vec::new(); + names.push(inherited.unwrap_or_else(|| role_name(&o.feature, &o.before, ps, ns.get(fi).unwrap_or(&empty), fi, fid))); + } + number_pieces(&mut names, &pts, '#'); + names +} + +/// A face name without its piece number (`#k`). +pub fn strip_piece(n: &str) -> &str { + strip_mark(n, '#') +} + +/// An edge name without piece numbers: its own (`~k`) and its faces' (`#k`), so pieces of a +/// split edge, or of an edge between split faces, match. +pub fn strip_edge_piece(n: &str) -> String { + let n = strip_mark(n, '~'); + let mut out = String::with_capacity(n.len()); + let mut it = n.chars().peekable(); + while let Some(c) = it.next() { + if c == '#' && it.peek().is_some_and(|d| d.is_ascii_digit()) { + while it.peek().is_some_and(|d| d.is_ascii_digit()) { + it.next(); + } + continue; + } + out.push(c); + } + out +} + +fn strip_mark(n: &str, mark: char) -> &str { + match n.rfind(mark) { + Some(i) if n[i + 1..].chars().all(|c| c.is_ascii_digit()) && i + 1 < n.len() => &n[..i], + _ => n, + } +} + +/// Faces sharing a name are pieces of one: number them along the longest axis of their spread. +fn number_pieces(names: &mut [String], pts: &[Vec], mark: char) { + let mut seen: Vec = Vec::new(); + for i in 0..names.len() { + let n = names[i].clone(); + if seen.contains(&n) { + continue; + } + seen.push(n.clone()); + let same: Vec = (0..names.len()).filter(|j| names[*j] == n).collect(); + if same.len() < 2 { + continue; + } + let centre = |j: usize| { + let ps = pts.get(j).cloned().unwrap_or_default(); + ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64) + }; + let cs: Vec<(usize, Vec3)> = same.iter().map(|j| (*j, centre(*j))).collect(); + let (lo, hi) = cs.iter().fold((Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)), |(l, h), (_, c)| { + (Vec3::new(l.x.min(c.x), l.y.min(c.y), l.z.min(c.z)), Vec3::new(h.x.max(c.x), h.y.max(c.y), h.z.max(c.z))) + }); + let ext = hi - lo; + let key = |c: Vec3| { + if ext.x >= ext.y && ext.x >= ext.z { + (c.x, c.y, c.z) + } else if ext.y >= ext.z { + (c.y, c.z, c.x) + } else { + (c.z, c.x, c.y) + } + }; + let mut order = cs.clone(); + order.sort_by(|a, b| key(a.1).partial_cmp(&key(b.1)).unwrap_or(std::cmp::Ordering::Equal)); + for (k, (j, _)) in order.iter().enumerate() { + if let Some(slot) = names.get_mut(*j) { + *slot = format!("{n}{mark}{}", k + 1); + } + } + } +} + +/// The name of a face new to `f`. +fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usize, fid: u64) -> String { + let c = ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64); + match &f.kind { + FeatureKind::Extrude { sketch, .. } => { + let Some(ss) = before.sketch(*sketch) else { return format!("F{fid}:face:{fi}") }; + let n = ss.plane.normal(); + // A face across the extrusion is a cap; others sweep a sketch curve. + let flat = !ns.is_empty() && ns.iter().all(|m| m.cross(n).len() < 1e-6); + if flat { + // On the sketch plane, above it or below it. + let h = (c - ss.plane.origin).dot(n); + let tol = 1e-6 * (1.0 + h.abs()); + return format!( + "F{fid}:{}", + if h.abs() <= tol { + "start" + } else if h > 0.0 { + "top" + } else { + "bottom" + } + ); + } + let q = ss.plane.to_local(c); + nearest_curve(ss, Vec2::new(q.x, q.y)).map(|id| format!("F{fid}:side:{id}")).unwrap_or_else(|| format!("F{fid}:face:{fi}")) + } + FeatureKind::Fillet { edges, .. } | FeatureKind::Chamfer { edges, .. } => { + let k = edges.iter().enumerate().min_by(|a, b| a.1.dist(c).total_cmp(&b.1.dist(c))).map(|(k, _)| k).unwrap_or(0); + match f.edge_names.get(k) { + Some(e) => format!("F{fid}:blend:{e}"), + None => format!("F{fid}:blend:{k}"), + } + } + _ => format!("F{fid}:face:{fi}"), + } +} + +/// The sketch curve (id) nearest a point in the sketch plane. +fn nearest_curve(ss: &crate::SolvedSketch, q: Vec2) -> Option { + let mut best: Option<(f64, String)> = None; + for (ci, c) in ss.sketch.curves.iter().enumerate() { + if c.construction { + continue; + } + for s in ss.sketch.segs(ci) { + let d = seg_dist(&s, q); + if best.as_ref().is_none_or(|(bd, _)| d < *bd) { + best = Some((d, c.id.clone())); + } + } + } + best.map(|(_, id)| id) +} + +fn seg_dist(s: &Seg2, q: Vec2) -> f64 { + let pts = s.polyline(1e-3); + pts.windows(2) + .map(|w| { + let (a, b) = (w[0], w[1]); + let d = b - a; + let t = if d.len2() > 0.0 { ((q - a).dot(d) / d.len2()).clamp(0.0, 1.0) } else { 0.0 }; + (a + d * t).dist(q) + }) + .fold(f64::MAX, f64::min) +} + +/// Names of a body's edges (by mesh edge index): `|`, numbered when repeated. +pub fn edge_names(b: &ModelBody) -> Vec { + let m = b.mesh(); + let faces = face_names(b); + let mut names: Vec = m + .edge_faces + .iter() + .map(|fs| { + let mut n: Vec<&str> = fs.iter().filter_map(|f| faces.get(*f as usize).map(String::as_str)).collect(); + n.sort(); + n.join("|") + }) + .collect(); + let pts: Vec> = m.edges.clone(); + number_pieces(&mut names, &pts, '~'); + names +} + +/// The edge of a body with this name: exact, else the pieces of the same edge nearest `near` +/// (`Err` explains when there is none). +pub fn find_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<(Vec3, bool), String> { + let m = b.mesh(); + let names = edge_names(b); + let mid = |i: usize| -> Option { + let e = m.edges.get(i)?; + let total: f64 = e.windows(2).map(|w| w[0].dist(w[1])).sum(); + let mut acc = 0.0; + for w in e.windows(2) { + let l = w[0].dist(w[1]); + if acc + l >= total / 2.0 { + let t = if l > 0.0 { (total / 2.0 - acc) / l } else { 0.0 }; + return Some(w[0] + (w[1] - w[0]) * t); + } + acc += l; + } + e.first().copied() + }; + if let Some(i) = names.iter().position(|n| n == name) { + // Keep the picked point when it is on that edge. + let on = m.edges.get(i).is_some_and(|e| e.windows(2).any(|w| near.dist_to_segment(w[0], w[1]) < (b.body.size() * 2e-3).max(1e-3))); + return mid(i).map(|p| (if on { near } else { p }, false)).ok_or_else(|| "edge".into()); + } + let stem = strip_edge_piece(name); + let cands: Vec = (0..names.len()).filter(|i| names.get(*i).is_some_and(|n| strip_edge_piece(n) == stem)).collect(); + cands + .iter() + .filter_map(|i| mid(*i)) + .min_by(|a, b| a.dist(near).total_cmp(&b.dist(near))) + .map(|p| (p, true)) + .ok_or_else(|| format!("edge `{name}` no longer exists")) +} + +/// Names of the edges nearest each point on a body (for storing references). +pub fn edge_names_at(b: &ModelBody, pts: &[Vec3]) -> Vec { + let m = b.mesh(); + let names = edge_names(b); + pts.iter() + .map(|p| { + m.edges + .iter() + .enumerate() + .map(|(i, e)| (e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::MAX, f64::min), i)) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .and_then(|(_, i)| names.get(i).cloned()) + .unwrap_or_default() + }) + .collect() +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 5675c224..b50a70a4 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -189,7 +189,9 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result { ) = (&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; diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index fb76fa18..b55602d8 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -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; diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 652a0510..38bbb006 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -228,11 +228,12 @@ fn model_edges(s: &mut Session, p: &Value) -> Result { 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 = 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 { fn model_faces(s: &mut Session, p: &Value) -> Result { 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 = 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})) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7a070755..151123ee 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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 { + 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 = 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}"); + } +}