From 452ee5f9f9bba4042734b80131b749e5f9179e14 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 10:31:05 +0000 Subject: [PATCH] =?UTF-8?q?Persistent=20naming,=20step=202:=20revolved=20f?= =?UTF-8?q?aces=20named=20by=20the=20section=20curve=20they=20sweep=20and?= =?UTF-8?q?=20their=20angle=20caps,=20box=20faces=20by=20side=20(+x=20?= =?UTF-8?q?=E2=80=A6=20-z),=20cylinders=20by=20top/bottom/side,=20and=20pa?= =?UTF-8?q?ttern=20and=20mirror=20copies=20after=20their=20sources=20throu?= =?UTF-8?q?gh=20the=20instance=20transforms=20(@F.)?= 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/eval.rs | 16 +++- crates/doc/src/naming.rs | 146 ++++++++++++++++++++++++++++++++----- crates/engine/src/tests.rs | 30 ++++++++ 3 files changed, 169 insertions(+), 23 deletions(-) diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 1e975115..43ad1433 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -218,10 +218,10 @@ 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: &Arc, f: &Feature, key: u64) { + fn stamp(&mut self, before: &Arc, f: &Feature, key: u64, mats: Vec) { 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 }); + let origin = Arc::new(crate::naming::NamingOrigin { feature: f.clone(), before: before.clone(), depth, mats }); for b in &mut self.bodies { let kept = before.bodies.iter().find(|x| x.name == b.name && x.body.same(&b.body)); match kept { @@ -528,7 +528,15 @@ impl Model { let ms = t0.elapsed().as_secs_f64() * 1000.0; let error = match r { Ok(()) => { - next.stamp(&state, f, key); + // Copies are named after their sources through the instance transforms. + let mats = match &f.kind { + FeatureKind::Pattern { pattern, .. } => pattern_transforms(&vals, &state, pattern).unwrap_or_default(), + FeatureKind::Mirror { plane, .. } => { + doc.resolve_plane(&vals, plane, 0).map(|pl| vec![mirror_matrix(&pl)]).unwrap_or_default() + } + _ => Vec::new(), + }; + next.stamp(&state, f, key, mats); state = Arc::new(next); None } @@ -1007,7 +1015,7 @@ fn blend_target(state: &ModelState, body: &Option, edges: &[Vec3]) -> Re }) } -fn revolve_axis(ss: &SolvedSketch, axis: &AxisRef) -> Result<(Vec2, Vec2)> { +pub(crate) fn revolve_axis(ss: &SolvedSketch, axis: &AxisRef) -> Result<(Vec2, Vec2)> { match axis { AxisRef::SketchLine { curve } => { let ci = ss.sketch.curve_index(curve).ok_or_else(|| DocError::Unknown(format!("sketch line `{curve}`")))?; diff --git a/crates/doc/src/naming.rs b/crates/doc/src/naming.rs index d5c237ce..531bbfd2 100644 --- a/crates/doc/src/naming.rs +++ b/crates/doc/src/naming.rs @@ -19,6 +19,8 @@ pub struct NamingOrigin { pub feature: Feature, pub before: Arc, pub depth: usize, + /// Instance transforms of a pattern or mirror (its copies are named after their sources). + pub mats: Vec, } /// The lazily computed names of a body, shared by its clones. @@ -193,32 +195,70 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec { .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; - } + let mut names: Vec> = pts.iter().map(|ps| vote(ps, &inputs, tol, None)).collect(); + // A pattern or mirror copy: its faces lie on its source's faces moved by one instance. + let found = names.iter().filter(|n| n.is_some()).count(); + if !o.mats.is_empty() && found * 2 < nf.max(1) { + let all: Vec<(&ModelBody, FaceIndex, Arc>)> = o + .before + .bodies + .iter() + .filter(|x| !x.body.is_mesh()) + .take(64) + .map(|x| (x, FaceIndex::new(&x.mesh(), face_count(x)), face_names(x))) + .collect(); + let mut best: Option<(usize, usize, Vec>)> = None; + for (k, mat) in o.mats.iter().enumerate() { + let Some(inv) = crate::mat_inverse(mat) else { continue }; + let got: Vec> = pts.iter().map(|ps| vote(ps, &all, tol, Some(&inv))).collect(); + let n = got.iter().filter(|x| x.is_some()).count(); + if n > found && best.as_ref().is_none_or(|(bn, _, _)| n > *bn) { + best = Some((n, k, got)); } } - 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))); + if let Some((_, k, got)) = best { + names = got.into_iter().map(|n| n.map(|n| format!("{n}@F{fid}.{}", k + 1))).collect(); + } } + let empty = Vec::new(); + let mut names: Vec = names + .into_iter() + .enumerate() + .map(|(fi, n)| { + n.unwrap_or_else(|| { + role_name(&o.feature, &o.before, pts.get(fi).map(Vec::as_slice).unwrap_or(&[]), ns.get(fi).unwrap_or(&empty), fi, fid) + }) + }) + .collect(); number_pieces(&mut names, &pts, '#'); names } +/// The input face most of a face's sample points lie on (mapped by `map` first), if it holds +/// at least half of them. +fn vote(ps: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc>)], tol: f64, map: Option<&crate::Mat>) -> Option { + let mut votes: Vec<(String, usize)> = Vec::new(); + for p in ps { + let p = match map { + Some(m) => crate::apply_point(m, *p), + None => *p, + }; + 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))); + votes.first().filter(|(_, n)| *n * 2 >= ps.len().max(1)).map(|(n, _)| strip_piece(n).to_string()) +} + /// A face name without its piece number (`#k`). pub fn strip_piece(n: &str) -> &str { strip_mark(n, '#') @@ -317,6 +357,57 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi 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::Revolve { sketch, axis, .. } => { + let Some(ss) = before.sketch(*sketch) else { return format!("F{fid}:face:{fi}") }; + let Ok((o2, d2)) = crate::eval::revolve_axis(ss, axis) else { return format!("F{fid}:face:{fi}") }; + let (o, d) = (ss.plane.to_world(o2), (ss.plane.to_world(o2 + d2) - ss.plane.to_world(o2)).normalized().unwrap_or(Vec3::Z)); + // A flat face holding the axis is an angle cap: on the sketch plane, or the far one. + let flat = !ns.is_empty() && ns.windows(2).all(|w| w[0].cross(w[1]).len() < 1e-6) && ns.iter().all(|m| m.dot(d).abs() < 1e-6); + if flat { + let on_plane = ps.iter().all(|p| (*p - ss.plane.origin).dot(ss.plane.normal()).abs() < 1e-6 * (1.0 + p.len())); + return format!("F{fid}:{}", if on_plane { "start" } else { "end" }); + } + // A swept face: turn its centre back into the sketch plane and find the curve. + let q = c - o; + let t = q.dot(d); + let r = (q - d * t).len(); + let u3 = ss.plane.normal().cross(d).normalized().unwrap_or(Vec3::X); + let best = [u3, u3 * -1.0] + .iter() + .filter_map(|u| { + let w = ss.plane.to_local(o + d * t + *u * r); + let q2 = Vec2::new(w.x, w.y); + nearest_curve_dist(ss, q2) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)); + best.map(|(_, id)| format!("F{fid}:side:{id}")).unwrap_or_else(|| format!("F{fid}:face:{fi}")) + } + FeatureKind::Box { .. } => { + let n = ns.first().copied().unwrap_or(Vec3::Z); + let axis = |v: f64, p: &str, m: &str| { + if v > 0.5 { + Some(p.to_string()) + } else if v < -0.5 { + Some(m.to_string()) + } else { + None + } + }; + let role = + axis(n.x, "+x", "-x").or_else(|| axis(n.y, "+y", "-y")).or_else(|| axis(n.z, "+z", "-z")).unwrap_or_else(|| format!("face:{fi}")); + format!("F{fid}:{role}") + } + FeatureKind::Cylinder { axis, .. } => { + let flat = !ns.is_empty() && ns.iter().all(|m| m.cross(*axis).len() < 1e-6); + let role = if !flat { + "side" + } else if ns.first().is_some_and(|m| m.dot(*axis) > 0.0) { + "top" + } else { + "bottom" + }; + format!("F{fid}:{role}") + } 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) { @@ -328,6 +419,23 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi } } +/// The sketch curve (id) nearest a point in the sketch plane, and how near. +fn nearest_curve_dist(ss: &crate::SolvedSketch, q: Vec2) -> Option<(f64, String)> { + 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 +} + /// 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; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 151123ee..d86335fb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1266,4 +1266,34 @@ mod naming { let w = warning(&s, "Round").unwrap_or_default(); assert!(w.contains("no longer exists"), "{w}"); } + + fn face_names(s: &mut Session, body: &str) -> Vec { + let f = run(s, "model.faces", json!({ "body": body })); + f["faces"].as_array().into_iter().flatten().filter_map(|x| x["name"].as_str().map(str::to_string)).collect() + } + + #[test] + fn primitives_revolves_and_pattern_copies_are_named() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"})); + let n = face_names(&mut s, "B"); + for want in ["F1:+x", "F1:-x", "F1:+y", "F1:-y", "F1:+z", "F1:-z"] { + assert!(n.contains(&want.to_string()), "{want} in {n:?}"); + } + run(&mut s, "PatternRectangular", json!({"bodies": ["B"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 20})); + let bodies: Vec = s.model.state().bodies.iter().map(|b| b.name.clone()).collect(); + let copy = face_names(&mut s, &bodies[2]); + assert!(copy.iter().all(|x| x.starts_with("F1:") && x.contains("@F2.")), "{copy:?}"); + // A revolved ring: the section's four lines name the four faces. + let mut r = Session::default(); + run(&mut r, "SketchCreate", json!({"plane": "XZ"})); + run(&mut r, "DrawPolyline", json!({"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true, "ids": ["a", "b", "c", "d"]})); + run(&mut r, "SketchStop", json!({})); + run(&mut r, "Revolve", json!({"axis": "y", "angle": "270 deg"})); + let body = r.model.state().bodies[0].name.clone(); + let n = face_names(&mut r, &body); + for want in ["F2:side:a", "F2:side:b", "F2:side:c", "F2:side:d", "F2:start", "F2:end"] { + assert!(n.iter().any(|x| x.starts_with(want)), "{want} in {n:?}"); + } + } }