Skip to main content

ogeom_algo/
measure.rs

1//! Bounding volumes and projection.
2//!
3//! # Bounds must contain what they claim to
4//!
5//! A bounding box in a kernel is always a *rejection* test. Too large costs
6//! time; too small silently drops a real intersection, and nothing downstream
7//! can tell it happened. So every bound here is derived from a property that
8//! guarantees containment, never from sampling:
9//!
10//! - a line's bound is its endpoints, which is exact;
11//! - a spline's is its control points, which is guaranteed by the convex hull
12//!   property: the curve never leaves the hull of its control polygon;
13//! - an analytic curve or surface's is computed from its own definition;
14//! - a *trimmed* piece falls back to the bound of the whole, which is loose but
15//!   never wrong.
16//!
17//! Sampling a curve at a few parameters and taking the extremes is the obvious
18//! alternative and is not sound: the curve bulges between the samples, and the
19//! amount it bulges is exactly what a bound is supposed to capture.
20
21use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
22use ogeom_geom::{
23    Curve, Curve2d, Curve3d, PlanarCurve, Surface, SurfaceGeometry, curve::LINE_EXTENT,
24};
25use ogeom_math::{Aabb, Direction, Frame, Point, Point2, Vector, solve};
26use ogeom_topo::{EdgeRepr, Model, NodeData, Orientation, Shape, ShapeType, explore_unique};
27
28/// A guaranteed bound for a space curve.
29///
30/// Loose for a trimmed curve, which reports the bound of the whole rather than
31/// of the piece: never wrong, and tightening it would mean solving for the
32/// extremes of the trimmed range, which is the same work as an intersection.
33///
34/// # Errors
35///
36/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if the curve cannot be
37/// evaluated at its own domain ends.
38pub fn curve_bounds(curve: &Curve, tol: Tolerances) -> OgeomResult<Aabb> {
39    Ok(match curve {
40        // Exact: a segment is the hull of its ends.
41        Curve::Line(_) => Aabb::of_corners(curve.start(tol)?, curve.end(tol)?),
42
43        // A conic's extremes are its centre displaced by the radii along each
44        // frame axis, which bounds the whole conic whatever arc is in use.
45        Curve::Circle(c) => {
46            let circle = c.circle();
47            frame_bounds(
48                circle.centre(),
49                circle.frame(),
50                (circle.radius(), circle.radius(), 0.0),
51            )
52        }
53        Curve::Ellipse(e) => {
54            let ellipse = e.ellipse();
55            frame_bounds(
56                ellipse.centre(),
57                ellipse.frame(),
58                (ellipse.major_radius(), ellipse.minor_radius(), 0.0),
59            )
60        }
61
62        // A helix never leaves its cylinder, and its rise over the trimmed
63        // angle interval is linear, so the cylinder's box over that rise
64        // contains it, tight along the axis and whole-circle-loose across,
65        // the same convention the circle uses.
66        Curve::Helix(h) => {
67            let rise = h.pitch() / core::f64::consts::TAU;
68            let slope = h.taper() / core::f64::consts::TAU;
69            let (a, b) = h.domain();
70            let reach = slope
71                .mul_add(a, h.radius())
72                .abs()
73                .max(slope.mul_add(b, h.radius()).abs());
74            let mid = h.frame().origin() + h.frame().z().vector() * (rise * f64::midpoint(a, b));
75            frame_bounds(
76                mid,
77                *h.frame(),
78                (reach, reach, (rise * (b - a) / 2.0).abs()),
79            )
80        }
81
82        // An offset never strays farther than its distance from the basis:
83        // the basis's bound grown by |d| on every axis contains it, exactly
84        // the guarantee and no tighter.
85        Curve::Offset(o) => curve_bounds(o.basis(), tol)?.expanded(o.distance().abs()),
86
87        // A surface curve never leaves its surface, whose bound is already
88        // guaranteed.
89        Curve::OnSurface(c) => surface_bounds(c.surface(), tol)?,
90
91        // A hyperbola and a parabola are unbounded, so only the trimmed extent
92        // has a bound at all. Both are convex in their own frame, so the hull
93        // of the two ends and the vertex contains the arc between them.
94        Curve::Hyperbola(_) | Curve::Parabola(_) => {
95            let (a, b) = curve.domain();
96            let mid = curve.point_at(f64::midpoint(a, b), tol)?;
97            let ends = Aabb::of_corners(curve.start(tol)?, curve.end(tol)?);
98            // The midpoint is the extreme in the frame's x direction for both,
99            // and the ends bound the rest.
100            ends.with_point(mid)
101        }
102
103        // The convex hull property: a B-spline never leaves the hull of its
104        // control polygon, so the polygon's box contains the curve exactly.
105        Curve::BSpline(s) => Aabb::of_points(
106            &s.control_points()
107                .iter()
108                .map(|w| w.point())
109                .collect::<Vec<_>>(),
110        ),
111
112        Curve::Trimmed(t) => curve_bounds(t.basis(), tol)?,
113    })
114}
115
116/// A guaranteed bound for the part of a curve an edge actually uses.
117///
118/// [`curve_bounds`] answers for the whole curve, which is the right answer
119/// to a different question: a line's carrier runs to the ends of the
120/// world, and an imported edge sits on a stretch of it a few millimetres
121/// long. Where the range can be honoured exactly it is (a segment is the
122/// hull of its two ends, an arc the hull of its ends and whichever of its
123/// frame's four extremes it sweeps past), and where it cannot, the whole
124/// curve's bound stands, which is still a bound.
125///
126/// # Errors
127///
128/// Whatever the curve reports when asked for a point.
129pub fn curve_bounds_over(curve: &Curve, range: (f64, f64), tol: Tolerances) -> OgeomResult<Aabb> {
130    use ogeom_geom::Curve3d as _;
131    Ok(match curve {
132        Curve::Line(_) => {
133            Aabb::of_corners(curve.point_at(range.0, tol)?, curve.point_at(range.1, tol)?)
134        }
135        Curve::Circle(c) => {
136            let circle = c.circle();
137            arc_bounds(
138                circle.centre(),
139                circle.frame(),
140                circle.radius(),
141                circle.radius(),
142                range,
143                curve,
144                tol,
145            )?
146        }
147        Curve::Ellipse(e) => {
148            let ellipse = e.ellipse();
149            arc_bounds(
150                ellipse.centre(),
151                ellipse.frame(),
152                ellipse.major_radius(),
153                ellipse.minor_radius(),
154                range,
155                curve,
156                tol,
157            )?
158        }
159        Curve::Offset(o) => curve_bounds_over(o.basis(), range, tol)?.expanded(o.distance().abs()),
160        Curve::Trimmed(t) => curve_bounds_over(t.basis(), range, tol)?,
161        _ => curve_bounds(curve, tol)?,
162    })
163}
164
165/// The hull of an arc's ends and the frame extremes it sweeps past.
166///
167/// A conic in its own frame reaches its extremes at the four quarter
168/// angles; an arc reaches only the ones inside it, and its ends otherwise.
169fn arc_bounds(
170    centre: Point,
171    frame: ogeom_math::Frame,
172    rx: f64,
173    ry: f64,
174    range: (f64, f64),
175    curve: &Curve,
176    tol: Tolerances,
177) -> OgeomResult<Aabb> {
178    use core::f64::consts::{PI, TAU};
179    use ogeom_geom::Curve3d as _;
180    let (lo, hi) = if range.0 <= range.1 {
181        (range.0, range.1)
182    } else {
183        (range.1, range.0)
184    };
185    if hi - lo >= TAU {
186        return Ok(frame_bounds(centre, frame, (rx, ry, 0.0)));
187    }
188    let mut out = Aabb::of_corners(curve.point_at(lo, tol)?, curve.point_at(hi, tol)?);
189    // Every quarter angle the arc runs through, counted from the turn its
190    // own start sits in.
191    let turns = (lo / TAU).floor();
192    for step in 0..=4 {
193        #[allow(clippy::cast_precision_loss)]
194        let at = turns.mul_add(TAU, step as f64 * PI / 2.0);
195        for angle in [at, at + TAU] {
196            if angle >= lo && angle <= hi {
197                out = out.with_point(curve.point_at(angle, tol)?);
198            }
199        }
200    }
201    Ok(out)
202}
203
204/// A guaranteed bound for a surface.
205///
206/// # Errors
207///
208/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if the surface cannot be
209/// evaluated over its own domain.
210pub fn surface_bounds(surface: &SurfaceGeometry, tol: Tolerances) -> OgeomResult<Aabb> {
211    Ok(match surface {
212        // An offset never strays farther than its distance from the basis.
213        SurfaceGeometry::Offset(o) => surface_bounds(o.basis(), tol)?.expanded(o.distance().abs()),
214
215        // A plane is unbounded; its declared domain is what there is to bound,
216        // and the four corners span it exactly.
217        SurfaceGeometry::Plane(p) => {
218            let ((ua, ub), (va, vb)) = p.domain();
219            if ua <= -LINE_EXTENT || ub >= LINE_EXTENT {
220                ogeom_bail!(
221                    Domain,
222                    "an unbounded plane has no finite bound; trim it before asking"
223                );
224            }
225            let mut out = Aabb::EMPTY;
226            for (u, v) in [(ua, va), (ua, vb), (ub, va), (ub, vb)] {
227                out = out.with_point(p.point_at(u, v, tol)?);
228            }
229            out
230        }
231
232        SurfaceGeometry::Cylinder(c) => {
233            let cyl = c.cylinder();
234            let ((_, _), (va, vb)) = c.domain();
235            let frame = cyl.frame();
236            let base = frame.origin() + frame.z() * va;
237            let top = frame.origin() + frame.z() * vb;
238            let radial = frame_bounds(base, frame, (cyl.radius(), cyl.radius(), 0.0));
239            radial.union(&frame_bounds(top, frame, (cyl.radius(), cyl.radius(), 0.0)))
240        }
241
242        SurfaceGeometry::Cone(c) => {
243            let cone = c.cone();
244            let ((_, _), (va, vb)) = c.domain();
245            let frame = cone.frame();
246            let mut out = Aabb::EMPTY;
247            for height in [va, vb] {
248                let radius = cone.radius_at(height).abs();
249                let centre = frame.origin() + frame.z() * height;
250                out = out.union(&frame_bounds(centre, frame, (radius, radius, 0.0)));
251            }
252            out
253        }
254
255        // A sphere's bound is its centre plus its radius on every axis, whatever
256        // patch of it is in use.
257        SurfaceGeometry::Sphere(s) => {
258            let sphere = s.sphere();
259            let r = Vector::splat(sphere.radius());
260            Aabb::of_corners(sphere.centre() - r, sphere.centre() + r)
261        }
262
263        SurfaceGeometry::Torus(t) => {
264            let torus = t.torus();
265            let reach = torus.major_radius() + torus.minor_radius();
266            frame_bounds(
267                torus.centre(),
268                torus.frame(),
269                (reach, reach, torus.minor_radius()),
270            )
271        }
272
273        // Convex hull property again, in two directions.
274        SurfaceGeometry::BSpline(s) => Aabb::of_points(
275            &s.grid()
276                .points()
277                .iter()
278                .map(|w| w.point())
279                .collect::<Vec<_>>(),
280        ),
281
282        // A revolved curve reaches at most its own furthest distance from the
283        // axis, in every direction around it.
284        SurfaceGeometry::Revolution(r) => {
285            let curve = curve_bounds(r.curve(), tol)?;
286            let axis = r.axis();
287            let mut reach: f64 = 0.0;
288            let mut along = Aabb::EMPTY;
289            for corner in curve.corners() {
290                reach = reach.max(axis.distance_to(corner));
291                along = along.with_point(axis.project(corner));
292            }
293            let radial = Vector::splat(reach);
294            along.expanded(0.0).union(&Aabb::of_corners(
295                along.low().unwrap_or(axis.location) - radial,
296                along.high().unwrap_or(axis.location) + radial,
297            ))
298        }
299
300        // A swept curve reaches the curve's bound at each end of the sweep.
301        SurfaceGeometry::Extrusion(e) => {
302            let base = curve_bounds(e.curve(), tol)?;
303            let ((_, _), (va, vb)) = e.domain();
304            let start = base.transformed(&ogeom_math::Transform::translation(e.direction() * va));
305            let end = base.transformed(&ogeom_math::Transform::translation(e.direction() * vb));
306            start.union(&end)
307        }
308
309        SurfaceGeometry::Trimmed(t) => surface_bounds(t.basis(), tol)?,
310    })
311}
312
313/// The box of a point displaced by `(x, y, z)` extents along a frame's axes.
314///
315/// Every axis of the result gets the sum of the absolute contributions from all
316/// three frame directions, which is what makes it a bound rather than an
317/// estimate: a tilted frame's extent projects onto every world axis at once.
318fn frame_bounds(centre: Point, frame: ogeom_math::Frame, extent: (f64, f64, f64)) -> Aabb {
319    let (ex, ey, ez) = extent;
320    let reach = |axis: fn(&Vector) -> f64| {
321        (frame.x().vector().pipe(axis) * ex).abs()
322            + (frame.y().vector().pipe(axis) * ey).abs()
323            + (frame.z().vector().pipe(axis) * ez).abs()
324    };
325    let r = Vector::new(reach(|v| v.x), reach(|v| v.y), reach(|v| v.z));
326    Aabb::of_corners(centre - r, centre + r)
327}
328
329/// A tiny helper so the reach computation above reads as one expression.
330trait Pipe {
331    fn pipe<R>(&self, f: impl FnOnce(&Self) -> R) -> R;
332}
333
334impl Pipe for Vector {
335    fn pipe<R>(&self, f: impl FnOnce(&Self) -> R) -> R {
336        f(self)
337    }
338}
339
340/// A guaranteed bound for a shape, including everything below it.
341///
342/// Vertices contribute their point widened by their own tolerance, since a
343/// vertex genuinely occupies that much space. Edges and faces contribute the
344/// bound of their geometry, likewise widened.
345///
346/// # Errors
347///
348/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if any handle fails to
349/// resolve, and whatever the geometry's own bound reports.
350pub fn shape_bounds(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Aabb> {
351    let Some(node) = model.node(shape) else {
352        ogeom_bail!(Dangling, "shape refers to a node not in this model");
353    };
354    let placement = shape.transform(model.datums())?;
355
356    let own = match node.data() {
357        NodeData::Vertex(v) => Aabb::of_point(placement.apply(v.point)).expanded(v.tolerance.get()),
358        NodeData::Edge(e) => {
359            let mut out = Aabb::EMPTY;
360            for repr in &e.representations {
361                if let ogeom_topo::EdgeRepr::Curve3d { curve, range, .. } = repr
362                    && let Some(geometry) = model.geometry().curve(*curve)
363                {
364                    // Over the range the edge uses, not the curve's whole
365                    // carrier: an imported edge sits on a few millimetres of
366                    // a line that runs to the ends of the world.
367                    out = out.union(&curve_bounds_over(geometry, *range, tol)?);
368                }
369            }
370            out.transformed(&placement).expanded(e.tolerance.get())
371        }
372        NodeData::Face(f) => {
373            // A face's boundary is bounded by the wires below it, so what
374            // the face itself has to add is only where its surface bulges
375            // past that boundary: a dome past its equator. A trimmed face
376            // whose carrier runs to the ends of the world must not bring
377            // the carrier: an imported plane's window spans kilometres, and
378            // a cylinder's height domain more, and either would drown every
379            // consumer that asks a body how big it is.
380            //
381            // A face with no boundary at all (a whole sphere, a natural
382            // face) has nothing below it and keeps its surface's bound.
383            let own = match model.geometry().surface(f.surface) {
384                Some(surface) if !model.children_of(shape)?.is_empty() => {
385                    patch_bulge(model, shape, surface, f.surface, tol)?
386                }
387                Some(surface) => surface_bounds(surface, tol).unwrap_or(Aabb::EMPTY),
388                None => Aabb::EMPTY,
389            };
390            own.transformed(&placement).expanded(f.tolerance.get())
391        }
392        NodeData::Container => Aabb::EMPTY,
393    };
394
395    let mut out = own;
396    for child in model.children_of(shape)? {
397        out = out.union(&shape_bounds(model, &child, tol)?);
398    }
399    Ok(out)
400}
401
402/// Where a face's surface reaches past the boundary that trims it.
403///
404/// A flat or ruled patch reaches nowhere: every one of its points lies on a
405/// straight line between two points of its own boundary, so the boundary's
406/// bound holds it and this adds nothing. A sphere's or a torus's does (the
407/// button head of a screw is a sphere zone whose apex is a bulge between
408/// its rims, three millimetres past the hull of every vertex it has), and
409/// for those the bulge is exactly where the surface reaches its own extreme
410/// along each axis, when that point lies inside the face's trim.
411///
412/// Anything else keeps its surface's whole bound, which is what it had
413/// before there was a better answer: a spline's control hull is finite and
414/// honest, and a revolution's or an extrusion's carrier is the only bound
415/// there is for it.
416fn patch_bulge(
417    model: &Model,
418    face: &Shape,
419    surface: &SurfaceGeometry,
420    surface_id: ogeom_topo::SurfaceId,
421    tol: Tolerances,
422) -> OgeomResult<Aabb> {
423    use ogeom_geom::Surface as _;
424    let frame = match surface {
425        // Flat, or ruled along a straight generator: the boundary says all.
426        SurfaceGeometry::Plane(_)
427        | SurfaceGeometry::Cylinder(_)
428        | SurfaceGeometry::Cone(_)
429        | SurfaceGeometry::Extrusion(_) => return Ok(Aabb::EMPTY),
430        SurfaceGeometry::Sphere(s) => s.sphere().frame(),
431        SurfaceGeometry::Torus(t) => t.torus().frame(),
432        // A patch's whole net is honest and can still be useless. A patch
433        // whose `u` knots run from −80 to 1 and whose `v` run to 85 after
434        // four spans inside the first two carries a face in the last unit
435        // of each, and the whole net bounds it seven metres across, which
436        // is what a viewer frames a scene to, so the part it belongs to
437        // draws as a speck. The trim says which part of the net can matter.
438        SurfaceGeometry::BSpline(spline) => {
439            let Some(outline) = chart_outline(model, face, surface_id, tol)? else {
440                return Ok(surface_bounds(surface, tol).unwrap_or(Aabb::EMPTY));
441            };
442            let (mut ua, mut ub) = (f64::INFINITY, f64::NEG_INFINITY);
443            let (mut va, mut vb) = (f64::INFINITY, f64::NEG_INFINITY);
444            for ring in &outline {
445                for at in ring {
446                    ua = ua.min(at.x);
447                    ub = ub.max(at.x);
448                    va = va.min(at.y);
449                    vb = vb.max(at.y);
450                }
451            }
452            if !(ua.is_finite() && ub.is_finite() && va.is_finite() && vb.is_finite()) {
453                return Ok(surface_bounds(surface, tol).unwrap_or(Aabb::EMPTY));
454            }
455            return Ok(spline_hull_over(spline, (ua, ub), (va, vb), tol));
456        }
457        _ => return Ok(surface_bounds(surface, tol).unwrap_or(Aabb::EMPTY)),
458    };
459    let Some(outline) = chart_outline(model, face, surface_id, tol)? else {
460        return Ok(surface_bounds(surface, tol).unwrap_or(Aabb::EMPTY));
461    };
462    // The turn the trim is written in, so a bulge can be folded into it.
463    let (mut ua, mut ub) = (f64::INFINITY, f64::NEG_INFINITY);
464    for ring in &outline {
465        for at in ring {
466            ua = ua.min(at.x);
467            ub = ub.max(at.x);
468        }
469    }
470    // Both surfaces read the same way: a ring about the frame's z whose
471    // radius falls off with `cos v`, lifted along z by `sin v`. So the
472    // extreme along a direction is one pair of angles, in closed form: the
473    // longitude facing that way, and the latitude that tilts toward it.
474    let (x, y, z) = (frame.x().vector(), frame.y().vector(), frame.z().vector());
475    let mut out = Aabb::EMPTY;
476    for direction in [
477        Vector::X,
478        -Vector::X,
479        Vector::Y,
480        -Vector::Y,
481        Vector::Z,
482        -Vector::Z,
483    ] {
484        let (a, b, c) = (x.dot(direction), y.dot(direction), z.dot(direction));
485        let sideways = a.hypot(b);
486        let u = b.atan2(a);
487        let v = c.atan2(sideways);
488        // Folded into the turn the trim is written in, then asked of the
489        // outline itself.
490        let turn = core::f64::consts::TAU;
491        let turns = ((ua - u) / turn).ceil();
492        let folded = turns.mul_add(turn, u);
493        let inside = (folded <= ub && inside_outline(&outline, ogeom_math::Point2::new(folded, v)))
494            || (surface.is_periodic_v()
495                && [-turn, turn].iter().any(|shift| {
496                    inside_outline(&outline, ogeom_math::Point2::new(folded, v + shift))
497                }));
498        if inside {
499            out = out.with_point(surface.point_at(u, v, tol)?);
500        }
501    }
502    Ok(out)
503}
504
505/// A patch's control hull over one rectangle of its chart.
506///
507/// The convex-hull property is *local*, but taking the control points whose
508/// support merely overlaps the rectangle is not enough on a real file: the
509/// patch above runs its `u` knots from −80 to 1, and the control
510/// points that shape the last unit also shape the eighty before it, so they
511/// sit a hundred and seventy millimetres from a part sixty across. The
512/// patch is cut down to the rectangle instead (knots raised to full
513/// multiplicity at each edge, which is what makes the control points either
514/// side independent), and the piece that remains carries its own net, tight
515/// around the only part of the surface the trim can reach.
516///
517/// A cut that cannot be made (an edge already at the domain's own end, or a
518/// multiplicity already full) leaves that direction whole, which is the
519/// bound this had before.
520fn spline_hull_over(
521    spline: &ogeom_geom::BSplineSurface,
522    u: (f64, f64),
523    v: (f64, f64),
524    tol: Tolerances,
525) -> Aabb {
526    let grid = spline.grid();
527    // One polygon along `u` per `v` column, cut down; then the same net
528    // read the other way and cut along `v`.
529    let columns: Vec<Vec<ogeom_math::Weighted<Point>>> = (0..grid.v_count())
530        .map(|j| (0..grid.u_count()).filter_map(|i| grid.get(i, j)).collect())
531        .collect();
532    let columns = cut_to(spline.u_knots(), columns, u, tol);
533    let Some(width) = columns.first().map(Vec::len) else {
534        return Aabb::EMPTY;
535    };
536    let rows: Vec<Vec<ogeom_math::Weighted<Point>>> = (0..width)
537        .map(|i| columns.iter().map(|column| column[i]).collect())
538        .collect();
539    let rows = cut_to(spline.v_knots(), rows, v, tol);
540    Aabb::of_points(
541        &rows
542            .iter()
543            .flat_map(|row| row.iter().map(|w| w.point()))
544            .collect::<Vec<_>>(),
545    )
546}
547
548/// Cut every control polygon of one direction down to `[a, b]`, together.
549///
550/// The polygons share a knot vector, so they are cut with the same value at
551/// the same multiplicity and come out the same length; a cut that refuses
552/// leaves all of them as they were.
553fn cut_to(
554    knots: &ogeom_math::KnotVector,
555    polygons: Vec<Vec<ogeom_math::Weighted<Point>>>,
556    (a, b): (f64, f64),
557    tol: Tolerances,
558) -> Vec<Vec<ogeom_math::Weighted<Point>>> {
559    let mut knots = knots.clone();
560    let mut polygons = polygons;
561    for (at, keep_right) in [(a, true), (b, false)] {
562        let mut cut = Vec::with_capacity(polygons.len());
563        let mut cut_knots = None;
564        for polygon in &polygons {
565            let Ok((left, right)) = ogeom_math::bspline::split(&knots, polygon, at, tol) else {
566                cut.clear();
567                break;
568            };
569            let (half_knots, points) = if keep_right { right } else { left };
570            cut_knots = Some(half_knots);
571            cut.push(points);
572        }
573        if let Some(half_knots) = cut_knots.filter(|_| cut.len() == polygons.len()) {
574            knots = half_knots;
575            polygons = cut;
576        }
577    }
578    polygons
579}
580
581/// A face's trim as polygons in its surface's chart, sampled from the
582/// pcurves, which is what a trim is written as.
583///
584/// The rectangle they span is not the trim: a screw's button head is a cap
585/// of a sphere whose own axis is not the cap's, so its rim wanders across
586/// the chart and the box around it holds most of a hemisphere. What a bulge
587/// has to be asked is whether it stands inside the outline itself.
588fn chart_outline(
589    model: &Model,
590    face: &Shape,
591    surface_id: ogeom_topo::SurfaceId,
592    tol: Tolerances,
593) -> OgeomResult<Option<Vec<Vec<ogeom_math::Point2>>>> {
594    use ogeom_geom::Curve2d as _;
595    const STATIONS: usize = 16;
596    let mut outline = Vec::new();
597    for wire in model.ordered_children_of(face)? {
598        let mut ring: Vec<ogeom_math::Point2> = Vec::new();
599        // In the wire's own order, because a polygon is a walk and not a
600        // bag of pieces. Which column a seam's occurrence takes is decided
601        // by the ring (the side whose start continues where the walk has
602        // got to), as the tessellator decides it, since no flag can.
603        for edge in model.ordered_children_of(&wire)? {
604            let Some(repr) = model
605                .node(&edge)
606                .and_then(|n| n.data().as_edge())
607                .and_then(|d| d.pcurve_for(surface_id, edge.location()))
608            else {
609                return Ok(None);
610            };
611            let backwards = edge.orientation() == ogeom_topo::Orientation::Reversed;
612            let (id, range) = match repr {
613                ogeom_topo::EdgeRepr::PCurve { curve, range, .. } => (*curve, *range),
614                ogeom_topo::EdgeRepr::Seam {
615                    forward,
616                    reversed,
617                    range,
618                    ..
619                } => {
620                    let start = if backwards { range.1 } else { range.0 };
621                    let reach = |id: ogeom_topo::PCurveId| -> f64 {
622                        let Some(pcurve) = model.geometry().pcurve(id) else {
623                            return f64::INFINITY;
624                        };
625                        let Ok(at) = pcurve.point_at(start, tol) else {
626                            return f64::INFINITY;
627                        };
628                        ring.last().map_or(0.0, |previous| previous.distance(at))
629                    };
630                    let take = if reach(*forward) <= reach(*reversed) {
631                        *forward
632                    } else {
633                        *reversed
634                    };
635                    (take, *range)
636                }
637                _ => return Ok(None),
638            };
639            let Some(pcurve) = model.geometry().pcurve(id) else {
640                return Ok(None);
641            };
642            let (from, to) = if backwards {
643                (range.1, range.0)
644            } else {
645                (range.0, range.1)
646            };
647            for step in 0..=STATIONS {
648                #[allow(clippy::cast_precision_loss)]
649                let t = from + (to - from) * (step as f64 / STATIONS as f64);
650                ring.push(pcurve.point_at(t, tol)?);
651            }
652        }
653        if ring.len() >= 3 {
654            outline.push(ring);
655        }
656    }
657    Ok((!outline.is_empty()).then_some(outline))
658}
659
660/// Whether a point of the chart stands inside an outline, by crossings.
661///
662/// Every ring counts, so a hole cancels the boundary it is a hole in, which
663/// is the even-odd rule and all a bulge needs of it.
664fn inside_outline(outline: &[Vec<ogeom_math::Point2>], at: ogeom_math::Point2) -> bool {
665    let mut crossings = 0_usize;
666    for ring in outline {
667        for pair in ring.windows(2) {
668            let (a, b) = (pair[0], pair[1]);
669            if (a.y > at.y) == (b.y > at.y) {
670                continue;
671            }
672            let span = b.y - a.y;
673            if span.abs() <= f64::MIN_POSITIVE {
674                continue;
675            }
676            let x = (b.x - a.x).mul_add((at.y - a.y) / span, a.x);
677            if x > at.x {
678                crossings += 1;
679            }
680        }
681        // Rings arrive open (the sampling walks each edge), so the closing
682        // step is counted too.
683        if let (Some(first), Some(last)) = (ring.first(), ring.last())
684            && (first.y > at.y) != (last.y > at.y)
685        {
686            let span = first.y - last.y;
687            if span.abs() > f64::MIN_POSITIVE {
688                let x = (first.x - last.x).mul_add((at.y - last.y) / span, last.x);
689                if x > at.x {
690                    crossings += 1;
691                }
692            }
693        }
694    }
695    crossings % 2 == 1
696}
697
698/// A bound for a shape built only from its vertices.
699///
700/// Tighter than [`shape_bounds`] for a solid whose faces sit on unbounded
701/// surfaces, and *not* a guarantee: a curved edge bulges past its own
702/// endpoints. Use it for a quick estimate, never for a rejection test.
703///
704/// # Errors
705///
706/// As [`shape_bounds`].
707pub fn vertex_bounds(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Aabb> {
708    let mut out = Aabb::EMPTY;
709    for vertex in explore_unique(model, shape, ShapeType::Vertex)? {
710        let Some(node) = model.node(&vertex) else {
711            ogeom_bail!(Dangling, "vertex is not in this model");
712        };
713        if let Some(data) = node.data().as_vertex() {
714            let placed = vertex.transform(model.datums())?.apply(data.point);
715            out = out.with_point(placed);
716        }
717    }
718    Ok(out.expanded(tol.confusion()))
719}
720
721/// A box that has been turned to fit what it bounds.
722///
723/// An axis-aligned box around a long thin rod lying diagonally is mostly empty;
724/// this one is not. The cost is that testing a point against it is a transform
725/// and then a comparison, rather than six comparisons, so [`Aabb`] stays the
726/// default and this is for when the emptiness matters, which is broad-phase
727/// rejection and anything that quotes a shape's real extent.
728#[derive(Debug, Clone, Copy, PartialEq)]
729pub struct Obb {
730    /// The centre, and the axes the half-extents are measured along.
731    pub frame: Frame,
732    /// Half the size along the frame's `x`, `y` and `z`.
733    pub half_extent: Vector,
734}
735
736impl Obb {
737    /// The eight corners, in the same order [`Aabb::corners`] uses.
738    #[must_use]
739    pub fn corners(&self) -> Vec<Point> {
740        let (x, y, z) = (
741            self.frame.x().vector() * self.half_extent.x,
742            self.frame.y().vector() * self.half_extent.y,
743            self.frame.z().vector() * self.half_extent.z,
744        );
745        let mut out = Vec::with_capacity(8);
746        for k in [-1.0_f64, 1.0] {
747            for j in [-1.0_f64, 1.0] {
748                for i in [-1.0_f64, 1.0] {
749                    out.push(self.frame.origin() + x * i + y * j + z * k);
750                }
751            }
752        }
753        out
754    }
755
756    /// The volume it encloses.
757    #[must_use]
758    pub fn volume(&self) -> f64 {
759        8.0 * self.half_extent.x * self.half_extent.y * self.half_extent.z
760    }
761
762    /// Whether a point is inside, measured in the box's own frame.
763    #[must_use]
764    pub fn contains(&self, p: Point) -> bool {
765        let local = self.frame.to_local(p);
766        local.x.abs() <= self.half_extent.x
767            && local.y.abs() <= self.half_extent.y
768            && local.z.abs() <= self.half_extent.z
769    }
770
771    /// The axis-aligned box that contains this one.
772    #[must_use]
773    pub fn to_aabb(&self) -> Aabb {
774        Aabb::of_points(&self.corners())
775    }
776}
777
778/// An oriented bound for a shape, from the spread of its geometry.
779///
780/// The axes come from the covariance of sampled points (the directions the
781/// shape is most and least spread along), and the extents are then measured
782/// along those axes, so the box is tight even though the fit is not exact.
783///
784/// **Not a guarantee, unlike [`shape_bounds`].** It is built from samples, so a
785/// curved face can bulge a little past it. Widen it before using it to reject
786/// anything.
787///
788/// # Errors
789///
790/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if a handle fails to
791/// resolve; [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
792/// shape has no geometry to bound.
793pub fn oriented_bounds(
794    model: &Model,
795    shape: &Shape,
796    deflection: ogeom_mesh::Deflection,
797    tol: Tolerances,
798) -> OgeomResult<Obb> {
799    let mut points = Vec::new();
800    // The tessellation, where there is one to build: it follows the shape's
801    // real extent, where the vertices alone would miss the bulge of a cylinder.
802    if let Ok(mesh) = ogeom_mesh::triangulate(model, shape, deflection, tol) {
803        points.extend(mesh.positions.iter().copied());
804    }
805    for vertex in explore_unique(model, shape, ShapeType::Vertex)? {
806        if let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) {
807            points.push(vertex.transform(model.datums())?.apply(data.point));
808        }
809    }
810    if points.is_empty() {
811        ogeom_bail!(Construction, "the shape has no geometry to bound");
812    }
813
814    let frame = spread_frame(&points, tol);
815    let mut low = Vector::new(f64::MAX, f64::MAX, f64::MAX);
816    let mut high = Vector::new(f64::MIN, f64::MIN, f64::MIN);
817    for p in &points {
818        let local = frame.to_local(*p);
819        low = Vector::new(low.x.min(local.x), low.y.min(local.y), low.z.min(local.z));
820        high = Vector::new(
821            high.x.max(local.x),
822            high.y.max(local.y),
823            high.z.max(local.z),
824        );
825    }
826    // The covariance frame is centred on the mean, which is not the middle of
827    // the extent: a shape with more detail at one end pulls it. Recentring is
828    // what makes the half-extents symmetric and the box actually tight.
829    let middle = (low + high) * 0.5;
830    let centre = frame.to_world(Point::ORIGIN + middle);
831    Ok(Obb {
832        frame: frame.with_origin(centre),
833        half_extent: (high - low) * 0.5,
834    })
835}
836
837/// The direction a face presents, in space.
838///
839/// Sampled at the mean of its boundary in parameter space, which for a planar
840/// profile is exact everywhere and for a curved one is representative: a
841/// profile whose normal turns past perpendicular to the sweep somewhere across
842/// its own extent sweeps into a solid that passes through itself, and one
843/// sample is enough to decide which side the material lands on in every case
844/// this can build. A face with no boundary at all covers its whole surface, so
845/// the middle of the domain is the point to ask about.
846pub fn face_normal(model: &Model, face: &Shape, tol: Tolerances) -> OgeomResult<(Point, Vector)> {
847    let Some(node) = model.node(face) else {
848        ogeom_bail!(Dangling, "face is not in this model");
849    };
850    let Some(data) = node.data().as_face() else {
851        ogeom_bail!(Construction, "face node holds no face data");
852    };
853    let Some(surface) = model.geometry().surface(data.surface) else {
854        ogeom_bail!(Dangling, "face refers to a surface not in this model");
855    };
856
857    let mut sum = (0.0, 0.0);
858    let mut count = 0_u32;
859    // The outer wire is the first, and it alone bounds the region; a hole would
860    // only pull the sample towards a point the face does not cover.
861    for edge in match model.children_of(face)?.first() {
862        Some(outer) => model.children_of(outer)?,
863        None => Vec::new(),
864    } {
865        let Some(edge_data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
866            continue;
867        };
868        let (id, range) = match edge_data.pcurve_for(data.surface, edge.location()) {
869            Some(EdgeRepr::PCurve { curve, range, .. }) => (*curve, *range),
870            Some(EdgeRepr::Seam { forward, range, .. }) => (*forward, *range),
871            _ => continue,
872        };
873        let Some(pcurve) = model.geometry().pcurve(id) else {
874            ogeom_bail!(Dangling, "pcurve is not in this model");
875        };
876        for at in [range.0, f64::midpoint(range.0, range.1), range.1] {
877            let p = pcurve.point_at(at, tol)?;
878            sum = (sum.0 + p.x, sum.1 + p.y);
879            count += 1;
880        }
881    }
882
883    let ((ua, ub), (va, vb)) = surface.domain();
884    // A face with no trims on its surface is read at its outer wire's own
885    // middle, found on the surface; the middle of an unbounded plane's
886    // chart is its origin, which may be anywhere relative to the face.
887    let wire_middle = if count == 0 {
888        let mut points: Vec<Point> = Vec::new();
889        if let Some(outer) = model.children_of(face)?.first() {
890            for edge in model.children_of(outer)? {
891                let Some(edge_data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
892                    continue;
893                };
894                let Some(EdgeRepr::Curve3d { curve, range, .. }) = edge_data.curve3d() else {
895                    continue;
896                };
897                let Some(geometry) = model.geometry().curve(*curve) else {
898                    continue;
899                };
900                for k in 0..4 {
901                    let t = range.0 + (range.1 - range.0) * f64::from(k) / 4.0;
902                    points.push(geometry.point_at(t, tol)?);
903                }
904            }
905        }
906        if points.is_empty() {
907            None
908        } else {
909            #[allow(clippy::cast_precision_loss)]
910            let n = points.len() as f64;
911            let sum = points
912                .iter()
913                .fold(Vector::new(0.0, 0.0, 0.0), |acc, p| acc + p.to_vector());
914            let centre = Point::from_vector(sum / n);
915            project_on_surface(surface, centre, 16, tol)
916                .ok()
917                .map(|found| found.parameters)
918        }
919    } else {
920        None
921    };
922    let (u, v) = if let Some(uv) = wire_middle {
923        uv
924    } else if count == 0 {
925        (f64::midpoint(ua, ub), f64::midpoint(va, vb))
926    } else {
927        let n = f64::from(count);
928        (sum.0 / n, sum.1 / n)
929    };
930    let normal = surface.normal_at(u, v, tol)?;
931    let point = surface.point_at(u, v, tol)?;
932
933    let placement = face.transform(model.datums())?;
934    let placed = placement.apply_vector(normal.vector());
935    Ok((
936        placement.apply(point),
937        if face.orientation() == Orientation::Reversed {
938            -placed
939        } else {
940            placed
941        },
942    ))
943}
944
945/// A deflection expressed as a fraction of a shape's own size.
946///
947/// "A thousandth of the part" survives the part being modelled in metres rather
948/// than millimetres, and being scaled after it was drawn; an absolute chord
949/// does not. [`Deflection::relative`](ogeom_mesh::Deflection::relative) does the
950/// arithmetic once a size is known; this is what finds the size, which is the
951/// part a caller should not have to get right.
952///
953/// The measure is the bounding box's *diagonal*, not its longest side: a thin
954/// plate and a cube of the same longest side are not equally demanding, and the
955/// diagonal is the one that notices.
956///
957/// # Errors
958///
959/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `fraction` is
960/// not finite and positive, or the shape has nothing a deflection would
961/// describe.
962pub fn relative_deflection(
963    model: &Model,
964    shape: &Shape,
965    fraction: f64,
966    tol: Tolerances,
967) -> OgeomResult<ogeom_mesh::Deflection> {
968    // A deflection says how closely a polyline should follow a curve, or a
969    // triangle a surface. A shape with neither has nothing for it to be about,
970    // and it is not enough to look at the size, because a lone vertex *does*
971    // have a bound: its own tolerance. A fraction of that is a chord of about
972    // 1e-10, which is not a small answer, it is a meaningless one.
973    if explore_unique(model, shape, ShapeType::Edge)?.is_empty()
974        && explore_unique(model, shape, ShapeType::Face)?.is_empty()
975    {
976        ogeom_bail!(
977            Construction,
978            "the shape has no edges or faces, so there is nothing a deflection \
979             would describe"
980        );
981    }
982    let diagonal = shape_bounds(model, shape, tol)?.diagonal();
983    if !diagonal.is_finite() || diagonal <= tol.confusion() {
984        ogeom_bail!(
985            Construction,
986            "the shape has no extent for a deflection to be a fraction of"
987        );
988    }
989    ogeom_mesh::Deflection::relative(diagonal, fraction)
990}
991
992/// A frame whose axes are the directions a point set is most spread along.
993///
994/// The eigenvectors of the covariance, largest spread first. Falls back to the
995/// world frame when the points are too few or too degenerate to say: a fit
996/// that cannot decide should return something usable rather than fail, since
997/// the caller then measures extents along whatever axes it gets and still
998/// bounds the shape.
999fn spread_frame(points: &[Point], tol: Tolerances) -> Frame {
1000    let Some((centroid, axes)) = covariance_axes(points) else {
1001        return Frame::WORLD.with_origin(points.first().copied().unwrap_or(Point::ORIGIN));
1002    };
1003    // `Frame::new` takes the primary direction as `z` and a *reference* for
1004    // `x`, so the most-spread axis goes in the second slot: the frame's `x` is
1005    // the direction the shape is longest along, which is what a caller reading
1006    // `half_extent.x` will expect, and its `z` is the flattest.
1007    let [most, _, least] = axes;
1008    Frame::new(centroid, least, most, tol)
1009        .or_else(|_| Frame::new(centroid, least, Direction::X, tol))
1010        .or_else(|_| Frame::new(centroid, least, Direction::Y, tol))
1011        .unwrap_or_else(|_| Frame::WORLD.with_origin(centroid))
1012}
1013
1014/// The plane that best fits a point set: its centroid, and the normal to it.
1015///
1016/// The eigenvector of the covariance with the *smallest* eigenvalue: the
1017/// direction the points vary along least. `None` when there is nothing to fit.
1018///
1019/// Fitting says nothing about whether the points are actually planar. Every set
1020/// of three or more points has a best-fit plane, including a set that is
1021/// nowhere near one, so a caller has to measure the residual and decide.
1022pub(crate) fn least_squares_plane(points: &[Point], tol: Tolerances) -> Option<(Point, Direction)> {
1023    let (centroid, axes) = covariance_axes(points)?;
1024    let _ = tol;
1025    Some((centroid, axes[2]))
1026}
1027
1028/// The centroid of a point set and its covariance eigenvectors, most spread
1029/// first.
1030fn covariance_axes(points: &[Point]) -> Option<(Point, [Direction; 3])> {
1031    if points.len() < 3 {
1032        return None;
1033    }
1034    #[allow(clippy::cast_precision_loss)]
1035    let n = points.len() as f64;
1036    let mut sum = Vector::ZERO;
1037    for p in points {
1038        sum += p.to_vector();
1039    }
1040    let centroid = Point::ORIGIN + sum * (1.0 / n);
1041
1042    let mut c = nalgebra::Matrix3::<f64>::zeros();
1043    for p in points {
1044        let d = *p - centroid;
1045        let v = nalgebra::Vector3::new(d.x, d.y, d.z);
1046        c += v * v.transpose();
1047    }
1048    c /= n;
1049
1050    // Symmetric by construction, so the eigenvalues are real and the
1051    // eigenvectors orthogonal.
1052    let eigen = nalgebra::SymmetricEigen::new(c);
1053    let mut order: Vec<usize> = (0..3).collect();
1054    order.sort_by(|a, b| {
1055        eigen.eigenvalues[*b]
1056            .partial_cmp(&eigen.eigenvalues[*a])
1057            .unwrap_or(core::cmp::Ordering::Equal)
1058    });
1059
1060    let mut axes = Vec::with_capacity(3);
1061    for i in order {
1062        let column = eigen.eigenvectors.column(i);
1063        axes.push(
1064            Direction::from_coords(column[0], column[1], column[2], Tolerances::millimetres())
1065                .ok()?,
1066        );
1067    }
1068    Some((centroid, [axes[0], axes[1], axes[2]]))
1069}
1070
1071/// Where a point projects onto a curve, and how far away it is.
1072#[derive(Debug, Clone, Copy, PartialEq)]
1073pub struct Projection {
1074    /// The parameter of the nearest point found.
1075    pub parameter: f64,
1076    /// The nearest point.
1077    pub point: Point,
1078    /// The distance to it.
1079    pub distance: f64,
1080}
1081
1082/// The nearest point on a curve to `target`.
1083///
1084/// Samples the domain to bracket the minimum, then refines. The sampling is not
1085/// decoration: the distance function along a curve is generally multi-modal
1086/// (a point inside a circle is equidistant from every part of it, and a point
1087/// near a spline's inflection has two competing minima), so starting a local
1088/// method from one guess finds whichever basin it happens to land in. The
1089/// sample count sets how fine a feature can be resolved, and is stated rather
1090/// than hidden.
1091///
1092/// # Errors
1093///
1094/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if the curve cannot be
1095/// evaluated over its own domain.
1096pub fn project_on_curve(
1097    curve: &Curve,
1098    target: Point,
1099    samples: usize,
1100    tol: Tolerances,
1101) -> OgeomResult<Projection> {
1102    let (a, b) = curve.domain();
1103    let steps = samples.max(8);
1104
1105    let distance_at = |u: f64| -> f64 {
1106        curve
1107            .point_at(u, tol)
1108            .map_or(f64::INFINITY, |p| p.square_distance(target))
1109    };
1110
1111    // Coarse scan, then every local minimum of it refined, not only the
1112    // best sample's bracket: the nearest sample and the nearest point need
1113    // not share a bracket. A short stretch of a long fitted curve, seen
1114    // from its own middle, puts the three samples nearest to it (the
1115    // stretch's two ends and the curve's far end, if the curve closes) at
1116    // the same distance to within rounding, and the one that wins by a
1117    // hair may bracket nothing. The samples are cheap; the brackets that
1118    // dip are few.
1119    #[allow(clippy::cast_precision_loss)]
1120    let at = |i: usize| a + (b - a) * (i as f64 / steps as f64);
1121    let scanned: Vec<f64> = (0..=steps).map(|i| distance_at(at(i))).collect();
1122    let mut best = (a, scanned[0]);
1123    for i in 0..=steps {
1124        let dips = (i == 0 || scanned[i] <= scanned[i - 1])
1125            && (i == steps || scanned[i] <= scanned[i + 1]);
1126        if !dips {
1127            continue;
1128        }
1129        let (lo, hi) = (at(i.saturating_sub(1)), at((i + 1).min(steps)));
1130        let mut candidate = (at(i), scanned[i]);
1131        if hi > lo {
1132            let refined = solve::minimize(
1133                distance_at,
1134                lo,
1135                hi,
1136                solve::Criteria {
1137                    residual: 0.0,
1138                    step: tol.parametric(),
1139                    max_iterations: 100,
1140                },
1141            )?;
1142            // The refinement may land marginally worse than the sample if
1143            // the bracket was already at the boundary; keep whichever is
1144            // actually nearer rather than trusting the method.
1145            let d = distance_at(refined.value);
1146            if d <= candidate.1 {
1147                candidate = (refined.value, d);
1148            }
1149        }
1150        if candidate.1 < best.1 {
1151            best = candidate;
1152        }
1153    }
1154    let parameter = best.0;
1155
1156    let point = curve.point_at(parameter, tol)?;
1157    Ok(Projection {
1158        parameter,
1159        point,
1160        distance: point.distance(target),
1161    })
1162}
1163
1164/// Where a point projects onto a surface.
1165#[derive(Debug, Clone, Copy, PartialEq)]
1166pub struct SurfaceProjection {
1167    /// The parameters of the nearest point found.
1168    pub parameters: (f64, f64),
1169    /// The nearest point.
1170    pub point: Point,
1171    /// The distance to it.
1172    pub distance: f64,
1173}
1174
1175/// The nearest point on a surface to `target`.
1176///
1177/// A coarse grid to bracket, then Newton on the two conditions that define a
1178/// foot point: the displacement from the surface to the target is perpendicular
1179/// to both tangents. Grid resolution is `samples` per direction, for the same
1180/// reason as [`project_on_curve`].
1181///
1182/// # Errors
1183///
1184/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if the surface cannot be
1185/// evaluated over its own domain.
1186pub fn project_on_surface(
1187    surface: &SurfaceGeometry,
1188    target: Point,
1189    samples: usize,
1190    tol: Tolerances,
1191) -> OgeomResult<SurfaceProjection> {
1192    let (us, vs) = seed_lines(surface, samples);
1193
1194    let mut scan = Scan::default();
1195    for &u in &us {
1196        let mut row = Row::with_capacity(vs.len());
1197        for &v in &vs {
1198            let d = surface
1199                .point_at(u, v, tol)
1200                .map_or(f64::INFINITY, |p| p.square_distance(target));
1201            row.push((u, v, d));
1202        }
1203        scan.push_row(row);
1204    }
1205
1206    scan.finish().refine(surface, target, tol)
1207}
1208
1209/// One row of a seed scan: `(u, v, square distance)` per cell, a gap where
1210/// the surface would not evaluate.
1211type Row = smallvec::SmallVec<[(f64, f64, f64); 64]>;
1212
1213/// A seed scan that keeps the grid's local minima, row by row.
1214///
1215/// Three rows are enough to know whether a cell of the middle one beats
1216/// its eight neighbours, so the scan never holds the grid: a projection
1217/// onto a thread flank seeds thousands of cells, and a caller projecting
1218/// every sample of an edge would pay that grid each time.
1219#[derive(Debug, Default)]
1220struct Scan {
1221    before: Option<Row>,
1222    last: Option<Row>,
1223    rows: usize,
1224    starts: Starts,
1225}
1226
1227impl Scan {
1228    /// Take the next row; the previous row's minima are now decidable.
1229    fn push_row(&mut self, row: Row) {
1230        if let Some(last) = self.last.take() {
1231            self.starts
1232                .minima(self.rows - 1, self.before.as_ref(), &last, Some(&row));
1233            self.before = Some(last);
1234        }
1235        self.last = Some(row);
1236        self.rows += 1;
1237    }
1238
1239    /// The last row's minima, then the picks.
1240    fn finish(mut self) -> Starts {
1241        if let Some(last) = self.last.take() {
1242            self.starts
1243                .minima(self.rows - 1, self.before.as_ref(), &last, None);
1244        }
1245        self.starts
1246    }
1247}
1248
1249/// The few best basins of a grid scan.
1250///
1251/// The nearest seed is not always in the right basin. A helical flank
1252/// stacks its turns a pitch apart, and where the pitch is shorter than the
1253/// chord between neighbouring seeds, a seed on the turn above sits nearer
1254/// the target than the seed a half-span along the right turn; Newton then
1255/// converges faithfully on the wrong turn. So a scan keeps a handful of
1256/// candidates, one per basin (a cell that beats its eight neighbours),
1257/// and the refinement runs from each, nearest first, until one lands.
1258#[derive(Debug, Default, Clone, Copy)]
1259struct Starts {
1260    /// Nearest first.
1261    picks: [Option<Pick>; 4],
1262}
1263
1264/// One basin of a seed scan.
1265#[derive(Debug, Clone, Copy)]
1266struct Pick {
1267    /// The grid cell the seed came from.
1268    cell: (usize, usize),
1269    /// The seed's parameters.
1270    at: (f64, f64),
1271    /// The seed's square distance to the target.
1272    d: f64,
1273}
1274
1275impl Starts {
1276    /// Offer every cell of `row` (the grid's row `r`) that is no farther
1277    /// than any of its neighbours in the three rows around it.
1278    fn minima(&mut self, r: usize, before: Option<&Row>, row: &Row, after: Option<&Row>) {
1279        for (j, &(u, v, d)) in row.iter().enumerate() {
1280            if !d.is_finite() {
1281                continue;
1282            }
1283            let lo = j.saturating_sub(1);
1284            let hi = (j + 1).min(row.len() - 1);
1285            let beaten = |cells: &Row| cells[lo..=hi].iter().any(|c| c.2 < d);
1286            if beaten(row) || before.is_some_and(beaten) || after.is_some_and(beaten) {
1287                continue;
1288            }
1289            self.offer((r, j), (u, v), d);
1290        }
1291    }
1292
1293    /// Consider a seed; it displaces an adjacent pick it beats, or the
1294    /// worst pick when it is nearer, and otherwise fills a free slot.
1295    fn offer(&mut self, cell: (usize, usize), at: (f64, f64), d: f64) {
1296        if !d.is_finite() {
1297            return;
1298        }
1299        let pick = Pick { cell, at, d };
1300        let adjacent = |a: (usize, usize)| a.0.abs_diff(cell.0) <= 1 && a.1.abs_diff(cell.1) <= 1;
1301        if let Some(slot) = self
1302            .picks
1303            .iter()
1304            .position(|p| p.is_some_and(|p| adjacent(p.cell)))
1305        {
1306            if self.picks[slot].is_some_and(|p| d < p.d) {
1307                self.picks[slot] = Some(pick);
1308                self.settle();
1309            }
1310            return;
1311        }
1312        if let Some(slot) = self.picks.iter().position(Option::is_none) {
1313            self.picks[slot] = Some(pick);
1314            self.settle();
1315        } else if self.picks[3].is_some_and(|p| d < p.d) {
1316            self.picks[3] = Some(pick);
1317            self.settle();
1318        }
1319    }
1320
1321    /// Nearest first; a tie keeps the earlier pick.
1322    fn settle(&mut self) {
1323        self.picks.sort_by(|a, b| match (a, b) {
1324            (Some(a), Some(b)) => a.d.total_cmp(&b.d),
1325            (Some(_), None) => std::cmp::Ordering::Less,
1326            (None, Some(_)) => std::cmp::Ordering::Greater,
1327            (None, None) => std::cmp::Ordering::Equal,
1328        });
1329    }
1330
1331    /// Newton from each pick, nearest first, keeping the closest foot; a
1332    /// foot within confusion ends the search, since nothing beats it.
1333    fn refine(
1334        self,
1335        surface: &SurfaceGeometry,
1336        target: Point,
1337        tol: Tolerances,
1338    ) -> OgeomResult<SurfaceProjection> {
1339        let ((ua, _), (va, _)) = surface.domain();
1340        let mut best: Option<SurfaceProjection> = None;
1341        for pick in self.picks.iter().flatten() {
1342            let found = refine_foot(surface, target, pick.at, tol)?;
1343            let better = best.as_ref().is_none_or(|b| found.distance < b.distance);
1344            if better {
1345                let done = found.distance <= tol.confusion();
1346                best = Some(found);
1347                if done {
1348                    break;
1349                }
1350            }
1351        }
1352        match best {
1353            Some(found) => Ok(found),
1354            None => refine_foot(surface, target, (ua, va), tol),
1355        }
1356    }
1357}
1358
1359/// Where to seed a projection in each direction.
1360///
1361/// A fitted surface can carry hundreds of knot spans in one direction (a
1362/// thread flank swept two hundred turns down a lead screw has 1261), and a
1363/// grid of sixteen or ninety-six seeds lands turns away from the nearest
1364/// point, where Newton converges faithfully onto the wrong flank. So a
1365/// patch is seeded *by its spans*, never by its domain: every span gets its
1366/// share of the caller's budget, one seed at the least, and a span a
1367/// thousand times wider than its neighbour gets no more for being wide.
1368/// Everything else has a domain that means what it says, and is seeded
1369/// evenly across it.
1370fn seed_lines(surface: &SurfaceGeometry, samples: usize) -> (Vec<f64>, Vec<f64>) {
1371    const CAP: usize = 4096;
1372    let base = samples.max(4);
1373    let ((ua, ub), (va, vb)) = surface.domain();
1374    let SurfaceGeometry::BSpline(spline) = surface else {
1375        return (spread(ua, ub, base), spread(va, vb, base));
1376    };
1377    // A patch's knots are where its shape is, and a file's knots are its
1378    // own business: one imported patch runs its `u` from −80 to 1 with every
1379    // knot but the first inside the last unit, and its face occupies a
1380    // tenth of that unit. A grid spread evenly over that domain puts one
1381    // seed in the whole region the face lives in, and a projection seeded
1382    // a knot span away lands wherever Newton takes it, four millimetres
1383    // out, on an edge that sits on the surface. So the seeds follow the
1384    // spans: each one gets its share, however wide the file made it.
1385    (
1386        per_span(&breaks(spline.u_knots()), base, CAP),
1387        per_span(&breaks(spline.v_knots()), base, CAP),
1388    )
1389}
1390
1391/// `count + 1` parameters evenly across `[from, to]`.
1392fn spread(from: f64, to: f64, count: usize) -> Vec<f64> {
1393    #[allow(clippy::cast_precision_loss)]
1394    (0..=count)
1395        .map(|i| from + (to - from) * (i as f64 / count as f64))
1396        .collect()
1397}
1398
1399/// A knot vector's distinct values, without their multiplicities.
1400fn breaks(knots: &ogeom_math::KnotVector) -> Vec<f64> {
1401    knots.distinct().into_iter().map(|(at, _)| at).collect()
1402}
1403
1404/// The budget shared out over the knot spans, ends included, once each.
1405fn per_span(knots: &[f64], budget: usize, cap: usize) -> Vec<f64> {
1406    let spans = knots.len().saturating_sub(1);
1407    if spans == 0 {
1408        return knots.to_vec();
1409    }
1410    let each = (budget / spans).min(cap / spans).max(1);
1411    let mut out = Vec::with_capacity(spans * each + 1);
1412    for pair in knots.windows(2) {
1413        out.extend(spread(pair[0], pair[1], each).into_iter().take(each));
1414    }
1415    out.push(knots[knots.len() - 1]);
1416    out
1417}
1418
1419/// A surface's seeding grid, built once and asked many times.
1420///
1421/// [`project_on_surface`] evaluates the same grid of surface points for
1422/// every call: hundreds of evaluations per projection, identical each
1423/// time. A caller projecting *many* targets onto *one* surface builds the
1424/// grid once and each projection reduces to a nearest-seed scan plus the
1425/// Newton polish: the same seeds, the same refinement, the same answer to
1426/// the bit, at a fraction of the evaluations.
1427#[derive(Debug, Clone)]
1428pub struct SurfaceSeeds {
1429    /// `(parameters, point)` per cell, row by row; a gap where the surface
1430    /// would not evaluate.
1431    rows: Vec<Vec<(f64, f64, Option<Point>)>>,
1432}
1433
1434impl SurfaceSeeds {
1435    /// Evaluate the grid `project_on_surface` would use, once.
1436    ///
1437    /// # Errors
1438    ///
1439    /// Never for a well-formed surface; evaluation failures leave gaps in
1440    /// the grid exactly as the per-call version tolerates them.
1441    pub fn over(surface: &SurfaceGeometry, samples: usize, tol: Tolerances) -> OgeomResult<Self> {
1442        use ogeom_geom::Surface as _;
1443        let (us, vs) = seed_lines(surface, samples);
1444        let mut rows = Vec::with_capacity(us.len());
1445        for &u in &us {
1446            let mut row = Vec::with_capacity(vs.len());
1447            for &v in &vs {
1448                row.push((u, v, surface.point_at(u, v, tol).ok()));
1449            }
1450            rows.push(row);
1451        }
1452        Ok(Self { rows })
1453    }
1454
1455    /// Project `target`, seeded from the stored grid, bit-identical to
1456    /// [`project_on_surface`] at the same sample count.
1457    ///
1458    /// # Errors
1459    ///
1460    /// As [`project_on_surface`].
1461    pub fn project(
1462        &self,
1463        surface: &SurfaceGeometry,
1464        target: Point,
1465        tol: Tolerances,
1466    ) -> OgeomResult<SurfaceProjection> {
1467        let mut scan = Scan::default();
1468        for row in &self.rows {
1469            scan.push_row(
1470                row.iter()
1471                    .map(|&(u, v, p)| {
1472                        (u, v, p.map_or(f64::INFINITY, |p| p.square_distance(target)))
1473                    })
1474                    .collect(),
1475            );
1476        }
1477        scan.finish().refine(surface, target, tol)
1478    }
1479}
1480
1481/// The nearest point on a surface to `target`, starting from a guess.
1482///
1483/// [`project_on_surface`] brackets with a grid before refining. A caller
1484/// walking *along* something (the samples of a curve being projected into a
1485/// chart) already has a far better guess than any grid: where the previous
1486/// sample landed. Neighbouring samples of a curve are neighbouring points of
1487/// the surface, so the refinement starts inside the right basin and converges
1488/// in a few steps, and the grid's hundreds of evaluations per sample are not
1489/// spent at all.
1490///
1491/// The guess is load-bearing: this converges on the foot point nearest it, not
1492/// on the globally nearest one. A caller that cannot vouch for its guess
1493/// should check the reported distance and fall back to
1494/// [`project_on_surface`], which is what the exchange readers do.
1495///
1496/// # Errors
1497///
1498/// As [`project_on_surface`].
1499pub fn project_on_surface_from(
1500    surface: &SurfaceGeometry,
1501    target: Point,
1502    guess: (f64, f64),
1503    tol: Tolerances,
1504) -> OgeomResult<SurfaceProjection> {
1505    refine_foot(surface, target, guess, tol)
1506}
1507
1508/// Newton on the foot-point conditions from a starting parameter pair.
1509///
1510/// The iteration is box-constrained: the parameters never leave the
1511/// surface's domain. A periodic direction wraps; a bounded one clamps, and a
1512/// coordinate held against its bound by the step is pinned there while the
1513/// other one keeps solving on its own. Without that, every edge that runs
1514/// along a face's boundary (the outer helix of a thread flank sits exactly
1515/// on the flank's `u` bound) pushes the unconstrained foot a hair outside
1516/// the domain, and a solver that then rejects the whole answer hands back
1517/// its seed, turns away from the true foot.
1518///
1519/// The line search damps on the distance itself, which is the quantity a
1520/// projection minimises, so an iterate that stops improving is the answer
1521/// rather than a failure.
1522fn refine_foot(
1523    surface: &SurfaceGeometry,
1524    target: Point,
1525    start: (f64, f64),
1526    tol: Tolerances,
1527) -> OgeomResult<SurfaceProjection> {
1528    let ((ua, ub), (va, vb)) = surface.domain();
1529    let periodic = (surface.is_periodic_u(), surface.is_periodic_v());
1530    let inside = |t: f64, a: f64, b: f64, wraps: bool| -> f64 {
1531        if wraps {
1532            a + (t - a).rem_euclid(b - a)
1533        } else {
1534            t.clamp(a, b)
1535        }
1536    };
1537    let square_distance = |u: f64, v: f64| -> f64 {
1538        surface
1539            .point_at(u, v, tol)
1540            .map_or(f64::INFINITY, |p| p.square_distance(target))
1541    };
1542
1543    let mut x = (
1544        inside(start.0, ua, ub, periodic.0),
1545        inside(start.1, va, vb, periodic.1),
1546    );
1547    let mut best = (x.0, x.1, square_distance(x.0, x.1));
1548
1549    for _ in 0..60 {
1550        // One evaluation, not three. Every value here comes from the same
1551        // jet, so they are consistent with each other, which is what a
1552        // Newton step needs.
1553        let Ok(jet) = surface.jet_at(x.0, x.1, tol) else {
1554            break;
1555        };
1556        let ogeom_geom::SurfaceJet {
1557            point: p,
1558            du,
1559            dv,
1560            d2u,
1561            duv,
1562            d2v,
1563        } = jet;
1564        let gap = p - target;
1565        // The foot point conditions: (S - target) . Su = 0 and (S - target) . Sv = 0.
1566        let r = [gap.dot(du), gap.dot(dv)];
1567        let j = [
1568            [du.dot(du) + gap.dot(d2u), du.dot(dv) + gap.dot(duv)],
1569            [du.dot(dv) + gap.dot(duv), dv.dot(dv) + gap.dot(d2v)],
1570        ];
1571
1572        // A bounded coordinate sitting on its bound with the residual pushing
1573        // it further out is pinned: its condition cannot be met inside the
1574        // domain, and it drops out of the system.
1575        let pinned = |t: f64, a: f64, b: f64, wraps: bool, push: f64| -> bool {
1576            !wraps && ((t <= a && push < 0.0) || (t >= b && push > 0.0))
1577        };
1578        // The residual is the gradient of the half square distance, so the
1579        // descent pushes against it.
1580        let pin_u = pinned(x.0, ua, ub, periodic.0, -r[0]);
1581        let pin_v = pinned(x.1, va, vb, periodic.1, -r[1]);
1582
1583        let free_norm = match (pin_u, pin_v) {
1584            (true, true) => 0.0,
1585            (true, false) => r[1].abs(),
1586            (false, true) => r[0].abs(),
1587            (false, false) => r[0].hypot(r[1]),
1588        };
1589        if free_norm <= tol.confusion() {
1590            break;
1591        }
1592
1593        let delta = match (pin_u, pin_v) {
1594            (true, true) => break,
1595            (true, false) => {
1596                if j[1][1].abs() <= f64::EPSILON {
1597                    break;
1598                }
1599                [0.0, r[1] / j[1][1]]
1600            }
1601            (false, true) => {
1602                if j[0][0].abs() <= f64::EPSILON {
1603                    break;
1604                }
1605                [r[0] / j[0][0], 0.0]
1606            }
1607            (false, false) => {
1608                let Some(d) = solve_2x2(j, r) else {
1609                    break;
1610                };
1611                d
1612            }
1613        };
1614        if !delta[0].is_finite() || !delta[1].is_finite() {
1615            break;
1616        }
1617
1618        // Damping: halve until the distance actually falls. Each candidate is
1619        // put back inside the domain first, so a step aimed past a bound
1620        // becomes a step to it.
1621        let mut scale = 1.0;
1622        let mut accepted = None;
1623        for _ in 0..30 {
1624            let candidate = (
1625                inside(delta[0].mul_add(-scale, x.0), ua, ub, periodic.0),
1626                inside(delta[1].mul_add(-scale, x.1), va, vb, periodic.1),
1627            );
1628            let d = square_distance(candidate.0, candidate.1);
1629            if d < best.2 {
1630                accepted = Some((candidate, d));
1631                break;
1632            }
1633            scale *= 0.5;
1634        }
1635        let Some((next, d)) = accepted else {
1636            break;
1637        };
1638        let step = (next.0 - x.0).hypot(next.1 - x.1);
1639        x = next;
1640        best = (x.0, x.1, d);
1641        if step <= tol.parametric() {
1642            break;
1643        }
1644    }
1645
1646    let point = surface.point_at(best.0, best.1, tol)?;
1647    Ok(SurfaceProjection {
1648        parameters: (best.0, best.1),
1649        point,
1650        distance: point.distance(target),
1651    })
1652}
1653
1654/// `j * d = r` for a two-by-two system, `None` when it is singular.
1655fn solve_2x2(j: [[f64; 2]; 2], r: [f64; 2]) -> Option<[f64; 2]> {
1656    let (row0, row1, rhs0, rhs1) = if j[0][0].abs() >= j[1][0].abs() {
1657        (j[0], j[1], r[0], r[1])
1658    } else {
1659        (j[1], j[0], r[1], r[0])
1660    };
1661    if row0[0].abs() <= f64::EPSILON * (row1[0].abs() + row0[1].abs()).max(1.0) {
1662        return None;
1663    }
1664    let factor = row1[0] / row0[0];
1665    let denom = factor.mul_add(-row0[1], row1[1]);
1666    if denom.abs() <= f64::EPSILON * row0[1].abs().max(1.0) {
1667        return None;
1668    }
1669    let d1 = factor.mul_add(-rhs0, rhs1) / denom;
1670    let d0 = d1.mul_add(-row0[1], rhs0) / row0[0];
1671    Some([d0, d1])
1672}
1673
1674/// The nearest point on a planar curve to a point in the same parameter space.
1675///
1676/// # Errors
1677///
1678/// As [`project_on_curve`].
1679pub fn project_on_planar_curve(
1680    curve: &PlanarCurve,
1681    target: Point2,
1682    samples: usize,
1683    tol: Tolerances,
1684) -> OgeomResult<(f64, Point2, f64)> {
1685    use ogeom_geom::Curve2d;
1686
1687    let (a, b) = curve.domain();
1688    let steps = samples.max(8);
1689    let distance_at = |u: f64| -> f64 {
1690        curve
1691            .point_at(u, tol)
1692            .map_or(f64::INFINITY, |p| p.square_distance(target))
1693    };
1694
1695    let mut best = (a, distance_at(a));
1696    for i in 1..=steps {
1697        #[allow(clippy::cast_precision_loss)]
1698        let u = a + (b - a) * (i as f64 / steps as f64);
1699        let d = distance_at(u);
1700        if d < best.1 {
1701            best = (u, d);
1702        }
1703    }
1704
1705    #[allow(clippy::cast_precision_loss)]
1706    let width = (b - a) / steps as f64;
1707    let (lo, hi) = ((best.0 - width).max(a), (best.0 + width).min(b));
1708    let parameter = if hi > lo {
1709        let refined = solve::minimize(
1710            distance_at,
1711            lo,
1712            hi,
1713            solve::Criteria {
1714                residual: 0.0,
1715                step: tol.parametric(),
1716                max_iterations: 100,
1717            },
1718        )?;
1719        if distance_at(refined.value) <= best.1 {
1720            refined.value
1721        } else {
1722            best.0
1723        }
1724    } else {
1725        best.0
1726    };
1727
1728    let point = curve.point_at(parameter, tol)?;
1729    Ok((parameter, point, point.distance(target)))
1730}
1731
1732/// A surface's parameterization window widened until it holds every one of
1733/// `points`.
1734///
1735/// A surface's extent is a *window*, not a trim. Anything built on the surface
1736/// (a face, a pcurve, a projection) has to evaluate inside it, and a window
1737/// clamped tight around whatever was measured last will refuse the boundary of
1738/// the very region it was measured from. That failure is unhelpfully quiet: it
1739/// arrives as a domain error from an evaluation deep inside triangulation,
1740/// having overshot by a part in ten million.
1741///
1742/// Widening is therefore a step to take *before* building on a surface whose
1743/// window came from samples, and it changes nothing about the geometry: the
1744/// carrier is untouched and only the window moves. The margin is proportional
1745/// to the span measured, plus a floor in confusion tolerances, so widening is
1746/// not itself a tolerance question.
1747///
1748/// Only the *bounded* directions can be widened, and only they need to be: a
1749/// periodic direction already covers its whole turn. A surface with no bounded
1750/// direction (a sphere, a torus) comes back as it went in, and so does one
1751/// given no points.
1752///
1753/// # Errors
1754///
1755/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if a point cannot be
1756/// projected onto the surface, or the widened window is not a valid range.
1757pub fn widened_to_hold(
1758    surface: &SurfaceGeometry,
1759    points: &[Point],
1760    tol: Tolerances,
1761) -> OgeomResult<SurfaceGeometry> {
1762    use ogeom_geom::SurfaceGeometry as S;
1763    use ogeom_geom::{ConeSurface, CylinderSurface, PlaneSurface};
1764    if points.is_empty() {
1765        return Ok(surface.clone());
1766    }
1767    let (mut u0, mut u1) = (f64::INFINITY, f64::NEG_INFINITY);
1768    let (mut v0, mut v1) = (f64::INFINITY, f64::NEG_INFINITY);
1769    for p in points {
1770        let projected = project_on_surface(surface, *p, 16, tol)?;
1771        let (u, v) = projected.parameters;
1772        u0 = u0.min(u);
1773        u1 = u1.max(u);
1774        v0 = v0.min(v);
1775        v1 = v1.max(v);
1776    }
1777    if !u0.is_finite() || !v0.is_finite() {
1778        return Ok(surface.clone());
1779    }
1780    // A margin proportional to what was measured, so widening is not itself
1781    // a tolerance question.
1782    let margin = |lo: f64, hi: f64| (hi - lo).mul_add(0.05, tol.confusion() * 1e3);
1783    let ((du0, du1), (dv0, dv1)) = surface.domain();
1784    Ok(match surface {
1785        S::Plane(p) => {
1786            let (mu, mv) = (margin(u0, u1), margin(v0, v1));
1787            PlaneSurface::over(
1788                p.plane(),
1789                (du0.min(u0 - mu), du1.max(u1 + mu)),
1790                (dv0.min(v0 - mv), dv1.max(v1 + mv)),
1791            )?
1792            .into()
1793        }
1794        S::Cylinder(c) => {
1795            let m = margin(v0, v1);
1796            CylinderSurface::new(c.cylinder(), (dv0.min(v0 - m), dv1.max(v1 + m)))?.into()
1797        }
1798        S::Cone(c) => {
1799            let m = margin(v0, v1);
1800            ConeSurface::new(c.cone(), (dv0.min(v0 - m), dv1.max(v1 + m)))?.into()
1801        }
1802        // A patch has no carrier past its net: it is *continued*, side by
1803        // side, by as far as a point stands off the side its projection
1804        // clamped to, and a margin over that.
1805        S::BSpline(patch) => {
1806            let mut need = [0.0_f64; 4]; // u low, u high, v low, v high
1807            let slack = tol.parametric().max(1e-9);
1808            for p in points {
1809                let projected = project_on_surface(surface, *p, 16, tol)?;
1810                if projected.distance <= tol.confusion() {
1811                    continue;
1812                }
1813                let (u, v) = projected.parameters;
1814                let sides = [
1815                    u <= du0 + slack,
1816                    u >= du1 - slack,
1817                    v <= dv0 + slack,
1818                    v >= dv1 - slack,
1819                ];
1820                for (side, at) in sides.into_iter().enumerate() {
1821                    if at {
1822                        need[side] = need[side].max(projected.distance);
1823                    }
1824                }
1825            }
1826            let mut longer = patch.clone();
1827            for (side, distance) in need.into_iter().enumerate() {
1828                if distance <= 0.0 {
1829                    continue;
1830                }
1831                let (along_u, at_end) = (side < 2, side % 2 == 1);
1832                let length = distance.mul_add(1.5, tol.confusion() * 1e3);
1833                longer = longer.extended(along_u, at_end, length, 2, tol)?;
1834            }
1835            S::BSpline(longer)
1836        }
1837        other => other.clone(),
1838    })
1839}
1840
1841#[cfg(test)]
1842#[allow(clippy::unwrap_used)]
1843mod tests {
1844    use super::*;
1845    use crate::make_box;
1846    use approx::assert_relative_eq;
1847    use ogeom_geom::{
1848        BSplineCurve, CircleCurve, CylinderSurface, LineCurve, PlaneSurface, SphereSurface,
1849        TorusSurface, TrimmedCurve,
1850    };
1851    use ogeom_math::{Circle, Cylinder, Direction, Frame, KnotVector, Plane, Sphere, Torus};
1852
1853    const T: Tolerances = Tolerances::millimetres();
1854
1855    #[test]
1856    fn a_guessed_foot_lands_where_the_grid_lands() {
1857        // The warm start is only sound where the guess is already in the right
1858        // basin, which is the case it exists for: walking along a curve, each
1859        // sample near the last. Held to the grid's own answer along such a
1860        // walk, over a curved surface where the two could disagree.
1861        let sphere = SurfaceGeometry::Sphere(SphereSurface::new(
1862            Sphere::new(Frame::WORLD, 5.0, T).unwrap(),
1863        ));
1864        let mut guess = (0.4, 0.1);
1865        for i in 0..40 {
1866            let t = f64::from(i) * 0.04;
1867            // A path across the chart, lifted onto the sphere and nudged off
1868            // it, so the projection has real work to do.
1869            let (u, v) = (0.4 + t, 0.1 + t * 0.5);
1870            let on = sphere.point_at(u, v, T).unwrap();
1871            let target = on + (on - Point::ORIGIN) * 0.01;
1872
1873            let gridded = project_on_surface(&sphere, target, 24, T).unwrap();
1874            let guessed = project_on_surface_from(&sphere, target, guess, T).unwrap();
1875
1876            assert!(
1877                guessed.distance <= gridded.distance + T.confusion(),
1878                "step {i}: the guessed foot is farther than the gridded one"
1879            );
1880            assert!(
1881                guessed.point.distance(gridded.point) < 1e-6,
1882                "step {i}: the two feet are different points"
1883            );
1884            guess = guessed.parameters;
1885        }
1886    }
1887
1888    #[test]
1889    fn a_guess_in_the_wrong_basin_reports_its_distance_honestly() {
1890        // The other half of the contract: a bad guess is not silently wrong,
1891        // it comes back with a distance the caller can reject on, which is
1892        // exactly what the exchange readers do before keeping it.
1893        let cylinder = SurfaceGeometry::Cylinder(
1894            CylinderSurface::new(Cylinder::new(Frame::WORLD, 2.0, T).unwrap(), (-5.0, 5.0))
1895                .unwrap(),
1896        );
1897        let target = Point::new(2.0, 0.0, 1.0);
1898        let opposite =
1899            project_on_surface_from(&cylinder, target, (core::f64::consts::PI, 1.0), T).unwrap();
1900        assert!(
1901            opposite.distance > 1.0 || opposite.point.distance(target) < 1e-6,
1902            "a guess on the far side either finds the point or says how far it is"
1903        );
1904    }
1905
1906    /// A curve sampled densely: the ground truth a bound must contain.
1907    fn dense_points(curve: &Curve, n: usize) -> Vec<Point> {
1908        let (a, b) = curve.domain();
1909        (0..=n)
1910            .map(|i| {
1911                #[allow(clippy::cast_precision_loss)]
1912                let u = a + (b - a) * (i as f64 / n as f64);
1913                curve.point_at(u, T).unwrap()
1914            })
1915            .collect()
1916    }
1917
1918    #[test]
1919    fn a_line_bounds_exactly_to_its_endpoints() {
1920        let curve: Curve = LineCurve::segment(Point::ORIGIN, Point::new(3.0, 4.0, 0.0), T)
1921            .unwrap()
1922            .into();
1923        let b = curve_bounds(&curve, T).unwrap();
1924        assert_eq!(b.low(), Some(Point::ORIGIN));
1925        assert_eq!(b.high(), Some(Point::new(3.0, 4.0, 0.0)));
1926    }
1927
1928    #[test]
1929    fn every_curves_bound_contains_the_curve() {
1930        // The one property that matters. Checked against dense sampling, which
1931        // is fine as a *test* oracle even though it is not sound as an
1932        // implementation.
1933        let spline_control = vec![
1934            Point::new(0.0, 0.0, 0.0),
1935            Point::new(1.0, 5.0, 0.0),
1936            Point::new(3.0, -4.0, 2.0),
1937            Point::new(5.0, 2.0, -1.0),
1938            Point::new(6.0, 0.0, 0.0),
1939        ];
1940        let tilted = Frame::new(
1941            Point::new(1.0, -2.0, 3.0),
1942            Direction::from_coords(1.0, 2.0, 3.0, T).unwrap(),
1943            Direction::X,
1944            T,
1945        )
1946        .unwrap();
1947
1948        let curves: Vec<Curve> = vec![
1949            LineCurve::segment(Point::ORIGIN, Point::new(3.0, 4.0, 0.0), T)
1950                .unwrap()
1951                .into(),
1952            CircleCurve::new(Circle::new(tilted, 2.0, T).unwrap()).into(),
1953            ogeom_geom::EllipseCurve::new(ogeom_math::Ellipse::new(tilted, 5.0, 3.0, T).unwrap())
1954                .into(),
1955            ogeom_geom::HyperbolaCurve::new(
1956                ogeom_math::Hyperbola::new(tilted, 3.0, 4.0, T).unwrap(),
1957                1.5,
1958            )
1959            .unwrap()
1960            .into(),
1961            ogeom_geom::ParabolaCurve::new(ogeom_math::Parabola::new(tilted, 2.0, T).unwrap(), 4.0)
1962                .unwrap()
1963                .into(),
1964            BSplineCurve::new(
1965                KnotVector::clamped_uniform(3, spline_control.len()).unwrap(),
1966                spline_control,
1967                T,
1968            )
1969            .unwrap()
1970            .into(),
1971        ];
1972
1973        for curve in curves {
1974            let bound = curve_bounds(&curve, T).unwrap().with_tolerance(T);
1975            for p in dense_points(&curve, 400) {
1976                assert!(
1977                    bound.contains(p),
1978                    "{:?} escaped its bound at {p:?}: {bound}",
1979                    curve.kind()
1980                );
1981            }
1982        }
1983    }
1984
1985    #[test]
1986    fn a_splines_bound_is_its_control_hull_and_that_is_a_guarantee() {
1987        // Sampling would miss the bulge between samples; the convex hull
1988        // property does not, because the curve provably never leaves the hull.
1989        let control = vec![
1990            Point::new(0.0, 0.0, 0.0),
1991            Point::new(1.0, 10.0, 0.0),
1992            Point::new(2.0, 10.0, 0.0),
1993            Point::new(3.0, 0.0, 0.0),
1994        ];
1995        let curve: Curve = BSplineCurve::new(
1996            KnotVector::clamped_uniform(3, control.len()).unwrap(),
1997            control.clone(),
1998            T,
1999        )
2000        .unwrap()
2001        .into();
2002
2003        let bound = curve_bounds(&curve, T).unwrap();
2004        assert_eq!(bound, Aabb::of_points(&control));
2005        for p in dense_points(&curve, 200) {
2006            assert!(bound.contains(p));
2007        }
2008        // And the curve really does stay well inside; the bound is loose, in
2009        // the safe direction.
2010        let peak = dense_points(&curve, 200)
2011            .iter()
2012            .fold(0.0_f64, |m, p| m.max(p.y));
2013        assert!(peak < 10.0, "the curve should not reach its control points");
2014    }
2015
2016    #[test]
2017    fn a_trimmed_curve_reports_the_bound_of_the_whole() {
2018        // Loose but never wrong. Tightening it means solving for the extremes
2019        // of the trimmed range, which is the same work as an intersection.
2020        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
2021        let quarter: Curve = TrimmedCurve::new(circle.clone(), 0.0, 1.5, T)
2022            .unwrap()
2023            .into();
2024
2025        let whole = curve_bounds(&circle, T).unwrap();
2026        let part = curve_bounds(&quarter, T).unwrap();
2027        assert_eq!(part, whole);
2028        for p in dense_points(&quarter, 200) {
2029            assert!(part.contains(p));
2030        }
2031    }
2032
2033    #[test]
2034    fn every_surfaces_bound_contains_the_surface() {
2035        let tilted = Frame::new(
2036            Point::new(1.0, -2.0, 3.0),
2037            Direction::from_coords(1.0, 2.0, 3.0, T).unwrap(),
2038            Direction::X,
2039            T,
2040        )
2041        .unwrap();
2042        let surfaces: Vec<SurfaceGeometry> = vec![
2043            PlaneSurface::over(Plane::new(tilted), (-5.0, 5.0), (-3.0, 3.0))
2044                .unwrap()
2045                .into(),
2046            CylinderSurface::new(Cylinder::new(tilted, 2.0, T).unwrap(), (-4.0, 4.0))
2047                .unwrap()
2048                .into(),
2049            ogeom_geom::ConeSurface::new(
2050                ogeom_math::Cone::new(tilted, 3.0, 0.6, T).unwrap(),
2051                (-1.0, 5.0),
2052            )
2053            .unwrap()
2054            .into(),
2055            SphereSurface::new(Sphere::new(tilted, 4.0, T).unwrap()).into(),
2056            TorusSurface::new(Torus::new(tilted, 5.0, 2.0, T).unwrap()).into(),
2057        ];
2058
2059        for surface in surfaces {
2060            let bound = surface_bounds(&surface, T).unwrap().with_tolerance(T);
2061            let ((ua, ub), (va, vb)) = surface.domain();
2062            for i in 0..=40 {
2063                for j in 0..=40 {
2064                    let u = ua + (ub - ua) * (f64::from(i) / 40.0);
2065                    let v = va + (vb - va) * (f64::from(j) / 40.0);
2066                    let p = surface.point_at(u, v, T).unwrap();
2067                    assert!(
2068                        bound.contains(p),
2069                        "{:?} escaped its bound at ({u}, {v}) -> {p:?}: {bound}",
2070                        surface.kind()
2071                    );
2072                }
2073            }
2074        }
2075    }
2076
2077    #[test]
2078    fn a_spheres_bound_is_exact() {
2079        let s: SurfaceGeometry =
2080            SphereSurface::new(Sphere::centred(Point::new(1.0, 2.0, 3.0), 4.0, T).unwrap()).into();
2081        let b = surface_bounds(&s, T).unwrap();
2082        assert_eq!(b.low(), Some(Point::new(-3.0, -2.0, -1.0)));
2083        assert_eq!(b.high(), Some(Point::new(5.0, 6.0, 7.0)));
2084    }
2085
2086    #[test]
2087    fn an_unbounded_plane_is_refused_rather_than_bounded_wrongly() {
2088        // Reporting the enormous default extent as a bound would make every
2089        // rejection test involving it useless, and silently.
2090        let s: SurfaceGeometry = PlaneSurface::new(Plane::new(Frame::WORLD)).into();
2091        assert!(surface_bounds(&s, T).is_err());
2092    }
2093
2094    #[test]
2095    fn a_shapes_bound_contains_every_vertex_it_holds() {
2096        let mut model = Model::new();
2097        let built = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T).unwrap();
2098        let bound = shape_bounds(&model, &built.shape, T).unwrap();
2099
2100        for vertex in explore_unique(&model, &built.shape, ShapeType::Vertex).unwrap() {
2101            let p = model
2102                .node(&vertex)
2103                .unwrap()
2104                .data()
2105                .as_vertex()
2106                .unwrap()
2107                .point;
2108            assert!(bound.contains(p), "vertex {p:?} escaped {bound}");
2109        }
2110    }
2111
2112    #[test]
2113    fn the_vertex_bound_of_a_box_is_tight_and_the_full_bound_contains_it() {
2114        // A box's faces sit on planes trimmed to the box, so the two agree
2115        // closely here, but the vertex bound is documented as an estimate, and
2116        // the full bound is the one that guarantees containment.
2117        let mut model = Model::new();
2118        let built = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T).unwrap();
2119
2120        let tight = vertex_bounds(&model, &built.shape, T).unwrap();
2121        assert_relative_eq!(tight.size().x, 2.0, epsilon = 1e-6);
2122        assert_relative_eq!(tight.size().y, 3.0, epsilon = 1e-6);
2123        assert_relative_eq!(tight.size().z, 4.0, epsilon = 1e-6);
2124
2125        let full = shape_bounds(&model, &built.shape, T).unwrap();
2126        assert!(full.contains_box(&tight));
2127    }
2128
2129    #[test]
2130    fn a_placed_shapes_bound_moves_with_it() {
2131        let mut model = Model::new();
2132        let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
2133        let here = vertex_bounds(&model, &built.shape, T).unwrap();
2134
2135        let moved = model.placed(
2136            &built.shape,
2137            ogeom_math::Transform::translation(Vector::new(10.0, 0.0, 0.0)),
2138        );
2139        let there = vertex_bounds(&model, &moved, T).unwrap();
2140
2141        assert_relative_eq!(
2142            there.centre().unwrap().x - here.centre().unwrap().x,
2143            10.0,
2144            epsilon = 1e-9
2145        );
2146        assert_relative_eq!(there.size().x, here.size().x, epsilon = 1e-9);
2147    }
2148
2149    #[test]
2150    fn projecting_onto_a_line_lands_on_the_foot_of_the_perpendicular() {
2151        let curve: Curve = LineCurve::segment(Point::ORIGIN, Point::new(10.0, 0.0, 0.0), T)
2152            .unwrap()
2153            .into();
2154        let p = project_on_curve(&curve, Point::new(3.0, 4.0, 0.0), 32, T).unwrap();
2155        assert_relative_eq!(p.parameter, 3.0, epsilon = 1e-6);
2156        assert!(p.point.is_equal(Point::new(3.0, 0.0, 0.0), T));
2157        assert_relative_eq!(p.distance, 4.0, epsilon = 1e-9);
2158    }
2159
2160    /// A patch has no carrier past its net; widened to hold a point past
2161    /// one side, it is continued that way (and only that way) until the
2162    /// point projects onto it.
2163    #[test]
2164    fn a_patch_widened_to_hold_a_point_is_continued_to_it() {
2165        let cylinder = Cylinder::new(Frame::WORLD, 5.0, T).unwrap();
2166        let wall: SurfaceGeometry =
2167            SurfaceGeometry::Cylinder(CylinderSurface::new(cylinder, (0.0, 10.0)).unwrap())
2168                .to_bspline(T)
2169                .unwrap()
2170                .into();
2171        let past = Point::new(0.0, 5.0, 12.0);
2172        let short = project_on_surface(&wall, past, 16, T).unwrap();
2173        assert!(short.distance > 1.0, "the point stands off the patch's top");
2174        let longer = widened_to_hold(&wall, &[past], T).unwrap();
2175        let ((ua, ub), (va, vb)) = longer.domain();
2176        let ((wa, wb), (wva, wvb)) = wall.domain();
2177        assert!((ua - wa).abs() < 1e-12 && (ub - wb).abs() < 1e-12 && (va - wva).abs() < 1e-12);
2178        assert!(vb > wvb, "the top grew: {vb} over {wvb}");
2179        let held = project_on_surface(&longer, past, 16, T).unwrap();
2180        assert!(
2181            held.distance < 1e-6,
2182            "and holds the point: {}",
2183            held.distance
2184        );
2185    }
2186
2187    #[test]
2188    fn a_window_widened_to_hold_a_point_holds_it_with_room_to_spare() {
2189        // The failure this exists to stop: a window clamped exactly to what was
2190        // measured refuses the boundary of the region it was measured from,
2191        // by a part in ten million, as a domain error from deep inside
2192        // whatever was being built on it.
2193        let cylinder = Cylinder::new(Frame::WORLD, 5.0, T).unwrap();
2194        let tight: SurfaceGeometry = CylinderSurface::new(cylinder, (0.0, 10.0)).unwrap().into();
2195        let just_past = Point::new(5.0, 0.0, 10.0 + 1e-6);
2196        assert!(
2197            tight.domain().1.1 < just_past.z,
2198            "the point is outside the tight window, which is the premise"
2199        );
2200
2201        let wide = widened_to_hold(&tight, &[just_past], T).unwrap();
2202        let (_, (v0, v1)) = wide.domain();
2203        assert!(
2204            v1 > just_past.z && v0 <= 0.0,
2205            "the window holds the point and gives nothing back: ({v0}, {v1})"
2206        );
2207        // The window grows by the floor (a thousand confusions), and this is
2208        // the case that says why there is a floor at all. Projection clamps to
2209        // the window it is measuring, so a point that overshoots by 1e-6 comes
2210        // back at the old edge and the proportional term sees a span of zero.
2211        // Only the floor stands between the overshoot and another domain
2212        // error, which is why it is a thousand confusions and not one.
2213        assert_relative_eq!(v1 - 10.0, T.confusion() * 1e3, epsilon = 1e-12);
2214        assert!(
2215            v1 - just_past.z > 0.0,
2216            "and it clears the point: {}",
2217            v1 - just_past.z
2218        );
2219
2220        // The carrier is untouched. Widening a window is not a change of shape.
2221        let SurfaceGeometry::Cylinder(c) = &wide else {
2222            panic!("still a cylinder");
2223        };
2224        assert_relative_eq!(c.cylinder().radius(), 5.0, epsilon = 1e-15);
2225    }
2226
2227    #[test]
2228    fn a_surface_with_no_bounded_direction_comes_back_unchanged() {
2229        // A sphere is periodic one way and bounded by its own poles the other:
2230        // there is no window to widen, and inventing one would put chart space
2231        // past the pole where the parameterization means nothing.
2232        let sphere: SurfaceGeometry =
2233            SphereSurface::new(Sphere::new(Frame::WORLD, 4.0, T).unwrap()).into();
2234        let widened = widened_to_hold(&sphere, &[Point::new(4.0, 0.0, 0.0)], T).unwrap();
2235        assert_eq!(sphere.domain(), widened.domain());
2236
2237        // And no points is no information, so nothing moves either.
2238        let cylinder: SurfaceGeometry =
2239            CylinderSurface::new(Cylinder::new(Frame::WORLD, 2.0, T).unwrap(), (1.0, 3.0))
2240                .unwrap()
2241                .into();
2242        assert_eq!(
2243            cylinder.domain(),
2244            widened_to_hold(&cylinder, &[], T).unwrap().domain()
2245        );
2246    }
2247
2248    #[test]
2249    fn projecting_past_the_end_of_a_segment_clamps_to_the_end() {
2250        let curve: Curve = LineCurve::segment(Point::ORIGIN, Point::new(10.0, 0.0, 0.0), T)
2251            .unwrap()
2252            .into();
2253        let p = project_on_curve(&curve, Point::new(50.0, 0.0, 0.0), 32, T).unwrap();
2254        assert_relative_eq!(p.parameter, 10.0, epsilon = 1e-6);
2255        assert_relative_eq!(p.distance, 40.0, epsilon = 1e-6);
2256    }
2257
2258    #[test]
2259    fn projecting_onto_a_circle_finds_the_nearest_of_many_minima() {
2260        // The reason for the coarse scan: from outside the circle's plane there
2261        // is one minimum, but a local method started at the wrong parameter
2262        // converges to the far side just as happily.
2263        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 5.0, T).unwrap()).into();
2264        for angle in [0.1_f64, 1.0, 2.5, 4.0, 6.0] {
2265            let outside = Point::new(8.0 * angle.cos(), 8.0 * angle.sin(), 0.0);
2266            let p = project_on_curve(&circle, outside, 64, T).unwrap();
2267            assert_relative_eq!(p.distance, 3.0, epsilon = 1e-6);
2268            assert_relative_eq!(p.point.to_vector().magnitude(), 5.0, epsilon = 1e-9);
2269        }
2270    }
2271
2272    #[test]
2273    fn projecting_onto_a_plane_gives_the_perpendicular_foot() {
2274        let plane: SurfaceGeometry =
2275            PlaneSurface::over(Plane::new(Frame::WORLD), (-10.0, 10.0), (-10.0, 10.0))
2276                .unwrap()
2277                .into();
2278        let p = project_on_surface(&plane, Point::new(2.0, 3.0, 7.0), 8, T).unwrap();
2279        assert!(p.point.is_equal(Point::new(2.0, 3.0, 0.0), T));
2280        assert_relative_eq!(p.distance, 7.0, epsilon = 1e-9);
2281    }
2282
2283    #[test]
2284    fn projecting_onto_a_sphere_lands_on_the_radial_line() {
2285        let sphere = Sphere::centred(Point::new(1.0, 1.0, 1.0), 3.0, T).unwrap();
2286        let surface: SurfaceGeometry = SphereSurface::new(sphere).into();
2287        for target in [
2288            Point::new(10.0, 1.0, 1.0),
2289            Point::new(1.0, 1.0, 9.0),
2290            Point::new(-4.0, -2.0, 0.0),
2291        ] {
2292            let p = project_on_surface(&surface, target, 16, T).unwrap();
2293            // The foot point is on the sphere, and on the line from the centre.
2294            assert_relative_eq!(sphere.centre().distance(p.point), 3.0, max_relative = 1e-7);
2295            assert_relative_eq!(
2296                p.distance,
2297                (sphere.centre().distance(target) - 3.0).abs(),
2298                max_relative = 1e-6
2299            );
2300        }
2301    }
2302
2303    #[test]
2304    fn projecting_onto_a_cylinder_is_radial() {
2305        let cylinder = Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
2306        let surface: SurfaceGeometry = CylinderSurface::new(cylinder, (-5.0, 5.0)).unwrap().into();
2307        let p = project_on_surface(&surface, Point::new(6.0, 0.0, 1.0), 16, T).unwrap();
2308        assert_relative_eq!(p.distance, 4.0, max_relative = 1e-6);
2309        assert_relative_eq!(p.point.z, 1.0, epsilon = 1e-6);
2310        assert_relative_eq!(p.point.x.hypot(p.point.y), 2.0, max_relative = 1e-7);
2311    }
2312
2313    #[test]
2314    fn shared_seeds_project_to_the_bit_where_the_per_call_grid_lands() {
2315        // The contract SurfaceSeeds sells: same seeds, same Newton, same
2316        // answer to the bit, while the grid is evaluated once instead of
2317        // once per target. A cylinder exercises the periodic axis and the
2318        // straight one together.
2319        let cylinder = Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
2320        let surface: SurfaceGeometry = CylinderSurface::new(cylinder, (-5.0, 5.0)).unwrap().into();
2321        let seeds = SurfaceSeeds::over(&surface, 16, T).unwrap();
2322        for target in [
2323            Point::new(6.0, 0.0, 1.0),
2324            Point::new(-1.0, 3.0, -4.5),
2325            Point::new(0.5, -0.5, 0.0),
2326            Point::new(2.0, 0.0, 5.0),
2327        ] {
2328            let gridded = project_on_surface(&surface, target, 16, T).unwrap();
2329            let seeded = seeds.project(&surface, target, T).unwrap();
2330            assert_eq!(seeded.parameters, gridded.parameters);
2331            assert_eq!(seeded.point, gridded.point);
2332            assert_eq!(seeded.distance, gridded.distance);
2333        }
2334    }
2335
2336    #[test]
2337    fn projection_of_a_point_already_on_the_geometry_returns_zero_distance() {
2338        let curve: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 3.0, T).unwrap()).into();
2339        let on = curve.point_at(1.2, T).unwrap();
2340        let p = project_on_curve(&curve, on, 64, T).unwrap();
2341        assert!(p.distance < 1e-7, "distance was {}", p.distance);
2342    }
2343
2344    #[test]
2345    fn projecting_onto_a_planar_curve_works_in_parameter_space() {
2346        let curve: PlanarCurve =
2347            ogeom_geom::Line2d::segment(Point2::ORIGIN, Point2::new(10.0, 0.0), T)
2348                .unwrap()
2349                .into();
2350        let (u, point, distance) =
2351            project_on_planar_curve(&curve, Point2::new(3.0, 4.0), 32, T).unwrap();
2352        assert_relative_eq!(u, 3.0, epsilon = 1e-6);
2353        assert!(point.is_equal(Point2::new(3.0, 0.0), T));
2354        assert_relative_eq!(distance, 4.0, epsilon = 1e-9);
2355    }
2356
2357    /// A patch is bounded by the part of it the trim can reach.
2358    ///
2359    /// A real export carries a patch whose `u` knots run (−80, 0, 0.571, 1)
2360    /// and whose `v` knots stop at 85 after four spans inside the first
2361    /// two: one enormous span beside the spans that hold the shape. Its
2362    /// face lives in the last unit of each, and the whole net bounded it
2363    /// seven metres across, which is what a viewer frames a scene to, so
2364    /// the part it belongs to drew as a speck.
2365    ///
2366    /// The control points are not near the surface they shape here: the one
2367    /// at `u = −80` is a metre away, and it shapes the span next to the
2368    /// trim as well as its own. Only cutting the patch down separates them.
2369    #[test]
2370    fn a_patch_is_bounded_by_the_piece_its_trim_can_reach() {
2371        use ogeom_geom::BSplineSurface;
2372        use ogeom_math::ControlGrid;
2373        let far = Point::new(0.0, -1000.0, 0.0);
2374        let grid = ControlGrid::new(
2375            vec![
2376                far,
2377                Point::new(0.0, -1000.0, 1.0),
2378                Point::new(0.0, 0.0, 0.0),
2379                Point::new(0.0, 0.0, 1.0),
2380                Point::new(1.0, 0.0, 0.0),
2381                Point::new(1.0, 0.0, 1.0),
2382            ],
2383            3,
2384            2,
2385        )
2386        .unwrap();
2387        let patch = BSplineSurface::new(
2388            KnotVector::new(vec![-80.0, -80.0, 0.0, 1.0, 1.0], 1).unwrap(),
2389            KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1).unwrap(),
2390            &grid,
2391            T,
2392        )
2393        .unwrap();
2394
2395        // The whole net is honest and says nothing: a metre of bound on a
2396        // patch whose last span is a millimetre across.
2397        let whole = surface_bounds(&SurfaceGeometry::BSpline(patch.clone()), T).unwrap();
2398        assert!(whole.low().unwrap().y < -999.0, "the net reaches a metre");
2399
2400        // Cut to the span the trim occupies, the far column is not in it.
2401        let over = spline_hull_over(&patch, (0.0, 1.0), (0.0, 1.0), T);
2402        let (low, high) = (over.low().unwrap(), over.high().unwrap());
2403        assert_relative_eq!(low.y, 0.0, epsilon = 1e-12);
2404        assert_relative_eq!(low.x, 0.0, epsilon = 1e-12);
2405        assert_relative_eq!(high.x, 1.0, epsilon = 1e-12);
2406        assert_relative_eq!(high.z, 1.0, epsilon = 1e-12);
2407
2408        // A rectangle that does reach into the far span keeps it: the
2409        // cut narrows the bound, it does not pretend the patch is smaller.
2410        let across = spline_hull_over(&patch, (-40.0, 1.0), (0.0, 1.0), T);
2411        assert!(across.low().unwrap().y < -400.0, "half of it is still far");
2412    }
2413
2414    /// A thread flank: a cubic strip from radius 3 to 4, swept `turns`
2415    /// times round the axis at 1.5 mm per turn, one control column per
2416    /// radian. The stack of turns sits a pitch apart, closer than the
2417    /// chord between neighbouring columns.
2418    fn helical_flank(turns: usize) -> SurfaceGeometry {
2419        use ogeom_geom::BSplineSurface;
2420        use ogeom_math::ControlGrid;
2421        let columns = turns * 7;
2422        let mut points = Vec::with_capacity(4 * columns);
2423        for i in 0..4 {
2424            let r = 3.0 + f64::from(i) / 3.0;
2425            for j in 0..columns {
2426                #[allow(clippy::cast_precision_loss)]
2427                let a = j as f64;
2428                points.push(Point::new(
2429                    r * a.cos(),
2430                    r * a.sin(),
2431                    a * 1.5 / std::f64::consts::TAU,
2432                ));
2433            }
2434        }
2435        let grid = ControlGrid::new(points, 4, columns).unwrap();
2436        let u_knots = KnotVector::clamped_uniform(3, 4).unwrap();
2437        let v_knots = KnotVector::clamped_uniform(3, columns).unwrap();
2438        SurfaceGeometry::BSpline(BSplineSurface::new(u_knots, v_knots, &grid, T).unwrap())
2439    }
2440
2441    #[test]
2442    fn a_point_on_a_long_flanks_outer_helix_projects_to_its_own_turn() {
2443        use ogeom_geom::Surface as _;
2444        let flank = helical_flank(200);
2445        let ((_, ub), (va, vb)) = flank.domain();
2446        // Along the outer bound, well inside the stack of turns, and again
2447        // at the flank's end where the foot has nowhere to run past.
2448        for v in [va + (vb - va) * 0.617, vb - 0.4] {
2449            let on = flank.point_at(ub, v, T).unwrap();
2450            let foot = project_on_surface(&flank, on, 24, T).unwrap();
2451            assert!(foot.distance < 1e-9, "at v={v}: {} off", foot.distance);
2452            assert_relative_eq!(foot.parameters.0, ub, epsilon = 1e-9);
2453            assert_relative_eq!(foot.parameters.1, v, epsilon = 1e-6);
2454            let seeded = SurfaceSeeds::over(&flank, 24, T).unwrap();
2455            let again = seeded.project(&flank, on, T).unwrap();
2456            assert_eq!(again.parameters, foot.parameters);
2457        }
2458    }
2459
2460    #[test]
2461    fn a_point_past_a_bound_lands_on_the_bound_not_on_its_seed() {
2462        use ogeom_geom::Surface as _;
2463        let flank = helical_flank(3);
2464        let ((_, ub), (va, vb)) = flank.domain();
2465        let v = va + (vb - va) * 0.37;
2466        let jet = flank.jet_at(ub, v, T).unwrap();
2467        // A hair outside the outer bound: the unconstrained foot leaves the
2468        // domain, the constrained one is the bound point itself.
2469        let target = jet.point + jet.du.normalized(T).unwrap() * 0.01;
2470        let foot = project_on_surface(&flank, target, 24, T).unwrap();
2471        assert_relative_eq!(foot.parameters.0, ub, epsilon = 1e-12);
2472        assert_relative_eq!(foot.parameters.1, v, epsilon = 1e-5);
2473        assert!(foot.distance < 0.0101, "{} off", foot.distance);
2474    }
2475}
2476
2477#[cfg(test)]
2478#[allow(clippy::unwrap_used)]
2479mod oriented_bound_tests {
2480    use super::*;
2481    use crate::{make_box, make_cylinder};
2482    use approx::assert_relative_eq;
2483    use ogeom_math::Transform;
2484
2485    const T: Tolerances = Tolerances::millimetres();
2486
2487    fn fine() -> ogeom_mesh::Deflection {
2488        ogeom_mesh::Deflection {
2489            chord: 0.01,
2490            ..ogeom_mesh::Deflection::default()
2491        }
2492    }
2493
2494    #[test]
2495    fn an_oriented_box_around_a_box_is_that_box() {
2496        let mut model = Model::new();
2497        let size = (2.0, 5.0, 1.0);
2498        let built = make_box(&mut model, Frame::WORLD, size, T).unwrap();
2499        let obb = oriented_bounds(&model, &built.shape, fine(), T).unwrap();
2500
2501        assert_relative_eq!(obb.volume(), size.0 * size.1 * size.2, epsilon = 1e-9);
2502        assert!(
2503            obb.frame.origin().distance(Point::new(1.0, 2.5, 0.5)) < 1e-9,
2504            "got {:?}",
2505            obb.frame.origin()
2506        );
2507        // The half-extents are the box's, in some order: the axes come from the
2508        // spread, which does not know or care which one we called x.
2509        let mut found = [obb.half_extent.x, obb.half_extent.y, obb.half_extent.z];
2510        found.sort_by(|a, b| a.partial_cmp(b).unwrap());
2511        let mut want = [size.0 / 2.0, size.1 / 2.0, size.2 / 2.0];
2512        want.sort_by(|a, b| a.partial_cmp(b).unwrap());
2513        for (a, b) in found.iter().zip(&want) {
2514            assert_relative_eq!(a, b, epsilon = 1e-9);
2515        }
2516    }
2517
2518    #[test]
2519    fn turning_a_box_turns_its_oriented_bound_with_it_and_not_its_volume() {
2520        // The whole point. An axis-aligned box around a rotated box grows; an
2521        // oriented one does not, and that difference is what makes it worth the
2522        // transform at every containment test.
2523        let mut model = Model::new();
2524        let built = make_box(&mut model, Frame::WORLD, (1.0, 6.0, 1.0), T).unwrap();
2525        let turned = crate::transformed(
2526            &mut model,
2527            &built.shape,
2528            Transform::rotation(
2529                ogeom_math::Axis::new(Point::ORIGIN, Direction::Z),
2530                std::f64::consts::FRAC_PI_4,
2531            ),
2532        )
2533        .unwrap()
2534        .shape;
2535
2536        let obb = oriented_bounds(&model, &turned, fine(), T).unwrap();
2537        let aabb = shape_bounds(&model, &turned, T).unwrap();
2538        assert_relative_eq!(obb.volume(), 6.0, max_relative = 1e-6);
2539        assert!(
2540            aabb.volume() > obb.volume() * 1.5,
2541            "an axis-aligned box around a diagonal bar should be much emptier: \
2542             {} against {}",
2543            aabb.volume(),
2544            obb.volume()
2545        );
2546        for corner in obb.corners() {
2547            assert!(obb.contains(corner) || obb.to_aabb().contains(corner));
2548        }
2549    }
2550
2551    #[test]
2552    fn a_cylinders_oriented_bound_follows_its_axis() {
2553        let mut model = Model::new();
2554        let (radius, height) = (0.5_f64, 8.0);
2555        let built = make_cylinder(&mut model, Frame::WORLD, radius, height, T).unwrap();
2556        let obb = oriented_bounds(&model, &built.shape, fine(), T).unwrap();
2557
2558        // The long axis is the cylinder's own, and it is the first the spread
2559        // reports.
2560        assert!(
2561            obb.frame
2562                .x()
2563                .vector()
2564                .cross(Direction::Z.vector())
2565                .magnitude()
2566                < 1e-6,
2567            "the most-spread axis should be the cylinder's, got {:?}",
2568            obb.frame.x()
2569        );
2570        assert_relative_eq!(obb.half_extent.x, height / 2.0, max_relative = 1e-6);
2571    }
2572
2573    #[test]
2574    fn a_shape_with_nothing_to_bound_says_so() {
2575        let mut model = Model::new();
2576        let vertex = model.add_point(Point::ORIGIN);
2577        // One point has a bound but no spread; it must not claim a frame it
2578        // cannot justify, and it must not fail either.
2579        assert!(oriented_bounds(&model, &vertex, fine(), T).is_ok());
2580    }
2581}
2582
2583#[cfg(test)]
2584#[allow(clippy::unwrap_used)]
2585mod deflection_tests {
2586    use super::*;
2587    use crate::make_box;
2588    use approx::assert_relative_eq;
2589
2590    const T: Tolerances = Tolerances::millimetres();
2591
2592    #[test]
2593    fn a_relative_deflection_follows_the_shape_it_is_for() {
2594        // The property that makes it worth having: the same fraction gives the
2595        // same *number of segments* whatever units the part was drawn in, and
2596        // an absolute chord does not.
2597        let mut model = Model::new();
2598        let small = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2599            .unwrap()
2600            .shape;
2601        let large = make_box(&mut model, Frame::WORLD, (1000.0, 1000.0, 1000.0), T)
2602            .unwrap()
2603            .shape;
2604
2605        let a = relative_deflection(&model, &small, 1e-3, T).unwrap();
2606        let b = relative_deflection(&model, &large, 1e-3, T).unwrap();
2607        // Not exactly a thousand: `shape_bounds` is a *guaranteed* bound, so it
2608        // includes each entity's tolerance, and that padding is a larger share
2609        // of a one-unit box than of a thousand-unit one. Which is the right
2610        // behaviour: the padding is really there.
2611        assert_relative_eq!(b.chord / a.chord, 1000.0, max_relative = 1e-3);
2612        assert_relative_eq!(a.chord, 3.0_f64.sqrt() * 1e-3, max_relative = 1e-3);
2613    }
2614
2615    #[test]
2616    fn a_shape_with_no_extent_has_no_fraction_of_itself() {
2617        // A lone vertex *does* have a bound (its own tolerance), so the guard
2618        // cannot be about size. It is about whether there is a curve or a
2619        // surface for a deflection to describe.
2620        let mut model = Model::new();
2621        let vertex = model.add_point(Point::ORIGIN);
2622        let err = relative_deflection(&model, &vertex, 1e-3, T).unwrap_err();
2623        assert!(
2624            err.to_string().contains("no edges or faces"),
2625            "unexpected message: {err}"
2626        );
2627
2628        let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2629            .unwrap()
2630            .shape;
2631        assert!(relative_deflection(&model, &solid, 0.0, T).is_err());
2632        assert!(relative_deflection(&model, &solid, -1.0, T).is_err());
2633        assert!(relative_deflection(&model, &solid, f64::NAN, T).is_err());
2634    }
2635
2636    #[test]
2637    fn a_planar_face_is_bounded_by_its_wires_not_its_carrier() {
2638        // An imported plane declares a carrier window of kilometres; the
2639        // face on it spans a hand's width, and its bound must say so.
2640        let mut model = Model::new();
2641        let block = crate::make_box(
2642            &mut model,
2643            ogeom_math::Frame::WORLD,
2644            (8.0, 6.0, 4.0),
2645            Tolerances::millimetres(),
2646        )
2647        .unwrap();
2648        let bound = shape_bounds(&model, &block.shape, Tolerances::millimetres()).unwrap();
2649        let (Some(lo), Some(hi)) = (bound.low(), bound.high()) else {
2650            panic!("the box has a bound");
2651        };
2652        assert!(
2653            lo.distance(ogeom_math::Point::new(0.0, 0.0, 0.0)) < 1e-3,
2654            "{lo:?}"
2655        );
2656        assert!(
2657            hi.distance(ogeom_math::Point::new(8.0, 6.0, 4.0)) < 1e-3,
2658            "{hi:?}"
2659        );
2660    }
2661}