Multi profile extrusion performance (#2064)

Thanks @Koschtetnix.
This commit is contained in:
Koschtetnix authored and GitHub committed 2026-10-17 18:30:52 -04:00
1 parent 180e62c981
commit 487aa64e10
21 files changed
+1624 -78

No files matched your search

+27
View File
@@ -1494,6 +1494,33 @@ impl Body {
shell_groups::meshes_meet(&a, &b, 2.0 * tol)
}
/// The same contact test for several tools, reusing this unchanged body's
/// fine mesh at each required tolerance. No contact threshold is relaxed.
pub fn meets_each(&self, others: &[Body]) -> Result<Vec<bool>> {
if others.len() > 1024 {
return Err(KernelError::Invalid("too many contact tools".into()));
}
self.require_brep("contact test")?;
let size = self.size();
let mut meshes = std::collections::HashMap::new();
let mut contacts = Vec::with_capacity(others.len());
for other in others {
other.require_brep("contact test")?;
let tol = (size.max(other.size()) * 1e-4).max(1e-6);
let key = tol.to_bits();
if !meshes.contains_key(&key) {
// Bound retained meshes even when hostile inputs request many tolerances.
if meshes.len() == 16 {
meshes.clear();
}
meshes.insert(key, self.tessellate(tol)?);
}
let mesh = meshes.get(&key).ok_or_else(|| KernelError::Internal("contact mesh disappeared".into()))?;
contacts.push(shell_groups::meshes_meet(mesh, &other.tessellate(tol)?, 2.0 * tol)?);
}
Ok(contacts)
}
/// A boolean shortcut needs clearance larger than both shell mesh deflections.
pub(crate) fn separated_lumps(&self) -> Result<Vec<Body>> {
self.lumps_with_clearance(4.0 * (self.size() * 1e-3).max(1e-3))
+43 -1
View File
@@ -1193,10 +1193,18 @@ pub(crate) fn imprint_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool)
/// faces): every such pair of faces goes and the shells are stitched along their edges. `None`
/// when no such pair of faces exists.
pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
glue_impl(a, b, false)
}
/// Glue every certified start cap, retaining each tool's original contact tolerance.
pub(crate) fn glue_all_caps(a: &Body, b: &Body) -> Option<Result<Body>> {
glue_impl(a, b, true)
}
fn glue_impl(a: &Body, b: &Body, all_caps: bool) -> Option<Result<Body>> {
use mt::{BoundedCurve, ParametricCurve};
let size = a.size().max(b.size());
let tol = (size * 1e-7).max(1e-9) * 10.0;
let key = |p: Vec3| ((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64);
let mid = |e: &mt::Edge| {
let c = e.curve();
let (t0, t1) = c.range_tuple();
@@ -1205,14 +1213,40 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
// Matching faces: same plane, opposite normals, the same edges. Every pair is glued: a
// pair left behind would stay inside the result as a zero-thickness slit (issue #616).
let (pa, pb) = (planes(a), planes(b));
let mut tool_sizes = HashMap::new();
if all_caps {
for shell in b.solid.boundaries() {
let mut bounds = solidcraft_geom::Aabb3::EMPTY;
for vertex in shell.vertex_iter() {
bounds.add(from_p3(vertex.point()));
}
let size = bounds.diagonal();
if !size.is_finite() {
return None;
}
for face in shell.face_iter() {
tool_sizes.insert(face.id(), size);
}
}
}
let fa_list: Vec<&mt::Face> = a.solid.face_iter().collect();
let fb_list: Vec<&mt::Face> = b.solid.face_iter().collect();
// B edge → the matching A edge; B vertex → the matching A vertex.
let mut map: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
let mut vmap: HashMap<mt::VertexID, mt::Vertex> = HashMap::new();
let (mut glued_a, mut glued_b): (Vec<usize>, Vec<usize>) = (Vec::new(), Vec::new());
if all_caps && pa.len().saturating_mul(pb.len()) > 2_000_000 {
return None;
}
for (ia, x) in &pa {
for (ib, y) in &pb {
if all_caps
&& !matches!(b.face_sources().and_then(|sources| sources.get(*ib)), Some(Some(crate::FaceSource::ExtrudeCap { high: false, .. })))
{
continue;
}
let tol = if all_caps { (a.size().max(*tool_sizes.get(&fb_list.get(*ib)?.id())?) * 1e-7).max(1e-9) * 10.0 } else { tol };
let key = |p: Vec3| ((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64);
if glued_a.contains(ia) || glued_b.contains(ib) || x.n.dot(y.n) > -1.0 + 1e-9 || (x.d + y.d).abs() > tol {
continue;
}
@@ -1264,6 +1298,14 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
glued_b.push(*ib);
}
}
if all_caps
&& b.face_sources()?
.iter()
.enumerate()
.any(|(index, source)| matches!(source, Some(crate::FaceSource::ExtrudeCap { high: false, .. })) && !glued_b.contains(&index))
{
return None;
}
if glued_a.is_empty() {
return None;
}
+4 -1
View File
@@ -175,7 +175,10 @@ pub use mesh_convert::convert_prismatic_mesh;
pub use meshbody::{MAX_TRIANGLES, mesh_body};
pub use native::{decode_native_body, encode_native_body, native_preview_mesh};
pub use offset::with_same_surface;
pub use ops::{BoolOp, boolean, can_join_touching_faces, half_space, join_apart, split_by_plane, transform, transform_matrix, union_apart};
pub use ops::{
BoolOp, boolean, can_join_all_touching_faces, can_join_touching_faces, compound_disjoint_extrusions, half_space, is_planar, join_apart,
split_by_plane, transform, transform_matrix, union_apart,
};
pub use polybool::{faceted_boolean, planar_boolean};
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
pub use prism::{PLANAR_FIT_TOL, extrude_planar_regions, planar_regions_boolean};
+253 -8
View File
@@ -23,12 +23,167 @@ pub enum BoolOp {
Intersect,
}
/// Exact planar Booleans need no sampled enlargement of their tool footprint.
pub fn is_planar(body: &Body) -> bool {
if body.is_mesh() || body.face_count() > 16_384 {
return false;
}
guard("planar body", || Ok(body.solid.face_iter().all(|face| matches!(face.oriented_surface(), mt::Surface::Plane(_))))).unwrap_or(false)
}
/// Batch separated extrusion tools without repeatedly unioning an ever-growing target.
/// Construction caps provide conservative bounds, including the full circle of each arc;
/// unsupported or touching tools retain the caller's sequential Boolean path.
pub fn compound_disjoint_extrusions(tools: &[Body]) -> Result<Option<Body>> {
if tools.len() < 2 || tools.len() > 1024 {
return Ok(None);
}
guard("compound extrusion tools", || {
let mut bounds = Vec::with_capacity(tools.len());
let mut corners = Vec::with_capacity(tools.len());
let mut axes = Vec::with_capacity(tools.len());
let mut remaining = 16_384usize;
for tool in tools {
let Some(sources) = tool.face_sources() else { return Ok(None) };
if sources.len() != tool.face_count() {
return Ok(None);
}
let mut bb = solidcraft_geom::Aabb3::EMPTY;
let mut points = Some(Vec::new());
let mut directions = Vec::new();
let mut caps = 0;
for source in sources {
let Some(left) = remaining.checked_sub(1) else { return Ok(None) };
remaining = left;
match source {
Some(crate::FaceSource::ExtrudeCap { region, plane, high }) => {
caps += 1;
// The bottom support is outward-facing, but its region retains
// the original sketch coordinates (see build::extrude).
let plane = if *high { *plane } else { solidcraft_geom::Plane { y: -plane.y, ..*plane } };
for segment in &region.outer.segs {
let Some(left) = remaining.checked_sub(1) else { return Ok(None) };
remaining = left;
match *segment {
solidcraft_geom::Seg2::Line { a, b } => {
let (a, b) = (plane.to_world(a), plane.to_world(b));
bb.add(a);
bb.add(b);
if let Some(points) = points.as_mut() {
points.extend([a, b]);
}
if let Some(axis) = (b - a).cross(plane.normal()).normalized() {
directions.push(axis);
}
}
solidcraft_geom::Seg2::Arc { center, radius, .. } => {
points = None;
for x in [-radius, radius] {
for y in [-radius, radius] {
bb.add(plane.to_world(center + solidcraft_geom::Vec2::new(x, y)));
}
}
}
_ => return Ok(None),
}
}
}
Some(crate::FaceSource::ExtrudeSide { .. }) => {}
_ => return Ok(None),
}
}
if caps != 2 || bb.is_empty() || !bb.min.is_finite() || !bb.max.is_finite() {
return Ok(None);
}
bounds.push(bb);
corners.push(points);
axes.push(directions);
}
let mut remaining_separation_work = 16_000_000usize;
for (i, a) in bounds.iter().enumerate() {
for (j, b) in bounds.iter().enumerate().skip(i + 1) {
let gap = (a.diagonal().max(b.diagonal()) * 1e-6).max(1e-7);
let separated = a.max.x + gap < b.min.x
|| b.max.x + gap < a.min.x
|| a.max.y + gap < b.min.y
|| b.max.y + gap < a.min.y
|| a.max.z + gap < b.min.z
|| b.max.z + gap < a.min.z;
if separated {
continue;
}
let (Some(Some(x)), Some(Some(y))) = (corners.get(i), corners.get(j)) else { return Ok(None) };
let Some(origin) = x.first() else { return Ok(None) };
// Every straight extrusion lies inside the hull of its cap corners.
// A separating cap-edge axis therefore proves clearance even when
// rotated tools' world-coordinate boxes overlap. Arcs retain the box test.
let range = |points: &[Vec3], axis: Vec3| {
points.iter().map(|p| (*p - *origin).dot(axis)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v)))
};
let rounding = origin.x.abs().max(origin.y.abs()).max(origin.z.abs()) * f64::EPSILON * 128.0;
let separated = axes.get(i).into_iter().flatten().chain(axes.get(j).into_iter().flatten()).any(|axis| {
let Some(left) = remaining_separation_work.checked_sub(x.len().saturating_add(y.len())) else { return false };
remaining_separation_work = left;
let (a, b) = (range(x, *axis), range(y, *axis));
a.1 + gap + rounding < b.0 || b.1 + gap + rounding < a.0
});
if !separated {
return Ok(None);
}
}
}
let sources: std::collections::HashMap<_, _> = tools
.iter()
.flat_map(|tool| {
tool.solid
.face_iter()
.zip(tool.face_sources().unwrap_or_default())
.map(|(face, source)| (face.id(), (face.orientation(), source.clone())))
})
.collect();
let operands: Vec<_> = tools.iter().collect();
let shells = tools.iter().flat_map(|tool| tool.solid.boundaries().clone()).collect();
let solid = Solid::try_new(shells).map_err(|error| KernelError::Failed(format!("extrusion compound: {error}")))?;
let faces = tools
.iter()
.enumerate()
.flat_map(|(operand, tool)| (0..tool.face_count()).map(move |face| record::from_face(operand, face, crate::history::Change::Kept)))
.collect();
let mut edges = HashMap::new();
for (operand, tool) in tools.iter().enumerate() {
edges.extend(record::edge_origins(&tool.solid, operand));
}
record::build(solid, &record::counts(&operands), Some(faces), Some(&edges)).map(|mut body| {
body.regularization_protected = tools.iter().any(|tool| tool.regularization_protected);
// Only unchanged face identities carry these transient tool witnesses.
// No whole-compound extrusion/producer certificate is created.
let mapped = body
.solid
.face_iter()
.map(|face| {
sources.get(&face.id()).filter(|(orientation, _)| *orientation == face.orientation()).and_then(|(_, source)| source.clone())
})
.collect();
Some(body.with_face_sources(Some(std::sync::Arc::new(mapped))))
})
})
}
/// Whether a union can sew two matching planar faces without intersecting them.
/// Callers can retain the exact touching tool instead of extending it into material.
pub fn can_join_touching_faces(a: &Body, b: &Body) -> bool {
a.solid.boundaries().len() == 1 && b.solid.boundaries().len() == 1 && crate::coplanar::glue(a, b).is_some_and(|r| r.is_ok())
}
/// Check all separated extrusion starts in one construction, without rebuilding
/// the unchanged target once for each face. Partial seams keep the ordinary path.
pub fn can_join_all_touching_faces(a: &Body, b: &Body) -> bool {
if a.solid.boundaries().len() != 1 || !(2..=1024).contains(&b.solid.boundaries().len()) || a.face_count().max(b.face_count()) > 16_384 {
return false;
}
guard("check extrusion seams", || Ok(crate::coplanar::glue_all_caps(a, b).is_some_and(|result| result.is_ok()))).unwrap_or(false)
}
/// Shifts applied to both operands on retries. The boolean classifies some faces by casting a
/// ray whose direction is derived from point coordinates; moving both solids (and the result
/// back) leaves the geometry unchanged but changes those rays.
@@ -238,6 +393,97 @@ mod shortcut_tests {
assert!((meeting_size(&thin, &thin) - 1e-5).abs() < 1e-12);
}
#[test]
fn rotated_extrusion_bounds_use_exact_cap_separation_and_keep_sources() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let rectangle = |angle: f64, offset: f64| {
let (c, s) = (angle.cos(), angle.sin());
Region2 {
outer: Loop2::polygon(
&[(-2., -0.5), (2., -0.5), (2., 0.5), (-2., 0.5)].map(|(x, y)| Vec2::new(x * c - (y + offset) * s, x * s + (y + offset) * c)),
),
holes: vec![],
}
};
for offset in [1.2, 1., 0.8] {
let tools = crate::extrude(&Plane::XY, &[rectangle(0.7, 0.), rectangle(0.7, offset)], 0., 2.).unwrap();
let compound = compound_disjoint_extrusions(&tools).unwrap();
assert_eq!(compound.is_some(), offset > 1.);
if let Some(compound) = compound {
let sources = compound.face_sources().unwrap();
assert_eq!(sources.len(), compound.face_count());
assert_eq!(sources.iter().filter(|source| matches!(source, Some(crate::FaceSource::ExtrudeCap { high: false, .. }))).count(), 2);
assert!(compound.validity().is_empty());
}
}
}
#[test]
fn batched_contact_test_matches_individual_tolerances() {
let target = crate::cylinder(Vec3::ZERO, Vec3::Z, 5., 3.).unwrap();
let tools = [
crate::box_solid(Vec3::new(-1., -1., 3.), Vec3::new(1., 1., 5.)).unwrap(),
crate::box_solid(Vec3::new(-1., -1., 3.002), Vec3::new(1., 1., 5.)).unwrap(),
crate::sphere(Vec3::new(20., 0., 1.), 1.).unwrap(),
];
let expected = tools.iter().map(|tool| target.meets(tool).unwrap()).collect::<Vec<_>>();
assert_eq!(target.meets_each(&tools).unwrap(), expected);
assert!(target.meets_each(&[]).unwrap().is_empty());
}
#[test]
fn compound_span_does_not_relax_full_face_seam_tolerance() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let mut target = crate::box_solid(Vec3::new(0., 0., -3.), Vec3::new(100., 2., -2.)).unwrap();
for x in [0., 98.] {
let boss = crate::box_solid(Vec3::new(x, 0., -2.), Vec3::new(x + 2., 2., 0.)).unwrap();
target = boolean(&target, &boss, BoolOp::Union).unwrap().unwrap();
}
let regions: Vec<_> = [0., 98.]
.into_iter()
.map(|x| Region2 {
outer: Loop2::polygon(&[Vec2::new(x, 0.), Vec2::new(x + 2., 0.), Vec2::new(x + 2., 2.), Vec2::new(x, 2.)]),
holes: vec![],
})
.collect();
let tools = crate::extrude(&Plane::XY, &regions, 0.000_105, 200.).unwrap();
assert!(tools.iter().all(|tool| !can_join_touching_faces(&target, tool)));
let compound = compound_disjoint_extrusions(&tools).unwrap().unwrap();
assert!(!can_join_all_touching_faces(&target, &compound));
}
#[test]
fn extrusion_compound_requires_separated_construction_bounds() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let rectangle = |x: f64| Region2 {
outer: Loop2::polygon(&[Vec2::new(x, 0.0), Vec2::new(x + 2.0, 0.0), Vec2::new(x + 2.0, 2.0), Vec2::new(x, 2.0)]),
holes: vec![],
};
let plane = Plane::from_normal(Vec3::new(100.0, -20.0, 8.0), Vec3::new(1.0, 2.0, 3.0)).unwrap();
let tools = crate::extrude(&plane, &[rectangle(0.0), rectangle(20.0)], -1.0, 5.0).unwrap();
let compound = compound_disjoint_extrusions(&tools).unwrap().unwrap();
assert_eq!(compound.shell_count(), 2);
assert_eq!(compound.history().unwrap().operands, 2);
assert!((crate::measure(&compound).unwrap().volume - 48.0).abs() < 1e-8);
assert!(compound.validity().is_empty());
for x in [1.0, 2.0, 2.000_000_1] {
let touching = crate::extrude(&solidcraft_geom::Plane::XY, &[rectangle(0.0), rectangle(x)], 0.0, 5.0).unwrap();
assert!(compound_disjoint_extrusions(&touching).unwrap().is_none());
}
let unsupported = [crate::sphere(Vec3::ZERO, 1.0).unwrap(), crate::sphere(Vec3::new(10.0, 0.0, 0.0), 1.0).unwrap()];
assert!(compound_disjoint_extrusions(&unsupported).unwrap().is_none());
}
#[test]
fn extrusion_compound_includes_arc_extrema_between_seam_vertices() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let circle = |x| Region2 { outer: Loop2::circle(Vec2::new(x, 0.0), 2.0), holes: vec![] };
let separated = crate::extrude(&Plane::XY, &[circle(0.0), circle(8.0)], 0.0, 5.0).unwrap();
assert!(compound_disjoint_extrusions(&separated).unwrap().is_some());
let overlapping = crate::extrude(&Plane::XY, &[circle(0.0), circle(3.0)], 0.0, 5.0).unwrap();
assert!(compound_disjoint_extrusions(&overlapping).unwrap().is_none());
}
#[test]
fn curved_near_contact_never_uses_a_shell_only_boolean_candidate() {
let direction = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap();
@@ -756,14 +1002,13 @@ fn boolean_whole_with_refinement(a: &Body, b: &Body, op: BoolOp, refine: bool) -
}
}
// Bodies touching along one whole face (a part joined with its mirror image).
if op == BoolOp::Union
&& a.solid.boundaries().len() == 1
&& b.solid.boundaries().len() == 1
&& let Some(Ok(body)) = crate::coplanar::glue(a, b)
{
let (ok, _) = check(&body);
if ok {
return Ok(Some(body));
if op == BoolOp::Union && a.solid.boundaries().len() == 1 {
let glued = if b.solid.boundaries().len() == 1 { crate::coplanar::glue(a, b) } else { crate::coplanar::glue_all_caps(a, b) };
if let Some(Ok(body)) = glued {
let (ok, _) = check(&body);
if ok {
return Ok(Some(body));
}
}
}
// Bores on a common cylinder (a pad's bore around the hole it is joined over, a disc filling
+115 -21
View File
@@ -181,8 +181,8 @@ pub fn extrude_planar_regions(plane: &Plane, regions: &[Region2], start: f64, en
remaining_pairs = remaining_pairs
.checked_sub(1)
.ok_or_else(|| KernelError::Invalid("planar extrusion separation exceeds its work budget".into()))?;
let endpoints_touch = [s.start(), s.end()].iter().any(|p| t.point_at(t.closest_param(*p)).dist(*p) <= 1e-7)
|| [t.start(), t.end()].iter().any(|p| s.point_at(s.closest_param(*p)).dist(*p) <= 1e-7);
let endpoints_touch = [s.start(), s.end()].iter().any(|p| t.point_at(closest_param(t, *p)).dist(*p) <= 1e-7)
|| [t.start(), t.end()].iter().any(|p| s.point_at(closest_param(s, *p)).dist(*p) <= 1e-7);
if endpoints_touch || !crossings(s, t, 1e-7).is_empty() {
return Err(KernelError::Invalid("planar material pieces touch at a boundary".into()));
}
@@ -765,7 +765,7 @@ fn heights_of(b: &Body, plane: &Plane) -> (f64, f64) {
/// Does `s` run along `t` from end to end? Two lines, `s`'s ends on `t`; two arcs of one
/// circle, `s`'s ends and middle on `t`.
fn runs_along(s: &Seg2, t: &Seg2, tol: f64) -> bool {
let on = |p: Vec2| t.point_at(t.closest_param(p)).dist(p) < tol;
let on = |p: Vec2| t.point_at(closest_param(t, p)).dist(p) < tol;
match (*s, *t) {
(Seg2::Line { .. }, Seg2::Line { .. }) => on(s.start()) && on(s.end()),
(Seg2::Arc { center: c, radius: r, .. }, Seg2::Arc { center: d, radius: q, .. }) => {
@@ -840,7 +840,7 @@ fn crossings(s: &Seg2, t: &Seg2, tol: f64) -> Vec<(f64, f64)> {
}
let mut out = Vec::new();
for p in pts {
let (u, v) = (s.closest_param(p), t.closest_param(p));
let (u, v) = (closest_param(s, p), closest_param(t, p));
if s.point_at(u).dist(p) < tol && t.point_at(v).dist(p) < tol {
out.push((u, v));
}
@@ -871,7 +871,7 @@ fn inside_loop(lp: &Loop2, p: Vec2) -> bool {
}
}
// A point on the loop keeps the half-open answer.
if lp.segs.iter().any(|s| s.point_at(s.closest_param(p)).dist(p) < scale * 1e-9) {
if lp.segs.iter().any(|s| s.point_at(closest_param(s, p)).dist(p) < scale * 1e-9) {
return inside_loop_ray(lp, p);
}
let grazes = |q: Vec2| {
@@ -991,12 +991,43 @@ fn inside(regions: &[Region2], p: Vec2) -> bool {
regions.iter().any(|r| inside_loop(&r.outer, p) && !r.holes.iter().any(|h| inside_loop(h, p)))
}
/// Call only after proving the point is at least `tol` from the other boundary.
/// This sub-tolerance interior probe cannot cross that boundary, and avoids casting
/// exactly through fitted arc/line endpoints whose heights differ by roundoff.
fn inside_clear(regions: &[Region2], point: Vec2, tol: f64) -> bool {
inside(regions, point + Vec2::new(tol * 0.173, tol * 0.317))
}
/// Analytic projections avoid iterative searches at every planar boundary probe.
/// Other curve types keep the shared solver introduced on main.
fn closest_param(segment: &Seg2, point: Vec2) -> f64 {
match *segment {
Seg2::Line { a, b } => {
let direction = b - a;
let length2 = direction.dot(direction);
if length2 > 1e-300 && length2.is_finite() { ((point - a).dot(direction) / length2).clamp(0.0, 1.0) } else { 0.0 }
}
Seg2::Arc { center, radius, start, sweep } if radius > 0.0 && sweep.abs() > 1e-12 && sweep.abs() <= std::f64::consts::TAU + 1e-9 => {
let offset = point - center;
let delta = ((offset.y.atan2(offset.x) - start) * sweep.signum()).rem_euclid(std::f64::consts::TAU);
if delta <= sweep.abs() {
(delta / sweep.abs()).clamp(0.0, 1.0)
} else if segment.start().dist(point) <= segment.end().dist(point) {
0.0
} else {
1.0
}
}
_ => segment.closest_param(point),
}
}
fn dist_to(regions: &[Region2], p: Vec2) -> f64 {
regions
.iter()
.flat_map(|r| std::iter::once(&r.outer).chain(&r.holes))
.flat_map(|l| l.segs.iter())
.map(|s| s.point_at(s.closest_param(p)).dist(p))
.map(|s| s.point_at(closest_param(s, p)).dist(p))
.fold(f64::INFINITY, f64::min)
}
@@ -1043,7 +1074,7 @@ fn box_holds(b: Option<SegBox>, p: Vec2) -> bool {
/// Distance from `p` to the nearest of `segs` whose box holds it: infinite when none does (no
/// segment is within the tolerance of `p` then).
fn near_dist(segs: &[Seg2], boxes: &[Option<SegBox>], p: Vec2) -> f64 {
segs.iter().zip(boxes).filter(|(_, b)| box_holds(**b, p)).map(|(s, _)| s.point_at(s.closest_param(p)).dist(p)).fold(f64::INFINITY, f64::min)
segs.iter().zip(boxes).filter(|(_, b)| box_holds(**b, p)).map(|(s, _)| s.point_at(closest_param(s, p)).dist(p)).fold(f64::INFINITY, f64::min)
}
/// Direction of the first of `segs` through `p` (within `tol`), as it runs.
@@ -1052,7 +1083,7 @@ fn near_dir(segs: &[Seg2], boxes: &[Option<SegBox>], p: Vec2, tol: f64) -> Optio
.iter()
.zip(boxes)
.filter(|(_, b)| box_holds(**b, p))
.map(|(s, _)| (s, s.closest_param(p)))
.map(|(s, _)| (s, closest_param(s, p)))
.find(|(s, t)| s.point_at(*t).dist(p) < tol)?;
s.derivative(t).normalized()
}
@@ -1193,7 +1224,7 @@ fn trimmed(src: &Seg2, t0: f64, t1: f64, a: Vec2, b: Vec2) -> Seg2 {
if hi < 1.0 - 1e-12 {
// Splitting a conic renormalises its weights, which moves its parameters: the second
// split is found on the piece.
let at = s.closest_param(src.point_at(hi));
let at = closest_param(&s, src.point_at(hi));
if at < 1.0 - 1e-12 {
s = s.split_at(at).0;
}
@@ -1304,7 +1335,7 @@ fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec<Seg
} else if p.dist(src.end()) < near {
1.0
} else {
src.closest_param(p)
closest_param(src, p)
}
};
let (t0, t1) = (at(seg.start()), at(last.end()));
@@ -1340,12 +1371,12 @@ fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec<Seg
(Piece::Exact { src: f, t1, .. }, Piece::Exact { src: g, t0, .. }) if f != g && (inner(t1) || inner(t0)) => {
src(f).zip(src(g)).and_then(|(x, y)| meet(x, t1, y, t0, near).map(|(a, b)| (Some(a), Some(b), x.point_at(a))))
}
(Piece::Exact { src: f, t1, .. }, Piece::Plain(s, _)) if inner(t1) => {
src(f).zip(carrier(&s)).and_then(|(x, c)| meet(x, t1, &c, c.closest_param(joint), near).map(|(a, _)| (Some(a), None, x.point_at(a))))
}
(Piece::Plain(s, _), Piece::Exact { src: f, t0, .. }) if inner(t0) => {
src(f).zip(carrier(&s)).and_then(|(x, c)| meet(x, t0, &c, c.closest_param(joint), near).map(|(a, _)| (None, Some(a), x.point_at(a))))
}
(Piece::Exact { src: f, t1, .. }, Piece::Plain(s, _)) if inner(t1) => src(f)
.zip(carrier(&s))
.and_then(|(x, c)| meet(x, t1, &c, closest_param(&c, joint), near).map(|(a, _)| (Some(a), None, x.point_at(a)))),
(Piece::Plain(s, _), Piece::Exact { src: f, t0, .. }) if inner(t0) => src(f)
.zip(carrier(&s))
.and_then(|(x, c)| meet(x, t0, &c, closest_param(&c, joint), near).map(|(a, _)| (None, Some(a), x.point_at(a)))),
_ => None,
};
// No crossing to refine (or none found): both end where the curve does.
@@ -1449,7 +1480,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Sect
continue;
}
for p in [t.start(), t.end()] {
let u = s.closest_param(p);
let u = closest_param(s, p);
if s.point_at(u).dist(p) < tol {
events += 1;
if events > MAX_PLANAR_EVENTS {
@@ -1466,7 +1497,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Sect
continue;
}
for p in [s.start(), s.end()] {
let v = t.closest_param(p);
let v = closest_param(t, p);
if t.point_at(v).dist(p) < tol {
events += 1;
if events > MAX_PLANAR_EVENTS {
@@ -1522,7 +1553,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Sect
BoolOp::Cut => !same,
}
} else {
let ins = inside(&b, m);
let ins = inside_clear(&b, m, tol);
match op {
BoolOp::Union | BoolOp::Cut => !ins,
BoolOp::Intersect => ins,
@@ -1548,7 +1579,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Sect
if near_dist(&sa, &box_a, m) < tol {
continue;
}
let ins = inside(&a, m);
let ins = inside_clear(&a, m, tol);
if kept.len() >= MAX_PLANAR_SEGMENTS {
return None;
}
@@ -1712,7 +1743,7 @@ fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option<Vec<Seg2>> {
if p.dist(seg.start()) < tol || p.dist(seg.end()) < tol {
return None;
}
let t = seg.closest_param(*p);
let t = closest_param(seg, *p);
(t > 1e-9 && t < 1.0 - 1e-9 && seg.point_at(t).dist(*p) < tol).then_some(t)
})
.collect();
@@ -1942,6 +1973,13 @@ fn sew_connected_sections(
ends.push(u);
}
}
// Welding above keeps its original encounter order. Side splitting needs
// only the distinct stations, whose resulting parameters are sorted anyway.
// Deduplicate in place, with bounded sorting work and no extra point list.
if pts.len() <= MAX_PLANAR_SEGMENTS * 8 {
pts.sort_unstable_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
pts.dedup_by_key(|p| (p.x.to_bits(), p.y.to_bits()));
}
// A piece of a free-form curve whose ends weld into one point (a font's curve cut a hair from
// its end) is no edge and has no wall: the pieces on either side meet there. (Lines and arcs
// are left as they were: such a piece of one fails the sewing, as before.)
@@ -2518,6 +2556,62 @@ pub(crate) fn prism_union_between_caps(a: &Body, b: &Body, op: BoolOp) -> Option
#[cfg(test)]
mod planar_tests {
use super::*;
#[test]
fn shared_arc_line_endpoint_roundoff_does_not_open_a_union() {
let a = Region2 { outer: Loop2::polygon(&[Vec2::new(-2., 0.), Vec2::new(2., 0.), Vec2::new(2., 2.), Vec2::new(-2., 2.)]), holes: vec![] };
let line = |a: (f64, f64), b: (f64, f64)| Seg2::Line { a: Vec2::new(a.0, a.1), b: Vec2::new(b.0, b.1) };
for epsilon in [-4e-14, 4e-14] {
let b = Region2 {
outer: Loop2 {
segs: vec![
line((1.15, epsilon), (-1.15, -epsilon)),
Seg2::Arc { center: Vec2::new(-1.35, epsilon), radius: 0.2, start: 0., sweep: -std::f64::consts::FRAC_PI_2 },
line((-1.35, -0.2), (-2.4, -0.2)),
line((-2.4, -0.2), (-2.4, -1.)),
line((-2.4, -1.), (2.4, -1.)),
line((2.4, -1.), (2.4, -0.2)),
line((2.4, -0.2), (1.35, -0.2)),
Seg2::Arc {
center: Vec2::new(1.35, -epsilon),
radius: 0.2,
start: -std::f64::consts::FRAC_PI_2,
sweep: -std::f64::consts::FRAC_PI_2,
},
],
},
holes: vec![],
};
let result = planar_regions_boolean(std::slice::from_ref(&a), std::slice::from_ref(&b), BoolOp::Union, 1e-5).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].outer.is_closed(1e-4));
assert!((result[0].area() - a.area() - b.area()).abs() < 1e-6);
}
}
#[test]
fn analytic_projection_keeps_clamped_lines_and_wrapped_arcs() {
let line = Seg2::Line { a: Vec2::new(2., 3.), b: Vec2::new(5., 7.) };
for (point, expected) in [(Vec2::new(-1., -1.), 0.), (Vec2::new(3.5, 5.), 0.5), (Vec2::new(8., 11.), 1.)] {
assert!((closest_param(&line, point) - expected).abs() < 1e-12);
}
for start in [-7., -0.5, 3., 7.] {
for sweep in [-std::f64::consts::TAU, -5., -0.2, 0.2, 5., std::f64::consts::TAU] {
let arc = Seg2::Arc { center: Vec2::new(2., -3.), radius: 4., start, sweep };
for index in 0..128 {
let angle = index as f64 * std::f64::consts::TAU / 128.;
let point = Vec2::new(2., -3.) + Vec2::new(angle.cos(), angle.sin()) * 6.;
let nearest = arc.point_at(closest_param(&arc, point));
// Includes both signs, sweeps over pi, seam crossings and endpoint clamps.
for sample in 0..=128 {
assert!(nearest.dist(point) <= arc.point_at(sample as f64 / 128.).dist(point) + 1e-10);
}
}
for t in [0., 0.25, 0.5, 0.75, 1.] {
let point = arc.point_at(t);
assert!(arc.point_at(closest_param(&arc, point)).dist(point) < 1e-10);
}
}
}
}
fn rectangle(a: Vec2, b: Vec2) -> Region2 {
Region2 { outer: Loop2::polygon(&[a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)]), holes: vec![] }
}