Persistent naming, step 2: revolved faces named by the section curve they sweep and their angle caps, box faces by side (+x … -z), cylinders by top/bottom/side, and pattern and mirror copies after their sources through the instance transforms (<source>@F<pattern>.<instance>)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 11:37:33 +00:00
1 parent 72297047d5
commit 452ee5f9f9
3 files changed
+169 -23

No files matched your search

+12 -4
View File
@@ -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<ModelState>, f: &Feature, key: u64) {
fn stamp(&mut self, before: &Arc<ModelState>, f: &Feature, key: u64, mats: Vec<Mat>) {
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<String>, 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}`")))?;
+127 -19
View File
@@ -19,6 +19,8 @@ pub struct NamingOrigin {
pub feature: Feature,
pub before: Arc<ModelState>,
pub depth: usize,
/// Instance transforms of a pattern or mirror (its copies are named after their sources).
pub mats: Vec<crate::Mat>,
}
/// The lazily computed names of a body, shared by its clones.
@@ -193,32 +195,70 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
.collect();
let fid = o.feature.id;
let ns = normals(&m, nf);
let mut names: Vec<String> = 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<Option<String>> = 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<Vec<String>>)> = 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<Option<String>>)> = None;
for (k, mat) in o.mats.iter().enumerate() {
let Some(inv) = crate::mat_inverse(mat) else { continue };
let got: Vec<Option<String>> = 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<String> = 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<Vec<String>>)], tol: f64, map: Option<&crate::Mat>) -> Option<String> {
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<String> {
let mut best: Option<(f64, String)> = None;
+30
View File
@@ -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<String> {
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<String> = 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:?}");
}
}
}