From ebafe07487df5cdd108e091db939de765645e5eb Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:20:17 +0100 Subject: [PATCH] Drawing W06a: exact orthographic hidden lines for the analytic subset (#2455) (#2457) * Drawing W06a: exact orthographic hidden lines for the analytic subset (#2455) crates/geom/src/hlr (L0): prepare once per body, then hidden_lines for a scene of placed bodies. Edges and face silhouettes are projected from their exact curves, split where projections cross, and each piece is classified by rays toward the viewer against the trimmed faces: visible, hidden or explicitly uncertain, never guessed. Planes, cylinders, cones, spheres and tori are supported whether stored as analytic records, revolutions or spline patches; freeform faces are reported and make what they could hide uncertain. Screen geometry is Seg2 with the W01a accuracy vocabulary: exact lines and conic arcs where the projection is exact, bounded chords otherwise. docs/drawing-hlr.md holds the contract and its limits. * geom/hlr: handle the brep records main gained in #3093 Main's exact model storage (#3093) added Curve::SurfaceIntersection, Surface::PlaneThrough and Surface::PlacedRevolution, and the HLR matches on the records no longer compiled. A plane through three points is a plane: it is trimmed and intersected as one, and a box stored that way draws the same bits as one with axis-stored planes. The procedural intersection curve and the placed revolution (whose affine placement may scale it unevenly) are refused by prepare as not supported yet, until the kernel adapter handles them. The records' positions move with the body's centre like the others. * geom/hlr: pad plane faces' boxes, so a plane record off by rounding still hides Plane faces took the box of their boundary chords unpadded, flat along the normal. When the plane record and its edges disagree by rounding (a unit cube's top plane one ulp above its edges, a 100 mm cube's 1e-11), the ray toward the viewer reached the plane just outside that box, the slab test culled the hit, and the back edges came out Visible with no doubt. Every face's box is now padded by 1e-6 of its size, as curved faces' boxes already were. Reported by DevonHope on #2457. * geom/hlr: limits from review: trim samples, scene extent, crossing pairs, surface groups From DevonHope's review of #2457: - Options::trim_samples must be 1 to MAX_TRIM_SAMPLES (4096). - Placed bodies must stay within MAX_COORDINATE (1e12 mm, as section and brep bound stored numbers). Placements 1e200 apart made the scene's size infinite and every tolerance taken from it with it, so everything came out Visible. - The crossing search counts its candidate pairs before it starts and meets each pair only in the first grid cell the two segments share, instead of keeping a set of the pairs met, which could outgrow wasm's 4 GB before the 500 million pair budget tripped. The same pairs in the same order. - prepare groups faces on one quadric or torus by comparing each face with the faces of its kind whose size agrees to the tolerance (sorted), not with every face; at most 50 million comparisons. prepare_cancellable polls a cancel callback between edges, faces and comparisons. * geom/hlr: boundary chords on a grid, and no fine pass over coincident curved chords The cost items of DevonHope's review of #2457: - Every ray hit asked how far it lies from its face's boundary by scanning all of the face's chords (4096 per circle; a plate's face with 256 holes has about a million). The chords are now bucketed on a uniform grid over the boundary's box, cells at least as long as the longest chord, and the test looks only at the cells around the point. It finds exactly the chords the scan finds. - Chords of two curved curves that lie on each other end to end (aligned holes seen along their axes) were cut into 16 by 16 pieces and intersected, to find crossings that are only noise within the curves' tolerance. They are now skipped; visibility changes where a coincident run starts or ends, and chords that only partly overlap still find those. - Fixtures: a plate with holes, coaxial cylinders seen along their axis, and 300 placements. * geom/hlr: pad boxes by the body's coordinates too; coincident chords only to rounding Review of the previous commits found two holes: - The padding grew with the face's size, but rounding grows with where the body was modelled: a 0.001 mm cube 1e7 mm out, its top plane one step (1.9e-9 mm) above its edges, still had its hit culled. The pad now adds 1e-12 of the body's offset. - Two chords were taken for coincident when their ends lay within each other's sag, which in a coarse scene can hide real crossings: an arc can bulge across a line 1 mm off its chord twice. Only chords that coincide to rounding are skipped now; identical projections, the costly case, still are. * geom/hlr: coincident curves meeting at shared sample points do not cross Timing the review's scenes showed that skipping coincident chords was not enough: chords of two coincident curves also touch end to end at every sample point they share, and each touch was a crossing to refine and split at. 64 coaxial cylinders seen along their axis gave 8.3 million crossings (19 s), 64 cylinders one behind another along the line of sight 4.2 million (214 s; before these commits the crossing budget refused it). A touch at a shared sample point is now dropped when the two curves also coincide, to rounding, on both sides of it. Where a coincident run starts or ends one side differs, and the touch is kept. * geom/hlr: the own-face margin allows for rounding where a body was modelled The far 0.001 mm cube of the previous commit showed a second effect of records rounded at large coordinates: its top plane, one ulp (1.9e-9 mm) above its edges, hid the top edges themselves, because a ray leaving an edge met its own face 3.2e-9 mm along, past the own-face margin of 1e-6 of the face (1.4e-9). The margin now also covers 1e-14 of the body's offset (about 45 ulps of its coordinates), which only matters for parts far smaller than their distance from the origin. * geom/hlr: no parity test for a hit on the boundary, where it is uncertain anyway * docs/drawing-hlr: the fixtures of the review's second round --------- Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com> --- crates/geom/src/hlr/curve.rs | 371 +++++ crates/geom/src/hlr/mod.rs | 1915 +++++++++++++++++++++++++ crates/geom/src/hlr/silhouette.rs | 347 +++++ crates/geom/src/hlr/surface.rs | 1298 +++++++++++++++++ crates/geom/src/hlr/trim.rs | 382 +++++ crates/geom/src/hlr_tests.rs | 2213 +++++++++++++++++++++++++++++ crates/geom/src/lib.rs | 1 + docs/drawing-hlr.md | 198 +++ 8 files changed, 6725 insertions(+) create mode 100644 crates/geom/src/hlr/curve.rs create mode 100644 crates/geom/src/hlr/mod.rs create mode 100644 crates/geom/src/hlr/silhouette.rs create mode 100644 crates/geom/src/hlr/surface.rs create mode 100644 crates/geom/src/hlr/trim.rs create mode 100644 crates/geom/src/hlr_tests.rs create mode 100644 docs/drawing-hlr.md diff --git a/crates/geom/src/hlr/curve.rs b/crates/geom/src/hlr/curve.rs new file mode 100644 index 0000000000..2da8e2e8fd --- /dev/null +++ b/crates/geom/src/hlr/curve.rs @@ -0,0 +1,371 @@ +//! Evaluation of portable B-rep curves: lines, circle and ellipse arcs, polynomial and rational +//! splines (fitted intersection records are ordinary splines with a stated fitting tolerance). + +use super::HlrError; +use crate::Vec3; +use crate::brep::{Curve, Frame}; +use crate::section::CurveAccuracy; + +#[derive(Clone, Debug)] +pub(crate) enum C3 { + Line { + a: Vec3, + b: Vec3, + }, + /// origin + x*rx*cos(t) + y*ry*sin(t), t in `range`. + Arc { + o: Vec3, + x: Vec3, + y: Vec3, + r: [f64; 2], + range: [f64; 2], + }, + /// Homogeneous control points `[x*w, y*w, z*w, w]` (w = 1 for polynomial splines), and the + /// same curve as Bézier pieces (each piece's parameter span and control points; empty when + /// it could not be split). + Spline { + degree: usize, + knots: Vec, + points: Vec<[f64; 4]>, + pieces: Vec<([f64; 2], Vec<[f64; 4]>)>, + }, + Polyline(Vec), +} + +pub(crate) fn v3(p: [f64; 3]) -> Vec3 { + Vec3::new(p[0], p[1], p[2]) +} + +pub(crate) fn frame_axes(f: &Frame) -> (Vec3, Vec3, Vec3) { + let (x, y) = (v3(f.x_axis), v3(f.y_axis)); + (v3(f.origin), x, y) +} + +impl C3 { + pub fn new(c: &Curve) -> Result<(C3, CurveAccuracy), HlrError> { + Ok(match c { + Curve::Line { start, end } => (C3::Line { a: v3(*start), b: v3(*end) }, CurveAccuracy::Analytic), + Curve::CircleArc { frame, radius, angle } => { + let (o, x, y) = frame_axes(frame); + (C3::Arc { o, x, y, r: [*radius, *radius], range: *angle }, CurveAccuracy::Analytic) + } + Curve::EllipseArc { frame, radii, angle } => { + let (o, x, y) = frame_axes(frame); + (C3::Arc { o, x, y, r: *radii, range: *angle }, CurveAccuracy::Analytic) + } + Curve::Polyline { points } => (C3::Polyline(points.iter().copied().map(v3).collect()), CurveAccuracy::Analytic), + Curve::Spline { knots, points } => (spline(knots, points.iter().map(|p| [p[0], p[1], p[2], 1.]).collect())?, CurveAccuracy::Analytic), + Curve::RationalSpline { knots, homogeneous } => (spline(knots, homogeneous.clone())?, CurveAccuracy::Analytic), + Curve::Intersection { knots, points, fitting_tolerance, .. } => ( + spline(knots, points.iter().map(|p| [p[0], p[1], p[2], 1.]).collect())?, + CurveAccuracy::Bounded { max_deviation_mm: *fitting_tolerance }, + ), + // Procedural: the curve is traced on demand from its leader, which carries no bound. + Curve::SurfaceIntersection { .. } => return Err(HlrError::Unsupported("surface intersection curve")), + }) + } + + pub fn domain(&self) -> [f64; 2] { + match self { + C3::Line { .. } => [0., 1.], + C3::Arc { range, .. } => *range, + C3::Spline { degree, knots, points, .. } => [knots.get(*degree).copied().unwrap_or(0.), knots.get(points.len()).copied().unwrap_or(1.)], + C3::Polyline(p) => [0., p.len().saturating_sub(1) as f64], + } + } + + pub fn at(&self, t: f64) -> Vec3 { + match self { + C3::Line { a, b } => *a + (*b - *a) * t, + C3::Arc { o, x, y, r, .. } => *o + *x * (r[0] * t.cos()) + *y * (r[1] * t.sin()), + C3::Spline { degree, knots, points, .. } => de_boor(*degree, knots, points, t), + C3::Polyline(p) => { + let n = p.len(); + if n == 0 { + return Vec3::ZERO; + } + let t = t.clamp(0., (n - 1) as f64); + let i = (t.floor() as usize).min(n.saturating_sub(2)); + match (p.get(i), p.get(i + 1)) { + (Some(a), Some(b)) => a.lerp(*b, t - i as f64), + (Some(a), None) => *a, + _ => Vec3::ZERO, + } + } + } + } + + /// First derivative by a central difference scaled to the domain. + pub fn d1(&self, t: f64) -> Vec3 { + match self { + C3::Line { a, b } => *b - *a, + C3::Arc { x, y, r, .. } => *x * (-r[0] * t.sin()) + *y * (r[1] * t.cos()), + _ => { + let [t0, t1] = self.domain(); + let h = (t1 - t0) * 1e-6; + let (a, b) = ((t - h).max(t0), (t + h).min(t1)); + if b - a <= 0. { Vec3::ZERO } else { (self.at(b) - self.at(a)) / (b - a) } + } + } + } + + /// Parameters that split the curve into pieces each turning at most `max_turn` radians or + /// spanning at most a quarter of the domain, plus all interior knots. + pub fn breaks(&self) -> Vec { + let [t0, t1] = self.domain(); + let mut out = vec![t0, t1]; + match self { + C3::Spline { knots, .. } => out.extend(knots.iter().copied().filter(|k| *k > t0 && *k < t1)), + C3::Polyline(p) => out.extend((1..p.len().saturating_sub(1)).map(|i| i as f64)), + C3::Arc { .. } => { + let n = ((t1 - t0) / (std::f64::consts::PI / 8.)).ceil().max(1.) as usize; + out.extend((1..n).map(|i| t0 + (t1 - t0) * i as f64 / n as f64)); + } + C3::Line { .. } => {} + } + out.sort_by(f64::total_cmp); + // Only equal knots merge: a span narrower than rounding can still carry a whole arc. + out.dedup(); + out + } +} + +fn spline(knots: &[f64], points: Vec<[f64; 4]>) -> Result { + let degree = knots.len().checked_sub(points.len() + 1).ok_or(HlrError::Unsupported("spline knot count"))?; + if degree == 0 || degree > 16 || points.is_empty() { + return Err(HlrError::Unsupported("spline degree")); + } + let pieces = bezier_pieces(degree, knots, &points); + Ok(C3::Spline { degree, knots: knots.to_vec(), points, pieces }) +} + +/// A spline split into Bézier pieces: clamped, then every inner knot raised to the degree +/// (NURBS Book §5.2); empty when that fails. +fn bezier_pieces(p: usize, knots: &[f64], points: &[[f64; 4]]) -> Vec<([f64; 2], Vec<[f64; 4]>)> { + use crate::bspline::{HPoint, clamp_row, insert_knot}; + let row: Vec = points.iter().map(|h| HPoint { x: h[0], y: h[1], z: h[2], w: h[3] }).collect(); + let Ok((mut k, mut c)) = clamp_row(p, knots, &row) else { return Vec::new() }; + let (Some(&lo), Some(&hi)) = (k.get(p), k.get(c.len())) else { return Vec::new() }; + let mut inner: Vec = k.iter().copied().filter(|x| *x > lo && *x < hi).collect(); + inner.dedup(); + for u in &inner { + let mut guard = 0; + while k.iter().filter(|x| *x == u).count() < p { + guard += 1; + let Ok((nk, nc)) = insert_knot(p, &k, &c, *u) else { return Vec::new() }; + if guard > p + 1 { + return Vec::new(); + } + (k, c) = (nk, nc); + } + } + let breaks: Vec = std::iter::once(lo).chain(inner).chain(std::iter::once(hi)).collect(); + let mut out = Vec::with_capacity(breaks.len()); + for (j, w) in breaks.windows(2).enumerate() { + let (Some(&s0), Some(&s1)) = (w.first(), w.get(1)) else { return Vec::new() }; + let Some(ctrl) = c.get(j * p..=j * p + p) else { return Vec::new() }; + out.push(([s0, s1], ctrl.iter().map(|h| [h.x, h.y, h.z, h.w]).collect())); + } + out +} + +/// The Bernstein coefficients of the product of two polynomials given by theirs. +fn bernstein_product(a: &[f64], b: &[f64]) -> Vec { + use crate::bspline::binomial; + let (m, n) = (a.len().saturating_sub(1), b.len().saturating_sub(1)); + (0..=m + n) + .map(|k| { + let terms = (k.saturating_sub(n)..=k.min(m)) + .map(|i| binomial(m, i) * binomial(n, k - i) * a.get(i).copied().unwrap_or(0.) * b.get(k - i).copied().unwrap_or(0.)); + terms.sum::() / binomial(m + n, k) + }) + .collect() +} + +/// The sign a polynomial in Bernstein form keeps over its interval: +1, -1, 0 when it is zero +/// (to rounding) all along, `None` when that cannot be certified. All coefficients of one sign +/// mean the polynomial has it (their hull holds its graph); otherwise the interval is halved. +fn definite_sign(c: &[f64], depth: u32) -> Option { + let scale = c.iter().fold(0f64, |m, x| m.max(x.abs())); + let eps = 1e-12 * scale; + if c.iter().all(|x| x.abs() <= eps) { + return Some(0.); + } + if c.iter().all(|x| *x >= -eps) { + return Some(1.); + } + if c.iter().all(|x| *x <= eps) { + return Some(-1.); + } + if depth >= 16 { + return None; + } + // de Casteljau at the middle. + let (mut left, mut right, mut pts) = (Vec::with_capacity(c.len()), Vec::with_capacity(c.len()), c.to_vec()); + while let (Some(&f), Some(&l)) = (pts.first(), pts.last()) { + left.push(f); + right.push(l); + pts = pts.windows(2).map(|w| 0.5 * (w.first().copied().unwrap_or(0.) + w.get(1).copied().unwrap_or(0.))).collect(); + } + right.reverse(); + match (definite_sign(&left, depth + 1)?, definite_sign(&right, depth + 1)?) { + (a, b) if a == b || b == 0. => Some(a), + (0., b) => Some(b), + _ => None, + } +} + +impl C3 { + /// The way a spline's part `r` turns about the centre `o` in the plane of `u` and `v`: + /// +1 or -1 when its angular speed keeps that sign all along (certified on each Bézier piece + /// from the Bernstein coefficients of `X Y' - Y X'`, which has the speed's sign as the + /// weights are positive), 0 when it does not turn, `None` when it turns back or this cannot + /// be certified. + pub fn turn_sign(&self, r: [f64; 2], o: Vec3, u: Vec3, v: Vec3) -> Option { + use crate::bspline::{HPoint, bezier_segment}; + let C3::Spline { pieces, .. } = self else { return None }; + let mut sign = 0f64; + for (span, ctrl) in pieces { + let (a, b) = (r[0].max(span[0]), r[1].min(span[1])); + let len = span[1] - span[0]; + if !(b > a && len > 0.) { + continue; + } + let (fa, fb) = (((a - span[0]) / len).clamp(0., 1.), ((b - span[0]) / len).clamp(0., 1.)); + if !(fb > fa) { + continue; + } + let h: Vec = ctrl.iter().map(|q| HPoint { x: q[0], y: q[1], z: q[2], w: q[3] }).collect(); + let seg = bezier_segment(&h, fa, fb).ok()?; + if seg.iter().any(|q| !(q.w > 0.)) { + return None; + } + let rel = |q: &HPoint| Vec3::new(q.x, q.y, q.z) - o * q.w; + let x: Vec = seg.iter().map(|q| rel(q).dot(u)).collect(); + let y: Vec = seg.iter().map(|q| rel(q).dot(v)).collect(); + let p = x.len().saturating_sub(1); + if p == 0 { + return None; + } + let d = |c: &[f64]| { + c.windows(2).map(|w| p as f64 * (w.get(1).copied().unwrap_or(0.) - w.first().copied().unwrap_or(0.))).collect::>() + }; + let n: Vec = bernstein_product(&x, &d(&y)).iter().zip(bernstein_product(&y, &d(&x))).map(|(a, b)| a - b).collect(); + let s = definite_sign(&n, 0)?; + if s != 0. { + if sign != 0. && s != sign { + return None; + } + sign = s; + } + } + Some(sign) + } + + /// Points whose hull holds the spline's part `r`: the projected control points of its + /// Bézier pieces cut to `r` (de Casteljau). They close in on the curve as `r` shrinks. + /// `None` for other curves, or when a weight is not positive. + pub fn hull(&self, r: [f64; 2]) -> Option> { + use crate::bspline::{HPoint, bezier_segment}; + let C3::Spline { pieces, .. } = self else { return None }; + let mut out = Vec::new(); + for (span, ctrl) in pieces { + let (a, b) = (r[0].max(span[0]), r[1].min(span[1])); + let len = span[1] - span[0]; + if !(b > a && len > 0.) { + continue; + } + let (fa, fb) = (((a - span[0]) / len).clamp(0., 1.), ((b - span[0]) / len).clamp(0., 1.)); + if !(fb > fa) { + continue; + } + let h: Vec = ctrl.iter().map(|q| HPoint { x: q[0], y: q[1], z: q[2], w: q[3] }).collect(); + for q in bezier_segment(&h, fa, fb).ok()? { + if !(q.w > 0.) { + return None; + } + out.push(q.project()?); + } + } + (!out.is_empty()).then_some(out) + } +} + +/// De Boor evaluation of a (rational) B-spline at `t`, clamped to the active domain. +pub(crate) fn de_boor(p: usize, knots: &[f64], ctrl: &[[f64; 4]], t: f64) -> Vec3 { + 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; + } + 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() { + let (a, b) = (knots.get(j + k - p).copied().unwrap_or(0.), knots.get(j + 1 + k - r).copied().unwrap_or(0.)); + let alpha = if b - a == 0. { 0. } else { (t - a) / (b - a) }; + let (prev, cur) = (d.get(j - 1).copied().unwrap_or([0.; 4]), d.get(j).copied().unwrap_or([0.; 4])); + if let Some(slot) = d.get_mut(j) { + for c in 0..4 { + slot[c] = (1. - alpha) * prev[c] + alpha * cur[c]; + } + } + } + } + let h = d.get(p).copied().unwrap_or([0.; 4]); + if h[3] == 0. { Vec3::ZERO } else { Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3]) } +} + +/// The circle a curve lies on, if it is one: centre, unit normal, radius. Checked at many +/// parameters against `tol` (relative to the radius), so rational circle splines are recognised. +pub(crate) fn as_circle(c: &C3, tol: f64) -> Option<(Vec3, Vec3, f64)> { + match c { + C3::Arc { o, x, y, r, .. } if (r[0] - r[1]).abs() <= tol * r[0] => Some((*o, x.cross(*y).normalized()?, r[0])), + C3::Spline { degree, points, .. } => { + let [t0, t1] = c.domain(); + let at = |f: f64| c.at(t0 + (t1 - t0) * f); + let (a, b, m) = (at(0.), at(1.), at(0.5)); + // Circumcentre of a, m and a third point (b may equal a for a full circle). + let third = if a.dist(b) < 1e-9 * (1. + a.len()) { at(0.25) } else { b }; + let (u, v) = (m - a, third - a); + let n = u.cross(v); + let n2 = n.len2(); + if n2 <= 1e-24 { + return None; + } + let centre = a + (v * u.len2() - u * v.len2()).cross(n) * (1. / (2. * n2)); + let normal = n.normalized()?; + let r = centre.dist(a); + // On one knot span of a (rational) spline of degree p, f = w^2 (|P - c|^2 - r^2) is + // a polynomial of degree 2p. At 2p + 3 Chebyshev points of the span it is at most + // w_max^2 2 r d (d the largest distance from the circle there), so everywhere at most + // 4 times that (their Lebesgue constant is below 4), and as w >= w_min the distance + // anywhere is at most 4 (w_max / w_min)^2 d: weights near zero cannot hide a swerve. + let weights = points.iter().map(|h| h[3]); + let (w_min, w_max) = weights.fold((f64::MAX, f64::MIN), |(lo, hi), w| (lo.min(w), hi.max(w))); + if !(w_min > 0. && w_max.is_finite()) { + return None; + } + let off = |p: Vec3| (p.dist(centre) - r).abs().max((p - centre).dot(normal).abs()); + let n = (2 * degree + 3).max(9); + let mut d = 0f64; + for w in c.breaks().windows(2) { + let (Some(&s0), Some(&s1)) = (w.first(), w.get(1)) else { continue }; + let (mid, half) = (0.5 * (s0 + s1), 0.5 * (s1 - s0)); + d = d.max(off(c.at(s0))).max(off(c.at(s1))); + for k in 0..n { + d = d.max(off(c.at(mid + half * (std::f64::consts::PI * (2 * k + 1) as f64 / (2 * n) as f64).cos()))); + } + } + // Relative to the curve's own size when that is smaller than the radius: a nearly + // straight curve fits a huge circle, whose radius says nothing about the error. + let pts: Vec = points.iter().filter(|h| h[3] != 0.).map(|h| Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3])).collect(); + let size = pts.iter().skip(1).fold(pts.first().map(|p| (*p, *p)), |acc, p| acc.map(|(lo, hi)| (lo.min(*p), hi.max(*p)))); + let size = size.map_or(r, |(lo, hi)| (hi - lo).len()); + (4. * (w_max / w_min).powi(2) * d <= tol * r.min(size)).then_some((centre, normal, r)) + } + _ => None, + } +} diff --git a/crates/geom/src/hlr/mod.rs b/crates/geom/src/hlr/mod.rs new file mode 100644 index 0000000000..8023c914fe --- /dev/null +++ b/crates/geom/src/hlr/mod.rs @@ -0,0 +1,1915 @@ +//! Exact orthographic hidden lines for placed portable B-rep bodies (Drawing W06a, #2455; +//! plan #860). Edges and face silhouettes are projected from their exact curves, split where +//! their projections cross, and each piece is classified by rays toward the viewer against the +//! trimmed faces. Every piece is visible, hidden or explicitly uncertain; nothing is guessed. +//! +//! Supported: planes, and cylinders, cones, spheres and tori whether stored as analytic records, +//! revolutions of lines and circles or spline patches (recognised to 1e-9 relative and then +//! trimmed and intersected in their own chart). Other spline and revolution faces are freeform: +//! unless [`Options::freeform`] is set they are listed in [`Drawing::unsupported`], their +//! silhouettes are not drawn and anything they could hide is reported uncertain. +//! +//! Work is done in a frame centred on the scene, so results do not drift for parts far from the +//! origin. Output curves are `Seg2` pieces in screen millimetres with the accuracy vocabulary of +//! [`crate::section`]. See `docs/drawing-hlr.md` for the contract and its measured limits. + +pub(crate) mod curve; +mod silhouette; +pub(crate) mod surface; +mod trim; + +use serde::Serialize; + +use crate::brep::Brep; +use crate::section::CurveAccuracy; +use crate::{Seg2, Vec2, Vec3}; +use curve::{C3, as_circle}; +use surface::{Analytic, Hit, S3}; +use trim::Trim; + +#[derive(Debug, thiserror::Error)] +pub enum HlrError { + #[error("not supported yet: {0}")] + Unsupported(&'static str), + #[error("hidden lines: {0} budget exceeded")] + Budget(&'static str), + #[error("hidden lines: cancelled")] + Cancelled, + #[error("hidden lines: invalid {0}")] + Invalid(&'static str), +} + +/// An orthographic view: `d` is the direction of sight; screen coordinates are `(p.x, p.y)`. +#[derive(Clone, Copy, Debug)] +pub struct View { + pub x: Vec3, + pub y: Vec3, + pub d: Vec3, +} + +impl View { + pub fn new(d: Vec3, up: Vec3) -> Result { + let d = d.normalized().ok_or(HlrError::Invalid("view direction"))?; + let x = d.cross(up).normalized().ok_or(HlrError::Invalid("view up direction"))?; + Ok(View { x, y: x.cross(d), d }) + } + pub fn screen(&self, p: Vec3) -> [f64; 2] { + [p.dot(self.x), p.dot(self.y)] + } +} + +/// One placed body: `world = rotation * local + translation`, rotation columns given. +#[derive(Clone, Copy, Debug)] +pub struct Placement { + pub body: usize, + pub rotation: [Vec3; 3], + pub translation: Vec3, +} + +impl Placement { + fn apply(&self, p: Vec3) -> Vec3 { + let [a, b, c] = self.rotation; + a * p.x + b * p.y + c * p.z + self.translation + } + fn local_dir(&self, v: Vec3) -> Vec3 { + let [a, b, c] = self.rotation; + Vec3::new(a.dot(v), b.dot(v), c.dot(v)) + } + fn local(&self, p: Vec3) -> Vec3 { + self.local_dir(p - self.translation) + } + fn rigid(&self) -> bool { + let [a, b, c] = self.rotation; + [a, b, c].iter().all(|v| v.is_finite() && (v.len() - 1.).abs() < 1e-9) + && a.dot(b).abs() < 1e-9 + && a.dot(c).abs() < 1e-9 + && b.dot(c).abs() < 1e-9 + && (a.cross(b).dot(c) - 1.).abs() < 1e-9 + && self.translation.is_finite() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum EdgeKind { + Sharp, + /// Between two faces that meet tangentially (or lie on one surface). + Tangent, + /// Used twice by one face, or between two faces of one analytic surface (a patch boundary). + Seam, +} + +struct EdgeData { + curve: C3, + acc: CurveAccuracy, + faces: Vec, + kind: EdgeKind, +} + +struct FaceData { + surface: S3, + analytic: Analytic, + /// Faces of this body on the same quadric or torus (patches of one surface), itself included. + group: Vec, + trim: Trim, + lo: Vec3, + hi: Vec3, + /// Neither a plane nor a recognised quadric or torus. + freeform: bool, +} + +/// A body prepared once and shared by all of its placements. +pub struct Prepared { + edges: Vec, + faces: Vec, + /// The body's records are kept moved by `-offset` (to its own centre), so its coordinates + /// stay small wherever it was modelled; placements add it back. + offset: Vec3, + lo: Vec3, + hi: Vec3, + pub report: PrepareReport, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct PrepareReport { + pub faces: usize, + pub edges: usize, + /// Face counts by recognised surface: plane, cylinder, cone, sphere, torus, other. + pub surfaces: std::collections::BTreeMap, + /// Edge counts by record kind and recognition, e.g. `rational_spline/circle`. + pub curves: std::collections::BTreeMap, + pub tangent_edges: usize, + pub seam_edges: usize, + /// Faces whose trim could not be classified, with the reason. + pub unsupported_trims: Vec<(usize, String)>, + /// Largest distance between a boundary curve and the chords used for trimming (mm). + pub max_trim_chord: f64, + /// Faces outside the supported subset (see [`Options::freeform`]). + pub freeform_faces: Vec, +} + +/// Whether two recognised surfaces are the same quadric or torus (planes never: coplanar +/// faces of a split are real edges in the record, and are drawn). +fn same_analytic(a: &Analytic, b: &Analytic, tol: f64) -> bool { + let close = |x: Vec3, y: Vec3, scale: f64| x.dist(y) <= tol * (1. + scale); + let on_axis = |p: Vec3, q: Vec3, a: Vec3, scale: f64| (q - p).cross(a).len() <= tol * (1. + scale); + match (a, b) { + (Analytic::Cylinder { p, a, r }, Analytic::Cylinder { p: q, a: b, r: s }) => { + (r - s).abs() <= tol * r && a.cross(*b).len() <= tol && on_axis(*p, *q, *a, *r) + } + (Analytic::Cone { apex, a, half }, Analytic::Cone { apex: b_apex, a: b, half: h }) => { + // The axes by their angle, as for cylinders and tori (a dot product would compare + // its square). + (half - h).abs() <= tol && a.cross(*b).len() <= tol && a.dot(*b) > 0. && close(*apex, *b_apex, apex.len()) + } + (Analytic::Sphere { c, r }, Analytic::Sphere { c: d, r: s }) => (r - s).abs() <= tol * r && close(*c, *d, *r), + (Analytic::Torus { c, a, major, minor }, Analytic::Torus { c: d, a: b, major: m, minor: n }) => { + (major - m).abs() <= tol * major && (minor - n).abs() <= tol * major && a.cross(*b).len() <= tol && close(*c, *d, *major) + } + _ => false, + } +} + +fn analytic_name(a: &Analytic) -> &'static str { + match a { + Analytic::Plane => "plane", + Analytic::Cylinder { .. } => "cylinder", + Analytic::Cone { .. } => "cone", + Analytic::Sphere { .. } => "sphere", + Analytic::Torus { .. } => "torus", + Analytic::Other => "other", + } +} + +pub fn prepare(b: &Brep, opts: &Options) -> Result { + prepare_cancellable(b, opts, &|| false) +} + +/// [`prepare`], polling `cancel` between edges, faces and surface comparisons; when it returns +/// true the call ends with `HlrError::Cancelled`. +pub fn prepare_cancellable(b: &Brep, opts: &Options, cancel: &dyn Fn() -> bool) -> Result { + if !(1..=MAX_TRIM_SAMPLES).contains(&opts.trim_samples) { + return Err(HlrError::Invalid("trim samples")); + } + b.validate().map_err(|_| HlrError::Invalid("portable B-rep"))?; + // Work on the body moved to its own centre: rounding in ray and inversion formulas grows + // with the coordinates, and a part may be modelled far from the origin (W05a finding 7). + let offset = anchor(b); + let moved = shifted(b, offset); + let b = &moved; + let mut edges = Vec::new(); + let mut faces = Vec::new(); + let mut report = PrepareReport::default(); + for shell in &b.shells { + let base_face = faces.len(); + let mut curves = Vec::with_capacity(shell.edges.len()); + for (ei, e) in shell.edges.iter().enumerate() { + if ei % 256 == 0 && cancel() { + return Err(HlrError::Cancelled); + } + let (c, acc) = C3::new(&e.curve)?; + let record = match &e.curve { + crate::brep::Curve::Line { .. } => "line", + crate::brep::Curve::CircleArc { .. } => "circle_arc", + crate::brep::Curve::EllipseArc { .. } => "ellipse_arc", + crate::brep::Curve::Polyline { .. } => "polyline", + crate::brep::Curve::Spline { .. } => "spline", + crate::brep::Curve::RationalSpline { .. } => "rational_spline", + crate::brep::Curve::Intersection { .. } => "intersection", + crate::brep::Curve::SurfaceIntersection { .. } => "surface_intersection", + }; + let shape = if matches!(c, C3::Line { .. }) { + "line" + } else if as_circle(&c, 1e-9).is_some() { + "circle" + } else { + "other" + }; + *report.curves.entry(format!("{record}/{shape}")).or_insert(0) += 1; + curves.push((c, acc)); + } + let local_curves: Vec = curves.iter().map(|(c, _)| c.clone()).collect(); + let mut users: Vec> = vec![Vec::new(); shell.edges.len()]; + for (fi, f) in shell.faces.iter().enumerate() { + if cancel() { + return Err(HlrError::Cancelled); + } + let mut surface = S3::new(&f.surface)?; + // A flat spline patch is a plane: trimmed and intersected as one. + if let Some(plane) = surface.flat(1e-9) { + surface = plane; + } + let analytic = surface.analytic(1e-9); + // Trim and intersect recognised spline surfaces in their own analytic chart. + if matches!(surface, S3::Nurbs(_)) { + let axis = match analytic { + Analytic::Cylinder { p, a, .. } => Some((p, a)), + Analytic::Cone { apex, a, .. } => Some((apex, a)), + _ => None, + }; + let mut h = [0., 0.]; + if let Some((p, a)) = axis { + let along = f.loops.iter().flatten().filter_map(|u| local_curves.get(u.edge as usize)).flat_map(|c| { + let [t0, t1] = c.domain(); + (0..=64).map(move |i| c.at(t0 + (t1 - t0) * i as f64 / 64.)) + }); + let (lo, hi) = along.fold((f64::MAX, f64::MIN), |(lo, hi), q| ((q - p).dot(a).min(lo), (q - p).dot(a).max(hi))); + // Generous: the chart only has to contain the face; the trim bounds it exactly. + let pad = 0.01 * (hi - lo).abs() + 1e-6; + h = [lo - pad, hi + pad]; + } + let toward = match analytic { + Analytic::Sphere { c, .. } => { + let pts: Vec = f + .loops + .iter() + .flatten() + .filter_map(|u| local_curves.get(u.edge as usize)) + .map(|c| c.at(0.5 * (c.domain()[0] + c.domain()[1]))) + .collect(); + let n = pts.len().max(1) as f64; + pts.iter().fold(Vec3::ZERO, |acc, p| acc + *p) / n - c + } + _ => Vec3::ZERO, + }; + if let Some(chart) = S3::from_analytic(&analytic, h, toward) { + surface = chart; + } + } + *report.surfaces.entry(analytic_name(&analytic).to_string()).or_insert(0) += 1; + let trim = trim::build(f, &local_curves, &surface, opts.trim_samples); + if let Some(why) = trim.unsupported { + report.unsupported_trims.push((base_face + fi, why.to_string())); + } + report.max_trim_chord = report.max_trim_chord.max(trim.chord_error); + let (mut lo, mut hi) = (trim.lo, trim.hi); + // Curved faces can bulge past their boundary: bound them by the whole surface's + // guaranteed box (spline hull, turned profile), clipped to nothing tighter. + if !matches!(surface, S3::Plane { .. }) + && let Some((blo, bhi)) = surface.bound() + { + lo = lo.min(blo); + hi = hi.max(bhi); + } + // Every face is padded, planes too: a plane record and its edges may disagree by + // rounding, and a box flat along the normal would cull the plane's own hit. Rounding + // grows with where the body was modelled (its offset), not with the face's size. + let pad = (hi - lo).len() * 1e-6 + 1e-12 * offset.x.abs().max(offset.y.abs()).max(offset.z.abs()); + lo -= Vec3::new(pad, pad, pad); + hi += Vec3::new(pad, pad, pad); + for u in f.loops.iter().flatten() { + if let Some(list) = users.get_mut(u.edge as usize) { + list.push(base_face + fi); + } + } + let freeform = matches!(analytic, Analytic::Other); + if freeform { + report.freeform_faces.push(base_face + fi); + } + faces.push(FaceData { surface, analytic, group: Vec::new(), trim, lo, hi, freeform }); + } + for (ei, (c, acc)) in curves.into_iter().enumerate() { + let list = users.get(ei).cloned().unwrap_or_default(); + let mut distinct = list.clone(); + distinct.sort_unstable(); + distinct.dedup(); + let kind = if list.len() >= 2 && distinct.len() == 1 { + EdgeKind::Seam + } else if let [f1, f2] = distinct.as_slice() { + let [t0, t1] = c.domain(); + let normals_parallel = [0.25, 0.5, 0.75].iter().all(|k| { + let m = c.at(t0 + (t1 - t0) * k); + let n = |f: usize| faces.get(f).and_then(|fd: &FaceData| fd.surface.inverse(m, None).and_then(|uv| fd.surface.normal(uv))); + n(*f1).zip(n(*f2)).is_some_and(|(a, b)| a.cross(b).len() < 1e-6) + }); + let same_surface = match (faces.get(*f1).map(|f: &FaceData| f.analytic), faces.get(*f2).map(|f: &FaceData| f.analytic)) { + (Some(a), Some(b)) => same_analytic(&a, &b, 1e-9), + _ => false, + }; + if same_surface { + EdgeKind::Seam + } else if normals_parallel { + EdgeKind::Tangent + } else { + EdgeKind::Sharp + } + } else { + EdgeKind::Sharp + }; + match kind { + EdgeKind::Tangent => report.tangent_edges += 1, + EdgeKind::Seam => report.seam_edges += 1, + EdgeKind::Sharp => {} + } + edges.push(EdgeData { curve: c, acc, faces: list, kind }); + } + } + // Faces on one quadric or torus. Only surfaces of one kind whose size (radius, half angle, + // major radius) agrees to the tolerance can match, so each face is compared with the faces + // in its window of the sorted sizes, not with every face. + let size = |a: &Analytic| match a { + Analytic::Cylinder { r, .. } => Some((0u8, *r)), + Analytic::Cone { half, .. } => Some((1, *half)), + Analytic::Sphere { r, .. } => Some((2, *r)), + Analytic::Torus { major, .. } => Some((3, *major)), + Analytic::Plane | Analytic::Other => None, + }; + let analytics: Vec = faces.iter().map(|f| f.analytic).collect(); + let mut sorted: Vec<(u8, f64, usize)> = analytics.iter().enumerate().filter_map(|(j, a)| size(a).map(|(k, v)| (k, v, j))).collect(); + sorted.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1))); + let mut compared = 0usize; + for (i, f) in faces.iter_mut().enumerate() { + if i % 256 == 0 && cancel() { + return Err(HlrError::Cancelled); + } + let mut group = vec![i]; + if let Some((kind, v)) = size(&f.analytic) { + // Wider than any match allows: same_analytic takes sizes within 1e-9 of the size + // (relative for lengths, absolute for the half angle). + let reach = 2e-9 * v.abs().max(1.); + let first = sorted.partition_point(|c| c.0 < kind || (c.0 == kind && c.1 < v - reach)); + for (_, _, j) in sorted.get(first..).unwrap_or_default().iter().take_while(|c| c.0 == kind && c.1 <= v + reach) { + compared += 1; + if compared > 50_000_000 { + return Err(HlrError::Budget("surface group")); + } + if compared.is_multiple_of(65536) && cancel() { + return Err(HlrError::Cancelled); + } + if *j != i && analytics.get(*j).is_some_and(|b| same_analytic(&f.analytic, b, 1e-9)) { + group.push(*j); + } + } + } + group.sort_unstable(); + f.group = group; + } + report.faces = faces.len(); + report.edges = edges.len(); + let (lo, hi) = + faces.iter().fold((Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)), |(a, b), f| (a.min(f.lo), b.max(f.hi))); + if faces.is_empty() || !lo.is_finite() || !hi.is_finite() { + return Err(HlrError::Invalid("empty or unbounded body")); + } + Ok(Prepared { edges, faces, offset, lo, hi, report }) +} + +/// The centre of the body's vertices' box (of its faces' reference points when it has no +/// vertices, a sphere without edges say). +fn anchor(b: &Brep) -> Vec3 { + use crate::brep::Surface; + let v3 = |p: &[f64; 3]| Vec3::new(p[0], p[1], p[2]); + let mut pts: Vec = b.shells.iter().flat_map(|s| s.vertices.iter().map(v3)).collect(); + if pts.is_empty() { + pts = b + .shells + .iter() + .flat_map(|s| s.faces.iter()) + .filter_map(|f| match &f.surface { + Surface::Plane { origin, .. } | Surface::PlaneThrough { origin, .. } | Surface::Revolution { origin, .. } => Some(v3(origin)), + Surface::PlacedRevolution { origin, matrix: [x, y, z, w], .. } => { + let column = |c: &[f64; 4]| Vec3::new(c[0], c[1], c[2]); + Some(column(x) * origin[0] + column(y) * origin[1] + column(z) * origin[2] + column(w)) + } + Surface::Cylinder { frame, .. } | Surface::Cone { frame, .. } | Surface::Sphere { frame, .. } | Surface::Torus { frame, .. } => { + Some(v3(&frame.origin)) + } + Surface::Spline { points, .. } => points.first().and_then(|r| r.first()).map(v3), + Surface::RationalSpline { homogeneous, .. } => { + homogeneous.first().and_then(|r| r.first()).filter(|h| h[3] != 0.).map(|h| Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3])) + } + }) + .collect(); + } + let Some(first) = pts.first().copied() else { return Vec3::ZERO }; + let (lo, hi) = pts.iter().fold((first, first), |(lo, hi), p| (lo.min(*p), hi.max(*p))); + let c = (lo + hi) * 0.5; + if c.is_finite() { c } else { Vec3::ZERO } +} + +/// The body's records with every position moved by `-d`. +fn shifted(b: &Brep, d: Vec3) -> Brep { + use crate::brep::{Curve, Surface}; + fn point(p: &mut [f64; 3], d: Vec3) { + p[0] -= d.x; + p[1] -= d.y; + p[2] -= d.z; + } + fn weighted(h: &mut [f64; 4], d: Vec3) { + h[0] -= d.x * h[3]; + h[1] -= d.y * h[3]; + h[2] -= d.z * h[3]; + } + fn curve(c: &mut Curve, d: Vec3) { + match c { + Curve::Line { start, end } => { + point(start, d); + point(end, d); + } + Curve::CircleArc { frame, .. } | Curve::EllipseArc { frame, .. } => point(&mut frame.origin, d), + Curve::Polyline { points } | Curve::Spline { points, .. } | Curve::Intersection { points, .. } => { + points.iter_mut().for_each(|p| point(p, d)) + } + Curve::RationalSpline { homogeneous, .. } => homogeneous.iter_mut().for_each(|h| weighted(h, d)), + Curve::SurfaceIntersection { surfaces, leader, .. } => { + surfaces.iter_mut().for_each(|s| surface(s, d)); + curve(leader, d); + } + } + } + fn surface(s: &mut Surface, d: Vec3) { + match s { + Surface::Plane { origin, .. } => point(origin, d), + Surface::PlaneThrough { origin, u_point, v_point } => [origin, u_point, v_point].into_iter().for_each(|p| point(p, d)), + Surface::Cylinder { frame, .. } | Surface::Cone { frame, .. } | Surface::Sphere { frame, .. } | Surface::Torus { frame, .. } => { + point(&mut frame.origin, d) + } + Surface::Spline { points, .. } => points.iter_mut().flatten().for_each(|p| point(p, d)), + Surface::RationalSpline { homogeneous, .. } => homogeneous.iter_mut().flatten().for_each(|h| weighted(h, d)), + Surface::Revolution { profile, origin, .. } => { + curve(profile, d); + point(origin, d); + } + // The placement's last column carries its translation (and the homogeneous weight). + Surface::PlacedRevolution { matrix: [.., w], .. } => weighted(w, d), + } + } + let mut out = b.clone(); + for s in &mut out.shells { + s.vertices.iter_mut().for_each(|p| point(p, d)); + s.edges.iter_mut().for_each(|e| curve(&mut e.curve, d)); + s.faces.iter_mut().for_each(|f| surface(&mut f.surface, d)); + } + out +} + +#[derive(Clone, Copy, Debug)] +pub struct Options { + /// Draw edges between tangent faces (seams are never drawn). + pub tangent_edges: bool, + /// Also handle freeform faces: contoured silhouettes and seeded ray tests, slow and + /// ill-conditioned where a face is seen nearly edge-on (W05a finding 6). Off by default. + pub freeform: bool, + /// Boundary samples per curve piece when trimming faces, 1 to [`MAX_TRIM_SAMPLES`]. + pub trim_samples: usize, + pub max_tracks: usize, + pub max_rays: 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 } + } +} + +/// 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; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Visibility { + Visible, + Hidden, + Uncertain, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Source { + Edge { + edge: usize, + tangent: bool, + }, + /// The silhouette of the faces on one surface; `face` is the first of them. + Silhouette { + face: usize, + }, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Piece { + /// Parameter range on the source curve. + pub t: [f64; 2], + pub visibility: Visibility, + /// Drawn by another piece: a hidden or uncertain piece lying on a visible one (hidden lines + /// never show through visible ones), or a duplicate of a visible or hidden piece. + pub covered: bool, + /// The piece on screen (millimetres): lines and conic arcs where the projection is exact, + /// chords otherwise. + pub geometry: Vec, + pub accuracy: CurveAccuracy, + /// Exact points along the piece, joined by `geometry`'s chords where it is sampled. + #[serde(skip)] + points: Vec<[f64; 2]>, + /// Largest distance between the projected curve and the chords joining `points`. + #[serde(skip)] + sag: f64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Line { + /// Index into the scene's placements. + pub placement: usize, + pub source: Source, + pub pieces: Vec, +} + +/// A face left out of the supported subset, and why. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct Unsupported { + pub placement: usize, + pub face: usize, + pub reason: &'static str, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct Stats { + pub tracks: usize, + pub silhouettes: usize, + pub crossings: usize, + pub pieces: usize, + pub uncertain_pieces: usize, + /// Uncertain pieces not covered by a coincident visible piece. + pub uncertain_uncovered: usize, + pub covered_pieces: usize, + /// Visibility changes found inside a piece by sampling, i.e. not at a computed crossing. + pub unexpected_transitions: usize, + pub rays: usize, + pub surface_ray_tests: usize, + /// Milliseconds per phase: curves and silhouettes, crossings, classification, coverage. + /// Zero where the platform has no clock (`wasm32-unknown-unknown`). + pub phase_ms: [f64; 4], +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct Drawing { + pub lines: Vec, + /// Faces outside the supported subset: their silhouettes are missing and what they could + /// hide is uncertain. + pub unsupported: Vec, + pub stats: Stats, +} + +enum TrackCurve<'a> { + Edge(&'a C3), + Owned(C3), +} + +struct Track<'a> { + placement: usize, + curve: TrackCurve<'a>, + range: [f64; 2], + source: Source, + acc: CurveAccuracy, + own: Vec, + /// Own-face hits closer than this are the curve's own surface: a sampled silhouette's + /// chord lies up to its deviation inside the surface, and a ray tangent there leaves it + /// again after about sqrt(2 * radius * deviation). + own_eps: f64, + samples: Vec<(f64, [f64; 2])>, + /// Per sample chord: the distance of the exact projected curve from it at its middle. + sags: Vec, + splits: Vec, +} + +impl Track<'_> { + fn c3(&self) -> &C3 { + match &self.curve { + TrackCurve::Edge(c) => c, + TrackCurve::Owned(c) => c, + } + } +} + +struct Ctx<'a> { + defs: &'a [Prepared], + scene: &'a [Placement], + view: View, + eps_own: f64, + eps_other: f64, + 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, + max_rays: usize, + freeform: bool, + cancel: &'a dyn Fn() -> bool, +} + +fn context<'a>(defs: &'a [Prepared], scene: &'a [Placement], view: &View, opts: &Options, cancel: &'a dyn Fn() -> bool) -> 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 { + if !p.rigid() { + return Err(HlrError::Invalid("placement (not a rotation and translation)")); + } + let def = defs.get(p.body).ok_or(HlrError::Invalid("placement body index"))?; + let (mut slo, mut shi, mut dmin) = ([f64::MAX; 2], [f64::MIN; 2], f64::MAX); + for k in 0..8 { + let c = Vec3::new( + if k & 1 == 0 { def.lo.x } else { def.hi.x }, + if k & 2 == 0 { def.lo.y } else { def.hi.y }, + if k & 4 == 0 { def.lo.z } else { def.hi.z }, + ); + let w = p.apply(c); + wlo = wlo.min(w); + whi = whi.max(w); + let s = view.screen(w); + slo = [slo[0].min(s[0]), slo[1].min(s[1])]; + shi = [shi[0].max(s[0]), shi[1].max(s[1])]; + dmin = dmin.min(w.dot(view.d)); + } + boxes.push((slo, shi, dmin)); + } + let scale = if scene.is_empty() { 1. } else { (whi - wlo).len().max(1e-6) }; + Ok(Ctx { + defs, + scene, + view: *view, + eps_own: 1e-6 * scale, + // Hits on other bodies closer than rounding are ignored, the rest up to eps_own are + // uncertain: the work is done in a frame at the scene's centre, so rounding is about + // 1e-16 of the coordinates, far below 1e-12 of the scene. + 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), + max_rays: opts.max_rays, + freeform: opts.freeform, + cancel, + }) +} + +pub fn hidden_lines(defs: &[Prepared], scene: &[Placement], view: &View, opts: &Options, cancel: &dyn Fn() -> bool) -> Result { + // 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)?; + // Each body's own offset (see `Prepared`) joins its placement here. + let local: Vec = + scene.iter().map(|p| Placement { translation: p.apply(defs.get(p.body).map_or(Vec3::ZERO, |d| d.offset)) - origin, ..*p }).collect(); + let scene = local.as_slice(); + let shift = view.screen(origin); + let ctx = context(defs, scene, view, opts, cancel)?; + let scale = ctx.scale; + let mut stats = Stats::default(); + let mut unsupported = Vec::new(); + let clock = Clock::start(); + // 1. Tracks: drawable edges and trimmed silhouettes of every placement. + let mut tracks: Vec = Vec::new(); + for (pi, p) in scene.iter().enumerate() { + if cancel() { + return Err(HlrError::Cancelled); + } + let Some(def) = defs.get(p.body) else { continue }; + // A body's records were rounded where it was modelled: a plane may stand an ulp of + // those coordinates off its edges, which matters for a part far smaller than its + // 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) { + continue; + } + tracks.push(Track { + placement: pi, + curve: TrackCurve::Edge(&e.curve), + range: e.curve.domain(), + source: Source::Edge { edge: ei, tangent: e.kind == EdgeKind::Tangent }, + acc: e.acc, + own: e.faces.clone(), + // Skip the ray's own start on its faces: rounding grows with the coordinates + // (1e-9 of the scene), the margin with the faces themselves (1e-6 of their + // size), never with the whole scene, so small parts in large scenes keep their + // self-occlusion. + own_eps: { + let size = e.faces.iter().filter_map(|f| def.faces.get(*f)).map(|f| (f.hi - f.lo).len()).fold(0., f64::max); + let base = (1e-9 * ctx.scale).max(1e-6 * size).max(far); + match e.acc { + CurveAccuracy::Bounded { max_deviation_mm: tol } => base.max(10. * tol), + _ => base, + } + }, + samples: Vec::new(), + sags: Vec::new(), + 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 { + 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 s in sils { + for r in &s.ranges { + stats.silhouettes += 1; + tracks.push(Track { + placement: pi, + curve: TrackCurve::Owned(s.curve.clone()), + range: *r, + source: Source::Silhouette { face: fi }, + acc: s.acc, + own: f.group.clone(), + own_eps: match s.acc { + CurveAccuracy::Sampled { max_sample_deviation_mm: dev } => { + (1e-9 * ctx.scale).max(1e-6 * size).max(far).max(2. * (2. * size * dev).sqrt()) + } + _ => (1e-9 * ctx.scale).max(1e-6 * size).max(far), + }, + samples: Vec::new(), + sags: Vec::new(), + splits: Vec::new(), + }); + } + } + } + if tracks.len() > opts.max_tracks { + return Err(HlrError::Budget("curve")); + } + } + 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(); + t.samples = samples; + t.sags = sags; + } + stats.phase_ms[0] = clock.lap(); + // 3. Crossings between projected curves (and each curve with itself). + stats.crossings = crossings(&mut tracks, &ctx)?; + // Faces left out (freeform or with an unsupported trim) draw no silhouettes to cut at, so + // 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 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 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); + } + 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 }); + } + 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)?; + 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(); + // 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) { + let p = scene.get(t.placement).ok_or(HlrError::Invalid("placement"))?; + for piece in &mut l.pieces { + let (geometry, accuracy) = piece_geometry(t, piece, p, view); + piece.geometry = geometry.into_iter().map(|g| shift_seg(g, shift)).collect(); + piece.accuracy = accuracy; + piece.points = piece.points.iter().map(|q| shifted(*q)).collect(); + } + } + Ok(Drawing { lines, unsupported, stats }) +} + +/// 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 { + lo: [f64; 2], + hi: [f64; 2], + near: f64, +} + +fn shadow_boxes(defs: &[Prepared], scene: &[Placement], view: &View, left_out: &[Unsupported]) -> Vec { + let mut out = Vec::new(); + for u in left_out { + let Some(p) = scene.get(u.placement) else { continue }; + let Some(f) = defs.get(p.body).and_then(|d| d.faces.get(u.face)) else { continue }; + let (mut lo, mut hi, mut near) = ([f64::MAX; 2], [f64::MIN; 2], f64::MAX); + for k in 0..8 { + let c = Vec3::new( + if k & 1 == 0 { f.lo.x } else { f.hi.x }, + if k & 2 == 0 { f.lo.y } else { f.hi.y }, + if k & 4 == 0 { f.lo.z } else { f.hi.z }, + ); + let w = p.apply(c); + let s = view.screen(w); + lo = [lo[0].min(s[0]), lo[1].min(s[1])]; + hi = [hi[0].max(s[0]), hi[1].max(s[1])]; + near = near.min(w.dot(view.d)); + } + out.push(Shadow { lo, hi, near }); + } + out +} + +/// Whether the piece `r` of a track may lie behind a left-out face, its own faces included (a +/// curled face can hide its own edge closer than the own-face margin). +fn in_shadow(t: &Track, r: [f64; 2], scene: &[Placement], view: &View, shadows: &ShadowIndex) -> bool { + if shadows.list.is_empty() { + return false; + } + let Some(p) = scene.get(t.placement) else { return false }; + let c = t.c3(); + // The piece is cut at the boxes' sides and where its curve enters a box between samples: + // it is in a box when its own screen box overlaps it and, for a piece holding samples, its + // middle or one of those samples lies in it (its ends may sit on the side it was cut at). + // Then it is in the shadow when its depth can pass the face's nearest point anywhere along + // it: the bound comes from the curve itself (its control points' hull, an arc's reach), + // not from samples, which a dip in depth that the screen does not show could fall between. + let (lo, hi) = screen_box(c, r, p, view); + let mut near = Vec::new(); + shadows.query(lo, hi, &mut near); + if near.is_empty() { + return false; + } + let inner: Vec<[f64; 2]> = t.samples.iter().filter(|q| q.0 > r[0] && q.0 < r[1]).map(|q| q.1).collect(); + let mid = view.screen(p.apply(c.at(0.5 * (r[0] + r[1])))); + let deepest = depth_bound(c, r, p, view.d); + near.iter().filter_map(|k| shadows.list.get(*k)).any(|sh| { + let within = |s: &[f64; 2]| s[0] >= sh.lo[0] && s[0] <= sh.hi[0] && s[1] >= sh.lo[1] && s[1] <= sh.hi[1]; + deepest > sh.near && overlaps(lo, hi, sh) && (inner.is_empty() || within(&mid) || inner.iter().any(within)) + }) +} + +/// Whether the box `lo hi` and a shadow's box share some area. +fn overlaps(lo: [f64; 2], hi: [f64; 2], sh: &Shadow) -> bool { + lo[0] < sh.hi[0] && hi[0] > sh.lo[0] && lo[1] < sh.hi[1] && hi[1] > sh.lo[1] +} + +/// The screen box of the curve's part `r` placed by `p`: a line's ends, an arc's ends and its +/// extremes along each screen axis, the projected control points a spline's part depends on +/// (they hold it), a polyline's vertices. +fn screen_box(c: &C3, r: [f64; 2], p: &Placement, view: &View) -> ([f64; 2], [f64; 2]) { + let s = |q: Vec3| view.screen(p.apply(q)); + let mut pts = vec![s(c.at(r[0])), s(c.at(r[1]))]; + match c { + C3::Line { .. } => {} + C3::Arc { x, y, r: rad, .. } => { + // On screen: centre + U cos t + V sin t; each axis peaks where tan t = V_k / U_k. + let lin = |w: Vec3| view.screen(p.apply(w) - p.translation); + let (u, v) = (lin(*x * rad[0]), lin(*y * rad[1])); + for k in 0..2 { + let (Some(&uk), Some(&vk)) = (u.get(k), v.get(k)) else { continue }; + // The peaks at base + k pi from the first one past the part's start, wherever + // its angles are counted from. + let (base, pi) = (vk.atan2(uk), std::f64::consts::PI); + let first = ((r[0] - base) / pi).ceil(); + for k in 0..8 { + let t = base + (first + k as f64) * pi; + if t >= r[1] { + break; + } + if t > r[0] { + pts.push(s(c.at(t))); + } + } + } + } + C3::Spline { degree, knots, points, .. } => match c.hull(r) { + Some(h) => pts.extend(h.into_iter().map(s)), + None => pts.extend( + points + .iter() + .enumerate() + .filter(|(i, h)| h[3] > 0. && knots.get(*i).zip(knots.get(i + degree + 1)).is_some_and(|(lo, hi)| *lo < r[1] && *hi > r[0])) + .map(|(_, h)| s(Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3]))), + ), + }, + C3::Polyline(v) => pts.extend(v.iter().enumerate().filter(|(i, _)| (*i as f64) > r[0] && (*i as f64) < r[1]).map(|(_, q)| s(*q))), + } + let lo = pts.iter().fold([f64::MAX; 2], |m, q| [m[0].min(q[0]), m[1].min(q[1])]); + let hi = pts.iter().fold([f64::MIN; 2], |m, q| [m[0].max(q[0]), m[1].max(q[1])]); + (lo, hi) +} + +/// Left-out faces' boxes on a uniform screen grid, so a curve meets only those near it. +struct ShadowIndex { + list: Vec, + lo: [f64; 2], + hi: [f64; 2], + cell: [f64; 2], + n: usize, + buckets: Vec>, +} + +impl ShadowIndex { + fn new(list: Vec) -> Result { + let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]); + for sh in &list { + lo = [lo[0].min(sh.lo[0]), lo[1].min(sh.lo[1])]; + hi = [hi[0].max(sh.hi[0]), hi[1].max(sh.hi[1])]; + } + let n = ((list.len() as f64).sqrt().ceil() as usize).clamp(1, 1024); + let cell = [((hi[0] - lo[0]) / n as f64).max(1e-12), ((hi[1] - lo[1]) / n as f64).max(1e-12)]; + let mut index = ShadowIndex { list: Vec::new(), lo, hi, cell, n, buckets: vec![Vec::new(); n * n] }; + let mut entries = 0usize; + for (k, sh) in list.iter().enumerate() { + let (a, b) = (index.cell_of(sh.lo), index.cell_of(sh.hi)); + entries = entries.saturating_add((b[0] - a[0] + 1).saturating_mul(b[1] - a[1] + 1)); + if entries > 50_000_000 { + return Err(HlrError::Budget("shadow grid")); + } + for y in a[1]..=b[1] { + for x in a[0]..=b[0] { + if let Some(bucket) = index.buckets.get_mut(y * n + x) { + bucket.push(k); + } + } + } + } + index.list = list; + Ok(index) + } + + fn cell_of(&self, p: [f64; 2]) -> [usize; 2] { + let f = |k: usize| (((p[k] - self.lo[k]) / self.cell[k]).floor().clamp(0., (self.n - 1) as f64)) as usize; + [f(0), f(1)] + } + + /// The boxes in the cells the box `lo hi` touches, each once. + fn query(&self, lo: [f64; 2], hi: [f64; 2], out: &mut Vec) { + out.clear(); + if self.list.is_empty() || hi[0] < self.lo[0] || hi[1] < self.lo[1] || lo[0] > self.hi[0] || lo[1] > self.hi[1] { + return; + } + let (a, b) = (self.cell_of(lo), self.cell_of(hi)); + for y in a[1]..=b[1] { + for x in a[0]..=b[0] { + if let Some(bucket) = self.buckets.get(y * self.n + x) { + out.extend_from_slice(bucket); + } + } + } + out.sort_unstable(); + out.dedup(); + } +} + +/// An upper bound of the depth along `d` of the curve's part `r` placed by `p`: a line's ends, +/// an arc's centre plus its reach, the projected control points a spline's part depends on +/// (its weights are positive, so the part lies in their hull), a polyline's vertices. +fn depth_bound(c: &C3, r: [f64; 2], p: &Placement, d: Vec3) -> f64 { + let depth = |q: Vec3| p.apply(q).dot(d); + match c { + C3::Line { .. } => depth(c.at(r[0])).max(depth(c.at(r[1]))), + C3::Arc { o, x, y, r: rad, .. } => { + let dl = p.local_dir(d); + depth(*o) + (rad[0] * x.dot(dl)).hypot(rad[1] * y.dot(dl)) + } + C3::Spline { degree, knots, points, .. } => match c.hull(r) { + Some(h) => h.into_iter().map(depth).fold(f64::MIN, f64::max), + None => points + .iter() + .enumerate() + .filter(|(i, h)| h[3] > 0. && knots.get(*i).zip(knots.get(i + degree + 1)).is_some_and(|(lo, hi)| *lo < r[1] && *hi > r[0])) + .map(|(_, h)| depth(Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3]))) + .fold(f64::MIN, f64::max), + } + .max(depth(c.at(r[0]))) + .max(depth(c.at(r[1]))), + C3::Polyline(pts) => pts + .iter() + .enumerate() + .filter(|(i, _)| (*i as f64) > r[0] && (*i as f64) < r[1]) + .map(|(_, q)| depth(*q)) + .fold(depth(c.at(r[0])).max(depth(c.at(r[1]))), f64::max), + } +} + +/// Distance between the segment `a b` and the box `lo hi` (zero when they meet). +fn box_gap(a: [f64; 2], b: [f64; 2], lo: [f64; 2], hi: [f64; 2]) -> f64 { + let inside = |p: [f64; 2]| p[0] >= lo[0] && p[0] <= hi[0] && p[1] >= lo[1] && p[1] <= hi[1]; + if inside(a) || inside(b) { + return 0.; + } + let corners = [[lo[0], lo[1]], [hi[0], lo[1]], [hi[0], hi[1]], [lo[0], hi[1]]]; + let crosses = (0..4).any(|k| { + let (Some(c), Some(d)) = (corners.get(k), corners.get((k + 1) % 4)) else { return false }; + let side = |p: [f64; 2], q: [f64; 2], r: [f64; 2]| (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]); + side(a, b, *c) * side(a, b, *d) <= 0. && side(*c, *d, a) * side(*c, *d, b) <= 0. + }); + if crosses { + return 0.; + } + let to_box = |p: [f64; 2]| { + let dx = (lo[0] - p[0]).max(p[0] - hi[0]).max(0.); + let dy = (lo[1] - p[1]).max(p[1] - hi[1]).max(0.); + dx.hypot(dy) + }; + let corner_gap = corners.iter().map(|q| point_segment(*q, a, b)).fold(f64::MAX, f64::min); + to_box(a).min(to_box(b)).min(corner_gap) +} + +fn scene_centre(defs: &[Prepared], scene: &[Placement]) -> Result { + let (mut lo, mut hi) = (Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)); + for p in scene { + if !p.rigid() { + return Err(HlrError::Invalid("placement (not a rotation and translation)")); + } + let def = defs.get(p.body).ok_or(HlrError::Invalid("placement body index"))?; + for k in 0..8 { + let c = Vec3::new( + if k & 1 == 0 { def.lo.x } else { def.hi.x }, + if k & 2 == 0 { def.lo.y } else { def.hi.y }, + if k & 4 == 0 { def.lo.z } else { def.hi.z }, + ); + let w = p.apply(c + def.offset); + if !(w.x.abs() <= MAX_COORDINATE && w.y.abs() <= MAX_COORDINATE && w.z.abs() <= MAX_COORDINATE) { + return Err(HlrError::Invalid("scene extent")); + } + lo = lo.min(w); + hi = hi.max(w); + } + } + let c = if scene.is_empty() { Vec3::ZERO } else { (lo + hi) * 0.5 }; + if c.is_finite() { Ok(c) } else { Err(HlrError::Invalid("scene extent")) } +} + +fn shift_seg(g: Seg2, d: [f64; 2]) -> Seg2 { + let m = |v: Vec2| Vec2::new(v.x + d[0], v.y + d[1]); + match g { + Seg2::Line { a, b } => Seg2::Line { a: m(a), b: m(b) }, + Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center: m(center), radius, start, sweep }, + Seg2::Cubic { p0, p1, p2, p3 } => Seg2::Cubic { p0: m(p0), p1: m(p1), p2: m(p2), p3: m(p3) }, + Seg2::Conic { a, apex, b, w } => Seg2::Conic { a: m(a), apex: m(apex), b: m(b), w }, + Seg2::Bezier { n, p } => Seg2::Bezier { n, p: p.map(m) }, + } +} + +/// A piece's screen geometry: a line for a straight source, exact conic arcs for a circle or +/// ellipse (an orthographic projection is affine, so it maps them to ellipses exactly), chords +/// between its exact points otherwise. +fn piece_geometry(t: &Track, piece: &Piece, p: &Placement, view: &View) -> (Vec, CurveAccuracy) { + let c = t.c3(); + let s = |q: Vec3| { + let v = view.screen(p.apply(q)); + Vec2::new(v[0], v[1]) + }; + let chords = || { + let segs: Vec = + piece.points.windows(2).filter_map(|w| Some(Seg2::Line { a: Vec2::from(*w.first()?), b: Vec2::from(*w.get(1)?) })).collect(); + (segs, CurveAccuracy::Sampled { max_sample_deviation_mm: piece.sag }) + }; + match c { + C3::Line { .. } if t.acc == CurveAccuracy::Analytic => { + (vec![Seg2::Line { a: s(c.at(piece.t[0])), b: s(c.at(piece.t[1])) }], CurveAccuracy::Analytic) + } + _ if t.acc == CurveAccuracy::Analytic => { + // A circle or ellipse: o + u cos(a) + v sin(a), sampled at the piece's angles. + let frame = match c { + C3::Arc { o, x, y, r, .. } => Some((*o, *x * r[0], *y * r[1], true)), + _ => as_circle(c, 1e-9).and_then(|(centre, normal, r)| { + let e1 = (c.at(t.range[0]) - centre).normalized()?; + Some((centre, e1 * r, normal.cross(e1) * r, false)) + }), + }; + let Some((o, u, v, param_is_angle)) = frame else { return chords() }; + let angle = |x: f64| { + if param_is_angle { + return x; + } + let w = c.at(x) - o; + w.dot(v).atan2(w.dot(u)) + }; + // The angle turned from the piece's start to its end: the way it turns, certified + // all along it (C3::turn_sign; a piece that turns back, or one that cannot be + // certified, is drawn as chords), and its end angles fix the turn, however unevenly + // it is parameterised. A whole closed edge turns a full circle. + let (a0, a1) = if param_is_angle { + (piece.t[0], piece.t[1]) + } else { + let tau = std::f64::consts::TAU; + let wrap = |d: f64| (d + std::f64::consts::PI).rem_euclid(tau) - std::f64::consts::PI; + let (t0, t1) = (piece.t[0], piece.t[1]); + let span = (t.range[1] - t.range[0]).abs(); + let a0 = angle(t0); + let ends = angle(t1) - a0; + let dir = match c.turn_sign([t0, t1], o, u, v) { + Some(d) if d != 0. => d, + // It does not turn at all: a point-like piece, unless its ends differ. + Some(_) if wrap(ends).abs() <= 1e-9 => 1., + _ => return chords(), + }; + let mut turn = (dir * ends).rem_euclid(tau); + let whole = + t0 <= t.range[0] + 1e-12 * span && t1 >= t.range[1] - 1e-12 * span && c.at(t.range[0]).dist(c.at(t.range[1])) <= 1e-9 * u.len(); + if whole && (turn < 1e-9 || turn > tau - 1e-9) { + turn = tau; + } + (a0, a0 + dir * turn) + }; + let pieces = ((a1 - a0).abs() / std::f64::consts::FRAC_PI_2).ceil().max(1.) as usize; + let at = |a: f64| o + u * a.cos() + v * a.sin(); + let mut out = Vec::with_capacity(pieces); + for k in 0..pieces { + let (b0, b1) = (a0 + (a1 - a0) * k as f64 / pieces as f64, a0 + (a1 - a0) * (k + 1) as f64 / pieces as f64); + let (m, h) = (0.5 * (b0 + b1), 0.5 * (b1 - b0)); + let w = h.cos(); + if w <= 1e-12 { + return chords(); + } + out.push(Seg2::Conic { a: s(at(b0)), apex: s(o + (u * m.cos() + v * m.sin()) / w), b: s(at(b1)), w }); + } + (out, CurveAccuracy::Analytic) + } + _ => chords(), + } +} + +/// Finds where projected tracks cross or touch and records the split parameters on both. +fn crossings(tracks: &mut [Track], ctx: &Ctx) -> Result { + struct Seg { + track: usize, + i: usize, + a: [f64; 2], + b: [f64; 2], + sag: f64, + /// The grid cells it was put in: first and last column and row. + cells: [usize; 4], + } + let mut segs = Vec::new(); + // Where each track's segments start in `segs`. + let mut first_seg = Vec::with_capacity(tracks.len()); + let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]); + for (ti, t) in tracks.iter().enumerate() { + first_seg.push(segs.len()); + for (i, w) in t.samples.windows(2).enumerate() { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + for p in [a.1, b.1] { + lo = [lo[0].min(p[0]), lo[1].min(p[1])]; + hi = [hi[0].max(p[0]), hi[1].max(p[1])]; + } + segs.push(Seg { track: ti, i, a: a.1, b: b.1, sag: t.sags.get(i).copied().unwrap_or(0.), cells: [0; 4] }); + } + } + if segs.is_empty() { + return Ok(0); + } + let n = ((segs.len() as f64).sqrt().ceil() as usize).clamp(1, 512); + let cell = [((hi[0] - lo[0]) / n as f64).max(1e-12), ((hi[1] - lo[1]) / n as f64).max(1e-12)]; + let idx = |p: [f64; 2]| -> (usize, usize) { + (((p[0] - lo[0]) / cell[0]).floor().clamp(0., (n - 1) as f64) as usize, ((p[1] - lo[1]) / cell[1]).floor().clamp(0., (n - 1) as f64) as usize) + }; + 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)() { + return Err(HlrError::Cancelled); + } + let g = s.sag + 1e-9 * ctx.scale; + let (a, b) = (idx([s.a[0].min(s.b[0]) - g, s.a[1].min(s.b[1]) - g]), idx([s.a[0].max(s.b[0]) + g, s.a[1].max(s.b[1]) + g])); + entries = entries.saturating_add((b.0 - a.0 + 1).saturating_mul(b.1 - a.1 + 1)); + if entries > 50_000_000 { + return Err(HlrError::Budget("crossing grid")); + } + s.cells = [a.0, a.1, b.0, b.1]; + for y in a.1..=b.1 { + for x in a.0..=b.0 { + if let Some(c) = grid.get_mut(y * n + x) { + c.push(si); + } + } + } + } + // 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. + 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; + // 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)] + .iter() + .all(|d| *d <= tol) + }; + // The segment of the same track on the other side of a segment's end `f` (0 or 1). + let across = |s: &Seg, f: f64| -> Option<&Seg> { + let i = if f == 1. { s.i + 1 } else { s.i.checked_sub(1)? }; + segs.get(first_seg.get(s.track)? + i).filter(|n| n.track == s.track && n.i == i) + }; + // Two curves that coincide meet at every sample point they share, chord ends touching. Such + // a meeting is no crossing when the curves also coincide on both sides of it; where a + // coincident run starts or ends, one side differs and the meeting is kept. + // The end (0 or 1) of a segment that fraction `f` along it lies at, to rounding. + let end_at = |s: &Seg, f: f64| { + let len = (s.b[0] - s.a[0]).hypot(s.b[1] - s.a[1]); + if f * len <= tol { + Some(0.) + } else if (1. - f) * len <= tol { + Some(1.) + } else { + None + } + }; + let shared_point = |s: &Seg, t: &Seg, (u, v): (f64, f64)| { + end_at(s, u) + .zip(end_at(t, v)) + .and_then(|(fu, fv)| across(s, fu).zip(across(t, fv))) + .is_some_and(|(s2, t2)| coincide(s, t2) && coincide(s2, t)) + }; + // 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(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)); + } + } + } + } 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)); + } + } + } + if found.len() > 50_000_000 { + return Err(HlrError::Budget("crossing")); + } + } + } + } + // 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. + let mut by_pair: std::collections::BTreeMap<(usize, usize), Vec<(usize, usize, f64, usize, usize, f64)>> = std::collections::BTreeMap::new(); + for c in found { + by_pair.entry((c.0.min(c.3), c.0.max(c.3))).or_default().push(c); + } + let mut found = Vec::new(); + for (_, mut list) in by_pair { + if list.len() > 8 { + list.sort_by(|a, b| (a.1 as f64 + a.2).total_cmp(&(b.1 as f64 + b.2))); + // Collapse only when the curves stay within their sag of each other between every + // pair of consecutive crossings (one curve seen twice); a curve that weaves across + // 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(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)); + let reach = 4. * (sag(ta) + sag(tb)) + tol; + // Only the other curve's chords between its two crossings (and one either side). + let (j0, j1) = (x.4.min(y.4).saturating_sub(1), x.4.max(y.4) + 2); + tb.samples.get(j0..j1.min(tb.samples.len())).is_some_and(|run| { + run.windows(2).any(|w| match (w.first(), w.get(1)) { + (Some(a), Some(b)) => point_segment(q, a.1, b.1) <= reach, + _ => false, + }) + }) + }); + if together { + let (Some(first), Some(last)) = (list.first().copied(), list.last().copied()) else { continue }; + list = vec![first, last]; + } + } + 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); + if let Some(t) = tracks.get_mut(ta) { + t.splits.push(pa); + } + if let Some(t) = tracks.get_mut(tb) { + t.splits.push(pb); + } + } + Ok(count) +} + +fn sample_param(tracks: &[Track], t: usize, i: usize, f: f64) -> f64 { + let Some(tr) = tracks.get(t) else { return 0. }; + match (tr.samples.get(i), tr.samples.get(i + 1)) { + (Some(a), Some(b)) => a.0 + (b.0 - a.0) * f, + _ => tr.range[0], + } +} + +/// Newton refinement of a crossing on the exact projected curves; keeps the linear estimate +/// when the curves are tangent or the iteration leaves the curves' ranges. +fn refine(tracks: &[Track], ctx: &Ctx, ta: usize, sa: f64, tb: usize, sb: f64) -> (f64, f64) { + let (Some(a), Some(b)) = (tracks.get(ta), tracks.get(tb)) else { return (sa, sb) }; + let (Some(pa), Some(pb)) = (ctx.scene.get(a.placement), ctx.scene.get(b.placement)) else { return (sa, sb) }; + if matches!(a.c3(), C3::Polyline(_)) || matches!(b.c3(), C3::Polyline(_)) { + return (sa, sb); + } + let fa = |s: f64| ctx.view.screen(pa.apply(a.c3().at(s))); + let fb = |s: f64| ctx.view.screen(pb.apply(b.c3().at(s))); + let da = |s: f64| ctx.view.screen(pa.apply(a.c3().d1(s)) - pa.translation); + let db = |s: f64| ctx.view.screen(pb.apply(b.c3().d1(s)) - pb.translation); + let (mut s, mut t) = (sa, sb); + for _ in 0..30 { + let (p, q) = (fa(s), fb(t)); + let f = [p[0] - q[0], p[1] - q[1]]; + let (ja, jb) = (da(s), db(t)); + // [ja, -jb] [ds, dt]^T = -f + let det = ja[0] * (-jb[1]) - (-jb[0]) * ja[1]; + if det.abs() <= 1e-14 * (1. + ja[0].abs() + ja[1].abs()) * (1. + jb[0].abs() + jb[1].abs()) { + return (sa, sb); + } + let ds = (-f[0] * (-jb[1]) - (-jb[0]) * (-f[1])) / det; + let dt = (ja[0] * (-f[1]) - (-f[0]) * ja[1]) / det; + s += ds; + t += dt; + if ds.abs() + dt.abs() <= 1e-15 * (1. + s.abs() + t.abs()) { + break; + } + } + let ok = |x: f64, r: [f64; 2]| x.is_finite() && x >= r[0] - 1e-12 * (r[1] - r[0]) && x <= r[1] + 1e-12 * (r[1] - r[0]); + let close = (s - sa).abs() <= 0.5 * (a.range[1] - a.range[0]) && (t - sb).abs() <= 0.5 * (b.range[1] - b.range[0]); + if ok(s, a.range) && ok(t, b.range) && close { (s.clamp(a.range[0], a.range[1]), t.clamp(b.range[0], b.range[1])) } else { (sa, sb) } +} + +/// 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> { + // 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. + let mut segs: Vec<((usize, usize), Visibility, [f64; 2], [f64; 2], f64)> = Vec::new(); + for (li, l) in lines.iter().enumerate() { + for (pi, p) in l.pieces.iter().enumerate().filter(|(_, p)| p.visibility != Visibility::Uncertain) { + segs.extend(p.points.windows(2).filter_map(|w| Some(((li, pi), p.visibility, *w.first()?, *w.get(1)?, p.sag)))); + } + } + let reach = segs.iter().map(|s| s.4).fold(0., f64::max) * 2. + tol; + if segs.is_empty() { + return Ok(()); + } + // Bucket the chords on a grid of cells at least `tol` wide. + let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]); + for (_, _, a, b, _) in &segs { + for p in [a, b] { + lo = [lo[0].min(p[0]), lo[1].min(p[1])]; + hi = [hi[0].max(p[0]), hi[1].max(p[1])]; + } + } + let n = ((segs.len() as f64).sqrt().ceil() as usize).clamp(1, 1024); + let cell = [((hi[0] - lo[0]) / n as f64).max(reach), ((hi[1] - lo[1]) / n as f64).max(reach)]; + let at = |x: f64, k: usize| (((x - lo[k]) / cell[k]).floor().max(0.) as usize).min(n - 1); + let mut grid: Vec> = vec![Vec::new(); n * n]; + for (i, (_, _, a, b, _)) in segs.iter().enumerate() { + for y in at(a[1].min(b[1]) - reach, 1)..=at(a[1].max(b[1]) + reach, 1) { + for x in at(a[0].min(b[0]) - reach, 0)..=at(a[0].max(b[0]) + reach, 0) { + if let Some(c) = grid.get_mut(y * n + x) { + c.push(i); + } + } + } + } + // Which coincident pieces hide a piece: visible ones hide hidden and uncertain pieces; + // a duplicate of a visible or hidden piece on an earlier line is dropped. + let hides = |key: (usize, usize), own: Visibility, other: (usize, usize), state: Visibility| { + other != key + && match (own, state) { + (Visibility::Hidden | Visibility::Uncertain, Visibility::Visible) => true, + (Visibility::Visible, Visibility::Visible) | (Visibility::Hidden, Visibility::Hidden) => other.0 < key.0, + _ => 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 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() { + // 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); + } + for w in p.points.windows(2) { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + let k = ((a[0] - b[0]).hypot(a[1] - b[1]) / step).ceil().clamp(1., 1e6) as usize; + covered = covered + && (0..=k).all(|j| { + let f = j as f64 / k as f64; + // 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) + }); + if !covered { + break; + } + } + if let [q] = p.points.as_slice() { + covered = near((li, pi), p.visibility, *q, p.sag); + } + p.covered = covered; + } + } + Ok(()) +} + +fn point_segment(q: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 { + let (ex, ey) = (b[0] - a[0], b[1] - a[1]); + let l2 = ex * ex + ey * ey; + let t = if l2 > 0. { (((q[0] - a[0]) * ex + (q[1] - a[1]) * ey) / l2).clamp(0., 1.) } else { 0. }; + (a[0] + t * ex - q[0]).hypot(a[1] + t * ey - q[1]) +} + +/// Smallest distance between two screen segments that do not cross. +fn seg_gap(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> f64 { + point_segment(a, c, d).min(point_segment(b, c, d)).min(point_segment(c, a, b)).min(point_segment(d, a, b)) +} + +/// Intersections of two screen segments, touching included: fractions along each. +fn seg_hits(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2], tol: f64, overlaps: bool) -> Vec<(f64, f64)> { + let r = [b[0] - a[0], b[1] - a[1]]; + let s = [d[0] - c[0], d[1] - c[1]]; + let den = r[0] * s[1] - r[1] * s[0]; + let w = [c[0] - a[0], c[1] - a[1]]; + let (rl, sl) = ((r[0] * r[0] + r[1] * r[1]).sqrt(), (s[0] * s[0] + s[1] * s[1]).sqrt()); + if rl <= 0. || sl <= 0. { + return Vec::new(); + } + if den.abs() > 1e-12 * rl * sl { + let u = (w[0] * s[1] - w[1] * s[0]) / den; + let v = (w[0] * r[1] - w[1] * r[0]) / den; + let (tu, tv) = (tol / rl, tol / sl); + if u >= -tu && u <= 1. + tu && v >= -tv && v <= 1. + tv { + return vec![(u.clamp(0., 1.), v.clamp(0., 1.))]; + } + return Vec::new(); + } + // Parallel: overlapping collinear lines split at each other's endpoints. Sampled curves + // that coincide are skipped: their chords overlap everywhere and would split at every sample. + let dist = (w[0] * r[1] - w[1] * r[0]).abs() / rl; + if dist > tol || !overlaps { + return Vec::new(); + } + let along = |p: [f64; 2]| ((p[0] - a[0]) * r[0] + (p[1] - a[1]) * r[1]) / (rl * rl); + let back = |p: [f64; 2]| ((p[0] - c[0]) * s[0] + (p[1] - c[1]) * s[1]) / (sl * sl); + let mut out = Vec::new(); + for (p, on_cd) in [(c, true), (d, true), (a, false), (b, false)] { + let (u, v) = if on_cd { (along(p), if p == c { 0. } else { 1. }) } else { (if p == a { 0. } else { 1. }, back(p)) }; + if (0. ..=1.).contains(&u) && (0. ..=1.).contains(&v) { + out.push((u, v)); + } + } + out +} + +/// States at a few interior points, with transitions located by bisection. +fn classify_range(t: &Track, a: f64, b: f64, ctx: &Ctx) -> Result, HlrError> { + let fr = [0.1, 0.3, 0.5, 0.7, 0.9]; + let xs: Vec = fr.iter().map(|f| a + (b - a) * f).collect(); + let mut states = Vec::with_capacity(xs.len()); + for x in &xs { + states.push(classify(t, *x, ctx)?); + } + let mut out: Vec<([f64; 2], Visibility)> = Vec::new(); + let mut start = a; + for k in 0..xs.len() { + let (Some(&s), Some(&x)) = (states.get(k), xs.get(k)) else { continue }; + if let (Some(&next), Some(&nx)) = (states.get(k + 1), xs.get(k + 1)) { + if next != s { + ctx.transitions.set(ctx.transitions.get() + 1); + let (mut lo, mut hi) = (x, nx); + for _ in 0..40 { + let mid = 0.5 * (lo + hi); + if classify(t, mid, ctx)? == s { + lo = mid; + } else { + hi = mid; + } + } + let cut = 0.5 * (lo + hi); + out.push(([start, cut], s)); + start = cut; + } + } else { + out.push(([start, b], s)); + } + let _ = x; + } + Ok(out) +} + +fn classify(t: &Track, x: f64, ctx: &Ctx) -> Result { + let n = ctx.rays.get() + 1; + ctx.rays.set(n); + if n > ctx.max_rays { + return Err(HlrError::Budget("ray")); + } + if n.is_multiple_of(1024) && (ctx.cancel)() { + return Err(HlrError::Cancelled); + } + let own = ctx.scene.get(t.placement).ok_or(HlrError::Invalid("placement"))?; + Ok(classify_point(ctx, t.placement, &t.own, t.own_eps, own.apply(t.c3().at(x)))) +} + +/// 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 screen = ctx.view.screen(world); + let depth = world.dot(ctx.view.d); + let toward = -ctx.view.d; + let mut uncertain = false; + let mut hits: Vec = Vec::new(); + for (qi, q) in ctx.scene.iter().enumerate() { + let Some((slo, shi, dmin)) = ctx.boxes.get(qi) else { continue }; + let pad = ctx.eps_other; + if screen[0] < slo[0] - pad || screen[0] > shi[0] + pad || screen[1] < slo[1] - pad || screen[1] > shi[1] + pad || *dmin > depth { + continue; + } + let Some(def) = ctx.defs.get(q.body) else { continue }; + let (p0, dl) = (q.local(world), q.local_dir(toward)); + for (fi, f) in def.faces.iter().enumerate() { + let is_own = qi == placement && own.contains(&fi); + let Some([l0, l1]) = slab(p0, dl, f.lo, f.hi) else { continue }; + let eps = if is_own { own_eps } else { ctx.eps_other }; + let range = [l0.max(eps), l1]; + if !(range[1] >= range[0]) { + continue; + } + ctx.surface_tests.set(ctx.surface_tests.get() + 1); + hits.clear(); + f.surface.ray_as(&f.analytic, p0, dl, range, &mut hits); + for h in &hits { + let p = f.surface.point(h.uv); + let tol = 2. * f.trim.chord_error + 1e-7 * (f.hi - f.lo).len(); + // A hit on the boundary is uncertain on either side of it, so the parity test is + // left out there: its cost grows with the boundary around the hit, all of a + // circle when bodies in line along the view meet each other on their circles. + if f.trim.near(p, tol) { + uncertain = true; + continue; + } + match f.trim.contains(h.uv) { + None => uncertain = true, + Some(inside) => { + // An unsupported freeform face never hides anything for certain. + let unsure = f.freeform && !ctx.freeform; + if inside && (unsure || h.grazing || (!is_own && h.lam < ctx.eps_own)) { + uncertain = true; + } else if inside { + return Visibility::Hidden; + } + } + } + } + } + } + if uncertain { Visibility::Uncertain } else { Visibility::Visible } +} + +/// Phase timing where a clock exists. +struct Clock { + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + last: std::cell::Cell, +} + +impl Clock { + fn start() -> Clock { + Clock { + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + last: std::cell::Cell::new(std::time::Instant::now()), + } + } + fn lap(&self) -> f64 { + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + let now = std::time::Instant::now(); + let ms = now.duration_since(self.last.get()).as_secs_f64() * 1e3; + self.last.set(now); + ms + } + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + 0. + } +} + +/// Ray parameter range inside an axis-aligned box. +fn slab(p: Vec3, d: Vec3, lo: Vec3, hi: Vec3) -> Option<[f64; 2]> { + let (mut t0, mut t1) = (f64::NEG_INFINITY, f64::INFINITY); + for (o, dd, a, b) in [(p.x, d.x, lo.x, hi.x), (p.y, d.y, lo.y, hi.y), (p.z, d.z, lo.z, hi.z)] { + if dd.abs() < 1e-300 { + if o < a || o > b { + return None; + } + } else { + let (x, y) = ((a - o) / dd, (b - o) / dd); + t0 = t0.max(x.min(y)); + t1 = t1.min(x.max(y)); + } + } + (t1 >= t0.max(0.)).then_some([t0.max(0.), t1]) +} + +#[cfg(test)] +#[path = "../hlr_tests.rs"] +mod tests; diff --git a/crates/geom/src/hlr/silhouette.rs b/crates/geom/src/hlr/silhouette.rs new file mode 100644 index 0000000000..af14e13323 --- /dev/null +++ b/crates/geom/src/hlr/silhouette.rs @@ -0,0 +1,347 @@ +//! Silhouettes of curved faces: the curves where the surface normal is perpendicular to the +//! direction of sight. Recognised cylinders and cones give exact lines and spheres an exact +//! circle; other surfaces are contoured on a chart grid, each point refined onto the curve, +//! and the deviation of the chords from the curve is measured. + +use super::curve::C3; +use super::surface::{Analytic, S3}; +use super::trim::Trim; +use crate::Vec3; +use crate::section::CurveAccuracy; + +const TAU: f64 = std::f64::consts::TAU; + +pub(crate) struct Sil { + pub curve: C3, + pub acc: CurveAccuracy, + /// Parameter ranges of `curve` that lie inside the face. + pub ranges: Vec<[f64; 2]>, +} + +fn perp(a: Vec3) -> Vec3 { + a.any_perp().normalized().unwrap_or(Vec3::new(1., 0., 0.)) +} + +/// Axial extent of the faces' boundaries relative to `p` along `a`. +fn extent(parts: &[(&S3, &Trim)], p: Vec3, a: Vec3) -> (f64, f64) { + parts.iter().flat_map(|(_, trim)| trim.chords.iter()).flat_map(|(x, y)| [*x, *y]).fold((f64::MAX, f64::MIN), |(lo, hi), q| { + let t = (q - p).dot(a); + (lo.min(t), hi.max(t)) + }) +} + +/// 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) { + 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. + let slack = match acc { + 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(); + 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()); + for r in ranges { + match merged.last_mut() { + Some(last) if r[0] <= last[1] + 1e-12 * (t1 - t0) => last[1] = last[1].max(r[1]), + _ => merged.push(r), + } + } + if !merged.is_empty() { + out.push(Sil { curve, acc, ranges: merged }); + } + }; + match an { + Analytic::Plane => {} + Analytic::Cylinder { p, a, r } => { + let Some(m) = a.cross(d).normalized() else { return }; + let (t0, t1) = extent(parts, p, a); + if !(t1 > t0) { + return; + } + for side in [1., -1.] { + let base = p + m * (r * side); + push(C3::Line { a: base + a * t0, b: base + a * t1 }, CurveAccuracy::Analytic); + } + } + Analytic::Cone { apex, a, half } => { + let e1 = perp(a); + let e2 = a.cross(e1); + let (da, d1, d2) = (d.dot(a), d.dot(e1), d.dot(e2)); + let rho = (d1 * d1 + d2 * d2).sqrt(); + if rho <= 1e-12 { + return; + } + let c = half.tan() * da / rho; + if c.abs() > 1. { + return; + } + let phid = d2.atan2(d1); + let (h0, h1) = extent(parts, apex, a); + let (t0, t1) = (h0.max(0.) / half.cos(), h1.max(0.) / half.cos()); + if !(t1 > t0) { + return; + } + for sign in [1., -1.] { + let phi = phid + sign * c.acos(); + let g = a * half.cos() + (e1 * phi.cos() + e2 * phi.sin()) * half.sin(); + push(C3::Line { a: apex + g * t0, b: apex + g * t1 }, CurveAccuracy::Analytic); + } + } + Analytic::Sphere { c, r } => { + let x = perp(d); + let y = d.cross(x); + push(C3::Arc { o: c, x, y, r: [r, r], range: [0., TAU] }, CurveAccuracy::Analytic); + } + Analytic::Torus { .. } | Analytic::Other => { + // Several faces of one torus may each hold part of its silhouette: trace it on the + // whole torus then. One face keeps its own chart (finer along a narrow strip). + let whole = if parts.len() > 1 { S3::from_analytic(&an, [0., 0.], Vec3::ZERO) } else { None }; + let s = whole.as_ref().unwrap_or(s); + let traced = if matches!(s, S3::Rev(_)) { s.rev_silhouette(d) } else { contour(s, trim, d) }; + for (curve, dev) in traced { + let acc = if dev == 0. && matches!(curve, C3::Arc { .. }) { + CurveAccuracy::Analytic + } else { + CurveAccuracy::Sampled { max_sample_deviation_mm: dev } + }; + push(curve, acc); + } + } + } +} + +/// How far a face's surface may lie from the surface its silhouette set is traced on: rounding, +/// 1e-13 of the face's boundary size plus its coordinates (faces join a set when their +/// recognised surfaces agree to 1e-13). Only that close does a ray along the set's silhouette +/// leave the face within the own-face margin (1e-6 of the size): a surface `e` away is crossed +/// about `sqrt(2 R e)` along the ray. +pub(crate) fn join_tol(trim: &Trim) -> f64 { + 1e-13 * (1. + (trim.hi - trim.lo).len()) + 1e-15 * trim.lo.len().max(trim.hi.len()) +} + +/// Parameter ranges of `c` whose points are inside the face, transitions found by bisection. +/// `slack` is how far the curve itself may stand off the surface (a traced curve's chords). +pub(crate) fn inside_ranges(c: &C3, s: &S3, trim: &Trim, slack: f64) -> Vec<[f64; 2]> { + let [t0, t1] = c.domain(); + let n = 256; + let mut hint = None; + // The face is closed: a silhouette along its boundary (a seam between two faces of one + // surface, which is not drawn as an edge) is inside. Off the patch is outside: the inversion + // clamps to the chart's domain. + let tol = 2. * trim.chord_error + 1e-9 * (1. + (trim.hi - trim.lo).len()); + let on_patch = tol + 2. * join_tol(trim) + 2. * slack; + let state = |t: f64, hint: &mut Option<[f64; 2]>| -> bool { + let p = c.at(t); + let uv = s.inverse(p, *hint); + *hint = uv; + let Some(uv) = uv else { return false }; + match trim.contains(uv) { + _ if s.point(uv).dist(p) > on_patch => false, + Some(true) => true, + Some(false) => trim.near(p, tol), + None => false, + } + }; + // Uniform samples, plus the parameters nearest to the face's boundary points: an in-face + // interval is bounded by boundary crossings, so a narrow one between two uniform samples + // still gets samples on both of its sides. + let mut ts: Vec = (0..=n).map(|i| t0 + (t1 - t0) * i as f64 / n as f64).collect(); + let near = 0.02 * (trim.hi - trim.lo).len(); + ts.extend(trim.chords.iter().step_by(4).filter_map(|(q, _)| closest_param(c, *q).filter(|t| c.at(*t).dist(*q) <= near))); + ts.retain(|t| *t >= t0 && *t <= t1); + ts.sort_by(f64::total_cmp); + ts.dedup_by(|a, b| (*a - *b).abs() <= 1e-12 * (t1 - t0)); + let mut hints = Vec::with_capacity(ts.len()); + let states: Vec = ts + .iter() + .map(|t| { + let st = state(*t, &mut hint); + hints.push(hint); + st + }) + .collect(); + let mut out = Vec::new(); + let mut start: Option = None; + for i in 0..ts.len() { + let (Some(&t), Some(&inside)) = (ts.get(i), states.get(i)) else { continue }; + let prev = i.checked_sub(1).and_then(|j| states.get(j).copied()); + if prev.is_some_and(|p| p != inside) { + let (mut lo, mut hi) = (ts.get(i - 1).copied().unwrap_or(t), t); + let near = hints.get(i).copied().flatten(); + for _ in 0..50 { + let mid = 0.5 * (lo + hi); + let mut h = near; + if state(mid, &mut h) == inside { + hi = mid; + } else { + lo = mid; + } + } + let edge = 0.5 * (lo + hi); + if inside { + start = Some(edge); + } else if let Some(s0) = start.take() { + out.push([s0, edge]); + } + } else if i == 0 && inside { + start = Some(t); + } + } + if let Some(s0) = start { + out.push([s0, t1]); + } + out.retain(|r| r[1] - r[0] > 1e-12 * (t1 - t0)); + out +} + +/// The parameter of the point of `c` nearest to `p` (lines and arcs exactly, polylines at a +/// vertex), when `p` projects inside the curve's domain. +fn closest_param(c: &C3, p: Vec3) -> Option { + match c { + C3::Line { a, b } => { + let e = *b - *a; + let l2 = e.len2(); + (l2 > 0.).then(|| ((p - *a).dot(e) / l2).clamp(0., 1.)) + } + C3::Arc { o, x, y, r, range } => { + let w = p - *o; + let t = (w.dot(*y) / r[1]).atan2(w.dot(*x) / r[0]); + // The turn of t that falls in the range, if any. + let k = ((range[0] - t) / TAU).ceil(); + let t = t + k * TAU; + (t <= range[1]).then_some(t) + } + // The nearest point on any chord, not just the nearest vertex: a face narrower than + // the chords still gets parameters inside it. + C3::Polyline(pts) => pts + .windows(2) + .enumerate() + .filter_map(|(i, w)| { + let (a, b) = (*w.first()?, *w.get(1)?); + let e = b - a; + let f = if e.len2() > 0. { ((p - a).dot(e) / e.len2()).clamp(0., 1.) } else { 0. }; + Some((i as f64 + f, (a + e * f).dist(p))) + }) + .min_by(|x, y| x.1.total_cmp(&y.1)) + .map(|(t, _)| t), + C3::Spline { .. } => None, + } +} + +/// Zero set of `normal . d` on a chart grid over the face's chart box. +fn contour(s: &S3, trim: &Trim, d: Vec3) -> Vec<(C3, f64)> { + let Some((lo, hi)) = trim.uv_bounds() else { return Vec::new() }; + let dom = s.domain(); + let lo = [lo[0].max(dom[0][0]), lo[1].max(dom[1][0])]; + let hi = [hi[0].min(dom[0][1]), hi[1].min(dom[1][1])]; + if !(hi[0] > lo[0] && hi[1] > lo[1]) { + return Vec::new(); + } + let g = 128usize; + let uv = |i: usize, j: usize| [lo[0] + (hi[0] - lo[0]) * i as f64 / g as f64, lo[1] + (hi[1] - lo[1]) * j as f64 / g as f64]; + let f = |p: [f64; 2]| s.normal(p).map_or(f64::NAN, |n| n.dot(d)); + let vals: Vec = (0..=g).flat_map(|j| (0..=g).map(move |i| (i, j))).map(|(i, j)| f(uv(i, j))).collect(); + let val = |i: usize, j: usize| vals.get(j * (g + 1) + i).copied().unwrap_or(f64::NAN); + let zero = |a: [f64; 2], b: [f64; 2], fa: f64| -> [f64; 2] { + let (mut lo, mut hi, mut flo) = (a, b, fa); + for _ in 0..50 { + let m = [0.5 * (lo[0] + hi[0]), 0.5 * (lo[1] + hi[1])]; + let fm = f(m); + if fm.is_nan() { + break; + } + if (fm > 0.) == (flo > 0.) { + lo = m; + flo = fm; + } else { + hi = m; + } + } + [0.5 * (lo[0] + hi[0]), 0.5 * (lo[1] + hi[1])] + }; + // Edge ids: horizontal (i,j)-(i+1,j) = 2k, vertical (i,j)-(i,j+1) = 2k+1, k = j*(g+1)+i. + let mut points = std::collections::BTreeMap::new(); + let mut crossing = |id: usize, a: (usize, usize), b: (usize, usize)| -> Option { + let (fa, fb) = (val(a.0, a.1), val(b.0, b.1)); + if fa.is_nan() || fb.is_nan() || (fa > 0.) == (fb > 0.) { + return None; + } + points.entry(id).or_insert_with(|| zero(uv(a.0, a.1), uv(b.0, b.1), fa)); + Some(id) + }; + let mut links: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + for j in 0..g { + for i in 0..g { + let k = |i: usize, j: usize| j * (g + 1) + i; + let e = [ + crossing(2 * k(i, j), (i, j), (i + 1, j)), + crossing(2 * k(i + 1, j) + 1, (i + 1, j), (i + 1, j + 1)), + crossing(2 * k(i, j + 1), (i, j + 1), (i + 1, j + 1)), + crossing(2 * k(i, j) + 1, (i, j), (i, j + 1)), + ]; + let found: Vec = e.iter().flatten().copied().collect(); + let pairs: Vec<(usize, usize)> = match found.as_slice() { + [a, b] => vec![(*a, *b)], + [a, b, c, dd] => { + let centre = f([0.5 * (uv(i, j)[0] + uv(i + 1, j)[0]), 0.5 * (uv(i, j)[1] + uv(i, j + 1)[1])]); + if (centre > 0.) == (val(i, j) > 0.) { vec![(*a, *b), (*c, *dd)] } else { vec![(*a, *dd), (*b, *c)] } + } + _ => Vec::new(), + }; + for (a, b) in pairs { + links.entry(a).or_default().push(b); + links.entry(b).or_default().push(a); + } + } + } + // Chain the links into polylines. + let mut used = std::collections::BTreeSet::new(); + let mut chains = Vec::new(); + let starts: Vec = links.iter().filter(|(_, v)| v.len() == 1).map(|(k, _)| *k).chain(links.keys().copied()).collect(); + for s0 in starts { + if used.contains(&s0) { + continue; + } + let mut chain = vec![s0]; + used.insert(s0); + let mut cur = s0; + while let Some(next) = links.get(&cur).and_then(|v| v.iter().find(|n| !used.contains(*n)).copied()) { + used.insert(next); + chain.push(next); + cur = next; + } + // Close loops back to their start. + if chain.len() > 2 && links.get(&cur).is_some_and(|v| v.contains(&s0)) { + chain.push(s0); + } + if chain.len() >= 2 { + chains.push(chain); + } + } + let mut out = Vec::new(); + for chain in chains { + let uvs: Vec<[f64; 2]> = chain.iter().filter_map(|id| points.get(id).copied()).collect(); + let pts: Vec = uvs.iter().map(|p| s.point(*p)).collect(); + // Deviation: refine each chord's midpoint onto the curve across the chord. + let mut dev = 0f64; + for (w, q) in uvs.windows(2).zip(pts.windows(2)) { + let (Some(a), Some(b), Some(pa), Some(pb)) = (w.first(), w.get(1), q.first(), q.get(1)) else { continue }; + let m = [0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1])]; + let n = [-(b[1] - a[1]), b[0] - a[0]]; + let (x, y) = ([m[0] - n[0], m[1] - n[1]], [m[0] + n[0], m[1] + n[1]]); + let (fx, fy) = (f(x), f(y)); + if fx.is_nan() || fy.is_nan() || (fx > 0.) == (fy > 0.) { + continue; + } + let exact = s.point(zero(x, y, fx)); + dev = dev.max(exact.dist_to_segment(*pa, *pb)); + } + out.push((C3::Polyline(pts), dev)); + } + out +} diff --git a/crates/geom/src/hlr/surface.rs b/crates/geom/src/hlr/surface.rs new file mode 100644 index 0000000000..d8c3dfc766 --- /dev/null +++ b/crates/geom/src/hlr/surface.rs @@ -0,0 +1,1298 @@ +//! Surfaces of portable B-rep faces with a parameter chart, inversion, ray intersection and +//! analytic recognition. Planes are closed form; revolutions are reduced to their 2D profile; +//! spline surfaces use Newton iterations seeded from a sample grid. + +use super::HlrError; +use super::curve::{C3, as_circle, frame_axes, v3}; +use crate::Vec3; +use crate::brep::{Frame, Surface}; +use crate::bspline::{HPoint, NurbsSurface}; + +const TAU: f64 = std::f64::consts::TAU; + +#[derive(Clone, Debug)] +pub(crate) struct Rev { + o: Vec3, + pub a: Vec3, + e1: Vec3, + e2: Vec3, + profile: C3, + dom: [f64; 2], + theta: [f64; 2], + closed: bool, + /// Profile samples `[s, r, h]`. + samples: Vec<[f64; 3]>, + pub rmax: f64, + hrange: [f64; 2], +} + +#[derive(Clone, Debug)] +pub(crate) struct Nurbs { + s: NurbsSurface, + dom: [[f64; 2]; 2], + g: usize, + /// `(g+1)²` samples, u fastest. + grid: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) enum S3 { + Plane { o: Vec3, u: Vec3, v: Vec3, n: Vec3 }, + Rev(Box), + Nurbs(Box), +} + +/// What a surface is, when it is a quadric or torus regardless of its record. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Analytic { + Plane, + /// A point on the axis, unit axis, radius. + Cylinder { + p: Vec3, + a: Vec3, + r: f64, + }, + /// Apex, unit axis (opening direction), half angle. + Cone { + apex: Vec3, + a: Vec3, + half: f64, + }, + Sphere { + c: Vec3, + r: f64, + }, + Torus { + c: Vec3, + a: Vec3, + major: f64, + minor: f64, + }, + Other, +} + +impl Analytic { + /// Distance from `q` to the whole surface (a cone's nappe near `q`); infinite for planes + /// and other surfaces, which are not checked this way. + fn distance(&self, q: Vec3) -> f64 { + let rh = |o: Vec3, a: Vec3| { + let w = q - o; + let h = w.dot(a); + ((w - a * h).len(), h) + }; + match *self { + Analytic::Cylinder { p, a, r } => (rh(p, a).0 - r).abs(), + Analytic::Cone { apex, a, half } => { + let (rho, h) = rh(apex, a); + (rho * half.cos() - h * half.sin()).abs() + } + Analytic::Sphere { c, r } => (q.dist(c) - r).abs(), + Analytic::Torus { c, a, major, minor } => { + let (rho, h) = rh(c, a); + ((rho - major).hypot(h) - minor).abs() + } + Analytic::Plane | Analytic::Other => f64::INFINITY, + } + } + + /// The degree of the surface's implicit equation: 2 for quadrics, 4 for a torus. + fn implicit_degree(&self) -> usize { + match self { + Analytic::Torus { .. } => 4, + _ => 2, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct Hit { + pub lam: f64, + pub uv: [f64; 2], + pub grazing: bool, +} + +fn perp(a: Vec3) -> Vec3 { + a.any_perp().normalized().unwrap_or(Vec3::new(1., 0., 0.)) +} + +impl S3 { + pub fn new(s: &Surface) -> Result { + Ok(match s { + Surface::Plane { origin, u_axis, v_axis } => { + let (u, v) = (v3(*u_axis), v3(*v_axis)); + let n = u.cross(v).normalized().ok_or(HlrError::Unsupported("degenerate plane"))?; + S3::Plane { o: v3(*origin), u, v, n } + } + // The plane through three points: parameter (1, 0) at `u_point`, (0, 1) at `v_point`. + Surface::PlaneThrough { origin, u_point, v_point } => { + let o = v3(*origin); + let (u, v) = (v3(*u_point) - o, v3(*v_point) - o); + let n = u.cross(v).normalized().ok_or(HlrError::Unsupported("degenerate plane"))?; + S3::Plane { o, u, v, n } + } + // A revolution mapped by an affine placement, which may scale it unevenly. + Surface::PlacedRevolution { .. } => return Err(HlrError::Unsupported("placed revolution surface")), + Surface::Spline { u_knots, v_knots, points } => { + let h = points.iter().map(|r| r.iter().map(|p| [p[0], p[1], p[2], 1.]).collect()).collect::>>(); + S3::Nurbs(Box::new(Nurbs::new(u_knots, v_knots, &h)?)) + } + Surface::RationalSpline { u_knots, v_knots, homogeneous } => S3::Nurbs(Box::new(Nurbs::new(u_knots, v_knots, homogeneous)?)), + Surface::Revolution { profile, origin, axis, angle } => { + let (c, _) = C3::new(profile)?; + S3::Rev(Box::new(Rev::new(v3(*origin), v3(*axis), None, c, *angle)?)) + } + Surface::Cylinder { frame, radius, angle, height } => { + let (o, x, _, z) = axes(frame); + let line = C3::Line { a: o + x * *radius + z * height[0], b: o + x * *radius + z * height[1] }; + S3::Rev(Box::new(Rev::new(o, z, Some(x), line, *angle)?)) + } + Surface::Cone { frame, radius_at_origin, half_angle, angle, height } => { + let (o, x, _, z) = axes(frame); + let r = |h: f64| radius_at_origin + h * half_angle.tan(); + let line = C3::Line { a: o + x * r(height[0]) + z * height[0], b: o + x * r(height[1]) + z * height[1] }; + S3::Rev(Box::new(Rev::new(o, z, Some(x), line, *angle)?)) + } + Surface::Sphere { frame, radius, longitude, latitude } => { + let (o, x, _, z) = axes(frame); + let arc = C3::Arc { o, x, y: z, r: [*radius, *radius], range: *latitude }; + S3::Rev(Box::new(Rev::new(o, z, Some(x), arc, *longitude)?)) + } + Surface::Torus { frame, major_radius, minor_radius, longitude, meridian } => { + let (o, x, _, z) = axes(frame); + let arc = C3::Arc { o: o + x * *major_radius, x, y: z, r: [*minor_radius, *minor_radius], range: *meridian }; + S3::Rev(Box::new(Rev::new(o, z, Some(x), arc, *longitude)?)) + } + }) + } + + /// The recognised quadric or torus as a revolution chart: closed-form inversion and exact + /// points for trimming, whatever record (spline, rational spline) the face was stored as. + /// `h` bounds the axial extent of cylinders and cones (the face's boundary along the axis); + /// `toward` points from a sphere's centre to the face. + pub fn from_analytic(an: &Analytic, h: [f64; 2], toward: Vec3) -> Option { + let rev = match *an { + Analytic::Cylinder { p, a, r } => { + let e1 = perp(a); + Rev::new(p, a, Some(e1), C3::Line { a: p + e1 * r + a * h[0], b: p + e1 * r + a * h[1] }, [0., TAU]) + } + Analytic::Cone { apex, a, half } => { + let e1 = perp(a); + let at = |x: f64| apex + a * x + e1 * (x * half.tan()); + Rev::new(apex, a, Some(e1), C3::Line { a: at(h[0].max(0.)), b: at(h[1].max(0.)) }, [0., TAU]) + } + Analytic::Sphere { c, r } => { + // An axis square to the patch's direction keeps the poles off the patch. + let a = perp(toward); + let e1 = toward.normalized().unwrap_or_else(|| perp(a)); + let half = std::f64::consts::FRAC_PI_2; + Rev::new(c, a, Some(e1), C3::Arc { o: c, x: e1, y: a, r: [r, r], range: [-half, half] }, [0., TAU]) + } + Analytic::Torus { c, a, major, minor } => { + let e1 = perp(a); + Rev::new(c, a, Some(e1), C3::Arc { o: c + e1 * major, x: e1, y: a, r: [minor, minor], range: [0., TAU] }, [0., TAU]) + } + Analytic::Plane | Analytic::Other => return None, + }; + rev.ok().map(|r| S3::Rev(Box::new(r))) + } + + /// A box that contains the whole surface for certain: the control-point hull of a spline + /// (weights are positive), the turned profile's hull for a revolution; `None` for planes. + pub fn bound(&self) -> Option<(Vec3, Vec3)> { + let hull = |pts: &mut dyn Iterator| { + pts.fold(None, |acc: Option<(Vec3, Vec3)>, p| Some(acc.map_or((p, p), |(lo, hi)| (lo.min(p), hi.max(p))))) + }; + match self { + S3::Plane { .. } => None, + S3::Nurbs(n) => hull(&mut n.s.homogeneous().iter().filter_map(|h| h.project())), + S3::Rev(r) => { + // Radial distance is convex, so its maximum over the profile's hull is at a vertex. + let pts: Vec = match &r.profile { + C3::Line { a, b } => vec![*a, *b], + C3::Polyline(p) => p.clone(), + C3::Spline { points, .. } => { + points.iter().filter(|h| h[3] > 0.).map(|h| Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3])).collect() + } + C3::Arc { o, x, y, r: rad, .. } => { + // The whole circle's bounding square in its plane. + let m = rad[0].max(rad[1]); + vec![*o + *x * m + *y * m, *o + *x * m - *y * m, *o - *x * m + *y * m, *o - *x * m - *y * m] + } + }; + let (mut rmax, mut h0, mut h1) = (0f64, f64::MAX, f64::MIN); + for p in &pts { + let w = *p - r.o; + let h = w.dot(r.a); + rmax = rmax.max((w - r.a * h).len()); + h0 = h0.min(h); + h1 = h1.max(h); + } + if !(h1 >= h0) { + return None; + } + let (c0, c1) = (r.o + r.a * h0, r.o + r.a * h1); + let ext = |k: f64| rmax * (1. - k * k).max(0.).sqrt(); + let e = Vec3::new(ext(r.a.x), ext(r.a.y), ext(r.a.z)); + Some((c0.min(c1) - e, c0.max(c1) + e)) + } + } + } + + /// Silhouette points of a revolution for direction `d`, traced along the profile: at each + /// profile parameter the normal is perpendicular to `d` at up to two angles. Returns + /// polylines of exact points with their chord deviation, and exact circles when `d` is the axis. + pub fn rev_silhouette(&self, d: Vec3) -> Vec<(C3, f64)> { + let S3::Rev(r) = self else { return Vec::new() }; + r.silhouette(d) + } + + /// A spline patch whose control points all lie in one plane (to `tol` of its size) as that + /// plane; the convex hull property puts the whole patch in it. + pub fn flat(&self, tol: f64) -> Option { + let S3::Nurbs(n) = self else { return None }; + let pts: Vec = n.s.homogeneous().iter().filter_map(|h| h.project()).collect(); + let o = *pts.first()?; + let far = |key: &dyn Fn(Vec3) -> f64| pts.iter().copied().max_by(|a, b| key(*a).total_cmp(&key(*b))); + let p1 = far(&|p| p.dist(o))?; + let u = (p1 - o).normalized()?; + let p2 = far(&|p| (p - o).cross(u).len())?; + let nrm = u.cross(p2 - o).normalized()?; + let size = (p1 - o).len(); + pts.iter().all(|p| (*p - o).dot(nrm).abs() <= tol * size).then(|| S3::Plane { o, u, v: nrm.cross(u), n: nrm }) + } + + pub fn domain(&self) -> [[f64; 2]; 2] { + match self { + S3::Plane { .. } => [[f64::NEG_INFINITY, f64::INFINITY]; 2], + S3::Rev(r) => [r.theta, r.dom], + S3::Nurbs(n) => n.dom, + } + } + + pub fn periods(&self) -> [Option; 2] { + match self { + S3::Rev(r) => [((r.theta[1] - r.theta[0]) >= TAU - 1e-9).then_some(TAU), r.closed.then_some(r.dom[1] - r.dom[0])], + _ => [None, None], + } + } + + pub fn point(&self, uv: [f64; 2]) -> Vec3 { + match self { + S3::Plane { o, u, v, .. } => *o + *u * uv[0] + *v * uv[1], + S3::Rev(r) => r.point(uv), + S3::Nurbs(n) => n.point(uv), + } + } + + pub fn partials(&self, uv: [f64; 2]) -> (Vec3, Vec3) { + match self { + S3::Plane { u, v, .. } => (*u, *v), + _ => { + let d = self.domain(); + let fd = |k: usize| { + let span = d[k][1] - d[k][0]; + let h = span * 1e-6; + let lo = if self.periods()[k].is_some() { uv[k] - h } else { (uv[k] - h).max(d[k][0]) }; + let hi = if self.periods()[k].is_some() { uv[k] + h } else { (uv[k] + h).min(d[k][1]) }; + let (mut a, mut b) = (uv, uv); + a[k] = lo; + b[k] = hi; + if hi - lo <= 0. { Vec3::ZERO } else { (self.point(b) - self.point(a)) / (hi - lo) } + }; + (fd(0), fd(1)) + } + } + } + + /// The chart normal `Su x Sv`, unit, or `None` at a singular point. + pub fn normal(&self, uv: [f64; 2]) -> Option { + match self { + S3::Plane { n, .. } => Some(*n), + _ => { + let (su, sv) = self.partials(uv); + su.cross(sv).normalized() + } + } + } + + /// Chart coordinates of a point on (or very near) the surface. `hint` guides spline + /// Newton iterations and the choice of periodic branch. + pub fn inverse(&self, p: Vec3, hint: Option<[f64; 2]>) -> Option<[f64; 2]> { + match self { + S3::Plane { o, u, v, .. } => { + let w = p - *o; + let (uu, uv, vv) = (u.dot(*u), u.dot(*v), v.dot(*v)); + let det = uu * vv - uv * uv; + if det.abs() <= 1e-300 { + return None; + } + let (bu, bv) = (w.dot(*u), w.dot(*v)); + Some([(bu * vv - bv * uv) / det, (bv * uu - bu * uv) / det]) + } + S3::Rev(r) => r.inverse(p, hint), + S3::Nurbs(n) => n.inverse(p, hint), + } + } + + /// Ray hits along unit `d` from `p0` with `lam` in `range` (the face's box along the ray). + pub fn ray(&self, p0: Vec3, d: Vec3, range: [f64; 2], out: &mut Vec) { + match self { + S3::Plane { n, .. } => { + let nd = n.dot(d); + if nd.abs() < 1e-12 { + return; + } + let lam = n.dot(self.point([0., 0.]) - p0) / nd; + if lam >= range[0] + && lam <= range[1] + && let Some(uv) = self.inverse(p0 + d * lam, None) + { + out.push(Hit { lam, uv, grazing: nd.abs() < 1e-6 }); + } + } + S3::Rev(r) => r.ray(p0, d, range, out), + S3::Nurbs(n) => n.ray(p0, d, range, out), + } + } + + /// Ray hits using the recognised analytic form when there is one (closed form for + /// quadrics, a sampled quartic for tori), else the record's own intersection. + pub fn ray_as(&self, an: &Analytic, p0: Vec3, d: Vec3, range: [f64; 2], out: &mut Vec) { + let lams = match *an { + Analytic::Cylinder { p, a, r } => { + let (w, v) = (p0 - p, d - a * d.dot(a)); + let w = w - a * w.dot(a); + quadratic(v.dot(v), 2. * v.dot(w), w.dot(w) - r * r) + } + Analytic::Cone { apex, a, half } => { + let w = p0 - apex; + let c2 = half.cos().powi(2); + let (da, wa) = (d.dot(a), w.dot(a)); + let roots = quadratic(da * da - c2, 2. * (da * wa - c2 * d.dot(w)), wa * wa - c2 * w.dot(w)); + roots.into_iter().filter(|(l, _)| (w + d * *l).dot(a) >= 0.).collect() + } + Analytic::Sphere { c, r } => { + let w = p0 - c; + quadratic(1., 2. * d.dot(w), w.dot(w) - r * r) + } + Analytic::Torus { c, a, major, minor } => torus_roots(p0 - c, d, a, major, minor, range), + Analytic::Plane | Analytic::Other => return self.ray(p0, d, range, out), + }; + for (lam, grazing) in lams { + if lam >= range[0] && lam <= range[1] { + // The analytic surface extends past this record's domain: keep hits on it only. + let p = p0 + d * lam; + if let Some(uv) = self.inverse(p, None).filter(|uv| self.point(*uv).dist(p) <= 1e-7 * (1. + p.len())) { + out.push(Hit { lam, uv, grazing }); + } + } + } + } + + /// Which analytic surface this is, checked at sample points to `tol` relative to its size. + pub fn analytic(&self, tol: f64) -> Analytic { + match self { + S3::Plane { .. } => Analytic::Plane, + S3::Rev(r) => r.analytic(tol), + S3::Nurbs(n) => n.analytic(tol), + } + } +} + +/// Real roots of `a x^2 + b x + c`, a double root flagged as grazing. +pub(crate) fn quadratic(a: f64, b: f64, c: f64) -> Vec<(f64, bool)> { + let scale = b * b + (a * c).abs(); + if a.abs() <= 1e-14 * (1. + b.abs()) { + return if b.abs() > 1e-300 { vec![(-c / b, false)] } else { Vec::new() }; + } + let disc = b * b - 4. * a * c; + if disc < -1e-12 * scale { + return Vec::new(); + } + if disc <= 1e-12 * scale { + return vec![(-b / (2. * a), true)]; + } + // Numerically stable pair. + let q = -0.5 * (b + b.signum() * disc.sqrt()); + vec![(q / a, false), (c / q, false)] +} + +/// Roots of the torus implicit function along a ray, by sampling and bisection; dips that +/// touch zero without a sign change are grazing. +pub(crate) fn torus_roots(w: Vec3, d: Vec3, a: Vec3, big: f64, small: f64, range: [f64; 2]) -> Vec<(f64, bool)> { + // Signed distance from the tube: distance to the centre circle less the tube radius, + // computed directly (no quartic), so a thin tube on a large ring keeps its precision. + let g = |l: f64| { + let p = w + d * l; + let z = p.dot(a); + let rho = (p - a * z).len(); + (rho - big).hypot(z) - small + }; + // Sample only where the ray is inside the shell |z| <= r, R - r <= rho <= R + r, in steps + // of a quarter of the tube radius: the shell is short along any ray, so the steps are few. + // The shell is padded: a crossing exactly on its surface (rho = R + r at z = 0, say) must + // fall strictly inside a sampled piece. + let m = 0.5 * small + 1e-9 * big; + let (z0, dz) = (w.dot(a), d.dot(a)); + let mut span = range; + if dz.abs() > 1e-300 { + let (l0, l1) = ((-small - m - z0) / dz, (small + m - z0) / dz); + span = [span[0].max(l0.min(l1)), span[1].min(l0.max(l1))]; + } else if z0.abs() > small + m { + return Vec::new(); + } + // rho^2 along the ray: |w_perp + l d_perp|^2. + let (wp, dp) = (w - a * z0, d - a * dz); + let (qa, qb, qc) = (dp.dot(dp), 2. * wp.dot(dp), wp.dot(wp)); + let within = |r2: f64| -> Option<[f64; 2]> { + // Where rho^2 <= r2. + if qa <= 1e-300 { + return (qc <= r2).then_some([f64::NEG_INFINITY, f64::INFINITY]); + } + let disc = qb * qb - 4. * qa * (qc - r2); + (disc >= 0.).then(|| [(-qb - disc.sqrt()) / (2. * qa), (-qb + disc.sqrt()) / (2. * qa)]) + }; + let Some(outer) = within((big + small + m).powi(2)) else { return Vec::new() }; + span = [span[0].max(outer[0]), span[1].min(outer[1])]; + if !(span[1] > span[0]) { + return Vec::new(); + } + // Remove the inside of the inner radius, leaving up to two pieces. + let mut pieces = vec![span]; + if big > small + m + && let Some(inner) = within((big - small - m).powi(2)) + { + pieces = vec![[span[0], span[1].min(inner[0])], [span[0].max(inner[1]), span[1]]]; + } + // |g| is 1-Lipschitz along the ray: two samples whose values share a sign and add up to + // more than their distance cannot have a crossing between them. Other pairs are halved down + // to a thousandth of the tube radius, so a shallow cut through the tube (a chord shorter than + // the sample step) is still found. + let fine = 1e-3 * small; + let mut out = Vec::new(); + for [s0, s1] in pieces.into_iter().filter(|p| p[1] > p[0]) { + let n = ((s1 - s0) / (0.25 * small)).ceil().clamp(8., 1_000_000.) as usize; + let at = |i: usize| s0 + (s1 - s0) * i as f64 / n as f64; + let mut stack: Vec<(f64, f64, f64, f64)> = Vec::new(); + let vals: Vec = (0..=n).map(|i| g(at(i))).collect(); + for i in 0..n { + if let (Some(&ga), Some(&gb)) = (vals.get(i), vals.get(i + 1)) { + stack.push((at(i), at(i + 1), ga, gb)); + } + } + stack.reverse(); + let mut budget = 1_000_000usize; + while let Some((lo0, hi0, ga, gb)) = stack.pop() { + budget = budget.saturating_sub(1); + if budget == 0 { + break; + } + if ga != 0. && gb != 0. && ga.signum() != gb.signum() { + let (mut lo, mut hi, mut glo) = (lo0, hi0, ga); + for _ in 0..80 { + let mid = 0.5 * (lo + hi); + let gm = g(mid); + if gm.signum() == glo.signum() { + lo = mid; + glo = gm; + } else { + hi = mid; + } + } + out.push((0.5 * (lo + hi), false)); + continue; + } + if ga.abs() + gb.abs() > hi0 - lo0 { + continue; + } + if hi0 - lo0 <= fine { + // Close to the tube without crossing it: a touch when it is a dip inside the shell. + let m = 0.5 * (lo0 + hi0); + let gm = g(m); + if gm > 0. && gm <= ga && gm <= gb && lo0 > s0 && hi0 < s1 && gm < 1e-4 * small.max(1e-9 * big) { + out.push((m, true)); + } + continue; + } + let m = 0.5 * (lo0 + hi0); + let gm = g(m); + stack.push((m, hi0, gm, gb)); + stack.push((lo0, m, ga, gm)); + } + } + out.sort_by(|x, y| x.0.total_cmp(&y.0)); + out.dedup_by(|x, y| (x.0 - y.0).abs() <= fine && x.1 == y.1); + out +} + +fn axes(f: &Frame) -> (Vec3, Vec3, Vec3, Vec3) { + let (o, x, y) = frame_axes(f); + (o, x, y, x.cross(y)) +} + +impl Rev { + fn new(o: Vec3, axis: Vec3, e1: Option, profile: C3, angle: [f64; 2]) -> Result { + let a = axis.normalized().ok_or(HlrError::Unsupported("zero revolution axis"))?; + let dom = profile.domain(); + let e1 = e1.unwrap_or_else(|| { + let w = profile.at(dom[0]) - o; + (w - a * w.dot(a)).normalized().unwrap_or_else(|| perp(a)) + }); + let e2 = a.cross(e1); + let closed = profile.at(dom[0]).dist(profile.at(dom[1])) <= 1e-9 * (1. + profile.at(dom[0]).dist(o)); + let n = 512; + let mut samples = Vec::with_capacity(n + 1); + let (mut rmax, mut hlo, mut hhi) = (0f64, f64::MAX, f64::MIN); + for i in 0..=n { + let s = dom[0] + (dom[1] - dom[0]) * i as f64 / n as f64; + let w = profile.at(s) - o; + let h = w.dot(a); + let r = (w - a * h).len(); + rmax = rmax.max(r); + hlo = hlo.min(h); + hhi = hhi.max(h); + samples.push([s, r, h]); + } + if !(rmax.is_finite() && hlo.is_finite() && hhi.is_finite()) { + return Err(HlrError::Unsupported("non-finite revolution profile")); + } + // Chart theta is measured from e1; a full turn is [0, 2pi). + let theta = if angle[1] - angle[0] >= TAU - 1e-9 { [0., TAU] } else { angle }; + Ok(Rev { o, a, e1, e2, profile, dom, theta, closed, samples, rmax, hrange: [hlo, hhi] }) + } + + fn rh(&self, s: f64) -> [f64; 2] { + let w = self.profile.at(s) - self.o; + let h = w.dot(self.a); + [(w - self.a * h).len(), h] + } + + fn rh_d(&self, s: f64) -> [f64; 2] { + // Exact for line and arc profiles: a one-sided difference at the domain's ends misses + // zeros that sit there (a half torus's equators). A difference for the rest. + let dw = match &self.profile { + C3::Line { a, b } => Some(*b - *a), + C3::Arc { x, y, r, .. } => Some(*x * (-r[0] * s.sin()) + *y * (r[1] * s.cos())), + _ => None, + }; + if let Some(dw) = dw { + let w = self.profile.at(s) - self.o; + let radial = w - self.a * w.dot(self.a); + let r = radial.len(); + if r > 1e-12 * (1. + self.rmax) { + return [radial.dot(dw) / r, dw.dot(self.a)]; + } + } + let h = (self.dom[1] - self.dom[0]) * 1e-7; + let (a, b) = ((s - h).max(self.dom[0]), (s + h).min(self.dom[1])); + if b - a <= 0. { + return [0., 0.]; + } + let (p, q) = (self.rh(a), self.rh(b)); + [(q[0] - p[0]) / (b - a), (q[1] - p[1]) / (b - a)] + } + + fn point(&self, uv: [f64; 2]) -> Vec3 { + let [r, h] = self.rh(uv[1]); + self.o + self.a * h + (self.e1 * uv[0].cos() + self.e2 * uv[0].sin()) * r + } + + /// Profile parameters where component `k` of the profile's (r, h) tangent vanishes: sign + /// changes between samples, refined by bisection, exact zeros at samples, and for a closed + /// profile the change across the seam where its ends meet. + fn roots(&self, ss: &[f64], k: usize) -> Vec { + let f = |s: f64| self.rh_d(s).get(k).copied().unwrap_or(0.); + let vals: Vec = ss.iter().map(|s| f(*s)).collect(); + let scale = vals.iter().fold(0f64, |m, v| m.max(v.abs())).max(1e-300); + let zero = |v: f64| v.abs() <= 1e-12 * scale; + let mut out: Vec = Vec::new(); + let mut pairs: Vec<(usize, usize)> = (0..ss.len().saturating_sub(1)).map(|i| (i, i + 1)).collect(); + if self.closed && ss.len() > 2 { + // The last sample repeats the first; the pair across the seam is (n-1, 1). + pairs.push((ss.len() - 2, 1)); + } + for (i, sv) in vals.iter().enumerate() { + if zero(*sv) + && !(self.closed && i + 1 == vals.len()) + && let Some(s0) = ss.get(i) + { + out.push(*s0); + } + } + for (i, j) in pairs { + let (Some(&a), Some(&b), Some(&sa), Some(&sb)) = (vals.get(i), vals.get(j), ss.get(i), ss.get(j)) else { continue }; + if zero(a) || zero(b) || a.signum() == b.signum() { + continue; + } + // Across the seam, walk sb forward a period so the interval is increasing. + let sb = if sb < sa { sb + (self.dom[1] - self.dom[0]) } else { sb }; + let wrap = |x: f64| if x > self.dom[1] { x - (self.dom[1] - self.dom[0]) } else { x }; + let (mut lo, mut hi, mut flo) = (sa, sb, a); + for _ in 0..60 { + let mid = 0.5 * (lo + hi); + let fm = f(wrap(mid)); + if fm.signum() == flo.signum() && !zero(fm) { + lo = mid; + flo = fm; + } else { + hi = mid; + } + } + out.push(wrap(0.5 * (lo + hi))); + } + out.sort_by(f64::total_cmp); + let span = self.dom[1] - self.dom[0]; + out.dedup_by(|x, y| (*x - *y).abs() <= 1e-9 * span); + // A closed profile's start and end are one point: one root there, not two. + if self.closed + && let (Some(&first), Some(&last)) = (out.first(), out.last()) + && out.len() > 1 + && (first + span - last).abs() <= 1e-9 * span + { + out.pop(); + } + out + } + + /// cos(theta - phi) = dr (a.d) / (dh rho) along the profile; see `S3::rev_silhouette`. + fn silhouette(&self, d: Vec3) -> Vec<(C3, f64)> { + let (d1, d2, da) = (d.dot(self.e1), d.dot(self.e2), d.dot(self.a)); + let rho = (d1 * d1 + d2 * d2).sqrt(); + let phi = d2.atan2(d1); + let n = 720usize; + let mut ss: Vec = (0..=n).map(|i| self.dom[0] + (self.dom[1] - self.dom[0]) * i as f64 / n as f64).collect(); + let mut out = Vec::new(); + if rho > 1e-12 { + // The silhouette exists where |dr (a.d)| <= |dh| rho; near the axis direction those + // intervals shrink around the zeros of dr, which are sampled so none is stepped over. + let extra = self.roots(&ss, 0); + ss.extend(extra); + ss.sort_by(f64::total_cmp); + ss.dedup_by(|x, y| (*x - *y).abs() <= 1e-15 * (self.dom[1] - self.dom[0])); + } + if rho <= 1e-12 { + // Along the axis: circles where the profile runs parallel to the axis (dr = 0). + for s0 in self.roots(&ss, 0) { + let [r, h] = self.rh(s0); + if r > 1e-12 { + out.push((C3::Arc { o: self.o + self.a * h, x: self.e1, y: self.e2, r: [r, r], range: [0., TAU] }, 0.)); + } + } + return out; + } + if da.abs() <= 1e-12 { + // Square to the axis: where the profile runs radially (dh = 0) the normal is the + // axis, square to d for every angle, so the whole parallel circle is a silhouette. + for s0 in self.roots(&ss, 1) { + let [r, h] = self.rh(s0); + if r > 1e-12 { + out.push((C3::Arc { o: self.o + self.a * h, x: self.e1, y: self.e2, r: [r, r], range: [0., TAU] }, 0.)); + } + } + } + let c = |s: f64| { + let t = self.rh_d(s); + if t[1].abs() <= 1e-300 { f64::INFINITY } else { t[0] * da / (t[1] * rho) } + }; + let at = |sign: f64, s: f64| self.point([phi + sign * c(s).clamp(-1., 1.).acos(), s]); + // The parameter between a (|c| <= 1) and b (|c| > 1) where |c| = 1. + let edge = |a: f64, b: f64| { + let (mut lo, mut hi) = (a, b); + for _ in 0..60 { + let mid = 0.5 * (lo + hi); + if c(mid).abs() <= 1. { + lo = mid; + } else { + hi = mid; + } + } + lo + }; + // Chords are halved where their middle is further than this from the curve; the angle + // moves fastest where a branch starts or ends, so uniform samples alone are coarse there. + let target = 1e-7 * (1. + self.rmax + (self.hrange[1] - self.hrange[0])); + // One polyline per branch while |c| <= 1. + for sign in [1., -1.] { + let mut run: Vec = Vec::new(); + let flush = |run: &mut Vec, out: &mut Vec<(C3, f64)>| { + if run.len() >= 2 { + for _ in 0..40 { + let mut next = Vec::with_capacity(run.len() * 2); + let mut split = false; + for w in run.windows(2) { + let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue }; + next.push(a); + let m = 0.5 * (a + b); + if next.len() < 1 << 16 && at(sign, m).dist_to_segment(at(sign, a), at(sign, b)) > target { + next.push(m); + split = true; + } + } + if let Some(&last) = run.last() { + next.push(last); + } + *run = next; + if !split { + break; + } + } + let pts: Vec = run.iter().map(|s| at(sign, *s)).collect(); + let mut dev = 0f64; + for (w, q) in run.windows(2).zip(pts.windows(2)) { + let (Some(a), Some(b), Some(pa), Some(pb)) = (w.first(), w.get(1), q.first(), q.get(1)) else { continue }; + dev = dev.max(at(sign, 0.5 * (a + b)).dist_to_segment(*pa, *pb)); + } + out.push((C3::Polyline(pts), dev)); + } + run.clear(); + }; + for i in 0..ss.len() { + let Some(&s) = ss.get(i) else { continue }; + let inside = c(s).abs() <= 1.; + let prev = i.checked_sub(1).and_then(|j| ss.get(j).copied()); + match (inside, prev.map(|p| c(p).abs() <= 1.)) { + (true, Some(false)) => { + if let Some(p) = prev { + run.push(edge(s, p)); + } + run.push(s); + } + (true, _) => run.push(s), + (false, Some(true)) => { + if let Some(p) = prev { + run.push(edge(p, s)); + } + flush(&mut run, &mut out); + } + (false, _) => {} + } + } + flush(&mut run, &mut out); + } + out + } + + /// Closest profile parameter to `(r, h)`, with the squared distance. + fn closest(&self, q: [f64; 2]) -> (f64, f64) { + if let C3::Line { .. } = self.profile { + let (p0, p1) = (self.rh(self.dom[0]), self.rh(self.dom[1])); + let e = [p1[0] - p0[0], p1[1] - p0[1]]; + let l2 = e[0] * e[0] + e[1] * e[1]; + let t = if l2 > 0. { (((q[0] - p0[0]) * e[0] + (q[1] - p0[1]) * e[1]) / l2).clamp(0., 1.) } else { 0. }; + let s = self.dom[0] + (self.dom[1] - self.dom[0]) * t; + let p = self.rh(s); + return (s, (p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)); + } + // Coarse scan, then the finer samples around the best coarse one. + let mut best = (0usize, f64::MAX); + for (i, smp) in self.samples.iter().enumerate().step_by(8) { + let d2 = (smp[1] - q[0]).powi(2) + (smp[2] - q[1]).powi(2); + if d2 < best.1 { + best = (i, d2); + } + } + let mut best = (self.samples.get(best.0).map_or(self.dom[0], |s| s[0]), best.1, best.0); + for i in best.2.saturating_sub(8)..(best.2 + 9).min(self.samples.len()) { + if let Some(smp) = self.samples.get(i) { + let d2 = (smp[1] - q[0]).powi(2) + (smp[2] - q[1]).powi(2); + if d2 < best.1 { + best = (smp[0], d2, i); + } + } + } + let best = (best.0, best.1); + let mut s = best.0; + for _ in 0..30 { + let p = self.rh(s); + let d = self.rh_d(s); + let f = (p[0] - q[0]) * d[0] + (p[1] - q[1]) * d[1]; + let fp = d[0] * d[0] + d[1] * d[1]; + if fp <= 1e-300 { + break; + } + let next = (s - f / fp).clamp(self.dom[0], self.dom[1]); + if (next - s).abs() <= 1e-15 * (1. + s.abs()) { + s = next; + break; + } + s = next; + } + let p = self.rh(s); + (s, (p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)) + } + + fn inverse(&self, p: Vec3, hint: Option<[f64; 2]>) -> Option<[f64; 2]> { + let w = p - self.o; + let h = w.dot(self.a); + let radial = w - self.a * h; + let r = radial.len(); + let theta = if r > 1e-12 * (1. + self.rmax) { radial.dot(self.e2).atan2(radial.dot(self.e1)) } else { hint.map_or(0., |u| u[0]) }; + let (s, _) = self.closest([r, h]); + let mut t = theta; + if self.theta[1] - self.theta[0] >= TAU - 1e-9 { + t = t.rem_euclid(TAU); + } else if t < self.theta[0] { + t += TAU; + } + Some([t, s]) + } + + /// Signed distance of `(r, h)` from the profile, or `None` beyond an open profile's ends. + fn signed(&self, q: [f64; 2]) -> Option { + let (s, d2) = self.closest(q); + let at_end = !self.closed && ((s - self.dom[0]).abs() <= 1e-12 * (1. + s.abs()) || (s - self.dom[1]).abs() <= 1e-12 * (1. + s.abs())); + let p = self.rh(s); + let t = self.rh_d(s); + if at_end { + // Beyond the end the normal distance is undefined unless q projects onto it. + let along = (q[0] - p[0]) * t[0] + (q[1] - p[1]) * t[1]; + let outward = if (s - self.dom[0]).abs() < (s - self.dom[1]).abs() { -along } else { along }; + if outward > 1e-12 * (1. + self.rmax) { + return None; + } + } + let cross = t[0] * (q[1] - p[1]) - t[1] * (q[0] - p[0]); + Some(cross.signum() * d2.sqrt()) + } + + fn ray(&self, p0: Vec3, d: Vec3, range: [f64; 2], out: &mut Vec) { + let rh = |lam: f64| { + let w = p0 + d * lam - self.o; + let h = w.dot(self.a); + [(w - self.a * h).len(), h] + }; + // Clip the ray to the bounding cylinder, coarsely, by sampling. + let pad = 1e-6 * (1. + self.rmax + (self.hrange[1] - self.hrange[0])); + let n = 400usize; + let f = |lam: f64| { + let q = rh(lam); + if q[0] > self.rmax + pad || q[1] < self.hrange[0] - pad || q[1] > self.hrange[1] + pad { None } else { self.signed(q) } + }; + // Values within the noise floor carry no sign (a ray tangent at its start, say). + let floor = 1e-9 * (1. + self.rmax + (self.hrange[1] - self.hrange[0])); + let mut prev: Option<(f64, f64)> = None; + let mut minima: Vec<(f64, f64)> = Vec::new(); + let mut last: Option = None; + let mut falling = false; + let at = |i: usize| range[0] + (range[1] - range[0]) * i as f64 / n as f64; + for i in 0..=n { + let lam = at(i); + let v = f(lam); + match (prev, v) { + (Some((la, fa)), Some(fb)) if fa.abs() > floor && fb.abs() > floor && fa.signum() != fb.signum() => { + let (mut lo, mut hi, mut flo) = (la, lam, fa); + for _ in 0..80 { + let mid = 0.5 * (lo + hi); + match f(mid) { + Some(fm) if fm.signum() == flo.signum() => { + lo = mid; + flo = fm; + } + Some(_) => hi = mid, + None => break, + } + } + let lam = 0.5 * (lo + hi); + if lam >= range[0] + && let Some(uv) = self.inverse(p0 + d * lam, None) + { + out.push(Hit { lam, uv, grazing: false }); + } + } + _ => {} + } + // Interior dips only: the range's first sample is never a minimum. + if let Some(fb) = v { + let a = fb.abs(); + if let Some(l) = last { + if falling && a > l { + minima.push((at(i - 1), l)); + } + falling = a < l; + } + last = Some(a); + } else { + falling = false; + last = None; + } + match v { + Some(fb) if fb.abs() > floor => prev = Some((lam, fb)), + Some(_) => {} + None => prev = None, + } + } + // Near-tangent rays: a dip close to zero without a sign change. + let tol = 1e-4 * (1. + self.rmax); + for (lam, a) in minima { + if a < tol + && lam >= range[0] + && !out.iter().any(|h| (h.lam - lam).abs() < 2. * (range[1] - range[0]) / n as f64) + && let Some(uv) = self.inverse(p0 + d * lam, None) + { + out.push(Hit { lam, uv, grazing: true }); + } + } + } + + fn analytic(&self, tol: f64) -> Analytic { + let size = self.rmax.max(self.hrange[1] - self.hrange[0]).max(1e-9); + let pts: Vec<[f64; 2]> = self.samples.iter().map(|s| [s[1], s[2]]).collect(); + let (Some(first), Some(last)) = (pts.first(), pts.last()) else { return Analytic::Other }; + // A straight profile: cylinder (constant r), cone, or a plane square to the axis. + let e = [last[0] - first[0], last[1] - first[1]]; + let el = (e[0] * e[0] + e[1] * e[1]).sqrt(); + let straight = el > 0. && pts.iter().all(|p| ((p[0] - first[0]) * e[1] - (p[1] - first[1]) * e[0]).abs() / el <= tol * size); + if straight { + if e[0].abs() <= tol * el { + return Analytic::Cylinder { p: self.o, a: self.a, r: first[0] }; + } + if e[1].abs() <= tol * el { + return Analytic::Plane; + } + // Apex where r = 0 on the line. + let t = -first[0] / e[0]; + let h_apex = first[1] + t * e[1]; + let dir = if e[1] * e[0] > 0. { self.a } else { -self.a }; + let half = (e[0].abs() / el).asin(); + return Analytic::Cone { apex: self.o + self.a * h_apex, a: dir, half }; + } + // A circular profile: sphere when its centre is on the axis, torus otherwise. + if let Some((c, _, r)) = as_circle(&self.profile, tol) { + let w = c - self.o; + let h = w.dot(self.a); + let rc = (w - self.a * h).len(); + if rc <= tol * size { + return Analytic::Sphere { c: self.o + self.a * h, r }; + } + return Analytic::Torus { c: self.o + self.a * h, a: self.a, major: rc, minor: r }; + } + Analytic::Other + } +} + +impl Nurbs { + fn new(u: &[f64], v: &[f64], rows: &[Vec<[f64; 4]>]) -> Result { + let nu = rows.len(); + let nv = rows.first().map_or(0, Vec::len); + let pu = u.len().checked_sub(nu + 1).ok_or(HlrError::Unsupported("surface knots"))?; + let pv = v.len().checked_sub(nv + 1).ok_or(HlrError::Unsupported("surface knots"))?; + let mut ctrl = Vec::with_capacity(nu * nv); + for j in 0..nv { + for row in rows { + let p = row.get(j).ok_or(HlrError::Unsupported("ragged surface"))?; + ctrl.push(HPoint { x: p[0], y: p[1], z: p[2], w: p[3] }); + } + } + let s = NurbsSurface::from_homogeneous([pu, pv], [u.to_vec(), v.to_vec()], ctrl).map_err(|_| HlrError::Unsupported("spline surface"))?; + let dom = s.domain(); + let g = (4 * nu.max(nv)).clamp(16, 64); + let mut n = Nurbs { s, dom, g, grid: Vec::new() }; + let mut grid = Vec::with_capacity((g + 1) * (g + 1)); + for j in 0..=g { + for i in 0..=g { + grid.push(n.point(n.grid_uv(i, j))); + } + } + if grid.iter().any(|p| !p.is_finite()) { + return Err(HlrError::Unsupported("spline surface evaluation")); + } + n.grid = grid; + Ok(n) + } + + pub fn grid_uv(&self, i: usize, j: usize) -> [f64; 2] { + let f = |k: usize, x: usize| self.dom[k][0] + (self.dom[k][1] - self.dom[k][0]) * x as f64 / self.g as f64; + [f(0, i), f(1, j)] + } + + fn point(&self, uv: [f64; 2]) -> Vec3 { + let u = uv[0].clamp(self.dom[0][0], self.dom[0][1]); + let v = uv[1].clamp(self.dom[1][0], self.dom[1][1]); + self.s.eval(u, v).unwrap_or(Vec3::new(f64::NAN, f64::NAN, f64::NAN)) + } + + fn partials(&self, uv: [f64; 2]) -> (Vec3, Vec3) { + let fd = |k: usize| { + let h = (self.dom[k][1] - self.dom[k][0]) * 1e-6; + let (mut a, mut b) = (uv, uv); + a[k] = (uv[k] - h).max(self.dom[k][0]); + b[k] = (uv[k] + h).min(self.dom[k][1]); + if b[k] - a[k] <= 0. { Vec3::ZERO } else { (self.point(b) - self.point(a)) / (b[k] - a[k]) } + }; + (fd(0), fd(1)) + } + + fn clamp(&self, uv: [f64; 2]) -> [f64; 2] { + [uv[0].clamp(self.dom[0][0], self.dom[0][1]), uv[1].clamp(self.dom[1][0], self.dom[1][1])] + } + + fn inverse(&self, p: Vec3, hint: Option<[f64; 2]>) -> Option<[f64; 2]> { + let mut uv = match hint { + Some(h) => self.clamp(h), + None => { + let mut best = (0usize, f64::MAX); + for (k, q) in self.grid.iter().enumerate() { + let d = q.dist(p); + if d < best.1 { + best = (k, d); + } + } + self.grid_uv(best.0 % (self.g + 1), best.0 / (self.g + 1)) + } + }; + for _ in 0..40 { + let (su, sv) = self.partials(uv); + let r = self.point(uv) - p; + let (a, b, c) = (su.dot(su), su.dot(sv), sv.dot(sv)); + let det = a * c - b * b; + if det.abs() <= 1e-300 { + break; + } + let (gu, gv) = (su.dot(r), sv.dot(r)); + let du = (gu * c - gv * b) / det; + let dv = (gv * a - gu * b) / det; + let next = self.clamp([uv[0] - du, uv[1] - dv]); + let moved = (next[0] - uv[0]).abs() + (next[1] - uv[1]).abs(); + uv = next; + if moved <= 1e-15 * (1. + uv[0].abs() + uv[1].abs()) { + break; + } + } + uv[0].is_finite().then_some(uv) + } + + fn ray(&self, p0: Vec3, d: Vec3, range: [f64; 2], out: &mut Vec) { + let ep = perp(d); + let eq = d.cross(ep); + let q2 = |p: Vec3| [(p - p0).dot(ep), (p - p0).dot(eq)]; + let g = self.g; + let start = out.len(); + let proj: Vec<[f64; 2]> = self.grid.iter().map(|p| q2(*p)).collect(); + for j in 0..g { + for i in 0..g { + let corners = [(i, j), (i + 1, j), (i + 1, j + 1), (i, j + 1)]; + let pts: Vec<[f64; 2]> = corners.iter().filter_map(|(a, b)| proj.get(b * (g + 1) + a).copied()).collect(); + let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]); + for p in &pts { + lo = [lo[0].min(p[0]), lo[1].min(p[1])]; + hi = [hi[0].max(p[0]), hi[1].max(p[1])]; + } + let pad = 0.25 * ((hi[0] - lo[0]).max(hi[1] - lo[1])); + if lo[0] - pad > 0. || hi[0] + pad < 0. || lo[1] - pad > 0. || hi[1] + pad < 0. { + continue; + } + let (a, b) = (self.grid_uv(i, j), self.grid_uv(i + 1, j + 1)); + let mut uv = [0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1])]; + let mut ok = false; + for _ in 0..40 { + let p = self.point(uv); + let f = q2(p); + let (su, sv) = self.partials(uv); + let (j11, j12, j21, j22) = (su.dot(ep), sv.dot(ep), su.dot(eq), sv.dot(eq)); + let det = j11 * j22 - j12 * j21; + if det.abs() <= 1e-300 { + break; + } + let du = (f[0] * j22 - f[1] * j12) / det; + let dv = (f[1] * j11 - f[0] * j21) / det; + uv = self.clamp([uv[0] - du, uv[1] - dv]); + if du.abs() + dv.abs() <= 1e-14 * (1. + uv[0].abs() + uv[1].abs()) { + let r = q2(self.point(uv)); + ok = r[0].abs() + r[1].abs() <= 1e-9 * (1. + self.size_hint()); + break; + } + } + // Keep roots inside this cell (with slack) so neighbouring cells don't repeat them. + let slack = [(b[0] - a[0]) * 1e-6, (b[1] - a[1]) * 1e-6]; + if !ok || uv[0] < a[0] - slack[0] || uv[0] > b[0] + slack[0] || uv[1] < a[1] - slack[1] || uv[1] > b[1] + slack[1] { + continue; + } + let p = self.point(uv); + let lam = (p - p0).dot(d); + if lam >= range[0] && lam <= range[1] && !out.iter().skip(start).any(|h| (h.lam - lam).abs() <= 1e-9 * (1. + lam)) { + let (su, sv) = self.partials(uv); + let grazing = su.cross(sv).normalized().is_none_or(|n| n.dot(d).abs() < 1e-6); + out.push(Hit { lam, uv, grazing }); + } + } + } + } + + fn size_hint(&self) -> f64 { + self.grid.first().zip(self.grid.last()).map_or(1., |(a, b)| a.dist(*b)) + } + + /// The recognised form, checked over the whole patch: on each knot span the implicit + /// equation of a surface of degree d, multiplied out by the weight, is a polynomial of + /// degree d p by d q in the span's parameters, so it is zero everywhere when zero at + /// d p + 1 by d q + 1 points. It is checked at d p + 3 by d q + 3 Chebyshev points of + /// every span and the span's corners: nothing between samples can hide a bump. + fn analytic(&self, tol: f64) -> Analytic { + let an = self.candidate(tol); + if matches!(an, Analytic::Other | Analytic::Plane) { + return an; + } + let pts: Vec = self.s.homogeneous().iter().filter_map(|h| h.project()).collect(); + let hull = pts.iter().skip(1).fold(pts.first().map(|p| (*p, *p)), |acc, p| acc.map(|(lo, hi)| (lo.min(*p), hi.max(*p)))); + let size = hull.map_or(1., |(lo, hi)| (hi - lo).len()).max(1e-9); + let [pu, pv] = self.s.degree(); + let d = an.implicit_degree(); + let spans = |k: usize| -> Vec<[f64; 2]> { + let (Some(ks), Some(dom)) = (self.s.knots().get(k), self.dom.get(k)) else { return Vec::new() }; + let mut cuts: Vec = ks.iter().copied().filter(|x| *x > dom[0] && *x < dom[1]).collect(); + cuts.push(dom[0]); + cuts.push(dom[1]); + cuts.sort_by(f64::total_cmp); + cuts.dedup(); + cuts.windows(2).filter_map(|w| Some([*w.first()?, *w.get(1)?])).collect() + }; + let nodes = |deg: usize, s: [f64; 2]| -> Vec { + let n = d * deg + 3; + let (mid, half) = (0.5 * (s[0] + s[1]), 0.5 * (s[1] - s[0])); + let inner = (0..n).map(move |k| mid + half * (std::f64::consts::PI * (2 * k + 1) as f64 / (2 * n) as f64).cos()); + [s[0], s[1]].into_iter().chain(inner).collect() + }; + // As for circles (curve::as_circle): the largest distance at the points, times the + // tensor Lebesgue constant (below 16) and the weights' spread to the power d, bounds the + // distance anywhere on the patch. + let (w_min, w_max) = self.s.homogeneous().iter().fold((f64::MAX, f64::MIN), |(lo, hi), h| (lo.min(h.w), hi.max(h.w))); + if !(w_min > 0. && w_max.is_finite()) { + return Analytic::Other; + } + let (su, sv) = (spans(0), spans(1)); + let mut far = 0f64; + for a in &su { + let us = nodes(pu, *a); + for b in &sv { + for v in nodes(pv, *b) { + for u in &us { + far = far.max(an.distance(self.point([*u, v]))); + } + } + } + } + if 16. * (w_max / w_min).powi(d as i32) * far <= tol * size { an } else { Analytic::Other } + } + + /// Cylinder/cone/sphere/torus candidates from iso-curves and grid samples. + fn candidate(&self, tol: f64) -> Analytic { + let size = self.size_hint().max(1e-9); + let iso = |k: usize, x: f64| -> C3 { + let n = 24; + let pts = (0..=n) + .map(|i| { + let t = self.dom[k][0] + (self.dom[k][1] - self.dom[k][0]) * i as f64 / n as f64; + if k == 0 { self.point([t, x]) } else { self.point([x, t]) } + }) + .collect(); + C3::Polyline(pts) + }; + let straight = |c: &C3| { + let C3::Polyline(p) = c else { return false }; + let (Some(a), Some(b)) = (p.first(), p.last()) else { return false }; + let Some(e) = (*b - *a).normalized() else { return false }; + p.iter().all(|q| ((*q - *a) - e * (*q - *a).dot(e)).len() <= tol * size) + }; + let circle_of = |k: usize, x: f64| -> Option<(Vec3, Vec3, f64)> { + // Fit through exact surface points, then verify densely. + let at = |f: f64| { + let t = self.dom[k][0] + (self.dom[k][1] - self.dom[k][0]) * f; + if k == 0 { self.point([t, x]) } else { self.point([x, t]) } + }; + let pts: Vec = (0..=32).map(|i| at(i as f64 / 32.)).collect(); + let sp = C3::Polyline(pts.clone()); + let (a, m, b) = (pts.first().copied()?, pts.get(16).copied()?, pts.get(8).copied()?); + let (u, v) = (m - a, b - a); + let n = u.cross(v); + let n2 = n.len2(); + // Nearly collinear relative to the points' own spacing. + if n2 <= 1e-24 * u.len2() * v.len2() { + return None; + } + let c = a + (v * u.len2() - u * v.len2()).cross(n) * (1. / (2. * n2)); + let nn = n.normalized()?; + let r = c.dist(a); + let ok = match &sp { + C3::Polyline(p) => p.iter().all(|q| (q.dist(c) - r).abs() <= tol * r && (*q - c).dot(nn).abs() <= tol * r), + _ => false, + }; + ok.then_some((c, nn, r)) + }; + for (line_dir, circ_dir) in [(1usize, 0usize), (0, 1)] { + let mids = [0., 0.5, 1.].map(|f| self.dom[circ_dir][0] + (self.dom[circ_dir][1] - self.dom[circ_dir][0]) * f); + if !mids.iter().all(|x| straight(&iso(line_dir, *x))) { + continue; + } + let ends = [0., 0.5, 1.].map(|f| self.dom[line_dir][0] + (self.dom[line_dir][1] - self.dom[line_dir][0]) * f); + let circles: Option> = ends.iter().map(|x| circle_of(circ_dir, *x)).collect(); + let Some(circles) = circles else { continue }; + let (Some(c0), Some(c2)) = (circles.first(), circles.last()) else { continue }; + let Some(axis) = (c2.0 - c0.0).normalized() else { continue }; + if c0.1.cross(axis).len() > tol { + continue; + } + if (c0.2 - c2.2).abs() <= tol * c0.2 { + return Analytic::Cylinder { p: c0.0, a: axis, r: c0.2 }; + } + let dr = (c2.2 - c0.2) / c0.0.dist(c2.0); + let half = dr.abs().atan(); + let dir = if dr > 0. { axis } else { -axis }; + let apex = c0.0 - dir * (c0.2 / dr.abs()); + return Analytic::Cone { apex, a: dir, half }; + } + // Sphere: every grid point equidistant from the centre of four spread points. Several + // spreads are tried: on a patch closed around (a full band) the corners meet at its seam. + let g = self.g; + let pick = |i: usize, j: usize| self.grid.get(j * (g + 1) + i).copied(); + let spreads = [ + [(0, 0), (g, 0), (g / 2, g / 2), (0, g)], + [(0, 0), (g / 2, 0), (g / 4, g), (3 * g / 4, g / 2)], + [(g / 3, 0), (0, g / 2), (2 * g / 3, g), (g, g / 3)], + ]; + for q in spreads { + let (Some(a), Some(b), Some(c), Some(d)) = (pick(q[0].0, q[0].1), pick(q[1].0, q[1].1), pick(q[2].0, q[2].1), pick(q[3].0, q[3].1)) + else { + continue; + }; + let Some(centre) = sphere_centre(a, b, c, d) else { continue }; + let r = centre.dist(a); + if r > 0. && self.grid.iter().all(|p| (p.dist(centre) - r).abs() <= tol * r) { + return Analytic::Sphere { c: centre, r }; + } + } + // Torus: circles both ways, a meridian giving the tube's centre and radius and a + // parallel the axis; then every grid point on that torus. + let mid = |k: usize| self.dom.get(k).map_or(0., |d| 0.5 * (d[0] + d[1])); + for (meridian, parallel) in [(0usize, 1usize), (1, 0)] { + let (Some(m), Some(p)) = (circle_of(meridian, mid(parallel)), circle_of(parallel, mid(meridian))) else { continue }; + let (a, minor) = (p.1, m.2); + let c = p.0 + a * (m.0 - p.0).dot(a); + let major = m.0.dist(c); + if !(major > 0. && minor > 0.) { + continue; + } + let on = self.grid.iter().all(|q| { + let w = *q - c; + let h = w.dot(a); + (((w - a * h).len() - major).hypot(h) - minor).abs() <= tol * (major + minor) + }); + if on { + return Analytic::Torus { c, a, major, minor }; + } + } + Analytic::Other + } +} + +fn sphere_centre(a: Vec3, b: Vec3, c: Vec3, d: Vec3) -> Option { + // Solve 2(p_i - a).x = |p_i|^2 - |a|^2 for i = b, c, d. + let rows = [b - a, c - a, d - a]; + let rhs = [b.len2() - a.len2(), c.len2() - a.len2(), d.len2() - a.len2()].map(|v| v * 0.5); + let [r0, r1, r2] = rows; + let det = r0.dot(r1.cross(r2)); + if det.abs() <= 1e-18 * (1. + r0.len() * r1.len() * r2.len()) { + return None; + } + let x = (r1.cross(r2) * rhs[0] + r2.cross(r0) * rhs[1] + r0.cross(r1) * rhs[2]) / det; + x.is_finite().then_some(x) +} diff --git a/crates/geom/src/hlr/trim.rs b/crates/geom/src/hlr/trim.rs new file mode 100644 index 0000000000..c416d33190 --- /dev/null +++ b/crates/geom/src/hlr/trim.rs @@ -0,0 +1,382 @@ +//! Face trimming in the surface chart. Boundary loops are sampled from the exact edge curves, +//! unwrapped across periodic directions, and a point is classified by the parity of boundary +//! crossings on a path to a point known to be inside. Seams (an edge used twice by one face) +//! are crossed twice, so they cancel without special cases. + +use super::curve::C3; +use super::surface::S3; +use crate::Vec3; +use crate::brep::Face; + +#[derive(Clone, Debug)] +pub(crate) struct Trim { + /// Unwrapped chart segments of all loops. + segs: Vec<([f64; 2], [f64; 2])>, + period: [Option; 2], + /// A point inside the face; `None` when the face covers its whole chart. + inside: Option<[f64; 2]>, + /// 3D boundary chords, for distance-to-boundary margins. + pub chords: Vec<(Vec3, Vec3)>, + /// Largest distance between an exact boundary curve and its chords. + pub chord_error: f64, + pub lo: Vec3, + pub hi: Vec3, + /// Why classification is unavailable, if it is. + pub unsupported: Option<&'static str>, + /// Index for the two-leg parity path q -> (r.u, q.v) -> r. + index: Option, + /// `chords` on a grid, for boundaries with many of them. + grid: Option, +} + +/// Boundary chords bucketed on a uniform grid over the boundary's box, so a point is checked +/// against the chords around it, not all of them (a circle alone has `trim_samples` per break). +/// Cells are at least as long as the longest chord, so a chord lies in at most two per axis. +#[derive(Clone, Debug)] +struct ChordGrid { + lo: Vec3, + cell: f64, + n: [usize; 3], + /// (cell, chord) pairs sorted by cell. + entries: Vec<(u32, u32)>, +} + +impl ChordGrid { + fn new(chords: &[(Vec3, Vec3)], lo: Vec3, hi: Vec3) -> Option { + // A few chords are checked faster one by one than through a grid. + if chords.len() < 64 || u32::try_from(chords.len()).is_err() { + return None; + } + let ext = hi - lo; + let longest = chords.iter().map(|(a, b)| a.dist(*b)).fold(0., f64::max); + let cell = longest.max(ext.len() / 256.).max(ext.x.max(ext.y).max(ext.z) / 512.); + if !(cell > 0. && cell.is_finite()) { + return None; + } + let n = [ext.x, ext.y, ext.z].map(|e| ((e / cell).floor() as usize).saturating_add(1).min(513)); + let mut grid = ChordGrid { lo, cell, n, entries: Vec::with_capacity(chords.len() * 2) }; + for (i, (a, b)) in (0u32..).zip(chords) { + let (c0, c1) = (grid.cell_of(a.min(*b)), grid.cell_of(a.max(*b))); + for x in c0[0]..=c1[0] { + for y in c0[1]..=c1[1] { + for z in c0[2]..=c1[2] { + grid.entries.push((grid.key([x, y, z]), i)); + } + } + } + } + grid.entries.sort_unstable(); + Some(grid) + } + + fn cell_of(&self, p: Vec3) -> [usize; 3] { + let at = |v: f64, l: f64, n: usize| (((v - l) / self.cell).floor().max(0.) as usize).min(n.saturating_sub(1)); + [at(p.x, self.lo.x, self.n[0]), at(p.y, self.lo.y, self.n[1]), at(p.z, self.lo.z, self.n[2])] + } + + fn key(&self, c: [usize; 3]) -> u32 { + // At most 513 cells per axis: the key fits in 28 bits. + ((c[0] * self.n[1] + c[1]) * self.n[2] + c[2]) as u32 + } + + /// Whether a chord touching a cell of the box `p` +- `tol` is within `tol` of `p`; `None` + /// when that box covers too many cells to be worth it. + fn near(&self, chords: &[(Vec3, Vec3)], p: Vec3, tol: f64) -> Option { + // A little wider than `tol`, so rounding in the distance cannot pick a chord whose + // nearest point lies in a cell left out. + let w = tol * (1. + 1e-6); + let t = Vec3::new(w, w, w); + let (c0, c1) = (self.cell_of(p - t), self.cell_of(p + t)); + let span = |k: usize| c1[k].saturating_sub(c0[k]).saturating_add(1); + if span(0).saturating_mul(span(1)).saturating_mul(span(2)) > 64 { + return None; + } + for x in c0[0]..=c1[0] { + for y in c0[1]..=c1[1] { + for z in c0[2]..=c1[2] { + let k = self.key([x, y, z]); + let first = self.entries.partition_point(|e| e.0 < k); + let close = self + .entries + .get(first..) + .unwrap_or_default() + .iter() + .take_while(|e| e.0 == k) + .any(|(_, i)| chords.get(*i as usize).is_some_and(|(a, b)| p.dist_to_segment(*a, *b) <= tol)); + if close { + return Some(true); + } + } + } + } + Some(false) + } +} + +/// Segment copies shifted across periods near the inside point, the sorted crossings of the +/// vertical line u = r.u, and v-buckets of the copies for the horizontal leg. +#[derive(Clone, Debug)] +struct Index { + copies: Vec<([f64; 2], [f64; 2])>, + vcross: Vec, + buckets: Vec>, + v0: f64, + dv: f64, +} + +fn spans(x: f64, a: f64, b: f64) -> bool { + (a <= x && x < b) || (b <= x && x < a) +} + +fn index(segs: &[([f64; 2], [f64; 2])], period: [Option; 2], r: [f64; 2]) -> Index { + let shifts = |k: usize| -> Vec { + let Some(p) = period[k] else { return vec![0.] }; + let (lo, hi) = segs.iter().fold((f64::MAX, f64::MIN), |(lo, hi), (a, b)| (lo.min(a[k]).min(b[k]), hi.max(a[k]).max(b[k]))); + let k0 = ((r[k] - p - hi) / p).floor() as i64; + let k1 = ((r[k] + p - lo) / p).ceil() as i64; + (k0.max(-8)..=k1.min(8)).map(|i| i as f64 * p).collect() + }; + let mut copies = Vec::new(); + for dx in shifts(0) { + for dy in shifts(1) { + copies.extend(segs.iter().map(|(a, b)| ([a[0] + dx, a[1] + dy], [b[0] + dx, b[1] + dy]))); + } + } + let mut vcross: Vec = + copies.iter().filter(|(a, b)| spans(r[0], a[0], b[0])).map(|(a, b)| a[1] + (b[1] - a[1]) * (r[0] - a[0]) / (b[0] - a[0])).collect(); + vcross.sort_by(f64::total_cmp); + let (v0, v1) = copies.iter().fold((f64::MAX, f64::MIN), |(lo, hi), (a, b)| (lo.min(a[1]).min(b[1]), hi.max(a[1]).max(b[1]))); + let n = ((copies.len() as f64).sqrt().ceil() as usize).clamp(1, 4096); + let dv = ((v1 - v0) / n as f64).max(1e-300); + let mut buckets = vec![Vec::new(); n]; + for (i, (a, b)) in copies.iter().enumerate() { + let k0 = (((a[1].min(b[1]) - v0) / dv).floor().max(0.) as usize).min(n - 1); + let k1 = (((a[1].max(b[1]) - v0) / dv).floor().max(0.) as usize).min(n - 1); + for bucket in buckets.iter_mut().take(k1 + 1).skip(k0) { + bucket.push(i as u32); + } + } + Index { copies, vcross, buckets, v0, dv } +} + +fn lift(x: f64, near: f64, p: Option) -> f64 { + match p { + Some(p) if p > 0. => x + p * ((near - x) / p).round(), + _ => x, + } +} + +pub(crate) fn build(face: &Face, edges: &[C3], surface: &S3, per_break: usize) -> Trim { + let period = surface.periods(); + let mut segs = Vec::new(); + let mut seam = Vec::new(); + let mut chords = Vec::new(); + let mut chord_error = 0f64; + let (mut lo, mut hi) = (Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)); + let mut uses = std::collections::BTreeMap::new(); + for u in face.loops.iter().flatten() { + *uses.entry(u.edge).or_insert(0usize) += 1; + } + let mut area_u = 0.; // -loop integral of v du + let mut area_v = 0.; // loop integral of u dv + let mut wraps = [false; 2]; + let mut unsupported = None; + for lp in &face.loops { + let mut pts: Vec<([f64; 2], bool)> = Vec::new(); + let mut hint: Option<[f64; 2]> = None; + for u in lp { + let Some(c) = edges.get(u.edge as usize) else { + unsupported = Some("edge reference"); + continue; + }; + let is_seam = uses.get(&u.edge).copied().unwrap_or(0) > 1; + let mut ts = Vec::new(); + let b = c.breaks(); + for w in b.windows(2) { + let (Some(t0), Some(t1)) = (w.first(), w.get(1)) else { continue }; + for i in 0..per_break { + ts.push(t0 + (t1 - t0) * i as f64 / per_break as f64); + } + } + if let Some(last) = b.last() { + ts.push(*last); + } + if !u.forward { + ts.reverse(); + } + let mut prev3: Option<(f64, Vec3)> = None; + for t in ts { + let p = c.at(t); + lo = lo.min(p); + hi = hi.max(p); + if let Some((tp, q)) = prev3 { + chords.push((q, p)); + let m = c.at(0.5 * (t + tp)); + chord_error = chord_error.max(m.dist_to_segment(q, p)); + } + prev3 = Some((t, p)); + let Some(mut uv) = surface.inverse(p, hint) else { + unsupported = Some("boundary point off its surface"); + continue; + }; + if let Some(h) = hint { + uv = [lift(uv[0], h[0], period[0]), lift(uv[1], h[1], period[1])]; + } + hint = Some(uv); + pts.push((uv, is_seam)); + } + } + let (Some(first), Some(last)) = (pts.first().copied(), pts.last().copied()) else { continue }; + // Close the loop at the lifted copy of its start (a wrapping loop ends a period away). + let close = [lift(first.0[0], last.0[0], period[0]), lift(first.0[1], last.0[1], period[1])]; + for k in 0..2 { + if (close[k] - first.0[k]).abs() > 1e-9 { + wraps[k] = true; + } + } + pts.push((close, last.1)); + for w in pts.windows(2) { + let (Some((a, _)), Some((b, s))) = (w.first(), w.get(1)) else { continue }; + area_u -= 0.5 * (a[1] + b[1]) * (b[0] - a[0]); + area_v += 0.5 * (a[0] + b[0]) * (b[1] - a[1]); + segs.push((*a, *b)); + seam.push(*s); + } + } + let area = match wraps { + [true, true] => { + unsupported = unsupported.or(Some("loops wrap in both chart directions")); + 0. + } + [false, true] => area_v, + _ => area_u, + }; + // Loops that wind once around a periodic direction without coming back (a hemisphere's + // equator) close against the chart's own edge, so their area depends on where the chart + // starts: there the face is taken on the loops' left (the B-rep convention), and below the + // side within the surface's domain wins either way. + let net = segs.iter().fold([0f64; 2], |n, (a, b)| [n[0] + b[0] - a[0], n[1] + b[1] - a[1]]); + let open = (0..2).any(|k| period[k].is_some_and(|p| net[k].abs() > 0.5 * p)); + let sigma = if open { 1. } else { area.signum() }; + let dom = surface.domain(); + // Inside point: beside the longest non-seam segment, on the face's side. + let mut inside = None; + let real: Vec = (0..segs.len()).filter(|i| !seam.get(*i).copied().unwrap_or(true)).collect(); + if !real.is_empty() && (open || area != 0.) { + let len = |i: usize| segs.get(i).map_or(0., |(a, b)| ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt()); + let best = real.iter().copied().max_by(|a, b| len(*a).total_cmp(&len(*b))); + if let Some((a, b)) = best.and_then(|i| segs.get(i)) { + let m = [0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1])]; + let l = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt(); + let n = [-(b[1] - a[1]) / l * sigma, (b[0] - a[0]) / l * sigma]; + // Stay well inside: a quarter of the distance to any other boundary piece. + let mut room = l; + for (j, (c, e)) in segs.iter().enumerate() { + if Some(j) == best { + continue; + } + for (dx, dy) in shifts(period) { + let (c2, e2) = ([c[0] + dx, c[1] + dy], [e[0] + dx, e[1] + dy]); + room = room.min(point_segment(m, c2, e2)); + } + } + let delta = 0.25 * room; + // At most half way to the chart's domain edge, so a face hugging that edge (a cone's + // tip) keeps its point; a loop on the edge itself has no room on the outer side. + let step = |dir: [f64; 2]| { + let mut s = delta; + for (((d, &c), &mk), p) in dom.iter().zip(&dir).zip(&m).zip(&period) { + if p.is_some() { + continue; + } + if c < 0. { + s = s.min(0.5 * (mk - d[0]) / -c); + } else if c > 0. { + s = s.min(0.5 * (d[1] - mk) / c); + } + } + s + }; + let flip = [-n[0], -n[1]]; + let pick = [(n, step(n)), (flip, step(flip))].into_iter().find(|(_, s)| *s > 0. && s.is_finite()); + if let Some((dir, s)) = pick { + inside = Some([m[0] + dir[0] * s, m[1] + dir[1] * s]); + } + } + if inside.is_none() { + unsupported = unsupported.or(Some("no room for an inside reference point")); + } + } + let index = inside.map(|r| index(&segs, period, r)); + let grid = ChordGrid::new(&chords, lo, hi); + Trim { segs, period, inside, chords, chord_error, lo, hi, unsupported, index, grid } +} + +fn shifts(period: [Option; 2]) -> Vec<(f64, f64)> { + let ks = |p: Option| p.map_or(vec![0.], |p| vec![-p, 0., p]); + let mut out = Vec::new(); + for dx in ks(period[0]) { + for dy in ks(period[1]) { + out.push((dx, dy)); + } + } + out +} + +fn point_segment(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 { + let (ex, ey) = (b[0] - a[0], b[1] - a[1]); + let l2 = ex * ex + ey * ey; + let t = if l2 > 0. { (((p[0] - a[0]) * ex + (p[1] - a[1]) * ey) / l2).clamp(0., 1.) } else { 0. }; + ((a[0] + t * ex - p[0]).powi(2) + (a[1] + t * ey - p[1]).powi(2)).sqrt() +} + +impl Trim { + /// Whether chart point `uv` lies inside the face; `None` when the trim is unsupported. + /// Parity of boundary crossings on the path q -> (r.u, q.v) -> r to the inside point r. + pub fn contains(&self, uv: [f64; 2]) -> Option { + if self.unsupported.is_some() { + return None; + } + let (Some(r), Some(ix)) = (self.inside, self.index.as_ref()) else { return Some(true) }; + let q = [lift(uv[0], r[0], self.period[0]), lift(uv[1], r[1], self.period[1])]; + let mut crossings = 0usize; + // Horizontal leg at v = q.v between q.u and r.u. + let (ulo, uhi) = (q[0].min(r[0]), q[0].max(r[0])); + let k = (((q[1] - ix.v0) / ix.dv).floor().max(0.) as usize).min(ix.buckets.len().saturating_sub(1)); + for i in ix.buckets.get(k).map(Vec::as_slice).unwrap_or(&[]) { + let Some((a, b)) = ix.copies.get(*i as usize) else { continue }; + if spans(q[1], a[1], b[1]) { + let u = a[0] + (b[0] - a[0]) * (q[1] - a[1]) / (b[1] - a[1]); + if u > ulo && u <= uhi { + crossings += 1; + } + } + } + // Vertical leg at u = r.u between q.v and r.v. + let (vlo, vhi) = (q[1].min(r[1]), q[1].max(r[1])); + let first = ix.vcross.partition_point(|v| *v <= vlo); + let last = ix.vcross.partition_point(|v| *v <= vhi); + crossings += last.saturating_sub(first); + Some(crossings.is_multiple_of(2)) + } + + /// The chart box of the unwrapped boundary. + pub fn uv_bounds(&self) -> Option<([f64; 2], [f64; 2])> { + let mut it = self.segs.iter().flat_map(|(a, b)| [a, b]); + let first = *it.next()?; + Some(it.fold((first, first), |(lo, hi), p| ([lo[0].min(p[0]), lo[1].min(p[1])], [hi[0].max(p[0]), hi[1].max(p[1])]))) + } + + /// Whether a 3D point is within `tol` of the sampled boundary: the boundary's box first, + /// then the chords around the point (all of them for a short boundary or a wide `tol`). + pub fn near(&self, p: Vec3, tol: f64) -> bool { + let out = (self.lo - p).max(p - self.hi).max(Vec3::new(0., 0., 0.)); + if !(out.len() <= tol) { + return false; + } + match self.grid.as_ref().and_then(|g| g.near(&self.chords, p, tol)) { + Some(close) => close, + None => self.chords.iter().any(|(a, b)| p.dist_to_segment(*a, *b) <= tol), + } + } +} diff --git a/crates/geom/src/hlr_tests.rs b/crates/geom/src/hlr_tests.rs new file mode 100644 index 0000000000..d56729e3d9 --- /dev/null +++ b/crates/geom/src/hlr_tests.rs @@ -0,0 +1,2213 @@ +use super::*; +use crate::brep::{Brep, Curve, Edge, EdgeUse, Face, Shell, Surface}; + +/// An axis-aligned box with line edges and plane faces. +fn cuboid(lo: [f64; 3], hi: [f64; 3]) -> Brep { + let corner = |i: usize| [if i & 1 == 0 { lo[0] } else { hi[0] }, if i & 2 == 0 { lo[1] } else { hi[1] }, if i & 4 == 0 { lo[2] } else { hi[2] }]; + let vertices: Vec<[f64; 3]> = (0..8).map(corner).collect(); + let pairs = [(0, 1), (2, 3), (4, 5), (6, 7), (0, 2), (1, 3), (4, 6), (5, 7), (0, 4), (1, 5), (2, 6), (3, 7)]; + let edges: Vec = + pairs.iter().map(|&(a, b)| Edge { vertices: [a as u32, b as u32], curve: Curve::Line { start: corner(a), end: corner(b) } }).collect(); + let cycle = |vs: [usize; 4]| -> Vec { + (0..4) + .map(|k| { + let (a, b) = (vs[k], vs[(k + 1) % 4]); + let (i, fwd) = pairs + .iter() + .enumerate() + .find_map(|(i, &(x, y))| (x == a && y == b).then_some((i, true)).or((x == b && y == a).then_some((i, false)))) + .unwrap(); + EdgeUse { edge: i as u32, forward: fwd } + }) + .collect() + }; + let plane = |o: usize, u: usize, v: usize| { + let (o, u, v) = (corner(o), corner(u), corner(v)); + Surface::Plane { origin: o, u_axis: [u[0] - o[0], u[1] - o[1], u[2] - o[2]], v_axis: [v[0] - o[0], v[1] - o[1], v[2] - o[2]] } + }; + let faces = vec![ + Face { loops: vec![cycle([0, 2, 3, 1])], surface: plane(0, 2, 1), forward: true }, + Face { loops: vec![cycle([4, 5, 7, 6])], surface: plane(4, 5, 6), forward: true }, + Face { loops: vec![cycle([0, 1, 5, 4])], surface: plane(0, 1, 4), forward: true }, + Face { loops: vec![cycle([2, 6, 7, 3])], surface: plane(2, 6, 3), forward: true }, + Face { loops: vec![cycle([0, 4, 6, 2])], surface: plane(0, 4, 2), forward: true }, + Face { loops: vec![cycle([1, 3, 7, 5])], surface: plane(1, 3, 5), forward: true }, + ]; + Brep { version: crate::brep::VERSION, shells: vec![Shell { vertices, edges, faces }] } +} + +fn identity(body: usize, t: [f64; 3]) -> Placement { + Placement { body, rotation: [Vec3::new(1., 0., 0.), Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.)], translation: Vec3::new(t[0], t[1], t[2]) } +} + +fn never() -> bool { + false +} + +#[test] +fn iso_cube_hides_the_three_back_edges() { + 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 d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let mut hidden = Vec::new(); + for l in &d.lines { + let Source::Edge { edge, .. } = l.source else { panic!("a box has no silhouettes") }; + let states: Vec<_> = l.pieces.iter().map(|p| p.visibility).collect(); + assert!(states.windows(2).all(|w| w[0] == w[1]), "edge {edge} is split: {states:?}"); + if states[0] == Visibility::Hidden { + hidden.push(edge); + } + } + hidden.sort_unstable(); + // The edges at the corner nearest the origin: (0,1), (0,2), (0,4). + assert_eq!(hidden, vec![0, 4, 8], "{d:#?}"); +} + +#[test] +fn a_box_in_front_hides_the_middle_of_an_edge() { + let back = prepare(&cuboid([0.; 3], [1.; 3]), &Options::default()).unwrap(); + let front = prepare(&cuboid([0.5, -2., 0.25], [1.5, -1., 0.75]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[back, front], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + // Edge 9 joins corners 1 (1,0,0) and 5 (1,0,1): t is z. + let line = d.lines.iter().find(|l| l.placement == 0 && l.source == Source::Edge { edge: 9, tangent: false }).unwrap(); + let got: Vec<([f64; 2], Visibility)> = line.pieces.iter().map(|p| (p.t, p.visibility)).collect(); + assert_eq!(got.len(), 3, "{got:?}"); + assert_eq!(got[1].1, Visibility::Hidden); + assert!((got[1].0[0] - 0.25).abs() < 1e-12 && (got[1].0[1] - 0.75).abs() < 1e-12, "{got:?}"); + assert_eq!((got[0].1, got[2].1), (Visibility::Visible, Visibility::Visible)); +} + +#[test] +fn quadratic_roots_with_no_linear_term() { + // A ray from inside a sphere, square to the radius at its start (b = 0): both roots. + let mut roots = surface::quadratic(1., 0., -100.); + roots.sort_by(|a, b| a.0.total_cmp(&b.0)); + assert_eq!(roots.len(), 2, "{roots:?}"); + assert!((roots[0].0 + 10.).abs() < 1e-12 && (roots[1].0 - 10.).abs() < 1e-12, "{roots:?}"); + let mut roots = surface::quadratic(1., -0., -100.); + roots.sort_by(|a, b| a.0.total_cmp(&b.0)); + assert!((roots[0].0 + 10.).abs() < 1e-12 && (roots[1].0 - 10.).abs() < 1e-12, "{roots:?}"); +} + +#[test] +fn a_torus_seen_along_its_axis_has_both_equator_silhouettes() { + // A whole-meridian torus record: the outer equator sits where the closed profile's ends meet. + let frame = crate::brep::Frame { origin: [0.; 3], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] }; + let tau = std::f64::consts::TAU; + let s = surface::S3::new(&Surface::Torus { frame, major_radius: 20., minor_radius: 5., longitude: [0., tau], meridian: [0., tau] }).unwrap(); + let mut radii: Vec = s + .rev_silhouette(Vec3::new(0., 0., -1.)) + .iter() + .map(|(c, _)| match c { + curve::C3::Arc { r, .. } => r[0], + _ => panic!("circles expected"), + }) + .collect(); + radii.sort_by(f64::total_cmp); + assert_eq!(radii.len(), 2, "{radii:?}"); + assert!((radii[0] - 15.).abs() < 1e-9 && (radii[1] - 25.).abs() < 1e-9, "{radii:?}"); +} + +#[test] +fn far_apart_bodies_do_not_merge_nearby_visible_edges() { + // A 5 mm cube and a box 100 m away: coincidence is judged at the chords' own accuracy, + // not at a fraction of the scene's size, so none of the cube's edges is taken as a duplicate. + let near = prepare(&cuboid([0.; 3], [5.; 3]), &Options::default()).unwrap(); + let far = prepare(&cuboid([100_000.; 3], [100_001.; 3]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(-1., -0.7, -0.4), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[near, far], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let covered_visible = d + .lines + .iter() + .filter(|l| l.placement == 0) + .flat_map(|l| l.pieces.iter()) + .filter(|p| p.visibility == Visibility::Visible && p.covered) + .count(); + assert_eq!(covered_visible, 0); +} + +#[test] +fn a_thin_torus_tube_stops_a_ray() { + // R 100, r 0.1: a ray across the tube meets it twice within 0.2 mm. + let roots = surface::torus_roots(Vec3::new(100., -50., 0.), Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.), 100., 0.1, [0., 200.]); + assert!(roots.iter().filter(|(_, grazing)| !grazing).count() >= 2, "{roots:?}"); +} + +#[test] +fn a_thin_tube_on_a_huge_torus_stops_a_ray() { + // R 100 m, r 1 mm: inside the tube the implicit function stays far below a size^4 floor. + // A radial ray from 5 mm inside the centre circle crosses the tube at 4 and 6 mm. + let roots = surface::torus_roots(Vec3::new(100_000. - 5., 0., 0.), Vec3::new(1., 0., 0.), Vec3::new(0., 0., 1.), 100_000., 1., [0., 10.]); + let hits: Vec = roots.iter().filter(|(_, grazing)| !grazing).map(|(l, _)| *l).collect(); + // The implicit form cancels terms of 4e20 here: the roots are good to about 1e-6 mm. + assert!(hits.len() == 2 && (hits[0] - 4.).abs() < 1e-5 && (hits[1] - 6.).abs() < 1e-5, "{roots:?}"); +} + +#[test] +fn a_hair_thin_tube_on_a_kilometre_torus_stops_a_ray() { + // R 1 km, r 0.1 mm (R/r = 1e7): roots at 0.9 and 1.1 mm, good to about 1e-4 mm here. + let roots = surface::torus_roots(Vec3::new(1e6 - 1., 0., 0.), Vec3::new(1., 0., 0.), Vec3::new(0., 0., 1.), 1e6, 0.1, [0., 2.]); + let hits: Vec = roots.iter().filter(|(_, grazing)| !grazing).map(|(l, _)| *l).collect(); + assert!(hits.len() == 2 && (hits[0] - 0.9).abs() < 1e-3 && (hits[1] - 1.1).abs() < 1e-3, "{roots:?}"); +} + +#[test] +fn a_ray_from_the_hole_crosses_a_thin_large_torus() { + // R 4 m, r 0.01 mm, from the centre outward over 8 m: only the shell around the tube is sampled. + let roots = surface::torus_roots(Vec3::new(0., 0., 0.), Vec3::new(1., 0., 0.), Vec3::new(0., 0., 1.), 4000., 0.01, [0., 8000.]); + let hits: Vec = roots.iter().filter(|(_, grazing)| !grazing).map(|(l, _)| *l).collect(); + assert!(hits.len() == 2 && (hits[0] - 3999.99).abs() < 1e-6 && (hits[1] - 4000.01).abs() < 1e-6, "{roots:?}"); +} + +#[test] +fn a_torus_seen_nearly_along_its_axis_keeps_its_silhouettes() { + // Meridian starting off the equator and a view 1e-4 rad from the axis: the silhouette lives + // in narrow profile intervals around the equators, which must not fall between samples. + let frame = crate::brep::Frame { origin: [0.; 3], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] }; + let tau = std::f64::consts::TAU; + let shift = std::f64::consts::PI / 720.; + let s = + surface::S3::new(&Surface::Torus { frame, major_radius: 1000., minor_radius: 500., longitude: [0., tau], meridian: [shift, shift + tau] }) + .unwrap(); + let d = Vec3::new(1e-4, 0., -1.).normalized().unwrap(); + let curves = s.rev_silhouette(d); + // Both the outer (radius about 1500) and the inner (about 500) silhouettes are there. + let radii: Vec = curves + .iter() + .flat_map(|(c, _)| { + let [t0, t1] = c.domain(); + (0..=8).map(move |i| c.at(t0 + (t1 - t0) * i as f64 / 8.)) + }) + .map(|p| p.x.hypot(p.y)) + .collect(); + assert!(radii.iter().any(|r| *r > 1400.) && radii.iter().any(|r| *r < 600.), "{radii:?}"); +} + +#[test] +fn a_shallow_cut_through_a_torus_tube_is_found() { + // R 20, r 5, a ray along the axis 0.01 mm inside the outer equator: a 0.63 mm chord. + let roots = surface::torus_roots(Vec3::new(24.99, 0., -10.), Vec3::new(0., 0., 1.), Vec3::new(0., 0., 1.), 20., 5., [0., 20.]); + let hits: Vec = roots.iter().filter(|(_, grazing)| !grazing).map(|(l, _)| *l).collect(); + let half = (25f64 - 4.99f64.powi(2)).sqrt(); + assert!(hits.len() == 2 && (hits[0] - (10. - half)).abs() < 1e-6 && (hits[1] - (10. + half)).abs() < 1e-6, "{roots:?}"); +} + +/// A frame at `o` with x along +X and y along +Y (z along +Z). +fn frame_at(o: [f64; 3]) -> crate::brep::Frame { + crate::brep::Frame { origin: o, x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] } +} + +/// A full circle about Z through vertex `v`. +fn circle(o: [f64; 3], r: f64, v: u32) -> Edge { + Edge { vertices: [v, v], curve: Curve::CircleArc { frame: frame_at(o), radius: r, angle: [0., std::f64::consts::TAU] } } +} + +fn uses(edge: u32, forward: bool) -> EdgeUse { + EdgeUse { edge, forward } +} + +/// A cylinder of radius `r` and height `h` standing on the XY plane, as analytic records. +fn cylinder(r: f64, h: f64) -> Brep { + turned(r, r, h) +} + +/// A cylinder (`r0 == r1`) or a cone frustum from radius `r0` at z = 0 to `r1` at z = `h`, +/// its seam at angle 0. +fn turned(r0: f64, r1: f64, h: f64) -> Brep { + let tau = std::f64::consts::TAU; + let seam = Edge { vertices: [0, 1], curve: Curve::Line { start: [r0, 0., 0.], end: [r1, 0., h] } }; + let edges = vec![circle([0.; 3], r0, 0), circle([0., 0., h], r1, 1), seam]; + let side = if r0 == r1 { + Surface::Cylinder { frame: frame_at([0.; 3]), radius: r0, angle: [0., tau], height: [0., h] } + } else { + Surface::Cone { frame: frame_at([0.; 3]), radius_at_origin: r0, half_angle: ((r1 - r0) / h).atan(), angle: [0., tau], height: [0., h] } + }; + let faces = vec![ + Face { loops: vec![vec![uses(0, true), uses(2, true), uses(1, false), uses(2, false)]], surface: side, forward: true }, + Face { + loops: vec![vec![uses(0, false)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., -1., 0.] }, + forward: true, + }, + Face { + loops: vec![vec![uses(1, true)]], + surface: Surface::Plane { origin: [0., 0., h], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }, + ]; + Brep { version: crate::brep::VERSION, shells: vec![Shell { vertices: vec![[r0, 0., 0.], [r1, 0., h]], edges, faces }] } +} + +/// A sphere of radius `r` at the origin: two hemispheres meeting at the equator. +fn sphere(r: f64) -> Brep { + let (tau, half) = (std::f64::consts::TAU, std::f64::consts::FRAC_PI_2); + let hemisphere = |latitude: [f64; 2], forward| Face { + loops: vec![vec![uses(0, forward)]], + surface: Surface::Sphere { frame: frame_at([0.; 3]), radius: r, longitude: [0., tau], latitude }, + forward: true, + }; + let faces = vec![hemisphere([0., half], true), hemisphere([-half, 0.], false)]; + Brep { version: crate::brep::VERSION, shells: vec![Shell { vertices: vec![[r, 0., 0.]], edges: vec![circle([0.; 3], r, 0)], faces }] } +} + +/// A torus about Z (major radius `big`, minor `small`): top and bottom halves meeting at the +/// outer and inner equators. +fn torus(big: f64, small: f64) -> Brep { + let (tau, pi) = (std::f64::consts::TAU, std::f64::consts::PI); + let half = |meridian: [f64; 2], forward: bool| Face { + loops: vec![vec![uses(0, forward)], vec![uses(1, !forward)]], + surface: Surface::Torus { frame: frame_at([0.; 3]), major_radius: big, minor_radius: small, longitude: [0., tau], meridian }, + forward: true, + }; + let edges = vec![circle([0.; 3], big + small, 0), circle([0.; 3], big - small, 1)]; + let faces = vec![half([0., pi], true), half([pi, tau], false)]; + Brep { version: crate::brep::VERSION, shells: vec![Shell { vertices: vec![[big + small, 0., 0.], [big - small, 0., 0.]], edges, faces }] } +} + +/// A bilinear (twisted) spline patch over x, y in [-5, 5] with z between 0 and 1. +fn twisted_patch() -> Brep { + let p = [[-5., -5., 0.], [5., -5., 1.], [5., 5., 0.], [-5., 5., 1.]]; + let edges = + (0..4).map(|i| Edge { vertices: [i as u32, ((i + 1) % 4) as u32], curve: Curve::Line { start: p[i], end: p[(i + 1) % 4] } }).collect(); + let face = Face { + loops: vec![(0..4).map(|e| uses(e, true)).collect()], + surface: Surface::Spline { u_knots: vec![0., 0., 1., 1.], v_knots: vec![0., 0., 1., 1.], points: vec![vec![p[0], p[3]], vec![p[1], p[2]]] }, + forward: true, + }; + Brep { version: crate::brep::VERSION, shells: vec![Shell { vertices: p.to_vec(), edges, faces: vec![face] }] } +} + +fn pieces(d: &Drawing) -> impl Iterator { + d.lines.iter().flat_map(|l| l.pieces.iter()) +} + +/// The turn of a circle edge's pieces drawn as exact conic arcs, or `None` when they are all +/// drawn as chords (weights too far apart to certify the circle): never a wrong exact arc. +fn turn_or_chords<'a>(pieces: impl Iterator) -> Option { + let pieces: Vec<&Piece> = pieces.collect(); + if pieces.iter().all(|p| matches!(p.accuracy, CurveAccuracy::Sampled { .. })) { + return None; + } + let mut sweep = 0.; + for p in pieces { + assert_eq!(p.accuracy, CurveAccuracy::Analytic, "{p:?}"); + for g in &p.geometry { + let Seg2::Conic { w, .. } = *g else { panic!("a circle is drawn as conic arcs: {g:?}") }; + sweep += 2. * w.acos(); + } + } + Some(sweep) +} + +fn silhouette_pieces(d: &Drawing) -> Vec<&Piece> { + d.lines.iter().filter(|l| matches!(l.source, Source::Silhouette { .. })).flat_map(|l| l.pieces.iter()).collect() +} + +fn edge_line(d: &Drawing, placement: usize, edge: usize) -> &Line { + let found = d.lines.iter().find(|l| l.placement == placement && matches!(l.source, Source::Edge { edge: e, .. } if e == edge)); + found.unwrap_or_else(|| panic!("no line for edge {edge} of placement {placement}")) +} + +#[test] +fn a_cylinder_seen_from_the_side_has_exact_silhouette_lines() { + let def = prepare(&cylinder(10., 30.), &Options::default()).unwrap(); + // Slightly from above, along +X (rulings at +-90 degrees) and along +Y (rulings at 0 and + // 180 degrees: one runs along the seam, which is not drawn as an edge). + for dir in [Vec3::new(1., 0., -0.3), Vec3::new(0., 1., -0.3)] { + let view = View::new(dir, Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(std::slice::from_ref(&def), &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + check_cylinder_sides(&d, &view); + } +} + +fn check_cylinder_sides(d: &Drawing, view: &View) { + let sil = silhouette_pieces(d); + assert!(!sil.is_empty()); + let rise = 30. * view.y.z; + let mut sides = Vec::new(); + for p in sil { + assert_eq!(p.accuracy, CurveAccuracy::Analytic); + assert_eq!(p.visibility, Visibility::Visible); + for g in &p.geometry { + let Seg2::Line { a, b } = *g else { panic!("a silhouette ruling is a line: {g:?}") }; + assert!((a.x.abs() - 10.).abs() < 1e-12 && (a.x - b.x).abs() < 1e-12, "{g:?}"); + sides.push((a.x.signum(), (b.y - a.y).abs())); + } + } + for side in [-1., 1.] { + let len: f64 = sides.iter().filter(|s| s.0 == side).map(|s| s.1).sum(); + assert!((len - rise).abs() < 1e-9, "side {side}: {len} vs {rise}"); + } + assert_eq!(d.stats.uncertain_uncovered, 0, "{:?}", d.stats); + assert!(d.unsupported.is_empty()); +} + +#[test] +fn a_sphere_outline_is_exact_conic_arcs_in_any_view() { + let def = prepare(&sphere(10.), &Options::default()).unwrap(); + let views = [ + (Vec3::new(1., 2., 3.), Vec3::new(0., 0., 1.)), + (Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)), + (Vec3::new(-0.3, 1., 0.1), Vec3::new(0., 0., 1.)), + ]; + for (dir, up) in views { + let view = View::new(dir, up).unwrap(); + let d = hidden_lines(std::slice::from_ref(&def), &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + // The outline: every uncovered visible piece lies on the circle of radius 10. + let mut drawn = 0.; + for p in + pieces(&d).filter(|p| p.visibility == Visibility::Visible && !p.covered && p.geometry.iter().all(|g| matches!(g, Seg2::Conic { .. }))) + { + assert_eq!(p.accuracy, CurveAccuracy::Analytic); + for g in &p.geometry { + let ends = (g.point_at(0.), g.point_at(1.)); + if (ends.0.len() - 10.).abs() > 1e-9 { + continue; + } + for k in 0..=16 { + let q = g.point_at(k as f64 / 16.); + assert!((q.len() - 10.).abs() < 1e-9, "{dir:?}: {q:?} off the outline"); + } + drawn += ends.0.dist(ends.1); + } + } + assert!(drawn > 40., "{dir:?}: the outline is drawn ({drawn} mm of chords)"); + for p in silhouette_pieces(&d) { + assert!(p.geometry.iter().all(|g| matches!(g, Seg2::Conic { .. })), "{dir:?}: {p:?}"); + } + assert_eq!(d.stats.uncertain_uncovered, 0, "{dir:?}: {:?}", d.stats); + } +} + +#[test] +fn a_torus_seen_face_on_is_drawn_at_both_equators() { + let def = prepare(&torus(20., 5.), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let (mut outer, mut inner) = (false, false); + for p in pieces(&d).filter(|p| p.visibility == Visibility::Visible && !p.covered) { + for g in &p.geometry { + for k in 0..=8 { + let r = g.point_at(k as f64 / 8.).len(); + assert!((r - 25.).abs() < 1e-9 || (r - 15.).abs() < 1e-9, "a point at radius {r}"); + outer |= (r - 25.).abs() < 1e-9; + inner |= (r - 15.).abs() < 1e-9; + } + } + } + assert!(outer && inner); + // Both equators are silhouettes along the seams between the halves: one curve each. + assert_eq!(d.stats.silhouettes, 2, "{:?}", d.stats); + assert_eq!(d.stats.uncertain_uncovered, 0, "{:?}", d.stats); +} + +#[test] +fn an_edge_under_a_torus_is_hidden_where_the_tube_covers_it() { + // Looking down on a torus (R 20, r 5) over a long bar: the bar's top edge along y = 1 is + // hidden where sqrt(x^2 + 1) is between 15 and 25. + let t = prepare(&torus(20., 5.), &Options::default()).unwrap(); + let bar = prepare(&cuboid([-40., -1., -12.], [40., 1., -10.]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[t, bar], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + // Edge 3 joins corners 6 (-40, 1, -10) and 7 (40, 1, -10): x = 80 t - 40. + let hidden: Vec<[f64; 2]> = + edge_line(&d, 1, 3).pieces.iter().filter(|p| p.visibility == Visibility::Hidden).map(|p| [p.t[0] * 80. - 40., p.t[1] * 80. - 40.]).collect(); + let (a, b) = (224f64.sqrt(), 624f64.sqrt()); + assert_eq!(hidden.len(), 2, "{hidden:?}"); + for (h, want) in hidden.iter().zip([[-b, -a], [a, b]]) { + assert!((h[0] - want[0]).abs() < 1e-9 && (h[1] - want[1]).abs() < 1e-9, "{hidden:?}"); + } + assert_eq!(d.stats.uncertain_uncovered, 0, "{:?}", d.stats); +} + +#[test] +fn a_part_far_from_the_origin_draws_the_same_as_at_the_origin() { + let def = prepare(&cylinder(10., 30.), &Options::default()).unwrap(); + let view = View::new(Vec3::new(-1., -0.7, -0.4), Vec3::new(0., 0., 1.)).unwrap(); + let defs = [def]; + let states = |d: &Drawing| -> Vec<(Visibility, bool, usize)> { pieces(d).map(|p| (p.visibility, p.covered, p.geometry.len())).collect() }; + let base = hidden_lines(&defs, &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + for t in [[1e5, 2e5, -3e5], [1e7, 2e7, -3e7]] { + let d = hidden_lines(&defs, &[identity(0, t)], &view, &Options::default(), &never).unwrap(); + assert_eq!(states(&base), states(&d), "classification moved at {t:?}"); + let shift = view.screen(Vec3::new(t[0], t[1], t[2])); + // Only the final shift back to the caller's frame rounds: about 1e-16 of |t|. + let tol = 1e-15 * (t[0].abs() + t[1].abs() + t[2].abs()) + 1e-9; + for (a, b) in pieces(&base).zip(pieces(&d)) { + for (g, h) in a.geometry.iter().zip(&b.geometry) { + for s in [0., 0.5, 1.] { + let (p, q) = (g.point_at(s), h.point_at(s)); + assert!((q.x - shift[0] - p.x).abs() < tol && (q.y - shift[1] - p.y).abs() < tol, "{p:?} vs {q:?} at {t:?}"); + } + } + } + } +} + +#[test] +fn freeform_faces_are_reported_and_never_hide_for_certain() { + let patch = prepare(&twisted_patch(), &Options::default()).unwrap(); + assert_eq!(patch.report.freeform_faces, vec![0]); + // A bar under the patch, seen from above: its top edges (2, 3, 6, 7) are behind the patch only. + let bar = prepare(&cuboid([-2., -1., -5.], [2., 1., -3.]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0.1, 0.05, -1.), Vec3::new(0., 1., 0.)).unwrap(); + let defs = [patch, bar]; + let scene = [identity(0, [0.; 3]), identity(1, [0.; 3])]; + let top = |d: &Drawing| -> Vec { + [2, 3, 6, 7].iter().flat_map(|&e| edge_line(d, 1, e).pieces.iter().map(|p| p.visibility)).collect::>() + }; + let d = hidden_lines(&defs, &scene, &view, &Options::default(), &never).unwrap(); + assert_eq!(d.unsupported, vec![Unsupported { placement: 0, face: 0, reason: "freeform face" }]); + assert!(top(&d).iter().all(|v| *v == Visibility::Uncertain), "{:?}", top(&d)); + // With freeform faces handled the patch hides them. + let d = hidden_lines(&defs, &scene, &view, &Options { freeform: true, ..Options::default() }, &never).unwrap(); + assert!(d.unsupported.is_empty()); + assert!(top(&d).iter().all(|v| *v == Visibility::Hidden), "{:?}", top(&d)); +} + +#[test] +fn a_flat_spline_patch_is_a_plane_that_hides_for_certain() { + // A bilinear patch with all four corners at z = 0 over a bar whose top is at z = -1. + let p = [[-5., -5., 0.], [5., -5., 0.], [5., 5., 0.], [-5., 5., 0.]]; + let edges = + (0..4).map(|i| Edge { vertices: [i as u32, ((i + 1) % 4) as u32], curve: Curve::Line { start: p[i], end: p[(i + 1) % 4] } }).collect(); + let face = Face { + loops: vec![(0..4).map(|e| uses(e, true)).collect()], + surface: Surface::Spline { u_knots: vec![0., 0., 1., 1.], v_knots: vec![0., 0., 1., 1.], points: vec![vec![p[0], p[3]], vec![p[1], p[2]]] }, + forward: true, + }; + let patch = + prepare(&Brep { version: crate::brep::VERSION, shells: vec![Shell { vertices: p.to_vec(), edges, faces: vec![face] }] }, &Options::default()) + .unwrap(); + assert!(patch.report.freeform_faces.is_empty()); + let bar = prepare(&cuboid([-2., -1., -3.], [2., 1., -1.]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0.1, 0.05, -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[patch, bar], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + assert!(d.unsupported.is_empty()); + let top: Vec = [2, 3, 6, 7].iter().flat_map(|&e| edge_line(&d, 1, e).pieces.iter().map(|p| p.visibility)).collect(); + assert!(top.iter().all(|v| *v == Visibility::Hidden), "{top:?}"); +} + +/// The Bernstein coefficients of the product of two polynomials given by theirs. +fn bernstein_product(a: &[f64], b: &[f64]) -> Vec { + let binom = |n: usize, k: usize| (0..k).fold(1f64, |acc, i| acc * (n - i) as f64 / (i + 1) as f64); + let (m, n) = (a.len() - 1, b.len() - 1); + (0..=m + n) + .map(|k| { + let terms = (k.saturating_sub(n)..=k.min(m)).map(|i| binom(m, i) * binom(n, k - i) * a[i] * b[k - i]); + terms.sum::() / binom(m + n, k) + }) + .collect() +} + +#[test] +fn a_circle_racing_through_its_first_turn_is_drawn_whole_edge_on() { + // A closed rational sextic on the circle of radius 10: 10 (A + iB)^6 / |A + iB|^6 with + // A = 0.05 + 0.95 t, B = sqrt 3 t. It turns 3.6 rad in its first screen sample step, and + // seen along its plane that step looks straight; the arcs must still turn 360 degrees. + // A + iB = 0.05 (1 - t) + (1 + i sqrt 3) t, so (A + iB)^6 has the Bernstein coefficients + // 0.05^(6-k) (1 + i sqrt 3)^k, and A^2 + B^2 the coefficients 0.0025, 0.05, 4. + let c1 = (1f64, 3f64.sqrt()); + let mut num = Vec::new(); + let mut pw = (1f64, 0f64); + for k in 0..=6 { + num.push((0.05f64.powi(6 - k) * pw.0, 0.05f64.powi(6 - k) * pw.1)); + pw = (pw.0 * c1.0 - pw.1 * c1.1, pw.0 * c1.1 + pw.1 * c1.0); + } + let q = [0.0025, 0.05, 4.]; + let w = bernstein_product(&bernstein_product(&q, &q), &q); + let homogeneous: Vec<[f64; 4]> = num.iter().zip(&w).map(|((re, im), w)| [10. * re, 10. * im, 0., *w]).collect(); + let start = [10., 0., 0.]; + let disc = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![start], + edges: vec![Edge { vertices: [0, 0], curve: Curve::RationalSpline { knots: [[0.; 7], [1.; 7]].concat(), homogeneous } }], + 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, + }], + }], + }; + let def = prepare(&disc, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + if let Some(sweep) = turn_or_chords(pieces(&d)) { + assert!((sweep - std::f64::consts::TAU).abs() < 1e-9, "the arcs turn {sweep} rad"); + } +} + +#[test] +fn an_arc_racing_through_almost_a_full_turn_is_drawn_whole() { + // 10 z^4 / |z|^4 with z = (1 - t) + 400 e^(i (pi/2 - 0.01)) t: an exact arc of radius 10 that + // turns 2 pi - 0.04 almost all within t < 0.01, closed by a short line. Seen along its plane + // at -0.45 rad, the first samples look straight and turn little between them; the arcs + // must still turn the whole 2 pi - 0.04. + let phi = std::f64::consts::FRAC_PI_2 - 0.01; + let c1 = (400. * phi.cos(), 400. * phi.sin()); + let mut num = Vec::new(); + let mut pw = (1f64, 0f64); + for _ in 0..=4 { + num.push(pw); + pw = (pw.0 * c1.0 - pw.1 * c1.1, pw.0 * c1.1 + pw.1 * c1.0); + } + let q = [1., c1.0, 160_000.]; + let w = bernstein_product(&q, &q); + let homogeneous: Vec<[f64; 4]> = num.iter().zip(&w).map(|((re, im), w)| [10. * re, 10. * im, 0., *w]).collect(); + let sweep_want = 4. * phi; + let end = [10. * sweep_want.cos(), 10. * sweep_want.sin(), 0.]; + let shape = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[10., 0., 0.], end], + edges: vec![ + Edge { vertices: [0, 1], curve: Curve::RationalSpline { knots: [[0.; 5], [1.; 5]].concat(), homogeneous } }, + Edge { vertices: [1, 0], curve: Curve::Line { start: end, end: [10., 0., 0.] } }, + ], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(1, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + }; + let def = prepare(&shape, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0.45f64.sin(), 0.45f64.cos(), 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + if let Some(sweep) = turn_or_chords(edge_line(&d, 0, 0).pieces.iter()) { + assert!((sweep - sweep_want).abs() < 1e-9, "the arcs turn {sweep} rad, want {sweep_want}"); + } +} + +#[test] +fn a_short_piece_of_a_fast_arc_turns_its_own_way() { + // An exact arc of radius 10 as a quartic rational spline of two spans: 0 to 5 rad over the + // parameters 0 to 1e-6, then on to 5.8 rad; a chord closes the loop. A box in front, seen + // along -Z, cuts the arc into pieces a fraction of 1e-6 long that turn several radians. + let span = |c0: (f64, f64), c1: (f64, f64)| -> Vec<[f64; 4]> { + let mul = |a: (f64, f64), b: (f64, f64)| (a.0 * b.0 - a.1 * b.1, a.0 * b.1 + a.1 * b.0); + let pw = |c: (f64, f64), k: usize| (0..k).fold((1., 0.), |acc, _| mul(acc, c)); + let q = [c0.0 * c0.0 + c0.1 * c0.1, c0.0 * c1.0 + c0.1 * c1.1, c1.0 * c1.0 + c1.1 * c1.1]; + let w = bernstein_product(&q, &q); + (0..=4) + .map(|k| { + let z = mul(pw(c0, 4 - k), pw(c1, k)); + [10. * z.0, 10. * z.1, 0., w[k]] + }) + .collect() + }; + let unit = |a: f64| (a.cos(), a.sin()); + // Weights far apart in the first span (|z| from 1 to 40) crowd its turn near its start. + let fast = (40. * 1.25f64.cos(), 40. * 1.25f64.sin()); + let first = span(unit(0.), fast); + let second = span(fast, unit(1.45)); + let homogeneous: Vec<[f64; 4]> = first.iter().chain(second.iter().skip(1)).copied().collect(); + let end = [10. * 5.8f64.cos(), 10. * 5.8f64.sin(), 0.]; + let shape = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[10., 0., 0.], end], + edges: vec![ + Edge { vertices: [0, 1], curve: Curve::RationalSpline { knots: [vec![0.; 5], vec![1e-6; 4], vec![1.; 5]].concat(), homogeneous } }, + Edge { vertices: [1, 0], curve: Curve::Line { start: end, end: [10., 0., 0.] } }, + ], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(1, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + }; + let arc = prepare(&shape, &Options::default()).unwrap(); + let block = prepare(&cuboid([-8.2, -11., 1.], [11., -0.001, 2.]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[arc, block], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let line = edge_line(&d, 0, 0); + assert!(line.pieces.len() > 1, "the block splits the arc"); + if let Some(sweep) = turn_or_chords(line.pieces.iter()) { + assert!((sweep - 5.8).abs() < 1e-9, "the pieces turn {sweep} rad in all, want 5.8"); + } +} + +/// The homogeneous control points of an exact quartic arc of radius 10 about the origin from +/// angle `a0` to `a1` (z^4 / |z|^4 with z running straight between the quarter angles). +fn quartic_arc(a0: f64, a1: f64) -> Vec<[f64; 4]> { + let (c0, c1) = ((a0 / 4.).cos(), (a0 / 4.).sin()); + let (d0, d1) = ((a1 / 4.).cos(), (a1 / 4.).sin()); + let mul = |a: (f64, f64), b: (f64, f64)| (a.0 * b.0 - a.1 * b.1, a.0 * b.1 + a.1 * b.0); + let pw = |c: (f64, f64), k: usize| (0..k).fold((1., 0.), |acc, _| mul(acc, c)); + let q = [1., c0 * d0 + c1 * d1, 1.]; + let w = bernstein_product(&q, &q); + (0..=4) + .map(|k| { + let z = mul(pw((c0, c1), 4 - k), pw((d0, d1), k)); + [10. * z.0, 10. * z.1, 0., w[k]] + }) + .collect() +} + +#[test] +fn an_arc_whose_whole_domain_is_8_roundings_wide_is_not_guessed() { + // An exact arc of radius 10 turning +3.75 rad over the parameters 0.25 to 0.25 + 2^-51 + // (8 roundings), closed by a chord: drawn as the right arc or as chords, never as the short + // arc the other way round, through (0, -10). + let k = 0.25 + 2f64.powi(-51); + let end = [10. * 3.75f64.cos(), 10. * 3.75f64.sin(), 0.]; + let shape = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[10., 0., 0.], end], + edges: vec![ + Edge { + vertices: [0, 1], + curve: Curve::RationalSpline { knots: [vec![0.25; 5], vec![k; 5]].concat(), homogeneous: quartic_arc(0., 3.75) }, + }, + Edge { vertices: [1, 0], curve: Curve::Line { start: end, end: [10., 0., 0.] } }, + ], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(1, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + }; + let def = prepare(&shape, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + if let Some(sweep) = turn_or_chords(edge_line(&d, 0, 0).pieces.iter()) { + assert!((sweep - 3.75).abs() < 1e-9, "the arcs turn {sweep} rad"); + } + for p in &edge_line(&d, 0, 0).pieces { + for g in &p.geometry { + for s in [0., 0.25, 0.5, 0.75, 1.] { + assert!(g.point_at(s).dist(Vec2::new(0., -10.)) > 1., "{g:?} passes the wrong side"); + } + } + } +} + +#[test] +fn a_piece_in_a_knot_span_of_1e_11_turns_its_own_way() { + // An exact arc of radius 10 in three quartic spans: 0 to 0.1 rad, then 0.1 to 3.85 rad over + // the parameters 0.25 to 0.25 + 1e-11, then on to 4.5 rad. Two blocks in front hide the + // outer spans, so the middle one is a visible piece of its own turning 3.75 rad. + let span = quartic_arc; + let (s1, s2, s3) = (span(0., 0.1), span(0.1, 3.85), span(3.85, 4.5)); + let homogeneous: Vec<[f64; 4]> = s1.iter().chain(s2.iter().skip(1)).chain(s3.iter().skip(1)).copied().collect(); + let k = 0.25 + 1e-11; + let end = [10. * 4.5f64.cos(), 10. * 4.5f64.sin(), 0.]; + let shape = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[10., 0., 0.], end], + edges: vec![ + Edge { + vertices: [0, 1], + curve: Curve::RationalSpline { knots: [vec![0.; 5], vec![0.25; 4], vec![k; 4], vec![1.; 5]].concat(), homogeneous }, + }, + Edge { vertices: [1, 0], curve: Curve::Line { start: end, end: [10., 0., 0.] } }, + ], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(1, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + }; + let arc = prepare(&shape, &Options::default()).unwrap(); + let low = prepare(&cuboid([9., -1., 1.], [11., 10. * 0.1f64.sin(), 2.]), &Options::default()).unwrap(); + let high = prepare(&cuboid([-11., -11., 1.], [0., 10. * 3.85f64.sin(), 2.]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let scene = [identity(0, [0.; 3]), identity(1, [0.; 3]), identity(2, [0.; 3])]; + let d = hidden_lines(&[arc, low, high], &scene, &view, &Options::default(), &never).unwrap(); + let line = edge_line(&d, 0, 0); + let mut sweep = 0.; + for p in &line.pieces { + for g in &p.geometry { + match *g { + Seg2::Conic { w, .. } => sweep += 2. * w.acos(), + _ => panic!("an arc is drawn as conic arcs: {g:?}"), + } + } + } + assert!(line.pieces.len() >= 3, "{:?}", line.pieces.iter().map(|p| p.t).collect::>()); + assert!((sweep - 4.5).abs() < 1e-9, "the pieces turn {sweep} rad in all, want 4.5"); +} + +#[test] +fn a_curve_that_swerves_where_its_weight_is_near_zero_is_not_taken_for_a_circle() { + // 1e-12 (1 - t)^3 (0, -10) + t Q(t), Q the exact quarter circle of radius 10 from (10, 0) + // to (0, 10): on the circle at every check point to 1.3e-9 mm, yet near t = 1e-12, where + // its weight is tiny, it passes (5, -5), 2.9 mm inside. Drawn as chords, not an arc. + let s = std::f64::consts::FRAC_1_SQRT_2; + let q = [[10., 0., 0., 1.], [10. * s, 10. * s, 0., s], [0., 10., 0., 1.]]; + let homogeneous: Vec<[f64; 4]> = (0..4) + .map(|k| { + let tq: Vec = (0..4).map(|c| bernstein_product(&[0., 1.], &q.map(|h| h[c]))[k]).collect(); + let a = if k == 0 { [0., -10e-12, 0., 1e-12] } else { [0.; 4] }; + [tq[0] + a[0], tq[1] + a[1], tq[2] + a[2], tq[3] + a[3]] + }) + .collect(); + let shape = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[0., -10., 0.], [0., 10., 0.]], + edges: vec![ + Edge { vertices: [0, 1], curve: Curve::RationalSpline { knots: [[0.; 4], [1.; 4]].concat(), homogeneous } }, + Edge { vertices: [1, 0], curve: Curve::Line { start: [0., 10., 0.], end: [0., -10., 0.] } }, + ], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(1, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + }; + let def = prepare(&shape, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + assert!(edge_line(&d, 0, 0).pieces.iter().all(|p| matches!(p.accuracy, CurveAccuracy::Sampled { .. })), "{d:?}"); +} + +#[test] +fn an_edge_leaving_a_left_out_faces_box_in_front_stays_visible() { + // Over the twisted patch (x, y in [-5, 5], z in [0, 1]), seen from above, an edge from + // (-10, 0, -1) up to (0, 0, 5): below the patch's level only outside its box, inside the + // box above the patch. Nothing can hide it. + let patch = prepare(&twisted_patch(), &Options::default()).unwrap(); + let c = [[-10., 0., -1.], [0., 0., 5.], [0., 0.2, 5.], [-10., 0.2, -1.]]; + let edges = + (0..4).map(|i| Edge { vertices: [i as u32, ((i + 1) % 4) as u32], curve: Curve::Line { start: c[i], end: c[(i + 1) % 4] } }).collect(); + let face = Face { + loops: vec![(0..4).map(|e| uses(e, true)).collect()], + surface: Surface::Plane { origin: c[0], u_axis: [10., 0., 6.], v_axis: [0., 0.2, 0.] }, + forward: true, + }; + let strip = + prepare(&Brep { version: crate::brep::VERSION, shells: vec![Shell { vertices: c.to_vec(), edges, faces: vec![face] }] }, &Options::default()) + .unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[patch, strip], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let states: Vec<([f64; 2], Visibility)> = edge_line(&d, 1, 0).pieces.iter().map(|p| (p.t, p.visibility)).collect(); + assert!(states.iter().all(|(_, v)| *v == Visibility::Visible), "{states:?}"); +} + +#[test] +fn a_nearly_straight_cubic_is_not_taken_for_a_huge_circle() { + // x = 1000 t, y = 0.071 t - 0.113 t^2 + 0.042 t^3: within 0.002 mm of a circle of radius + // about 1e7, which is 2e-10 of that radius but 2e-6 of the curve. Drawn as chords. + let pts = [[0., 0., 0.], [1000. / 3., 0.071 / 3., 0.], [2000. / 3., 0.071 * 2. / 3. - 0.113 / 3., 0.], [1000., 0., 0.]]; + let shape = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[0.; 3], [1000., 0., 0.]], + edges: vec![ + Edge { vertices: [0, 1], curve: Curve::Spline { knots: [[0.; 4], [1.; 4]].concat(), points: pts.to_vec() } }, + Edge { vertices: [1, 0], curve: Curve::Line { start: [1000., 0., 0.], end: [0.; 3] } }, + ], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(1, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + }; + let def = prepare(&shape, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + assert!(edge_line(&d, 0, 0).pieces.iter().all(|p| matches!(p.accuracy, CurveAccuracy::Sampled { .. })), "{d:?}"); +} + +#[test] +fn an_edge_dipping_behind_a_left_out_face_between_samples_is_uncertain() { + // Under a freeform patch over x in [0, 10], y in [0, 1] (z within 1e-4 of zero, zero along + // y = 0.5), a quadratic edge x = 10 t, y = 0.5, z = 20 (t - 0.0075)(t - 0.02375) dips + // below the patch for t from 0.0075 to 0.02375, between its samples; its projection is + // straight, so the screen never asks for more. + let e = 1e-4; + let p = [[0., 0., e], [10., 0., -e], [10., 1., e], [0., 1., -e]]; + let edges = + (0..4).map(|i| Edge { vertices: [i as u32, ((i + 1) % 4) as u32], curve: Curve::Line { start: p[i], end: p[(i + 1) % 4] } }).collect(); + let face = Face { + loops: vec![(0..4).map(|e| uses(e, true)).collect()], + surface: Surface::Spline { u_knots: vec![0., 0., 1., 1.], v_knots: vec![0., 0., 1., 1.], points: vec![vec![p[0], p[3]], vec![p[1], p[2]]] }, + forward: true, + }; + let patch = + prepare(&Brep { version: crate::brep::VERSION, shells: vec![Shell { vertices: p.to_vec(), edges, faces: vec![face] }] }, &Options::default()) + .unwrap(); + assert_eq!(patch.report.freeform_faces, vec![0]); + // z = 20 t^2 - 0.625 t + 0.0035625 as a quadratic Bezier. + let z = [0.0035625, 0.0035625 - 0.3125, 20. - 0.625 + 0.0035625]; + let c = [[0., 0.5, z[0]], [5., 0.5, z[1]], [10., 0.5, z[2]]]; + let shape = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![c[0], c[2]], + edges: vec![ + Edge { vertices: [0, 1], curve: Curve::Spline { knots: vec![0., 0., 0., 1., 1., 1.], points: c.to_vec() } }, + Edge { vertices: [1, 0], curve: Curve::Line { start: c[2], end: c[0] } }, + ], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(1, true)]], + surface: Surface::Plane { origin: c[0], u_axis: [1., 0., 0.], v_axis: [0., 0., 1.] }, + forward: true, + }], + }], + }; + let curve = prepare(&shape, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[patch, curve], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let dip: Vec<&Piece> = edge_line(&d, 1, 0).pieces.iter().filter(|p| p.t[0] < 0.02 && p.t[1] > 0.01).collect(); + assert!(!dip.is_empty()); + assert!(dip.iter().all(|p| p.visibility != Visibility::Visible), "{dip:?}"); +} + +#[test] +fn an_arc_that_turns_back_is_drawn_as_chords() { + // (10 (1 - u^2), 20 u) / (1 + u^2) with u = 10 (t^3 / 3 - t^2 / 4 + 0.04 t): on the circle + // of radius 10, but u falls between t = 0.1 and 0.4, so the edge goes back down to + // y = -0.533 before it turns on to 1.78 rad. No single arc draws that: chords, which reach + // down there. + let u3 = [0., 0.4 / 3., 0.8 / 3. - 2.5 / 3., 0.4 - 2.5 + 10. / 3.]; + let u = bernstein_product(&u3, &[1.; 4]); + let uu = bernstein_product(&u3, &u3); + let homogeneous: Vec<[f64; 4]> = (0..7).map(|k| [10. * (1. - uu[k]), 20. * u[k], 0., 1. + uu[k]]).collect(); + let th = 2. * u3[3].atan(); + let end = [10. * th.cos(), 10. * th.sin(), 0.]; + let shape = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[10., 0., 0.], end], + edges: vec![ + Edge { vertices: [0, 1], curve: Curve::RationalSpline { knots: [[0.; 7], [1.; 7]].concat(), homogeneous } }, + Edge { vertices: [1, 0], curve: Curve::Line { start: end, end: [10., 0., 0.] } }, + ], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(1, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + }; + let def = prepare(&shape, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let line = edge_line(&d, 0, 0); + assert!(line.pieces.iter().all(|p| matches!(p.accuracy, CurveAccuracy::Sampled { .. })), "{:?}", line.pieces); + let low = line.pieces.iter().flat_map(|p| p.geometry.iter()).map(|g| g.point_at(0.).y.min(g.point_at(1.).y)).fold(f64::MAX, f64::min); + assert!(low < -0.5, "the chords reach down to y = {low}"); +} + +#[test] +fn a_cone_tip_trimmed_near_the_apex_is_only_its_tip() { + // A cone record of slope 0.25 from its apex up to 10 m, trimmed by a circle at z = 1 to the + // tip below it: a box edge at x = 40, z = 50 to 150, seen along +X, is in front of nothing. + let tau = std::f64::consts::TAU; + let tip = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[0.25, 0., 1.]], + edges: vec![circle([0., 0., 1.], 0.25, 0)], + faces: vec![Face { + loops: vec![vec![uses(0, false)]], + surface: Surface::Cone { + frame: frame_at([0.; 3]), + radius_at_origin: 0., + half_angle: 0.25f64.atan(), + angle: [0., tau], + height: [0., 10_000.], + }, + forward: true, + }], + }], + }; + let tip = prepare(&tip, &Options::default()).unwrap(); + let bar = prepare(&cuboid([40., -1., 50.], [41., 1., 150.]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(1., 0., 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[tip, bar], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + // Edges 8 and 10 run along z at x = 40, the side facing the viewer. + for e in [8, 10] { + let states: Vec = edge_line(&d, 1, e).pieces.iter().map(|p| p.visibility).collect(); + assert!(states.iter().all(|v| *v == Visibility::Visible), "edge {e}: {states:?}"); + } +} + +#[test] +fn an_edge_behind_a_left_out_freeform_face_is_uncertain_there() { + // A freeform tube about the vertical line x = 10 (radius 2.5 at its ends, 2.75 halfway up, + // a quadratic profile) in front of a long box edge at z = 5. Its silhouettes are left out, + // so nothing cuts the edge where the tube covers it (x from 7.25 to 12.75); that part may + // be hidden and must not come out visible. + let tau = std::f64::consts::TAU; + let profile = Curve::Spline { knots: vec![0., 0., 0., 1., 1., 1.], points: vec![[12.5, 0., 0.], [13., 0., 5.], [12.5, 0., 10.]] }; + let tube = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[12.5, 0., 0.], [12.5, 0., 10.]], + edges: vec![circle([10., 0., 0.], 2.5, 0), circle([10., 0., 10.], 2.5, 1), Edge { vertices: [0, 1], curve: profile.clone() }], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(2, true), uses(1, false), uses(2, false)]], + surface: Surface::Revolution { profile: Box::new(profile), origin: [10., 0., 0.], axis: [0., 0., 1.], angle: [0., tau] }, + forward: true, + }], + }], + }; + let tube = prepare(&tube, &Options::default()).unwrap(); + assert_eq!(tube.report.freeform_faces, vec![0]); + let bar = prepare(&cuboid([-50., 4., 4.], [50., 6., 5.]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[tube, bar], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + // Edge 2 joins corners 4 (-50, 4, 5) and 5 (50, 4, 5): x = 100 t - 50. + for p in &edge_line(&d, 1, 2).pieces { + let (x0, x1) = (100. * p.t[0] - 50., 100. * p.t[1] - 50.); + if x1 > 7.25 && x0 < 12.75 { + assert_ne!(p.visibility, Visibility::Visible, "x {x0}..{x1}"); + } + if x1 < 6. || x0 > 14. { + assert_eq!(p.visibility, Visibility::Visible, "x {x0}..{x1}"); + } + } +} + +#[test] +fn a_sloping_edge_that_runs_behind_a_left_out_face_is_uncertain() { + // Over the twisted patch (z = 0.5 along y = 0), seen from above, a sloping strip's edge runs + // from (-4, 0, 2) down to (4, 0, 0.4): in front of the patch at its middle, behind it past + // x = 3.5. All of it lies in the patch's screen box, so nothing cuts it there. + let patch = prepare(&twisted_patch(), &Options::default()).unwrap(); + let c = [[-4., 0., 2.], [4., 0., 0.4], [4., 0.2, 0.4], [-4., 0.2, 2.]]; + let edges = + (0..4).map(|i| Edge { vertices: [i as u32, ((i + 1) % 4) as u32], curve: Curve::Line { start: c[i], end: c[(i + 1) % 4] } }).collect(); + let face = Face { + loops: vec![(0..4).map(|e| uses(e, true)).collect()], + surface: Surface::Plane { origin: c[0], u_axis: [8., 0., -1.6], v_axis: [0., 0.2, 0.] }, + forward: true, + }; + let strip = + prepare(&Brep { version: crate::brep::VERSION, shells: vec![Shell { vertices: c.to_vec(), edges, faces: vec![face] }] }, &Options::default()) + .unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[patch, strip], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + for p in &edge_line(&d, 1, 0).pieces { + if 8. * p.t[1] - 4. > 3.6 { + assert_ne!(p.visibility, Visibility::Visible, "{p:?}"); + } + } +} + +#[test] +fn a_torus_stored_as_a_rational_spline_patch_is_recognised() { + // The patch of a torus R 20, r 5 with longitude and meridian both from -pi/4 to pi/4, as a + // degree (2, 2) rational spline, seen along the axis: a torus, not a freeform face, and the + // outer equator inside it is an exact silhouette arc of radius 25. + let (big, small) = (20., 5.); + let q = std::f64::consts::FRAC_PI_4; + let s2 = std::f64::consts::SQRT_2; + // The unit arc from -45 to 45 degrees: its control points and weights. + let arc = [([q.cos(), -q.sin()], 1.), ([s2, 0.], q.cos()), ([q.cos(), q.sin()], 1.)]; + let homogeneous: Vec> = arc + .iter() + .map(|(ci, wi)| { + arc.iter() + .map(|(mj, wj)| { + let (rho, h, w) = (big + small * mj[0], small * mj[1], wi * wj); + [rho * ci[0] * w, rho * ci[1] * w, h * w, w] + }) + .collect() + }) + .collect(); + let at = |t: f64, f: f64| [(big + small * f.cos()) * t.cos(), (big + small * f.cos()) * t.sin(), small * f.sin()]; + let parallel = |f: f64| crate::brep::Frame { origin: [0., 0., small * f.sin()], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] }; + let meridian = |t: f64| crate::brep::Frame { origin: [big * t.cos(), big * t.sin(), 0.], x_axis: [t.cos(), t.sin(), 0.], y_axis: [0., 0., 1.] }; + let edges = vec![ + Edge { vertices: [0, 1], curve: Curve::CircleArc { frame: parallel(-q), radius: big + small * q.cos(), angle: [-q, q] } }, + Edge { vertices: [1, 2], curve: Curve::CircleArc { frame: meridian(q), radius: small, angle: [-q, q] } }, + Edge { vertices: [3, 2], curve: Curve::CircleArc { frame: parallel(q), radius: big + small * q.cos(), angle: [-q, q] } }, + Edge { vertices: [0, 3], curve: Curve::CircleArc { frame: meridian(-q), radius: small, angle: [-q, q] } }, + ]; + let face = Face { + loops: vec![vec![uses(0, true), uses(1, true), uses(2, false), uses(3, false)]], + surface: Surface::RationalSpline { u_knots: vec![0., 0., 0., 1., 1., 1.], v_knots: vec![0., 0., 0., 1., 1., 1.], homogeneous }, + forward: true, + }; + let patch = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { vertices: vec![at(-q, -q), at(q, -q), at(q, q), at(-q, q)], edges, faces: vec![face] }], + }; + let def = prepare(&patch, &Options::default()).unwrap(); + assert!(def.report.freeform_faces.is_empty(), "{:?}", def.report); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + assert!(d.unsupported.is_empty()); + let mut length = 0.; + for p in silhouette_pieces(&d) { + assert_eq!(p.accuracy, CurveAccuracy::Analytic); + for g in &p.geometry { + for k in 0..=8 { + assert!((g.point_at(k as f64 / 8.).len() - 25.).abs() < 1e-6, "{g:?}"); + } + length += (0..16).map(|k| g.point_at(k as f64 / 16.).dist(g.point_at((k + 1) as f64 / 16.))).sum::(); + } + } + assert!((length - 25. * std::f64::consts::FRAC_PI_2).abs() < 0.01, "{length} mm of outline"); +} + +#[test] +fn a_torus_patch_with_a_bump_in_a_narrow_knot_span_is_not_taken_for_a_torus() { + // The torus patch R 20, r 5 again, its longitude in three spans, the middle one 0.002 of + // the parameters wide; one control point there pushed out by 0.2 %. Between the sample + // grid's points, but not between the checks on every knot span. + let (big, small) = (20., 5.); + let q = std::f64::consts::FRAC_PI_4; + let s2 = std::f64::consts::SQRT_2; + let ts = [-q, -q + 0.3, -q + 0.5, q]; + let mut lon: Vec<([f64; 2], f64)> = vec![([ts[0].cos(), ts[0].sin()], 1.)]; + for w in ts.windows(2) { + let (m, h) = (0.5 * (w[0] + w[1]), 0.5 * (w[1] - w[0])); + lon.push(([m.cos() / h.cos(), m.sin() / h.cos()], h.cos())); + lon.push(([w[1].cos(), w[1].sin()], 1.)); + } + let mer = [([q.cos(), -q.sin()], 1.), ([s2, 0.], q.cos()), ([q.cos(), q.sin()], 1.)]; + let homogeneous: Vec> = lon + .iter() + .enumerate() + .map(|(i, (ci, wi))| { + mer.iter() + .enumerate() + .map(|(j, (mj, wj))| { + let bump = if (i, j) == (3, 1) { 1.002 } else { 1. }; + let (rho, h, w) = ((big + small * mj[0]) * bump, small * mj[1], wi * wj); + [rho * ci[0] * w, rho * ci[1] * w, h * w, w] + }) + .collect() + }) + .collect(); + let at = |t: f64, f: f64| [(big + small * f.cos()) * t.cos(), (big + small * f.cos()) * t.sin(), small * f.sin()]; + let parallel = |f: f64| crate::brep::Frame { origin: [0., 0., small * f.sin()], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] }; + let meridian = |t: f64| crate::brep::Frame { origin: [big * t.cos(), big * t.sin(), 0.], x_axis: [t.cos(), t.sin(), 0.], y_axis: [0., 0., 1.] }; + let edges = vec![ + Edge { vertices: [0, 1], curve: Curve::CircleArc { frame: parallel(-q), radius: big + small * q.cos(), angle: [-q, q] } }, + Edge { vertices: [1, 2], curve: Curve::CircleArc { frame: meridian(q), radius: small, angle: [-q, q] } }, + Edge { vertices: [3, 2], curve: Curve::CircleArc { frame: parallel(q), radius: big + small * q.cos(), angle: [-q, q] } }, + Edge { vertices: [0, 3], curve: Curve::CircleArc { frame: meridian(-q), radius: small, angle: [-q, q] } }, + ]; + let face = Face { + loops: vec![vec![uses(0, true), uses(1, true), uses(2, false), uses(3, false)]], + surface: Surface::RationalSpline { + u_knots: vec![0., 0., 0., 0.1, 0.1, 0.102, 0.102, 1., 1., 1.], + v_knots: vec![0., 0., 0., 1., 1., 1.], + homogeneous, + }, + forward: true, + }; + let patch = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { vertices: vec![at(-q, -q), at(q, -q), at(q, q), at(-q, q)], edges, faces: vec![face] }], + }; + let def = prepare(&patch, &Options::default()).unwrap(); + assert_eq!(def.report.freeform_faces, vec![0], "{:?}", def.report); +} + +#[test] +fn a_big_circle_passing_behind_a_thin_left_out_face_is_uncertain_there() { + // A disc of radius 5000 whose edge passes under a freeform tube (radius 0.01 to about + // 0.02, a cubic profile) lying along Y over (5000, 0), with a cube 9 km away making the scene coarse. The edge is turned so + // that its samples on either side of the tube are 30 mm away at x = 4999.905: the chord + // between them misses the tube's box by less than its sag, crossing none of its sides. + let tau = std::f64::consts::TAU; + let turn = 8.5 * std::f64::consts::PI / 256.; + let (c, sn) = (turn.cos(), turn.sin()); + let frame = crate::brep::Frame { origin: [0.; 3], x_axis: [-c, -sn, 0.], y_axis: [sn, -c, 0.] }; + let disc = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[-5000. * c, -5000. * sn, 0.]], + edges: vec![Edge { vertices: [0, 0], curve: Curve::CircleArc { frame, radius: 5000., angle: [0., tau] } }], + 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, + }], + }], + }; + let profile = Curve::Spline { + knots: vec![0., 0., 0., 0., 1., 1., 1., 1.], + points: vec![[5000.01, -100., 1.], [5000.03, -33., 1.], [5000.01, 33., 1.], [5000.01, 100., 1.]], + }; + let end = |y: f64, v: u32| Edge { + vertices: [v, v], + curve: Curve::CircleArc { + frame: crate::brep::Frame { origin: [5000., y, 1.], x_axis: [1., 0., 0.], y_axis: [0., 0., 1.] }, + radius: 0.01, + angle: [0., tau], + }, + }; + let tube = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[5000.01, -100., 1.], [5000.01, 100., 1.]], + edges: vec![end(-100., 0), end(100., 1), Edge { vertices: [0, 1], curve: profile.clone() }], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(2, true), uses(1, false), uses(2, false)]], + surface: Surface::Revolution { profile: Box::new(profile), origin: [5000., 0., 1.], axis: [0., 1., 0.], angle: [0., tau] }, + forward: true, + }], + }], + }; + let defs = [ + prepare(&tube, &Options::default()).unwrap(), + prepare(&disc, &Options::default()).unwrap(), + prepare(&cuboid([9e6, 0., 0.], [9e6 + 1., 1., 1.]), &Options::default()).unwrap(), + ]; + assert_eq!(defs[0].report.freeform_faces, vec![0]); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&defs, &[identity(0, [0.; 3]), identity(1, [0.; 3]), identity(2, [0.; 3])], &view, &Options::default(), &never).unwrap(); + // The disc's edge reaches (5000, 0) at the parameter pi - turn. + let at = std::f64::consts::PI - turn; + let under: Vec<&Piece> = edge_line(&d, 1, 0).pieces.iter().filter(|p| p.t[0] <= at && p.t[1] >= at).collect(); + assert!(!under.is_empty()); + assert!(under.iter().all(|p| p.visibility != Visibility::Visible), "{under:?}"); +} + +#[test] +fn a_torus_in_two_halves_keeps_whole_outlines_with_fine_trims() { + // Seen along (0, 1, -1) the traced outline's chords stand up to their deviation off the + // torus; with a finer trim (768 samples a span) the boundary tolerance is smaller than + // that, and the outline must still not break into gaps. + let def = |n: usize| prepare(&torus(20., 5.), &Options { trim_samples: n, ..Options::default() }).unwrap(); + let view = View::new(Vec3::new(0., 1., -1.), Vec3::new(0., 0., 1.)).unwrap(); + let count = |n: usize| { + hidden_lines(&[def(n)], &[identity(0, [0.; 3])], &view, &Options { trim_samples: n, ..Options::default() }, &never).unwrap().stats.silhouettes + }; + assert_eq!(count(768), count(256)); +} + +#[test] +fn an_arc_folding_back_under_a_left_out_face_seen_edge_on_is_uncertain_there() { + // An arc of radius 10000 in the XZ plane, seen from above: its projection lies on the X axis + // and turns back at x = 10000 a quarter of the way into its first sample step, so the chord + // between its first samples (x 9999.953 and 9999.577) has no sag and stops short of the + // turn. A tiny freeform patch above x = 10000 covers the turn. The arc's angles are counted + // from 0 and from 8 pi. + for turns in [0., 4.] { + folding_arc_under_a_patch(turns * std::f64::consts::TAU); + } +} + +fn folding_arc_under_a_patch(offset: f64) { + let step = std::f64::consts::PI / 256.; + let ta = -step / 4. + offset; + let frame = crate::brep::Frame { origin: [0.; 3], x_axis: [1., 0., 0.], y_axis: [0., 0., 1.] }; + let at = |t: f64| [10000. * t.cos(), 0., 10000. * t.sin()]; + let (start, end) = (at(ta), at(ta + std::f64::consts::PI / 8.)); + let arc = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![start, end], + edges: vec![ + Edge { vertices: [0, 1], curve: Curve::CircleArc { frame, radius: 10000., angle: [ta, ta + std::f64::consts::PI / 8.] } }, + Edge { vertices: [1, 0], curve: Curve::Line { start: end, end: start } }, + ], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(1, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 0., 1.] }, + forward: true, + }], + }], + }; + let (x0, x1, y0, y1) = (9999.98, 10000.02, -0.02, 0.02); + let p = [[x0, y0, 1.], [x1, y0, 1.0004], [x1, y1, 1.], [x0, y1, 1.0004]]; + let edges = + (0..4).map(|i| Edge { vertices: [i as u32, ((i + 1) % 4) as u32], curve: Curve::Line { start: p[i], end: p[(i + 1) % 4] } }).collect(); + let face = Face { + loops: vec![(0..4).map(|e| uses(e, true)).collect()], + surface: Surface::Spline { u_knots: vec![0., 0., 1., 1.], v_knots: vec![0., 0., 1., 1.], points: vec![vec![p[0], p[3]], vec![p[1], p[2]]] }, + forward: true, + }; + let patch = Brep { version: crate::brep::VERSION, shells: vec![Shell { vertices: p.to_vec(), edges, faces: vec![face] }] }; + let defs = [prepare(&patch, &Options::default()).unwrap(), prepare(&arc, &Options::default()).unwrap()]; + assert_eq!(defs[0].report.freeform_faces, vec![0]); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&defs, &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let turn: Vec<&Piece> = edge_line(&d, 1, 0).pieces.iter().filter(|p| p.t[0] <= offset && p.t[1] >= offset).collect(); + assert!(!turn.is_empty()); + assert!(turn.iter().all(|p| p.visibility != Visibility::Visible), "{turn:?}"); +} + +#[test] +fn a_left_out_face_curling_over_its_own_edge_makes_it_uncertain() { + // A freeform patch X = 1000 u (u - 0.5), Y = 8000 v, Z = 0.01 u seen from above: at u = 0.5 + // it passes back over its own edge u = 0, 0.005 mm above it, closer than the own-face + // margin of the ray test. The edge may be hidden. + let (a, b, c) = ([0., 0.], [-250., 0.005], [500., 0.01]); + let at = |q: [f64; 2], y: f64| [q[0], y, q[1]]; + let edges = vec![ + Edge { vertices: [0, 1], curve: Curve::Spline { knots: vec![0., 0., 0., 1., 1., 1.], points: vec![at(a, 0.), at(b, 0.), at(c, 0.)] } }, + Edge { vertices: [1, 2], curve: Curve::Line { start: at(c, 0.), end: at(c, 8000.) } }, + Edge { + vertices: [3, 2], + curve: Curve::Spline { knots: vec![0., 0., 0., 1., 1., 1.], points: vec![at(a, 8000.), at(b, 8000.), at(c, 8000.)] }, + }, + Edge { vertices: [0, 3], curve: Curve::Line { start: at(a, 0.), end: at(a, 8000.) } }, + ]; + let face = Face { + loops: vec![vec![uses(0, true), uses(1, true), uses(2, false), uses(3, false)]], + surface: Surface::Spline { + u_knots: vec![0., 0., 0., 1., 1., 1.], + v_knots: vec![0., 0., 1., 1.], + points: vec![vec![at(a, 0.), at(a, 8000.)], vec![at(b, 0.), at(b, 8000.)], vec![at(c, 0.), at(c, 8000.)]], + }, + forward: true, + }; + let sheet = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { vertices: vec![at(a, 0.), at(c, 0.), at(c, 8000.), at(a, 8000.)], edges, faces: vec![face] }], + }; + let def = prepare(&sheet, &Options::default()).unwrap(); + assert_eq!(def.report.freeform_faces, vec![0]); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let states: Vec = edge_line(&d, 0, 3).pieces.iter().map(|p| p.visibility).collect(); + assert!(states.iter().all(|v| *v != Visibility::Visible), "{states:?}"); +} + +#[test] +fn a_plate_just_above_a_box_in_a_large_scene_is_not_looked_through() { + // A box with its top at z = 0, a larger plate 0.005 mm above it and a cube 1e7 mm away: + // the box's top edges are under the plate and must not come out visible. + let defs = [ + prepare(&cuboid([0., 0., -10.], [10., 10., 0.]), &Options::default()).unwrap(), + prepare(&cuboid([-5., -5., 0.005], [15., 15., 0.007]), &Options::default()).unwrap(), + prepare(&cuboid([1e7, 0., 0.], [1e7 + 1., 1., 1.]), &Options::default()).unwrap(), + ]; + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&defs, &[identity(0, [0.; 3]), identity(1, [0.; 3]), identity(2, [0.; 3])], &view, &Options::default(), &never).unwrap(); + // Edges 2, 3, 6 and 7 of the box run along its top face. + for e in [2, 3, 6, 7] { + let states: Vec = edge_line(&d, 0, e).pieces.iter().map(|p| p.visibility).collect(); + assert!(states.iter().all(|v| *v != Visibility::Visible), "edge {e}: {states:?}"); + } +} + +#[test] +fn a_sphere_with_a_spline_band_between_record_caps_is_one_sphere() { + // A sphere of radius 10: a rational spline band all the way round between the latitudes + // -45 and 45 degrees, and two sphere records as caps, meeting at circles of radius 7.07. + // The band is recognised as the same sphere, so those circles are seams: seen along the + // axis only the outline of radius 10 is drawn. + let (r, q, s) = (10., std::f64::consts::FRAC_PI_4, std::f64::consts::FRAC_1_SQRT_2); + let ring = [ + ([1., 0.], 1.), + ([1., 1.], s), + ([0., 1.], 1.), + ([-1., 1.], s), + ([-1., 0.], 1.), + ([-1., -1.], s), + ([0., -1.], 1.), + ([1., -1.], s), + ([1., 0.], 1.), + ]; + let lat = [([r * q.cos(), -r * q.sin()], 1.), ([r / q.cos(), 0.], q.cos()), ([r * q.cos(), r * q.sin()], 1.)]; + let homogeneous: Vec> = ring + .iter() + .map(|(ci, wi)| lat.iter().map(|(pj, wj)| [pj[0] * ci[0] * wi * wj, pj[0] * ci[1] * wi * wj, pj[1] * wi * wj, wi * wj]).collect()) + .collect(); + let z = r * q.sin(); + let rho = r * q.cos(); + let tau = std::f64::consts::TAU; + let half = std::f64::consts::FRAC_PI_2; + let cap = |latitude: [f64; 2], e: u32, forward: bool| Face { + loops: vec![vec![uses(e, forward)]], + surface: Surface::Sphere { frame: frame_at([0.; 3]), radius: r, longitude: [0., tau], latitude }, + forward: true, + }; + let band = Face { + loops: vec![vec![uses(0, true)], vec![uses(1, false)]], + surface: Surface::RationalSpline { + u_knots: vec![0., 0., 0., 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1., 1., 1.], + v_knots: vec![0., 0., 0., 1., 1., 1.], + homogeneous, + }, + forward: true, + }; + let body = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[rho, 0., -z], [rho, 0., z]], + edges: vec![circle([0., 0., -z], rho, 0), circle([0., 0., z], rho, 1)], + faces: vec![band, cap([q, half], 1, true), cap([-half, -q], 0, false)], + }], + }; + let def = prepare(&body, &Options::default()).unwrap(); + assert_eq!(def.report.surfaces.get("sphere"), Some(&3), "{:?}", def.report); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + for p in pieces(&d).filter(|p| p.visibility == Visibility::Visible && !p.covered) { + for g in &p.geometry { + let radius = g.point_at(0.5).len(); + assert!((radius - 10.).abs() < 1e-6, "a line at radius {radius}"); + } + } +} + +#[test] +fn chords_of_a_racing_circle_stay_within_their_stated_deviation() { + // z^6 / |z|^6 with z = 0.01 (1 - t) + (1 + i sqrt 3) t: the unit circle, its weights + // 1e-12 to 64 apart, so drawn as chords; a cube 9 km away makes the scene coarse. Every + // point of the circle must lie within the chords' stated deviation of them. + let c1 = (1f64, 3f64.sqrt()); + let mut num = Vec::new(); + let mut pw = (1f64, 0f64); + for k in 0..=6 { + num.push((0.01f64.powi(6 - k) * pw.0, 0.01f64.powi(6 - k) * pw.1)); + pw = (pw.0 * c1.0 - pw.1 * c1.1, pw.0 * c1.1 + pw.1 * c1.0); + } + let q = [1e-4, 0.01, 4.]; + let w = bernstein_product(&bernstein_product(&q, &q), &q); + let homogeneous: Vec<[f64; 4]> = num.iter().zip(&w).map(|((re, im), w)| [*re, *im, 0., *w]).collect(); + let disc = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[1., 0., 0.]], + edges: vec![Edge { vertices: [0, 0], curve: Curve::RationalSpline { knots: [[0.; 7], [1.; 7]].concat(), homogeneous } }], + 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, + }], + }], + }; + let defs = [prepare(&disc, &Options::default()).unwrap(), prepare(&cuboid([9e6, 0., 0.], [9e6 + 1., 1., 1.]), &Options::default()).unwrap()]; + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&defs, &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let line = edge_line(&d, 0, 0); + if turn_or_chords(line.pieces.iter()).is_some() { + return; + } + let dev = line + .pieces + .iter() + .filter_map(|p| if let CurveAccuracy::Sampled { max_sample_deviation_mm: v } = p.accuracy { Some(v) } else { None }) + .fold(0., f64::max); + let chords: Vec<(Vec2, Vec2)> = + line.pieces.iter().flat_map(|p| p.geometry.iter()).filter_map(|g| if let Seg2::Line { a, b } = *g { Some((a, b)) } else { None }).collect(); + for k in 0..720 { + let t = std::f64::consts::TAU * k as f64 / 720.; + let q = Vec2::new(t.cos(), t.sin()); + let gap = chords.iter().map(|(a, b)| point_segment([q.x, q.y], [a.x, a.y], [b.x, b.y])).fold(f64::MAX, f64::min); + assert!(gap <= dev + 1e-6, "({}, {}) is {gap} mm from the chords, stated {dev}", q.x, q.y); + } +} + +#[test] +fn a_big_arc_is_cut_where_it_meets_a_left_out_faces_box() { + // An arc of radius 10000 (angles -pi/8 to 0) under a freeform tube lying along Y at + // x = 9990 (radius about 2.5, a cubic profile), a cube 9 km away making the scene coarse: + // no visible piece of the arc may reach under the tube. + let tau = std::f64::consts::TAU; + let a0 = -std::f64::consts::PI / 8.; + let (start, end) = ([10000. * a0.cos(), 10000. * a0.sin(), 0.], [10000., 0., 0.]); + let arc = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![start, end], + edges: vec![ + Edge { vertices: [0, 1], curve: Curve::CircleArc { frame: frame_at([0.; 3]), radius: 10000., angle: [a0, 0.] } }, + Edge { vertices: [1, 0], curve: Curve::Line { start: end, end: start } }, + ], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(1, true)]], + surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }, + forward: true, + }], + }], + }; + let x = 9990.; + let profile = Curve::Spline { + knots: vec![0., 0., 0., 0., 1., 1., 1., 1.], + points: vec![[x + 2.5, -4000., 1.], [x + 2.504, -4000. / 3., 1.], [x + 2.5, 2000. / 3., 1.], [x + 2.5, 3000., 1.]], + }; + let end_circle = |y: f64, v: u32| Edge { + vertices: [v, v], + curve: Curve::CircleArc { + frame: crate::brep::Frame { origin: [x, y, 1.], x_axis: [1., 0., 0.], y_axis: [0., 0., 1.] }, + radius: 2.5, + angle: [0., tau], + }, + }; + let tube = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[x + 2.5, -4000., 1.], [x + 2.5, 3000., 1.]], + edges: vec![end_circle(-4000., 0), end_circle(3000., 1), Edge { vertices: [0, 1], curve: profile.clone() }], + faces: vec![Face { + loops: vec![vec![uses(0, true), uses(2, true), uses(1, false), uses(2, false)]], + surface: Surface::Revolution { profile: Box::new(profile), origin: [x, 0., 1.], axis: [0., 1., 0.], angle: [0., tau] }, + forward: true, + }], + }], + }; + let defs = [ + prepare(&tube, &Options::default()).unwrap(), + prepare(&arc, &Options::default()).unwrap(), + prepare(&cuboid([9e6, 0., 0.], [9e6 + 1., 1., 1.]), &Options::default()).unwrap(), + ]; + assert_eq!(defs[0].report.freeform_faces, vec![0]); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&defs, &[identity(0, [0.; 3]), identity(1, [0.; 3]), identity(2, [0.; 3])], &view, &Options::default(), &never).unwrap(); + for p in edge_line(&d, 1, 0).pieces.iter().filter(|p| p.visibility == Visibility::Visible) { + for k in 0..=400 { + let t = p.t[0] + (p.t[1] - p.t[0]) * k as f64 / 400.; + let (px, py) = (10000. * t.cos(), 10000. * t.sin()); + let under = px > x - 2.5 && px < x + 2.5 && py > -4000. && py < 3000.; + assert!(!under, "visible at ({px}, {py}), t {t}, piece {:?}", p.t); + } + } +} + +#[test] +fn narrow_torus_strips_keep_their_outline() { + // Two strips of a torus R 20, r 10 between the meridian angles 0.3002, 0.3005 and 0.3008, + // seen from the side: each side of the outline is a 0.006 mm piece of the circle traced on + // the whole torus, whose chords are far longer than the strips are wide. + let tau = std::f64::consts::TAU; + let ring = |phi: f64, v: u32| circle([0., 0., 10. * phi.sin()], 20. + 10. * phi.cos(), v); + let strip = |m: [f64; 2], e: [u32; 2]| Face { + loops: vec![vec![uses(e[0], true)], vec![uses(e[1], false)]], + surface: Surface::Torus { frame: frame_at([0.; 3]), major_radius: 20., minor_radius: 10., longitude: [0., tau], meridian: m }, + forward: true, + }; + let phis: [f64; 3] = [0.3002, 0.3005, 0.3008]; + let body = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: phis.iter().map(|p| [20. + 10. * p.cos(), 0., 10. * p.sin()]).collect(), + edges: vec![ring(phis[0], 0), ring(phis[1], 1), ring(phis[2], 2)], + faces: vec![strip([phis[0], phis[1]], [0, 1]), strip([phis[1], phis[2]], [1, 2])], + }], + }; + let def = prepare(&body, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + for side in [-1., 1.] { + let drawn: f64 = silhouette_pieces(&d) + .iter() + .filter(|p| p.visibility == Visibility::Visible && !p.covered) + .flat_map(|p| p.geometry.iter()) + .filter(|g| g.point_at(0.5).x * side > 0.) + .map(|g| g.point_at(0.).dist(g.point_at(1.))) + .sum(); + assert!((drawn - 0.006).abs() < 1e-4, "side {side}: {drawn} mm of outline"); + } +} + +#[test] +fn mirrored_placements_bad_indices_and_bad_views_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 mirror = + Placement { body: 0, rotation: [Vec3::new(-1., 0., 0.), Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.)], translation: Vec3::new(0., 0., 0.) }; + let defs = [def]; + assert!(matches!(hidden_lines(&defs, &[mirror], &view, &Options::default(), &never), Err(HlrError::Invalid(_)))); + assert!(matches!(hidden_lines(&defs, &[identity(3, [0.; 3])], &view, &Options::default(), &never), Err(HlrError::Invalid(_)))); + assert!(matches!(hidden_lines(&defs, &[identity(0, [f64::NAN, 0., 0.])], &view, &Options::default(), &never), Err(HlrError::Invalid(_)))); + assert!(View::new(Vec3::new(0., 0., 0.), Vec3::new(0., 0., 1.)).is_err()); + assert!(View::new(Vec3::new(0., 0., 1.), Vec3::new(0., 0., 2.)).is_err()); +} + +#[test] +fn cancellation_and_budgets_return_errors() { + let def = prepare(&cylinder(10., 30.), &Options::default()).unwrap(); + let view = View::new(Vec3::new(-1., -0.7, -0.4), Vec3::new(0., 0., 1.)).unwrap(); + let defs = [def]; + let scene = [identity(0, [0.; 3]), identity(0, [5., 0., 0.])]; + assert!(matches!(hidden_lines(&defs, &scene, &view, &Options::default(), &|| true), Err(HlrError::Cancelled))); + assert!(matches!(hidden_lines(&defs, &scene, &view, &Options { max_rays: 3, ..Options::default() }, &never), Err(HlrError::Budget(_)))); + assert!(matches!(hidden_lines(&defs, &scene, &view, &Options { max_tracks: 1, ..Options::default() }, &never), Err(HlrError::Budget(_)))); +} + +#[test] +fn two_touching_boxes_draw_each_shared_edge_once() { + let a = prepare(&cuboid([0.; 3], [10.; 3]), &Options::default()).unwrap(); + let b = prepare(&cuboid([10., 0., 0.], [20., 10., 10.]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(-1., -0.7, -0.5), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[a, b], &[identity(0, [0.; 3]), identity(1, [0.; 3])], &view, &Options::default(), &never).unwrap(); + assert_eq!(d.stats.uncertain_uncovered, 0, "{:?}", d.stats); + // The edges at x = 10: 5, 7, 9, 11 of the first box are 4, 6, 8, 10 of the second. + let uncovered = |l: &Line| -> f64 { l.pieces.iter().filter(|p| !p.covered).map(|p| p.t[1] - p.t[0]).sum() }; + for (ea, eb) in [(5, 4), (7, 6), (9, 8), (11, 10)] { + let once = uncovered(edge_line(&d, 0, ea)) + uncovered(edge_line(&d, 1, eb)); + assert!((once - 1.).abs() < 1e-9, "edges {ea}/{eb} drawn {once} times"); + } +} + +#[test] +fn a_cone_in_a_front_view_has_exact_outline_rulings_and_edge_on_circles() { + // A frustum from radius 5 at z = 0 to 10 at z = 20, seen exactly from the front: the + // rulings run from (+-5, 0) to (+-10, 20) (one along the seam) and each circle is drawn + // once as the segment it is seen edge-on as. + let def = prepare(&turned(5., 10., 20.), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + assert_eq!(d.stats.uncertain_uncovered, 0, "{:?}", d.stats); + let mut rulings: Vec<[f64; 4]> = Vec::new(); + for p in silhouette_pieces(&d) { + assert_eq!((p.visibility, p.accuracy), (Visibility::Visible, CurveAccuracy::Analytic)); + for g in &p.geometry { + let Seg2::Line { a, b } = *g else { panic!("a ruling is a line: {g:?}") }; + let (a, b) = if a.y <= b.y { (a, b) } else { (b, a) }; + rulings.push([a.x, a.y, b.x, b.y]); + } + } + rulings.sort_by(|a, b| a[0].total_cmp(&b[0])); + assert_eq!(rulings.len(), 2, "{rulings:?}"); + for (r, want) in rulings.iter().zip([[-5., 0., -10., 20.], [5., 0., 10., 20.]]) { + assert!(r.iter().zip(want).all(|(x, y)| (x - y).abs() < 1e-9), "{rulings:?}"); + } + for (edge, (y, half)) in [(0, (0., 5.)), (1, (20., 10.))] { + let drawn: f64 = edge_line(&d, 0, edge) + .pieces + .iter() + .filter(|p| p.visibility == Visibility::Visible && !p.covered) + .flat_map(|p| p.geometry.iter()) + .map(|g| { + let (a, b) = (g.point_at(0.), g.point_at(1.)); + assert!((a.y - y).abs() < 1e-9 && (b.y - y).abs() < 1e-9 && a.x.abs() <= half + 1e-9, "{g:?}"); + (b.x - a.x).abs() + }) + .sum(); + assert!((drawn - 2. * half).abs() < 1e-6, "circle {edge}: {drawn} mm drawn"); + } +} + +#[test] +fn a_torus_seen_obliquely_spans_its_outer_diameter() { + // Across the view (screen x is square to the axis here) the outline of a torus reaches + // R + r on both sides, whatever the tilt; the traced silhouette reports its deviation. + let def = prepare(&torus(20., 5.), &Options::default()).unwrap(); + for tilt in [0.3, 1., 3.] { + let view = View::new(Vec3::new(0., 1., -tilt), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(std::slice::from_ref(&def), &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + assert_eq!(d.stats.uncertain_uncovered, 0, "tilt {tilt}: {:?}", d.stats); + let mut dev = 0f64; + let (mut lo, mut hi) = (f64::MAX, f64::MIN); + for p in pieces(&d).filter(|p| p.visibility == Visibility::Visible && !p.covered) { + if let CurveAccuracy::Sampled { max_sample_deviation_mm } = p.accuracy { + dev = dev.max(max_sample_deviation_mm); + } + for g in &p.geometry { + for s in [0., 0.5, 1.] { + let q = g.point_at(s); + (lo, hi) = (lo.min(q.x), hi.max(q.x)); + } + } + } + assert!(dev < 1e-3, "tilt {tilt}: deviation {dev}"); + assert!((hi - 25.).abs() < 1e-3 && (lo + 25.).abs() < 1e-3, "tilt {tilt}: x from {lo} to {hi}"); + } +} + +/// A disc bounded by a rational quadratic circle of radius `r` (control points `pts` with +/// weights 1 and 1/sqrt 2 alternating) whose first three quarters are squeezed into the +/// parameters 0 to 0.009. +fn squeezed_disc(r: f64, pts: [[f64; 2]; 9]) -> Brep { + disc(r, pts, vec![0., 0., 0., 0.003, 0.003, 0.006, 0.006, 0.009, 0.009, 1., 1., 1.]) +} + +/// A disc bounded by a rational quadratic spline with control points `pts` (weights 1 and +/// 1/sqrt 2 alternating) and `knots`, starting at (`r`, 0). +fn disc(r: f64, pts: [[f64; 2]; 9], knots: Vec) -> Brep { + let s = std::f64::consts::FRAC_1_SQRT_2; + let homogeneous = pts.iter().enumerate().map(|(i, p)| if i % 2 == 1 { [p[0] * s, p[1] * s, 0., s] } else { [p[0], p[1], 0., 1.] }).collect(); + Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![[r, 0., 0.]], + edges: vec![Edge { vertices: [0, 0], curve: Curve::RationalSpline { knots, homogeneous } }], + 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, + }], + }], + } +} + +fn circle_points(r: f64) -> [[f64; 2]; 9] { + [[r, 0.], [r, r], [0., r], [-r, r], [-r, 0.], [-r, -r], [0., -r], [r, -r], [r, 0.]] +} + +#[test] +fn a_bent_quarter_in_a_knot_span_narrower_than_rounding_is_drawn() { + // The second quarter lives in the knot span 0.25 to 0.25 + 2^-50 and bulges to a radius of + // about 10.3: the edge is not taken for a circle and the bulge is drawn. + let mut pts = circle_points(10.); + if let Some(p) = pts.get_mut(3) { + *p = [-11., 10.]; + } + let k = 0.25 + 2f64.powi(-50); + let def = prepare(&disc(10., pts, vec![0., 0., 0., 0.25, 0.25, k, k, 0.5, 0.5, 1., 1., 1.]), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + assert!(pieces(&d).all(|p| matches!(p.accuracy, CurveAccuracy::Sampled { .. })), "{d:?}"); + let far = pieces(&d).flat_map(|p| p.geometry.iter()).flat_map(|g| [g.point_at(0.), g.point_at(1.)]).map(|q| q.len()).fold(0., f64::max); + assert!(far > 10.25, "the bulge reaches {far}"); +} + +#[test] +fn an_unevenly_parameterised_circle_is_drawn_whole() { + // The conic arcs must still turn the full 360 degrees. + let def = prepare(&squeezed_disc(10., circle_points(10.)), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0.2, 0.1, -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let mut sweep = 0.; + for p in pieces(&d) { + assert_eq!((p.visibility, p.accuracy), (Visibility::Visible, CurveAccuracy::Analytic)); + for g in &p.geometry { + let Seg2::Conic { w, .. } = *g else { panic!("a circle is drawn as conic arcs: {g:?}") }; + sweep += 2. * w.acos(); + } + } + assert!((sweep - std::f64::consts::TAU).abs() < 1e-9, "the arcs turn {sweep} rad"); +} + +#[test] +fn a_spline_off_its_circle_in_a_narrow_knot_span_is_not_taken_for_one() { + // Moving one control point of the squeezed circle bends the span 0.003 to 0.006 off the + // circle by about 0.3 mm: the edge is drawn as sampled chords, not exact arcs. + let mut pts = circle_points(10.); + if let Some(p) = pts.get_mut(3) { + *p = [-11., 10.]; + } + let def = prepare(&squeezed_disc(10., pts), &Options::default()).unwrap(); + let view = View::new(Vec3::new(0.2, 0.1, -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + assert!(pieces(&d).all(|p| matches!(p.accuracy, CurveAccuracy::Sampled { .. })), "{d:?}"); +} + +/// A cone frustum about a vertical axis through `o`: radius `r0` at the frame's origin and +/// slope `k`, between the heights `h`. +fn frustum(o: [f64; 3], r0: f64, k: f64, h: [f64; 2]) -> Shell { + let tau = std::f64::consts::TAU; + let at = |z: f64| [o[0] + r0 + k * z, o[1], o[2] + z]; + let ring = |z: f64, v: u32| circle([o[0], o[1], o[2] + z], r0 + k * z, v); + let edges = vec![ring(h[0], 0), ring(h[1], 1), Edge { vertices: [0, 1], curve: Curve::Line { start: at(h[0]), end: at(h[1]) } }]; + let plane = |z: f64, flip: f64| Surface::Plane { origin: [o[0], o[1], o[2] + z], u_axis: [1., 0., 0.], v_axis: [0., flip, 0.] }; + let faces = vec![ + Face { + loops: vec![vec![uses(0, true), uses(2, true), uses(1, false), uses(2, false)]], + surface: Surface::Cone { frame: frame_at(o), radius_at_origin: r0, half_angle: k.atan(), angle: [0., tau], height: h }, + forward: true, + }, + Face { loops: vec![vec![uses(0, false)]], surface: plane(h[0], -1.), forward: true }, + Face { loops: vec![vec![uses(1, true)]], surface: plane(h[1], 1.), forward: true }, + ]; + Shell { vertices: vec![at(h[0]), at(h[1])], edges, faces } +} + +#[test] +fn nearly_coaxial_cones_far_out_keep_their_own_silhouettes() { + // Two frusta of slope 0.25 whose axes are slightly apart, far out: recognition may take + // them for one cone, but each is drawn with its own outline. Small ones 0.005 mm apart + // 1e7 mm out, and 1 km ones 0.4 mm apart 1e9 mm out. + for (x, r, gap) in [(1e7, 5., 0.005), (1e9, 1e6, 0.4)] { + let shells = vec![frustum([x, 0., 0.], r, 0.25, [0., 10.]), frustum([x + gap, 0., 0.], r, 0.25, [20., 30.])]; + check_two_cone_outlines(&Brep { version: crate::brep::VERSION, shells }); + } +} + +/// A sector `a` (radians) of a cone about a vertical axis through `o`, radius `r0` at the +/// frame's origin and slope `k`, between the heights `h`: one face, no caps. +fn cone_sector(o: [f64; 3], r0: f64, k: f64, h: [f64; 2], a: [f64; 2]) -> Shell { + let at = |z: f64, t: f64| [o[0] + (r0 + k * z) * t.cos(), o[1] + (r0 + k * z) * t.sin(), o[2] + z]; + let arc = |z: f64, v: u32, w: u32| Edge { + vertices: [v, w], + curve: Curve::CircleArc { frame: frame_at([o[0], o[1], o[2] + z]), radius: r0 + k * z, angle: a }, + }; + let ruling = |t: f64, v: u32, w: u32| Edge { vertices: [v, w], curve: Curve::Line { start: at(h[0], t), end: at(h[1], t) } }; + let edges = vec![arc(h[0], 0, 1), ruling(a[1], 1, 2), arc(h[1], 3, 2), ruling(a[0], 0, 3)]; + let face = Face { + loops: vec![vec![uses(0, true), uses(1, true), uses(2, false), uses(3, false)]], + surface: Surface::Cone { frame: frame_at(o), radius_at_origin: r0, half_angle: k.atan(), angle: a, height: h }, + forward: true, + }; + Shell { vertices: vec![at(h[0], a[0]), at(h[0], a[1]), at(h[1], a[1]), at(h[1], a[0])], edges, faces: vec![face] } +} + +#[test] +fn nearly_coincident_cone_sectors_both_draw_their_outline() { + // Two sectors (0 to 0.02 rad) of 1 m cones of slope 0.25, 1e9 mm out, axes 3e-6 mm apart, + // seen along the tangent at 0.01 rad: recognition takes them for one cone, but a ray along + // one's ruling would cross the other, so each keeps its own outline, visible over its + // whole height. + let x = 1e9; + let shells = vec![ + cone_sector([x, 0., 0.], 1000., 0.25, [0., 1000.], [0., 0.02]), + cone_sector([x + 3e-6, 0., 0.], 1000., 0.25, [2000., 3000.], [0., 0.02]), + ]; + let def = prepare(&Brep { version: crate::brep::VERSION, shells }, &Options::default()).unwrap(); + let view = View::new(Vec3::new(-(0.01f64.sin()), 0.01f64.cos(), 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + for (z0, z1) in [(0., 1000.), (2000., 3000.)] { + let drawn: f64 = silhouette_pieces(&d) + .iter() + .filter(|p| p.visibility == Visibility::Visible && !p.covered) + .flat_map(|p| p.geometry.iter()) + .map(|g| { + let (a, b) = (g.point_at(0.).y.clamp(z0, z1), g.point_at(1.).y.clamp(z0, z1)); + (b - a).abs() + }) + .sum(); + assert!((drawn - (z1 - z0)).abs() < 1e-3, "z {z0}..{z1}: {drawn} mm of ruling drawn"); + } +} + +/// A spherical cap of radius `r` about `c`: the latitudes from `lat` up to the pole, bounded by +/// one circle through the vertex `v` at longitude 0. +fn cap(c: [f64; 3], r: f64, lat: f64, edge: Curve, v: [f64; 3]) -> Shell { + let (tau, half) = (std::f64::consts::TAU, std::f64::consts::FRAC_PI_2); + let face = Face { + loops: vec![vec![uses(0, true)]], + surface: Surface::Sphere { frame: frame_at(c), radius: r, longitude: [0., tau], latitude: [lat, half] }, + forward: true, + }; + Shell { vertices: vec![v], edges: vec![Edge { vertices: [0, 0], curve: edge }], faces: vec![face] } +} + +#[test] +fn spherical_caps_through_one_circle_keep_their_own_outlines() { + // Two caps of radius 1000 whose centres are 5e-7 mm apart along the axis, both bounded by + // the same circle: recognition takes them for one sphere, the boundary lies on both, but + // inside they differ, so each draws its own outline. + let (r, z): (f64, f64) = (1000., 600.); + let ring = (r * r - z * z).sqrt(); + let edge = || Curve::CircleArc { frame: frame_at([0., 0., z]), radius: ring, angle: [0., std::f64::consts::TAU] }; + let dz = 5e-7; + let lat = |zc: f64| ((z - zc) / r).asin(); + let shells = vec![ + cap([0.; 3], r, lat(0.), edge(), [ring, 0., z]), + cap([0., 0., dz], (ring * ring + (z - dz).powi(2)).sqrt(), lat(dz), edge(), [ring, 0., z]), + ]; + let def = prepare(&Brep { version: crate::brep::VERSION, shells }, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + // Above the ring the outline is a half circle of about pi * 1000 * 0.41 mm on each side. + let drawn: f64 = silhouette_pieces(&d) + .iter() + .filter(|p| p.visibility == Visibility::Visible && !p.covered) + .flat_map(|p| p.geometry.iter()) + .map(|g| (0..32).map(|k| g.point_at(k as f64 / 32.).dist(g.point_at((k + 1) as f64 / 32.))).sum::()) + .sum(); + let arc = 2. * r * (std::f64::consts::FRAC_PI_2 - (z / r).asin()); + assert!(drawn > 0.99 * arc, "{drawn} mm of outline drawn of about {arc}"); + assert_eq!(d.stats.uncertain_uncovered, 0, "{:?}", d.stats); +} + +/// The lateral face of a cone with its apex at the origin, slope 0.25, between the heights +/// `h`, its axis turned by `tilt` about Y from +Z. +fn tilted_cone(tilt: f64, h: [f64; 2]) -> Shell { + let (x, y, z) = (Vec3::new(tilt.cos(), 0., -tilt.sin()), Vec3::new(0., 1., 0.), Vec3::new(tilt.sin(), 0., tilt.cos())); + let arr = |v: Vec3| [v.x, v.y, v.z]; + let frame = |o: Vec3| crate::brep::Frame { origin: arr(o), x_axis: arr(x), y_axis: arr(y) }; + let at = |hh: f64| x * (0.25 * hh) + z * hh; + let ring = |hh: f64, v: u32| Edge { + vertices: [v, v], + curve: Curve::CircleArc { frame: frame(z * hh), radius: 0.25 * hh, angle: [0., std::f64::consts::TAU] }, + }; + let seam = Edge { vertices: [0, 1], curve: Curve::Line { start: arr(at(h[0])), end: arr(at(h[1])) } }; + let face = Face { + loops: vec![vec![uses(0, true), uses(2, true), uses(1, false), uses(2, false)]], + surface: Surface::Cone { + frame: frame(Vec3::new(0., 0., 0.)), + radius_at_origin: 0., + half_angle: 0.25f64.atan(), + angle: [0., std::f64::consts::TAU], + height: h, + }, + forward: true, + }; + Shell { vertices: vec![arr(at(h[0])), arr(at(h[1]))], edges: vec![ring(h[0], 0), ring(h[1], 1), seam], faces: vec![face] } +} + +#[test] +fn cones_with_slightly_turned_axes_keep_their_own_outlines() { + // One apex, axes 3e-7 rad apart, heights 1000 to 3000 and 6000 to 8000: each frustum draws + // both rulings of its own outline. + let shells = vec![tilted_cone(0., [1000., 3000.]), tilted_cone(3e-7, [6000., 8000.])]; + let def = prepare(&Brep { version: crate::brep::VERSION, shells }, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + for (z0, z1) in [(1000., 3000.), (6000., 8000.)] { + let drawn: f64 = silhouette_pieces(&d) + .iter() + .filter(|p| p.visibility == Visibility::Visible && !p.covered) + .flat_map(|p| p.geometry.iter()) + .filter(|g| g.point_at(0.5).y > z0 && g.point_at(0.5).y < z1) + .map(|g| g.point_at(0.).dist(g.point_at(1.))) + .sum(); + let ruling = (z1 - z0) * (1f64 + 0.0625).sqrt(); + assert!((drawn - 2. * ruling).abs() < 1e-3 * ruling, "z {z0}..{z1}: {drawn} mm of rulings drawn, want {}", 2. * ruling); + } +} + +#[test] +fn a_high_degree_spline_through_points_of_a_circle_is_not_taken_for_one() { + // A closed degree-16 Bezier through 17 points of a circle of radius 10 at t = j / 16, at + // unevenly spaced angles: on the circle at every t = j / 16 (where a check with evenly + // spaced samples would look), off it between them, so it is drawn as chords. + let n = 16usize; + let ts: Vec = (0..=n).map(|j| j as f64 / n as f64).collect(); + let binom = |k: usize| (0..k).fold(1f64, |acc, i| acc * (n - i) as f64 / (i + 1) as f64); + // Solve the Bernstein collocation system for x and y by Gaussian elimination. + let mut rows: Vec> = ts + .iter() + .enumerate() + .map(|(j, t)| { + let a = std::f64::consts::TAU * j as f64 / n as f64 + 0.2 * (std::f64::consts::TAU * 3. * j as f64 / n as f64).sin(); + let mut row: Vec = (0..=n).map(|i| binom(i) * t.powi(i as i32) * (1. - t).powi((n - i) as i32)).collect(); + row.extend([10. * a.cos(), 10. * a.sin()]); + row + }) + .collect(); + for col in 0..=n { + let pivot = (col..=n).max_by(|a, b| rows[*a][col].abs().total_cmp(&rows[*b][col].abs())).unwrap(); + rows.swap(col, pivot); + for r in 0..=n { + if r != col { + let f = rows[r][col] / rows[col][col]; + let src = rows[col].clone(); + for (x, s) in rows[r].iter_mut().zip(src) { + *x -= f * s; + } + } + } + } + let points: Vec<[f64; 3]> = (0..=n).map(|i| [rows[i][n + 1] / rows[i][i], rows[i][n + 2] / rows[i][i], 0.]).collect(); + let knots: Vec = std::iter::repeat_n(0., n + 1).chain(std::iter::repeat_n(1., n + 1)).collect(); + let start = points[0]; + let disc = Brep { + version: crate::brep::VERSION, + shells: vec![Shell { + vertices: vec![start], + 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, + }], + }], + }; + let def = prepare(&disc, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0.2, 0.1, -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + assert!(pieces(&d).all(|p| matches!(p.accuracy, CurveAccuracy::Sampled { .. })), "{:?}", pieces(&d).map(|p| p.accuracy).collect::>()); +} + +fn check_two_cone_outlines(body: &Brep) { + let def = prepare(body, &Options::default()).unwrap(); + let view = View::new(Vec3::new(0., 1., 0.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + for (z0, z1) in [(0., 10.), (20., 30.)] { + let rulings = silhouette_pieces(&d) + .iter() + .filter(|p| p.visibility == Visibility::Visible && !p.covered) + .flat_map(|p| p.geometry.iter()) + .filter(|g| { + let (a, b) = (g.point_at(0.), g.point_at(1.)); + (a.y.min(b.y) - z0).abs() < 1e-6 && (a.y.max(b.y) - z1).abs() < 1e-6 + }) + .count(); + assert_eq!(rulings, 2, "z {z0}..{z1}: {:?}", silhouette_pieces(&d)); + } +} + +#[test] +fn the_shadow_index_finds_each_nearby_box_once() { + // 40 000 boxes 1 mm wide every 20 mm: a query near one finds it and no far one. + let mut list = Vec::new(); + for i in 0..200 { + for j in 0..200 { + let (x, y) = (20. * i as f64, 20. * j as f64); + list.push(Shadow { lo: [x, y], hi: [x + 1., y + 1.], near: 0. }); + } + } + let index = ShadowIndex::new(list).unwrap(); + let mut near = Vec::new(); + index.query([400.5, 600.5], [400.6, 600.6], &mut near); + let faces: Vec = near + .iter() + .copied() + .filter(|k| index.list.get(*k).is_some_and(|sh| sh.lo[0] <= 400.6 && sh.hi[0] >= 400.5 && sh.lo[1] <= 600.6 && sh.hi[1] >= 600.5)) + .collect(); + assert_eq!(faces, vec![20 * 200 + 30]); + assert!(near.len() <= 9, "{} candidates", near.len()); + index.query([-50., -50.], [-40., -40.], &mut near); + assert!(near.is_empty()); +} + +#[test] +fn a_plane_through_three_points_draws_as_a_plane() { + // The box with every face stored as the plane through three of its points draws the same + // bits as the box with axis-stored planes. + let o = Options::default(); + let mut through = cuboid([0.; 3], [1.; 3]); + for f in &mut through.shells[0].faces { + let Surface::Plane { origin, u_axis, v_axis } = f.surface else { panic!("a box face is a plane") }; + let at = |a: [f64; 3]| [origin[0] + a[0], origin[1] + a[1], origin[2] + a[2]]; + f.surface = Surface::PlaneThrough { origin, u_point: at(u_axis), v_point: at(v_axis) }; + } + let view = View::new(Vec3::new(-1., -0.8, -0.6), Vec3::new(0., 0., 1.)).unwrap(); + let draw = |b: &Brep| { + let mut d = hidden_lines(&[prepare(b, &o).unwrap()], &[identity(0, [0.; 3])], &view, &o, &never).unwrap(); + d.stats.phase_ms = [0.; 4]; + format!("{d:?}") + }; + assert!(draw(&through) == draw(&cuboid([0.; 3], [1.; 3]))); +} + +#[test] +fn procedural_and_placed_records_are_not_supported_yet() { + let o = Options::default(); + // A cylinder side stored as a placed revolution of its seam line. + let mut placed = cylinder(4., 10.); + let profile = Box::new(Curve::Line { start: [4., 0., 0.], end: [4., 0., 10.] }); + let matrix = [[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., 0., 0., 1.]]; + placed.shells[0].faces[0].surface = Surface::PlacedRevolution { profile, origin: [0.; 3], axis: [0., 0., 1.], matrix, forward: true }; + assert!(matches!(prepare(&placed, &o), Err(HlrError::Unsupported("placed revolution surface")))); + // A box edge stored as the meeting of the planes y = 0 and z = 0, traced from a leader. + let mut traced = cuboid([0.; 3], [1.; 3]); + let y0 = Box::new(Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 0., 1.] }); + let z0 = Box::new(Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }); + let leader = Box::new(Curve::Polyline { points: vec![[0.; 3], [1., 0., 0.]] }); + traced.shells[0].edges[0].curve = Curve::SurfaceIntersection { surfaces: [y0, z0], leader, tolerance: 1e-6 }; + assert!(matches!(prepare(&traced, &o), Err(HlrError::Unsupported("surface intersection curve")))); +} + +/// The edges whose every piece is hidden, in a box drawn from the isometric view. +fn hidden_box_edges(b: &Brep) -> Vec { + let def = prepare(b, &Options::default()).unwrap(); + let view = View::new(Vec3::new(-1., -1., -1.), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let mut hidden = Vec::new(); + for l in &d.lines { + let Source::Edge { edge, .. } = l.source else { panic!("a box has no silhouettes") }; + let states: Vec<_> = l.pieces.iter().map(|p| p.visibility).collect(); + assert!(states.windows(2).all(|w| w[0] == w[1]), "edge {edge} is split: {states:?}"); + if states[0] == Visibility::Hidden { + hidden.push(edge); + } + } + hidden.sort_unstable(); + hidden +} + +#[test] +fn a_plane_record_off_its_edges_by_rounding_still_hides() { + // The top face's plane stands one rounding (a 1 mm cube) or 1e-11 (a 100 mm cube) above + // its edges. Its box must still let the rays from the back edges reach it. + for (size, off) in [(1., 2.2e-16), (100., 1e-11), (1., -2.2e-16)] { + let mut b = cuboid([0.; 3], [size; 3]); + let Surface::Plane { origin, .. } = &mut b.shells[0].faces[1].surface else { panic!("the top face is a plane") }; + assert_eq!(origin[2], size); + origin[2] += off; + assert_ne!(origin[2], size, "the offset must change the record"); + assert_eq!(hidden_box_edges(&b), vec![0, 4, 8], "size {size}, offset {off}"); + } + // A 0.001 mm cube modelled 1e7 mm out, its top plane one step (1.9e-9 mm) above its edges: + // more than 1e-6 of the face, so the padding must also grow with the coordinates. + let (c, size) = (1e7, 0.001); + let mut b = cuboid([c; 3], [c + size; 3]); + let Surface::Plane { origin, .. } = &mut b.shells[0].faces[1].surface else { panic!("the top face is a plane") }; + origin[2] = f64::from_bits(origin[2].to_bits() + 1); + assert!(origin[2] - (c + size) > 1e-9); + assert_eq!(hidden_box_edges(&b), vec![0, 4, 8], "a tiny cube far out"); +} + +/// A plate of `n` by `n` holes of radius `r` at `pitch`, `h` thick: the faces of `cuboid`, a +/// circle per hole in the top and bottom faces, and a cylinder face (seam at angle 0) per hole. +fn holed_plate(n: usize, pitch: f64, r: f64, h: f64) -> Brep { + let tau = std::f64::consts::TAU; + let size = n as f64 * pitch; + let mut b = cuboid([0.; 3], [size, size, h]); + let shell = &mut b.shells[0]; + for i in 0..n { + for j in 0..n { + let (cx, cy) = ((i as f64 + 0.5) * pitch, (j as f64 + 0.5) * pitch); + let v = shell.vertices.len() as u32; + shell.vertices.push([cx + r, cy, 0.]); + shell.vertices.push([cx + r, cy, h]); + let e = shell.edges.len() as u32; + shell.edges.push(circle([cx, cy, 0.], r, v)); + shell.edges.push(circle([cx, cy, h], r, v + 1)); + shell.edges.push(Edge { vertices: [v, v + 1], curve: Curve::Line { start: [cx + r, cy, 0.], end: [cx + r, cy, h] } }); + // Holes run clockwise seen from outside: the bottom face looks down, the top up. + shell.faces[0].loops.push(vec![uses(e, true)]); + shell.faces[1].loops.push(vec![uses(e + 1, false)]); + shell.faces.push(Face { + loops: vec![vec![uses(e, true), uses(e + 2, true), uses(e + 1, false), uses(e + 2, false)]], + surface: Surface::Cylinder { frame: frame_at([cx, cy, 0.]), radius: r, angle: [0., tau], height: [0., h] }, + forward: false, + }); + } + } + b +} + +#[test] +fn trim_samples_and_scene_extents_beyond_their_limits_are_refused() { + 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(_)))); + } + // Placements far apart once made the scene's size infinite and every piece visible. + 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 defs = [def]; + for far in [1e200, 2e12, -2e12] { + let scene = [identity(0, [0.; 3]), identity(0, [far, 0., 0.])]; + assert!(matches!(hidden_lines(&defs, &scene, &view, &Options::default(), &never), Err(HlrError::Invalid("scene extent"))), "{far}"); + } + let scene = [identity(0, [0.; 3]), identity(0, [1e11, 0., 0.])]; + assert!(hidden_lines(&defs, &scene, &view, &Options::default(), &never).is_ok()); +} + +#[test] +fn surface_groups_match_comparing_every_pair_and_preparing_can_be_cancelled() { + // Many holes of one radius, one a hair larger, a torus and a sphere in halves. + let mut b = holed_plate(4, 10., 3., 2.); + let wider = holed_plate(1, 10., 3. * (1. + 5e-10), 2.); + for s in [wider.shells[0].clone(), torus(5., 1.5).shells[0].clone(), sphere(4.).shells[0].clone()] { + b.shells.push(s); + } + let def = prepare(&b, &Options::default()).unwrap(); + for (i, f) in def.faces.iter().enumerate() { + let every: Vec = + def.faces.iter().enumerate().filter(|(j, g)| *j == i || same_analytic(&f.analytic, &g.analytic, 1e-9)).map(|(j, _)| j).collect(); + assert_eq!(f.group, every, "face {i}"); + } + // The wider hole joins the first hole (same axis); the torus and sphere halves pair up. + assert_eq!(def.faces.iter().filter(|f| f.group.len() == 2).count(), 6); + assert!(matches!(prepare_cancellable(&b, &Options::default(), &|| true), Err(HlrError::Cancelled))); +} + +#[test] +fn the_chord_grid_finds_what_a_scan_of_every_chord_finds() { + // The plate's top face: 16 circles and a square, thousands of chords. + let def = prepare(&holed_plate(4, 10., 3., 2.), &Options::default()).unwrap(); + let trim = &def.faces[1].trim; + assert!(trim.chords.len() > 1000); + let (lo, hi) = (trim.lo, trim.hi); + let mut checked = [0usize; 2]; + for i in 0..=60 { + for j in 0..=60 { + let p = Vec3::new(lo.x + (hi.x - lo.x) * i as f64 / 60., lo.y + (hi.y - lo.y) * j as f64 / 60., lo.z + 1e-9 * (i % 3) as f64); + for tol in [1e-12, 1e-6, 0.01, 0.3, 2., 50.] { + let scan = trim.chords.iter().any(|(a, b)| p.dist_to_segment(*a, *b) <= tol); + assert_eq!(trim.near(p, tol), scan, "point {p:?}, tol {tol}"); + checked[usize::from(scan)] += 1; + } + } + } + assert!(checked[0] > 1000 && checked[1] > 1000, "{checked:?}"); +} + +#[test] +fn every_hole_of_a_plate_hides_the_same_part_of_its_bottom_circle() { + let def = prepare(&holed_plate(4, 10., 3., 2.), &Options::default()).unwrap(); + let view = View::new(Vec3::new(-1., -0.8, -0.6), Vec3::new(0., 0., 1.)).unwrap(); + let d = hidden_lines(&[def], &[identity(0, [0.; 3])], &view, &Options::default(), &never).unwrap(); + let hidden = + |e: usize| -> f64 { edge_line(&d, 0, e).pieces.iter().filter(|p| p.visibility == Visibility::Hidden).map(|p| p.t[1] - p.t[0]).sum() }; + // Edges 12, 15, 18, ...: each hole's bottom circle (12 box edges first). + let first = hidden(12); + assert!(first > 0.1 && first < std::f64::consts::TAU - 0.1, "part of a bottom circle is hidden by the wall: {first}"); + for k in 1..16 { + let e = 12 + 3 * k; + assert!((hidden(e) - first).abs() < 1e-6, "hole {k}: {} against {first}", hidden(e)); + let top = edge_line(&d, 0, e + 1); + assert!(top.pieces.iter().all(|p| p.visibility == Visibility::Visible), "hole {k}'s top circle is in front: {:?}", top.pieces); + } +} + +#[test] +fn coaxial_cylinders_seen_along_their_axis_draw_one_circle() { + // Eight cylinders stacked along Z, seen from above: all sixteen circles fall on one; only + // the top one shows, the others are hidden or lie under it. + let def = prepare(&cylinder(4., 10.), &Options::default()).unwrap(); + let scene: Vec = (0..8).map(|k| identity(0, [0., 0., 15. * k as f64])).collect(); + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &scene, &view, &Options::default(), &never).unwrap(); + assert_eq!(d.stats.uncertain_uncovered, 0, "{:?}", d.stats); + // The circles meet at every sample point they share; none of that is a crossing (only the + // seam's ends, where each curve stops, may count): 120 pairs once gave over 100 000. + assert!(d.stats.crossings <= 4 * 120, "{:?}", d.stats); + for l in &d.lines { + let top = l.placement == 7 && l.source == Source::Edge { edge: 1, tangent: false }; + let shown: f64 = l.pieces.iter().filter(|p| p.visibility == Visibility::Visible && !p.covered).map(|p| p.t[1] - p.t[0]).sum(); + if top { + assert!((shown - std::f64::consts::TAU).abs() < 1e-9, "the top circle shows whole: {:?}", l.pieces); + } else { + assert_eq!(shown, 0., "placement {} {:?}: {:?}", l.placement, l.source, l.pieces); + } + } +} + +#[test] +fn a_few_hundred_placements_draw_as_one() { + // 300 boxes apart on screen: each is drawn exactly as the first one is. + let def = prepare(&cuboid([0.; 3], [1.; 3]), &Options::default()).unwrap(); + let scene: Vec = (0..300).map(|k| identity(0, [4. * (k % 20) as f64, 4. * (k / 20) as f64, 0.])).collect(); + let view = View::new(Vec3::new(0.3, 0.2, -1.), Vec3::new(0., 1., 0.)).unwrap(); + let d = hidden_lines(&[def], &scene, &view, &Options::default(), &never).unwrap(); + assert_eq!(d.lines.len(), 300 * 12); + let first: Vec<&Line> = d.lines.iter().filter(|l| l.placement == 0).collect(); + assert!(first.iter().any(|l| l.pieces.iter().any(|p| p.visibility == Visibility::Hidden)), "a box hides some of its own edges"); + for (k, chunk) in d.lines.chunks(12).enumerate() { + for (l, f) in chunk.iter().zip(&first) { + assert_eq!((l.placement, l.source), (k, f.source)); + assert_eq!(l.pieces.len(), f.pieces.len(), "placement {k} {:?}", l.source); + for (p, q) in l.pieces.iter().zip(&f.pieces) { + assert_eq!((p.visibility, p.covered), (q.visibility, q.covered), "placement {k} {:?}", l.source); + assert!((p.t[0] - q.t[0]).abs() < 1e-9 && (p.t[1] - q.t[1]).abs() < 1e-9, "placement {k} {:?}", l.source); + } + } + } +} + +#[test] +fn chords_only_near_each_other_still_meet_their_crossings() { + // An arc of radius 1000 sampled as one chord (sag about 5 mm) and a line 1 mm off that chord + // toward the arc's bulge: all four ends lie within the sag of the other chord, yet the arc + // crosses the line twice. The crossings must be found, not skipped as a coincidence. + let def = prepare(&cuboid([0.; 3], [1.; 3]), &Options::default()).unwrap(); + let scene = [identity(0, [0.; 3])]; + let view = View::new(Vec3::new(0., 0., -1.), Vec3::new(0., 1., 0.)).unwrap(); + let defs = [def]; + let ctx = context(&defs, &scene, &view, &Options::default(), &never).unwrap(); + let arc = C3::Arc { o: Vec3::ZERO, x: Vec3::new(1., 0., 0.), y: Vec3::new(0., 1., 0.), r: [1000., 1000.], range: [0., 0.2] }; + let (a, b) = (arc.at(0.), arc.at(0.2)); + 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| { + let screen = |t: f64| view.screen(curve.at(t)); + Track { + placement: 0, + curve: TrackCurve::Owned(curve.clone()), + range, + source: Source::Edge { edge: 0, tangent: false }, + acc: CurveAccuracy::Analytic, + own: Vec::new(), + own_eps: 1e-6, + samples: vec![(range[0], screen(range[0])), (range[1], screen(range[1]))], + sags: vec![sag], + splits: Vec::new(), + } + }; + let mut tracks = vec![track(&arc, [0., 0.2], sag), track(&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::>()); +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index b6fc687010..0c60fbe722 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -24,6 +24,7 @@ pub mod env; pub mod fill; pub mod fill_general; mod grid; +pub mod hlr; mod mesh; pub mod mesh_distance; pub mod network; diff --git a/docs/drawing-hlr.md b/docs/drawing-hlr.md new file mode 100644 index 0000000000..3e68470d5c --- /dev/null +++ b/docs/drawing-hlr.md @@ -0,0 +1,198 @@ +# 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 +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. + +The code is `crates/geom/src/hlr` (layer L0). It reads the neutral `geom::brep` +records, uses no kernel or UI types, returns errors instead of panicking and builds for +`wasm32-unknown-unknown`. It does not register commands, create views, extract bodies +from the kernel or draw sections. The read-only kernel adapter follows the W02 +extraction contract; until then callers build `Brep` records themselves. + +## Using it + +```text +prepare(&Brep, &Options) -> Result once per body +hidden_lines(&[Prepared], &[Placement], &View, &Options, &cancel) -> Result +``` + +- `prepare` checks the body (`Brep::validate`), recognises each face's surface, builds + its trim and classifies each edge as sharp, tangent or seam. `Prepared::report` says + what it found: surfaces and curves by kind, tangent and seam edges, faces whose trim is + unsupported, freeform faces and the largest boundary chord error. +- A `Placement` puts a prepared body in the scene: `world = rotation * local + + translation`, the rotation given by its columns. Several placements may share one + prepared body. A placement that is not a proper rotation (a mirror, a scale) is refused + with `HlrError::Invalid`. +- `View::new(direction_of_sight, up)` is an orthographic view. Screen coordinates are + `(p . x, p . y)` in millimetres, with `x = direction x up` normalised and `y = x x + direction`. +- `cancel` is polled during the work; when it returns true the call ends with + `HlrError::Cancelled`. `prepare_cancellable(&Brep, &Options, &cancel)` is `prepare` with + such a callback, polled between edges, faces and surface comparisons. + +The `Drawing` holds one `Line` per drawable edge and per silhouette curve of each +placement, in order of placement. A line names its `placement` and `source` (`Edge { edge, tangent }` +or `Silhouette { face }`) and is split into `pieces`. Each `Piece` has: + +| Field | Meaning | +| --- | --- | +| `t` | Parameter interval on the source curve. | +| `visibility` | `Visible`, `Hidden` or `Uncertain`. | +| `covered` | Another piece already draws the same projected curve here; draw only uncovered pieces. | +| `geometry` | The piece in screen coordinates as `Seg2` segments. | +| `accuracy` | `CurveAccuracy` of `geometry` (the [W01a vocabulary](drawing-geometry.md)). | + +`Drawing::unsupported` lists the faces whose silhouettes were not drawn and why; +`Drawing::stats` counts curves, crossings, pieces, rays and uncertain pieces, with phase +timings where the platform has a clock (zero on `wasm32-unknown-unknown`). + +## What is exact + +| Source | Geometry | Accuracy | +| --- | --- | --- | +| Line edges; cylinder and cone silhouette rulings | one `Seg2::Line` | `Analytic` | +| Circle and ellipse edges; sphere silhouettes; torus silhouettes seen along the axis | `Seg2::Conic` arcs of at most 90 degrees | `Analytic` | +| Spline and intersection edges | chords between exact points, halved until each piece's control hull is within the sag target | `Sampled` (a bound from that hull) | +| Torus silhouettes in other views | chords between traced points, halved to 1e-7 of the torus's size | `Sampled` (measured deviation) | + +An orthographic projection is affine, so a circle projects to an ellipse, which a +rational quadratic arc represents exactly. Chords are refined until their sag on screen +is at most 1e-7 of the scene size. A fitted edge record also carries its own bound, and +the visibility work allows for it. + +Surfaces are recognised whether they are stored as analytic records, as revolutions of +lines and circles or as spline patches (to 1e-9 relative; a patch whose control points lie +in one plane is that plane). A spline curve or patch is taken for a circle, quadric or torus +only when it lies on it at enough points of every knot span to make the check exact for its +degree, with its largest distance there, scaled by how far apart its weights are, within +the tolerance: weights near zero cannot hide a swerve, and widely spread weights mean chords. +A recognised patch is trimmed and intersected in its own analytic chart. Faces that lie on one surface (a sphere +stored as two hemispheres, a cylinder split in two) share their silhouettes: the edges +between them are seams and are not drawn, and a silhouette running along such a seam is +one curve. A face joins such a set only when the first face's surface passes through its +own points to rounding (1e-13 of its size); recognition alone groups surfaces more loosely, +and such faces keep silhouettes of their own. + +## Visibility + +Projected curves are split where they cross or touch, found on a screen grid and +refined on the exact curves. Each piece is classified by rays toward the viewer against +every trimmed face: closed forms for planes and quadrics, a sampled quartic for tori. +A ray that grazes a face, meets one within its boundary tolerance or meets another body +right at its start makes the piece `Uncertain`; nothing is guessed. + +Coincident curves (an edge and a silhouette on top of each other, two touching bodies) +follow the W05a policy: visible wins and duplicates are marked `covered`. The tolerance +comes from the curves' own accuracy (chord sag and fit bounds) plus a rounding allowance +of 1e-7 mm and 1e-9 of the scene size, never from the scene size alone, so small parts in +large scenes keep their detail. + +`prepare` moves each body to its own centre, and the work is done in a frame centred on +the scene and shifted back at the end, so coordinates stay small: a part 1e7 mm from the +origin, whether placed there or modelled there, is classified as at the origin. + +## Freeform faces + +Faces that are not planes, cylinders, cones, spheres or tori are freeform. By default +they are listed in `Drawing::unsupported` with the reason `freeform face`, their +silhouettes are not drawn, and a piece whose ray meets one is `Uncertain`, never +`Hidden`. Without its silhouettes nothing marks where such a face's outline crosses a curve, +so curves are cut at the face's screen box and every piece inside the box that can reach +behind its nearest point (bounded by the curve's control points, not by samples) is +`Uncertain` too, the face's own edges included (faces left out for an unsupported trim +likewise). With `Options::freeform` set their silhouettes are contoured and their ray +tests use seeded Newton iterations: slower, `Sampled`, and subject to the misses listed +in the W05a findings. + +## Limits + +- `Options::max_tracks` (curves) and `Options::max_rays` bound the work, as do internal + caps: crossing grid entries (50 million), crossing candidate pairs (500 million, counted + before the search, so no set of pairs met grows with them), crossings (50 million) and + the surface comparisons that group a body's faces (50 million). Exceeding one returns + `HlrError::Budget`. +- `Options::trim_samples` is 1 to `MAX_TRIM_SAMPLES` (4096), and every coordinate of a + placed body at most `MAX_COORDINATE` (1e12 mm, the bound `geom::section` and + `geom::brep` put on stored numbers); otherwise the call returns `HlrError::Invalid`. +- `Options::tangent_edges` (default on) draws edges between tangent faces, flagged + `tangent: true`; drafting policy for them is W17. +- Tolerances are relative to the faces and the scene. They are meant for faces up to + about 10 m across and features down to about 1e-3 mm. A spline patch only a sliver wide + next to its radii (a few millionths of them) cannot be told from its neighbours' surfaces + reliably and is reported as freeform. Distinct surfaces that + recognition takes for one (closer than 1e-9 of their size or position, a nearly flat + cone's far apex included) share the own-face margin of 1e-6 of the face's size when a + ray leaves one of them. +- Not in this slice: parallel per-curve work and the shared work limits of + `geom::section`, the kernel adapter (after W02), perspective, section regions (W03), + views and commands (W07), and mirrored placements. Of the records the kernel now saves, + planes through three points (`PlaneThrough`) are drawn as planes; a body with a procedural + `SurfaceIntersection` curve or a `PlacedRevolution` surface (whose placement may scale it + unevenly) is refused by `prepare` with `HlrError::Unsupported` until the adapter handles + them. + +## Fixtures + +`crates/geom/src/hlr_tests.rs` builds bodies from records and checks analytic positions +and intervals: + +- a cube in an isometric view hides its three back edges; a box in front hides the + middle of an edge over exactly the covered interval; +- a cylinder's silhouette rulings are exact lines, including one along the seam; a cone + frustum seen from the front has exact rulings and draws each edge-on circle once; +- a sphere's outline is exact conic arcs at its radius in three views; +- a disc bounded by a rational circle with unevenly spaced knots, with a knot span narrower + than rounding, or with a parameterisation that races through its first turn is drawn as + arcs turning the full 360 degrees; splines off their circle between evenly spaced samples + or inside such a narrow span are drawn as chords; exact arcs that turn almost all of + their 358 degrees in the first hundredth of their parameter, or several radians in a + knot span of 1e-6 or 1e-11, keep the turn of every piece; +- a torus stored as two halves, seen face on, is drawn at both equators, one silhouette + each; an edge under it is hidden over exactly the intervals the tube covers; seen + obliquely its outline spans R + r on both sides with a deviation under 1e-3 mm; +- cone frusta and narrow cone sectors a hair off one axis or with axes 3e-7 rad apart, and + spherical caps through one circle with centres 5e-7 mm apart, keep their own outlines; +- a cylinder 1e5 and 1e7 mm from the origin draws as at the origin; a cone trimmed to its + tip 1 mm below the apex hides nothing above it; +- a freeform patch is reported and makes the edges it covers uncertain, or hidden when + freeform faces are enabled; a flat spline patch hides them for certain; a long edge behind + a freeform tube is uncertain where the tube may cover it and visible elsewhere, and so is + a sloping edge that only dips behind a freeform patch near its end, while one that leaves + the patch's box below its level but crosses the box above it stays visible; +- a curve that swerves 2.9 mm off its circle where its weight is 1e-12, and a nearly straight + cubic 0.002 mm off a circle of radius 1e7, are drawn as chords; an edge whose depth dips + behind a freeform patch between its samples is uncertain there, and so is an arc seen + along its plane whose projection folds back under a freeform patch between two samples, + and the edge of a freeform sheet that curls back over it 0.005 mm above; +- a torus patch stored as a rational spline is recognised and draws its outer equator as an + exact arc, and one with a bump in a knot span 0.002 of its parameters wide is not taken for + a torus; a big circle passing under a thin freeform tube in a coarse scene is uncertain + there; +- two strips of a torus 0.0003 rad wide keep their 0.006 mm of outline; +- two touching boxes draw each shared edge once; +- a box whose top plane record stands one rounding (or 1e-11 of 100 mm) off its edges still + hides its back edges, and so does a 0.001 mm cube 1e7 mm out whose top plane is one step + above its edges; a plate's sixteen holes each hide the same part of their bottom circle; + eight coaxial cylinders seen along their axis show one circle; 300 boxes apart on screen + are each drawn as the first one; an arc that bulges across a line 1 mm off its chord still + crosses it twice; the trim's chord grid agrees with a scan of every chord; surface groups + agree with comparing every pair of faces, and preparing can be cancelled; +- mirrored placements, bad body indices and bad views are refused; cancellation and + budgets return errors; +- a box stored with planes through three points draws the same bits as one with + axis-stored planes; placed revolutions and procedural intersection curves are refused as + not supported yet; +- torus ray roots for thin, large and nearly tangent cases, and torus silhouettes near + the axis direction. + +[rfc]: https://github.com/tangency-labs/solidcraft/issues/860 +[plan]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6234779815 +[findings]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6243691483 +[issue]: https://github.com/tangency-labs/solidcraft/issues/2455 +[pr]: https://github.com/tangency-labs/solidcraft/pull/2457 +[w05-issue]: https://github.com/tangency-labs/solidcraft/issues/2105 +[w05-pr]: https://github.com/tangency-labs/solidcraft/pull/2106