Edge division: evaluate each point once (#1560)

Thanks @rlartot.
This commit is contained in:
rlartot authored and GitHub committed 2026-10-15 22:19:51 -04:00
1 parent 5165e428cd
commit daf618c344
6 files changed
+243 -15

No files matched your search

+159
View File
@@ -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<AtomicUsize>,
}
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<f64> {
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<Point3> {
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<f64>, Vec<Point3>) {
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<Point3> {
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<Point3> {
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<Curve> = 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:?}");
}
}
}
+2
View File
@@ -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)]
+49
View File
@@ -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
+5
View File
@@ -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
+13 -4
View File
@@ -25,6 +25,13 @@ pub fn preview_face<S: RobustMeshableSurface>(surface: &S, loops: &[Vec<Point3>]
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<C: PolylineableCurve>(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
}
}
+15 -11
View File
@@ -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<C: PolylineableCurve>(curve: &C, range: (f64, f64), d: Deflection) -> PolylineCurve {
pub(super) fn bounded_polyline<C: PolylineableCurve>(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)