From daf618c344402f40502634d99f389d536576d674 Mon Sep 17 00:00:00 2001 From: rlartot <94693724+rlartot@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:19:51 +0200 Subject: [PATCH] Edge division: evaluate each point once (#1560) Thanks @rlartot. --- crates/kernel/src/edge_polyline_tests.rs | 159 ++++++++++++++++++ crates/kernel/src/lib.rs | 2 + docs/perf.md | 49 ++++++ vendor/README.md | 5 + vendor/truck-meshalgo/src/tessellation/mod.rs | 17 +- .../src/tessellation/triangulation.rs | 26 +-- 6 files changed, 243 insertions(+), 15 deletions(-) create mode 100644 crates/kernel/src/edge_polyline_tests.rs diff --git a/crates/kernel/src/edge_polyline_tests.rs b/crates/kernel/src/edge_polyline_tests.rs new file mode 100644 index 00000000..88d068e9 --- /dev/null +++ b/crates/kernel/src/edge_polyline_tests.rs @@ -0,0 +1,159 @@ +//! The vendored edge division evaluates each parameter once (the ends of a piece are carried +//! along, its midpoint is the probe and an end of both halves). Regressions against the division +//! as it was, which evaluated both ends of every piece again: the polylines must be the same, bit +//! for bit, including the fallbacks for samples that do not evaluate. +#![allow(clippy::unwrap_used, clippy::panic)] +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use solidcraft_geom::Vec3; +use truck_meshalgo::prelude::PolylineCurve; +use truck_meshalgo::tessellation::{Deflection, TrySubs, bounded_polyline}; +use truck_modeling::{BoundedCurve, Curve, EuclideanSpace, MetricSpace, ParameterDivision1D, ParameterRange, ParametricCurve, Point3, Vector3}; + +use crate::*; + +/// A curve that counts its evaluations. +#[derive(Clone)] +struct Counted { + curve: Curve, + calls: Arc, +} + +impl Counted { + fn new(curve: &Curve) -> Self { + Counted { curve: curve.clone(), calls: Arc::default() } + } + + fn calls(&self) -> usize { + self.calls.load(Ordering::Relaxed) + } +} + +impl ParametricCurve for Counted { + type Point = Point3; + type Vector = Vector3; + fn subs(&self, t: f64) -> Point3 { + self.calls.fetch_add(1, Ordering::Relaxed); + self.curve.subs(t) + } + fn der(&self, t: f64) -> Vector3 { + self.curve.der(t) + } + fn der2(&self, t: f64) -> Vector3 { + self.curve.der2(t) + } + fn parameter_range(&self) -> ParameterRange { + self.curve.parameter_range() + } + fn period(&self) -> Option { + self.curve.period() + } +} + +impl BoundedCurve for Counted {} + +/// The division samples through `TrySubs`: count those evaluations. +impl TrySubs for Counted { + fn try_subs(&self, t: f64) -> Option { + self.calls.fetch_add(1, Ordering::Relaxed); + TrySubs::try_subs(&self.curve, t) + } + fn approx_subs(&self, t: f64) -> Point3 { + self.calls.fetch_add(1, Ordering::Relaxed); + TrySubs::approx_subs(&self.curve, t) + } +} + +impl ParameterDivision1D for Counted { + type Point = Point3; + fn parameter_division(&self, range: (f64, f64), tol: f64) -> (Vec, Vec) { + self.curve.parameter_division(range, tol) + } +} + +/// `bounded_polyline` before it carried the end points: 4 evaluations per piece (both ends, the +/// probe and the midpoint), with the same fallbacks for samples that do not evaluate. +fn reference(curve: &Counted, range: (f64, f64), d: Deflection) -> PolylineCurve { + const MAX_EDGE_POINTS: usize = 20_000; + let (tol, turn2) = (d.chord, d.turn2()); + let end = |t: f64| curve.try_subs(t).unwrap_or_else(|| curve.approx_subs(t)); + let probe = |t: f64| curve.try_subs(t).unwrap_or(Point3::new(f64::NAN, f64::NAN, f64::NAN)); + let mut out = vec![end(range.0)]; + let mut stack: Vec<(f64, f64, usize)> = vec![(range.0, range.1, 0)]; + while let Some((a, b, depth)) = stack.pop() { + let (pa, pb) = (end(a), end(b)); + let t = a + (b - a) * 0.4729; + let chord = pa + (pb - pa) * 0.4729; + let d2 = probe(t).distance2(chord); + let tm = 0.5 * (a + b); + let dm = probe(tm).distance2(pa.midpoint(pb)); + let flat = !(d2 >= tol * tol || dm >= tol * tol); + let flat = flat && !d.turns(turn2, d2.max(dm), pa.distance2(pb)); + if flat || depth >= 24 || out.len() + stack.len() >= MAX_EDGE_POINTS { + out.push(pb); + } else { + stack.push((tm, b, depth + 1)); + stack.push((a, tm, depth + 1)); + } + } + PolylineCurve(out) +} + +/// Divides `curve` both ways and checks the points, their order and the evaluations; returns +/// the polyline. +fn same_division(curve: &Curve, d: Deflection) -> Vec { + let range = curve.range_tuple(); + let (before, after) = (Counted::new(curve), Counted::new(curve)); + let old = reference(&before, range, d).0; + let new = bounded_polyline(&after, range, d).0; + let bits = |p: &Point3| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]; + assert_eq!(old.len(), new.len(), "{d:?}"); + for (i, (p, q)) in old.iter().zip(&new).enumerate() { + assert_eq!(bits(p), bits(q), "point {i} of {}: {p:?} before, {q:?} now ({d:?})", new.len()); + } + // Every piece is kept (one point) or halved: n points come from 2n - 3 pieces. + let pieces = 2 * new.len() - 3; + assert_eq!(before.calls(), 1 + 4 * pieces); + assert_eq!(after.calls(), 2 + 2 * pieces); + new +} + +/// The chord tolerance alone (booleans), then with the display's facet angle. +fn deflections(tol: f64) -> [Deflection; 2] { + [Deflection::chord(tol), Deflection { chord: tol, angle: DISPLAY_ANGLE, floor: tol * 0.05, rulings: true }] +} + +#[test] +fn intersection_curves_of_a_drilled_cylinder_divide_as_before() { + // The radial hole of a clamping hub: both cylinders are NURBS, so each point of the edges + // is two grid searches. + let hub = cylinder(Vec3::new(0.0, 0.0, -10.0), Vec3::Z, 8.0, 20.0).unwrap(); + let drill = cylinder(Vec3::new(-12.0, 0.0, 1.5), Vec3::X, 1.25, 24.0).unwrap(); + let drilled = boolean(&hub, &drill, BoolOp::Cut).unwrap().unwrap(); + let curves: Vec = drilled.solid.edge_iter().map(|e| e.curve()).filter(|c| matches!(c, Curve::IntersectionCurve(_))).collect(); + assert!(curves.len() >= 2, "{} intersection curves", curves.len()); + // One curve with each deflection (each division takes about half a second here). + for (curve, d) in curves.iter().zip(deflections(0.01)) { + let points = same_division(curve, d); + assert!(points.len() > 8, "{} points ({d:?})", points.len()); + // On the drill's wall. + for p in &points { + let r = (p.y * p.y + (p.z - 1.5) * (p.z - 1.5)).sqrt(); + assert!((r - 1.25).abs() < 1e-3, "{p:?} at {r} from the drill's axis"); + } + } +} + +#[test] +fn circle_divides_as_before() { + let body = cylinder(Vec3::ZERO, Vec3::Z, 5.0, 10.0).unwrap(); + let circle = body.solid.edge_iter().map(|e| e.curve()).find(|c| !matches!(c, Curve::Line(_))).unwrap(); + for d in deflections(0.001) { + let points = same_division(&circle, d); + assert!(points.len() > 18, "{} points ({d:?})", points.len()); + for p in &points { + assert!(((p.x * p.x + p.y * p.y).sqrt() - 5.0).abs() < 1e-9, "{p:?}"); + } + } +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 46c4a087..77ae5f6e 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -30,6 +30,8 @@ mod clearance; mod coplanar; mod curveblend; mod delete_face; +#[cfg(test)] +mod edge_polyline_tests; mod emboss; mod face_params; #[cfg(test)] diff --git a/docs/perf.md b/docs/perf.md index ad0cd727..cd6f35c9 100644 --- a/docs/perf.md +++ b/docs/perf.md @@ -1,5 +1,54 @@ # Design loading +## Edges made by booleans: each point evaluated once (2026-10-16) + +A boolean meshes both operands with truck-meshalgo, which first divides every edge into a +polyline (`bounded_polyline`, vendored). An edge that an earlier boolean made is an +intersection curve. Each of its points is found by a grid search on each of the two surfaces, +51 × 51 evaluations of a NURBS surface each, before Newton. The division evaluated both ends of +every piece again, so 2 of its 4 evaluations per piece repeated a point it already had. +Profiles of `main` (01b1b705) on the machine below put the division at 42 % of the CPU time +of opening the rover, 58 % of `bench-agent` and 82-85 % of a drilled hub or a union of tubes. + +Each piece now carries the points of its ends, and its midpoint is both the probe and an end of +both halves: 2 evaluations per piece. The fallbacks for samples that do not evaluate (#1364) are +kept: a midpoint that fails is NaN as a probe and the curve's approximation as an end, exactly as +before. The polyline is the same, bit for bit: `edge_polyline_tests` compares it with the +previous division (points, order and number of evaluations) on the intersection curves of a +drilled cylinder and on a circle. + +Release `solidcraft-cli` from `main` (73493b94) and the same with the change, 3 alternating +pairs (before/after, after/before, before/after), each run a new process timed with +`/usr/bin/time -v`, started once the 1-minute load average was below 2.5. Medians, with the +range of the 3 runs. 2018 laptop: Intel Core i7-8565U (4 cores / 8 threads, 15 W), 7.4 GB, +Ubuntu (Linux 7.0), on mains power: + +| Run | Evaluation | Meshes | Wall time | CPU time | Peak RSS | +|---|---:|---:|---:|---:|---:| +| `bench-open`, `examples/acceptance/sensor_enclosure.json` | 3,203 → 3,089 ms | 6 → 6 ms | 3.2 → 3.1 s | 8.7 → 8.5 s | 91 → 91 MB | +| `bench-open`, a hub barrel with one radial hole cut through it | 966 → 817 ms | 878 → 481 ms | 1.9 → 1.4 s (-24 %) | 4.4 → 2.8 s (-37 %) | 27 → 30 MB | +| `bench-open`, the frame of the `bench-agent` bicycle (one union of tilted tubes) | 8,204 → 6,277 ms | 1,711 → 893 ms | 9.9 → 7.2 s (-28 %) | 33.3 → 20.6 s (-38 %) | 44 → 46 MB | +| `bench-open`, a rover (3,324 features, 1,395 bodies) | 26,783 → 22,792 ms | 7,960 → 6,046 ms | 35.7 → 29.8 s (-17 %) | 129.0 → 101.9 s (-21 %) | 2,076 → 2,079 MB | +| `bench-agent` (build 33.5 → 30.2 s, -10 %) | 10,553 → 8,565 ms | 2,778 → 2,081 ms | 47.6 → 41.1 s (-14 %) | 124.0 → 91.0 s (-27 %) | 1,428 → 1,432 MB | + +`bench-agent`'s evaluation and meshes are those of reopening the saved design. CPU time is the +steadier measure on this 15 W processor: its before and after ranges are apart for the four +cases with boolean edges (the hub 3.0-4.4 s before, 2.5-2.9 s after; the rover 113.6-133.5 s, +101.0-102.5 s), while the first pair's "before" run, which started on a cool processor, overlaps +the "after" wall times of the two short cases. The sensor enclosure does not change: its time goes +to grid searches in face meshing, which this does not touch. An earlier series on `main` +(8535d229), on a quieter machine, gave -38 %, -35 %, -18 % and -16 % of wall time for the same +four cases. The CPU time saved is close to half the division's share in the profile. The mesh +phase gains as well, so these display meshes also call the reference mesher somewhere (where is +not traced here; `tess::edges`, the display plans' own edge division, already evaluated each +point once). In the rover, the second shell (which names every body's faces, so meshes them all) +goes from 7.8 s to 5.7 s. + +No model changed. `solidcraft-cli eval` of the five designs (the bicycle as `bench-agent` saved +it) is the same with both binaries apart from the per-feature milliseconds: bodies, volumes, +areas, centres, bounding boxes, topology counts and warnings. `bench-open` meshes the same +triangle counts, and the bicycles both binaries save differ only in the document's name. + ## STEP assemblies: a repeated part is meshed once (2026-10-14) The reader already converts each B-rep item once and keeps it for the instances that list it diff --git a/vendor/README.md b/vendor/README.md index eff274ac..62cd65e6 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -108,6 +108,11 @@ Changes: point that does not evaluate counts as flat instead of recursing). Surface grids refine only the direction that bends, and a ruling left undivided gets cells comparable to the other direction (long triangles from a trim loop to the grid's far ends cut through cylinders). +- `tessellation/triangulation.rs`: an edge's division evaluates each parameter once. A piece + carries the points of its ends and its midpoint is an end of both halves: 2 evaluations per + piece instead of 4, and the same polyline (a point of an intersection curve costs two grid + searches of its surfaces). `bounded_polyline` and `Deflection::turn2`/`turns` are public but + hidden, for the kernel's regression against the old division. - `tessellation/mod.rs`, `tessellation/triangulation.rs`, for display: a `Deflection` is the chord tolerance plus an optional bound on the angle one facet spans, and `RoundMeshableShape::robust_triangulation_within` divides by it: an edge or a grid direction diff --git a/vendor/truck-meshalgo/src/tessellation/mod.rs b/vendor/truck-meshalgo/src/tessellation/mod.rs index f9d64f02..1bcfd8a7 100644 --- a/vendor/truck-meshalgo/src/tessellation/mod.rs +++ b/vendor/truck-meshalgo/src/tessellation/mod.rs @@ -25,6 +25,13 @@ pub fn preview_face(surface: &S, loops: &[Vec] triangulation::preview_face(surface, loops, steps) } +/// SolidCraft: an edge's polyline as the meshers divide it (at most 20 000 points). Exposed for +/// the kernel's regressions in the workspace CI. +#[doc(hidden)] +pub fn bounded_polyline(curve: &C, range: (f64, f64), d: Deflection) -> PolylineCurve { + triangulation::bounded_polyline(curve, range, d) +} + /// SolidCraft: sampling a curve that may fail to evaluate. truck's `IntersectionCurve::subs` /// unwraps the projection of its leader's point onto both surfaces, which does not converge /// everywhere; the mesher samples through this trait so that a failed sample is data, not a panic. @@ -145,8 +152,9 @@ impl Deflection { /// The squared sag over squared length above which a facet turns more than `angle`: a /// chord spanning `a` of a circle sags `tan(a / 4) / 2` of its length. Infinite without - /// an angle bound. - pub(super) fn turn2(&self) -> f64 { + /// an angle bound. SolidCraft: public for the kernel's regressions. + #[doc(hidden)] + pub fn turn2(&self) -> f64 { match self.angular() && self.angle < std::f64::consts::PI { true => (f64::tan(self.angle / 4.0) / 2.0).powi(2), false => f64::INFINITY, @@ -154,8 +162,9 @@ impl Deflection { } /// Whether the angle bound splits a facet of squared sag `sag2` and squared length `len2` - /// (`turn2` from [`Deflection::turn2`]). - pub(super) fn turns(&self, turn2: f64, sag2: f64, len2: f64) -> bool { + /// (`turn2` from [`Deflection::turn2`]). SolidCraft: public for the kernel's regressions. + #[doc(hidden)] + pub fn turns(&self, turn2: f64, sag2: f64, len2: f64) -> bool { turn2.is_finite() && sag2 > self.floor * self.floor && sag2 > turn2 * len2 } } diff --git a/vendor/truck-meshalgo/src/tessellation/triangulation.rs b/vendor/truck-meshalgo/src/tessellation/triangulation.rs index 773f8f41..3049e389 100644 --- a/vendor/truck-meshalgo/src/tessellation/triangulation.rs +++ b/vendor/truck-meshalgo/src/tessellation/triangulation.rs @@ -861,30 +861,34 @@ pub(super) fn face_cap(faces: usize) -> usize { /// bisection depth of 24; a midpoint that does not evaluate (NaN) counts as flat (truck's /// version recursed on it 100 levels deep, which never finishes). A piece within the chord /// tolerance is still halved while it turns more than the deflection's angle. -fn bounded_polyline(curve: &C, range: (f64, f64), d: Deflection) -> PolylineCurve { +pub(super) fn bounded_polyline(curve: &C, range: (f64, f64), d: Deflection) -> PolylineCurve { let (tol, turn2) = (d.chord, d.turn2()); // A sample that does not evaluate (an intersection curve whose projection fails): an interval // end falls back to the curve's approximation so the polyline stays finite; an interior probe - // is NaN, which counts as flat. + // is NaN, which counts as flat. Each parameter is evaluated once: a piece carries the points + // of its ends, and its midpoint, an end of both halves, serves as probe and as end (a point of + // an intersection curve costs two grid searches). let end = |t: f64| curve.try_subs(t).unwrap_or_else(|| curve.approx_subs(t)); - let probe = |t: f64| curve.try_subs(t).unwrap_or(Point3::new(f64::NAN, f64::NAN, f64::NAN)); - let mut out = vec![end(range.0)]; - let mut stack: Vec<(f64, f64, usize)> = vec![(range.0, range.1, 0)]; - while let Some((a, b, depth)) = stack.pop() { - let (pa, pb) = (end(a), end(b)); + let nan = Point3::new(f64::NAN, f64::NAN, f64::NAN); + let (p0, p1) = (end(range.0), end(range.1)); + let mut out = vec![p0]; + let mut stack: Vec<(f64, f64, Point3, Point3, usize)> = vec![(range.0, range.1, p0, p1, 0)]; + while let Some((a, b, pa, pb, depth)) = stack.pop() { let t = a + (b - a) * 0.4729; let chord = pa + (pb - pa) * 0.4729; - let d2 = probe(t).distance2(chord); + let d2 = curve.try_subs(t).unwrap_or(nan).distance2(chord); let tm = 0.5 * (a + b); - let dm = probe(tm).distance2(pa.midpoint(pb)); + let sm = curve.try_subs(tm); + let dm = sm.unwrap_or(nan).distance2(pa.midpoint(pb)); let flat = !(d2 >= tol * tol || dm >= tol * tol); let flat = flat && !d.turns(turn2, d2.max(dm), pa.distance2(pb)); if flat || depth >= 24 || out.len() + stack.len() >= MAX_EDGE_POINTS { out.push(pb); } else { + let pm = sm.unwrap_or_else(|| curve.approx_subs(tm)); // Right half after the left one (stack order). - stack.push((tm, b, depth + 1)); - stack.push((a, tm, depth + 1)); + stack.push((tm, b, pm, pb, depth + 1)); + stack.push((a, tm, pa, pm, depth + 1)); } } PolylineCurve(out)