fix: restore shell and cut wall topology in 0.2.1

This commit is contained in:
bherbruck committed 2026-10-11 15:17:05 -04:00
1 parent 1aedbf6631
commit ef8f6ca6f9
31 files changed
+2976 -678

No files matched your search

+156 -26
View File
@@ -683,12 +683,17 @@ fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option
};
slabs.push(oriented(&s));
}
sew_sections(plane, d, &levels, &slabs, tol)
sew_sections(plane, d, &levels, &slabs, tol, false)
}
/// Sew the exact sections between successive levels, including empty gaps.
fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64) -> Option<Result<Body>> {
if levels.len() != slabs.len() + 1 || slabs.len() > 128 || plane.normal().dot(d) < 1. - 1e-12 {
fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64, coalesce: bool) -> Option<Result<Body>> {
if levels.len() != slabs.len() + 1
|| slabs.len() > 128
|| plane.normal().dot(d) < 1. - 1e-12
|| levels.iter().any(|z| !z.is_finite())
|| levels.windows(2).any(|z| z[0] >= z[1])
{
return None;
}
let mut blocks = Vec::new();
@@ -732,7 +737,7 @@ fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>],
pieces.push(piece);
}
} else {
let piece = sew_connected_sections(plane, d, &levels[first..=end], &slabs[first..end], tol)
let piece = sew_connected_sections(plane, d, &levels[first..=end], &slabs[first..end], tol, coalesce)
.ok_or_else(|| KernelError::Failed("stepped prism construction failed".into()))??;
remaining_faces =
remaining_faces.checked_sub(piece.face_count()).ok_or_else(|| KernelError::Failed("prism output face budget".into()))?;
@@ -765,8 +770,18 @@ fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>],
})())
}
/// Stable exact identity for the bounded analytic segment subset.
fn wall_key(s: &Seg2) -> Option<[u64; 6]> {
let bits = |v: f64| v.is_finite().then(|| if v == 0. { 0 } else { v.to_bits() });
match s {
Seg2::Line { a, b } => Some([0, bits(a.x)?, bits(a.y)?, bits(b.x)?, bits(b.y)?, 0]),
Seg2::Arc { center, radius, start, sweep } => Some([1, bits(center.x)?, bits(center.y)?, bits(*radius)?, bits(*start)?, bits(*sweep)?]),
_ => None,
}
}
/// A genuinely stepped connected axial block has no single extrusion footprint.
fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64) -> Option<Result<Body>> {
fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64, coalesce: bool) -> Option<Result<Body>> {
use crate::sew::{EdgeSpec, FaceSpec, SurfSpec};
// Faces at each level: up where the slab below sticks out, down where the one above does.
let diff = |x: &[Region2], y: &[Region2]| -> Option<Vec<Region2>> {
@@ -800,30 +815,61 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
_ => EdgeSpec::Line { a: at(sg.start(), z), b: at(sg.end(), z) },
}
};
let mut faces: Vec<FaceSpec> = Vec::new();
for (i, s) in slabs.iter().enumerate() {
let (z0, z1) = (*levels.get(i)?, *levels.get(i + 1)?);
for lp in loops_of(s) {
// Exact oriented generated segments alone define a run. No support fitting,
// proximity matching, or old face lineage is used here.
let mut runs: Vec<(usize, usize, Seg2)> = Vec::new();
let mut last = std::collections::BTreeMap::new();
let mut remaining = MAX_PLANAR_SEGMENTS * 2;
for (i, slab) in slabs.iter().enumerate() {
for lp in loops_of(slab) {
for sg0 in &lp.segs {
for sg in split_seg(sg0, &pts, tol)? {
let (a, b) = (sg.start(), sg.end());
let lp3 = vec![
edge(&sg, z0),
EdgeSpec::Line { a: at(b, z0), b: at(b, z1) },
edge(&sg.reversed(), z1),
EdgeSpec::Line { a: at(a, z1), b: at(a, z0) },
];
let tangent = plane.dir_to_world(sg.derivative(0.5));
let outward = tangent.cross(d).normalized()?;
let surface = match sg {
Seg2::Arc { .. } => SurfSpec::Extruded { a: at(a, z0), b: at(b, z0), mid: at(sg.point_at(0.5), z0), dir: d * (z1 - z0) },
_ => SurfSpec::Plane,
};
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) });
remaining = remaining.checked_sub(1)?;
let key = wall_key(&sg)?;
if coalesce {
if let Some(&index) = last.get(&key) {
let run: &mut (usize, usize, Seg2) = runs.get_mut(index)?;
if run.1 == i {
run.1 = i + 1;
continue;
}
// Duplicate oriented boundaries in one slab are not
// a valid reason to reconstruct a larger wall.
if run.1 == i + 1 {
return None;
}
}
last.insert(key, runs.len());
}
runs.push((i, i + 1, sg));
}
}
}
}
let mut faces: Vec<FaceSpec> = Vec::new();
let mut sources = Vec::new();
for (first, end, sg) in runs {
let (z0, z1) = (levels[first], levels[end]);
let (a, b) = (sg.start(), sg.end());
let mut lp3 = Vec::with_capacity(2 + 2 * (end - first));
lp3.push(edge(&sg, z0));
// Keep station vertices at both ends: neighboring stepped walls/caps
// must share complete edges rather than create T-junctions.
for k in first..end {
lp3.push(EdgeSpec::Line { a: at(b, levels[k]), b: at(b, levels[k + 1]) });
}
lp3.push(edge(&sg.reversed(), z1));
for k in (first..end).rev() {
lp3.push(EdgeSpec::Line { a: at(a, levels[k + 1]), b: at(a, levels[k]) });
}
let outward = plane.dir_to_world(sg.derivative(0.5)).cross(d).normalized()?;
let surface = match sg {
Seg2::Arc { .. } => SurfSpec::Extruded { a: at(a, z0), b: at(b, z0), mid: at(sg.point_at(0.5), z0), dir: d * (z1 - z0) },
_ => SurfSpec::Plane,
};
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) });
sources.push((end > first + 1).then_some(crate::FaceSource::ExtrudeSide { segment: sg, plane: None, coalesced: true }));
}
for (z, up, rs) in &caps {
for r in rs {
let mut loops = Vec::new();
@@ -836,9 +882,10 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::<Vec<_>>());
}
faces.push(FaceSpec { loops, surface: SurfSpec::Plane, probe: None });
sources.push(None);
}
}
Some(crate::sew::sew_touching(&faces))
Some(crate::sew::sew_touching_with_sources(&faces, &sources))
}
fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
@@ -1055,7 +1102,19 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>>
if slabs.iter().all(Vec::is_empty) {
return Some(Ok(None));
}
sew_sections(&plane, d, &levels, &slabs, tol).map(|r| r.map(Some))
sew_sections(
&plane,
d,
&levels,
&slabs,
tol,
op == BoolOp::Cut
&& a.split_keep().is_empty()
&& b.split_keep().is_empty()
&& a.paint().is_none_or(|p| p.faces.is_empty())
&& b.paint().is_none_or(|p| p.faces.is_empty()),
)
.map(|r| r.map(Some))
}
pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
@@ -1144,7 +1203,12 @@ mod planar_tests {
assert!(mesh.contains(Vec3::new(21., 21., 3.)));
assert!(!mesh.contains(Vec3::new(25., 25., 3.)));
assert!(mesh.contains(Vec3::new(25., 25., 22.)));
assert!(lump.face_sources().is_none_or(|s| s.iter().all(Option::is_none)), "genuinely stepped geometry remains uncertified");
assert!(
lump.face_sources().is_none_or(|s| s
.iter()
.all(|s| s.is_none() || matches!(s, Some(crate::FaceSource::ExtrudeSide { coalesced: true, plane: None, .. })))),
"genuinely stepped geometry has no durable certificate"
);
assert!((mesh.measure().volume - (18. * (100. - 4. * std::f64::consts::PI) + 500.)).abs() < 0.02);
}
}
@@ -1152,6 +1216,72 @@ mod planar_tests {
assert_eq!(crate::step_export(&[&tool], "tool").unwrap(), before_tool);
assert_eq!(crate::step_export(&[&roof], "roof").unwrap(), before_roof);
}
#[test]
fn contiguous_analytic_walls_remove_faces_but_retain_vertical_stations() {
for curved in [false, true] {
let outer = if curved {
Region2 { outer: Loop2::circle(Vec2::ZERO, 10.), holes: vec![] }
} else {
rectangle(Vec2::new(-10., -10.), Vec2::new(10., 10.))
};
let mut upper = outer.clone();
upper.holes.push(if curved {
Loop2::circle(Vec2::ZERO, 5.).reversed()
} else {
rectangle(Vec2::new(-5., -5.), Vec2::new(5., 5.)).outer.reversed()
});
let slabs = vec![vec![outer], vec![upper]];
let old = sew_sections(&Plane::XY, Vec3::Z, &[0., 2., 20.], &slabs, 1e-7, false).unwrap().unwrap();
let merged = sew_sections(&Plane::XY, Vec3::Z, &[0., 2., 20.], &slabs, 1e-7, true).unwrap().unwrap();
let count = if curved { 2 } else { 4 };
assert_eq!(old.face_count() - merged.face_count(), count);
let marked: Vec<_> = merged.solid.face_iter().enumerate().filter(|(i, _)| merged.face_was_coalesced(*i)).collect();
assert_eq!(marked.len(), count);
for (_, face) in marked {
assert_eq!(face.boundaries()[0].len(), 6, "all axial station vertices are retained");
let heights: Vec<_> = face.vertex_iter().map(|v| v.point().z).collect();
for z in [0., 2., 20.] {
assert!(heights.contains(&z));
}
}
assert!(old.face_sources().is_none_or(|s| s.iter().all(Option::is_none)));
let expected = if curved { std::f64::consts::PI * (100. * 20. - 25. * 18.) } else { 400. * 20. - 100. * 18. };
let mesh = merged.tessellate(1e-5).unwrap();
assert!((mesh.measure().volume - expected).abs() < 0.03);
assert!(mesh.contains(Vec3::new(0., 0., 1.)));
assert!(!mesh.contains(Vec3::new(0., 0., 10.)));
assert!(merged.validity().is_empty());
assert_ne!(wall_key(&slabs[0][0].outer.segs[0]), wall_key(&slabs[0][0].outer.segs[0].reversed()));
}
}
#[test]
fn cut_run_coalescing_respects_authored_splits_and_face_paint() {
let region = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.), holes: vec![] };
let tool_region = Region2 { outer: Loop2::circle(Vec2::ZERO, 5.), holes: vec![] };
let stock = crate::extrude(&Plane::XY, &[region], 0., 20.).unwrap().pop().unwrap();
let cutter = crate::extrude(&Plane::XY, &[tool_region], 2., 22.).unwrap().pop().unwrap();
let merged = stack_boolean(&stock, &cutter, BoolOp::Cut).unwrap().unwrap().unwrap();
let protected = [
stock.clone().with_split_lines(Some(std::sync::Arc::new(vec![vec![Vec3::new(10., 0., 0.), Vec3::new(10., 0., 20.)]]))),
stock.with_paint(Some(crate::Paint { opacity: 1., faces: vec![crate::FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] })),
];
for input in protected {
let output = stack_boolean(&input, &cutter, BoolOp::Cut).unwrap().unwrap().unwrap();
assert_eq!(output.face_count(), merged.face_count() + 2);
assert!((0..output.face_count()).all(|i| !output.face_was_coalesced(i)));
// Independent circular chord-volume bound: 2*pi*(10*20+5*18)*1e-5 < .019.
let expected = std::f64::consts::PI * (100. * 20. - 25. * 18.);
for body in [&output, &merged] {
let mesh = body.tessellate(1e-5).unwrap();
assert!((mesh.measure().volume - expected).abs() < 0.03);
assert!(mesh.contains(Vec3::new(0., 0., 1.)));
assert!(!mesh.contains(Vec3::new(0., 0., 10.)));
assert!(mesh.contains(Vec3::new(7., 0., 10.)));
}
}
}
#[test]
fn annular_inward_tool_recovers_exact_stacks_before_cap_policy() {
let plane = Plane::XY;