kernel: solids an SMLib boolean leaves touching come back as separate solids

The fork's booleans now hand out closed non-manifold results (SMLIB_REV e87e9b3, ABI 3): solids
touching each other along an edge or at a vertex, which the C API refused as "not a closed
solid". The SMLib → schema 4 mapping now gives each closed boundary its own record shell: the
faces sharing one runtime shell on the empty side and one on the material side, so solids that
share their empty side's runtime shell get a shell each. An edge or vertex faces of more than
one shell use is split into one copy per shell (same curve, interval and point), so every
shell is a closed manifold boundary; regions list each shell as before. The history follows: an
Order's `pieces` give each record edge of a split SMLib edge with the faces it bounds, and each
piece keeps the edge's origins.

SMLib's own check refuses the radial order of faces round an edge solids share, so a
non-manifold result is checked as the separate solids it becomes (imported as given). A solid
touching itself along an edge (one shell using an edge more than twice) is still refused, now
as "not supported yet: a solid touching itself along an edge".

Tests: cubes sharing an edge or a vertex unite into two shells of 1000 mm³ each, every face and
edge's history verified against the geometry; two opposite quarters cut off a slab leave two
solids. Of the pinch tests run through SMLib, cut_leaving_pieces_meeting_along_a_line now
passes; the other nine are solids touching themselves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
This commit is contained in:
jyviko
2026-10-21 10:13:34 -04:00
parent 5dd3acdec0
commit b900d6d469
7 changed files with 254 additions and 72 deletions
@@ -585,6 +585,35 @@ mod smlib {
assert_eq!(meets(h).len(), 4, "{}", show(h));
}
/// Cubes sharing an edge, and cubes sharing a vertex: SMLib's union touches itself there;
/// it comes back as two closed shells, the shared edge and vertices split into one copy per
/// cube, each copy with its edge's history (verified against the geometry).
#[test]
fn cubes_touching_along_an_edge_or_at_a_vertex_unite_into_two_solids() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
for (name, b) in [("edge", boxed(10.0, 10.0, 0.0, 20.0, 20.0, 10.0)), ("vertex", boxed(10.0, 10.0, 10.0, 20.0, 20.0, 20.0))] {
let r = through_smlib(&a, &b, BoolOp::Union);
assert_eq!(r.solid.boundaries().len(), 2, "{name}");
assert_eq!((r.face_count(), r.edge_count()), (12, 24), "{name}");
let v = crate::measure(&r).unwrap().volume;
assert!((v - 2000.0).abs() < 1e-6 * 2000.0, "{name}: {v}");
let h = verified(&r, &[&a, &b], 0.02);
assert!(h.faces.iter().all(|f| f.origins.len() == 1), "{name}: {}", show(h));
}
}
/// Pieces of a cut meeting along a line (two opposite quarters of a slab cut away) come
/// back as separate solids rather than a refusal.
#[test]
fn a_cut_leaving_pieces_along_a_line_gives_two_solids() {
let a = boxed(0.0, 0.0, 0.0, 20.0, 20.0, 10.0);
let once = through_smlib(&a, &boxed(10.0, -1.0, -1.0, 21.0, 10.0, 11.0), BoolOp::Cut);
let r = through_smlib(&once, &boxed(-1.0, 10.0, -1.0, 10.0, 21.0, 11.0), BoolOp::Cut);
assert_eq!(r.solid.boundaries().len(), 2);
let v = crate::measure(&r).unwrap().volume;
assert!((v - 2000.0).abs() < 1e-6 * 2000.0, "{v}");
}
#[test]
fn disjoint_intersection_is_empty() {
let a = boxed(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
+12 -1
View File
@@ -35,7 +35,18 @@ mod smlib {
BoolOp::Intersect => BooleanOp::Intersect,
};
let Some(r) = ta.boolean(&tb, kind).map_err(|e| failed("operation", e))? else { return Ok(None) };
let check = r.check(CheckLevel::All).map_err(|e| failed("check", e))?;
// Solids touching each other along an edge or at a vertex come back as one non-manifold
// body, which the export splits into separate solids: SMLib's check is of those, imported
// as given (its check of the touching arrangement itself refuses the radial order of
// faces round an edge they share).
let manifold = r.is_manifold_solid().map_err(|e| failed("check", e))?;
let check = if manifold {
r.check(CheckLevel::All).map_err(|e| failed("check", e))?
} else {
let record = r.to_native().map_err(|e| failed("export", e))?;
let split = solidcraft_smlib::Brep::from_record(&record, true).map_err(|e| failed("the separate solids", e))?;
split.check(CheckLevel::All).map_err(|e| failed("check", e))?
};
if !check.is_valid() {
// Whether an operand failed it already says whose the fault is.
let operands: Vec<&str> = [("the first operand", &sa), ("the second operand", &sb)]
+1 -1
View File
@@ -1 +1 @@
ffcf56d195d1d0c98c5190dd993d61732f099642
e87e9b3bdc348b00de5238963f387bfa4ba337d6
+157 -53
View File
@@ -34,7 +34,7 @@ use solidcraft_geom::Vec3;
use solidcraft_geom::brep as b;
use crate::flat::{self, *};
use crate::history::Order;
use crate::history::{Order, Piece};
use crate::import::Arrays;
use crate::{Brep, Result, error, ffi, init};
@@ -576,6 +576,7 @@ enum Loop {
}
/// One edge before renumbering, along its record's parameter.
#[derive(Clone)]
struct Edge {
curve: b::Curve,
interval: [f64; 2],
@@ -727,66 +728,59 @@ fn native_ordered(arrays: &Arrays) -> Res<(b::Brep, Report, Order)> {
face_uses.get_mut(f).ok_or_else(|| invalid("face"))?.push((s, same));
}
}
let mut shell_of: Vec<Option<u32>> = vec![None; n_shells];
let mut shells: Vec<Vec<(u32, bool)>> = Vec::new();
for s in 0..n_shells {
if shell_of.get(s).copied().flatten().is_some() {
continue;
}
let uses = shell_faceuses.get(s).ok_or_else(|| invalid("shell"))?;
let (f0, _) = faceuse(uses.start).map_err(|_| invalid("empty shell"))?;
let mate = match face_uses.get(f0).map(Vec::as_slice) {
Some([(a, _), (b, _)]) => {
if *a == s {
*b
} else {
*a
}
}
_ => return Err(invalid("a face without its two uses")),
};
let side = if mate == s {
s
} else {
let (rs, rm) = (region_of.get(s).copied().unwrap_or(0), region_of.get(mate).copied().unwrap_or(0));
match (material(rs), material(rm)) {
(false, true) => s,
(true, false) => mate,
_ => return Err(unsupported("a shell between two material regions or two empty ones")),
}
};
let mut oriented = Vec::new();
for fu in shell_faceuses.get(side).ok_or_else(|| invalid("shell"))?.clone() {
// Each record shell is one closed boundary between an empty region and a material one: the
// faces with the same runtime shell on the empty side and on the material side, as the empty
// side uses them. Solids touching along an edge or at a vertex share their empty side's
// runtime shell but not their material side's, so each gets its own record shell (the edges
// and vertices they share are split below). A sheet's one shell holds both uses of each
// face: the `same` one is kept.
let mut groups: Vec<((usize, usize), Vec<(u32, bool)>)> = Vec::new();
for (s, uses) in shell_faceuses.iter().enumerate() {
for fu in uses.clone() {
let (f, same) = faceuse(fu)?;
// A sheet lists both uses of each face in its one shell: keep the `same` one.
if mate == s && !same {
continue;
}
if !face_uses.get(f).is_some_and(|u| u.iter().any(|(o, _)| *o == mate)) {
return Err(unsupported("a shell whose faces' other sides are in different shells"));
}
let key = match face_uses.get(f).map(Vec::as_slice) {
Some([(a, _), (b, _)]) if a == b => {
if !same {
continue;
}
(*a, *a)
}
Some([(a, _), (b, _)]) => {
let other = if *a == s { *b } else { *a };
let (here, there) = (region_of.get(s).copied().unwrap_or(0), region_of.get(other).copied().unwrap_or(0));
match (material(here), material(there)) {
(false, true) => (s, other),
(true, false) => continue,
_ => return Err(unsupported("a face between two material regions or two empty ones")),
}
}
_ => return Err(invalid("a face without its two uses")),
};
let agrees = faces.get(f).ok_or_else(|| invalid("face"))?.agrees;
oriented.push((index(f)?, same == agrees));
}
let id = index(shells.len())?;
shells.push(oriented);
for k in [s, mate] {
if let Some(slot) = shell_of.get_mut(k) {
*slot = Some(id);
}
let at = match groups.iter().position(|(k, _)| *k == key) {
Some(at) => at,
None => {
groups.push((key, Vec::new()));
groups.len() - 1
}
};
groups.get_mut(at).ok_or_else(|| invalid("shell"))?.1.push((index(f)?, same == agrees));
}
}
let mut regions = Vec::with_capacity(region_shells.len());
for (r, block) in region_shells.iter().enumerate() {
let mut listed = Vec::new();
for s in block.clone() {
let id = shell_of.get(s).copied().flatten().ok_or_else(|| invalid("shell"))?;
if !listed.contains(&id) {
listed.push(id);
for (g, ((empty, solid), _)) in groups.iter().enumerate() {
let id = index(g)?;
if (*empty == s || *solid == s) && !listed.contains(&id) {
listed.push(id);
}
}
}
regions.push(b::Region { shells: listed, void: r > 0 && !material(r) });
}
let shells: Vec<Vec<(u32, bool)>> = groups.into_iter().map(|(_, faces)| faces).collect();
// Shells renumbered in first appearance walking the regions.
let mut order: Vec<u32> = Vec::new();
for r in &regions {
@@ -818,7 +812,10 @@ struct Parts {
/// the edges and then the vertex loops, with each face's pcurves; validated. With it, the
/// canonical index of each array face and edge.
fn canonical(parts: Parts, n_vertices: usize, flat: View, tolerance: f64) -> Res<(b::Brep, Order)> {
let Parts { faces, edges, shells, regions } = parts;
let Parts { mut faces, mut edges, shells, regions } = parts;
let n_edges = edges.len();
let (edge_source, vertex_source) = split_shared(&mut faces, &mut edges, &shells, n_vertices)?;
let n_vertices = vertex_source.len();
let mut face_new: Vec<Option<u32>> = vec![None; faces.len()];
let (mut face_order, mut direction) = (Vec::new(), Vec::new());
for shell in &shells {
@@ -865,7 +862,7 @@ fn canonical(parts: Parts, n_vertices: usize, flat: View, tolerance: f64) -> Res
return Err(unsupported("wire edges from SMLib"));
}
if edge_uses.iter().any(|u| u.len() > 2) {
return Err(unsupported("the radial order of an edge with more than two uses"));
return Err(unsupported("a solid touching itself along an edge"));
}
let mut vertex_new: Vec<Option<u32>> = vec![None; n_vertices];
let mut vertex_order = Vec::new();
@@ -934,7 +931,13 @@ fn canonical(parts: Parts, n_vertices: usize, flat: View, tolerance: f64) -> Res
face_records.push(b::Face { surface: face.surface.clone(), domain: face.domain, loops });
}
let positions = flat.f(VERTEX_POSITION);
let vertices = vertex_order.iter().map(|&v| Ok(b::Vertex { point: v3(positions, v as usize, "vertex")?, tolerance })).collect::<Res<Vec<_>>>()?;
let vertices = vertex_order
.iter()
.map(|&v| {
let at = vertex_source.get(v as usize).copied().ok_or_else(|| invalid("vertex"))?;
Ok(b::Vertex { point: v3(positions, at, "vertex")?, tolerance })
})
.collect::<Res<Vec<_>>>()?;
let shells = shells
.into_iter()
.map(|s| {
@@ -952,10 +955,111 @@ fn canonical(parts: Parts, n_vertices: usize, flat: View, tolerance: f64) -> Res
let placed = |new: &[Option<u32>]| {
new.iter().map(|i| i.map(|i| i as usize).ok_or_else(|| invalid("an entity outside the canonical walk"))).collect::<Res<Vec<_>>>()
};
let order = Order { faces: placed(&face_new)?, edges: placed(&edge_new)? };
let edge_new = placed(&edge_new)?;
// SMLib's edges split where solids touch: every piece, with the faces it bounds.
let mut pieces = Vec::new();
for (e, &canonical) in edge_new.iter().enumerate() {
let edge = edge_source.get(e).copied().ok_or_else(|| invalid("edge"))?;
if edge_source.iter().filter(|s| **s == edge).count() > 1 {
let record = brep.edges.get(canonical).ok_or_else(|| invalid("edge"))?;
let faces: std::collections::BTreeSet<usize> = record.uses.iter().map(|u| u.face as usize).collect();
pieces.push(Piece { edge, canonical, faces: faces.into_iter().collect() });
}
}
pieces.sort_by_key(|p| (p.edge, p.canonical));
let mut edges_out = Vec::with_capacity(n_edges);
for e in 0..n_edges {
edges_out
.push(pieces.iter().find(|p| p.edge == e).map_or_else(|| edge_new.get(e).copied().ok_or_else(|| invalid("edge")), |p| Ok(p.canonical))?);
}
let order = Order { faces: placed(&face_new)?, edges: edges_out, pieces };
Ok((brep, order))
}
/// Edges and vertices that solids touching each other share (faces of more than one shell use
/// them), split into one copy per shell, so each shell is a closed manifold boundary of its own.
/// The geometry is unchanged. Returns, for every edge and vertex after the split, the array edge
/// and vertex it is (a copy's original).
fn split_shared(faces: &mut [Face], edges: &mut Vec<Edge>, shells: &[Vec<(u32, bool)>], n_vertices: usize) -> Res<(Vec<usize>, Vec<usize>)> {
let mut shell_of = vec![None; faces.len()];
for (k, shell) in shells.iter().enumerate() {
for &(f, _) in shell {
*shell_of.get_mut(f as usize).ok_or_else(|| invalid("face"))? = Some(k);
}
}
let mut edge_source: Vec<usize> = (0..edges.len()).collect();
// Edges: the first shell to use one keeps it, every other gets a copy.
let mut edge_shell: Vec<Option<usize>> = vec![None; edges.len()];
let mut copies: HashMap<(u32, usize), u32> = HashMap::new();
for (f, face) in faces.iter_mut().enumerate() {
let Some(k) = shell_of.get(f).copied().flatten() else { continue };
for l in &mut face.loops {
let Loop::Edges(uses) = l else { continue };
for (e, _) in uses.iter_mut() {
let slot = edge_shell.get_mut(*e as usize).ok_or_else(|| invalid("edge"))?;
match slot {
None => *slot = Some(k),
Some(owner) if *owner == k => {}
Some(_) => {
let copy = match copies.get(&(*e, k)) {
Some(c) => *c,
None => {
let c = index(edges.len())?;
edges.push(edges.get(*e as usize).ok_or_else(|| invalid("edge"))?.clone());
edge_source.push(*e as usize);
edge_shell.push(Some(k));
copies.insert((*e, k), c);
c
}
};
*e = copy;
}
}
}
}
}
// Vertices: likewise, through the edges (each now in one shell) and the vertex loops.
let mut vertex_source: Vec<usize> = (0..n_vertices).collect();
let mut vertex_shell: Vec<Option<usize>> = vec![None; n_vertices];
let mut vertex_copies: HashMap<(u32, usize), u32> = HashMap::new();
let mut place = |v: &mut u32, k: usize| -> Res<()> {
let slot = vertex_shell.get_mut(*v as usize).ok_or_else(|| invalid("vertex"))?;
match slot {
None => *slot = Some(k),
Some(owner) if *owner == k => {}
Some(_) => {
let copy = match vertex_copies.get(&(*v, k)) {
Some(c) => *c,
None => {
let c = index(vertex_source.len())?;
vertex_source.push(*v as usize);
vertex_shell.push(Some(k));
vertex_copies.insert((*v, k), c);
c
}
};
*v = copy;
}
}
Ok(())
};
for (e, edge) in edges.iter_mut().enumerate() {
let Some(k) = edge_shell.get(e).copied().flatten() else { continue };
for v in &mut edge.vertices {
place(v, k)?;
}
}
for (f, face) in faces.iter_mut().enumerate() {
let Some(k) = shell_of.get(f).copied().flatten() else { continue };
for l in &mut face.loops {
if let Loop::Vertex(v) = l {
place(v, k)?;
}
}
}
Ok((edge_source, vertex_source))
}
/// A face's pcurves, edge loop by edge loop, from the plain-record routine on the face's
/// records, each loop moved by whole periods into the face's domain.
fn pcurves(surface: &b::Surface, domain: [[f64; 2]; 2], loops: &[Loop], edges: &[b::Edge], tolerance: f64) -> Res<Vec<Vec<b::Curve2>>> {
+1 -1
View File
@@ -7,7 +7,7 @@
use std::ffi::{c_char, c_void};
/// `SMLIB_ABI_VERSION` this crate was written against.
pub const ABI_VERSION: i32 = 2;
pub const ABI_VERSION: i32 = 3;
pub const OK: i32 = 0;
pub const ERR_EXCEPTION: i32 = -1000;
+48 -10
View File
@@ -41,10 +41,26 @@ pub struct Raw {
/// Where one body's faces and edges go in canonical order: `faces[i]` is the canonical index of
/// SMLib's face i, likewise for edges. Empty means SMLib's order is the canonical one.
///
/// An SMLib edge solids touch along (non-manifold) becomes one record edge per solid:
/// `edges[i]` is the first of them, and `pieces` lists every piece of such an edge, the first
/// too, with the canonical faces it bounds.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Order {
pub faces: Vec<usize>,
pub edges: Vec<usize>,
pub pieces: Vec<Piece>,
}
/// One record edge of an SMLib edge split where solids touch along it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Piece {
/// The SMLib edge.
pub edge: usize,
/// The record edge.
pub canonical: usize,
/// The canonical faces it bounds, ascending.
pub faces: Vec<usize>,
}
impl Brep {
@@ -122,7 +138,23 @@ impl Raw {
}
Ok(at)
};
let (fat, eat) = (place(&result.faces, self.faces.len())?, place(&result.edges, self.edges.len())?);
let fat = place(&result.faces, self.faces.len())?;
// Each raw edge's record edges with the faces each bounds where it is split (`None`: as
// SMLib has them); together a permutation of the result's edges.
let n_edges = self.edges.len() + result.pieces.len() - result.pieces.iter().map(|p| p.edge).collect::<BTreeSet<_>>().len();
let mut targets: Vec<(usize, usize, Option<&[usize]>)> = Vec::with_capacity(n_edges);
for i in 0..self.edges.len() {
let mut split = result.pieces.iter().filter(|p| p.edge == i).peekable();
if split.peek().is_none() {
targets.push((i, map(&result.edges, i)?, None));
} else {
targets.extend(split.map(|p| (i, p.canonical, Some(p.faces.as_slice()))));
}
}
let distinct: BTreeSet<usize> = targets.iter().map(|t| t.1).collect();
if targets.len() != n_edges || distinct.len() != n_edges || targets.iter().any(|t| t.1 >= n_edges) {
return Err(bad("the result's order is no permutation"));
}
let mut faces: Vec<Option<FaceProv>> = vec![None; self.faces.len()];
for (f, at) in self.faces.iter().zip(&fat) {
@@ -131,14 +163,20 @@ impl Raw {
}
let faces: Vec<FaceProv> = faces.into_iter().collect::<Option<_>>().ok_or(bad("the result's order is no permutation"))?;
let mut edges: Vec<Option<EdgeProv>> = vec![None; self.edges.len()];
for (e, at) in self.edges.iter().zip(&eat) {
let bounds: BTreeSet<usize> = e
.faces
.iter()
.map(|f| fat.get(*f).copied().ok_or(bad("an edge lies on a face outside the result")))
.collect::<std::result::Result<_, _>>()?;
let bounds: Vec<usize> = bounds.into_iter().collect();
let mut edges: Vec<Option<EdgeProv>> = vec![None; n_edges];
for &(i, at, split) in &targets {
let e = self.edges.get(i).ok_or(bad("an edge lies outside the result"))?;
let bounds: Vec<usize> = match split {
Some(faces) => faces.to_vec(),
None => {
let bounds: BTreeSet<usize> = e
.faces
.iter()
.map(|f| fat.get(*f).copied().ok_or(bad("an edge lies on a face outside the result")))
.collect::<std::result::Result<_, _>>()?;
bounds.into_iter().collect()
}
};
let origins = if e.origins.is_empty() {
// New: where its two faces meet, each side what that face came from.
let side = |f: usize| faces.get(f).and_then(|p| Side::new(p.origins.iter().copied()));
@@ -157,7 +195,7 @@ impl Raw {
.collect::<std::result::Result<_, Failed>>()?;
set.into_iter().collect()
};
edges[*at] = Some(EdgeProv { faces: bounds, origins });
*edges.get_mut(at).ok_or(bad("the result's order is no permutation"))? = Some(EdgeProv { faces: bounds, origins });
}
let edges: Vec<EdgeProv> = edges.into_iter().collect::<Option<_>>().ok_or(bad("the result's order is no permutation"))?;
Ok(History { operands: operands.len(), faces, edges })
+6 -6
View File
@@ -60,9 +60,9 @@ fn renumbers_into_canonical_order() {
let raw = Raw::decode(RECORDS).unwrap();
// Operand 0's SMLib face 2 is its canonical face 5, edge 7 its edge 0; the result's faces
// and edges are listed backwards.
let a = Order { faces: vec![1, 2, 5, 0, 3, 4], edges: vec![1, 2, 3, 4, 5, 6, 7, 0] };
let b = Order { faces: vec![0, 1, 2, 3, 4], edges: vec![0, 1, 2, 3] };
let result = Order { faces: vec![2, 1, 0], edges: vec![2, 1, 0] };
let a = Order { faces: vec![1, 2, 5, 0, 3, 4], edges: vec![1, 2, 3, 4, 5, 6, 7, 0], pieces: vec![] };
let b = Order { faces: vec![0, 1, 2, 3, 4], edges: vec![0, 1, 2, 3], pieces: vec![] };
let result = Order { faces: vec![2, 1, 0], edges: vec![2, 1, 0], pieces: vec![] };
let h = raw.history(&[a, b], &result).unwrap();
assert_eq!(h.faces[2].origins, vec![face(0, 5)]);
assert_eq!(h.faces[0].origins, vec![face(0, 1), face(1, 0)]);
@@ -79,10 +79,10 @@ fn bad_orders_are_errors() {
// An origin on an operand that isn't there.
assert!(raw.history(std::slice::from_ref(&ok), &ok).is_err());
// An operand order too short for an origin.
assert!(raw.history(&[Order { faces: vec![0], edges: vec![] }, ok.clone()], &ok).is_err());
assert!(raw.history(&[Order { faces: vec![0], edges: vec![], pieces: vec![] }, ok.clone()], &ok).is_err());
// A result order that isn't a permutation.
assert!(raw.history(&[ok.clone(), ok.clone()], &Order { faces: vec![0, 0, 1], edges: vec![] }).is_err());
assert!(raw.history(&[ok.clone(), ok.clone()], &Order { faces: vec![0, 1, 3], edges: vec![] }).is_err());
assert!(raw.history(&[ok.clone(), ok.clone()], &Order { faces: vec![0, 0, 1], edges: vec![], pieces: vec![] }).is_err());
assert!(raw.history(&[ok.clone(), ok.clone()], &Order { faces: vec![0, 1, 3], edges: vec![], pieces: vec![] }).is_err());
}
/// A box with its edge 0 (between faces 4 and 5) filleted, as SMLib reports it: the fillet face