Persistent face names: keep exact history when a prism boolean meets stacked pieces of one cylinder (#1209) (#2205)
* claim: #1209 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011YdMi9BDdjWZ8twfeBHAXz * Prism boolean: a curved edge at one level crosses the walls there only (#1209) The prism boolean sews its result slab by slab and keeps, for each section segment, the operand wall it was read beside; its history is built from those tags. `Stations::new` took every curved edge between two walls as crossing them at every level, so where two pieces of one cylinder are stacked (an arm's round end under a boss of that radius) the circle between them cleared the wall tags of every slab. `prism_lineage::sewn` then gave up, the join or cut came out with no history, and every face of the body was named from geometry (`exact: false`). A curve whose control points lie at one level (a B-spline, or a NURBS curve with positive weights, which lies in their convex hull) is at that level: it crosses the walls there only. Any other curve still spans every level. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011YdMi9BDdjWZ8twfeBHAXz --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
This commit is contained in:
3 files changed
+136
-3
No files matched your search
@@ -299,3 +299,74 @@ fn a_shell_fillet_does_not_follow_a_gone_edge_onto_a_smooth_seam() {
|
|||||||
assert!(message.contains("no longer exists"), "{message}");
|
assert!(message.contains("no longer exists"), "{message}");
|
||||||
assert!(!message.contains("smooth edge"), "{message}");
|
assert!(!message.contains("smooth edge"), "{message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The faces `model.faces` lists for `body`: name, whether it is exact, and centroid.
|
||||||
|
fn listed(s: &mut Session, body: &str) -> Vec<(String, bool, [f64; 3])> {
|
||||||
|
let v = run(s, "model.faces", json!({"body": body}));
|
||||||
|
let faces = v["faces"].as_array().unwrap_or_else(|| panic!("faces of {body}: {v}"));
|
||||||
|
faces
|
||||||
|
.iter()
|
||||||
|
.map(|f| {
|
||||||
|
let c = |k: usize| f["centroid"][k].as_f64().unwrap_or(f64::NAN);
|
||||||
|
(f["name"].as_str().unwrap_or_default().to_string(), f["exact"].as_bool() == Some(true), [c(0), c(1), c(2)])
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The face of `faces` whose centroid is within 0.05 of `at`.
|
||||||
|
fn named_at(faces: &[(String, bool, [f64; 3])], at: [f64; 3]) -> String {
|
||||||
|
let near = |c: &[f64; 3]| (0..3).all(|k| (c[k] - at[k]).abs() < 0.05);
|
||||||
|
let found: Vec<&String> = faces.iter().filter(|(_, _, c)| near(c)).map(|(n, _, _)| n).collect();
|
||||||
|
match found.as_slice() {
|
||||||
|
[n] => (*n).clone(),
|
||||||
|
_ => panic!("faces at {at:?}: {found:?} in {faces:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #1209: an arm with a boss at each end (each boss the radius of the arm's round end under it)
|
||||||
|
/// and a square cut through one. The second boss and the cut are prism booleans sewn slab by
|
||||||
|
/// slab, where the arm's round end and the boss over it are two pieces of one cylinder: every
|
||||||
|
/// face keeps exact history, so the cut's walls are its own four faces and the boss's outer
|
||||||
|
/// cylinder keeps its name, through the fillets on the bore's corners too.
|
||||||
|
#[test]
|
||||||
|
fn a_cut_through_one_of_two_bosses_names_only_its_own_walls() {
|
||||||
|
let mut s = Session::default();
|
||||||
|
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||||
|
let (lo0, lo1) = ([16.54527384497186, -18.318724018148217], [159.66891048662592, -12.425397803491872]);
|
||||||
|
let (hi0, hi1) = ([159.66891048662592, 12.425397803491872], [16.54527384497186, 18.318724018148217]);
|
||||||
|
let (j0, j1) = ([162.79697152270225, -13.15736982664994], [162.79697152270225, 13.15736982664994]);
|
||||||
|
let (k0, k1) = ([11.818299035314524, 19.73139143375053], [11.818299035314524, -19.73139143375053]);
|
||||||
|
run(&mut s, "sketch.line", json!({"points": [lo0, lo1]}));
|
||||||
|
run(&mut s, "sketch.arc.center_point", json!({"center": [159.91576013178314, -18.420317757309917], "start": j0, "end": lo1}));
|
||||||
|
run(&mut s, "sketch.arc.center_point", json!({"center": [170.0, 0], "start": j0, "end": j1}));
|
||||||
|
run(&mut s, "sketch.arc.center_point", json!({"center": [159.91576013178314, 18.420317757309917], "start": hi0, "end": j1}));
|
||||||
|
run(&mut s, "sketch.line", json!({"points": [hi0, hi1]}));
|
||||||
|
run(&mut s, "sketch.arc.center_point", json!({"center": [16.95668992023388, 28.310257274511628], "start": k0, "end": hi1}));
|
||||||
|
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": k0, "end": k1}));
|
||||||
|
run(&mut s, "sketch.arc.center_point", json!({"center": [16.95668992023388, -28.310257274511628], "start": lo0, "end": k1}));
|
||||||
|
run(&mut s, "sketch.finish", json!({}));
|
||||||
|
run(&mut s, "solid.extrude", json!({"distance": 16, "body_name": "Arm"}));
|
||||||
|
run(&mut s, "solid.cylinder", json!({"diameter": 46.0, "height": 22, "base": [0, 0, 0], "operation": "join", "targets": ["Arm"]}));
|
||||||
|
let boss = named_at(&listed(&mut s, "Arm"), [19.98, -4.24, 19.0]);
|
||||||
|
assert!(boss.starts_with("F3:side"), "{boss}");
|
||||||
|
run(&mut s, "solid.cylinder", json!({"diameter": 30.0, "height": 20, "base": [170.0, 0, 0], "operation": "join", "targets": ["Arm"]}));
|
||||||
|
let joined = listed(&mut s, "Arm");
|
||||||
|
assert!(joined.iter().all(|(_, exact, _)| *exact), "{joined:?}");
|
||||||
|
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||||
|
run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": [6.35, 6.35]}));
|
||||||
|
run(&mut s, "sketch.finish", json!({}));
|
||||||
|
run(&mut s, "solid.extrude", json!({"through_all": true, "operation": "cut", "targets": ["Arm"]}));
|
||||||
|
let cut = listed(&mut s, "Arm");
|
||||||
|
assert!(cut.iter().all(|(_, exact, _)| *exact), "{cut:?}");
|
||||||
|
let walls: Vec<&String> = cut.iter().map(|(n, _, _)| n).filter(|n| n.starts_with("F6:")).collect();
|
||||||
|
assert_eq!(walls.len(), 4, "{cut:?}");
|
||||||
|
for (at, line) in [([0.0, -6.35, 11.0], "l1"), ([6.35, 0.0, 11.0], "l2"), ([0.0, 6.35, 11.0], "l3"), ([-6.35, 0.0, 11.0], "l4")] {
|
||||||
|
assert_eq!(named_at(&cut, at), format!("F6:side:{line}"));
|
||||||
|
}
|
||||||
|
assert_eq!(named_at(&cut, [19.98, -4.24, 19.0]), boss);
|
||||||
|
run(&mut s, "solid.fillet", json!({"edges": [[6.35, 6.35, 10], [-6.35, 6.35, 10], [-6.35, -6.35, 10], [6.35, -6.35, 10]], "radius": 1.5}));
|
||||||
|
let filleted = listed(&mut s, "Arm");
|
||||||
|
let blends = filleted.iter().filter(|(n, _, _)| n.starts_with("F7:blend:")).count();
|
||||||
|
assert_eq!(blends, 4, "{filleted:?}");
|
||||||
|
assert_eq!(named_at(&filleted, [19.98, -4.24, 19.0]), boss);
|
||||||
|
}
|
||||||
@@ -501,3 +501,26 @@ fn bodies_apart_joined_keep_their_faces() {
|
|||||||
}
|
}
|
||||||
assert!(faces_of(h).iter().all(|(change, origins)| *change == Change::Kept && origins.len() == 1), "{}", show(h));
|
assert!(faces_of(h).iter().all(|(change, origins)| *change == Change::Kept && origins.len() == 1), "{}", show(h));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A plate with a round end and a boss of that radius standing on it (#1209): the boss's wall
|
||||||
|
/// and the plate's round end are two pieces of one cylinder, one over the other, meeting along a
|
||||||
|
/// circle at the plate's top. That circle crosses the walls at its level only, so a second boss
|
||||||
|
/// joined to the plate and a cut through the first, both prism booleans sewn slab by slab, keep
|
||||||
|
/// every face of the plate and its boss, with their history.
|
||||||
|
#[test]
|
||||||
|
fn stacked_pieces_of_one_cylinder_keep_their_walls() {
|
||||||
|
let plate =
|
||||||
|
boolean(&boxed(0.0, -6.0, 0.0, 40.0, 6.0, 5.0), &crate::cylinder(Vec3::ZERO, Vec3::Z, 10.0, 5.0).unwrap(), BoolOp::Union).unwrap().unwrap();
|
||||||
|
let arm = boolean(&plate, &crate::cylinder(Vec3::ZERO, Vec3::Z, 10.0, 8.0).unwrap(), BoolOp::Union).unwrap().unwrap();
|
||||||
|
verified(&arm, &[&plate, &crate::cylinder(Vec3::ZERO, Vec3::Z, 10.0, 8.0).unwrap()], 0.01);
|
||||||
|
// Standing over the plate's far end, as the boss at the arm's other end does.
|
||||||
|
let second = crate::cylinder(Vec3::new(40.0, 0.0, 0.0), Vec3::Z, 4.0, 7.0).unwrap();
|
||||||
|
let bore = boxed(-3.0, -3.0, -1.0, 3.0, 3.0, 10.0);
|
||||||
|
for (tool, op) in [(&second, BoolOp::Union), (&bore, BoolOp::Cut)] {
|
||||||
|
let r = boolean(&arm, tool, op).unwrap().unwrap();
|
||||||
|
let h = verified(&r, &[&arm, tool], 0.01);
|
||||||
|
for f in 0..arm.face_count() {
|
||||||
|
assert!(!from(h, face(0, f)).is_empty(), "{op:?}: face {f} of the plate and boss is lost\n{}", show(h));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -435,6 +435,9 @@ pub(crate) struct Index {
|
|||||||
points: Vec<Vec3>,
|
points: Vec<Vec3>,
|
||||||
/// Per edge (edge order): whether it is a line.
|
/// Per edge (edge order): whether it is a line.
|
||||||
lines: Vec<bool>,
|
lines: Vec<bool>,
|
||||||
|
/// Per edge (edge order) that is not a line: the points its curve lies within (see
|
||||||
|
/// [`control_hull`]); `None` where they are not known.
|
||||||
|
hulls: Vec<Option<Vec<Vec3>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Index {
|
impl Index {
|
||||||
@@ -451,6 +454,7 @@ impl Index {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let lines = edges.iter().map(|e| matches!(e.curve(), mt::Curve::Line(_))).collect();
|
let lines = edges.iter().map(|e| matches!(e.curve(), mt::Curve::Line(_))).collect();
|
||||||
|
let hulls = edges.iter().map(|e| control_hull(&e.curve())).collect();
|
||||||
let mut between: std::collections::HashMap<(usize, usize), Vec<usize>> = std::collections::HashMap::new();
|
let mut between: std::collections::HashMap<(usize, usize), Vec<usize>> = std::collections::HashMap::new();
|
||||||
for (i, fs) in bounds.iter().enumerate() {
|
for (i, fs) in bounds.iter().enumerate() {
|
||||||
match fs.as_slice() {
|
match fs.as_slice() {
|
||||||
@@ -464,7 +468,7 @@ impl Index {
|
|||||||
.zip(bounds)
|
.zip(bounds)
|
||||||
.map(|(e, fs)| (fs, vertices.get(&e.absolute_front().id()).copied(), vertices.get(&e.absolute_back().id()).copied()))
|
.map(|(e, fs)| (fs, vertices.get(&e.absolute_front().id()).copied(), vertices.get(&e.absolute_back().id()).copied()))
|
||||||
.collect();
|
.collect();
|
||||||
Index { operand, edges: crate::record::edge_index(&b.solid), bounds, between, vertices, points, lines }
|
Index { operand, edges: crate::record::edge_index(&b.solid), bounds, between, vertices, points, lines, hulls }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The tag of the segments read off edge `e` (as it runs in a wire) of cap face `cap`.
|
/// The tag of the segments read off edge `e` (as it runs in a wire) of cap face `cap`.
|
||||||
@@ -517,7 +521,7 @@ struct Stations {
|
|||||||
/// Each wall's highest level.
|
/// Each wall's highest level.
|
||||||
tops: std::collections::HashMap<usize, f64>,
|
tops: std::collections::HashMap<usize, f64>,
|
||||||
/// Per wall, the edges across it: the other wall and the edge's lowest and highest levels.
|
/// Per wall, the edges across it: the other wall and the edge's lowest and highest levels.
|
||||||
/// A curve other than a line spans every level.
|
/// A curve other than a line spans every level, unless it lies at one level.
|
||||||
cross: std::collections::HashMap<usize, Vec<(usize, f64, f64)>>,
|
cross: std::collections::HashMap<usize, Vec<(usize, f64, f64)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,7 +544,13 @@ impl Stations {
|
|||||||
let (h, k) = (plane.height(*p), plane.height(*q));
|
let (h, k) = (plane.height(*p), plane.height(*q));
|
||||||
(h.min(k), h.max(k))
|
(h.min(k), h.max(k))
|
||||||
}
|
}
|
||||||
_ => (f64::NEG_INFINITY, f64::INFINITY),
|
// A curve at one level (where a piece of a wall's surface stacked on another
|
||||||
|
// meets it: a boss's cylinder over the arc of the plate it stands on) crosses
|
||||||
|
// the walls there only. Any other spans every level.
|
||||||
|
_ => match index.hulls.get(i).and_then(Option::as_deref).map(|hull| level_span(hull, plane)) {
|
||||||
|
Some((lo, hi)) if hi - lo < tol => (lo, hi),
|
||||||
|
_ => (f64::NEG_INFINITY, f64::INFINITY),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
cross.entry(f).or_default().push((g, lo, hi));
|
cross.entry(f).or_default().push((g, lo, hi));
|
||||||
cross.entry(g).or_default().push((f, lo, hi));
|
cross.entry(g).or_default().push((f, lo, hi));
|
||||||
@@ -590,6 +600,35 @@ impl Stations {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The most control points [`control_hull`] reads of one curve.
|
||||||
|
const MAX_HULL_POINTS: usize = 4096;
|
||||||
|
|
||||||
|
/// The points curve `c` lies within: a B-spline's control points, or a NURBS curve's where its
|
||||||
|
/// weights are positive (the curve then lies in their convex hull). `None` for a line, an
|
||||||
|
/// intersection curve, other weights or too many points.
|
||||||
|
fn control_hull(c: &mt::Curve) -> Option<Vec<Vec3>> {
|
||||||
|
match c {
|
||||||
|
mt::Curve::BSplineCurve(c) if c.control_points().len() <= MAX_HULL_POINTS => Some(c.control_points().iter().map(|p| from_p3(*p)).collect()),
|
||||||
|
mt::Curve::NurbsCurve(c) if c.control_points().len() <= MAX_HULL_POINTS => {
|
||||||
|
c.control_points().iter().map(|p| (p.w > 0.0 && p.w.is_finite()).then(|| Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w))).collect()
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lowest and highest levels of `points` (infinite where there are none, or one isn't
|
||||||
|
/// finite).
|
||||||
|
fn level_span(points: &[Vec3], plane: &Plane) -> (f64, f64) {
|
||||||
|
let span = points
|
||||||
|
.iter()
|
||||||
|
.map(|p| plane.height(*p))
|
||||||
|
.try_fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), h| h.is_finite().then(|| (lo.min(h), hi.max(h))));
|
||||||
|
match span {
|
||||||
|
Some((lo, hi)) if lo <= hi => (lo, hi),
|
||||||
|
_ => (f64::NEG_INFINITY, f64::INFINITY),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The highest level of face `f`'s vertices.
|
/// The highest level of face `f`'s vertices.
|
||||||
fn top_of(f: &mt::Face, plane: &Plane) -> f64 {
|
fn top_of(f: &mt::Face, plane: &Plane) -> f64 {
|
||||||
f.vertex_iter().map(|v| plane.height(from_p3(v.point()))).fold(f64::NEG_INFINITY, f64::max)
|
f.vertex_iter().map(|v| plane.height(from_p3(v.point()))).fold(f64::NEG_INFINITY, f64::max)
|
||||||
|
|||||||
Reference in new issue
Block a user