diff --git a/crates/kernel/src/boolean_history_tests.rs b/crates/kernel/src/boolean_history_tests.rs index ab4bdd6215..f090cec832 100644 --- a/crates/kernel/src/boolean_history_tests.rs +++ b/crates/kernel/src/boolean_history_tests.rs @@ -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); diff --git a/crates/smlib/src/export.rs b/crates/smlib/src/export.rs index 90c0c027ac..3bff85f43d 100644 --- a/crates/smlib/src/export.rs +++ b/crates/smlib/src/export.rs @@ -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, + /// The array edgeuse record of each use, loop by loop as `loops` (none for a vertex loop). + records: Vec>, } #[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> = 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, shells: Vec>, regions: Vec, + radial: Option, +} + +/// 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, + /// 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, + /// 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>, +} + +/// 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>) -> Res> { + 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::>>()?; + let next = next.iter().map(|n| idx(*n, n_edgeuses, "next radial edgeuse")).collect::>>()?; + // 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::>>()?; + 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> = 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, 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_source: &mut Vec, radial: Option<&Radial>) -> Res<()> { + // The records using each edge, and where each record is: (face, loop, position). + let mut records_of: Vec> = vec![Vec::new(); edges.len()]; + let mut place: HashMap = 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 = (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 { + 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 { Ok(material_same(r)? != top_entry(r)?) }; + let before = |r: usize| -> Res { 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 { + let mut pair_of: HashMap = 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>> { diff --git a/crates/smlib/src/export/tests.rs b/crates/smlib/src/export/tests.rs index e48c29c329..a8a4c5a9ed 100644 --- a/crates/smlib/src/export/tests.rs +++ b/crates/smlib/src/export/tests.rs @@ -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 = 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 = + 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::() / 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();