diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index c801d39d..886581a7 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -146,7 +146,7 @@ in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied. |---|---|---|---| | vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) | | vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) | -| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro (vendor/README.md) | +| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on (vendor/README.md) | | vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) | ## Vendored renderer compatibility fix diff --git a/ROADMAP.md b/ROADMAP.md index c5186ba2..860d2c97 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -498,6 +498,14 @@ and reference-object placement remain separate work, as do loft rails and center events by strategy and reports any bounded-log truncation. Existing geometry acceptance checks remain in force. +- Cylinders of one radius whose axes cross (a second bore across a bore of the same size, a pipe + tee of equal pipes) touch at two points, where the mesh intersection cannot trace the curves + and a boolean cannot tell the four arms of the crossing apart. When the ordinary boolean fails + on such a pair, both faces are split along the line of their cylinder through the touching + points (the solids are unchanged), the boolean is taken of those, and its history is composed + back over the operands as given (#788). The result keeps the split lines as edges of the bore + walls. A crossing the ordinary route fails on and the split faces do not help (a radius of 17.5 + in a 45 mm block with a tool of 80 mm) is still refused. - Boolean intersection-curve construction refuses point-only, collapsed and non-finite polylines without panicking. Direct constructor regressions retain valid open endpoints and closed spherical intersections; the original interactive trigger remains unidentified. diff --git a/crates/kernel/src/boolean_guard.rs b/crates/kernel/src/boolean_guard.rs index ddb04914..5d52e22b 100644 --- a/crates/kernel/src/boolean_guard.rs +++ b/crates/kernel/src/boolean_guard.rs @@ -19,7 +19,7 @@ use crate::{BoolOp, KernelError, Result}; const MAX_SAMPLES: usize = 20_000; /// Distance from `p` to the triangle `abc`. -fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 { +pub(crate) fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 { let n = (b - a).cross(c - a); let Some(nn) = n.normalized() else { return [(a, b), (b, c), (c, a)].iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min); diff --git a/crates/kernel/src/issue_788_tests.rs b/crates/kernel/src/issue_788_tests.rs new file mode 100644 index 00000000..20e048d1 --- /dev/null +++ b/crates/kernel/src/issue_788_tests.rs @@ -0,0 +1,150 @@ +//! Two bores of one radius crossing each other (#788): the surfaces touch where the two ellipses +//! of the crossing meet, which a mesh intersection cannot trace and a boolean cannot take in the +//! middle of a face. +use crate::tangent_cylinders; +use crate::*; +use solidcraft_geom::Vec3; +use std::f64::consts::PI; + +const SIDE: f64 = 45.0; + +fn volume(b: &Body) -> f64 { + measure(b).unwrap().volume +} + +fn block() -> Body { + let h = SIDE / 2.0; + box_solid(Vec3::new(-h, -h, -h), Vec3::new(h, h, h)).unwrap() +} + +/// A cylinder of radius `r` along `axis` through `centre`, longer than the block. +fn bore(centre: Vec3, axis: Vec3, r: f64, length: f64) -> Body { + cylinder(centre - axis * (length / 2.0), axis, r, length).unwrap() +} + +/// The block with a bore along Z, then one of the same radius along `axis` through `centre`. +fn crossed(r: f64, centre: Vec3, axis: Vec3) -> Result> { + let one = boolean(&block(), &bore(Vec3::ZERO, Vec3::Z, r, 60.0), BoolOp::Cut)?.expect("first bore"); + boolean(&one, &bore(centre, axis, r, 60.0), BoolOp::Cut) +} + +fn within(got: f64, want: f64, what: &str) { + assert!((got - want).abs() < 2e-3 * want, "{what}: {got} vs {want}"); +} + +/// Block less two perpendicular bores through its middle: each takes `π r² L`, and the Steinmetz +/// solid they share, 16r³/3, is counted twice. +fn expected_perpendicular(r: f64) -> f64 { + SIDE.powi(3) - 2.0 * PI * r * r * SIDE + 16.0 * r.powi(3) / 3.0 +} + +#[test] +fn equal_bores_crossing_at_right_angles() { + for r in [10.0, 17.5, 4.0] { + let body = crossed(r, Vec3::ZERO, Vec3::Y).unwrap_or_else(|e| panic!("r {r}: {e}")).unwrap(); + assert_eq!(body.shell_count(), 1, "r {r}"); + within(volume(&body), expected_perpendicular(r), &format!("r {r}")); + } +} + +#[test] +fn equal_bores_crossing_along_every_axis_pair() { + let r = 10.0; + let want = expected_perpendicular(r); + for (first, second) in [(Vec3::X, Vec3::Y), (Vec3::Y, Vec3::X), (Vec3::X, Vec3::Z), (Vec3::Z, Vec3::X), (Vec3::Y, Vec3::Z)] { + let one = boolean(&block(), &bore(Vec3::ZERO, first, r, 60.0), BoolOp::Cut).unwrap().unwrap(); + let body = boolean(&one, &bore(Vec3::ZERO, second, r, 60.0), BoolOp::Cut).unwrap().unwrap(); + within(volume(&body), want, &format!("{first:?} then {second:?}")); + } +} + +#[test] +fn equal_bores_crossing_at_an_angle() { + // Two infinite cylinders of radius r at angle t share 16r³ / (3 sin t). + let (r, t) = (5.0f64, PI / 3.0); + let axis = Vec3::new(0.0, t.sin(), t.cos()); + let body = crossed(r, Vec3::ZERO, axis).unwrap().unwrap(); + let first = PI * r * r * SIDE; + let second = PI * r * r * SIDE / t.sin(); + let shared = 16.0 * r.powi(3) / (3.0 * t.sin()); + within(volume(&body), SIDE.powi(3) - first - second + shared, "60 degrees"); +} + +#[test] +fn equal_bores_crossing_off_the_middle_of_the_block() { + // Both axes meet at (3, -2, 5): the touching points are not at the block's centre. + let r = 8.0; + let c = Vec3::new(3.0, -2.0, 5.0); + let one = boolean(&block(), &bore(c, Vec3::Z, r, 60.0), BoolOp::Cut).unwrap().unwrap(); + let body = boolean(&one, &bore(c, Vec3::Y, r, 80.0), BoolOp::Cut).unwrap().unwrap(); + // The bores leave the block through its faces; the shared part lies inside it. + let removed = PI * r * r * SIDE * 2.0 - 16.0 * r.powi(3) / 3.0; + within(volume(&body), SIDE.powi(3) - removed, "off centre"); +} + +#[test] +fn equal_cylinders_joined_and_intersected() { + let r = 10.0; + let (z, y) = (bore(Vec3::ZERO, Vec3::Z, r, 60.0), bore(Vec3::ZERO, Vec3::Y, r, 60.0)); + let tee = boolean(&z, &y, BoolOp::Union).unwrap().unwrap(); + within(volume(&tee), 2.0 * PI * r * r * 60.0 - 16.0 * r.powi(3) / 3.0, "join"); + assert_eq!(tee.shell_count(), 1); + let steinmetz = boolean(&z, &y, BoolOp::Intersect).unwrap().unwrap(); + within(volume(&steinmetz), 16.0 * r.powi(3) / 3.0, "intersect"); +} + +#[test] +fn the_result_keeps_a_history_over_the_operands_as_given() { + let r = 10.0; + let one = boolean(&block(), &bore(Vec3::ZERO, Vec3::Z, r, 60.0), BoolOp::Cut).unwrap().unwrap(); + let tool = bore(Vec3::ZERO, Vec3::Y, r, 60.0); + let body = boolean(&one, &tool, BoolOp::Cut).unwrap().unwrap(); + let history = body.history().expect("the cut has a history"); + assert_eq!(history.operands, 2); + assert_eq!(history.faces.len(), body.face_count()); +} + +#[test] +fn faces_split_for_the_crossing_leave_the_operands_alone() { + // The split is on the working copies: the target keeps its faces. + let r = 10.0; + let one = boolean(&block(), &bore(Vec3::ZERO, Vec3::Z, r, 60.0), BoolOp::Cut).unwrap().unwrap(); + let (faces, volume_before) = (one.face_count(), volume(&one)); + let _ = boolean(&one, &bore(Vec3::ZERO, Vec3::Y, r, 60.0), BoolOp::Cut).unwrap(); + assert_eq!(one.face_count(), faces); + assert_eq!(volume(&one), volume_before); +} + +#[test] +fn crossings_that_are_not_touching_are_left_alone() { + let r = 10.0; + let size = 100.0; + let tol = size * 1e-6; + let cyl = |face, origin: Vec3, axis: Vec3, radius| tangent_cylinders::Cylinder::new(face, origin, axis, radius); + let z = cyl(0, Vec3::ZERO, Vec3::Z, r); + // Different radius, parallel axes, skew axes: no touching points to split at. + assert!(tangent_cylinders::crossing(&z, &cyl(1, Vec3::ZERO, Vec3::Y, r * 0.9), tol).is_none()); + assert!(tangent_cylinders::crossing(&z, &cyl(1, Vec3::new(5.0, 0.0, 0.0), Vec3::Z, r), tol).is_none()); + assert!(tangent_cylinders::crossing(&z, &cyl(1, Vec3::new(0.0, 0.0, 0.0), Vec3::new(1e-6, 0.0, 1.0), r), tol).is_none()); + assert!(tangent_cylinders::crossing(&z, &cyl(1, Vec3::new(1.0, 0.0, 0.0), Vec3::Y, r), tol).is_none()); + // Axes that meet: two touching points, a radius either side of the crossing. + let found = tangent_cylinders::crossing(&z, &cyl(1, Vec3::ZERO, Vec3::Y, r), tol).unwrap(); + assert!(found.points.iter().any(|p| p.dist(Vec3::new(r, 0.0, 0.0)) < 1e-9)); + assert!(found.points.iter().any(|p| p.dist(Vec3::new(-r, 0.0, 0.0)) < 1e-9)); +} + +#[test] +fn hostile_cylinders_are_refused() { + let tol = 1e-4; + let cyl = |axis: Vec3, radius, origin: Vec3| tangent_cylinders::Cylinder::new(0, origin, axis, radius); + let good = cyl(Vec3::Z, 10.0, Vec3::ZERO); + for bad in [ + cyl(Vec3::new(f64::NAN, 0.0, 1.0), 10.0, Vec3::ZERO), + cyl(Vec3::Y, f64::NAN, Vec3::ZERO), + cyl(Vec3::Y, f64::INFINITY, Vec3::ZERO), + cyl(Vec3::Y, 10.0, Vec3::new(f64::INFINITY, 0.0, 0.0)), + cyl(Vec3::ZERO, 10.0, Vec3::ZERO), + ] { + assert!(tangent_cylinders::crossing(&good, &bad, tol).is_none(), "{bad:?}"); + } +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 8743ea4d..e600d2b5 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -49,6 +49,8 @@ mod issue_674_tests; #[cfg(test)] mod issue_679_tests; #[cfg(test)] +mod issue_788_tests; +#[cfg(test)] mod issue_851_tests; #[cfg(test)] mod issue_871_tests; @@ -89,6 +91,7 @@ mod surface_kind; mod surface_offset; mod surfaces; mod sweep_path; +mod tangent_cylinders; mod tess; #[cfg(test)] mod thread_tests; diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index af75dc6e..14eaf396 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -300,7 +300,21 @@ thread_local! { /// The result carries its exact history ([`Body::history`]) over the operands `[a, b]`, /// whichever way it was made, or none where a step it took does not report one. pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { - let (mut r, mut diagnostics) = capture_boolean_diagnostics(|| boolean_whole(a, b, op)); + let r = boolean_via(a, b, op, &boolean_whole); + if r.is_ok() || !crate::tangent_cylinders::needed(a, b) { + return r; + } + // Equal cylinders crossing each other touch at two points the boolean cannot take in the + // middle of a face (#788): once more with the faces split along them. + let split = |a: &Body, b: &Body, op| { + crate::tangent_cylinders::boolean(a, b, op, &boolean_whole).unwrap_or_else(|| Err(KernelError::Failed("no split of faces helps".into()))) + }; + boolean_via(a, b, op, &split).or(r) +} + +/// [`boolean`], the boolean of the whole bodies taken by `whole`. +fn boolean_via(a: &Body, b: &Body, op: BoolOp, whole: &dyn Fn(&Body, &Body, BoolOp) -> Result>) -> Result> { + let (mut r, mut diagnostics) = capture_boolean_diagnostics(|| whole(a, b, op)); // Bodies in several pieces: when the whole failed (or came out empty), a piece at a time. let retry = match &r { Err(_) => true, diff --git a/crates/kernel/src/tangent_cylinders.rs b/crates/kernel/src/tangent_cylinders.rs new file mode 100644 index 00000000..c54c9477 --- /dev/null +++ b/crates/kernel/src/tangent_cylinders.rs @@ -0,0 +1,200 @@ +//! Cylinders of one radius whose axes cross (#788: a second bore of the same size drilled across +//! a bore, a pipe tee of equal pipes). +//! +//! Two such cylinders meet in two ellipses that cross each other at two points, where the +//! surfaces touch along a common normal. A mesh intersection cannot trace the curves through +//! there, and a boolean with a crossing in the middle of a face has no way to tell which of the +//! curves' four arms lies in which sector of the face. With the touching points on the edges of +//! the faces instead, each arm is a curve running from an edge to an edge, which the boolean +//! takes. So before the boolean, each of the two faces is split along the line of its cylinder +//! through the touching points (the solids are unchanged), and the result's history is composed +//! back over the operands as they were given. + +use solidcraft_geom::{Plane, Vec3}; +use truck_modeling::{ParametricCurve, SearchNearestParameter}; + +use crate::body::{Body, from_p3, p3}; +use crate::boolean_record as recorded; +use crate::history::History; +use crate::splitface::{SplitTool, split_any}; +use crate::{BoolOp, FaceSurface, Result, face_surface}; + +/// Most faces split before the boolean (a face crossing several cylinders is split for each). +const MAX_SPLITS: usize = 16; + +/// A cylindrical face and its cylinder. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Cylinder { + face: usize, + origin: Vec3, + axis: Vec3, + radius: f64, +} + +impl Cylinder { + #[cfg(test)] + pub(crate) fn new(face: usize, origin: Vec3, axis: Vec3, radius: f64) -> Self { + Self { face, origin, axis, radius } + } +} + +fn cylinders(body: &Body) -> Vec { + (0..body.face_count()) + .filter_map(|face| match face_surface(body, face)? { + FaceSurface::Cylinder { origin, axis, radius } if origin.is_finite() && axis.is_finite() && radius.is_finite() => { + Some(Cylinder { face, origin, axis: axis.normalized()?, radius }) + } + _ => None, + }) + .collect() +} + +/// Where two cylinders of one radius with crossing axes touch, and the planes that hold the +/// touching points and one cylinder's axis each. +pub(crate) struct Crossing { + pub(crate) points: [Vec3; 2], + plane_x: Plane, + plane_y: Plane, +} + +pub(crate) fn crossing(x: &Cylinder, y: &Cylinder, tol: f64) -> Option { + let finite = [x, y].iter().all(|c| c.origin.is_finite() && c.axis.is_finite() && c.radius.is_finite() && c.radius > 0.0); + if !finite || !tol.is_finite() || (x.radius - y.radius).abs() > tol { + return None; + } + // Unit axes, as the cross product below and the planes take them. + let (x, y) = (&Cylinder { axis: x.axis.normalized()?, ..*x }, &Cylinder { axis: y.axis.normalized()?, ..*y }); + let across = x.axis.cross(y.axis); + // Axes within a hair of parallel touch along a line, which is another matter. + let sine = across.len(); + if !(sine > 1e-4) { + return None; + } + // The closest points of the two axes, which meet in a point. + let (b, w) = (x.axis.dot(y.axis), x.origin - y.origin); + let (d, e) = (x.axis.dot(w), y.axis.dot(w)); + let denominator = 1.0 - b * b; + if !(denominator > 1e-12) { + return None; + } + let s = (b * e - d) / denominator; + let t = (e - b * d) / denominator; + let (on_x, on_y) = (x.origin + x.axis * s, y.origin + y.axis * t); + if !(on_x.dist(on_y) <= tol) { + return None; + } + let centre = (on_x + on_y) * 0.5; + // Both cylinders reach their common normal `c` at a radius from the crossing. + let c = across / sine; + let points = [centre + c * x.radius, centre - c * x.radius]; + Some(Crossing { points, plane_x: Plane::from_normal(centre, x.axis.cross(c))?, plane_y: Plane::from_normal(centre, y.axis.cross(c))? }) +} + +/// Whether `p` is on face `face` of the body whose mesh is `mesh`: within `reach` of one of the +/// face's triangles. +fn on_face(mesh: &solidcraft_geom::Mesh, face: usize, p: Vec3, reach: f64) -> bool { + mesh.triangles + .iter() + .zip(&mesh.tri_face) + .filter(|(_, f)| **f as usize == face) + .filter_map(|(t, _)| mesh.tri(t)) + .any(|[a, b, c]| crate::boolean_guard::point_triangle_dist(p, a, b, c) <= reach) +} + +/// Whether `p` lies on an edge of face `face` (within `margin`). +fn on_boundary(body: &Body, face: usize, p: Vec3, margin: f64) -> bool { + let Some(face) = body.solid.face_iter().nth(face) else { return false }; + face.absolute_boundaries().iter().any(|wire| { + wire.edge_iter().any(|edge| { + let curve = edge.curve(); + curve.search_nearest_parameter(p3(p), None, 100).is_some_and(|t| from_p3(curve.subs(t)).dist(p) <= margin) + }) + }) +} + +/// The split the bodies need next: `(whether in the first body, face, plane)`, if a touching +/// point of two cylinders lies on both their faces but on the edges of neither yet. +fn next_split(a: &Body, b: &Body, tol: f64) -> Option<(bool, usize, Plane)> { + let (in_b, in_a) = (cylinders(b), cylinders(a)); + if in_a.is_empty() || in_b.is_empty() { + return None; + } + let size = a.size().max(b.size()); + let (margin, chord) = ((size * 1e-6).max(1e-9), (size * 1e-3).max(1e-6)); + let mut meshes = None; + for x in &in_a { + for y in &in_b { + let Some(crossing) = crossing(x, y, tol) else { continue }; + if meshes.is_none() { + meshes = Some((a.tessellate(chord).ok()?, b.tessellate(chord).ok()?)); + } + let (ma, mb) = meshes.as_ref()?; + for p in crossing.points { + if !(on_face(ma, x.face, p, 3.0 * chord) && on_face(mb, y.face, p, 3.0 * chord)) { + continue; + } + if !on_boundary(a, x.face, p, margin) { + return Some((true, x.face, crossing.plane_x)); + } + if !on_boundary(b, y.face, p, margin) { + return Some((false, y.face, crossing.plane_y)); + } + } + } + } + None +} + +/// Whether the bodies have cylinders of one radius crossing each other that touch inside both +/// their faces, so that [`boolean`] has faces to split. +pub(crate) fn needed(a: &Body, b: &Body) -> bool { + !a.is_mesh() && !b.is_mesh() && !SPLITTING.with(|c| c.get()) && next_split(a, b, (a.size().max(b.size()) * 1e-7).max(1e-9) * 10.0).is_some() +} + +thread_local! { + /// Set while the boolean of the split bodies runs (no second round of splitting). + static SPLITTING: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// The boolean `op` of `a` and `b` with their faces split along the touching points of equal +/// cylinders crossing each other, `run` doing the boolean of the split bodies. `None` when the +/// bodies have no such cylinders, or the boolean of the split bodies gave no (checked) result: +/// the caller then takes its other ways. +pub(crate) fn boolean(a: &Body, b: &Body, op: BoolOp, run: &dyn Fn(&Body, &Body, BoolOp) -> Result>) -> Option>> { + if a.is_mesh() || b.is_mesh() || SPLITTING.with(|c| c.get()) { + return None; + } + let size = a.size().max(b.size()); + let tol = (size * 1e-7).max(1e-9) * 10.0; + let ((mut x, mut hx), (mut y, mut hy)) = ((a.clone(), Some(History::identity(a))), (b.clone(), Some(History::identity(b)))); + let mut split = false; + for _ in 0..MAX_SPLITS { + let Some((first, face, plane)) = next_split(&x, &y, tol) else { break }; + let (body, history) = if first { (&x, &hx) } else { (&y, &hy) }; + let (mut done, _) = split_any(body, &[face], &SplitTool::Plane(plane)).ok()?; + // The split is no authored boundary: a later feature may still merge what it separates. + done.regularization_protected = body.regularization_protected; + let through = recorded::through(&done, 0, history.as_ref()); + if first { + (x, hx) = (done, through); + } else { + (y, hy) = (done, through); + } + split = true; + } + if !split { + return None; + } + SPLITTING.with(|c| c.set(true)); + let result = crate::guard("boolean of split faces", || run(&x, &y, op)); + SPLITTING.with(|c| c.set(false)); + let Ok(result) = result else { return None }; + // Held to what a boolean can do, as a retry's result is (see `boolean_guard`). + crate::boolean_guard::check_retry(a, b, op, result.as_ref()).ok()?; + let counts = crate::record::counts(&[a, b]); + Some(Ok(result.map(|body| { + // Each step's history over the operands `[a, b]`. + let history = recorded::of_pair(&body, &recorded::placed(hx.clone(), 0, 2), &recorded::placed(hy.clone(), 1, 2)); + body.with_checked_history(history, &counts) + }))) +} diff --git a/docs/book/src/dev/kernel.md b/docs/book/src/dev/kernel.md index 74d56913..0b4d70fa 100644 --- a/docs/book/src/dev/kernel.md +++ b/docs/book/src/dev/kernel.md @@ -33,5 +33,11 @@ wired in with `[patch.crates-io]`. The patches fix robustness bugs found by our STEP corpus. `vendor/` is excluded from our fmt and clippy rules, and each patch should go upstream when it can. +Equal cylinders crossing each other (`tangent_cylinders.rs`) touch at two points, which truck's +mesh-based intersection cannot trace through. When a boolean of such a pair fails, the kernel +splits both cylinder faces along the line through the touching points and takes the boolean of +the split bodies; the shapeops patch that picks the corner of a face at a vertex several curves +end on makes the arms of the crossing connect. + Some truck assertions only run in debug builds and can fail on valid geometry, so tests and CI run with `--release`. diff --git a/vendor/README.md b/vendor/README.md index 948b86fc..2af44493 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -17,6 +17,15 @@ Changes: - `transversal/integrate/mod.rs`: the result's shells and faces keep the order of the shell they come from (truck's connected components followed hash order of face addresses, so a boolean's faces came out in a different order on every run). +- `transversal/loops_store/mod.rs`: an intersection edge added to a face at a vertex that more + than one wire visits (several curves end on it: the touching point of two equal cylinders) goes + into the corner that holds its direction, swept counterclockwise about the face's normal from + the wire's outgoing edge to the way back along its incoming one (`add_edge_by_angle`, told on + the polygon loops and followed by the geometry loops). Taking the first visit of each end + joined wires that are different regions of a face and left a wire visiting a vertex twice. + With no direction to go by, the edge goes where both of its ends are in one wire. +- `transversal/divide_face/mod.rs`: a face piece whose wire is not simple makes the operation + give up (`Face::try_new`) instead of panicking. - `lib.rs`: `tr!` reports where an operation gives up when `SHAPEOPS_TRACE` is set; compiler warnings no longer fail the build. - Face and edge history (`transversal/history.rs`, new): `and_with_history` / `or_with_history` diff --git a/vendor/truck-shapeops/src/transversal/divide_face/mod.rs b/vendor/truck-shapeops/src/transversal/divide_face/mod.rs index c2292b44..0226809a 100644 --- a/vendor/truck-shapeops/src/transversal/divide_face/mod.rs +++ b/vendor/truck-shapeops/src/transversal/divide_face/mod.rs @@ -140,7 +140,7 @@ where })?; let vec: Vec<_> = pre_faces .into_iter() - .map(|pre_face| { + .map(|pre_face| -> Option<_> { let surface = face.surface(); let op = pre_face .iter() @@ -153,13 +153,14 @@ where .into_iter() .map(|chunk| chunk.wire.deref().clone()) .collect(); - let mut new_face = Face::debug_new(wires, surface); + // SolidCraft: no panics (wasm can't catch them): a wire visiting a vertex twice gives up. + let mut new_face = crate::tr!(Face::try_new(wires, surface).ok(), "face of a wire visiting a vertex twice"); if !face.orientation() { new_face.invert(); } - (new_face, status) + Some((new_face, status)) }) - .collect(); + .collect::>()?; Some(vec) } diff --git a/vendor/truck-shapeops/src/transversal/loops_store/mod.rs b/vendor/truck-shapeops/src/transversal/loops_store/mod.rs index 9ae116fa..a6d4decd 100644 --- a/vendor/truck-shapeops/src/transversal/loops_store/mod.rs +++ b/vendor/truck-shapeops/src/transversal/loops_store/mod.rs @@ -232,24 +232,50 @@ impl Loops { edge0: Edge, status: ShapesOpStatus, ) -> [Option<(usize, usize)>; 2] { - let a = self.iter().enumerate().find_map(|(i, wire)| { - wire.iter().enumerate().find_map(|(j, edge)| { - if edge.front() == edge0.back() { - Some((i, j)) - } else { - None - } - }) - }); - let b = self.iter().enumerate().find_map(|(i, wire)| { - wire.iter().enumerate().find_map(|(j, edge)| { - if edge.front() == edge0.front() { - Some((i, j)) - } else { - None - } - }) - }); + self.add_edge_choosing(edge0, status, &|_, _, _| None) + } + + // SolidCraft: the edge added at the visits `chosen` (found for the same edge in the mirror + // store, whose wires and edges lie in the same order); the default where they are no visits + // of its ends. + fn add_edge_at(&mut self, edge0: Edge, status: ShapesOpStatus, chosen: [Option<(usize, usize)>; 2]) -> [Option<(usize, usize)>; 2] { + self.add_edge_choosing(edge0, status, &|_, at_back, at_front| { + let held = |visits: &[(usize, usize)], c: Option<(usize, usize)>| c.is_none_or(|v| visits.contains(&v)); + (held(at_back, chosen[0]) && held(at_front, chosen[1])).then_some(chosen) + }) + } + + // SolidCraft: `pick(loops, at_back, at_front)` may name the visits (wire, edge) the edge is + // added at, from the visits of a wire to its back and front vertices; `None` leaves it to the + // default below. + #[allow(clippy::type_complexity)] + fn add_edge_choosing( + &mut self, + edge0: Edge, + status: ShapesOpStatus, + pick: &dyn Fn(&Self, &[(usize, usize)], &[(usize, usize)]) -> Option<[Option<(usize, usize)>; 2]>, + ) -> [Option<(usize, usize)>; 2] { + // SolidCraft: a vertex several intersection curves end on (the tangent point of two equal + // cylinders, say) is visited by more than one wire. The edge belongs where both of its + // ends are: a wire holding the visit at each end. Taking the first visit of each end + // joined wires that are different regions of the face, and the face came out with a wire + // visiting a vertex twice. + let starting_at = |loops: &Self, vertex: &Vertex

