Equal cylinders crossing each other no longer refuse a cut (#788) (#1179)

Equal cylinders crossing each other no longer refuse a cut (#788).
This commit is contained in:
Lasse Lian authored and GitHub committed 2026-10-14 17:56:02 -04:00
1 parent beb3ea01e7
commit 8f081679bc
11 files changed
+537 -29

No files matched your search

+1 -1
View File
@@ -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
+8
View File
@@ -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.
+1 -1
View File
@@ -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);
+150
View File
@@ -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<Option<Body>> {
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:?}");
}
}
+3
View File
@@ -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;
+15 -1
View File
@@ -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<Option<Body>> {
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<Option<Body>>) -> Result<Option<Body>> {
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,
+200
View File
@@ -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<Cylinder> {
(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<Crossing> {
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<bool> = 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<Body>>) -> Option<Result<Option<Body>>> {
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)
})))
}
+6
View File
@@ -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`.
+9
View File
@@ -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`
+5 -4
View File
@@ -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::<Option<_>>()?;
Some(vec)
}
+139 -22
View File
@@ -232,24 +232,50 @@ impl<P: Copy, C: Clone> Loops<P, C> {
edge0: Edge<P, C>,
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<P, C>, 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<P, C>,
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<P>| -> 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<P: Copy, C: Clone> Loops<P, C> {
}
}
/// 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<Point3, PolylineCurve>, from_front: bool) -> Option<Vector3> {
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<usize>) = 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<f64> {
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<Point3, PolylineCurve> {
/// 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<Point3, PolylineCurve>, status: ShapesOpStatus, normals: [Option<Vector3>; 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<P: Copy + Tolerance, C: Clone> LoopsStore<P, C> {
#[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<Vector3> {
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(())
})