From 23e50e454020baf6dcbfb8b6354e575493afa63b Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 12:47:08 +0000 Subject: [PATCH] Persistent naming: shell inner faces named after the wall face they offset (F:inner:), sweep and loft faces by start/end caps and the profile curve each side sweeps Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/naming.rs | 83 +++++++++++++++++++++++++++++++++++++- crates/engine/src/tests.rs | 11 +++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/naming.rs b/crates/doc/src/naming.rs index 091adef2..bbf71318 100644 --- a/crates/doc/src/naming.rs +++ b/crates/doc/src/naming.rs @@ -226,7 +226,14 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec { .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) + let (ps, nn) = (pts.get(fi).map(Vec::as_slice).unwrap_or(&[]), ns.get(fi).unwrap_or(&empty)); + // A shell's inner face: the face its wall runs back to. + if matches!(o.feature.kind, FeatureKind::Shell { .. }) + && let Some(n) = behind(ps, nn, &inputs) + { + return format!("F{fid}:inner:{}", strip_piece(&n)); + } + role_name(&o.feature, &o.before, ps, nn, fi, fid) }) }) .collect(); @@ -234,6 +241,59 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec { names } +/// The input face a face looks back at through the material (along minus its normal): the +/// face a shell wall's inner side offsets. +fn behind(ps: &[Vec3], ns: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc>)]) -> Option { + let n = ns.first().copied()?; + let d = n * -1.0; + let mut votes: Vec<(String, usize)> = Vec::new(); + for p in ps { + let mut best: Option<(f64, String)> = None; + for (_, idx, nm) in inputs { + for (fi, (_, _, tris)) in idx.faces.iter().enumerate() { + for [a, b, c] in tris { + if let Some(t) = ray_hit(*p, d, *a, *b, *c) + && t > 1e-9 + && best.as_ref().is_none_or(|(bt, _)| t < *bt) + && let Some(name) = nm.get(fi) + { + best = Some((t, name.clone())); + } + } + } + } + if let Some((_, n)) = best { + match votes.iter_mut().find(|(v, _)| *v == n) { + Some(v) => v.1 += 1, + None => votes.push((n, 1)), + } + } + } + votes.sort_by(|a, b| b.1.cmp(&a.1)); + votes.first().filter(|(_, k)| *k * 2 >= ps.len().max(1)).map(|(n, _)| n.clone()) +} + +fn ray_hit(o: Vec3, d: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Option { + let (e1, e2) = (b - a, c - a); + let h = d.cross(e2); + let det = e1.dot(h); + if det.abs() < 1e-14 { + return None; + } + let f = 1.0 / det; + let s = o - a; + let u = f * s.dot(h); + if !(0.0..=1.0).contains(&u) { + return None; + } + let q = s.cross(e1); + let v = f * d.dot(q); + if v < 0.0 || u + v > 1.0 { + return None; + } + Some(f * e2.dot(q)) +} + /// 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 { @@ -382,6 +442,27 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi .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::Sweep { .. } | FeatureKind::Loft { .. } => { + let sk = match &f.kind { + FeatureKind::Sweep { sketch, .. } => Some(*sketch), + FeatureKind::Loft { sections, .. } => sections.first().map(|x| x.sketch), + _ => None, + }; + let Some(ss) = sk.and_then(|k| before.sketch(k)) else { return format!("F{fid}:face:{fi}") }; + let n = ss.plane.normal(); + let h = |p: &Vec3| (*p - ss.plane.origin).dot(n); + let flat = !ns.is_empty() && ns.windows(2).all(|w| w[0].cross(w[1]).len() < 1e-6); + if flat && ps.iter().all(|p| h(p).abs() < 1e-6 * (1.0 + p.len())) { + return format!("F{fid}:start"); + } + if flat && ns.iter().all(|m| m.cross(n).len() < 1e-6) { + return format!("F{fid}:end"); + } + // A side: the profile curve under its sample nearest the start. + let Some(p0) = ps.iter().min_by(|a, b| h(a).abs().total_cmp(&h(b).abs())) else { return format!("F{fid}:face:{fi}") }; + let q = ss.plane.to_local(*p0); + 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::Box { .. } => { let n = ns.first().copied().unwrap_or(Vec3::Z); let axis = |v: f64, p: &str, m: &str| { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 45864e88..b4bac4fb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1355,6 +1355,17 @@ mod naming { assert_eq!(sk.map(|z| (z * 1e6).round() / 1e6), Some(10.0)); } + #[test] + fn shell_inner_faces_are_named_after_the_walls() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 20, "body_name": "B"})); + run(&mut s, "FusionShellBodyCommand", json!({"faces": [[20, 15, 20]], "thickness": 2})); + let n = face_names(&mut s, "B"); + for want in ["F2:inner:F1:-z", "F2:inner:F1:+x", "F2:inner:F1:-x", "F2:inner:F1:+y", "F2:inner:F1:-y"] { + assert!(n.contains(&want.to_string()), "{want} in {n:?}"); + } + } + #[test] fn shell_and_hole_faces_follow_by_name() { let mut s = Session::default();