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