solidcraft-smlib: a solid touching itself along an edge comes back with every edge bounding two faces

An SMLib result where one shell uses an edge four or more times (a hole tangent to the outline,
touching holes, stepped cuts touching along a line) was refused as "not supported yet: a solid
touching itself along an edge". The export now reads SMLib's radial order round each edge
(EDGEUSE_NEXT_RADIAL, EDGEUSE_RADIAL_ENTRY) and the side of each face its material is on, and
gives each pair of faces across one sector its own copy of the edge: the pairs across material,
so the surface is cut apart round each wedge of material, unless one face would get two copies
(a face on both sides of the line), which SMLib's check refuses as coincident edges (tests 33,
34); then the pairs across the empty sectors, so that face keeps one edge on both sides, as
SMLib's crack edges do. The vertices at the ends stay shared: copies would give a face passing
the point twice two coincident vertices, which SMLib refuses too (test 32). Geometry unchanged;
each copy keeps its edge's tolerance and history (Order.pieces). The radial arrays are checked
as hostile input (in range, cycles on their own edge).

Tests: a hole tangent to a block's outline exports with the line copied once per wedge (each
copy joining faces on one side of it), every edge two uses, and goes back into SMLib valid at
the same volume; a blind hole tangent to a side leaves the side a crack; through SMLib both come
back as one solid with verified history and empty validity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
jyviko
2026-10-21 13:26:19 -04:00
parent 36b7aa267d
commit d22e89bcc7
3 changed files with 298 additions and 5 deletions
@@ -614,6 +614,28 @@ mod smlib {
assert!((v - 2000.0).abs() < 1e-6 * 2000.0, "{v}");
}
/// A block touching itself along a line: a through hole tangent to its side from inside, and
/// a blind hole tangent to its side (the side then on both sides of the line). SMLib's
/// result uses the tangent line four times; it comes back as one solid whose edges each bound
/// two faces, of the block's volume less the hole's, every face and edge's history verified
/// against the geometry.
#[test]
fn holes_tangent_to_the_side_come_back_as_one_solid() {
let a = boxed(0.0, 0.0, 0.0, 30.0, 20.0, 10.0);
for (name, z0, depth) in [("through", -1.0, 12.0), ("blind", 2.0, 9.0)] {
let b = crate::cylinder(Vec3::new(10.0, 15.0, z0), Vec3::Z, 5.0, depth).unwrap();
let r = through_smlib(&a, &b, BoolOp::Cut);
assert_eq!(r.solid.boundaries().len(), 1, "{name}");
let record = crate::brep_record(&r).unwrap();
assert!(record.edges.iter().all(|e| e.uses.len() == 2), "{name}");
let hole = std::f64::consts::PI * 25.0 * (10.0 - z0.max(0.0));
let v = crate::measure(&r).unwrap().volume;
assert!((v - (6000.0 - hole)).abs() < 1e-4 * 6000.0, "{name}: {v}");
assert!(r.validity().is_empty(), "{name}: {:?}", r.validity());
verified(&r, &[&a, &b], 0.02);
}
}
#[test]
fn disjoint_intersection_is_empty() {
let a = boxed(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
+187 -5
View File
@@ -19,6 +19,10 @@
//! oriented faces as the side in the region that is not material uses them (out of the
//! material). A sheet's one shell holds both uses of each face: the `same` one is kept. The
//! infinite region is never a void (SMLib marks it one).
//! - **Touching:** an edge or vertex solids touching each other share is copied once per solid;
//! an edge one solid touches itself along (four or more uses in a shell) is copied once per
//! pair of faces SMLib's radial order puts across one sector round it, so every edge bounds
//! two faces.
//! - **Order:** faces, edges and vertices renumbered in the canonical walk; each edge's uses
//! from that walk (`upper = !forward`), never SMLib's radial entry flags.
//! - **Pcurves:** computed from each edge record on its face's surface record
@@ -160,6 +164,8 @@ const I64_IDS: &[i32] = &[
LOOP_VERTEX,
EDGEUSE_EDGE,
EDGEUSE_ORIENTATION,
EDGEUSE_NEXT_RADIAL,
EDGEUSE_RADIAL_ENTRY,
EDGE_CURVE_TYPE,
EDGE_VERTICES,
EDGE_NURBS_VERTEX_COUNT,
@@ -572,6 +578,8 @@ struct Face {
/// SMLib's natural normal is the record's.
agrees: bool,
loops: Vec<Loop>,
/// The array edgeuse record of each use, loop by loop as `loops` (none for a vertex loop).
records: Vec<Vec<usize>>,
}
#[derive(Clone)]
@@ -696,14 +704,17 @@ fn native_ordered(arrays: &Arrays) -> Res<(b::Brep, Report, Order)> {
let natural = surface.natural();
let domain = [onto([r[0], r[2]], natural[0]), onto([r[1], r[3]], natural[1])];
let mut out = Vec::with_capacity(loops.len());
let mut records = Vec::with_capacity(loops.len());
for l in loops.clone() {
let uses = loop_edgeuses.get(l).ok_or_else(|| invalid("loop"))?;
if uses.is_empty() {
let v = idx(at(flat.i(LOOP_VERTEX), l, "loop vertex")?, n_vertices, "loop vertex")?;
out.push(Loop::Vertex(index(v)?));
records.push(Vec::new());
continue;
}
let mut l = Vec::with_capacity(uses.len());
records.push(uses.clone().collect());
for u in uses.clone() {
let e = idx(at(flat.i(EDGEUSE_EDGE), u, "edgeuse")?, n_edges, "edgeuse edge")?;
let same = at(flat.i(EDGEUSE_ORIENTATION), u, "edgeuse")? == 1;
@@ -712,7 +723,7 @@ fn native_ordered(arrays: &Arrays) -> Res<(b::Brep, Report, Order)> {
}
out.push(Loop::Edges(l));
}
faces.push(Face { surface, domain, agrees: true, loops: out });
faces.push(Face { surface, domain, agrees: true, loops: out, records });
}
for face in &mut faces {
if outer_area(face, &edges, largest)? < 0.0 {
@@ -723,6 +734,7 @@ fn native_ordered(arrays: &Arrays) -> Res<(b::Brep, Report, Order)> {
uses.iter_mut().for_each(|u| u.1 = !u.1);
}
}
face.records.iter_mut().for_each(|r| r.reverse());
report.reversed_faces += 1;
}
}
@@ -816,7 +828,13 @@ fn native_ordered(arrays: &Arrays) -> Res<(b::Brep, Report, Order)> {
}
let shells: Vec<Vec<(u32, bool)>> = order.iter().filter_map(|s| shells.get(*s as usize).cloned()).collect();
let parts = Parts { faces, edges, shells, regions };
// Round each edge: SMLib's radial order of its uses, and the side of each face the material
// is on, which say which faces bound the same material where a solid touches itself.
let material_same =
face_uses.iter().map(|uses| uses.iter().find(|(s, _)| material(region_of.get(*s).copied().unwrap_or(0))).map(|(_, same)| *same)).collect();
let radial = radial(flat, n_edgeuses, n_edges, material_same)?;
let parts = Parts { faces, edges, shells, regions, radial };
let made = match canonical(parts.clone(), n_vertices, flat, &tolerances) {
// SMLib's own tolerance for an edge or vertex can understate what its approximated
// geometry needs here (a blend's end curve 2.3e-5 mm off its plane at 2e-5): then the
@@ -837,6 +855,56 @@ struct Parts {
edges: Vec<Edge>,
shells: Vec<Vec<(u32, bool)>>,
regions: Vec<b::Region>,
radial: Option<Radial>,
}
/// SMLib's order of the faces round each edge (`EDGEUSE_NEXT_RADIAL`, `EDGEUSE_RADIAL_ENTRY`),
/// read where the arrays have it. Each edgeuse record is a face's two sides along the edge; a
/// right-hand traversal round the edge meets one side of each record, then its other side, then
/// the next record. Between two records lies one sector of space round the edge, empty or
/// material.
#[derive(Clone)]
struct Radial {
/// The next record round the edge.
next: Vec<usize>,
/// The traversal enters the record from its face's `same` side (SMLib's top edgeuse, the
/// one on the faceuse the surface normal points out of) and leaves from the other.
top_entry: Vec<bool>,
/// Per array face: whether the material is on its `same` side; none for a face with no
/// material on either side (a sheet's).
material_same: Vec<Option<bool>>,
}
/// The radial arrays, checked: in range, entries 0 or 1, each edge's records one cycle or more.
/// None where the arrays have neither (the export of a library without them).
fn radial(flat: View, n_edgeuses: usize, n_edges: usize, material_same: Vec<Option<bool>>) -> Res<Option<Radial>> {
let (next, entry) = (flat.i(EDGEUSE_NEXT_RADIAL), flat.i(EDGEUSE_RADIAL_ENTRY));
if next.is_empty() && entry.is_empty() {
return Ok(None);
}
if next.len() != n_edgeuses || entry.len() != n_edgeuses {
return Err(invalid("radial arrays"));
}
let edge = flat.i(EDGEUSE_EDGE).iter().map(|e| idx(*e, n_edges, "edgeuse edge")).collect::<Res<Vec<_>>>()?;
let next = next.iter().map(|n| idx(*n, n_edgeuses, "next radial edgeuse")).collect::<Res<Vec<_>>>()?;
// Each record is some record's next (the lists are cycles), on its own edge.
let mut reached = vec![false; n_edgeuses];
for (u, n) in next.iter().enumerate() {
let slot = reached.get_mut(*n).ok_or_else(|| invalid("next radial edgeuse"))?;
if *slot || edge.get(u) != edge.get(*n) {
return Err(invalid("a radial list that is not a cycle round its edge"));
}
*slot = true;
}
let top_entry = entry
.iter()
.map(|x| match x {
0 => Ok(false),
1 => Ok(true),
_ => Err(invalid("radial entry")),
})
.collect::<Res<Vec<_>>>()?;
Ok(Some(Radial { next, top_entry, material_same }))
}
/// The record in canonical order: faces in first appearance through the shells, edges through
@@ -862,10 +930,11 @@ impl Tolerances {
fn canonical(parts: Parts, n_vertices: usize, flat: View, tolerances: &Tolerances) -> Res<(b::Brep, Order)> {
let tolerance = tolerances.body;
let Parts { mut faces, mut edges, shells, regions } = parts;
let Parts { mut faces, mut edges, shells, regions, radial } = parts;
let n_edges = edges.len();
// A copy made by the split keeps the tolerance of the array edge or vertex it copies.
let (edge_source, vertex_source) = split_shared(&mut faces, &mut edges, &shells, n_vertices)?;
// A copy made by the splits keeps the tolerance of the array edge or vertex it copies.
let (mut edge_source, vertex_source) = split_shared(&mut faces, &mut edges, &shells, n_vertices)?;
split_touching(&mut faces, &mut edges, &mut edge_source, radial.as_ref())?;
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());
@@ -1112,6 +1181,119 @@ fn split_shared(faces: &mut [Face], edges: &mut Vec<Edge>, shells: &[Vec<(u32, b
Ok((edge_source, vertex_source))
}
/// Edges one shell uses more than twice, where a solid touches itself along a line (a hole
/// tangent to its outline, holes touching each other), split so that every edge bounds two faces:
/// SMLib's radial order round the edge pairs each record with the next one across a sector, and
/// each pair gets its own copy of the edge. The pairs are those across a material sector, so the
/// surface is cut apart along the line round each wedge of material, unless that would give one
/// face two copies (a face on both sides of the line, its loop running along one copy and back
/// along the other), which SMLib refuses (its check's tests 33 and 34, coincident edges in a
/// face): then the pairs across the empty sectors, so that face meets itself across the line as
/// SMLib's crack edges do, one edge used twice. The vertices at the ends stay shared, the points
/// where the boundary meets itself: copies would give a face whose loop passes the point twice two
/// coincident vertices, which SMLib also refuses (test 32). The geometry is unchanged; each copy
/// is added to `edge_source` with its original's source.
fn split_touching(faces: &mut [Face], edges: &mut Vec<Edge>, edge_source: &mut Vec<usize>, radial: Option<&Radial>) -> Res<()> {
// The records using each edge, and where each record is: (face, loop, position).
let mut records_of: Vec<Vec<usize>> = vec![Vec::new(); edges.len()];
let mut place: HashMap<usize, (usize, usize, usize)> = HashMap::new();
for (f, face) in faces.iter().enumerate() {
for (li, (l, records)) in face.loops.iter().zip(&face.records).enumerate() {
let Loop::Edges(uses) = l else { continue };
if records.len() != uses.len() {
return Err(invalid("loop records"));
}
for (p, (&(e, _), &r)) in uses.iter().zip(records).enumerate() {
records_of.get_mut(e as usize).ok_or_else(|| invalid("edge"))?.push(r);
if place.insert(r, (f, li, p)).is_some() {
return Err(invalid("an edgeuse record in two loops"));
}
}
}
}
let crowded: Vec<usize> = (0..edges.len()).filter(|e| records_of.get(*e).is_some_and(|r| r.len() > 2)).collect();
if crowded.is_empty() {
return Ok(());
}
let radial = radial.ok_or_else(|| unsupported("a solid touching itself along an edge, without SMLib's radial order"))?;
let face_of = |r: usize| place.get(&r).map(|p| p.0).ok_or_else(|| invalid("edgeuse record"));
let material_same = |r: usize| -> Res<bool> {
radial.material_same.get(face_of(r)?).copied().flatten().ok_or_else(|| unsupported("a sheet touching itself along an edge"))
};
let top_entry = |r: usize| radial.top_entry.get(r).copied().ok_or_else(|| invalid("radial entry"));
// Whether the sector the traversal leaves a record into, and the one it enters a record
// from, is material: it leaves from the side it did not enter.
let after = |r: usize| -> Res<bool> { Ok(material_same(r)? != top_entry(r)?) };
let before = |r: usize| -> Res<bool> { Ok(material_same(r)? == top_entry(r)?) };
for e in crowded {
let mine = records_of.get(e).cloned().unwrap_or_default();
let start = *mine.first().ok_or_else(|| invalid("edge"))?;
// The edge's radial list from one of its records, keeping this edge's (another solid's
// faces may lie between them where solids touch each other too).
let mut cycle = Vec::with_capacity(mine.len());
let mut r = start;
for _ in 0..radial.next.len() {
if mine.contains(&r) {
cycle.push(r);
}
r = radial.next.get(r).copied().ok_or_else(|| invalid("next radial edgeuse"))?;
if r == start {
break;
}
}
if r != start || cycle.len() != mine.len() {
return Err(invalid("a radial list that does not go once round its edge's uses"));
}
let k = cycle.len();
let at = |i: usize| cycle.get(i % k).copied().ok_or_else(|| invalid("radial list"));
// Sectors alternate material and space, each seen alike from the records on its sides.
let mut alternate = k % 2 == 0;
for i in 0..k {
let (a, b) = (at(i)?, at(i + 1)?);
alternate &= after(a)? == before(b)? && after(a)? != after(b)?;
}
if !alternate {
return Err(unsupported("a solid touching itself along an edge whose faces do not alternate between material and space round it"));
}
// The pairs across material sectors, unless one face would get two copies; then those
// across empty sectors, unless that does too.
let material = if after(at(0)?)? { 0 } else { 1 };
let one_copy_per_face = |first: usize| -> Res<bool> {
let mut pair_of: HashMap<usize, usize> = HashMap::new();
for i in 0..k {
let f = face_of(at(first + i)?)?;
if *pair_of.entry(f).or_insert(i / 2) != i / 2 {
return Ok(false);
}
}
Ok(true)
};
let first = if one_copy_per_face(material)? || !one_copy_per_face(1 - material)? { material } else { 1 - material };
for pair in 0..k / 2 {
let (a, b) = (at(first + 2 * pair)?, at(first + 2 * pair + 1)?);
let copy = if pair == 0 {
e
} else {
let c = edges.len();
edges.push(edges.get(e).ok_or_else(|| invalid("edge"))?.clone());
edge_source.push(edge_source.get(e).copied().ok_or_else(|| invalid("edge"))?);
c
};
for r in [a, b] {
let (f, li, p) = place.get(&r).copied().ok_or_else(|| invalid("edgeuse record"))?;
let slot = faces
.get_mut(f)
.and_then(|face| face.loops.get_mut(li))
.and_then(|l| if let Loop::Edges(u) = l { u.get_mut(p) } else { None })
.ok_or_else(|| invalid("edgeuse record"))?;
slot.0 = index(copy)?;
}
}
}
Ok(())
}
/// 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>>> {
+89
View File
@@ -106,6 +106,95 @@ mod native_tests {
assert!((half.unwrap() + 0.5f64.atan()).abs() < 1e-12);
}
/// A hole tangent to the block's outline from inside: the material touches itself along the
/// tangent line, where SMLib's one shell uses the edge four times. The record has two copies
/// of that edge, each bounding one wedge of material (a face of the side and the half of the
/// hole on the same side of the line), so every edge has two uses; the line's two vertices
/// stay shared. The record goes back into SMLib as a valid solid of the same volume.
#[test]
fn a_hole_tangent_to_the_outline_comes_back_as_a_manifold_boundary() {
let block = Brep::make_box([0.0; 3], [31.0, 29.0, 10.0]).unwrap();
let hole = Brep::make_cylinder([5.0, 14.5, -1.0], 5.0, 12.0).unwrap();
let r = block.boolean(&hole, crate::BooleanOp::Subtract).unwrap().unwrap();
assert!(!r.is_manifold_solid().unwrap(), "SMLib keeps the touching boundary as one non-manifold shell");
let (record, order) = r.to_native_ordered().unwrap();
let [f, e, v] = r.counts().unwrap();
assert_eq!((record.faces.len(), record.edges.len(), record.vertices.len()), (f, e + 1, v));
assert_eq!((record.shells.len(), record.regions.len()), (1, 2));
assert!(record.edges.iter().all(|e| e.uses.len() == 2));
record.check_geometry().unwrap();
let on_line = |e: &b::Edge| {
e.vertices.iter().all(|v| {
let p = record.vertices[*v as usize].point;
p[0].abs() < 1e-9 && (p[1] - 14.5).abs() < 1e-9
})
};
let middle = |e: &b::Edge| b::eval::CurveEval::new(&e.curve).unwrap().point(0.5 * (e.interval[0] + e.interval[1])).unwrap();
let copies: Vec<&b::Edge> = record.edges.iter().filter(|e| on_line(e)).collect();
assert_eq!(copies.len(), 2);
for copy in copies {
// Each face's other edges lie on one side of y = 14.5: the same side for both faces.
let sides: Vec<f64> = copy
.uses
.iter()
.map(|u| {
let b::Loop::Edges { edges } = &record.faces[u.face as usize].loops[u.r#loop as usize] else { panic!("vertex loop") };
let others: Vec<f64> =
edges.iter().map(|o| &record.edges[o.edge as usize]).filter(|o| !on_line(o)).map(|o| middle(o).y - 14.5).collect();
others.iter().sum::<f64>() / others.len() as f64
})
.collect();
assert!(sides[0] * sides[1] > 0.0, "the copy joins faces on opposite sides of the line: {sides:?}");
}
// SMLib's history of the tangent line goes to both copies.
assert_eq!(order.pieces.len(), 2);
let back = Brep::from_record(&record, true).unwrap();
let check = back.check(CheckLevel::All).unwrap();
assert!(check.is_valid(), "{check}");
let want = 31.0 * 29.0 * 10.0 - PI * 25.0 * 10.0;
let got = back.mass_properties(MEASURE_ACCURACY).unwrap().volume;
assert!((got - want).abs() < 1e-6 * want, "volume {got} vs {want}");
}
/// A blind hole from z = 2 tangent to the block's side y = 20 from inside: the side is one
/// face on both sides of the tangent line (joined below the hole), so pairing the faces round
/// the line across the material would give it two coincident copies of the line, which
/// SMLib refuses. The line is then copied across the empty sectors: the side uses one copy
/// twice (a crack, as SMLib keeps a face meeting itself), the hole's two halves the other.
#[test]
fn a_blind_hole_tangent_to_a_side_leaves_the_side_a_crack() {
let block = Brep::make_box([0.0; 3], [30.0, 20.0, 10.0]).unwrap();
let hole = Brep::make_cylinder([10.0, 15.0, 2.0], 5.0, 9.0).unwrap();
let r = block.boolean(&hole, crate::BooleanOp::Subtract).unwrap().unwrap();
let (record, _) = r.to_native_ordered().unwrap();
assert!(record.edges.iter().all(|e| e.uses.len() == 2));
record.check_geometry().unwrap();
let on_line = |e: &b::Edge| {
e.vertices.iter().all(|v| {
let p = record.vertices[*v as usize].point;
(p[0] - 10.0).abs() < 1e-9 && (p[1] - 20.0).abs() < 1e-9
})
};
let copies: Vec<&b::Edge> = record.edges.iter().filter(|e| on_line(e)).collect();
assert_eq!(copies.len(), 2);
let kinds: Vec<(bool, bool)> = copies
.iter()
.map(|e| {
let faces: Vec<&b::Face> = e.uses.iter().map(|u| &record.faces[u.face as usize]).collect();
let side = |f: &b::Face| matches!(f.surface, b::Surface::Plane { .. });
(faces.iter().all(|f| side(f)), e.uses[0].face == e.uses[1].face)
})
.collect();
assert!(kinds.contains(&(true, true)), "one copy is the side's crack: {kinds:?}");
assert!(kinds.contains(&(false, false)), "the other joins the hole's halves: {kinds:?}");
let back = Brep::from_record(&record, true).unwrap();
let check = back.check(CheckLevel::All).unwrap();
assert!(check.is_valid(), "{check}");
let want = 6000.0 - PI * 25.0 * 8.0;
let got = back.mass_properties(MEASURE_ACCURACY).unwrap().volume;
assert!((got - want).abs() < 1e-6 * want, "volume {got} vs {want}");
}
#[test]
fn hostile_arrays_are_errors_never_panics() {
let good = Brep::make_box([0.0; 3], [1.0, 2.0, 3.0]).unwrap().arrays().unwrap();