Compare commits

...

4 Commits

Author SHA1 Message Date
h0witzer 61e7055185 ContainsUVPoint: drop the point onto the edges whose box reaches it only
The loop containment test copied and dropped its point onto every edge of the loop, to see
whether the point lies on one. A drop succeeds only for a solution closer than the edge's
tolerance, so an edge whose box (a line's points at its interval's ends, a well-made
B-spline's control points), grown by that tolerance and an evaluation's rounding, lies apart
from the point is no longer copied or dropped onto. Other curves are dropped onto as before.
Copying a line or a B-spline for evaluation cannot fail, so no error is lost. The plate of 4n
edges classified at n x n points and one on its side now makes 1 drop (it made 258 and 2050
for n = 4 and 8).

The results are the same: 90 2D booleans dumped from a SolidCraft sheet metal part (an octagon
with a flange on every edge) give bit-identical vertices and edge tolerances, and take 143 ms
where they took 166 (the worst, 31.7 ms where it took 45.6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:34:32 -05:00
h0witzer 4af742e6bb ContainsUVPoint: count the edge drops, and test how they grow
The loop containment test drops its point onto every edge of the loop it walks, to see whether
the point lies on one. A work counter now counts those drops (containment_edge_drops), and a
test classifies a plate of 4n edges at n x n points inside it and one on its side: 4n^3 + 2
drops, 258 and 2050 for n = 4 and 8. The test expects the drops onto the edges near each point
only (1 and 1) and fails until the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:34:32 -05:00
h0witzer e4dcfe48bd Boolean2D: FaceIntersect of coincident faces leaves out the edges apart from the other face
An edge whose box lies apart from the other face's box (by the intersection or zone tolerance,
whichever is larger) lies inside no part of that face, so its classification on both faces keeps
nothing: it is no longer copied and classified. A box that cannot be computed, or holds nothing,
rules nothing out. The plate of 4n edges less n x n holes now classifies its holes' edges only,
64 and 256 curves for n = 4 and 8 (it was 320 and 2304).

The results are the same: 90 2D booleans dumped from a SolidCraft sheet metal part (an octagon
with a flange on every edge) give the same faces, edges, vertices and vertex coordinates, and
take 178 ms where they took 243.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:34:32 -05:00
h0witzer dd88fe8057 Boolean2D: count the coincident edges FaceIntersect classifies, and test how they grow
A work counter (`WorkCounters::coincident_curves_classified`) for the edge curves of two
coincident faces that `FaceIntersect` copies and classifies on both, and a test of a plate whose
sides are split into n segments, less n x n square holes cut in 2D. Upstream classifies every
edge of both faces for every pair `Boolean2D` makes: the plate's 4n edges again for each hole,
(4n + 4) n^2 curves, 320 and 2304 for n = 4 and 8, where the holes' own edges are 64 and 256.
The test asks for 64 and 256 and fails today.

SolidCraft's sheet metal flat patterns spend most of each 2D boolean there: a panel of 64 edges
less 17 trims classified 1,152 curves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:34:32 -05:00
5 changed files with 386 additions and 9 deletions
+32
View File
@@ -162,6 +162,8 @@ pub struct WorkCounters {
face_pairs: Cell<u64>,
loops_classified: Cell<u64>,
wall_body_copies: Cell<u64>,
coincident_curves_classified: Cell<u64>,
containment_edge_drops: Cell<u64>,
}
impl WorkCounters {
@@ -190,11 +192,29 @@ impl WorkCounters {
self.wall_body_copies.get()
}
/// Edge curves of two coincident faces that `FaceIntersect` classifies on both: those
/// of each face within reach of the other face's box. Not upstream (the fork's counters
/// have none).
#[must_use]
pub fn coincident_curves_classified(&self) -> u64 {
self.coincident_curves_classified.get()
}
/// Edge curves the loop containment test (`query::loop::contains_uv_point`) drops the
/// test point onto: those whose box, grown by the edge's tolerance, reaches the point.
/// Not upstream (upstream drops it onto every edge of the loop it walks).
#[must_use]
pub fn containment_edge_drops(&self) -> u64 {
self.containment_edge_drops.get()
}
/// Zeroes every count (`SmWorkCounters::Reset`).
pub fn reset(&self) {
self.face_pairs.set(0);
self.loops_classified.set(0);
self.wall_body_copies.set(0);
self.coincident_curves_classified.set(0);
self.containment_edge_drops.set(0);
}
/// Counts one face pair examined for intersection.
@@ -213,6 +233,18 @@ impl WorkCounters {
self.wall_body_copies
.set(self.wall_body_copies.get().saturating_add(1));
}
/// Counts one edge curve of coincident faces classified on both.
pub(crate) fn add_coincident_curve_classified(&self) {
self.coincident_curves_classified
.set(self.coincident_curves_classified.get().saturating_add(1));
}
/// Counts one drop of a containment test's point onto an edge curve.
pub(crate) fn add_containment_edge_drop(&self) {
self.containment_edge_drops
.set(self.containment_edge_drops.get().saturating_add(1));
}
}
/// Emits the [`UpstreamFixes`] definition it wraps unchanged, plus
+91 -8
View File
@@ -169,6 +169,80 @@ fn or_unknown<T>(k: &Kernel, r: Result<T>) -> Result<Option<T>> {
}
}
/// Not upstream: whether `point` may lie within `reach` of `curve` over `interval`, so
/// that [`drop_on_edge`] there can find it (a drop succeeds only for a solution closer than
/// its tolerance). `false` only when the point lies outside the box of the curve's points
/// over the interval, grown by `reach` and by an evaluation's rounding (`1e-6` of the box's
/// size and of the coordinates, as [`WindingHull::excludes`]), in some coordinate. That box
/// is, for a line, the box of its points at the interval's ends (its points between lie
/// between them); for a B-spline with positive weights whose basis is well made (as many
/// control points as its knots and degree call for, the knots in order) over an interval
/// inside its domain, the box of its control points (each point is a convex combination of
/// them). Every other curve may reach every point. Neither curve's copy for evaluation can
/// fail, so leaving out its copy and drop changes no result.
fn curve_box_reaches(
k: &Kernel,
curve: &Curve,
interval: &Extent1d,
point: Point3,
reach: f64,
) -> bool {
use crate::curve::curve::CurveKind;
use crate::nurbs::math::NOW;
let mut lo = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let mut hi = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
let mut add = |e: Point3| {
lo = Point3::new(lo.x.min(e.x), lo.y.min(e.y), lo.z.min(e.z));
hi = Point3::new(hi.x.max(e.x), hi.y.max(e.y), hi.z.max(e.z));
};
match curve.kind() {
CurveKind::Line(_) => {
for t in [interval.min(), interval.max()] {
let Ok(e) = curve.evaluate_point(k, t) else {
return true;
};
add(e);
}
}
CurveKind::BSpline(c) => {
let Some(nurb) = c.nurb.as_ref() else {
return true;
};
let p = nurb.degree();
let cpts = nurb.cpts();
let well_made = p >= 1
&& cpts.len() > p
&& nurb.knots().len() == cpts.len() + p + 1
&& nurb
.knots()
.windows(2)
.all(|w| matches!(w, [a, b] if a <= b));
let domain = c.natural_interval();
if !(well_made && interval.min() >= domain.min() && interval.max() <= domain.max()) {
return true;
}
for c in cpts {
if !(c.w == NOW || (c.w > 0.0 && c.w.is_finite())) {
return true;
}
add(c.to_euclid());
}
}
_ => return true,
}
if ![lo.x, lo.y, lo.z, hi.x, hi.y, hi.z]
.iter()
.all(|v| v.is_finite())
{
return true;
}
let apart = |lo: f64, hi: f64, x: f64| {
let m = reach + 1e-6 * (1.0 + (hi - lo) + lo.abs().max(hi.abs()) + x.abs());
x < lo - m || x > hi + m
};
!(apart(lo.x, hi.x, point.x) || apart(lo.y, hi.y, point.y) || apart(lo.z, hi.z, point.z))
}
/// `LocalPointSolve(SM_SO_MINIMIZE)` of the test point on a UV trim curve: whether it
/// converged and the solution. A failing solve has not converged (its status is
/// ignored upstream and the flag is reset first); with the opt-in
@@ -421,18 +495,27 @@ pub(crate) fn contains_uv_point_with_topology(
let this_edge = edge_of(b, this_eu)?;
// low work: see if TgtPoint is on this EdgeCurve
// (not upstream) only where the curve's box, grown by the edge's tolerance, reaches
// the point: the drop finds the point only within that tolerance of the curve
// ([`curve_box_reaches`]). Upstream copies and drops onto every edge of the loop, so
// a face of n edges classified at n points made n^2 drops.
let on_edge_curve = {
let edge = b.edge(this_edge)?;
// the drop builds the edge curve's cache at the edge's tolerance
// (SmTol::GetApproxTol3d(&m_crCurve) reads pEdge->GetTolerance() through the
// owner)
let mut curve = edge
let curve = edge
.curve
.as_ref()
.ok_or_else(|| null("ContainsUVPoint: edge without curve"))?
.clone_for_evaluation()?;
curve.set_owner_zone_tol_3d(Some(edge.tolerance));
drop_on_edge(k, &curve, &edge.interval, point_3d, edge.tolerance)?
.ok_or_else(|| null("ContainsUVPoint: edge without curve"))?;
if curve_box_reaches(k, curve, &edge.interval, point_3d, edge.tolerance) {
k.work_counters.add_containment_edge_drop();
// the drop builds the edge curve's cache at the edge's tolerance
// (SmTol::GetApproxTol3d(&m_crCurve) reads pEdge->GetTolerance() through the
// owner)
let mut curve = curve.clone_for_evaluation()?;
curve.set_owner_zone_tol_3d(Some(edge.tolerance));
drop_on_edge(k, &curve, &edge.interval, point_3d, edge.tolerance)?
} else {
None
}
};
if let Some(param) = on_edge_curve {
// TgtPoint is on EdgeCurve - set output and return
+37 -1
View File
@@ -102,6 +102,7 @@ use crate::curve::curve::Curve;
use crate::error::{ignore_status, ignored, EntityRef, Result, SolidError};
use crate::geom::extent1d::{Extent1d, ExtentPointType};
use crate::geom::extent2d::{Extent2d, SS_UMAX, SS_UMIN, SS_VMAX, SS_VMIN};
use crate::geom::extent3d::Extent3d;
use crate::geom::object::{NotifyOperation, ObjectRef};
use crate::geom::solution_array::{ObjectKind, Solution, SolutionObject, MAX_TREES};
use crate::geom::tol::xsect_tol_3d_from_zones;
@@ -2520,8 +2521,26 @@ pub fn face_intersect_breps_cached(
let mut have_curves = false;
if are_coincident && coinc_dist < approx_tol_3d {
have_curves = true;
'faces: for (fb, face) in [(b, f), (ob, other)] {
// (not upstream) an edge whose box lies apart from the other face's box lies inside
// no part of it, and its classification on both faces keeps nothing: it is left out.
// Upstream classifies every edge of each face on both, the other face's edges again
// for each face it is paired with, so a 2D boolean of one face of n edges against m
// small faces classified n·m curves, each against all n edges. A box that cannot be
// computed, or holds nothing, rules nothing out.
let reach = smos_max(approx_tol_3d, zone_tol);
let face_box = |fb: &Brep, face: FaceId| {
crate::query::face::p1::calculate_bounding_box(k, fb, face, false)
.ok()
.filter(|x| !x.has_negative_volume())
};
let boxes = [face_box(ob, other), face_box(b, f)];
'faces: for ((fb, face), apart_from) in [(b, f), (ob, other)].into_iter().zip(boxes) {
for e in face_edges(fb, face)? {
if let Some(far) = &apart_from {
if edge_box(k, fb, e).is_some_and(|x| x.are_disjoint(far, reach)) {
continue;
}
}
let edge_crv = edge_curve(fb, e)?;
if edge_crv.is_degenerate(k, EFF_ZERO, None)? {
continue;
@@ -2541,6 +2560,7 @@ pub fn face_intersect_breps_cached(
uv_curves1.push(None);
uv_curves2.push(None);
curve_types.push(TsectCurveType::RegionBoundary);
k.work_counters.add_coincident_curve_classified();
}
}
}
@@ -2772,3 +2792,19 @@ pub fn face_intersect_breps_cached(
}
Ok(out)
}
/// The axis aligned box of edge `e`'s curve over its interval, grown by the edge's tolerance;
/// `None` when it cannot be computed or holds nothing.
fn edge_box(k: &Kernel, b: &Brep, e: EdgeId) -> Option<Extent3d> {
let request = crate::nurbs::b_spline_curve::p2::BoundingBoxRequest {
normal: true,
pseudo: false,
polar: false,
};
let mut x = crate::query::edge::calculate_bounding_box(k, b, e, request, false)
.ok()?
.normal
.filter(|x| !x.has_negative_volume())?;
x.expand_absolute(edge::tolerance(b, e).ok()?);
Some(x)
}
@@ -0,0 +1,124 @@
//! A plate whose sides are split into n segments each, less n x n square holes cut in 2D
//! (`boolean_2d`, the holes united in balanced rounds first), n = 4 and 8: the work of
//! `FaceIntersect` on the coincident faces it pairs (SolidCraft's sheet metal flat patterns,
//! a panel of many edges less its bends' trims, spent most of each 2D boolean there).
//!
//! `Boolean2D` pairs the plate's face with each hole's (their boxes overlap). Upstream's
//! `FaceIntersect` of two coincident faces copies every edge of both and classifies each on
//! both faces: the plate's 4n edges again for every hole, (4n + 4) n^2 curves, 320 and 2304,
//! n^3, which is what this test counts without the box test. An edge whose box lies apart
//! from the other face's box lies inside no part of it and is left out: the plate's sides
//! lie 18 mm from every hole, so each pair classifies its hole's 4 edges only, 4 n^2 curves,
//! 64 and 256, four times the curves for four times the holes. The plate keeps its exact
//! area either way.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)] // test helpers
use solidcore::boolean::api_brep::{boolean_2d, Boolean2d, Boolean2dOutcome};
use solidcore::curve::curve::Curve;
use solidcore::mass::integrator::properties::brep_area;
use solidcore::math::Point3;
use solidcore::primitive::api_curves::create_line_segment;
use solidcore::primitive::api_trim_ops::create_planar_faces;
use solidcore::{Brep, Kernel};
const SIDE: f64 = 4.0;
const PITCH: f64 = 20.0;
fn boolean(k: &mut Kernel, a: Brep, b: Brep, op: Boolean2d) -> Brep {
match boolean_2d(k, a, b, op).unwrap() {
Boolean2dOutcome::Result(r) => r,
Boolean2dOutcome::Unassigned { .. } => panic!("2D boolean left its operands"),
}
}
/// A planar face bounded by the closed polygon through `corners`.
fn polygon(k: &mut Kernel, corners: &[(f64, f64)]) -> Brep {
let sides: Vec<Curve> = (0..corners.len())
.map(|i| {
let (a, b) = (corners[i], corners[(i + 1) % corners.len()]);
Curve::from(
create_line_segment(Point3::new(a.0, a.1, 0.0), Point3::new(b.0, b.1, 0.0))
.unwrap(),
)
})
.collect();
create_planar_faces(k, &sides).unwrap()
}
/// The `w` x `w` plate from the origin, each side split into `n` equal segments.
fn plate(k: &mut Kernel, w: f64, n: u32) -> Brep {
let step = w / f64::from(n);
let mut corners = Vec::new();
for (from, along) in [
((0.0, 0.0), (1.0, 0.0)),
((w, 0.0), (0.0, 1.0)),
((w, w), (-1.0, 0.0)),
((0.0, w), (0.0, -1.0)),
] {
for i in 0..n {
let d = step * f64::from(i);
corners.push((from.0 + along.0 * d, from.1 + along.1 * d));
}
}
polygon(k, &corners)
}
/// The plate with `n` x `n` square holes cut in 2D: checks its exact area and returns the
/// coincident edge curves `FaceIntersect` classified.
fn curves_classified(n: u32) -> u64 {
let mut k = Kernel::default();
k.fixes.boolean_skips_faces_apart = true;
k.work_counters.reset();
let h = SIDE / 2.0;
let mut holes = Vec::new();
for i in 0..n {
for j in 0..n {
let (x, y) = (PITCH * f64::from(i + 1), PITCH * f64::from(j + 1));
holes.push(polygon(
&mut k,
&[
(x - h, y - h),
(x + h, y - h),
(x + h, y + h),
(x - h, y + h),
],
));
}
}
while holes.len() > 1 {
let mut next = Vec::new();
let mut round = holes.into_iter();
while let Some(a) = round.next() {
match round.next() {
Some(b) => next.push(boolean(&mut k, a, b, Boolean2d::Union)),
None => next.push(a),
}
}
holes = next;
}
let w = PITCH * f64::from(n + 1);
let plate = plate(&mut k, w, n);
let mut cut = boolean(&mut k, plate, holes.pop().unwrap(), Boolean2d::Difference);
let curves = k.work_counters.coincident_curves_classified();
let area = brep_area(&k, &mut cut, 1e-8).unwrap();
let exact = w * w - f64::from(n * n) * SIDE * SIDE;
println!(
"plate less {} holes: {curves} coincident curves classified, area {area}",
n * n
);
assert!(
(area - exact).abs() <= 1e-9 * exact,
"area {area}, exact {exact}"
);
curves
}
#[test]
fn coincident_faces_classify_only_the_edges_near_each_other() {
assert_eq!((curves_classified(4), curves_classified(8)), (64, 256));
}
@@ -0,0 +1,102 @@
//! A plate whose sides are split into n segments each, classified at n x n points inside it
//! and at one point on its first side (`face_classify_uv`), n = 4 and 8: the work of the loop
//! containment test (`query::loop::contains_uv_point`) the classification makes for each
//! point (SolidCraft's sheet metal flat patterns, faces of many edges, spent most of each 2D
//! boolean there: the boolean classifies each new edge's midpoint on the face it splits).
//!
//! Upstream's containment test drops the point onto every edge of the loop to see whether it
//! lies on one, up to the edge it lies on: 4n drops for each point inside, 2 for the point on
//! the first side (its segment is the second edge the walk visits), 4n^3 + 2 for the plate,
//! 258 and 2050, which is what this test counts without the box test. A drop finds the point only within the edge's tolerance,
//! so an edge whose box, grown by that tolerance, lies apart from the point is not dropped
//! onto: the points inside lie 10 mm or more from every side, and the point on the first
//! side reaches the box of the one segment it lies on, 1 drop for either plate. Every point
//! keeps its class.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)] // test helpers
use solidcore::curve::curve::Curve;
use solidcore::intersect::point_class::PointClassType;
use solidcore::math::Point3;
use solidcore::primitive::api_curves::create_line_segment;
use solidcore::primitive::api_trim_ops::create_planar_faces;
use solidcore::query::api_queries::{face_classify_uv, surface_closest_point};
use solidcore::topology::face::p2::surface;
use solidcore::{Brep, Kernel};
const WIDTH: f64 = 100.0;
/// The `WIDTH` square plate from the origin, each side split into `n` equal segments.
fn plate(k: &mut Kernel, n: u32) -> Brep {
let step = WIDTH / f64::from(n);
let mut corners = Vec::new();
for (from, along) in [
((0.0, 0.0), (1.0, 0.0)),
((WIDTH, 0.0), (0.0, 1.0)),
((WIDTH, WIDTH), (-1.0, 0.0)),
((0.0, WIDTH), (0.0, -1.0)),
] {
for i in 0..n {
let d = step * f64::from(i);
corners.push((from.0 + along.0 * d, from.1 + along.1 * d));
}
}
let sides: Vec<Curve> = (0..corners.len())
.map(|i| {
let (a, b) = (corners[i], corners[(i + 1) % corners.len()]);
Curve::from(
create_line_segment(Point3::new(a.0, a.1, 0.0), Point3::new(b.0, b.1, 0.0))
.unwrap(),
)
})
.collect();
create_planar_faces(k, &sides).unwrap()
}
/// The plate of `4n` edges classified at `n` x `n` points inside it and at the middle of its
/// first segment: checks each class and returns the containment test's edge drops.
fn edge_drops(n: u32) -> u64 {
let mut k = Kernel::default();
let mut b = plate(&mut k, n);
let f = b.face_list()[0];
let srf = surface(&b, f).unwrap().unwrap().clone();
let pitch = (WIDTH - 20.0) / f64::from(n - 1);
let mut points = Vec::new();
for i in 0..n {
for j in 0..n {
let p = Point3::new(
10.0 + pitch * f64::from(i),
10.0 + pitch * f64::from(j),
0.0,
);
points.push((p, PointClassType::Face));
}
}
points.push((
Point3::new(WIDTH / f64::from(2 * n), 0.0, 0.0),
PointClassType::Edge,
));
let uvs: Vec<_> = points
.iter()
.map(|(p, _)| surface_closest_point(&k, &srf, *p).unwrap().uvs[0])
.collect();
k.work_counters.reset();
for ((p, class), uv) in points.iter().zip(uvs) {
assert_eq!(
face_classify_uv(&k, &mut b, f, uv).unwrap(),
*class,
"at {p:?}"
);
}
let drops = k.work_counters.containment_edge_drops();
println!(
"plate of {} edges at {} points: {drops} edge drops",
4 * n,
points.len()
);
drops
}
#[test]
fn containment_drops_onto_the_edges_near_the_point_only() {
assert_eq!((edge_drops(4), edge_drops(8)), (1, 1));
}