Drawing W06a: parallel hidden lines and shared work limits (#2455) (#3013)

* geom/hlr: per-curve work on worker threads, shared work limits

Silhouette tracing, screen sampling, shadow cuts, classification, coverage and
the crossing search (by grid rows) with crossing refinement run on worker
threads through one ordered map: results go back in item order, so drawings
are the same bit for bit on any number of threads. Options::threads picks the
workers (0 = one per core, small views stay on the calling thread); the
browser build stays serial. Placements sharing a body and a turn share one
silhouette tracing.

Cancellation is polled before every item and inside long ones from every
worker; the first error stops the others; a panicking worker becomes
HlrError::Internal instead of escaping. Ray, curve, crossing, shadow and
coverage budgets are totals over all threads (shared counters; crossing pairs
are counted before the search).

Limits: placements and the default max_tracks are hlr::MAX_ITEMS, which is
section::MAX_ITEMS (also the brep record limit); trim_samples must be 1..=4096.

* docs/drawing-hlr: link the parallel-work PR

* geom/hlr: clearer limits and parallel-work docs, faster thread tests

docs/drawing-hlr.md now separates input limits (with the error each returns:
body records fail with Invalid, not Budget) from the work budgets, names every
budget's error, and states how errors and panics behave on worker threads.
Code comments say the same in plainer terms. The budget and cancellation
tests drop the oblique torus, and the freeform pass of the identity test
compares one parallel run, which keeps the HLR suite under 4 s.

* geom/hlr: poll cancellation inside long items, find spline spans by bisection

Review found two items a worker could run for seconds without seeing a
cancellation: tracing a body with many curved surfaces, and sampling one long
spline (up to 4096 control points). Silhouette tracing now asks before each
surface, screen sampling at every refinement pass, the cuts at left-out faces
every 4096 chords and coverage every 65536 checks.

The long spline was slow because every point and every part hull scanned all
of its knot spans. de Boor evaluation now finds its span by bisection over the
ordered knots, and a part's hull starts at its first piece and stops after
its last: the same spans, so the same bits (checked on the fixture scenes),
and a 4096-point spline edge prepares in 0.9 s instead of 2.2 s and samples
in 0.23 s instead of 1.47 s.

* geom/hlr: poll cancellation while forming and trimming silhouette sets

Review found that forming a body's silhouette sets scanned each face's whole
surface group before the first poll, quadratic in the faces on one surface
(about 4e8 steps for 20 000 faces). The group scan now stops at its first
supported face (groups list faces in order, so that is the same face), the
loop polls every 256 faces, and trimming a traced curve asks before each
face. A cancellation seen while trimming drops the partial curves, even if
the callback answers false later. Output unchanged on the fixture scenes.

* geom/hlr: poll cancellation per boxes met when cutting at left-out faces

Review found that one chord crossing the screen boxes of very many left-out
faces bisects every side without a poll (polls came only every 4096 chords).
It now also polls every 1024 boxes met. The docs state the cancellation
contract precisely: polls are where listed, and the work between two polls is
bounded by the input limits rather than by a time.

* docs/drawing-hlr: say where coverage polls cancellation

* geom/hlr: body records have brep's own, larger limit now

Main raised brep::MAX_ITEMS to 1 000 000 (#3093, which stores whole evaluated
models), so it no longer equals section::MAX_ITEMS. hlr::MAX_ITEMS stays
section's 100 000 for placements and the default curve budget; the docs and
the limits test now say that a body's records are bounded separately, by
brep's limit.

* geom/hlr: a panicking worker stops the others; the ray budget is HLR's own

A worker that panics now sets the shared stop flag while it unwinds, so the
other workers end the phase early instead of finishing work the call throws
away. The docs no longer compare the ray budget with the occlusion work
budget of file.export_view_dxf: the two count different work.

* geom/hlr: an ignored timing test, prepare and drawing timed apart

hlr_timing prepares four bodies, then draws 100 differently turned placements
on one thread, two workers and one worker per core, printing the total, the
phase timings and the ray count. SOLIDCRAFT_BENCH_HLR sets the placement count.

* geom/hlr: timing scenes for sharing and crossings; placements keep their lines when permuted

hlr_timing now starts its prepare clock after the fixtures are built, adds the
same grid at one turn (shared definitions against one per placement) and
sixteen thin bars crossing 8x8 from above, rotates the thread order over three
rounds and checks the drawings match across thread counts outside the timed
part. A new test permutes three placements of one prepared box and removes one:
each line keeps its own placement index, and the boxes' lines stay exact.

* docs/drawing-hlr: the review's limits and fixtures in the parallel tables

* geom/hlr: follow the B-rep record into solidcore-record

The record, its version and its item limit now come from solidcore-record
(#11496): the new tests build their bodies at `solidcore_record::brep::VERSION`
and make a spline's curve with `C3::bounded`, as the other fixtures do, and
the limits table gives the record's own limit, 4 000 000 items, checked by
`brep::check::validate`.

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
This commit is contained in:
Mateusz Sych
2026-11-04 22:56:07 +01:00
committed by GitHub
parent 96bb41c480
commit e81a201ff8
5 changed files with 1125 additions and 343 deletions
+10 -6
View File
@@ -402,7 +402,13 @@ impl C3 {
use crate::bspline::{HPoint, bezier_segment};
let C3::Spline { pieces, .. } = self else { return None };
let mut out = Vec::new();
for (span, ctrl) in pieces {
// The pieces run in parameter order: start at the first one that can reach past r[0]
// and stop after r[1], so a part costs its own pieces, not the whole curve's.
let first = pieces.partition_point(|(span, _)| span[1] <= r[0]);
for (span, ctrl) in pieces.get(first..).unwrap_or_default() {
if span[0] >= r[1] {
break;
}
let (a, b) = (r[0].max(span[0]), r[1].min(span[1]));
let len = span[1] - span[0];
if !(b > a && len > 0.) {
@@ -429,11 +435,9 @@ pub(crate) fn de_boor(p: usize, knots: &[f64], ctrl: &[[f64; 4]], t: f64) -> Vec
let n = ctrl.len();
let (Some(&lo), Some(&hi)) = (knots.get(p), knots.get(n)) else { return Vec3::ZERO };
let t = t.clamp(lo, hi);
// Span k with knots[k] <= t < knots[k+1], last nonempty span at the end.
let mut k = p;
while k + 1 < n && knots.get(k + 1).is_some_and(|u| *u <= t) {
k += 1;
}
// Span k with knots[k] <= t < knots[k+1], last nonempty span at the end: the knots are
// in order (Brep::validate), so the knots after p that are at most t come first.
let k = p + knots.get(p + 1..n).map_or(0, |after| after.partition_point(|u| *u <= t));
let mut d: Vec<[f64; 4]> = (0..=p).map(|j| ctrl.get(j + k - p).copied().unwrap_or([0.; 4])).collect();
for r in 1..=p {
for j in (r..=p).rev() {
+567 -312
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -33,8 +33,9 @@ fn extent(parts: &[(&S3, &Trim)], p: Vec3, a: Vec3) -> (f64, f64) {
/// Silhouettes of a set of faces on one surface (`parts`: each face's chart and trim, all
/// recognised as `an`): traced once and kept where they lie on any of the faces, so a
/// silhouette along a seam between them is one curve.
pub(crate) fn silhouettes(parts: &[(&S3, &Trim)], an: Analytic, d: Vec3, out: &mut Vec<Sil>) {
/// silhouette along a seam between them is one curve. `stop` is asked before each face a
/// curve is trimmed to; once it answers true the work ends and `out` is incomplete.
pub(crate) fn silhouettes(parts: &[(&S3, &Trim)], an: Analytic, d: Vec3, out: &mut Vec<Sil>, stop: &dyn Fn() -> bool) {
let Some(&(s, trim)) = parts.first() else { return };
let mut push = |curve: C3, acc: CurveAccuracy| {
// A traced curve's chords stand up to their deviation off the surface.
@@ -42,7 +43,13 @@ pub(crate) fn silhouettes(parts: &[(&S3, &Trim)], an: Analytic, d: Vec3, out: &m
CurveAccuracy::Sampled { max_sample_deviation_mm: dev } => dev,
_ => 0.,
};
let mut ranges: Vec<[f64; 2]> = parts.iter().flat_map(|(s, trim)| inside_ranges(&curve, s, trim, slack)).collect();
let mut ranges: Vec<[f64; 2]> = Vec::new();
for (s, trim) in parts {
if stop() {
return;
}
ranges.extend(inside_ranges(&curve, s, trim, slack));
}
ranges.sort_by(|a, b| a[0].total_cmp(&b[0]));
let [t0, t1] = curve.domain();
let mut merged: Vec<[f64; 2]> = Vec::with_capacity(ranges.len());
+436 -4
View File
@@ -2557,11 +2557,11 @@ fn chords_only_near_each_other_still_meet_their_crossings() {
let sag = 1000. * (1. - 0.1f64.cos());
let out = Vec3::new(0.1f64.cos(), 0.1f64.sin(), 0.);
let line = C3::Line { a: a + out, b: b + out };
let track = |curve: &C3, range: [f64; 2], sag: f64| {
fn track<'a>(view: &View, curve: &'a C3, range: [f64; 2], sag: f64) -> Track<'a> {
let screen = |t: f64| view.screen(curve.at(t));
Track {
placement: 0,
curve: TrackCurve::Owned(curve.clone()),
curve,
range,
source: Source::Edge { edge: 0, tangent: false },
acc: CurveAccuracy::Analytic,
@@ -2571,8 +2571,8 @@ fn chords_only_near_each_other_still_meet_their_crossings() {
sags: vec![sag],
splits: Vec::new(),
}
};
let mut tracks = vec![track(&arc, [0., 0.2], sag), track(&line, [0., 1.], 0.)];
}
let mut tracks = vec![track(&view, &arc, [0., 0.2], sag), track(&view, &line, [0., 1.], 0.)];
assert!(sag > 4.9 && ((a + out).dist(a) - 1.).abs() < 1e-12);
assert_eq!(crossings(&mut tracks, &ctx).unwrap(), 2);
assert!(tracks.iter().all(|t| t.splits.len() == 2), "{:?}", tracks.iter().map(|t| &t.splits).collect::<Vec<_>>());
@@ -2870,3 +2870,435 @@ fn a_traced_rim_is_drawn_on_the_true_intersection_or_refused() {
let off = prepare(&traced(11.), &o).err();
assert!(matches!(off, Some(HlrError::Unsupported("surface intersection curve"))), "{off:?}");
}
/// Turned `a` radians about Z, standing at `t`.
fn turned_about_z(body: usize, a: f64, t: [f64; 3]) -> Placement {
let (c, s) = (a.cos(), a.sin());
Placement { body, rotation: [Vec3::new(c, s, 0.), Vec3::new(-s, c, 0.), Vec3::new(0., 0., 1.)], translation: Vec3::new(t[0], t[1], t[2]) }
}
/// A mixed scene: boxes (two touching), cylinders, a sphere, a torus seen obliquely (body 3,
/// a traced and sampled silhouette) and a left-out freeform patch over them, some placements
/// sharing a body and a turn.
fn mixed_scene(o: &Options) -> (Vec<Prepared>, Vec<Placement>) {
let defs = vec![
prepare(&cuboid([0.; 3], [8.; 3]), o).unwrap(),
prepare(&cylinder(4., 10.), o).unwrap(),
prepare(&sphere(4.), o).unwrap(),
prepare(&torus(5., 1.5), o).unwrap(),
prepare(&twisted_patch(), o).unwrap(),
];
let mut scene = vec![identity(0, [0.; 3]), identity(0, [8., 0., 0.]), identity(3, [4., 4., 12.]), identity(4, [6., 2., 20.])];
for k in 0..12 {
let body = [1, 2, 0][k % 3];
// Every other placement repeats an earlier turn: those share their silhouettes.
scene.push(turned_about_z(body, 0.4 * (k / 2) as f64, [3. + 5. * (k % 4) as f64, -6. - 5. * (k / 4) as f64, (k % 3) as f64]));
}
(defs, scene)
}
/// Timing (`cargo test --release -p solidcraft-geom --lib hlr_timing -- --ignored --nocapture`).
/// Each scene is drawn on one thread, two workers and one worker per core, in a rotated order
/// over three rounds; the first round's drawings must not depend on the thread count (compared
/// outside the timed part). Scenes: 100 placements (`SOLIDCRAFT_BENCH_HLR=N` for N) of four
/// bodies each turned differently; the same grid at one turn sharing four definitions, against
/// one definition prepared per placement; and sixteen thin bars seen from above, crossing 8x8.
/// The first of the four phases is grouping, silhouette tracing, tracks and screen sampling.
#[test]
#[ignore]
fn hlr_timing() {
let n = std::env::var("SOLIDCRAFT_BENCH_HLR").ok().and_then(|s| s.parse().ok()).unwrap_or(100);
let o = Options::default();
let bodies = [cuboid([0.; 3], [8.; 3]), cylinder(4., 10.), sphere(4.), torus(5., 1.5)];
let started = std::time::Instant::now();
let shared: Vec<Prepared> = bodies.iter().map(|b| prepare(b, &o).unwrap()).collect();
eprintln!("prepare {} bodies: {:.3} s", shared.len(), started.elapsed().as_secs_f64());
let started = std::time::Instant::now();
let own: Vec<Prepared> = (0..n).map(|k| prepare(&bodies[k % 4], &o).unwrap()).collect();
eprintln!("prepare {n} definitions: {:.3} s", started.elapsed().as_secs_f64());
let at = |k: usize| [14. * (k % 10) as f64, 14. * (k / 10) as f64, 0.];
let turned: Vec<Placement> = (0..n).map(|k| turned_about_z(k % 4, 0.07 * k as f64, at(k))).collect();
let alike: Vec<Placement> = (0..n).map(|k| turned_about_z(k % 4, 0.3, at(k))).collect();
let unshared: Vec<Placement> = (0..n).map(|k| turned_about_z(k, 0.3, at(k))).collect();
let bar = vec![prepare(&cuboid([-18., -0.5, 0.], [18., 0.5, 1.]), &o).unwrap()];
let half = std::f64::consts::FRAC_PI_2;
let bars: Vec<Placement> = (0..8)
.map(|k| identity(0, [0., -14. + 4. * k as f64, 0.]))
.chain((0..8).map(|k| turned_about_z(0, half, [-14. + 4. * k as f64, 0., 2.])))
.collect();
let iso = View::new(Vec3::new(-1., -1., -1.), Vec3::new(0., 0., 1.)).unwrap();
let top = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap();
let scenes: [(&str, &[Prepared], &[Placement], &View); 4] = [
("turned", &shared, &turned, &iso),
("alike, shared", &shared, &alike, &iso),
("alike, unshared", &own, &unshared, &iso),
("bars", &bar, &bars, &top),
];
for (name, defs, scene, view) in scenes {
let mut drawn = Vec::new();
for round in 0..3 {
for i in 0..3 {
let threads = [1, 2, 0][(round + i) % 3];
let started = std::time::Instant::now();
let d = hidden_lines(defs, scene, view, &Options { threads, ..o }, &never).unwrap();
let seconds = started.elapsed().as_secs_f64();
let s = &d.stats;
eprintln!("{name}, {} placements, threads {threads}: {seconds:.3} s, phases {:.0?} ms, {} rays", scene.len(), s.phase_ms, s.rays);
if round == 0 {
drawn.push(fingerprint(d));
}
}
}
assert!(drawn.windows(2).all(|w| w[0] == w[1]), "{name}: the thread count changed the drawing");
}
}
#[test]
fn permuting_or_removing_placements_moves_only_their_lines() {
// One prepared box at three positions far apart on screen, so none hides another: every
// line names its placement by its current index, whatever the order or count.
let def = prepare(&cuboid([0.; 3], [1.; 3]), &Options::default()).unwrap();
let view = View::new(Vec3::new(-1., -1., -1.), Vec3::new(0., 0., 1.)).unwrap();
let at = [[0., 0., 0.], [6., -6., 0.], [-6., 6., 0.]];
let draw = |order: &[usize]| {
let scene: Vec<Placement> = order.iter().map(|&i| identity(0, at[i])).collect();
hidden_lines(std::slice::from_ref(&def), &scene, &view, &Options::default(), &never).unwrap()
};
// Each line keyed by the position it was placed at: bit for bit, or with its pieces' ends
// rounded to 1e-9 mm (the view-local frame follows the scene, so a smaller scene rounds
// its coordinates differently in the last bits).
let lines = |order: &[usize], exact: bool| {
let r = |v: f64| (v * 1e9).round() / 1e9 + 0.;
let mut keyed: Vec<(usize, String)> = draw(order)
.lines
.iter()
.map(|l| {
let pieces = if exact {
format!("{:?}", l.pieces)
} else {
let rounded: Vec<_> = l
.pieces
.iter()
.map(|p| {
let ends: Vec<_> = p
.geometry
.iter()
.map(|g| match g {
Seg2::Line { a, b } => [r(a.x), r(a.y), r(b.x), r(b.y)],
_ => [f64::NAN; 4],
})
.collect();
format!("{:?} {:?} {} {:?} {ends:?}", p.t.map(r), p.visibility, p.covered, p.accuracy)
})
.collect();
format!("{rounded:?}")
};
(order[l.placement], format!("{:?} {pieces}", l.source))
})
.collect();
keyed.sort();
keyed
};
let base = lines(&[0, 1, 2], true);
assert_eq!(base.iter().filter(|(p, _)| *p == 1).count(), 12, "{base:?}");
for order in [[2, 0, 1], [1, 2, 0]] {
assert_eq!(lines(&order, true), base, "{order:?}");
}
let without: Vec<_> = lines(&[0, 1, 2], false).into_iter().filter(|(p, _)| *p != 1).collect();
assert_eq!(lines(&[0, 2], false), without);
// Analytic and exact: each box draws the first one's lines moved by its own screen offset.
let d = draw(&[0, 1, 2]);
for (p, offset) in at.iter().enumerate().skip(1) {
let [dx, dy] = view.screen(Vec3::new(offset[0], offset[1], offset[2]));
for l in d.lines.iter().filter(|l| l.placement == p) {
let first = d.lines.iter().find(|f| f.placement == 0 && f.source == l.source).unwrap();
assert_eq!(l.pieces.len(), first.pieces.len(), "{:?}", l.source);
for (a, b) in l.pieces.iter().zip(&first.pieces) {
assert_eq!((a.visibility, a.accuracy), (b.visibility, b.accuracy));
assert!(matches!(a.accuracy, crate::section::CurveAccuracy::Analytic), "{:?}", a.accuracy);
for (g, h) in a.geometry.iter().zip(&b.geometry) {
let (Seg2::Line { a: ga, b: gb }, Seg2::Line { a: ha, b: hb }) = (g, h) else { panic!("a box edge is a line: {g:?}") };
for (u, v) in [(ga, ha), (gb, hb)] {
assert!((u.x - v.x - dx).abs() < 1e-9 && (u.y - v.y - dy).abs() < 1e-9, "{u:?} vs {v:?} + ({dx}, {dy})");
}
}
}
}
}
}
/// Everything a drawing holds, phase timings left out.
fn fingerprint(mut d: Drawing) -> String {
d.stats.phase_ms = [0.; 4];
format!("{d:?}")
}
#[test]
fn serial_and_parallel_work_draw_the_same_bits() {
let view = View::new(Vec3::new(-1., -0.8, -0.6), Vec3::new(0., 0., 1.)).unwrap();
// Freeform faces handled and tangent edges left out change every phase's work; once more
// than one thread is enough to compare there.
let freeform = Options { freeform: true, tangent_edges: false, ..Options::default() };
for (base, counts) in [(Options::default(), &[2, 3, 8, 0][..]), (freeform, &[4][..])] {
let (defs, scene) = mixed_scene(&base);
let run = |threads: usize| hidden_lines(&defs, &scene, &view, &Options { threads, ..base }, &never).unwrap();
let serial = run(1);
assert!(serial.stats.crossings > 0 && serial.stats.pieces > serial.stats.tracks, "{:?}", serial.stats);
assert!(base.freeform || serial.stats.uncertain_pieces > 0, "the patch casts its shadow: {:?}", serial.stats);
let serial = fingerprint(serial);
for &threads in counts {
assert!(fingerprint(run(threads)) == serial, "{threads} threads differ from one");
}
}
}
#[test]
fn placements_sharing_a_body_and_turn_draw_as_unshared_ones() {
// The second placement shares the first one's silhouettes; with its own copy of the body
// it traces them again. The drawings are the same.
let o = Options::default();
let view = View::new(Vec3::new(-1., -0.8, -0.6), Vec3::new(0., 0., 1.)).unwrap();
let scene = [turned_about_z(0, 0.3, [0.; 3]), turned_about_z(0, 0.3, [9., 3., 1.])];
let shared = hidden_lines(&[prepare(&torus(5., 1.5), &o).unwrap()], &scene, &view, &o, &never).unwrap();
let defs = [prepare(&torus(5., 1.5), &o).unwrap(), prepare(&torus(5., 1.5), &o).unwrap()];
let own = hidden_lines(&defs, &[scene[0], Placement { body: 1, ..scene[1] }], &view, &o, &never).unwrap();
assert!(shared.stats.silhouettes > 0);
assert!(fingerprint(shared) == fingerprint(own));
}
#[test]
fn budgets_hold_for_the_whole_call_on_any_number_of_threads() {
let view = View::new(Vec3::new(-1., -0.8, -0.6), Vec3::new(0., 0., 1.)).unwrap();
let (defs, mut scene) = mixed_scene(&Options::default());
// The torus's tracing is slow and adds nothing here.
scene.retain(|p| p.body != 3);
let all = hidden_lines(&defs, &scene, &view, &Options { threads: 1, ..Options::default() }, &never).unwrap();
let (rays, tracks) = (all.stats.rays, all.stats.tracks);
for threads in [1, 4] {
let o = Options { threads, ..Options::default() };
let run = |o: Options| hidden_lines(&defs, &scene, &view, &o, &never);
assert_eq!(run(Options { max_rays: rays, ..o }).map(|d| d.stats.rays).ok(), Some(rays), "{threads} threads");
assert!(matches!(run(Options { max_rays: rays - 1, ..o }), Err(HlrError::Budget("ray"))), "{threads} threads");
assert!(run(Options { max_tracks: tracks, ..o }).is_ok(), "{threads} threads");
assert!(matches!(run(Options { max_tracks: tracks - 1, ..o }), Err(HlrError::Budget("curve"))), "{threads} threads");
}
}
#[test]
fn workers_poll_cancellation() {
// Cancelled only when asked from another thread than the caller's: every parallel item
// runs on a worker, which asks before it starts.
let caller = std::thread::current().id();
let elsewhere = move || std::thread::current().id() != caller;
let view = View::new(Vec3::new(-1., -0.8, -0.6), Vec3::new(0., 0., 1.)).unwrap();
let (defs, mut scene) = mixed_scene(&Options::default());
scene.retain(|p| p.body != 3);
let run =
|threads: usize, cancel: &(dyn Fn() -> bool + Sync)| hidden_lines(&defs, &scene, &view, &Options { threads, ..Options::default() }, cancel);
assert!(run(1, &elsewhere).is_ok());
assert!(matches!(run(4, &elsewhere), Err(HlrError::Cancelled)));
// Cancelled part way through, wherever it is noticed, the call ends cancelled.
for after in [1, 10, 40] {
let polls = AtomicUsize::new(0);
let late = || polls.fetch_add(1, Ordering::Relaxed) >= after;
assert!(matches!(run(4, &late), Err(HlrError::Cancelled)), "after {after} polls");
}
}
#[test]
fn a_failing_or_panicking_worker_stops_the_work_without_escaping() {
let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap();
let ctx = || context(&[], &[], &view, &Options { threads: 4, ..Options::default() }, &never).unwrap();
let items: Vec<usize> = (0..200).collect();
assert_eq!(ctx().map(&items, 1, |i| Ok(i * 3)).unwrap(), items.iter().map(|i| i * 3).collect::<Vec<_>>());
assert!(matches!(ctx().map(&items, 1, |i| if *i == 50 { Err(HlrError::Budget("test")) } else { Ok(*i) }), Err(HlrError::Budget("test"))));
let out = ctx().map(&items, 1, |i| {
assert_ne!(*i, 37, "a panicking item is the call's error");
Ok(*i)
});
assert!(matches!(out, Err(HlrError::Internal(_))), "{out:?}");
}
#[test]
fn a_panicking_worker_stops_the_other_workers() {
// The worker that takes item 1 waits until the one that panicked on item 0 has exited
// (its thread-local is dropped last), so by then the panic is known: no further item runs.
struct OnExit(Option<std::sync::mpsc::Sender<()>>);
impl Drop for OnExit {
fn drop(&mut self) {
if let Some(tx) = self.0.take() {
let _ = tx.send(());
}
}
}
thread_local!(static EXIT: std::cell::RefCell<OnExit> = const { std::cell::RefCell::new(OnExit(None)) });
let (tx, rx) = std::sync::mpsc::channel();
let rx = std::sync::Mutex::new(rx);
let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap();
let ctx = context(&[], &[], &view, &Options { threads: 2, ..Options::default() }, &never).unwrap();
let ran = AtomicUsize::new(0);
let items: Vec<usize> = (0..200).collect();
let out = ctx.map(&items, 1, |i| {
ran.fetch_add(1, Ordering::Relaxed);
if *i == 0 {
EXIT.with(|exit| exit.borrow_mut().0 = Some(tx.clone()));
panic!("item 0 panics");
}
if *i == 1 {
let _ = rx.lock().map(|rx| rx.recv());
}
Ok(*i)
});
assert!(matches!(out, Err(HlrError::Internal(_))), "{out:?}");
// Item 1 runs only if the other worker took it before the panic; without the stop, all 200 would.
let ran = ran.load(Ordering::Relaxed);
assert!(ran <= 2, "{ran} items ran");
}
#[test]
fn scenes_and_trims_beyond_the_shared_limits_are_refused() {
let def = prepare(&cuboid([0.; 3], [1.; 3]), &Options::default()).unwrap();
let view = View::new(Vec3::new(-1., -1., -1.), Vec3::new(0., 0., 1.)).unwrap();
let crowd = vec![identity(0, [0.; 3]); MAX_ITEMS + 1];
assert!(matches!(hidden_lines(&[def], &crowd, &view, &Options::default(), &never), Err(HlrError::Budget("placement"))));
for trim_samples in [0, MAX_TRIM_SAMPLES + 1] {
assert!(matches!(prepare(&cuboid([0.; 3], [1.; 3]), &Options { trim_samples, ..Options::default() }), Err(HlrError::Invalid(_))));
}
assert_eq!((MAX_ITEMS, Options::default().max_tracks), (crate::section::MAX_ITEMS, crate::section::MAX_ITEMS));
}
/// A disc bounded by one closed cubic spline through `n` control points on a wavy circle of
/// radius `r`: `n - 3` knot spans, never taken for a circle.
fn wavy_disc(r: f64, n: usize) -> Brep {
let tau = std::f64::consts::TAU;
let points: Vec<[f64; 3]> = (0..n)
.map(|i| {
let a = tau * (i % (n - 1)) as f64 / (n - 1) as f64;
let rho = r * (1. + 0.01 * (7. * a).sin());
[rho * a.cos(), rho * a.sin(), 0.]
})
.collect();
let spans = n - 3;
let knots: Vec<f64> = (0..n + 4).map(|i| (i.saturating_sub(3).min(spans)) as f64 / spans as f64).collect();
Brep {
version: solidcore_record::brep::VERSION,
shells: vec![Shell {
vertices: vec![points[0]],
edges: vec![Edge { vertices: [0, 0], curve: Curve::Spline { knots, points } }],
faces: vec![Face {
loops: vec![vec![uses(0, true)]],
surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] },
forward: true,
}],
}],
}
}
/// The track of a placement's first edge, unsampled.
fn first_edge_track(def: &Prepared) -> Track<'_> {
let e = &def.edges[0];
Track {
placement: 0,
curve: &e.curve,
range: e.curve.domain(),
source: Source::Edge { edge: 0, tangent: false },
acc: e.acc,
own: e.faces.clone(),
own_eps: 1e-6,
samples: Vec::new(),
sags: Vec::new(),
splits: Vec::new(),
}
}
#[test]
fn long_items_poll_cancellation_inside_them() {
let o = Options::default();
let view = View::new(Vec3::new(-1., -0.8, -0.6), Vec3::new(0., 0., 1.)).unwrap();
// Sampling one long spline is one item: it asks again at every refinement pass.
let defs = [prepare(&wavy_disc(50., 1000), &o).unwrap()];
let scene = [identity(0, [0.; 3])];
let track = first_edge_track(&defs[0]);
let polls = AtomicUsize::new(0);
let counting = || {
polls.fetch_add(1, Ordering::Relaxed);
false
};
let ctx = context(&defs, &scene, &view, &o, &counting).unwrap();
let (samples, _) = screen_samples(&track, &ctx).unwrap();
assert!(samples.len() > 1000 && polls.load(Ordering::Relaxed) >= 2, "{} samples, {polls:?} polls", samples.len());
let ctx = context(&defs, &scene, &view, &o, &|| true).unwrap();
assert!(matches!(screen_samples(&track, &ctx), Err(HlrError::Cancelled)));
// Tracing a body's silhouettes is one item: it asks again before each surface.
let (a, b) = (torus(5., 1.5), torus(9., 1.));
let two = prepare(&Brep { version: solidcore_record::brep::VERSION, shells: vec![a.shells[0].clone(), b.shells[0].clone()] }, &o).unwrap();
let ctx = context(&[], &[], &view, &o, &never).unwrap();
assert_eq!(body_silhouettes(&two, view.d, &o, &ctx).unwrap().len(), 2);
let polls = AtomicUsize::new(0);
let second = || polls.fetch_add(1, Ordering::Relaxed) >= 1;
let ctx = context(&[], &[], &view, &o, &second).unwrap();
assert!(matches!(body_silhouettes(&two, view.d, &o, &ctx), Err(HlrError::Cancelled)));
// One surface in two faces: it asks again before each face its curves are trimmed to, and
// a cancellation seen there drops the partial curves even if the callback changes its mind.
let halves = prepare(&torus(5., 1.5), &o).unwrap();
let polls = AtomicUsize::new(0);
let counting = || {
polls.fetch_add(1, Ordering::Relaxed);
false
};
let ctx = context(&[], &[], &view, &o, &counting).unwrap();
assert_eq!(body_silhouettes(&halves, view.d, &o, &ctx).unwrap().len(), 1);
assert!(polls.load(Ordering::Relaxed) >= 3, "{polls:?} polls");
let polls = AtomicUsize::new(0);
let once = || polls.fetch_add(1, Ordering::Relaxed) == 1;
let ctx = context(&[], &[], &view, &o, &once).unwrap();
assert!(matches!(body_silhouettes(&halves, view.d, &o, &ctx), Err(HlrError::Cancelled)));
// Many faces: forming the sets asks too, before any surface is traced.
let shells = (0..50).map(|k| cuboid([2. * k as f64, 0., 0.], [2. * k as f64 + 1., 1., 1.]).shells.remove(0)).collect();
let boxes = prepare(&Brep { version: solidcore_record::brep::VERSION, shells }, &o).unwrap();
let ctx = context(&[], &[], &view, &o, &|| true).unwrap();
assert!(matches!(body_silhouettes(&boxes, view.d, &o, &ctx), Err(HlrError::Cancelled)));
// One chord across the boxes of many left-out faces: it asks every 1024 boxes.
let defs = [prepare(&cuboid([0.; 3], [1.; 3]), &o).unwrap()];
let top = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap();
let mut track = first_edge_track(&defs[0]);
let ctx = context(&defs, &scene, &top, &o, &never).unwrap();
(track.samples, track.sags) = screen_samples(&track, &ctx).unwrap();
// The prepared box stands at its centre: edge 0 runs from (-0.5, -0.5) to (0.5, -0.5).
assert_eq!(track.samples.len(), 2, "one chord");
let across =
(1..2000).map(|k| Shadow { lo: [k as f64 / 2000. - 0.5 - 1e-4, -1.], hi: [k as f64 / 2000. - 0.5 + 1e-4, 1.], near: -10. }).collect();
let shadows = ShadowIndex::new(across).unwrap();
assert_eq!(shadow_cuts(&track, &ctx, &shadows, &AtomicUsize::new(0)).unwrap().len(), 2 * 1999);
let ctx = context(&defs, &scene, &top, &o, &|| true).unwrap();
assert!(matches!(shadow_cuts(&track, &ctx, &shadows, &AtomicUsize::new(0)), Err(HlrError::Cancelled)));
}
#[test]
fn spline_points_and_hulls_come_from_their_own_knot_spans() {
// Uniform knots, and repeated inner knots with an end span of their own.
let wavy = wavy_disc(50., 300);
let Curve::Spline { knots, points } = &wavy.shells[0].edges[0].curve else { panic!("a spline edge") };
let repeated = Curve::Spline {
knots: vec![0., 0., 0., 0., 0.2, 0.2, 0.5, 0.5, 0.5, 0.8, 1., 1., 1., 1.],
points: (0..10).map(|i| [i as f64, ((i * 7) % 5) as f64, (i % 3) as f64]).collect(),
};
for curve in [Curve::Spline { knots: knots.clone(), points: points.clone() }, repeated] {
let c = C3::bounded(&curve.label(), curve.extent()).unwrap();
let C3::Spline { degree, knots, pieces, .. } = &c else { panic!("a spline") };
let [t0, t1] = c.domain();
let ts = knots.iter().copied().chain((0..=997).map(|i| t0 + (t1 - t0) * i as f64 / 997.)).filter(|t| *t >= t0 && *t <= t1);
for t in ts {
// The point from the Bézier piece holding t, against de Boor's.
let (span, ctrl) = pieces.iter().find(|(s, _)| s[0] <= t && t <= s[1]).unwrap();
let h: Vec<crate::bspline::HPoint> = ctrl.iter().map(|q| crate::bspline::HPoint { x: q[0], y: q[1], z: q[2], w: q[3] }).collect();
let q = crate::bspline::bezier_point(&h, (t - span[0]) / (span[1] - span[0])).project().unwrap();
assert!(q.dist(c.at(t)) < 1e-9, "t = {t}: {q:?} against {:?}", c.at(t));
}
// A part's hull holds the control points of exactly the pieces it overlaps.
for (a, b) in [(t0, t1), (t0 + 0.3 * (t1 - t0), t0 + 0.31 * (t1 - t0)), (t0 + 0.5 * (t1 - t0), t1)] {
let overlapping = pieces.iter().filter(|(s, _)| s[0] < b && s[1] > a).count();
assert_eq!(c.hull([a, b]).map_or(0, |h| h.len()), overlapping * (degree + 1), "part {a}..{b}");
}
}
}
+102 -18
View File
@@ -1,7 +1,8 @@
# Hidden lines (W06a)
This is the geometry core of Drawing package W06 from the [published plan][plan] in
[#860][rfc]: [#2455][issue] / [PR #2457][pr]. It turns the subset that the W05a
[#860][rfc]: [#2455][issue] / [PR #2457][pr], with its parallel work and shared limits in
[PR #3013][pr2]. It turns the subset that the W05a
investigation ([#2105][w05-issue], [draft #2106][w05-pr], [findings][findings])
showed to be ready into code: exact orthographic hidden lines for placed bodies whose
faces are planes, cylinders, cones, spheres or tori.
@@ -30,9 +31,12 @@ hidden_lines(&[Prepared], &[Placement], &View, &Options, &cancel) -> Result<Draw
- `View::new(direction_of_sight, up)` is an orthographic view. Screen coordinates are
`(p . x, p . y)` in millimetres, with `x = direction x up` normalised and `y = x x
direction`.
- `cancel` is polled during the work; when it returns true the call ends with
`HlrError::Cancelled`. `prepare_cancellable(&Brep, &Options, &cancel)` is `prepare` with
such a callback, polled between edges, faces and surface comparisons.
- `cancel` is polled during the work, from every worker thread, so it must be `Sync`; when
it returns true the call ends with `HlrError::Cancelled`. `prepare_cancellable(&Brep,
&Options, &cancel)` is `prepare` with such a callback, polled between edges, faces and
surface comparisons.
- `Options::threads` sets the number of worker threads (0, the default, means one per core);
see [Parallel work](#parallel-work).
The `Drawing` holds one `Line` per drawable edge and per silhouette curve of each
placement, in order of placement. A line names its `placement` and `source` (`Edge { edge, tangent }`
@@ -95,6 +99,44 @@ large scenes keep their detail.
the scene and shifted back at the end, so coordinates stay small: a part 1e7 mm from the
origin, whether placed there or modelled there, is classified as at the origin.
## Parallel work
`hidden_lines` spreads its per-curve work over worker threads: silhouette tracing, screen
sampling, the cuts at the boxes of left-out faces, the classification of pieces, coverage,
and the crossing search (by rows of its screen grid) with the refinement of each crossing.
Each phase hands its items to the workers one at a time and stores every result at its
item's index, so the drawing is identical, bit for bit, for any number of threads: the same
pieces in the same order and the same statistics apart from the phase timings.
- **Thread count.** With `Options::threads` at 0 (the default) a phase uses up to one worker
per core, but at most one per distinct silhouette tracing, 16 curves, 4 grid rows or 256
crossings, so the per-curve phases of a small view stay on the calling thread. 1 runs
everything on the calling thread; a larger number caps the workers. The browser build
always runs on the calling thread.
- **Shared silhouettes.** Silhouettes depend on the body and the direction of sight in its
own frame, not on its position, so placements that share both (repeated parts turned
alike) share one tracing. This speeds up the browser build too.
- **Cancellation.** `cancel` is polled before every item, on whichever thread runs it, and
inside long items: every 256 faces and before every face a silhouette is trimmed to, at
every sampling pass, every 4096 chords and every 1024 boxes met when cutting at left-out
faces, every 1024 rays, every 65 536 crossing candidates, and between the chords of a piece
once 65 536 coverage checks have passed since the last poll.
The work between two polls is bounded by the input limits, not by a time: in ordinary
scenes the longest step is tracing one surface's silhouette curves, a few tenths of a second
for a torus, but a scene built at the limits can make one step take longer.
- **Errors.** The first error, cancellation included, stops the other workers and is the one
returned. When two different failures coincide on different threads (a cancellation while
a budget is exceeded), the error returned may differ from a run on one thread; a successful
result never does.
- **Panics.** Every parallel item runs on a spawned worker that the calling thread joins. A
worker that panics loses its item and stops the other workers: the call returns
`HlrError::Internal` and no drawing, and the panic does not reach the caller. Work on the calling thread (one thread, or the browser
build) lets a panic propagate to the caller as before.
Measured on a 32-thread desktop (release build, isometric view of boxes, cylinders, spheres
and tori): 100 placements, each turned differently, take 0.8 s, against 9.9 s on one thread;
300 placements turned alike take 0.8 s, against 33 to 55 s before silhouettes were shared.
## Freeform faces
Faces that are not planes, cylinders, cones, spheres or tori are freeform. By default
@@ -110,14 +152,45 @@ in the W05a findings.
## Limits
- `Options::max_tracks` (curves) and `Options::max_rays` bound the work, as do internal
caps: crossing grid entries (50 million), crossing candidate pairs (500 million, counted
before the search, so no set of pairs met grows with them), crossings (50 million) and
the surface comparisons that group a body's faces (50 million). Exceeding one returns
`HlrError::Budget`.
- `Options::trim_samples` is 1 to `MAX_TRIM_SAMPLES` (4096), and every coordinate of a
placed body at most `MAX_COORDINATE` (1e12 mm, the bound `geom::section` and
`geom::brep` put on stored numbers); otherwise the call returns `HlrError::Invalid`.
Inputs are checked before any work:
| Input | Limit | Otherwise |
| --- | --- | --- |
| Records of one body | `solidcore_record::brep::MAX_ITEMS` (4 000 000), checked by `brep::check::validate` in `prepare` | `Invalid("portable B-rep")` |
| Placements in one scene | `hlr::MAX_ITEMS` (100 000) | `Budget("placement")` |
| `Options::trim_samples` | 1 to `MAX_TRIM_SAMPLES` (4096) | `Invalid("trim samples")` |
| Coordinates of a placed body's box | at most `MAX_COORDINATE` (1e12 mm, the bound `geom::section` and `geom::brep` put on stored numbers) | `Invalid("scene extent")` |
| `Options::threads` | at most `MAX_THREADS` (64) | capped |
Work budgets are totals over the whole call and all worker threads. Whether one is exceeded
does not depend on the number of threads or on their scheduling:
| Work | Budget | Error |
| --- | --- | --- |
| Surface comparisons grouping a body's faces, in `prepare` | 50 million | `Budget("surface group")` |
| Curves: drawable edges and silhouette pieces | `Options::max_tracks`, default `hlr::MAX_ITEMS` | `Budget("curve")` |
| Visibility rays | `Options::max_rays`, default 20 000 000 | `Budget("ray")` |
| Crossing grid entries | 50 million | `Budget("crossing grid")` |
| Crossing candidate pairs, counted before the search | 500 million | `Budget("crossing pair")` |
| Crossings | 50 million | `Budget("crossing")` |
| Left-out face grid entries | 50 million | `Budget("shadow grid")` |
| Left-out face box checks | 500 million | `Budget("shadow")` |
| Coverage point checks | 200 million | `Budget("coverage")` |
`hlr::MAX_ITEMS` is `section::MAX_ITEMS`, the aggregate item limit that `geom::section`
applies, and the body limit of `file.export_view_dxf`. It bounds the placements a caller
supplies and, by default, the number of curves, which is the number of lines in the drawing;
a caller drawing a large assembly may raise `max_tracks`. A body's own records have the
larger limit of the B-rep record (`solidcore_record::brep`), which stores whole evaluated
models.
The ray budget is HLR's own: it counts visibility classifications, one per ray. The 200 million
work steps of `file.export_view_dxf` count other work (placements, tree nodes and triangles of
its occlusion test), so the two numbers are not comparable. Every piece costs at least one
ray, so the ray budget also bounds the pieces. The remaining caps bound
the steps in between.
Other limits:
- `Options::tangent_edges` (default on) draws edges between tangent faces, flagged
`tangent: true`; drafting policy for them is W17.
- Tolerances are relative to the faces and the scene. They are meant for faces up to
@@ -127,14 +200,15 @@ in the W05a findings.
recognition takes for one (closer than 1e-9 of their size or position, a nearly flat
cone's far apex included) share the own-face margin of 1e-6 of the face's size when a
ray leaves one of them.
- Not in this slice: parallel per-curve work and the shared work limits of
`geom::section`, the kernel adapter (after W02), perspective, section regions (W03),
- Not in this slice: the kernel adapter (after W02), perspective, section regions (W03),
views and commands (W07), and mirrored placements. Of the records the kernel now saves,
planes through three points (`PlaneThrough`) are drawn as planes; a procedural
`SurfaceIntersection` curve is drawn as its leader, within its tolerance, when the leader is
a spline; one with a polyline leader, or a `PlacedRevolution` surface whose placement is not
rigid (it may scale it unevenly), is refused by `prepare` with `HlrError::Unsupported` until
the adapter handles them.
`SurfaceIntersection` curve is drawn as its leader, within its tolerance, when the leader
is a spline; one with a polyline leader, or a `PlacedRevolution` surface whose placement
is not rigid (it may scale it unevenly), is refused by `prepare` with
`HlrError::Unsupported` until the adapter handles them. A single surface's silhouette is
traced on one thread, so one torus seen obliquely still takes about 0.3 s whatever the
thread count.
## Fixtures
@@ -187,6 +261,15 @@ and intervals:
- a box stored with planes through three points draws the same bits as one with
axis-stored planes; rigidly placed revolutions draw as what they are, others are refused
as not supported yet, as are intersection curves with no spline to follow;
- a mixed scene (touching boxes, cylinders, a sphere, an oblique torus and a left-out patch,
with and without freeform faces) draws the same bits on 1, 2, 3, 8 and automatic threads;
placements sharing a body and a turn draw as if each had its own copy of the body; the ray
and curve budgets pass at the serial run's counts and fail one below them on 1 and 4
threads; cancellation asked only from worker threads ends a parallel run, and a failing or
panicking item ends the work with its error; scenes over `MAX_ITEMS` placements and trim
sample counts out of range are refused; sampling one long spline and tracing a body's
silhouettes ask for cancellation inside the item; spline points and part hulls come from
their own knot spans;
- torus ray roots for thin, large and nearly tangent cases, and torus silhouettes near
the axis direction.
@@ -195,5 +278,6 @@ and intervals:
[findings]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6243691483
[issue]: https://github.com/tangency-labs/solidcraft/issues/2455
[pr]: https://github.com/tangency-labs/solidcraft/pull/2457
[pr2]: https://github.com/tangency-labs/solidcraft/pull/3013
[w05-issue]: https://github.com/tangency-labs/solidcraft/issues/2105
[w05-pr]: https://github.com/tangency-labs/solidcraft/pull/2106