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