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