From e81a201ff8ef91c6a6fa52907ecf68fa2ffda3f5 Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:56:07 +0100 Subject: [PATCH] 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> --- crates/geom/src/hlr/curve.rs | 16 +- crates/geom/src/hlr/mod.rs | 879 +++++++++++++++++++----------- crates/geom/src/hlr/silhouette.rs | 13 +- crates/geom/src/hlr_tests.rs | 440 ++++++++++++++- docs/drawing-hlr.md | 120 +++- 5 files changed, 1125 insertions(+), 343 deletions(-) diff --git a/crates/geom/src/hlr/curve.rs b/crates/geom/src/hlr/curve.rs index 1b1b74d51a..214709bb5c 100644 --- a/crates/geom/src/hlr/curve.rs +++ b/crates/geom/src/hlr/curve.rs @@ -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() { diff --git a/crates/geom/src/hlr/mod.rs b/crates/geom/src/hlr/mod.rs index 764fc524b3..df289594a0 100644 --- a/crates/geom/src/hlr/mod.rs +++ b/crates/geom/src/hlr/mod.rs @@ -19,6 +19,8 @@ mod silhouette; pub(crate) mod surface; mod trim; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use crate::prelude::*; use serde::Serialize; @@ -39,6 +41,9 @@ pub enum HlrError { Cancelled, #[error("hidden lines: invalid {0}")] Invalid(&'static str), + /// A worker thread failed (it panicked); no partial drawing is returned. + #[error("hidden lines: internal error in {0}")] + Internal(&'static str), } /// An orthographic view: `d` is the direction of sight; screen coordinates are `(p.x, p.y)`. @@ -422,22 +427,34 @@ pub struct Options { pub freeform: bool, /// Boundary samples per curve piece when trimming faces, 1 to [`MAX_TRIM_SAMPLES`]. pub trim_samples: usize, + /// Most curves (drawable edges and silhouette pieces) in the whole scene. pub max_tracks: usize, + /// Most visibility rays in the whole call, counted over all worker threads. pub max_rays: usize, + /// Worker threads: 0 uses one per core (small views stay on the calling thread), 1 runs + /// on the calling thread, a larger number caps the workers (at most [`MAX_THREADS`]). The + /// browser build always runs on the calling thread. The drawing is the same either way. + pub threads: usize, } impl Default for Options { fn default() -> Self { - Options { tangent_edges: true, freeform: false, trim_samples: 256, max_tracks: 200_000, max_rays: 20_000_000 } + Options { tangent_edges: true, freeform: false, trim_samples: 256, max_tracks: MAX_ITEMS, max_rays: 20_000_000, threads: 0 } } } +/// Most placements in one scene, and the default [`Options::max_tracks`]: the aggregate item +/// limit of [`crate::section`]. Each body's records are bounded on their own by +/// [`solidcore_record::brep::MAX_ITEMS`] when [`prepare`] validates the body. +pub const MAX_ITEMS: usize = crate::section::MAX_ITEMS; /// Largest [`Options::trim_samples`]. pub const MAX_TRIM_SAMPLES: usize = 4096; /// Largest coordinate of a placed body in the scene, in millimetres: the bound `geom::section` /// and `geom::brep` put on stored numbers. Beyond it the scene's size, and every tolerance /// taken from it, would lose its meaning or overflow. pub const MAX_COORDINATE: f64 = 1e12; +/// Most worker threads. +pub const MAX_THREADS: usize = 64; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] @@ -524,14 +541,10 @@ pub struct Drawing { pub stats: Stats, } -enum TrackCurve<'a> { - Edge(&'a C3), - Owned(C3), -} - struct Track<'a> { placement: usize, - curve: TrackCurve<'a>, + /// A prepared edge's curve, or a silhouette traced once for every placement that shares it. + curve: &'a C3, range: [f64; 2], source: Source, acc: CurveAccuracy, @@ -548,10 +561,7 @@ struct Track<'a> { impl Track<'_> { fn c3(&self) -> &C3 { - match &self.curve { - TrackCurve::Edge(c) => c, - TrackCurve::Owned(c) => c, - } + self.curve } } @@ -564,15 +574,123 @@ struct Ctx<'a> { scale: f64, /// World boxes of placements, screen min/max and depth min. boxes: Vec<([f64; 2], [f64; 2], f64)>, - rays: std::cell::Cell, - surface_tests: std::cell::Cell, - transitions: std::cell::Cell, + // Totals over all workers. Each worker adds its share as it goes, so a budget holds for + // the whole call and the sums do not depend on which thread did what. + rays: AtomicUsize, + surface_tests: AtomicUsize, + transitions: AtomicUsize, max_rays: usize, freeform: bool, - cancel: &'a dyn Fn() -> bool, + /// Most worker threads, and whether that number was chosen automatically (`threads: 0`). + workers: usize, + auto_workers: bool, + /// Set by the first worker that fails or panics: the others stop as if cancelled. + stop: AtomicBool, + cancel: &'a (dyn Fn() -> bool + Sync), } -fn context<'a>(defs: &'a [Prepared], scene: &'a [Placement], view: &View, opts: &Options, cancel: &'a dyn Fn() -> bool) -> Result, HlrError> { +impl Ctx<'_> { + /// Cancelled by the caller, or stopped because another worker failed. + fn cancelled(&self) -> bool { + self.stop.load(Ordering::Relaxed) || (self.cancel)() + } + + /// `f` over `items`, with the results in item order whichever threads ran them, so the + /// output is the same serial or parallel. With an automatic thread count it starts at most + /// one worker per `grain` items (cheap items are not worth a thread each). Cancellation is + /// polled before every item; the first error stops the other workers and is returned. + fn map(&self, items: &[T], grain: usize, f: impl Fn(&T) -> Result + Sync) -> Result, HlrError> { + let one = |item: &T| if self.cancelled() { Err(HlrError::Cancelled) } else { f(item) }; + #[cfg(not(target_arch = "wasm32"))] + if let Some(out) = self.map_on_threads(items, grain, &one) { + return out; + } + // The browser build has no threads. + #[cfg(target_arch = "wasm32")] + let _ = (grain, self.workers, self.auto_workers); + items.iter().map(one).collect() + } + + /// [`Ctx::map`] on worker threads. Every item runs on a spawned worker while the calling + /// thread waits, so a worker's panic is caught when it is joined; it also stops the other + /// workers, like a failure. `None` when one thread is enough or none could be started. + #[cfg(not(target_arch = "wasm32"))] + fn map_on_threads( + &self, + items: &[T], + grain: usize, + one: &(impl Fn(&T) -> Result + Sync), + ) -> Option, HlrError>> { + // Threads are started per phase, at tens of microseconds each. A pool kept for the + // whole call would pay that once, should small views ever need parallel work. + let workers = if self.auto_workers { self.workers.min(items.len() / grain.max(1)) } else { self.workers.min(items.len()) }; + if workers < 2 { + return None; + } + let next = AtomicUsize::new(0); + let first = std::sync::Mutex::new(None); + // Dropped while a worker unwinds: the call fails anyway, so the others stop too. + struct StopOnPanic<'s>(&'s AtomicBool); + impl Drop for StopOnPanic<'_> { + fn drop(&mut self) { + if std::thread::panicking() { + self.0.store(true, Ordering::Relaxed); + } + } + } + let work = || { + let _stop = StopOnPanic(&self.stop); + let mut done = Vec::new(); + while !self.stop.load(Ordering::Relaxed) { + let i = next.fetch_add(1, Ordering::Relaxed); + let Some(item) = items.get(i) else { break }; + match one(item) { + Ok(r) => done.push((i, r)), + Err(e) => { + // The first failure is the cause; the workers it stops see `Cancelled`. + if !self.stop.swap(true, Ordering::Relaxed) + && let Ok(mut slot) = first.lock() + { + *slot = Some(e); + } + break; + } + } + } + done + }; + let joined: Vec<_> = std::thread::scope(|scope| { + let helpers: Vec<_> = + (0..workers).filter_map(|_| std::thread::Builder::new().name("solidcraft-hlr".into()).spawn_scoped(scope, work).ok()).collect(); + helpers.into_iter().map(|h| h.join()).collect() + }); + if joined.is_empty() { + return None; + } + let mut out: Vec> = std::iter::repeat_with(|| None).take(items.len()).collect(); + for done in joined { + // A worker that panicked lost its item: an error, never a partial drawing. + let Ok(done) = done else { return Some(Err(HlrError::Internal("a worker thread"))) }; + for (i, r) in done { + if let Some(slot) = out.get_mut(i) { + *slot = Some(r); + } + } + } + if let Some(e) = first.into_inner().ok().flatten() { + return Some(Err(e)); + } + Some(out.into_iter().collect::>>().ok_or(HlrError::Internal("a worker thread"))) + } +} + +fn context<'a>( + defs: &'a [Prepared], + scene: &'a [Placement], + view: &View, + opts: &Options, + cancel: &'a (dyn Fn() -> bool + Sync), +) -> Result, HlrError> { let mut boxes = Vec::with_capacity(scene.len()); let (mut wlo, mut whi) = (Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)); for p in scene { @@ -609,16 +727,33 @@ fn context<'a>(defs: &'a [Prepared], scene: &'a [Placement], view: &View, opts: eps_other: 1e-12 * scale, scale, boxes, - rays: std::cell::Cell::new(0), - surface_tests: std::cell::Cell::new(0), - transitions: std::cell::Cell::new(0), + rays: AtomicUsize::new(0), + surface_tests: AtomicUsize::new(0), + transitions: AtomicUsize::new(0), max_rays: opts.max_rays, freeform: opts.freeform, + workers: match opts.threads { + #[cfg(not(target_arch = "wasm32"))] + 0 => std::thread::available_parallelism().map_or(1, usize::from), + n => n, + } + .clamp(1, MAX_THREADS), + auto_workers: opts.threads == 0, + stop: AtomicBool::new(false), cancel, }) } -pub fn hidden_lines(defs: &[Prepared], scene: &[Placement], view: &View, opts: &Options, cancel: &dyn Fn() -> bool) -> Result { +pub fn hidden_lines( + defs: &[Prepared], + scene: &[Placement], + view: &View, + opts: &Options, + cancel: &(dyn Fn() -> bool + Sync), +) -> Result { + if scene.len() > MAX_ITEMS { + return Err(HlrError::Budget("placement")); + } // Work in a frame centred on the scene: coordinates stay small, so rounding does not grow // with the distance from the origin (W05a finding 7). Results are shifted back at the end. let origin = scene_centre(defs, scene)?; @@ -632,10 +767,41 @@ pub fn hidden_lines(defs: &[Prepared], scene: &[Placement], view: &View, opts: & let mut stats = Stats::default(); let mut unsupported = Vec::new(); let clock = Clock::start(); - // 1. Tracks: drawable edges and trimmed silhouettes of every placement. + // 1. Tracks: drawable edges and trimmed silhouettes of every placement. Whether the edges + // alone exceed the budget is known before any silhouette is traced. + let drawable = |e: &EdgeData| !(e.kind == EdgeKind::Seam || (e.kind == EdgeKind::Tangent && !opts.tangent_edges)); + let edge_counts: Vec = defs.iter().map(|d| d.edges.iter().filter(|e| drawable(e)).count()).collect(); + let edge_tracks = scene.iter().fold(0usize, |n, p| n.saturating_add(edge_counts.get(p.body).copied().unwrap_or(0))); + if edge_tracks > opts.max_tracks { + return Err(HlrError::Budget("curve")); + } + // Silhouettes depend on the body and the direction of sight in its own frame, not on where + // it stands: placements that share both (repeated parts turned alike) share one tracing. + let key = |p: &Placement| { + let dl = p.local_dir(view.d); + (p.body, dl.x.to_bits(), dl.y.to_bits(), dl.z.to_bits()) + }; + let mut uses: std::collections::BTreeMap<(usize, u64, u64, u64), usize> = std::collections::BTreeMap::new(); + for p in scene { + *uses.entry(key(p)).or_insert(0) += 1; + } + let uses: Vec<_> = uses.into_iter().collect(); + // The curves each tracing adds, once per placement sharing it, are counted as they are + // found, so the budget holds while the workers trace. + let curves = AtomicUsize::new(edge_tracks); + let traced = ctx.map(&uses, 1, |&((body, x, y, z), placements)| { + let Some(def) = defs.get(body) else { return Ok(None) }; + let sets = body_silhouettes(def, Vec3::new(f64::from_bits(x), f64::from_bits(y), f64::from_bits(z)), opts, &ctx)?; + let n = sets.iter().flat_map(|(_, _, sils)| sils).fold(0usize, |n, s| n.saturating_add(s.ranges.len())).saturating_mul(placements); + if curves.fetch_add(n, Ordering::Relaxed).saturating_add(n) > opts.max_tracks { + return Err(HlrError::Budget("curve")); + } + Ok(Some(sets)) + })?; + let traced: std::collections::BTreeMap<_, _> = uses.into_iter().map(|(k, _)| k).zip(traced).collect(); let mut tracks: Vec = Vec::new(); for (pi, p) in scene.iter().enumerate() { - if cancel() { + if ctx.cancelled() { return Err(HlrError::Cancelled); } let Some(def) = defs.get(p.body) else { continue }; @@ -644,12 +810,12 @@ pub fn hidden_lines(defs: &[Prepared], scene: &[Placement], view: &View, opts: & // distance from the origin. let far = 1e-14 * def.offset.x.abs().max(def.offset.y.abs()).max(def.offset.z.abs()); for (ei, e) in def.edges.iter().enumerate() { - if e.kind == EdgeKind::Seam || (e.kind == EdgeKind::Tangent && !opts.tangent_edges) { + if !drawable(e) { continue; } tracks.push(Track { placement: pi, - curve: TrackCurve::Edge(&e.curve), + curve: &e.curve, range: e.curve.domain(), source: Source::Edge { edge: ei, tangent: e.kind == EdgeKind::Tangent }, acc: e.acc, @@ -671,45 +837,23 @@ pub fn hidden_lines(defs: &[Prepared], scene: &[Placement], view: &View, opts: & splits: Vec::new(), }); } - let dl = p.local_dir(view.d); - let supported = |f: &FaceData| !(f.freeform && !opts.freeform) && f.trim.unsupported.is_none(); - // Faces on one surface (a sphere in two halves, say) share their silhouettes, keyed by - // the set's first face: one curve along their seams, not one per face. - let mut sets: std::collections::BTreeMap> = std::collections::BTreeMap::new(); for (fi, f) in def.faces.iter().enumerate() { if f.freeform && !opts.freeform { unsupported.push(Unsupported { placement: pi, face: fi, reason: "freeform face" }); - continue; - } - if let Some(reason) = f.trim.unsupported { + } else if let Some(reason) = f.trim.unsupported { unsupported.push(Unsupported { placement: pi, face: fi, reason }); - continue; } - if matches!(f.analytic, Analytic::Plane) { - continue; - } - // The set's silhouette is traced on its first face's surface, so a face joins only - // when its recognised surface is that one to rounding (1e-13); recognition groups - // surfaces more loosely (1e-9), and two surfaces that close can still differ inside - // the faces (two spherical caps through one circle). - let first = f.group.iter().copied().filter(|j| def.faces.get(*j).is_some_and(supported)).min().unwrap_or(fi); - let fits = def.faces.get(first).is_some_and(|l| same_analytic(&l.analytic, &f.analytic, 1e-13)); - sets.entry(if fits { first } else { fi }).or_default().push(fi); } - for (fi, members) in sets { - let Some(f) = def.faces.get(fi) else { continue }; - let parts: Vec<(&S3, &Trim)> = members.iter().filter_map(|j| def.faces.get(*j)).map(|m| (&m.surface, &m.trim)).collect(); - let size = members.iter().filter_map(|j| def.faces.get(*j)).map(|m| (m.hi - m.lo).len()).fold(0., f64::max); - let mut sils = Vec::new(); - silhouette::silhouettes(&parts, f.analytic, dl, &mut sils); + for (fi, size, sils) in traced.get(&key(p)).into_iter().flatten().flatten() { + let Some(f) = def.faces.get(*fi) else { continue }; for s in sils { for r in &s.ranges { stats.silhouettes += 1; tracks.push(Track { placement: pi, - curve: TrackCurve::Owned(s.curve.clone()), + curve: &s.curve, range: *r, - source: Source::Silhouette { face: fi }, + source: Source::Silhouette { face: *fi }, acc: s.acc, own: f.group.clone(), own_eps: match s.acc { @@ -731,88 +875,8 @@ pub fn hidden_lines(defs: &[Prepared], scene: &[Placement], view: &View, opts: & } stats.tracks = tracks.len(); // 2. Screen samples. - for (ti, t) in tracks.iter_mut().enumerate() { - if ti % 64 == 0 && cancel() { - return Err(HlrError::Cancelled); - } - let Some(p) = scene.get(t.placement) else { continue }; - let c = t.c3(); - let mut ts: Vec = c.breaks().into_iter().filter(|x| *x > t.range[0] && *x < t.range[1]).collect(); - ts.push(t.range[0]); - ts.push(t.range[1]); - ts.sort_by(f64::total_cmp); - let per = match c { - C3::Line { .. } => 1, - C3::Polyline(_) => 1, - _ => 32, - }; - let mut params = Vec::new(); - for w in ts.windows(2) { - let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; - for i in 0..per { - params.push(a + (b - a) * i as f64 / per as f64); - } - } - params.push(t.range[1]); - let screen = |x: f64| view.screen(p.apply(c.at(x))); - let mut samples: Vec<(f64, [f64; 2])> = params.into_iter().map(|x| (x, screen(x))).collect(); - // Halve chords of exact curves until their sag on screen is within the target, so - // chords stand for the curve closely enough for coincidence and crossing tests. - if !matches!(c, C3::Line { .. } | C3::Polyline(_)) { - let target = 1e-7 * scale.max(1.); - for _ in 0..12 { - let mut next = Vec::with_capacity(samples.len() * 2); - let mut split = false; - for w in samples.windows(2) { - let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; - next.push(*a); - let m = 0.5 * (a.0 + b.0); - let q = screen(m); - // A spline's piece is held by its control points' hull: a parameterisation - // racing between samples cannot hide a swerve from the chord. - let hull = - c.hull([a.0, b.0]).map_or(0., |h| h.iter().map(|x| point_segment(view.screen(p.apply(*x)), a.1, b.1)).fold(0., f64::max)); - if point_segment(q, a.1, b.1).max(hull) > target && next.len() < 1 << 16 { - next.push((m, q)); - split = true; - } - } - if let Some(last) = samples.last() { - next.push(*last); - } - samples = next; - if !split { - break; - } - } - } - // Chord sag on screen: exact for lines, measured at the middle for curves; a sampled - // silhouette's chords stand for the true curve to within its measured deviation. - // A fitted source record stands for the true curve to within its bound. - let fitted = match t.acc { - CurveAccuracy::Bounded { max_deviation_mm: tol } => tol, - _ => 0., - }; - let sags = samples - .windows(2) - .map(|w| { - fitted - + match (c, w.first(), w.get(1)) { - (C3::Line { .. }, _, _) => 0., - (C3::Polyline(_), _, _) => match t.acc { - CurveAccuracy::Sampled { max_sample_deviation_mm: dev } => dev, - _ => 0., - }, - (_, Some(a), Some(b)) => { - let hull = c - .hull([a.0, b.0]) - .map_or(0., |h| h.iter().map(|x| point_segment(view.screen(p.apply(*x)), a.1, b.1)).fold(0., f64::max)); - point_segment(view.screen(p.apply(c.at(0.5 * (a.0 + b.0)))), a.1, b.1).max(hull) - } - _ => 0., - } - }) - .collect(); + let sampled = ctx.map(&tracks, 16, |t| screen_samples(t, &ctx))?; + for (t, (samples, sags)) in tracks.iter_mut().zip(sampled) { t.samples = samples; t.sags = sags; } @@ -823,106 +887,18 @@ pub fn hidden_lines(defs: &[Prepared], scene: &[Placement], view: &View, opts: & // the curves are cut where they cross each such face's screen box instead, and below a // piece inside a box and behind its nearest point is uncertain: it may be hidden. let shadows = ShadowIndex::new(shadow_boxes(defs, scene, view, &unsupported))?; - let mut near = Vec::new(); - let mut checks = 0usize; - for (ti, t) in tracks.iter_mut().enumerate() { - if shadows.list.is_empty() { - break; + if !shadows.list.is_empty() { + let checks = AtomicUsize::new(0); + let cuts = ctx.map(&tracks, 16, |t| shadow_cuts(t, &ctx, &shadows, &checks))?; + for (t, c) in tracks.iter_mut().zip(cuts) { + t.splits.extend(c); } - if ti % 64 == 0 && cancel() { - return Err(HlrError::Cancelled); - } - let Some(pl) = scene.get(t.placement) else { continue }; - let mut cuts = Vec::new(); - for w in t.samples.windows(2) { - let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; - // The curve's own screen box between the two samples: it can reach past the chord, - // across it or along it (a view along an arc's plane folds it back on itself). - let (lo, hi) = screen_box(t.c3(), [a.0, b.0], pl, view); - shadows.query(lo, hi, &mut near); - checks = checks.saturating_add(near.len()); - if checks > 500_000_000 { - return Err(HlrError::Budget("shadow")); - } - for sh in near.iter().filter_map(|k| shadows.list.get(*k)) { - for (k, edge) in [(0usize, sh.lo[0]), (0, sh.hi[0]), (1, sh.lo[1]), (1, sh.hi[1])] { - let (u, v) = (a.1[k], b.1[k]); - if (u - edge) * (v - edge) < 0. { - // Where the curve itself crosses the side, not its chord. - let side = |x: f64| view.screen(pl.apply(t.c3().at(x))).get(k).copied().unwrap_or(0.) - edge; - let (mut lo, mut hi) = (a.0, b.0); - for _ in 0..60 { - let mid = 0.5 * (lo + hi); - if (side(mid) < 0.) == (u - edge < 0.) { - lo = mid; - } else { - hi = mid; - } - } - cuts.push(0.5 * (lo + hi)); - } - } - // A curve that enters the box where its chord does not: its stretch becomes a - // piece of its own, judged below. - if overlaps(lo, hi, sh) && box_gap(a.1, b.1, sh.lo, sh.hi) > 0. { - cuts.push(a.0); - cuts.push(b.0); - } - } - } - t.splits.extend(cuts); } stats.phase_ms[1] = clock.lap(); // 4. Classify pieces between splits. + let classified = ctx.map(&tracks, 16, |t| classify_track(t, &ctx, &shadows))?; let mut lines = Vec::with_capacity(tracks.len()); - for (ti, t) in tracks.iter().enumerate() { - if ti % 64 == 0 && cancel() { - return Err(HlrError::Cancelled); - } - let c = t.c3(); - let mut cuts = t.splits.clone(); - cuts.push(t.range[0]); - cuts.push(t.range[1]); - cuts.retain(|x| *x >= t.range[0] && *x <= t.range[1]); - cuts.sort_by(f64::total_cmp); - let span = t.range[1] - t.range[0]; - // Cuts merge when their parameters and their points are both close: nearby parameters - // alone do not tell (a knot span narrower than 1e-10 of the range can carry most of a - // circle), nor do equal points (a closed curve's two ends). - cuts.dedup_by(|a, b| *a == *b || ((*a - *b).abs() <= 1e-10 * span && c.at(*a).dist(c.at(*b)) <= 1e-9 * scale.max(1.))); - let mut pieces: Vec = Vec::new(); - for w in cuts.windows(2) { - let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue }; - for (r, vis) in classify_range(t, a, b, &ctx)? { - let vis = if vis == Visibility::Visible && in_shadow(t, r, scene, view, &shadows) { Visibility::Uncertain } else { vis }; - match pieces.last_mut() { - Some(last) if last.visibility == vis && (last.t[1] - r[0]).abs() <= 1e-12 * span.max(1e-300) => last.t[1] = r[1], - _ => pieces.push(Piece { - t: r, - visibility: vis, - covered: false, - geometry: Vec::new(), - accuracy: t.acc, - points: Vec::new(), - sag: 0., - }), - } - } - } - let p = scene.get(t.placement).ok_or(HlrError::Invalid("placement"))?; - for piece in &mut pieces { - let mut pts = vec![view.screen(p.apply(c.at(piece.t[0])))]; - pts.extend(t.samples.iter().filter(|(x, _)| *x > piece.t[0] && *x < piece.t[1]).map(|(_, s)| *s)); - pts.push(view.screen(p.apply(c.at(piece.t[1])))); - piece.points = pts; - piece.sag = t - .samples - .windows(2) - .zip(&t.sags) - .filter(|(w, _)| w.get(1).is_some_and(|b| b.0 > piece.t[0]) && w.first().is_some_and(|a| a.0 < piece.t[1])) - .map(|(_, sag)| *sag) - .fold(0., f64::max); - } + for (t, pieces) in tracks.iter().zip(classified) { stats.pieces += pieces.len(); stats.uncertain_pieces += pieces.iter().filter(|p| p.visibility == Visibility::Uncertain).count(); lines.push(Line { placement: t.placement, source: t.source, pieces }); @@ -930,15 +906,15 @@ pub fn hidden_lines(defs: &[Prepared], scene: &[Placement], view: &View, opts: & stats.phase_ms[2] = clock.lap(); // Coverage is checked every 1e-4 of the scene (5 um to 0.5 mm): a gap narrower than that // in a coincident line is not seen. - cover(&mut lines, 1e-7 + 1e-9 * scale, (1e-4 * scale).clamp(0.005, 0.5), cancel)?; + cover(&mut lines, 1e-7 + 1e-9 * scale, (1e-4 * scale).clamp(0.005, 0.5), &ctx)?; stats.phase_ms[3] = clock.lap(); for p in lines.iter().flat_map(|l| l.pieces.iter()) { stats.covered_pieces += usize::from(p.covered); stats.uncertain_uncovered += usize::from(p.visibility == Visibility::Uncertain && !p.covered); } - stats.rays = ctx.rays.get(); - stats.surface_ray_tests = ctx.surface_tests.get(); - stats.unexpected_transitions = ctx.transitions.get(); + stats.rays = ctx.rays.load(Ordering::Relaxed); + stats.surface_ray_tests = ctx.surface_tests.load(Ordering::Relaxed); + stats.unexpected_transitions = ctx.transitions.load(Ordering::Relaxed); // Output geometry, back in the caller's frame. let shifted = |q: [f64; 2]| [q[0] + shift[0], q[1] + shift[1]]; for (l, t) in lines.iter_mut().zip(&tracks) { @@ -953,6 +929,245 @@ pub fn hidden_lines(defs: &[Prepared], scene: &[Placement], view: &View, opts: & Ok(Drawing { lines, unsupported, stats }) } +/// A body's silhouettes seen along `dl` (the direction of sight in its own frame), per set of +/// supported curved faces on one surface: the set's first face, its largest face and the +/// traced curves. Cancellation is polled every 256 faces while the sets are formed and before +/// every face a traced curve is trimmed to; tracing one surface (a few tenths of a second for +/// a torus) is the longest step between polls. +fn body_silhouettes(def: &Prepared, dl: Vec3, opts: &Options, ctx: &Ctx) -> Result)>, HlrError> { + let supported: Vec = def.faces.iter().map(|f| !(f.freeform && !opts.freeform) && f.trim.unsupported.is_none()).collect(); + // Faces on one surface (a sphere in two halves, say) share their silhouettes, keyed by + // the set's first face: one curve along their seams, not one per face. + let mut sets: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + for (fi, f) in def.faces.iter().enumerate() { + if fi % 256 == 255 && ctx.cancelled() { + return Err(HlrError::Cancelled); + } + if supported.get(fi) != Some(&true) || matches!(f.analytic, Analytic::Plane) { + continue; + } + // The set's silhouette is traced on its first face's surface, so a face joins only + // when its recognised surface is that one to rounding (1e-13); recognition groups + // surfaces more loosely (1e-9), and two surfaces that close can still differ inside + // the faces (two spherical caps through one circle). + // The group lists faces in order, so its first supported face is the smallest. + let first = f.group.iter().copied().find(|j| supported.get(*j) == Some(&true)).unwrap_or(fi); + let fits = def.faces.get(first).is_some_and(|l| same_analytic(&l.analytic, &f.analytic, 1e-13)); + sets.entry(if fits { first } else { fi }).or_default().push(fi); + } + let mut out = Vec::with_capacity(sets.len()); + for (fi, members) in sets { + if ctx.cancelled() { + return Err(HlrError::Cancelled); + } + let Some(f) = def.faces.get(fi) else { continue }; + let parts: Vec<(&S3, &Trim)> = members.iter().filter_map(|j| def.faces.get(*j)).map(|m| (&m.surface, &m.trim)).collect(); + let size = members.iter().filter_map(|j| def.faces.get(*j)).map(|m| (m.hi - m.lo).len()).fold(0., f64::max); + let mut sils = Vec::new(); + // Once cancellation is seen the tracing stops and its partial curves are dropped, even + // if the callback would answer false later. + let stopped = std::cell::Cell::new(false); + let stop = || { + if !stopped.get() && ctx.cancelled() { + stopped.set(true); + } + stopped.get() + }; + silhouette::silhouettes(&parts, f.analytic, dl, &mut sils, &stop); + if stopped.get() { + return Err(HlrError::Cancelled); + } + out.push((fi, size, sils)); + } + Ok(out) +} + +/// A track's screen samples and, per chord between them, the distance of the exact projected +/// curve from it (none when the track's placement is missing). Cancellation is polled at every +/// refinement pass. +fn screen_samples(t: &Track, ctx: &Ctx) -> Result<(Vec<(f64, [f64; 2])>, Vec), HlrError> { + let (view, scale) = (&ctx.view, ctx.scale); + let Some(p) = ctx.scene.get(t.placement) else { return Ok((Vec::new(), Vec::new())) }; + let c = t.c3(); + let mut ts: Vec = c.breaks().into_iter().filter(|x| *x > t.range[0] && *x < t.range[1]).collect(); + ts.push(t.range[0]); + ts.push(t.range[1]); + ts.sort_by(f64::total_cmp); + let per = match c { + C3::Line { .. } => 1, + C3::Polyline(_) => 1, + _ => 32, + }; + let mut params = Vec::new(); + for w in ts.windows(2) { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + for i in 0..per { + params.push(a + (b - a) * i as f64 / per as f64); + } + } + params.push(t.range[1]); + let screen = |x: f64| view.screen(p.apply(c.at(x))); + let mut samples: Vec<(f64, [f64; 2])> = params.into_iter().map(|x| (x, screen(x))).collect(); + // Halve chords of exact curves until their sag on screen is within the target, so + // chords stand for the curve closely enough for coincidence and crossing tests. + if !matches!(c, C3::Line { .. } | C3::Polyline(_)) { + let target = 1e-7 * scale.max(1.); + for _ in 0..12 { + if ctx.cancelled() { + return Err(HlrError::Cancelled); + } + let mut next = Vec::with_capacity(samples.len() * 2); + let mut split = false; + for w in samples.windows(2) { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + next.push(*a); + let m = 0.5 * (a.0 + b.0); + let q = screen(m); + // A spline's piece is held by its control points' hull: a parameterisation + // racing between samples cannot hide a swerve from the chord. + let hull = c.hull([a.0, b.0]).map_or(0., |h| h.iter().map(|x| point_segment(view.screen(p.apply(*x)), a.1, b.1)).fold(0., f64::max)); + if point_segment(q, a.1, b.1).max(hull) > target && next.len() < 1 << 16 { + next.push((m, q)); + split = true; + } + } + if let Some(last) = samples.last() { + next.push(*last); + } + samples = next; + if !split { + break; + } + } + } + // Chord sag on screen: exact for lines, measured at the middle for curves; a sampled + // silhouette's chords stand for the true curve to within its measured deviation. + // A fitted source record stands for the true curve to within its bound. + let fitted = match t.acc { + CurveAccuracy::Bounded { max_deviation_mm: tol } => tol, + _ => 0., + }; + if ctx.cancelled() { + return Err(HlrError::Cancelled); + } + let sags = samples + .windows(2) + .map(|w| { + fitted + + match (c, w.first(), w.get(1)) { + (C3::Line { .. }, _, _) => 0., + (C3::Polyline(_), _, _) => match t.acc { + CurveAccuracy::Sampled { max_sample_deviation_mm: dev } => dev, + _ => 0., + }, + (_, Some(a), Some(b)) => { + let hull = + c.hull([a.0, b.0]).map_or(0., |h| h.iter().map(|x| point_segment(view.screen(p.apply(*x)), a.1, b.1)).fold(0., f64::max)); + point_segment(view.screen(p.apply(c.at(0.5 * (a.0 + b.0)))), a.1, b.1).max(hull) + } + _ => 0., + } + }) + .collect(); + Ok((samples, sags)) +} + +/// Where a track's curve crosses the sides of left-out faces' screen boxes, and the ends of the +/// chords whose curve enters a box that the chord misses. `checks` counts the candidate boxes +/// over all tracks, whichever thread looked: the shadow budget is for the whole call. +/// Cancellation is polled every 4096 chords and every 1024 boxes met. +fn shadow_cuts(t: &Track, ctx: &Ctx, shadows: &ShadowIndex, checks: &AtomicUsize) -> Result, HlrError> { + let view = &ctx.view; + let Some(pl) = ctx.scene.get(t.placement) else { return Ok(Vec::new()) }; + let (mut near, mut cuts, mut met) = (Vec::new(), Vec::new(), 0usize); + for (i, w) in t.samples.windows(2).enumerate() { + if i % 4096 == 4095 && ctx.cancelled() { + return Err(HlrError::Cancelled); + } + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + // The curve's own screen box between the two samples: it can reach past the chord, + // across it or along it (a view along an arc's plane folds it back on itself). + let (lo, hi) = screen_box(t.c3(), [a.0, b.0], pl, view); + shadows.query(lo, hi, &mut near); + if checks.fetch_add(near.len(), Ordering::Relaxed).saturating_add(near.len()) > 500_000_000 { + return Err(HlrError::Budget("shadow")); + } + for sh in near.iter().filter_map(|k| shadows.list.get(*k)) { + // A chord can meet many boxes, each side found by bisection on the curve. + met += 1; + if met % 1024 == 0 && ctx.cancelled() { + return Err(HlrError::Cancelled); + } + for (k, edge) in [(0usize, sh.lo[0]), (0, sh.hi[0]), (1, sh.lo[1]), (1, sh.hi[1])] { + let (u, v) = (a.1[k], b.1[k]); + if (u - edge) * (v - edge) < 0. { + // Where the curve itself crosses the side, not its chord. + let side = |x: f64| view.screen(pl.apply(t.c3().at(x))).get(k).copied().unwrap_or(0.) - edge; + let (mut lo, mut hi) = (a.0, b.0); + for _ in 0..60 { + let mid = 0.5 * (lo + hi); + if (side(mid) < 0.) == (u - edge < 0.) { + lo = mid; + } else { + hi = mid; + } + } + cuts.push(0.5 * (lo + hi)); + } + } + // A curve that enters the box where its chord does not: its stretch becomes a + // piece of its own, judged below. + if overlaps(lo, hi, sh) && box_gap(a.1, b.1, sh.lo, sh.hi) > 0. { + cuts.push(a.0); + cuts.push(b.0); + } + } + } + Ok(cuts) +} + +/// A track's pieces between its splits, classified, with their exact points and sag. +fn classify_track(t: &Track, ctx: &Ctx, shadows: &ShadowIndex) -> Result, HlrError> { + let (scene, view, scale) = (ctx.scene, &ctx.view, ctx.scale); + let c = t.c3(); + let mut cuts = t.splits.clone(); + cuts.push(t.range[0]); + cuts.push(t.range[1]); + cuts.retain(|x| *x >= t.range[0] && *x <= t.range[1]); + cuts.sort_by(f64::total_cmp); + let span = t.range[1] - t.range[0]; + // Cuts merge when their parameters and their points are both close: nearby parameters + // alone do not tell (a knot span narrower than 1e-10 of the range can carry most of a + // circle), nor do equal points (a closed curve's two ends). + cuts.dedup_by(|a, b| *a == *b || ((*a - *b).abs() <= 1e-10 * span && c.at(*a).dist(c.at(*b)) <= 1e-9 * scale.max(1.))); + let mut pieces: Vec = Vec::new(); + for w in cuts.windows(2) { + let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue }; + for (r, vis) in classify_range(t, a, b, ctx)? { + let vis = if vis == Visibility::Visible && in_shadow(t, r, scene, view, shadows) { Visibility::Uncertain } else { vis }; + match pieces.last_mut() { + Some(last) if last.visibility == vis && (last.t[1] - r[0]).abs() <= 1e-12 * span.max(1e-300) => last.t[1] = r[1], + _ => pieces.push(Piece { t: r, visibility: vis, covered: false, geometry: Vec::new(), accuracy: t.acc, points: Vec::new(), sag: 0. }), + } + } + } + let p = scene.get(t.placement).ok_or(HlrError::Invalid("placement"))?; + for piece in &mut pieces { + let mut pts = vec![view.screen(p.apply(c.at(piece.t[0])))]; + pts.extend(t.samples.iter().filter(|(x, _)| *x > piece.t[0] && *x < piece.t[1]).map(|(_, s)| *s)); + pts.push(view.screen(p.apply(c.at(piece.t[1])))); + piece.points = pts; + piece.sag = t + .samples + .windows(2) + .zip(&t.sags) + .filter(|(w, _)| w.get(1).is_some_and(|b| b.0 > piece.t[0]) && w.first().is_some_and(|a| a.0 < piece.t[1])) + .map(|(_, sag)| *sag) + .fold(0., f64::max); + } + Ok(pieces) +} + /// The centre of the placed bodies' boxes, after checking the placements. /// A left-out face's screen box and the depth of its nearest corner. struct Shadow { @@ -1335,7 +1550,7 @@ fn crossings(tracks: &mut [Track], ctx: &Ctx) -> Result { let mut grid: Vec> = vec![Vec::new(); n * n]; let mut entries = 0usize; for (si, s) in segs.iter_mut().enumerate() { - if si % 65536 == 0 && (ctx.cancel)() { + if si % 65536 == 0 && ctx.cancelled() { return Err(HlrError::Cancelled); } let g = s.sag + 1e-9 * ctx.scale; @@ -1354,14 +1569,13 @@ fn crossings(tracks: &mut [Track], ctx: &Ctx) -> Result { } } // A pair of segments is visited once per cell they share, so the number of visits is - // known before the search starts, and no set of pairs already met has to be kept. + // known before the search starts. let pairs = grid.iter().fold(0usize, |n, c| n.saturating_add(c.len().saturating_mul(c.len().saturating_sub(1)) / 2)); if pairs > 500_000_000 { return Err(HlrError::Budget("crossing pair")); } let tol = 1e-9 * ctx.scale; - let mut found = Vec::new(); - let mut visits = 0usize; + let tracks_ro: &[Track] = tracks; // Two chords that lie on each other end to end, to rounding. let coincide = |s: &Seg, t: &Seg| { [point_segment(s.a, t.a, t.b), point_segment(s.b, t.a, t.b), point_segment(t.a, s.a, s.b), point_segment(t.b, s.a, s.b)] @@ -1395,69 +1609,82 @@ fn crossings(tracks: &mut [Track], ctx: &Ctx) -> Result { }; // Screen points of a segment's exact curve, `k` pieces (one for lines and polylines). let sub = |track: usize, i: usize, k: usize| -> Vec<[f64; 2]> { - let Some(tr) = tracks.get(track) else { return Vec::new() }; + let Some(tr) = tracks_ro.get(track) else { return Vec::new() }; let (Some(a), Some(b)) = (tr.samples.get(i), tr.samples.get(i + 1)) else { return Vec::new() }; let Some(p) = ctx.scene.get(tr.placement) else { return Vec::new() }; let k = if matches!(tr.c3(), C3::Line { .. } | C3::Polyline(_)) { 1 } else { k }; (0..=k).map(|j| ctx.view.screen(p.apply(tr.c3().at(a.0 + (b.0 - a.0) * j as f64 / k as f64)))).collect() }; - for (c, cellv) in grid.iter().enumerate() { - let (x, y) = (c % n, c / n); - for (k, &i) in cellv.iter().enumerate() { - for &j in cellv.iter().skip(k + 1) { - visits += 1; - if visits.is_multiple_of(65536) && (ctx.cancel)() { - return Err(HlrError::Cancelled); - } - let (Some(s), Some(t)) = (segs.get(i), segs.get(j)) else { continue }; - // A pair sharing several cells is intersected in the first of them only. - if s.cells[0].max(t.cells[0]) != x || s.cells[1].max(t.cells[1]) != y { - continue; - } - if s.track == t.track && s.i.abs_diff(t.i) <= 1 { - continue; - } - let straight = |k: usize| tracks.get(k).is_some_and(|tr| matches!(tr.c3(), C3::Line { .. })); - let overlaps = straight(s.track) && straight(t.track); - let hits = seg_hits(s.a, s.b, t.a, t.b, tol, overlaps); - let reach = s.sag + t.sag + tol; - // Chords of two curves that lie on each other end to end, to rounding (one curve - // projected again, aligned holes seen along their axes), cross only within that - // rounding, everywhere and nowhere in particular: visibility changes where such a - // run starts or ends, which chords that only partly overlap find. Intersecting - // their fine pieces is wasted work. Chords merely within each other's sag are not - // skipped: the curves between them can still cross. - let coincident = s.track != t.track && s.sag + t.sag > 0. && coincide(s, t); - if coincident { - continue; - } - if hits.is_empty() && s.sag + t.sag > 0. && seg_gap(s.a, s.b, t.a, t.b) <= reach { - // The chords miss but the curves may not: two crossings close together can - // lie within one chord's sag. Intersect finer pieces of the exact curves. - let (ps, pt) = (sub(s.track, s.i, 16), sub(t.track, t.i, 16)); - let (ns, nt) = (ps.len().saturating_sub(1).max(1) as f64, pt.len().saturating_sub(1).max(1) as f64); - for (x, w) in ps.windows(2).enumerate() { - for (y, z) in pt.windows(2).enumerate() { - let (Some(a), Some(b), Some(c), Some(d)) = (w.first(), w.get(1), z.first(), z.get(1)) else { continue }; - for (u, v) in seg_hits(*a, *b, *c, *d, tol, false) { - found.push((s.track, s.i, (x as f64 + u) / ns, t.track, t.i, (y as f64 + v) / nt)); + // Rows of cells in parallel, joined in grid order, so the crossings come out in the same + // order whichever thread found them. + let total = AtomicUsize::new(0); + let rows: Vec = (0..n).collect(); + let found = ctx.map(&rows, 4, |&y| { + let mut found = Vec::new(); + let mut visits = 0usize; + for x in 0..n { + let Some(cellv) = grid.get(y * n + x) else { continue }; + for (k, &i) in cellv.iter().enumerate() { + for &j in cellv.iter().skip(k + 1) { + visits += 1; + if visits.is_multiple_of(65536) && ctx.cancelled() { + return Err(HlrError::Cancelled); + } + let (Some(s), Some(t)) = (segs.get(i), segs.get(j)) else { continue }; + // A pair sharing several cells is intersected in the first of them only. + if s.cells[0].max(t.cells[0]) != x || s.cells[1].max(t.cells[1]) != y { + continue; + } + if s.track == t.track && s.i.abs_diff(t.i) <= 1 { + continue; + } + let before = found.len(); + let straight = |k: usize| tracks_ro.get(k).is_some_and(|tr| matches!(tr.c3(), C3::Line { .. })); + let overlaps = straight(s.track) && straight(t.track); + let hits = seg_hits(s.a, s.b, t.a, t.b, tol, overlaps); + let reach = s.sag + t.sag + tol; + // Chords of two curves that lie on each other end to end, to rounding (one + // curve projected again, aligned holes seen along their axes), cross only + // within that rounding, everywhere and nowhere in particular: visibility + // changes where such a run starts or ends, which chords that only partly + // overlap find. Intersecting their fine pieces is wasted work. Chords merely + // within each other's sag are not skipped: the curves between them can still + // cross. + let coincident = s.track != t.track && s.sag + t.sag > 0. && coincide(s, t); + if coincident { + continue; + } + if hits.is_empty() && s.sag + t.sag > 0. && seg_gap(s.a, s.b, t.a, t.b) <= reach { + // The chords miss but the curves may not: two crossings close together can + // lie within one chord's sag. Intersect finer pieces of the exact curves. + let (ps, pt) = (sub(s.track, s.i, 16), sub(t.track, t.i, 16)); + let (ns, nt) = (ps.len().saturating_sub(1).max(1) as f64, pt.len().saturating_sub(1).max(1) as f64); + for (x, w) in ps.windows(2).enumerate() { + for (y, z) in pt.windows(2).enumerate() { + let (Some(a), Some(b), Some(c), Some(d)) = (w.first(), w.get(1), z.first(), z.get(1)) else { continue }; + for (u, v) in seg_hits(*a, *b, *c, *d, tol, false) { + found.push((s.track, s.i, (x as f64 + u) / ns, t.track, t.i, (y as f64 + v) / nt)); + } + } + } + } else { + let curved = s.track != t.track && s.sag + t.sag > 0.; + for (u, v) in hits { + if !(curved && shared_point(s, t, (u, v))) { + found.push((s.track, s.i, u, t.track, t.i, v)); } } } - } else { - let curved = s.track != t.track && s.sag + t.sag > 0.; - for (u, v) in hits { - if !(curved && shared_point(s, t, (u, v))) { - found.push((s.track, s.i, u, t.track, t.i, v)); - } + let added = found.len() - before; + if added > 0 && total.fetch_add(added, Ordering::Relaxed).saturating_add(added) > 50_000_000 { + return Err(HlrError::Budget("crossing")); } } - if found.len() > 50_000_000 { - return Err(HlrError::Budget("crossing")); - } } } - } + Ok(found) + })?; + let found: Vec<(usize, usize, f64, usize, usize, f64)> = found.into_iter().flatten().collect(); // Curves that nearly coincide in projection (two fitted copies of one intersection, say) // cross each other again and again within their tolerance; visibility changes only where // the coincidence starts or ends, so such a pair keeps only its outermost crossings. @@ -1474,8 +1701,8 @@ fn crossings(tracks: &mut [Track], ctx: &Ctx) -> Result { // another keeps all its crossings. let together = list.windows(2).all(|w| { let (Some(x), Some(y)) = (w.first(), w.get(1)) else { return false }; - let (Some(ta), Some(tb)) = (tracks.get(x.0), tracks.get(x.3)) else { return false }; - let mid = 0.5 * (sample_param(tracks, x.0, x.1, x.2) + sample_param(tracks, y.0, y.1, y.2)); + let (Some(ta), Some(tb)) = (tracks_ro.get(x.0), tracks_ro.get(x.3)) else { return false }; + let mid = 0.5 * (sample_param(tracks_ro, x.0, x.1, x.2) + sample_param(tracks_ro, y.0, y.1, y.2)); let Some(pa) = ctx.scene.get(ta.placement) else { return false }; let q = ctx.view.screen(pa.apply(ta.c3().at(mid))); let sag = |t: &Track| t.sags.iter().fold(0., |m: f64, v| m.max(*v)); @@ -1497,10 +1724,10 @@ fn crossings(tracks: &mut [Track], ctx: &Ctx) -> Result { found.extend(list); } let count = found.len(); - for (ta, ia, u, tb, ib, v) in found { - let pa = sample_param(tracks, ta, ia, u); - let pb = sample_param(tracks, tb, ib, v); - let (pa, pb) = refine(tracks, ctx, ta, pa, tb, pb); + let refined = ctx.map(&found, 256, |&(ta, ia, u, tb, ib, v)| { + Ok(refine(tracks_ro, ctx, ta, sample_param(tracks_ro, ta, ia, u), tb, sample_param(tracks_ro, tb, ib, v))) + })?; + for (&(ta, _, _, tb, _, _), (pa, pb)) in found.iter().zip(refined) { if let Some(t) = tracks.get_mut(ta) { t.splits.push(pa); } @@ -1555,7 +1782,7 @@ fn refine(tracks: &[Track], ctx: &Ctx, ta: usize, sa: f64, tb: usize, sb: f64) - } /// Marks hidden and uncertain pieces lying within `tol` of a visible piece of another line. -fn cover(lines: &mut [Line], tol: f64, step: f64, cancel: &dyn Fn() -> bool) -> Result<(), HlrError> { +fn cover(lines: &mut [Line], tol: f64, step: f64, ctx: &Ctx) -> Result<(), HlrError> { // Chords of visible and hidden pieces, keyed by (line, piece) so a piece never covers // itself; another piece of the same line may (a circle seen edge-on folds over itself). // `tol` is the base; each chord adds its own sag, each query point the sag of its chord. @@ -1600,29 +1827,40 @@ fn cover(lines: &mut [Line], tol: f64, step: f64, cancel: &dyn Fn() -> bool) -> _ => false, } }; - let checks = std::cell::Cell::new(0usize); - let near = |key: (usize, usize), own: Visibility, q: [f64; 2], slack: f64| { - checks.set(checks.get() + 1); + let near = |key: (usize, usize), own: Visibility, q: [f64; 2], slack: f64, checks: &mut usize| { + *checks += 1; let Some(bucket) = grid.get(at(q[1], 1) * n + at(q[0], 0)) else { return false }; bucket .iter() .filter_map(|i| segs.get(*i)) .any(|(k, state, a, b, sag)| hides(key, own, *k, *state) && point_segment(q, *a, *b) <= tol + sag + slack) }; - for (li, l) in lines.iter_mut().enumerate() { - for (pi, p) in l.pieces.iter_mut().enumerate() { + // Lines in parallel: a piece's flag depends on the chords alone, never on other flags. + // `total` counts the checks over all lines, whichever thread made them. + let total = AtomicUsize::new(0); + let lines_ro: &[Line] = lines; + let indices: Vec = (0..lines_ro.len()).collect(); + let flags = ctx.map(&indices, 16, |&li| { + let Some(l) = lines_ro.get(li) else { return Ok(Vec::new()) }; + let mut flags = Vec::with_capacity(l.pieces.len()); + for (pi, p) in l.pieces.iter().enumerate() { + if pi % 16 == 0 && ctx.cancelled() { + return Err(HlrError::Cancelled); + } // Points along every chord at most `step` apart (a coincident line may cover a long // chord's ends and middle yet leave gaps between them). Points between the exact // ones lie on the chord, up to the piece's sag from its curve. let mut covered = !p.points.is_empty(); - if checks.get() > 200_000_000 { - return Err(HlrError::Budget("coverage")); - } - if pi % 16 == 0 && cancel() { - return Err(HlrError::Cancelled); - } + let (mut checks, mut polled) = (0usize, 0usize); for w in p.points.windows(2) { let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + // A long piece makes many checks: poll between its chords. + if checks - polled >= 65536 { + polled = checks; + if ctx.cancelled() { + return Err(HlrError::Cancelled); + } + } let k = ((a[0] - b[0]).hypot(a[1] - b[1]) / step).ceil().clamp(1., 1e6) as usize; covered = covered && (0..=k).all(|j| { @@ -1630,15 +1868,24 @@ fn cover(lines: &mut [Line], tol: f64, step: f64, cancel: &dyn Fn() -> bool) -> // The piece's own representation error (chord sag, fitted bound) applies // to every point it is checked at. let slack = p.sag; - near((li, pi), p.visibility, [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f], slack) + near((li, pi), p.visibility, [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f], slack, &mut checks) }); if !covered { break; } } if let [q] = p.points.as_slice() { - covered = near((li, pi), p.visibility, *q, p.sag); + covered = near((li, pi), p.visibility, *q, p.sag, &mut checks); } + if total.fetch_add(checks, Ordering::Relaxed).saturating_add(checks) > 200_000_000 { + return Err(HlrError::Budget("coverage")); + } + flags.push(covered); + } + Ok(flags) + })?; + for (l, flags) in lines.iter_mut().zip(flags) { + for (p, covered) in l.pieces.iter_mut().zip(flags) { p.covered = covered; } } @@ -1708,7 +1955,7 @@ fn classify_range(t: &Track, a: f64, b: f64, ctx: &Ctx) -> Result Result Result { - let n = ctx.rays.get() + 1; - ctx.rays.set(n); + // The budget is for the whole call: each ray takes the next number from the shared count. + let n = ctx.rays.fetch_add(1, Ordering::Relaxed).saturating_add(1); if n > ctx.max_rays { return Err(HlrError::Budget("ray")); } - if n.is_multiple_of(1024) && (ctx.cancel)() { + if n.is_multiple_of(1024) && ctx.cancelled() { return Err(HlrError::Cancelled); } let own = ctx.scene.get(t.placement).ok_or(HlrError::Invalid("placement"))?; @@ -1745,6 +1992,14 @@ fn classify(t: &Track, x: f64, ctx: &Ctx) -> Result { /// Visibility of a world point on placement `placement` lying on its faces `own`. fn classify_point(ctx: &Ctx, placement: usize, own: &[usize], own_eps: f64, world: Vec3) -> Visibility { + let mut tests = 0usize; + let state = classify_point_counted(ctx, placement, own, own_eps, world, &mut tests); + ctx.surface_tests.fetch_add(tests, Ordering::Relaxed); + state +} + +/// [`classify_point`], counting the surface ray tests in `tests`. +fn classify_point_counted(ctx: &Ctx, placement: usize, own: &[usize], own_eps: f64, world: Vec3, tests: &mut usize) -> Visibility { let screen = ctx.view.screen(world); let depth = world.dot(ctx.view.d); let toward = -ctx.view.d; @@ -1766,7 +2021,7 @@ fn classify_point(ctx: &Ctx, placement: usize, own: &[usize], own_eps: f64, worl if !(range[1] >= range[0]) { continue; } - ctx.surface_tests.set(ctx.surface_tests.get() + 1); + *tests += 1; hits.clear(); f.surface.ray_as(&f.analytic, p0, dl, range, &mut hits); for h in &hits { diff --git a/crates/geom/src/hlr/silhouette.rs b/crates/geom/src/hlr/silhouette.rs index 9b60866b7e..ac48a567bb 100644 --- a/crates/geom/src/hlr/silhouette.rs +++ b/crates/geom/src/hlr/silhouette.rs @@ -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) { +/// 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, 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()); diff --git a/crates/geom/src/hlr_tests.rs b/crates/geom/src/hlr_tests.rs index e04ab6631c..c4071a2ff3 100644 --- a/crates/geom/src/hlr_tests.rs +++ b/crates/geom/src/hlr_tests.rs @@ -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::>()); @@ -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, Vec) { + 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 = 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 = (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 = (0..n).map(|k| turned_about_z(k % 4, 0.07 * k as f64, at(k))).collect(); + let alike: Vec = (0..n).map(|k| turned_about_z(k % 4, 0.3, at(k))).collect(); + let unshared: Vec = (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 = (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 = 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 = (0..200).collect(); + assert_eq!(ctx().map(&items, 1, |i| Ok(i * 3)).unwrap(), items.iter().map(|i| i * 3).collect::>()); + 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>); + 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 = 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 = (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 = (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 = 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}"); + } + } +} diff --git a/docs/drawing-hlr.md b/docs/drawing-hlr.md index 349baa692f..eedf194226 100644 --- a/docs/drawing-hlr.md +++ b/docs/drawing-hlr.md @@ -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