feat: preserve modeling intent across sketches, assemblies, and fabrication

Persist typed sketch dimensions and bounded ordered configuration rules in format 8. Use exact occurrence paths and source identity for editing, movement, appearance, and mass. Preserve sheet material regions through cutting, folding, and exact DXF export; retain nested cavities and material islands.

Validation: cargo xtask ci passed, including 368 engine tests, 178 UI tests, 68 UI scenarios, 136 kernel tests, and the 10-crate wasm check. Actual preview workflows and legacy format-7 origin-corner reopening were inspected.
This commit is contained in:
bherbruck committed 2026-10-11 01:28:01 -04:00
1 parent d71bdb78ec
commit 07932d0f42
65 files changed
+6145 -484

No files matched your search

+76 -27
View File
@@ -5,6 +5,9 @@ use truck_modeling as mt;
use crate::{KernelError, Result, guard};
#[path = "shell_groups.rs"]
mod shell_groups;
pub(crate) type Solid = mt::Solid;
/// A solid body (one or more closed shells), or a mesh body (triangles only, like an imported
@@ -674,51 +677,97 @@ impl Body {
/// The separate pieces of a body: each outer shell with the voids inside it. A body in one
/// piece comes back as itself.
pub fn lumps(&self) -> Result<Vec<Body>> {
self.lumps_with_clearance(0.0)
}
/// A boolean shortcut needs clearance larger than both shell mesh deflections.
pub(crate) fn separated_lumps(&self) -> Result<Vec<Body>> {
self.lumps_with_clearance(4.0 * (self.size() * 1e-3).max(1e-3))
}
fn lumps_with_clearance(&self, clearance: f64) -> Result<Vec<Body>> {
if self.mesh.is_some() || self.solid.boundaries().len() < 2 {
return Ok(vec![self.clone()]);
}
let tol = (self.size() * 1e-3).max(1e-3);
guard("lumps", || {
let shells: Vec<mt::Shell> = self.solid.boundaries().clone();
let meshes: Vec<(f64, Option<Mesh>)> = shells
if shells.len() > shell_groups::MAX_SHELLS
|| shells.iter().flat_map(mt::Shell::vertex_iter).take(shell_groups::MAX_POINTS + 1).count() > shell_groups::MAX_POINTS
|| shells.iter().flat_map(mt::Shell::face_iter).take(shell_groups::MAX_TRIANGLES + 1).count() > shell_groups::MAX_TRIANGLES
{
return Err(KernelError::Failed("lumps: topology exceeds the shell grouping limit".into()));
}
let (mut point_count, mut triangle_count) = (0usize, 0usize);
let data: Vec<(f64, Mesh)> = shells
.iter()
.map(|sh| {
let pm = sh.robust_triangulation(tol).to_polygon();
let v = pm.volume();
let meshed = sh.robust_triangulation(tol);
if meshed.face_iter().any(|f| f.surface().is_none_or(|m| m.faces().triangle_iter().next().is_none())) {
return Err(KernelError::Failed("lumps: cannot mesh a complete shell boundary".into()));
}
let pm = meshed.to_polygon();
point_count = point_count
.checked_add(pm.positions().len())
.ok_or_else(|| KernelError::Failed("lumps: mesh exceeds the shell grouping limit".into()))?;
triangle_count = triangle_count
.checked_add(pm.faces().triangle_iter().take(shell_groups::MAX_TRIANGLES + 1).count())
.ok_or_else(|| KernelError::Failed("lumps: mesh exceeds the shell grouping limit".into()))?;
if point_count > shell_groups::MAX_POINTS || triangle_count > shell_groups::MAX_TRIANGLES {
return Err(KernelError::Failed("lumps: mesh exceeds the shell grouping limit".into()));
}
let mut m = Mesh::default();
for p in pm.positions() {
m.positions.push(Vec3::new(p.x, p.y, p.z));
}
for t in pm.faces().triangle_iter() {
let idx = |k: usize| t.get(k).map(|x| u32::try_from(x.pos).unwrap_or(0)).unwrap_or(0);
m.triangles.push([idx(0), idx(1), idx(2)]);
let idx = |k: usize| {
t.get(k).and_then(|x| u32::try_from(x.pos).ok()).ok_or_else(|| KernelError::Failed("lumps: invalid mesh index".into()))
};
let indices = [idx(0)?, idx(1)?, idx(2)?];
let [a, b, c] = m.tri(&indices).ok_or_else(|| KernelError::Failed("lumps: invalid mesh triangle".into()))?;
// STEP sphere poles can produce zero-area duplicate triangles.
// They have no boundary area or volume and are not ray crossings.
if (b - a).cross(c - a) == Vec3::ZERO {
continue;
}
m.triangles.push(indices);
m.tri_face.push(0);
}
(v, Some(m))
let origin = m.positions.first().copied().ok_or_else(|| KernelError::Failed("lumps: empty mesh".into()))?;
let v = m
.triangles
.iter()
.map(|t| {
m.tri(t)
.map(|[a, b, c]| (a - origin).dot((b - origin).cross(c - origin)) / 6.0)
.ok_or_else(|| KernelError::Failed("lumps: invalid mesh triangle".into()))
})
.collect::<Result<Vec<_>>>()?
.into_iter()
.sum();
Ok((v, m))
})
.collect();
let outer: Vec<usize> = (0..shells.len()).filter(|i| meshes.get(*i).is_some_and(|m| m.0 > 0.0)).collect();
if outer.len() < 2 {
.collect::<Result<_>>()?;
let (volumes, meshes): (Vec<_>, Vec<_>) = data.into_iter().unzip();
let groups = shell_groups::groups(&meshes, &volumes, (self.size() * 1e-8).max(1e-9).max(clearance))?;
if groups.len() == 1 {
return Ok(vec![self.clone()]);
}
let mut groups: Vec<Vec<mt::Shell>> = outer.iter().filter_map(|i| shells.get(*i).cloned()).map(|s| vec![s]).collect();
for (i, sh) in shells.iter().enumerate() {
if outer.contains(&i) {
continue;
}
// A void goes with the outer shell around one of its points.
let probe = sh.vertex_iter().next().map(|v| from_p3(v.point()));
let owner = outer.iter().position(|o| {
let (Some(p), Some((_, Some(m)))) = (probe, meshes.get(*o)) else { return false };
m.contains(p)
});
if let Some(k) = owner
&& let Some(g) = groups.get_mut(k)
{
g.push(sh.clone());
}
}
groups.into_iter().map(|g| Solid::try_new(g).map_err(|e| KernelError::Failed(format!("lump: {e}"))).and_then(Body::new)).collect()
groups
.into_iter()
.map(|g| {
let shells = g
.into_iter()
.map(|i| shells.get(i).cloned().ok_or_else(|| KernelError::Failed("lumps: missing shell".into())))
.collect::<Result<Vec<_>>>()?;
Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("lump: {e}"))).and_then(Body::new)
})
.collect()
})
}
}
#[cfg(test)]
#[path = "body_lump_tests.rs"]
mod lump_tests;
+224
View File
@@ -0,0 +1,224 @@
use super::*;
use crate::{BoolOp, boolean, box_solid, measure, sphere, step_export, step_import};
fn cube(half: f64) -> Body {
box_solid(Vec3::new(-half, -half, -half), Vec3::new(half, half, half)).unwrap()
}
fn nested_island() -> Body {
let hollow = boolean(&cube(50.0), &cube(40.0), BoolOp::Cut).unwrap().unwrap();
// This order puts the containing outer shell ahead of the disconnected island.
let joined = boolean(&cube(20.0), &hollow, BoolOp::Union).unwrap().unwrap();
boolean(&joined, &cube(10.0), BoolOp::Cut).unwrap().unwrap()
}
fn assert_material(body: &Body, expected: f64, probes: &[(Vec3, bool)]) {
assert!(body.validity().is_empty(), "{:?}", body.validity());
let mesh = body.tessellate_with(0.01, true).unwrap();
assert!((mesh.measure().volume - expected).abs() < expected * 1e-6, "{} vs {expected}", mesh.measure().volume);
for (p, want) in probes {
assert_eq!(mesh.contains(*p), *want, "material at {p:?}");
}
}
fn check_nested(body: &Body) {
let probes = [(Vec3::ZERO, false), (Vec3::X * 15.0, true), (Vec3::X * 30.0, false), (Vec3::X * 45.0, true)];
assert_material(body, 544_000.0, &probes);
let lumps = body.lumps().unwrap();
assert_eq!(lumps.len(), 2);
let mut volumes: Vec<_> = lumps.iter().map(|b| measure(b).unwrap().volume).collect();
volumes.sort_by(f64::total_cmp);
for (got, want) in volumes.iter().zip([56_000.0, 488_000.0]) {
assert!((got - want).abs() < 1e-6, "{volumes:?}");
}
let meshes: Vec<_> = lumps.iter().map(|b| b.tessellate_with(0.01, true).unwrap()).collect();
for (p, want) in probes {
assert_eq!(meshes.iter().filter(|m| m.contains(p)).count(), usize::from(want), "lump material at {p:?}");
}
}
#[test]
fn nested_cavities_stay_with_their_material_islands_for_every_shell_order() {
let body = nested_island();
check_nested(&body);
let before = step_export(&[&body], "nested cavities").unwrap();
let shells = body.deep_copy().boundaries().clone();
assert_eq!(shells.len(), 4);
for a in 0..4 {
for b in 0..4 {
for c in 0..4 {
for d in 0..4 {
let order = [a, b, c, d];
if order.iter().enumerate().any(|(i, x)| order.iter().skip(i + 1).any(|y| x == y)) {
continue;
}
let permuted = guard("permuted cavity shells", || {
Body::new(Solid::try_new(order.into_iter().map(|i| shells[i].clone()).collect()).unwrap())
})
.unwrap();
check_nested(&permuted);
}
}
}
}
assert_eq!(step_export(&[&body], "nested cavities").unwrap(), before);
}
#[test]
fn nested_voids_survive_step_round_trip_and_further_cuts() {
let body = nested_island();
let text = step_export(&[&body], "nested cavities").unwrap();
let imported = step_import(&text).unwrap();
assert!(imported.warnings.is_empty(), "{:?}", imported.warnings);
let mut lumps = Vec::new();
for imported in imported.bodies {
lumps.extend(imported.body.lumps().unwrap());
}
assert_eq!(lumps.len(), 2);
let mut volumes: Vec<_> = lumps.iter().map(|b| measure(b).unwrap().volume).collect();
volumes.sort_by(f64::total_cmp);
for (got, want) in volumes.iter().zip([56_000.0, 488_000.0]) {
assert!((got - want).abs() < 1e-3, "{volumes:?}");
}
let opening = box_solid(Vec3::new(5.0, -2.0, -2.0), Vec3::new(25.0, 2.0, 2.0)).unwrap();
let mut cut_volume = 0.0;
for lump in &lumps {
if let Some(cut) = boolean(lump, &opening, BoolOp::Cut).unwrap() {
cut_volume += measure(&cut).unwrap().volume;
assert!(!cut.tessellate_with(0.01, true).unwrap().contains(Vec3::X * 15.0));
}
}
// Only x=10..20 is material along this 4×4 opening: 160 mm³ removed.
assert!((cut_volume - 543_840.0).abs() < 1e-3, "{cut_volume}");
}
#[test]
fn curved_internal_void_and_disconnected_material_keep_analytic_volume() {
let hollow = boolean(&cube(20.0), &sphere(Vec3::ZERO, 10.0).unwrap(), BoolOp::Cut).unwrap().unwrap();
let island = sphere(Vec3::ZERO, 4.0).unwrap();
let joined = boolean(&island, &hollow, BoolOp::Union).unwrap().unwrap();
let reverse = boolean(&hollow, &island, BoolOp::Union).unwrap().unwrap();
assert!((measure(&joined).unwrap().volume - measure(&reverse).unwrap().volume).abs() < 1e-4);
let body = boolean(&joined, &sphere(Vec3::ZERO, 2.0).unwrap(), BoolOp::Cut).unwrap().unwrap();
let lumps = body.lumps().unwrap();
assert_eq!(lumps.len(), 2);
let mut volumes: Vec<_> = lumps.iter().map(|b| measure(b).unwrap().volume).collect();
volumes.sort_by(f64::total_cmp);
let pi = std::f64::consts::PI;
let want = [4.0 / 3.0 * pi * (64.0 - 8.0), 64_000.0 - 4.0 / 3.0 * pi * 1000.0];
for (got, want) in volumes.iter().zip(want) {
assert!((got - want).abs() < want * 0.002, "{got} vs {want}");
}
let meshes: Vec<_> = lumps.iter().map(|b| b.tessellate_with(0.01, true).unwrap()).collect();
for (p, want) in [(Vec3::ZERO, 0), (Vec3::X * 3.0, 1), (Vec3::X * 7.0, 0), (Vec3::X * 15.0, 1)] {
assert_eq!(meshes.iter().filter(|m| m.contains(p)).count(), want);
}
let imported = step_import(&step_export(&[&body], "curved nested cavities").unwrap()).unwrap();
assert!(imported.warnings.is_empty(), "{:?}", imported.warnings);
let imported: Vec<_> = imported.bodies.into_iter().flat_map(|b| b.body.lumps().unwrap()).collect();
assert_eq!(imported.len(), 2);
let imported_meshes: Vec<_> = imported.iter().map(|b| b.tessellate_with(0.01, true).unwrap()).collect();
for (p, want) in [(Vec3::ZERO, 0), (Vec3::X * 3.0, 1), (Vec3::X * 7.0, 0), (Vec3::X * 15.0, 1)] {
assert_eq!(imported_meshes.iter().filter(|m| m.contains(p)).count(), want);
}
}
fn joined_shells(shells: Vec<mt::Shell>) -> Body {
guard("invalid ownership fixture", || Body::new(Solid::try_new(shells).unwrap())).unwrap()
}
#[test]
fn unowned_overlapping_and_touching_cavities_are_errors_not_dropped_geometry() {
let outer = cube(50.0);
for (min, max) in [
(Vec3::new(60.0, 60.0, 60.0), Vec3::new(70.0, 70.0, 70.0)),
(Vec3::new(45.0, -5.0, -5.0), Vec3::new(55.0, 5.0, 5.0)),
(Vec3::new(40.0, -5.0, -5.0), Vec3::new(50.0, 5.0, 5.0)),
] {
let mut inner = box_solid(min, max).unwrap().deep_copy();
inner.not();
let body = joined_shells(outer.deep_copy().boundaries().iter().chain(inner.boundaries()).cloned().collect());
let before = step_export(&[&body], "invalid ownership").unwrap();
assert!(body.lumps().is_err());
assert_eq!(step_export(&[&body], "invalid ownership").unwrap(), before);
}
let mut a = box_solid(Vec3::new(-10.0, -10.0, -10.0), Vec3::new(10.0, 10.0, 10.0)).unwrap().deep_copy();
a.not();
let mut b = box_solid(Vec3::new(0.0, -10.0, -10.0), Vec3::new(20.0, 10.0, 10.0)).unwrap().deep_copy();
b.not();
let body = joined_shells(outer.deep_copy().boundaries().iter().chain(a.boundaries()).chain(b.boundaries()).cloned().collect());
assert!(body.lumps().is_err());
}
#[test]
fn shell_grouping_preserves_large_disconnected_patterns_and_bounds_hostile_work() {
let mesh = cube(1.0).tessellate_with(0.01, true).unwrap();
let copies: Vec<_> = (0..512)
.map(|i| {
let mut m = mesh.clone();
for p in &mut m.positions {
p.x += f64::from(i) * 4.0;
}
m
})
.collect();
assert_eq!(shell_groups::groups(&copies, &vec![8.0; 512], 1e-8).unwrap().len(), 512);
let excessive = vec![Mesh::default(); shell_groups::MAX_SHELLS + 1];
assert!(shell_groups::groups(&excessive, &vec![1.0; excessive.len()], 1e-8).unwrap_err().to_string().contains("shell count"));
let mut oversized = mesh.clone();
oversized.positions.resize(shell_groups::MAX_POINTS + 1, Vec3::ZERO);
assert!(shell_groups::groups(&[oversized], &[8.0], 1e-8).unwrap_err().to_string().contains("grouping limit"));
let mut repeated = mesh.clone();
repeated.triangles = mesh.triangles.iter().copied().cycle().take(7000).collect();
assert!(shell_groups::groups(&[repeated.clone(), repeated], &[8.0, 8.0], 1e-8).unwrap_err().to_string().contains("boundary work"));
let mut outer = cube(2.0).tessellate_with(0.01, true).unwrap();
let mut inner = mesh;
outer.triangles = outer.triangles.iter().copied().cycle().take(4000).collect();
inner.triangles = inner.triangles.iter().copied().cycle().take(4000).collect();
inner.positions.resize(4000, Vec3::ZERO);
assert!(shell_groups::groups(&[outer, inner], &[64.0, -8.0], 1e-8).unwrap_err().to_string().contains("containment work"));
}
#[test]
fn translated_and_scaled_nested_voids_keep_material_membership() {
let body = nested_island();
for scale in [0.01, 0.25, 2.0] {
let origin = Vec3::new(1234.0, -987.0, 765.0);
let transformed = crate::transform_matrix(
&body,
[[scale, 0.0, 0.0, 0.0], [0.0, scale, 0.0, 0.0], [0.0, 0.0, scale, 0.0], [origin.x, origin.y, origin.z, 1.0]],
)
.unwrap();
let lumps = transformed
.lumps()
.unwrap_or_else(|e| panic!("scale={scale}: {e}; bounds={:?}", transformed.tessellate_with(1e-4, true).unwrap().bounds()));
assert_eq!(lumps.len(), 2);
let meshes: Vec<_> = lumps.iter().map(|b| b.tessellate_with((scale * 0.01).max(1e-5), true).unwrap()).collect();
for (x, want) in [(0.0, 0), (15.0, 1), (30.0, 0), (45.0, 1)] {
assert_eq!(meshes.iter().filter(|m| m.contains(origin + Vec3::X * (x * scale))).count(), want);
}
}
}
#[test]
fn shortcut_clearance_refuses_curved_contact_hidden_between_mesh_vertices() {
let direction = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap();
let a = sphere(Vec3::ZERO, 10.0).unwrap();
for distance in [19.999, 20.001] {
let b = sphere(direction * distance, 10.0).unwrap();
for shells in [
a.deep_copy().boundaries().iter().chain(b.deep_copy().boundaries()).cloned().collect(),
b.deep_copy().boundaries().iter().chain(a.deep_copy().boundaries()).cloned().collect(),
] {
assert!(joined_shells(shells).separated_lumps().is_err(), "distance {distance}");
}
}
let far = sphere(direction * 21.0, 10.0).unwrap();
let safe = crate::ops::join_pieces(vec![a, far]).unwrap().unwrap();
assert_eq!(safe.separated_lumps().unwrap().len(), 2);
let outer = sphere(Vec3::ZERO, 10.0).unwrap();
let mut protruding = sphere(direction * 9.001, 1.0).unwrap().deep_copy();
protruding.not();
let invalid = joined_shells(outer.deep_copy().boundaries().iter().chain(protruding.boundaries()).cloned().collect());
assert!(invalid.separated_lumps().is_err());
}
+1
View File
@@ -64,6 +64,7 @@ pub use offset::with_same_surface;
pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix};
pub use polybool::{faceted_boolean, planar_boolean};
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
pub use prism::{extrude_planar_regions, planar_regions_boolean};
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew};
pub use splitface::{SplitTool, split_body, split_faces, split_faces_with_map};
pub use step::step_export;
+30 -20
View File
@@ -114,6 +114,13 @@ fn trivial(a: &Body, b: &Body, op: BoolOp, ma: &solvecraft_geom::Mesh, mb: &solv
const ALL: f64 = 0.997;
let disjoint = b_in_a < NONE && a_in_b < NONE;
match op {
// Sampling only selects this candidate. Validate every shell boundary and the
// complete material/void nesting before preserving a disconnected union.
BoolOp::Union if disjoint => {
let joined = join_pieces(vec![a.clone(), b.clone()]).ok().flatten()?;
joined.separated_lumps().ok()?;
Some(Some(joined))
}
BoolOp::Cut if disjoint => Some(Some(a.clone())),
BoolOp::Cut if a_in_b > ALL => Some(None),
BoolOp::Intersect if disjoint => Some(None),
@@ -122,31 +129,34 @@ fn trivial(a: &Body, b: &Body, op: BoolOp, ma: &solvecraft_geom::Mesh, mb: &solv
BoolOp::Union if b_in_a > ALL => Some(Some(a.clone())),
BoolOp::Union if a_in_b > ALL => Some(Some(b.clone())),
// The tool wholly inside, clear of the outside: a void.
BoolOp::Cut if b_in_a > ALL && clear_inside(ma, mb, a.size().max(b.size()) * 1e-6) => void(a, b).ok(),
BoolOp::Cut if b_in_a > ALL => {
let candidate = void(a, b).ok().flatten()?;
candidate.separated_lumps().ok()?;
Some(Some(candidate))
}
_ => None,
}
}
/// Does every vertex of `inner` lie inside `outer` and farther than `gap` from its surface?
fn clear_inside(outer: &solvecraft_geom::Mesh, inner: &solvecraft_geom::Mesh, gap: f64) -> bool {
if outer.triangles.len() * inner.positions.len() > 40_000_000 {
return false;
#[cfg(test)]
mod shortcut_tests {
use super::*;
#[test]
fn curved_near_contact_never_uses_a_shell_only_boolean_candidate() {
let direction = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap();
let outer = crate::sphere(Vec3::ZERO, 10.0).unwrap();
let mesh = outer.tessellate(0.03).unwrap();
for distance in [19.999, 20.001] {
let other = crate::sphere(direction * distance, 10.0).unwrap();
let other_mesh = other.tessellate(0.03).unwrap();
assert!(trivial(&outer, &other, BoolOp::Union, &mesh, &other_mesh).is_none());
assert!(trivial(&other, &outer, BoolOp::Union, &other_mesh, &mesh).is_none());
}
let protruding = crate::sphere(direction * 9.001, 1.0).unwrap();
let protruding_mesh = protruding.tessellate(0.03).unwrap();
assert!(trivial(&outer, &protruding, BoolOp::Cut, &mesh, &protruding_mesh).is_none());
}
let tris: Vec<[Vec3; 3]> = outer.triangles.iter().filter_map(|t| outer.tri(t)).collect();
inner.positions.iter().all(|p| {
outer.contains(*p)
&& tris.iter().all(|[a, b, c]| {
// Distance to the triangle's plane bounds the distance to the triangle from below.
let n = (*b - *a).cross(*c - *a);
let Some(nn) = n.normalized() else { return true };
let h = (*p - *a).dot(nn).abs();
h > gap || {
let q = *p - nn * (*p - *a).dot(nn);
let inside = [(*a, *b), (*b, *c), (*c, *a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= 0.0);
!inside && [(*a, *b), (*b, *c), (*c, *a)].iter().all(|(u, v)| p.dist_to_segment(*u, *v) > gap)
}
})
})
}
/// `a` with the inside of `b` (wholly within it) taken out: a second, inward-facing shell.
+300 -6
View File
@@ -10,7 +10,108 @@ use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use truck_modeling as mt;
use crate::body::{Body, from_p3};
use crate::{BoolOp, Result};
use crate::{BoolOp, KernelError, Result};
const MAX_PLANAR_SEGMENTS: usize = 8192;
const MAX_PLANAR_EVENTS: usize = 131_072;
const MAX_PLANAR_PAIRS: usize = 16_000_000;
/// Boolean of planar regions bounded by straight lines and circular arcs. Boundaries stay
/// analytic; disjoint results and retained islands are returned as separate regions.
pub fn planar_regions_boolean(a: &[Region2], b: &[Region2], op: BoolOp, tolerance: f64) -> Result<Vec<Region2>> {
crate::guard("planar region boolean", || {
if !tolerance.is_finite() || !(1e-9..=1.0).contains(&tolerance) {
return Err(KernelError::Invalid("planar boolean tolerance must be finite and between 1e-9 and 1 mm".into()));
}
let loops = || a.iter().chain(b).flat_map(|r| std::iter::once(&r.outer).chain(&r.holes));
let mut edges = 0usize;
let mut samples = 0usize;
for lp in loops() {
edges = edges.saturating_add(lp.segs.len());
if edges > MAX_PLANAR_SEGMENTS || lp.segs.is_empty() || !lp.is_closed(tolerance * 10.0) {
return Err(KernelError::Invalid("planar boolean needs closed loops within its geometry budget".into()));
}
for s in &lp.segs {
let finite = |p: Vec2| p.x.is_finite() && p.y.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9;
let valid = match *s {
Seg2::Line { a, b } => finite(a) && finite(b) && a.dist(b) > 1e-12,
Seg2::Arc { center, radius, start, sweep } => {
finite(center)
&& radius.is_finite()
&& radius > 0.0
&& radius < 1e9
&& start.is_finite()
&& sweep.is_finite()
&& sweep.abs() > 1e-12
&& sweep.abs() <= std::f64::consts::TAU + 1e-9
}
_ => false,
};
if !valid {
return Err(KernelError::Invalid("planar boolean supports finite nondegenerate lines and circular arcs".into()));
}
samples = samples.saturating_add(s.polyline(1e-3).len());
if samples > 65_536 {
return Err(KernelError::Invalid("planar boolean sampled geometry exceeds its budget".into()));
}
}
}
boolean2d(a, b, op, tolerance)
.ok_or_else(|| KernelError::Failed("planar region arrangement failed or exceeded its bounded processing budget".into()))
})
}
/// Extrude normalized planar material as one logical compound, retaining every disjoint
/// region and its holes. Planar arrangement certifies separation before shells are joined.
pub fn extrude_planar_regions(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Body> {
crate::guard("planar material extrusion", || {
// Validate aggregate inputs first; an arrangement compares opposite operands,
// so regions in the same operand must be normalized through balanced unions.
planar_regions_boolean(regions, &[], BoolOp::Union, 1e-7)?;
let mut groups: Vec<Vec<Region2>> = regions.iter().cloned().map(|r| vec![r]).collect();
let mut remaining_pairs = 32_000_000usize;
while groups.len() > 1 {
let mut next = Vec::new();
let mut iter = groups.into_iter();
while let Some(a) = iter.next() {
if let Some(b) = iter.next() {
let count = |rs: &[Region2]| {
rs.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).fold(0usize, |n, l| n.saturating_add(l.segs.len()))
};
remaining_pairs = remaining_pairs
.checked_sub(count(&a).saturating_mul(count(&b)))
.ok_or_else(|| KernelError::Invalid("planar extrusion arrangement exceeds its work budget".into()))?;
next.push(planar_regions_boolean(&a, &b, BoolOp::Union, 1e-7)?);
} else {
next.push(a);
}
}
groups = next;
}
let normalized = groups.pop().unwrap_or_default();
if normalized.is_empty() {
return Err(KernelError::Invalid("empty planar material extrusion".into()));
}
for (i, a) in normalized.iter().enumerate() {
for b in normalized.iter().skip(i + 1) {
for s in std::iter::once(&a.outer).chain(&a.holes).flat_map(|l| &l.segs) {
for t in std::iter::once(&b.outer).chain(&b.holes).flat_map(|l| &l.segs) {
remaining_pairs = remaining_pairs
.checked_sub(1)
.ok_or_else(|| KernelError::Invalid("planar extrusion separation exceeds its work budget".into()))?;
let endpoints_touch = [s.start(), s.end()].iter().any(|p| t.point_at(t.closest_param(*p)).dist(*p) <= 1e-7)
|| [t.start(), t.end()].iter().any(|p| s.point_at(s.closest_param(*p)).dist(*p) <= 1e-7);
if endpoints_touch || !crossings(s, t, 1e-7).is_empty() {
return Err(KernelError::Invalid("planar material pieces touch at a boundary".into()));
}
}
}
}
}
let pieces = crate::extrude(plane, &normalized, start, end)?;
crate::ops::join_pieces(pieces)?.ok_or_else(|| KernelError::Failed("empty planar material extrusion".into()))
})
}
/// A prism: the plane of its bottom cap (normal along the walls), its height and section.
struct Prism {
@@ -191,9 +292,83 @@ fn crossings(s: &Seg2, t: &Seg2, tol: f64) -> Vec<(f64, f64)> {
out
}
/// Exact point-in-loop by winding (arcs as fine polylines are enough away from the boundary).
/// Ray parity against analytic lines/arcs. Fragment classification must not depend on a
/// display chord tolerance: a tiny tool can sit between a circular boundary and its chords.
fn inside_loop(lp: &Loop2, p: Vec2) -> bool {
let mut odd = false;
for seg in &lp.segs {
match *seg {
Seg2::Line { a, b } => {
if (a.y > p.y) != (b.y > p.y) {
let x = a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y);
if x > p.x {
odd = !odd;
}
}
}
Seg2::Arc { center, radius, start, sweep } => {
let y = (p.y - center.y) / radius;
if !(-1.0..=1.0).contains(&y) {
continue;
}
let a = y.asin();
let root = (1.0 - y * y).max(0.0).sqrt();
let full = (sweep.abs() - std::f64::consts::TAU).abs() < 1e-12;
for (i, (angle, x)) in [(a, center.x + radius * root), (std::f64::consts::PI - a, center.x - radius * root)].into_iter().enumerate() {
// A tangent has one geometric root. Interior tangencies contribute zero
// parity; a minimum at an endpoint follows the same half-open rule as lines.
if root == 0.0 && i == 1 {
continue;
}
if x <= p.x {
continue;
}
let mut delta = if sweep > 0.0 { angle - start } else { start - angle };
delta = delta.rem_euclid(std::f64::consts::TAU);
if (delta - std::f64::consts::TAU).abs() < 1e-12 {
delta = 0.0;
}
if delta > sweep.abs() + 1e-12 {
continue;
}
let dy = angle.cos() * sweep;
let tangent = dy.abs() < sweep.abs() * 1e-12;
let at_start = delta < 1e-12;
let at_end = (delta - sweep.abs()).abs() < 1e-12;
let crosses = if full {
!tangent
} else if at_start {
dy > 0.0 && !tangent || tangent && y < 0.0
} else if at_end {
dy < 0.0 && !tangent || tangent && y < 0.0
} else {
!tangent
};
if crosses {
odd = !odd;
}
}
}
_ => {
// Private prism callers only supply lines/arcs. Keep a controlled fallback
// for future internal callers; the public helper rejects other segment kinds.
let points = seg.polyline(1e-7);
for pair in points.windows(2) {
if let [a, b] = pair
&& (a.y > p.y) != (b.y > p.y)
&& a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y) > p.x
{
odd = !odd;
}
}
}
}
}
odd
}
fn inside(regions: &[Region2], p: Vec2) -> bool {
regions.iter().any(|r| r.contains(p))
regions.iter().any(|r| inside_loop(&r.outer, p) && !r.holes.iter().any(|h| inside_loop(h, p)))
}
fn dist_to(regions: &[Region2], p: Vec2) -> f64 {
@@ -233,19 +408,30 @@ fn oriented(regions: &[Region2]) -> Vec<Region2> {
/// 2D boolean of regions bounded by lines and arcs.
fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option<Vec<Region2>> {
let count =
|rs: &[Region2]| rs.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).fold(0usize, |n, lp| n.saturating_add(lp.segs.len()));
let (na, nb) = (count(a), count(b));
if na.saturating_add(nb) > MAX_PLANAR_SEGMENTS || na.saturating_mul(nb) > MAX_PLANAR_PAIRS {
return None;
}
let (a, b) = (oriented(a), oriented(b));
let segs =
|rs: &[Region2]| -> Vec<Seg2> { rs.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).flat_map(|l| l.segs.clone()).collect() };
let (sa, sb) = (segs(&a), segs(&b));
if sa.len() * sb.len() > 4_000_000 || sa.iter().chain(&sb).any(|s| !matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) {
if sa.iter().chain(&sb).any(|s| !matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) {
return None;
}
// Split every segment where it crosses the other side's boundary.
let mut cut_a: Vec<Vec<f64>> = vec![Vec::new(); sa.len()];
let mut cut_b: Vec<Vec<f64>> = vec![Vec::new(); sb.len()];
let mut events = 0usize;
for (i, s) in sa.iter().enumerate() {
for (j, t) in sb.iter().enumerate() {
for (u, v) in crossings(s, t, tol) {
events += 2;
if events > MAX_PLANAR_EVENTS {
return None;
}
cut_a.get_mut(i)?.push(u);
cut_b.get_mut(j)?.push(v);
}
@@ -257,6 +443,10 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option<Vec<R
for p in [t.start(), t.end()] {
let u = s.closest_param(p);
if s.point_at(u).dist(p) < tol {
events += 1;
if events > MAX_PLANAR_EVENTS {
return None;
}
cut_a.get_mut(i)?.push(u);
}
}
@@ -267,6 +457,10 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option<Vec<R
for p in [s.start(), s.end()] {
let v = t.closest_param(p);
if t.point_at(v).dist(p) < tol {
events += 1;
if events > MAX_PLANAR_EVENTS {
return None;
}
cut_b.get_mut(j)?.push(v);
}
}
@@ -295,6 +489,9 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option<Vec<R
let mut kept: Vec<Seg2> = Vec::new();
// A's pieces.
for (i, s) in sa.iter().enumerate() {
if cut_a.get(i)?.len() > MAX_PLANAR_SEGMENTS {
return None;
}
for p in pieces(s, cut_a.get_mut(i)?) {
let m = p.point_at(0.5);
let on = dist_to(&b, m) < tol;
@@ -312,18 +509,27 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option<Vec<R
}
};
if keep {
if kept.len() >= MAX_PLANAR_SEGMENTS {
return None;
}
kept.push(p);
}
}
}
// B's pieces (coincident ones were decided with A's).
for (j, t) in sb.iter().enumerate() {
if cut_b.get(j)?.len() > MAX_PLANAR_SEGMENTS {
return None;
}
for p in pieces(t, cut_b.get_mut(j)?) {
let m = p.point_at(0.5);
if dist_to(&a, m) < tol {
continue;
}
let ins = inside(&a, m);
if kept.len() >= MAX_PLANAR_SEGMENTS {
return None;
}
match op {
BoolOp::Union if !ins => kept.push(p),
BoolOp::Intersect if ins => kept.push(p),
@@ -351,7 +557,9 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option<Vec<R
return None;
}
let end = segs.last()?.end();
if end.dist(first) < tol * 10.0 && segs.len() > 1 {
let full_circle =
segs.first().is_some_and(|s| matches!(s, Seg2::Arc { sweep, .. } if (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9));
if end.dist(first) < tol * 10.0 && (segs.len() > 1 || full_circle) {
break;
}
let next = (0..kept.len())
@@ -379,7 +587,7 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option<Vec<R
let mut regions: Vec<Region2> = outers.into_iter().map(|o| Region2 { outer: o, holes: Vec::new() }).collect();
for h in holes {
let p = h.segs.first()?.point_at(0.5);
let r = regions.iter_mut().find(|r| r.outer.contains(p) && r.outer.signed_area().abs() > h.signed_area().abs())?;
let r = regions.iter_mut().find(|r| inside_loop(&r.outer, p) && r.outer.signed_area().abs() > h.signed_area().abs())?;
r.holes.push(h);
}
Some(regions)
@@ -707,3 +915,89 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>>
pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
crate::guard("prism boolean", || Ok(simple_prism_boolean(a, b, op).or_else(|| stack_boolean(a, b, op)))).ok().flatten()
}
#[cfg(test)]
mod planar_tests {
use super::*;
fn rectangle(a: Vec2, b: Vec2) -> Region2 {
Region2 { outer: Loop2::polygon(&[a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)]), holes: vec![] }
}
#[test]
fn compound_planar_extrusion_normalizes_overlap_and_retains_islands() {
let plane = Plane::named("XY").unwrap();
let a = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0));
let b = rectangle(Vec2::new(5.0, 0.0), Vec2::new(15.0, 10.0));
let body = extrude_planar_regions(&plane, &[a.clone(), b], 0.0, 2.0).unwrap();
assert!((crate::measure(&body).unwrap().volume - 300.0).abs() < 1e-6);
let inner = Region2 { outer: Loop2::circle(Vec2::new(5.0, 5.0), 2.0), holes: vec![] };
let body = extrude_planar_regions(&plane, &[a.clone(), inner.clone()], 0.0, 2.0).unwrap();
assert!((crate::measure(&body).unwrap().volume - 200.0).abs() < 1e-6);
let annulus = Region2 { outer: a.outer, holes: vec![Loop2::circle(Vec2::new(5.0, 5.0), 3.0)] };
let body = extrude_planar_regions(&plane, &[annulus, inner], 0.0, 2.0).unwrap();
let expected = (100.0 - std::f64::consts::PI * 5.0) * 2.0;
assert!((crate::measure(&body).unwrap().volume - expected).abs() < 0.1);
assert_eq!(body.solid.boundaries().len(), 2);
let a = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0));
let b = rectangle(Vec2::new(10.0, 10.0), Vec2::new(20.0, 20.0));
assert!(extrude_planar_regions(&plane, &[a, b], 0.0, 2.0).is_err());
let circles = [Vec2::ZERO, Vec2::new(10.0, 0.0)].map(|center| Region2 { outer: Loop2::circle(center, 5.0), holes: vec![] });
assert!(extrude_planar_regions(&plane, &circles, 0.0, 2.0).is_err());
}
#[test]
fn analytic_circle_classification_respects_signed_sweeps_and_seams() {
for seam in [0.0, 0.13, std::f64::consts::FRAC_PI_2, -std::f64::consts::FRAC_PI_2] {
for reversed in [false, true] {
let lp = Loop2::circle_from(Vec2::ZERO, 10.0, seam);
let lp = if reversed { lp.reversed() } else { lp };
for p in [Vec2::ZERO, Vec2::new(9.9998, 0.0), Vec2::new(0.0, 9.9998), Vec2::new(0.0, -9.9998)] {
assert!(inside_loop(&lp, p), "{seam} {reversed} {p:?}");
}
for p in [Vec2::new(11.0, 0.0), Vec2::new(0.0, 10.0), Vec2::new(0.0, -10.0)] {
assert!(!inside_loop(&lp, p), "{seam} {reversed} {p:?}");
}
}
}
let half = Loop2 {
segs: vec![
Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI },
Seg2::Line { a: Vec2::new(0.0, 10.0), b: Vec2::new(0.0, -10.0) },
],
};
for lp in [half.clone(), half.reversed()] {
assert!(inside_loop(&lp, Vec2::new(5.0, 0.0)));
assert!(!inside_loop(&lp, Vec2::new(-5.0, 0.0)));
assert!(!inside_loop(&lp, Vec2::new(-5.0, -10.0)));
}
}
#[test]
fn tiny_tool_between_circle_and_display_chord_is_not_lost() {
let circle = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.0), holes: vec![] };
let sampled = circle.outer.polyline(1e-3);
let direction = (sampled[0] + sampled[1]).normalized().unwrap();
let at = direction * 9.9998;
let half = Vec2::new(0.000025, 0.000025);
let square = rectangle(at - half, at + half);
assert!(square.outer.segs.iter().all(|s| s.start().len() < 10.0));
assert!(!circle.contains(at), "fixture must lie outside the display chord");
let cut = planar_regions_boolean(std::slice::from_ref(&circle), std::slice::from_ref(&square), BoolOp::Cut, 1e-7).unwrap();
assert_eq!(cut.len(), 1);
assert_eq!(cut[0].holes.len(), 1);
assert!((cut[0].holes[0].signed_area().abs() - 2.5e-9).abs() < 1e-13);
let intersect = planar_regions_boolean(&[circle], &[square], BoolOp::Intersect, 1e-7).unwrap();
assert_eq!(intersect.len(), 1);
assert!((intersect[0].area() - 2.5e-9).abs() < 1e-13);
}
#[test]
fn planar_region_api_rejects_hostile_values_and_budgets() {
let plate = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0));
for tolerance in [f64::NAN, f64::INFINITY, 0.0, -1.0] {
assert!(planar_regions_boolean(std::slice::from_ref(&plate), &[], BoolOp::Union, tolerance).is_err());
}
let mut bad = plate.clone();
bad.outer.segs[0] = Seg2::Line { a: Vec2::new(f64::NAN, 0.0), b: Vec2::X };
assert!(planar_regions_boolean(&[bad], &[], BoolOp::Union, 1e-7).is_err());
let many = vec![plate; MAX_PLANAR_SEGMENTS / 4 + 1];
assert!(planar_regions_boolean(&many, &[], BoolOp::Union, 1e-7).is_err());
}
}
+132 -2
View File
@@ -193,6 +193,72 @@ fn faces_connected(faces: &[mt::Face]) -> bool {
seen.len() == faces.len()
}
/// Keep disconnected closed boundaries as distinct shells, preserving orientation.
fn closed_components(faces: Vec<mt::Face>) -> Result<Vec<mt::Shell>> {
const MAX_EDGE_USES: usize = 500_000;
const MAX_COMPONENTS: usize = 1024;
let fail = |s: &str| KernelError::Invalid(format!("sew: {s}"));
let mut by_edge: HashMap<mt::EdgeID, Vec<usize>> = HashMap::new();
let mut edge_uses = 0usize;
for (i, face) in faces.iter().enumerate() {
for edge in face.edge_iter() {
edge_uses = edge_uses.saturating_add(1);
if edge_uses > MAX_EDGE_USES {
return Err(fail("edge budget exceeded"));
}
let uses = by_edge.entry(edge.id()).or_default();
if uses.len() >= 2 {
return Err(fail("nonmanifold edge contact"));
}
uses.push(i);
}
}
if by_edge.values().any(|uses| uses.len() != 2) {
return Err(fail("the faces do not close"));
}
let mut seen = vec![false; faces.len()];
let mut vertices = HashMap::new();
let mut components = Vec::new();
for first in 0..faces.len() {
if seen.get(first).copied().unwrap_or(true) {
continue;
}
if components.len() >= MAX_COMPONENTS {
return Err(fail("component budget exceeded"));
}
let component = components.len();
let mut todo = vec![first];
if let Some(slot) = seen.get_mut(first) {
*slot = true;
}
let mut indices = Vec::new();
while let Some(i) = todo.pop() {
let face = faces.get(i).ok_or_else(|| fail("missing component face"))?;
indices.push(i);
for vertex in face.vertex_iter() {
if vertices.insert(vertex.id(), component).is_some_and(|previous| previous != component) {
return Err(fail("bodies touch only at a vertex"));
}
}
for edge in face.edge_iter() {
let adjacent = by_edge.get(&edge.id()).ok_or_else(|| fail("missing component edge"))?;
for j in adjacent {
let slot = seen.get_mut(*j).ok_or_else(|| fail("missing adjacent face"))?;
if !*slot {
*slot = true;
todo.push(*j);
}
}
}
}
indices.sort_unstable();
let component_faces =
indices.into_iter().map(|i| faces.get(i).cloned().ok_or_else(|| fail("missing component face"))).collect::<Result<Vec<_>>>()?;
components.push(component_faces.into());
}
Ok(components)
}
/// A contact's edge sheets can terminate in separate vertex fans. Give each fan
/// its own vertex at the same coordinates so the boundary remains a manifold.
fn split_vertex_fans(faces: Vec<mt::Face>) -> Result<Vec<mt::Face>> {
@@ -285,8 +351,13 @@ fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result<Body> {
if faces.len() < 4 || faces.len() > 200_000 {
return Err(KernelError::Invalid("a solid needs 4…200000 faces".into()));
}
let mut edge_uses = 0usize;
for f in faces {
for l in &f.loops {
edge_uses = edge_uses.saturating_add(l.len().max(1));
if edge_uses > 500_000 {
return Err(KernelError::Invalid("sew: edge budget exceeded".into()));
}
for e in l {
let (a, b) = e.ends();
if !a.is_finite() || !b.is_finite() || a.dist(b) < 1e-9 {
@@ -331,8 +402,8 @@ fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result<Body> {
if !sheets.is_empty() {
out = split_vertex_fans(out)?;
}
let shell: mt::Shell = out.into();
let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("sew: the faces do not close: {e}")))?;
let shells = closed_components(out)?;
let solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("sew: the faces do not close: {e}")))?;
Body::new(solid)
})
}
@@ -363,6 +434,65 @@ mod tests {
}
}
fn box_faces(origin: Vec3, size: Vec3) -> Vec<FaceSpec> {
let p = |x: f64, y: f64, z: f64| origin + Vec3::new(x * size.x, y * size.y, z * size.z);
let (a, b, c, d) = (p(0., 0., 0.), p(1., 0., 0.), p(1., 1., 0.), p(0., 1., 0.));
let (e, f, g, h) = (p(0., 0., 1.), p(1., 0., 1.), p(1., 1., 1.), p(0., 1., 1.));
vec![quad(a, d, c, b), quad(e, f, g, h), quad(a, b, f, e), quad(b, c, g, f), quad(c, d, h, g), quad(d, a, e, h)]
}
#[test]
fn disconnected_closed_faces_keep_every_material_component() {
let mut faces = box_faces(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0));
faces.extend(box_faces(Vec3::X * 10.0, Vec3::new(2.0, 3.0, 4.0)));
for reverse in [false, true] {
if reverse {
faces.reverse();
}
let body = sew(&faces).unwrap();
assert_eq!(body.shell_count(), 2);
assert!(body.validity().is_empty());
assert!((crate::measure(&body).unwrap().volume - 48.0).abs() < 1e-9);
let lumps = body.lumps().unwrap();
assert_eq!(lumps.len(), 2);
assert!(lumps.iter().all(|b| (crate::measure(b).unwrap().volume - 24.0).abs() < 1e-9));
let mesh = body.tessellate(0.01).unwrap();
assert!(mesh.contains(Vec3::new(1.0, 1.0, 1.0)));
assert!(mesh.contains(Vec3::new(11.0, 1.0, 1.0)));
assert!(!mesh.contains(Vec3::new(5.0, 1.0, 1.0)));
let imported = crate::step_import(&crate::step_export(&[&body], "sewn pieces").unwrap()).unwrap();
assert!(imported.warnings.is_empty());
let volume: f64 = imported.bodies.iter().map(|b| crate::measure(&b.body).unwrap().volume).sum();
assert!((volume - 48.0).abs() < 1e-6);
}
}
#[test]
fn sewing_preserves_inward_cavity_orientation_and_refuses_vertex_contact() {
let outer = box_faces(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0));
let mut inner = box_faces(Vec3::new(0.5, 0.75, 1.0), Vec3::new(1.0, 1.5, 2.0));
for face in &mut inner {
for lp in &mut face.loops {
lp.reverse();
for edge in lp {
if let EdgeSpec::Line { a, b } = edge {
std::mem::swap(a, b);
}
}
}
}
let cavity = sew(&outer.iter().chain(&inner).cloned().collect::<Vec<_>>()).unwrap();
assert_eq!(cavity.shell_count(), 2);
assert_eq!(cavity.lumps().unwrap().len(), 1);
assert!((crate::measure(&cavity).unwrap().volume - 21.0).abs() < 1e-9);
assert!(!cavity.tessellate(0.01).unwrap().contains(Vec3::new(1.0, 1.5, 2.0)));
let touching = box_faces(Vec3::new(2.0, 3.0, 4.0), Vec3::new(2.0, 3.0, 4.0));
assert!(sew(&outer.iter().chain(&touching).cloned().collect::<Vec<_>>()).unwrap_err().to_string().contains("vertex"));
let mut hostile = outer.clone();
hostile[0].loops = vec![Vec::new(); 500_001];
assert!(sew(&hostile).unwrap_err().to_string().contains("budget"));
}
#[test]
fn a_sewn_box() {
let p = |x: f64, y: f64, z: f64| Vec3::new(x, y, z);
+222
View File
@@ -0,0 +1,222 @@
//! Group disconnected material regions without moving a cavity to another enclosing shell.
//! A valid shell hierarchy alternates material and void at every nesting level.
use solvecraft_geom::{Aabb3, Mesh, Vec3};
use crate::{KernelError, Result};
pub(super) const MAX_SHELLS: usize = 1024;
pub(super) const MAX_POINTS: usize = 100_000;
pub(super) const MAX_TRIANGLES: usize = 100_000;
const MAX_PAIR_WORK: usize = 40_000_000;
const MAX_PROBES: usize = 2_000_000;
const MAX_CONTAINMENT_WORK: usize = 40_000_000;
fn failed(detail: &str) -> KernelError {
KernelError::Failed(format!("lumps: {detail}"))
}
fn box_of(points: &[Vec3]) -> Aabb3 {
let mut bounds = Aabb3::EMPTY;
for p in points {
bounds.add(*p);
}
bounds
}
fn boxes_separate(a: Aabb3, b: Aabb3, gap: f64) -> bool {
a.max.x < b.min.x - gap
|| b.max.x < a.min.x - gap
|| a.max.y < b.min.y - gap
|| b.max.y < a.min.y - gap
|| a.max.z < b.min.z - gap
|| b.max.z < a.min.z - gap
}
fn unit(v: Vec3) -> Option<Vec3> {
let scale = v.x.abs().max(v.y.abs()).max(v.z.abs());
if !scale.is_finite() || scale == 0.0 {
return None;
}
(v / scale).normalized()
}
/// Separating axes include the in-plane edge normals for coplanar triangles.
fn triangles_meet(a: &[Vec3; 3], b: &[Vec3; 3], gap: f64) -> bool {
let [a0, a1, a2] = *a;
let [b0, b1, b2] = *b;
let ea = [a1 - a0, a2 - a1, a0 - a2];
let eb = [b1 - b0, b2 - b1, b0 - b2];
let na = (a1 - a0).cross(a2 - a0);
let nb = (b1 - b0).cross(b2 - b0);
let separated = |axis: Vec3| {
let Some(n) = unit(axis) else { return false };
// Translate projections first so a far-away body does not lose its clearance.
let range = |points: &[Vec3; 3]| {
points.iter().map(|p| (*p - a0).dot(n)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| (lo.min(x), hi.max(x)))
};
let ((alo, ahi), (blo, bhi)) = (range(a), range(b));
ahi < blo - gap || bhi < alo - gap
};
if separated(na) || separated(nb) {
return false;
}
for e in ea {
if separated(na.cross(e)) {
return false;
}
for f in eb {
if separated(e.cross(f)) {
return false;
}
}
}
for e in eb {
if separated(nb.cross(e)) {
return false;
}
}
true
}
pub(super) fn groups(meshes: &[Mesh], volumes: &[f64], gap: f64) -> Result<Vec<Vec<usize>>> {
if meshes.is_empty() || meshes.len() != volumes.len() || meshes.len() > MAX_SHELLS {
return Err(failed("shell count exceeds the grouping limit or is invalid"));
}
if !gap.is_finite() || gap <= 0.0 {
return Err(failed("invalid shell clearance"));
}
let mut points = 0usize;
let mut triangle_count = 0usize;
let mut triangles = Vec::with_capacity(meshes.len());
let mut triangle_bounds = Vec::with_capacity(meshes.len());
let mut bounds = Vec::with_capacity(meshes.len());
for (mesh, volume) in meshes.iter().zip(volumes) {
points = points.checked_add(mesh.positions.len()).ok_or_else(|| failed("point count exceeds the grouping limit"))?;
triangle_count = triangle_count.checked_add(mesh.triangles.len()).ok_or_else(|| failed("triangle count exceeds the grouping limit"))?;
if points > MAX_POINTS || triangle_count > MAX_TRIANGLES {
return Err(failed("mesh exceeds the shell grouping limit"));
}
if !volume.is_finite()
|| *volume == 0.0
|| mesh.positions.is_empty()
|| mesh.triangles.is_empty()
|| mesh.positions.iter().any(|p| !p.is_finite())
{
return Err(failed("cannot determine a closed shell's orientation"));
}
let tris: Vec<_> = mesh.triangles.iter().map(|t| mesh.tri(t).ok_or_else(|| failed("invalid shell triangle"))).collect::<Result<_>>()?;
if tris.iter().any(|[a, b, c]| unit((*b - *a).cross(*c - *a)).is_none()) {
return Err(failed("degenerate shell triangle"));
}
triangle_bounds.push(tris.iter().map(|t| box_of(t)).collect::<Vec<_>>());
triangles.push(tris);
bounds.push(mesh.bounds());
}
let mut inside = vec![vec![false; meshes.len()]; meshes.len()];
let mut pair_work = 0usize;
let mut probes = 0usize;
let mut containment_work = 0usize;
for (i, mesh) in meshes.iter().enumerate() {
for j in i + 1..meshes.len() {
let (Some(a_bounds), Some(b_bounds)) = (bounds.get(i), bounds.get(j)) else { return Err(failed("missing shell bounds")) };
if boxes_separate(*a_bounds, *b_bounds, gap) {
continue;
}
let (Some(a), Some(b)) = (triangles.get(i), triangles.get(j)) else { return Err(failed("missing shell triangles")) };
let work = a.len().checked_mul(b.len()).ok_or_else(|| failed("shell boundary work exceeds the grouping limit"))?;
pair_work = pair_work.checked_add(work).ok_or_else(|| failed("shell boundary work exceeds the grouping limit"))?;
if pair_work > MAX_PAIR_WORK {
return Err(failed("shell boundary work exceeds the grouping limit"));
}
for (ai, ta) in a.iter().enumerate() {
for (bi, tb) in b.iter().enumerate() {
let (Some(ba), Some(bb)) = (triangle_bounds.get(i).and_then(|x| x.get(ai)), triangle_bounds.get(j).and_then(|x| x.get(bi)))
else {
return Err(failed("missing triangle bounds"));
};
if !boxes_separate(*ba, *bb, gap) && triangles_meet(ta, tb, gap) {
return Err(failed("shell boundaries intersect or touch; cavity ownership is ambiguous"));
}
}
}
for (inner, outer) in [(i, j), (j, i)] {
let ib = bounds.get(inner).ok_or_else(|| failed("missing shell bounds"))?;
let ob = bounds.get(outer).ok_or_else(|| failed("missing shell bounds"))?;
// With boundary intersections already excluded, a connected shell
// extending beyond another's bounds cannot be wholly contained.
if ib.min.x < ob.min.x - gap
|| ib.min.y < ob.min.y - gap
|| ib.min.z < ob.min.z - gap
|| ib.max.x > ob.max.x + gap
|| ib.max.y > ob.max.y + gap
|| ib.max.z > ob.max.z + gap
{
continue;
}
let inner_mesh = if inner == i { mesh } else { meshes.get(inner).ok_or_else(|| failed("missing shell"))? };
probes = probes.checked_add(inner_mesh.positions.len()).ok_or_else(|| failed("shell probes exceed the grouping limit"))?;
if probes > MAX_PROBES {
return Err(failed("shell probes exceed the grouping limit"));
}
let outer_mesh = meshes.get(outer).ok_or_else(|| failed("missing shell"))?;
let probe_count = inner_mesh.positions.len();
let work = probe_count
.checked_mul(outer_mesh.triangles.len())
.and_then(|n| n.checked_mul(3))
.ok_or_else(|| failed("shell containment work exceeds the grouping limit"))?;
containment_work = containment_work.checked_add(work).ok_or_else(|| failed("shell containment work exceeds the grouping limit"))?;
if containment_work > MAX_CONTAINMENT_WORK {
return Err(failed("shell containment work exceeds the grouping limit"));
}
let mut contained = None;
for p in inner_mesh.positions.iter().copied() {
let hit = outer_mesh.contains(p);
if contained.is_some_and(|previous| previous != hit) {
return Err(failed("shell containment is inconsistent; cavity ownership is ambiguous"));
}
contained = Some(hit);
}
if let Some(slot) = inside.get_mut(inner).and_then(|row| row.get_mut(outer)) {
*slot = contained == Some(true);
}
}
}
}
let mut parent = Vec::with_capacity(meshes.len());
for i in 0..meshes.len() {
let row = inside.get(i).ok_or_else(|| failed("missing containment"))?;
let candidates: Vec<_> = row.iter().enumerate().filter_map(|(j, hit)| hit.then_some(j)).collect();
for (k, a) in candidates.iter().enumerate() {
for b in candidates.iter().skip(k + 1) {
if !inside.get(*a).and_then(|r| r.get(*b)).copied().unwrap_or(false)
&& !inside.get(*b).and_then(|r| r.get(*a)).copied().unwrap_or(false)
{
return Err(failed("multiple overlapping shell owners"));
}
}
}
let owner = candidates.into_iter().min_by(|a, b| {
volumes.get(*a).copied().unwrap_or(f64::INFINITY).abs().total_cmp(&volumes.get(*b).copied().unwrap_or(f64::INFINITY).abs())
});
let outward = volumes.get(i).is_some_and(|v| *v > 0.0);
match owner {
None if !outward => return Err(failed("inward shell has no enclosing material shell")),
Some(p) if volumes.get(p).is_some_and(|v| (*v > 0.0) == outward) => {
return Err(failed("nested shells do not alternate material and void"));
}
_ => {}
}
parent.push(owner);
}
let mut groups = Vec::new();
for (i, v) in volumes.iter().enumerate() {
if *v <= 0.0 {
continue;
}
let mut group = vec![i];
group.extend(parent.iter().enumerate().filter_map(|(j, p)| (*p == Some(i)).then_some(j)));
groups.push(group);
}
Ok(groups)
}