1use 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
28pub fn curve_bounds(curve: &Curve, tol: Tolerances) -> OgeomResult<Aabb> {
39 Ok(match curve {
40 Curve::Line(_) => Aabb::of_corners(curve.start(tol)?, curve.end(tol)?),
42
43 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 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 Curve::Offset(o) => curve_bounds(o.basis(), tol)?.expanded(o.distance().abs()),
86
87 Curve::OnSurface(c) => surface_bounds(c.surface(), tol)?,
90
91 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 ends.with_point(mid)
101 }
102
103 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
116pub 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
168fn 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 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
201fn 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 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
250pub fn surface_bounds(surface: &SurfaceGeometry, tol: Tolerances) -> OgeomResult<Aabb> {
257 Ok(match surface {
258 SurfaceGeometry::Offset(o) => surface_bounds(o.basis(), tol)?.expanded(o.distance().abs()),
260
261 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 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 SurfaceGeometry::BSpline(s) => Aabb::of_points(
321 &s.grid()
322 .points()
323 .iter()
324 .map(|w| w.point())
325 .collect::<Vec<_>>(),
326 ),
327
328 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 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
359fn 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
375trait 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
386pub fn shape_bounds(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Aabb> {
397 let mut seen = ogeom_core::FastMap::default();
400 bounds_within(model, shape, tol, &mut seen)
401}
402
403fn 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 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 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
466fn 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 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 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 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 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 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
568fn 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 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
610fn 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
643fn 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 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 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
732fn 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 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
770pub 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#[derive(Debug, Clone, Copy, PartialEq)]
801pub struct Obb {
802 pub frame: Frame,
804 pub half_extent: Vector,
806}
807
808impl Obb {
809 #[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 #[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 #[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 #[must_use]
845 pub fn to_aabb(&self) -> Aabb {
846 Aabb::of_points(&self.corners())
847 }
848}
849
850pub 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 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 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
909pub 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 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 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
1017pub fn relative_deflection(
1035 model: &Model,
1036 shape: &Shape,
1037 fraction: f64,
1038 tol: Tolerances,
1039) -> OgeomResult<ogeom_mesh::Deflection> {
1040 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
1064fn 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 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
1086pub(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
1100fn 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 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#[derive(Debug, Clone, Copy, PartialEq)]
1145pub struct Projection {
1146 pub parameter: f64,
1148 pub point: Point,
1150 pub distance: f64,
1152}
1153
1154pub 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 #[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 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
1240pub 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
1298pub 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 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 S::BSpline(patch) => {
1372 let mut need = [0.0_f64; 4]; 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}