| -> Vec<(usize, usize)> { + loops + .iter() + .enumerate() + .flat_map(|(i, wire)| wire.iter().enumerate().filter(|(_, edge)| edge.front() == vertex).map(move |(j, _)| (i, j))) + .collect() + }; + let (at_back, at_front) = (starting_at(self, edge0.back()), starting_at(self, edge0.front())); + let together = at_back + .iter() + .find_map(|&(i, j)| at_front.iter().find(|&&(k, _)| k == i).map(|&(_, l)| ((i, j), (i, l)))); + let (a, b) = match (pick(self, &at_back, &at_front), together) { + (Some([a, b]), _) => (a, b), + (None, Some((a, b))) => (Some(a), Some(b)), + (None, None) => (at_back.first().copied(), at_front.first().copied()), + }; if let Some((wire_index0, edge_index0)) = a { self[wire_index0].rotate_left(edge_index0); self[wire_index0].push_front(edge0.clone()); @@ -287,6 +313,91 @@ impl Loops { } } +/// SolidCraft: where an edge leaves a vertex, as a unit vector: along the polyline from its +/// front (or back) vertex to the first point a tenth of its length away, which is steadier +/// than its first segment where the mesh it was traced on is noisy. +fn leaving(edge: &Edge, from_front: bool) -> Option { + let curve = edge.curve(); + let n = curve.len(); + if n < 2 { + return None; + } + let total: f64 = (1..n).map(|k| (curve[k] - curve[k - 1]).magnitude()).sum(); + let (origin, order): (Point3, Vec) = if from_front { (curve[0], (1..n).collect()) } else { (curve[n - 1], (0..n - 1).rev().collect()) }; + let mut found = None; + for k in order { + let d = curve[k] - origin; + found = Some(d); + if d.magnitude() >= 0.1 * total { + break; + } + } + found.filter(|d| d.magnitude() > 1e-12 && d.magnitude().is_finite()).map(|d| d.normalize()) +} + +/// SolidCraft: the counterclockwise angle about `normal` from `from` to `to`, in `[0, 2π)`. +fn sweep(from: Vector3, to: Vector3, normal: Vector3) -> Option { + let e1 = from - normal * from.dot(normal); + if e1.magnitude() < 1e-9 { + return None; + } + let e1 = e1.normalize(); + let e2 = normal.cross(e1); + let angle = to.dot(e2).atan2(to.dot(e1)); + Some(if angle < 0.0 { angle + 2.0 * std::f64::consts::PI } else { angle }) +} + +impl Loops { + /// SolidCraft: the visit among `visits` (of the vertex `at`) whose corner holds the direction + /// `d` the new edge leaves in: the wedge swept counterclockwise about `normal` from the + /// wire's outgoing edge to the way back along its incoming one. `None` when no corner can be + /// told (a single visit is returned as it is). + fn corner_holding(&self, visits: &[(usize, usize)], d: Vector3, normal: Vector3) -> Option<(usize, usize)> { + if let [only] = visits { + return Some(*only); + } + let tau = 2.0 * std::f64::consts::PI; + visits + .iter() + .filter_map(|&(i, j)| { + let wire = self.get(i)?; + let out = leaving(wire.get(j)?, true)?; + let before = wire.get((j + wire.len() - 1) % wire.len())?; + let back = leaving(before, false)?; + let wedge = sweep(out, back, normal)?; + let wedge = if wedge < 1e-6 { tau } else { wedge }; + let at = sweep(out, d, normal)?; + (at > 1e-6 && at < wedge - 1e-6).then_some(((i, j), wedge)) + }) + .min_by(|x, y| x.1.total_cmp(&y.1)) + .map(|(visit, _)| visit) + } + + /// SolidCraft: [`Loops::add_edge`], with the corner at each end of the edge told by its + /// direction there (`normals`: the face's normal at its back and front vertices) where a + /// vertex is visited more than once. + fn add_edge_by_angle(&mut self, edge0: Edge, status: ShapesOpStatus, normals: [Option; 2]) -> [Option<(usize, usize)>; 2] { + let (d_back, d_front) = (leaving(&edge0, false), leaving(&edge0, true)); + let pick = |loops: &Self, at_back: &[(usize, usize)], at_front: &[(usize, usize)]| { + if at_back.len() < 2 && at_front.len() < 2 { + return None; + } + let a = match (d_back, normals[0]) { + (Some(d), Some(n)) => loops.corner_holding(at_back, d, n), + _ => None, + }; + let b = match (d_front, normals[1]) { + (Some(d), Some(n)) => loops.corner_holding(at_front, d, n), + _ => None, + }; + // A vertex visited more than once whose corner could not be told is left to the default. + let told = |visits: &[(usize, usize)], c: Option<(usize, usize)>| visits.len() < 2 || c.is_some(); + (told(at_back, a) && told(at_front, b)).then_some([a.or_else(|| at_back.first().copied()), b.or_else(|| at_front.first().copied())]) + }; + self.add_edge_choosing(edge0, status, &pick) + } +} + impl LoopsStore { #[inline(always)] fn change_vertex( @@ -575,10 +686,16 @@ where let pedge = Edge::new(&pv0, &pv1, polyline); let gedge = Edge::new(&gv0, &gv1, intersection_curve.into()); lineage.meet(gedge.id(), meeting); - poly_loops_store0[face_index0].add_edge(pedge.clone(), status0); - geom_loops_store0[face_index0].add_edge(gedge.clone(), status0); - poly_loops_store1[face_index1].add_edge(pedge, status1); - geom_loops_store1[face_index1].add_edge(gedge, status1); + let normal_at = |surface: &S, p: Point3| -> Option { + let (u, v) = surface.search_nearest_parameter(p, None, 100)?; + Some(surface.normal(u, v)).filter(|n| n.magnitude() > 0.0 && n.magnitude().is_finite()) + }; + let normals0 = [normal_at(&surface0, gv1.point()), normal_at(&surface0, gv0.point())]; + let normals1 = [normal_at(&surface1, gv1.point()), normal_at(&surface1, gv0.point())]; + let chosen0 = poly_loops_store0[face_index0].add_edge_by_angle(pedge.clone(), status0, normals0); + geom_loops_store0[face_index0].add_edge_at(gedge.clone(), status0, chosen0); + let chosen1 = poly_loops_store1[face_index1].add_edge_by_angle(pedge, status1, normals1); + geom_loops_store1[face_index1].add_edge_at(gedge, status1, chosen1); } Some(()) })