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> {
130 use ogeom_geom::Curve3d as _;
131 Ok(match curve {
132 Curve::Line(_) => {
133 Aabb::of_corners(curve.point_at(range.0, tol)?, curve.point_at(range.1, tol)?)
134 }
135 Curve::Circle(c) => {
136 let circle = c.circle();
137 arc_bounds(
138 circle.centre(),
139 circle.frame(),
140 circle.radius(),
141 circle.radius(),
142 range,
143 curve,
144 tol,
145 )?
146 }
147 Curve::Ellipse(e) => {
148 let ellipse = e.ellipse();
149 arc_bounds(
150 ellipse.centre(),
151 ellipse.frame(),
152 ellipse.major_radius(),
153 ellipse.minor_radius(),
154 range,
155 curve,
156 tol,
157 )?
158 }
159 Curve::Offset(o) => curve_bounds_over(o.basis(), range, tol)?.expanded(o.distance().abs()),
160 Curve::Trimmed(t) => curve_bounds_over(t.basis(), range, tol)?,
161 _ => curve_bounds(curve, tol)?,
162 })
163}
164
165fn arc_bounds(
170 centre: Point,
171 frame: ogeom_math::Frame,
172 rx: f64,
173 ry: f64,
174 range: (f64, f64),
175 curve: &Curve,
176 tol: Tolerances,
177) -> OgeomResult<Aabb> {
178 use core::f64::consts::{PI, TAU};
179 use ogeom_geom::Curve3d as _;
180 let (lo, hi) = if range.0 <= range.1 {
181 (range.0, range.1)
182 } else {
183 (range.1, range.0)
184 };
185 if hi - lo >= TAU {
186 return Ok(frame_bounds(centre, frame, (rx, ry, 0.0)));
187 }
188 let mut out = Aabb::of_corners(curve.point_at(lo, tol)?, curve.point_at(hi, tol)?);
189 let turns = (lo / TAU).floor();
192 for step in 0..=4 {
193 #[allow(clippy::cast_precision_loss)]
194 let at = turns.mul_add(TAU, step as f64 * PI / 2.0);
195 for angle in [at, at + TAU] {
196 if angle >= lo && angle <= hi {
197 out = out.with_point(curve.point_at(angle, tol)?);
198 }
199 }
200 }
201 Ok(out)
202}
203
204pub fn surface_bounds(surface: &SurfaceGeometry, tol: Tolerances) -> OgeomResult<Aabb> {
211 Ok(match surface {
212 SurfaceGeometry::Offset(o) => surface_bounds(o.basis(), tol)?.expanded(o.distance().abs()),
214
215 SurfaceGeometry::Plane(p) => {
218 let ((ua, ub), (va, vb)) = p.domain();
219 if ua <= -LINE_EXTENT || ub >= LINE_EXTENT {
220 ogeom_bail!(
221 Domain,
222 "an unbounded plane has no finite bound; trim it before asking"
223 );
224 }
225 let mut out = Aabb::EMPTY;
226 for (u, v) in [(ua, va), (ua, vb), (ub, va), (ub, vb)] {
227 out = out.with_point(p.point_at(u, v, tol)?);
228 }
229 out
230 }
231
232 SurfaceGeometry::Cylinder(c) => {
233 let cyl = c.cylinder();
234 let ((_, _), (va, vb)) = c.domain();
235 let frame = cyl.frame();
236 let base = frame.origin() + frame.z() * va;
237 let top = frame.origin() + frame.z() * vb;
238 let radial = frame_bounds(base, frame, (cyl.radius(), cyl.radius(), 0.0));
239 radial.union(&frame_bounds(top, frame, (cyl.radius(), cyl.radius(), 0.0)))
240 }
241
242 SurfaceGeometry::Cone(c) => {
243 let cone = c.cone();
244 let ((_, _), (va, vb)) = c.domain();
245 let frame = cone.frame();
246 let mut out = Aabb::EMPTY;
247 for height in [va, vb] {
248 let radius = cone.radius_at(height).abs();
249 let centre = frame.origin() + frame.z() * height;
250 out = out.union(&frame_bounds(centre, frame, (radius, radius, 0.0)));
251 }
252 out
253 }
254
255 SurfaceGeometry::Sphere(s) => {
258 let sphere = s.sphere();
259 let r = Vector::splat(sphere.radius());
260 Aabb::of_corners(sphere.centre() - r, sphere.centre() + r)
261 }
262
263 SurfaceGeometry::Torus(t) => {
264 let torus = t.torus();
265 let reach = torus.major_radius() + torus.minor_radius();
266 frame_bounds(
267 torus.centre(),
268 torus.frame(),
269 (reach, reach, torus.minor_radius()),
270 )
271 }
272
273 SurfaceGeometry::BSpline(s) => Aabb::of_points(
275 &s.grid()
276 .points()
277 .iter()
278 .map(|w| w.point())
279 .collect::<Vec<_>>(),
280 ),
281
282 SurfaceGeometry::Revolution(r) => {
285 let curve = curve_bounds(r.curve(), tol)?;
286 let axis = r.axis();
287 let mut reach: f64 = 0.0;
288 let mut along = Aabb::EMPTY;
289 for corner in curve.corners() {
290 reach = reach.max(axis.distance_to(corner));
291 along = along.with_point(axis.project(corner));
292 }
293 let radial = Vector::splat(reach);
294 along.expanded(0.0).union(&Aabb::of_corners(
295 along.low().unwrap_or(axis.location) - radial,
296 along.high().unwrap_or(axis.location) + radial,
297 ))
298 }
299
300 SurfaceGeometry::Extrusion(e) => {
302 let base = curve_bounds(e.curve(), tol)?;
303 let ((_, _), (va, vb)) = e.domain();
304 let start = base.transformed(&ogeom_math::Transform::translation(e.direction() * va));
305 let end = base.transformed(&ogeom_math::Transform::translation(e.direction() * vb));
306 start.union(&end)
307 }
308
309 SurfaceGeometry::Trimmed(t) => surface_bounds(t.basis(), tol)?,
310 })
311}
312
313fn frame_bounds(centre: Point, frame: ogeom_math::Frame, extent: (f64, f64, f64)) -> Aabb {
319 let (ex, ey, ez) = extent;
320 let reach = |axis: fn(&Vector) -> f64| {
321 (frame.x().vector().pipe(axis) * ex).abs()
322 + (frame.y().vector().pipe(axis) * ey).abs()
323 + (frame.z().vector().pipe(axis) * ez).abs()
324 };
325 let r = Vector::new(reach(|v| v.x), reach(|v| v.y), reach(|v| v.z));
326 Aabb::of_corners(centre - r, centre + r)
327}
328
329trait Pipe {
331 fn pipe<R>(&self, f: impl FnOnce(&Self) -> R) -> R;
332}
333
334impl Pipe for Vector {
335 fn pipe<R>(&self, f: impl FnOnce(&Self) -> R) -> R {
336 f(self)
337 }
338}
339
340pub fn shape_bounds(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Aabb> {
351 let Some(node) = model.node(shape) else {
352 ogeom_bail!(Dangling, "shape refers to a node not in this model");
353 };
354 let placement = shape.transform(model.datums())?;
355
356 let own = match node.data() {
357 NodeData::Vertex(v) => Aabb::of_point(placement.apply(v.point)).expanded(v.tolerance.get()),
358 NodeData::Edge(e) => {
359 let mut out = Aabb::EMPTY;
360 for repr in &e.representations {
361 if let ogeom_topo::EdgeRepr::Curve3d { curve, range, .. } = repr
362 && let Some(geometry) = model.geometry().curve(*curve)
363 {
364 out = out.union(&curve_bounds_over(geometry, *range, tol)?);
368 }
369 }
370 out.transformed(&placement).expanded(e.tolerance.get())
371 }
372 NodeData::Face(f) => {
373 let own = match model.geometry().surface(f.surface) {
384 Some(surface) if !model.children_of(shape)?.is_empty() => {
385 patch_bulge(model, shape, surface, f.surface, tol)?
386 }
387 Some(surface) => surface_bounds(surface, tol).unwrap_or(Aabb::EMPTY),
388 None => Aabb::EMPTY,
389 };
390 own.transformed(&placement).expanded(f.tolerance.get())
391 }
392 NodeData::Container => Aabb::EMPTY,
393 };
394
395 let mut out = own;
396 for child in model.children_of(shape)? {
397 out = out.union(&shape_bounds(model, &child, tol)?);
398 }
399 Ok(out)
400}
401
402fn patch_bulge(
417 model: &Model,
418 face: &Shape,
419 surface: &SurfaceGeometry,
420 surface_id: ogeom_topo::SurfaceId,
421 tol: Tolerances,
422) -> OgeomResult<Aabb> {
423 use ogeom_geom::Surface as _;
424 let frame = match surface {
425 SurfaceGeometry::Plane(_)
427 | SurfaceGeometry::Cylinder(_)
428 | SurfaceGeometry::Cone(_)
429 | SurfaceGeometry::Extrusion(_) => return Ok(Aabb::EMPTY),
430 SurfaceGeometry::Sphere(s) => s.sphere().frame(),
431 SurfaceGeometry::Torus(t) => t.torus().frame(),
432 SurfaceGeometry::BSpline(spline) => {
439 let Some(outline) = chart_outline(model, face, surface_id, tol)? else {
440 return Ok(surface_bounds(surface, tol).unwrap_or(Aabb::EMPTY));
441 };
442 let (mut ua, mut ub) = (f64::INFINITY, f64::NEG_INFINITY);
443 let (mut va, mut vb) = (f64::INFINITY, f64::NEG_INFINITY);
444 for ring in &outline {
445 for at in ring {
446 ua = ua.min(at.x);
447 ub = ub.max(at.x);
448 va = va.min(at.y);
449 vb = vb.max(at.y);
450 }
451 }
452 if !(ua.is_finite() && ub.is_finite() && va.is_finite() && vb.is_finite()) {
453 return Ok(surface_bounds(surface, tol).unwrap_or(Aabb::EMPTY));
454 }
455 return Ok(spline_hull_over(spline, (ua, ub), (va, vb), tol));
456 }
457 _ => return Ok(surface_bounds(surface, tol).unwrap_or(Aabb::EMPTY)),
458 };
459 let Some(outline) = chart_outline(model, face, surface_id, tol)? else {
460 return Ok(surface_bounds(surface, tol).unwrap_or(Aabb::EMPTY));
461 };
462 let (mut ua, mut ub) = (f64::INFINITY, f64::NEG_INFINITY);
464 for ring in &outline {
465 for at in ring {
466 ua = ua.min(at.x);
467 ub = ub.max(at.x);
468 }
469 }
470 let (x, y, z) = (frame.x().vector(), frame.y().vector(), frame.z().vector());
475 let mut out = Aabb::EMPTY;
476 for direction in [
477 Vector::X,
478 -Vector::X,
479 Vector::Y,
480 -Vector::Y,
481 Vector::Z,
482 -Vector::Z,
483 ] {
484 let (a, b, c) = (x.dot(direction), y.dot(direction), z.dot(direction));
485 let sideways = a.hypot(b);
486 let u = b.atan2(a);
487 let v = c.atan2(sideways);
488 let turn = core::f64::consts::TAU;
491 let turns = ((ua - u) / turn).ceil();
492 let folded = turns.mul_add(turn, u);
493 let inside = (folded <= ub && inside_outline(&outline, ogeom_math::Point2::new(folded, v)))
494 || (surface.is_periodic_v()
495 && [-turn, turn].iter().any(|shift| {
496 inside_outline(&outline, ogeom_math::Point2::new(folded, v + shift))
497 }));
498 if inside {
499 out = out.with_point(surface.point_at(u, v, tol)?);
500 }
501 }
502 Ok(out)
503}
504
505fn spline_hull_over(
521 spline: &ogeom_geom::BSplineSurface,
522 u: (f64, f64),
523 v: (f64, f64),
524 tol: Tolerances,
525) -> Aabb {
526 let grid = spline.grid();
527 let columns: Vec<Vec<ogeom_math::Weighted<Point>>> = (0..grid.v_count())
530 .map(|j| (0..grid.u_count()).filter_map(|i| grid.get(i, j)).collect())
531 .collect();
532 let columns = cut_to(spline.u_knots(), columns, u, tol);
533 let Some(width) = columns.first().map(Vec::len) else {
534 return Aabb::EMPTY;
535 };
536 let rows: Vec<Vec<ogeom_math::Weighted<Point>>> = (0..width)
537 .map(|i| columns.iter().map(|column| column[i]).collect())
538 .collect();
539 let rows = cut_to(spline.v_knots(), rows, v, tol);
540 Aabb::of_points(
541 &rows
542 .iter()
543 .flat_map(|row| row.iter().map(|w| w.point()))
544 .collect::<Vec<_>>(),
545 )
546}
547
548fn cut_to(
554 knots: &ogeom_math::KnotVector,
555 polygons: Vec<Vec<ogeom_math::Weighted<Point>>>,
556 (a, b): (f64, f64),
557 tol: Tolerances,
558) -> Vec<Vec<ogeom_math::Weighted<Point>>> {
559 let mut knots = knots.clone();
560 let mut polygons = polygons;
561 for (at, keep_right) in [(a, true), (b, false)] {
562 let mut cut = Vec::with_capacity(polygons.len());
563 let mut cut_knots = None;
564 for polygon in &polygons {
565 let Ok((left, right)) = ogeom_math::bspline::split(&knots, polygon, at, tol) else {
566 cut.clear();
567 break;
568 };
569 let (half_knots, points) = if keep_right { right } else { left };
570 cut_knots = Some(half_knots);
571 cut.push(points);
572 }
573 if let Some(half_knots) = cut_knots.filter(|_| cut.len() == polygons.len()) {
574 knots = half_knots;
575 polygons = cut;
576 }
577 }
578 polygons
579}
580
581fn chart_outline(
589 model: &Model,
590 face: &Shape,
591 surface_id: ogeom_topo::SurfaceId,
592 tol: Tolerances,
593) -> OgeomResult<Option<Vec<Vec<ogeom_math::Point2>>>> {
594 use ogeom_geom::Curve2d as _;
595 const STATIONS: usize = 16;
596 let mut outline = Vec::new();
597 for wire in model.ordered_children_of(face)? {
598 let mut ring: Vec<ogeom_math::Point2> = Vec::new();
599 for edge in model.ordered_children_of(&wire)? {
604 let Some(repr) = model
605 .node(&edge)
606 .and_then(|n| n.data().as_edge())
607 .and_then(|d| d.pcurve_for(surface_id, edge.location()))
608 else {
609 return Ok(None);
610 };
611 let backwards = edge.orientation() == ogeom_topo::Orientation::Reversed;
612 let (id, range) = match repr {
613 ogeom_topo::EdgeRepr::PCurve { curve, range, .. } => (*curve, *range),
614 ogeom_topo::EdgeRepr::Seam {
615 forward,
616 reversed,
617 range,
618 ..
619 } => {
620 let start = if backwards { range.1 } else { range.0 };
621 let reach = |id: ogeom_topo::PCurveId| -> f64 {
622 let Some(pcurve) = model.geometry().pcurve(id) else {
623 return f64::INFINITY;
624 };
625 let Ok(at) = pcurve.point_at(start, tol) else {
626 return f64::INFINITY;
627 };
628 ring.last().map_or(0.0, |previous| previous.distance(at))
629 };
630 let take = if reach(*forward) <= reach(*reversed) {
631 *forward
632 } else {
633 *reversed
634 };
635 (take, *range)
636 }
637 _ => return Ok(None),
638 };
639 let Some(pcurve) = model.geometry().pcurve(id) else {
640 return Ok(None);
641 };
642 let (from, to) = if backwards {
643 (range.1, range.0)
644 } else {
645 (range.0, range.1)
646 };
647 for step in 0..=STATIONS {
648 #[allow(clippy::cast_precision_loss)]
649 let t = from + (to - from) * (step as f64 / STATIONS as f64);
650 ring.push(pcurve.point_at(t, tol)?);
651 }
652 }
653 if ring.len() >= 3 {
654 outline.push(ring);
655 }
656 }
657 Ok((!outline.is_empty()).then_some(outline))
658}
659
660fn inside_outline(outline: &[Vec<ogeom_math::Point2>], at: ogeom_math::Point2) -> bool {
665 let mut crossings = 0_usize;
666 for ring in outline {
667 for pair in ring.windows(2) {
668 let (a, b) = (pair[0], pair[1]);
669 if (a.y > at.y) == (b.y > at.y) {
670 continue;
671 }
672 let span = b.y - a.y;
673 if span.abs() <= f64::MIN_POSITIVE {
674 continue;
675 }
676 let x = (b.x - a.x).mul_add((at.y - a.y) / span, a.x);
677 if x > at.x {
678 crossings += 1;
679 }
680 }
681 if let (Some(first), Some(last)) = (ring.first(), ring.last())
684 && (first.y > at.y) != (last.y > at.y)
685 {
686 let span = first.y - last.y;
687 if span.abs() > f64::MIN_POSITIVE {
688 let x = (first.x - last.x).mul_add((at.y - last.y) / span, last.x);
689 if x > at.x {
690 crossings += 1;
691 }
692 }
693 }
694 }
695 crossings % 2 == 1
696}
697
698pub fn vertex_bounds(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Aabb> {
708 let mut out = Aabb::EMPTY;
709 for vertex in explore_unique(model, shape, ShapeType::Vertex)? {
710 let Some(node) = model.node(&vertex) else {
711 ogeom_bail!(Dangling, "vertex is not in this model");
712 };
713 if let Some(data) = node.data().as_vertex() {
714 let placed = vertex.transform(model.datums())?.apply(data.point);
715 out = out.with_point(placed);
716 }
717 }
718 Ok(out.expanded(tol.confusion()))
719}
720
721#[derive(Debug, Clone, Copy, PartialEq)]
729pub struct Obb {
730 pub frame: Frame,
732 pub half_extent: Vector,
734}
735
736impl Obb {
737 #[must_use]
739 pub fn corners(&self) -> Vec<Point> {
740 let (x, y, z) = (
741 self.frame.x().vector() * self.half_extent.x,
742 self.frame.y().vector() * self.half_extent.y,
743 self.frame.z().vector() * self.half_extent.z,
744 );
745 let mut out = Vec::with_capacity(8);
746 for k in [-1.0_f64, 1.0] {
747 for j in [-1.0_f64, 1.0] {
748 for i in [-1.0_f64, 1.0] {
749 out.push(self.frame.origin() + x * i + y * j + z * k);
750 }
751 }
752 }
753 out
754 }
755
756 #[must_use]
758 pub fn volume(&self) -> f64 {
759 8.0 * self.half_extent.x * self.half_extent.y * self.half_extent.z
760 }
761
762 #[must_use]
764 pub fn contains(&self, p: Point) -> bool {
765 let local = self.frame.to_local(p);
766 local.x.abs() <= self.half_extent.x
767 && local.y.abs() <= self.half_extent.y
768 && local.z.abs() <= self.half_extent.z
769 }
770
771 #[must_use]
773 pub fn to_aabb(&self) -> Aabb {
774 Aabb::of_points(&self.corners())
775 }
776}
777
778pub fn oriented_bounds(
794 model: &Model,
795 shape: &Shape,
796 deflection: ogeom_mesh::Deflection,
797 tol: Tolerances,
798) -> OgeomResult<Obb> {
799 let mut points = Vec::new();
800 if let Ok(mesh) = ogeom_mesh::triangulate(model, shape, deflection, tol) {
803 points.extend(mesh.positions.iter().copied());
804 }
805 for vertex in explore_unique(model, shape, ShapeType::Vertex)? {
806 if let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) {
807 points.push(vertex.transform(model.datums())?.apply(data.point));
808 }
809 }
810 if points.is_empty() {
811 ogeom_bail!(Construction, "the shape has no geometry to bound");
812 }
813
814 let frame = spread_frame(&points, tol);
815 let mut low = Vector::new(f64::MAX, f64::MAX, f64::MAX);
816 let mut high = Vector::new(f64::MIN, f64::MIN, f64::MIN);
817 for p in &points {
818 let local = frame.to_local(*p);
819 low = Vector::new(low.x.min(local.x), low.y.min(local.y), low.z.min(local.z));
820 high = Vector::new(
821 high.x.max(local.x),
822 high.y.max(local.y),
823 high.z.max(local.z),
824 );
825 }
826 let middle = (low + high) * 0.5;
830 let centre = frame.to_world(Point::ORIGIN + middle);
831 Ok(Obb {
832 frame: frame.with_origin(centre),
833 half_extent: (high - low) * 0.5,
834 })
835}
836
837pub fn face_normal(model: &Model, face: &Shape, tol: Tolerances) -> OgeomResult<(Point, Vector)> {
847 let Some(node) = model.node(face) else {
848 ogeom_bail!(Dangling, "face is not in this model");
849 };
850 let Some(data) = node.data().as_face() else {
851 ogeom_bail!(Construction, "face node holds no face data");
852 };
853 let Some(surface) = model.geometry().surface(data.surface) else {
854 ogeom_bail!(Dangling, "face refers to a surface not in this model");
855 };
856
857 let mut sum = (0.0, 0.0);
858 let mut count = 0_u32;
859 for edge in match model.children_of(face)?.first() {
862 Some(outer) => model.children_of(outer)?,
863 None => Vec::new(),
864 } {
865 let Some(edge_data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
866 continue;
867 };
868 let (id, range) = match edge_data.pcurve_for(data.surface, edge.location()) {
869 Some(EdgeRepr::PCurve { curve, range, .. }) => (*curve, *range),
870 Some(EdgeRepr::Seam { forward, range, .. }) => (*forward, *range),
871 _ => continue,
872 };
873 let Some(pcurve) = model.geometry().pcurve(id) else {
874 ogeom_bail!(Dangling, "pcurve is not in this model");
875 };
876 for at in [range.0, f64::midpoint(range.0, range.1), range.1] {
877 let p = pcurve.point_at(at, tol)?;
878 sum = (sum.0 + p.x, sum.1 + p.y);
879 count += 1;
880 }
881 }
882
883 let ((ua, ub), (va, vb)) = surface.domain();
884 let wire_middle = if count == 0 {
888 let mut points: Vec<Point> = Vec::new();
889 if let Some(outer) = model.children_of(face)?.first() {
890 for edge in model.children_of(outer)? {
891 let Some(edge_data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
892 continue;
893 };
894 let Some(EdgeRepr::Curve3d { curve, range, .. }) = edge_data.curve3d() else {
895 continue;
896 };
897 let Some(geometry) = model.geometry().curve(*curve) else {
898 continue;
899 };
900 for k in 0..4 {
901 let t = range.0 + (range.1 - range.0) * f64::from(k) / 4.0;
902 points.push(geometry.point_at(t, tol)?);
903 }
904 }
905 }
906 if points.is_empty() {
907 None
908 } else {
909 #[allow(clippy::cast_precision_loss)]
910 let n = points.len() as f64;
911 let sum = points
912 .iter()
913 .fold(Vector::new(0.0, 0.0, 0.0), |acc, p| acc + p.to_vector());
914 let centre = Point::from_vector(sum / n);
915 project_on_surface(surface, centre, 16, tol)
916 .ok()
917 .map(|found| found.parameters)
918 }
919 } else {
920 None
921 };
922 let (u, v) = if let Some(uv) = wire_middle {
923 uv
924 } else if count == 0 {
925 (f64::midpoint(ua, ub), f64::midpoint(va, vb))
926 } else {
927 let n = f64::from(count);
928 (sum.0 / n, sum.1 / n)
929 };
930 let normal = surface.normal_at(u, v, tol)?;
931 let point = surface.point_at(u, v, tol)?;
932
933 let placement = face.transform(model.datums())?;
934 let placed = placement.apply_vector(normal.vector());
935 Ok((
936 placement.apply(point),
937 if face.orientation() == Orientation::Reversed {
938 -placed
939 } else {
940 placed
941 },
942 ))
943}
944
945pub fn relative_deflection(
963 model: &Model,
964 shape: &Shape,
965 fraction: f64,
966 tol: Tolerances,
967) -> OgeomResult<ogeom_mesh::Deflection> {
968 if explore_unique(model, shape, ShapeType::Edge)?.is_empty()
974 && explore_unique(model, shape, ShapeType::Face)?.is_empty()
975 {
976 ogeom_bail!(
977 Construction,
978 "the shape has no edges or faces, so there is nothing a deflection \
979 would describe"
980 );
981 }
982 let diagonal = shape_bounds(model, shape, tol)?.diagonal();
983 if !diagonal.is_finite() || diagonal <= tol.confusion() {
984 ogeom_bail!(
985 Construction,
986 "the shape has no extent for a deflection to be a fraction of"
987 );
988 }
989 ogeom_mesh::Deflection::relative(diagonal, fraction)
990}
991
992fn spread_frame(points: &[Point], tol: Tolerances) -> Frame {
1000 let Some((centroid, axes)) = covariance_axes(points) else {
1001 return Frame::WORLD.with_origin(points.first().copied().unwrap_or(Point::ORIGIN));
1002 };
1003 let [most, _, least] = axes;
1008 Frame::new(centroid, least, most, tol)
1009 .or_else(|_| Frame::new(centroid, least, Direction::X, tol))
1010 .or_else(|_| Frame::new(centroid, least, Direction::Y, tol))
1011 .unwrap_or_else(|_| Frame::WORLD.with_origin(centroid))
1012}
1013
1014pub(crate) fn least_squares_plane(points: &[Point], tol: Tolerances) -> Option<(Point, Direction)> {
1023 let (centroid, axes) = covariance_axes(points)?;
1024 let _ = tol;
1025 Some((centroid, axes[2]))
1026}
1027
1028fn covariance_axes(points: &[Point]) -> Option<(Point, [Direction; 3])> {
1031 if points.len() < 3 {
1032 return None;
1033 }
1034 #[allow(clippy::cast_precision_loss)]
1035 let n = points.len() as f64;
1036 let mut sum = Vector::ZERO;
1037 for p in points {
1038 sum += p.to_vector();
1039 }
1040 let centroid = Point::ORIGIN + sum * (1.0 / n);
1041
1042 let mut c = nalgebra::Matrix3::<f64>::zeros();
1043 for p in points {
1044 let d = *p - centroid;
1045 let v = nalgebra::Vector3::new(d.x, d.y, d.z);
1046 c += v * v.transpose();
1047 }
1048 c /= n;
1049
1050 let eigen = nalgebra::SymmetricEigen::new(c);
1053 let mut order: Vec<usize> = (0..3).collect();
1054 order.sort_by(|a, b| {
1055 eigen.eigenvalues[*b]
1056 .partial_cmp(&eigen.eigenvalues[*a])
1057 .unwrap_or(core::cmp::Ordering::Equal)
1058 });
1059
1060 let mut axes = Vec::with_capacity(3);
1061 for i in order {
1062 let column = eigen.eigenvectors.column(i);
1063 axes.push(
1064 Direction::from_coords(column[0], column[1], column[2], Tolerances::millimetres())
1065 .ok()?,
1066 );
1067 }
1068 Some((centroid, [axes[0], axes[1], axes[2]]))
1069}
1070
1071#[derive(Debug, Clone, Copy, PartialEq)]
1073pub struct Projection {
1074 pub parameter: f64,
1076 pub point: Point,
1078 pub distance: f64,
1080}
1081
1082pub fn project_on_curve(
1097 curve: &Curve,
1098 target: Point,
1099 samples: usize,
1100 tol: Tolerances,
1101) -> OgeomResult<Projection> {
1102 let (a, b) = curve.domain();
1103 let steps = samples.max(8);
1104
1105 let distance_at = |u: f64| -> f64 {
1106 curve
1107 .point_at(u, tol)
1108 .map_or(f64::INFINITY, |p| p.square_distance(target))
1109 };
1110
1111 #[allow(clippy::cast_precision_loss)]
1120 let at = |i: usize| a + (b - a) * (i as f64 / steps as f64);
1121 let scanned: Vec<f64> = (0..=steps).map(|i| distance_at(at(i))).collect();
1122 let mut best = (a, scanned[0]);
1123 for i in 0..=steps {
1124 let dips = (i == 0 || scanned[i] <= scanned[i - 1])
1125 && (i == steps || scanned[i] <= scanned[i + 1]);
1126 if !dips {
1127 continue;
1128 }
1129 let (lo, hi) = (at(i.saturating_sub(1)), at((i + 1).min(steps)));
1130 let mut candidate = (at(i), scanned[i]);
1131 if hi > lo {
1132 let refined = solve::minimize(
1133 distance_at,
1134 lo,
1135 hi,
1136 solve::Criteria {
1137 residual: 0.0,
1138 step: tol.parametric(),
1139 max_iterations: 100,
1140 },
1141 )?;
1142 let d = distance_at(refined.value);
1146 if d <= candidate.1 {
1147 candidate = (refined.value, d);
1148 }
1149 }
1150 if candidate.1 < best.1 {
1151 best = candidate;
1152 }
1153 }
1154 let parameter = best.0;
1155
1156 let point = curve.point_at(parameter, tol)?;
1157 Ok(Projection {
1158 parameter,
1159 point,
1160 distance: point.distance(target),
1161 })
1162}
1163
1164#[derive(Debug, Clone, Copy, PartialEq)]
1166pub struct SurfaceProjection {
1167 pub parameters: (f64, f64),
1169 pub point: Point,
1171 pub distance: f64,
1173}
1174
1175pub fn project_on_surface(
1187 surface: &SurfaceGeometry,
1188 target: Point,
1189 samples: usize,
1190 tol: Tolerances,
1191) -> OgeomResult<SurfaceProjection> {
1192 let (us, vs) = seed_lines(surface, samples);
1193
1194 let mut scan = Scan::default();
1195 for &u in &us {
1196 let mut row = Row::with_capacity(vs.len());
1197 for &v in &vs {
1198 let d = surface
1199 .point_at(u, v, tol)
1200 .map_or(f64::INFINITY, |p| p.square_distance(target));
1201 row.push((u, v, d));
1202 }
1203 scan.push_row(row);
1204 }
1205
1206 scan.finish().refine(surface, target, tol)
1207}
1208
1209type Row = smallvec::SmallVec<[(f64, f64, f64); 64]>;
1212
1213#[derive(Debug, Default)]
1220struct Scan {
1221 before: Option<Row>,
1222 last: Option<Row>,
1223 rows: usize,
1224 starts: Starts,
1225}
1226
1227impl Scan {
1228 fn push_row(&mut self, row: Row) {
1230 if let Some(last) = self.last.take() {
1231 self.starts
1232 .minima(self.rows - 1, self.before.as_ref(), &last, Some(&row));
1233 self.before = Some(last);
1234 }
1235 self.last = Some(row);
1236 self.rows += 1;
1237 }
1238
1239 fn finish(mut self) -> Starts {
1241 if let Some(last) = self.last.take() {
1242 self.starts
1243 .minima(self.rows - 1, self.before.as_ref(), &last, None);
1244 }
1245 self.starts
1246 }
1247}
1248
1249#[derive(Debug, Default, Clone, Copy)]
1259struct Starts {
1260 picks: [Option<Pick>; 4],
1262}
1263
1264#[derive(Debug, Clone, Copy)]
1266struct Pick {
1267 cell: (usize, usize),
1269 at: (f64, f64),
1271 d: f64,
1273}
1274
1275impl Starts {
1276 fn minima(&mut self, r: usize, before: Option<&Row>, row: &Row, after: Option<&Row>) {
1279 for (j, &(u, v, d)) in row.iter().enumerate() {
1280 if !d.is_finite() {
1281 continue;
1282 }
1283 let lo = j.saturating_sub(1);
1284 let hi = (j + 1).min(row.len() - 1);
1285 let beaten = |cells: &Row| cells[lo..=hi].iter().any(|c| c.2 < d);
1286 if beaten(row) || before.is_some_and(beaten) || after.is_some_and(beaten) {
1287 continue;
1288 }
1289 self.offer((r, j), (u, v), d);
1290 }
1291 }
1292
1293 fn offer(&mut self, cell: (usize, usize), at: (f64, f64), d: f64) {
1296 if !d.is_finite() {
1297 return;
1298 }
1299 let pick = Pick { cell, at, d };
1300 let adjacent = |a: (usize, usize)| a.0.abs_diff(cell.0) <= 1 && a.1.abs_diff(cell.1) <= 1;
1301 if let Some(slot) = self
1302 .picks
1303 .iter()
1304 .position(|p| p.is_some_and(|p| adjacent(p.cell)))
1305 {
1306 if self.picks[slot].is_some_and(|p| d < p.d) {
1307 self.picks[slot] = Some(pick);
1308 self.settle();
1309 }
1310 return;
1311 }
1312 if let Some(slot) = self.picks.iter().position(Option::is_none) {
1313 self.picks[slot] = Some(pick);
1314 self.settle();
1315 } else if self.picks[3].is_some_and(|p| d < p.d) {
1316 self.picks[3] = Some(pick);
1317 self.settle();
1318 }
1319 }
1320
1321 fn settle(&mut self) {
1323 self.picks.sort_by(|a, b| match (a, b) {
1324 (Some(a), Some(b)) => a.d.total_cmp(&b.d),
1325 (Some(_), None) => std::cmp::Ordering::Less,
1326 (None, Some(_)) => std::cmp::Ordering::Greater,
1327 (None, None) => std::cmp::Ordering::Equal,
1328 });
1329 }
1330
1331 fn refine(
1334 self,
1335 surface: &SurfaceGeometry,
1336 target: Point,
1337 tol: Tolerances,
1338 ) -> OgeomResult<SurfaceProjection> {
1339 let ((ua, _), (va, _)) = surface.domain();
1340 let mut best: Option<SurfaceProjection> = None;
1341 for pick in self.picks.iter().flatten() {
1342 let found = refine_foot(surface, target, pick.at, tol)?;
1343 let better = best.as_ref().is_none_or(|b| found.distance < b.distance);
1344 if better {
1345 let done = found.distance <= tol.confusion();
1346 best = Some(found);
1347 if done {
1348 break;
1349 }
1350 }
1351 }
1352 match best {
1353 Some(found) => Ok(found),
1354 None => refine_foot(surface, target, (ua, va), tol),
1355 }
1356 }
1357}
1358
1359fn seed_lines(surface: &SurfaceGeometry, samples: usize) -> (Vec<f64>, Vec<f64>) {
1371 const CAP: usize = 4096;
1372 let base = samples.max(4);
1373 let ((ua, ub), (va, vb)) = surface.domain();
1374 let SurfaceGeometry::BSpline(spline) = surface else {
1375 return (spread(ua, ub, base), spread(va, vb, base));
1376 };
1377 (
1386 per_span(&breaks(spline.u_knots()), base, CAP),
1387 per_span(&breaks(spline.v_knots()), base, CAP),
1388 )
1389}
1390
1391fn spread(from: f64, to: f64, count: usize) -> Vec<f64> {
1393 #[allow(clippy::cast_precision_loss)]
1394 (0..=count)
1395 .map(|i| from + (to - from) * (i as f64 / count as f64))
1396 .collect()
1397}
1398
1399fn breaks(knots: &ogeom_math::KnotVector) -> Vec<f64> {
1401 knots.distinct().into_iter().map(|(at, _)| at).collect()
1402}
1403
1404fn per_span(knots: &[f64], budget: usize, cap: usize) -> Vec<f64> {
1406 let spans = knots.len().saturating_sub(1);
1407 if spans == 0 {
1408 return knots.to_vec();
1409 }
1410 let each = (budget / spans).min(cap / spans).max(1);
1411 let mut out = Vec::with_capacity(spans * each + 1);
1412 for pair in knots.windows(2) {
1413 out.extend(spread(pair[0], pair[1], each).into_iter().take(each));
1414 }
1415 out.push(knots[knots.len() - 1]);
1416 out
1417}
1418
1419#[derive(Debug, Clone)]
1428pub struct SurfaceSeeds {
1429 rows: Vec<Vec<(f64, f64, Option<Point>)>>,
1432}
1433
1434impl SurfaceSeeds {
1435 pub fn over(surface: &SurfaceGeometry, samples: usize, tol: Tolerances) -> OgeomResult<Self> {
1442 use ogeom_geom::Surface as _;
1443 let (us, vs) = seed_lines(surface, samples);
1444 let mut rows = Vec::with_capacity(us.len());
1445 for &u in &us {
1446 let mut row = Vec::with_capacity(vs.len());
1447 for &v in &vs {
1448 row.push((u, v, surface.point_at(u, v, tol).ok()));
1449 }
1450 rows.push(row);
1451 }
1452 Ok(Self { rows })
1453 }
1454
1455 pub fn project(
1462 &self,
1463 surface: &SurfaceGeometry,
1464 target: Point,
1465 tol: Tolerances,
1466 ) -> OgeomResult<SurfaceProjection> {
1467 let mut scan = Scan::default();
1468 for row in &self.rows {
1469 scan.push_row(
1470 row.iter()
1471 .map(|&(u, v, p)| {
1472 (u, v, p.map_or(f64::INFINITY, |p| p.square_distance(target)))
1473 })
1474 .collect(),
1475 );
1476 }
1477 scan.finish().refine(surface, target, tol)
1478 }
1479}
1480
1481pub fn project_on_surface_from(
1500 surface: &SurfaceGeometry,
1501 target: Point,
1502 guess: (f64, f64),
1503 tol: Tolerances,
1504) -> OgeomResult<SurfaceProjection> {
1505 refine_foot(surface, target, guess, tol)
1506}
1507
1508fn refine_foot(
1523 surface: &SurfaceGeometry,
1524 target: Point,
1525 start: (f64, f64),
1526 tol: Tolerances,
1527) -> OgeomResult<SurfaceProjection> {
1528 let ((ua, ub), (va, vb)) = surface.domain();
1529 let periodic = (surface.is_periodic_u(), surface.is_periodic_v());
1530 let inside = |t: f64, a: f64, b: f64, wraps: bool| -> f64 {
1531 if wraps {
1532 a + (t - a).rem_euclid(b - a)
1533 } else {
1534 t.clamp(a, b)
1535 }
1536 };
1537 let square_distance = |u: f64, v: f64| -> f64 {
1538 surface
1539 .point_at(u, v, tol)
1540 .map_or(f64::INFINITY, |p| p.square_distance(target))
1541 };
1542
1543 let mut x = (
1544 inside(start.0, ua, ub, periodic.0),
1545 inside(start.1, va, vb, periodic.1),
1546 );
1547 let mut best = (x.0, x.1, square_distance(x.0, x.1));
1548
1549 for _ in 0..60 {
1550 let Ok(jet) = surface.jet_at(x.0, x.1, tol) else {
1554 break;
1555 };
1556 let ogeom_geom::SurfaceJet {
1557 point: p,
1558 du,
1559 dv,
1560 d2u,
1561 duv,
1562 d2v,
1563 } = jet;
1564 let gap = p - target;
1565 let r = [gap.dot(du), gap.dot(dv)];
1567 let j = [
1568 [du.dot(du) + gap.dot(d2u), du.dot(dv) + gap.dot(duv)],
1569 [du.dot(dv) + gap.dot(duv), dv.dot(dv) + gap.dot(d2v)],
1570 ];
1571
1572 let pinned = |t: f64, a: f64, b: f64, wraps: bool, push: f64| -> bool {
1576 !wraps && ((t <= a && push < 0.0) || (t >= b && push > 0.0))
1577 };
1578 let pin_u = pinned(x.0, ua, ub, periodic.0, -r[0]);
1581 let pin_v = pinned(x.1, va, vb, periodic.1, -r[1]);
1582
1583 let free_norm = match (pin_u, pin_v) {
1584 (true, true) => 0.0,
1585 (true, false) => r[1].abs(),
1586 (false, true) => r[0].abs(),
1587 (false, false) => r[0].hypot(r[1]),
1588 };
1589 if free_norm <= tol.confusion() {
1590 break;
1591 }
1592
1593 let delta = match (pin_u, pin_v) {
1594 (true, true) => break,
1595 (true, false) => {
1596 if j[1][1].abs() <= f64::EPSILON {
1597 break;
1598 }
1599 [0.0, r[1] / j[1][1]]
1600 }
1601 (false, true) => {
1602 if j[0][0].abs() <= f64::EPSILON {
1603 break;
1604 }
1605 [r[0] / j[0][0], 0.0]
1606 }
1607 (false, false) => {
1608 let Some(d) = solve_2x2(j, r) else {
1609 break;
1610 };
1611 d
1612 }
1613 };
1614 if !delta[0].is_finite() || !delta[1].is_finite() {
1615 break;
1616 }
1617
1618 let mut scale = 1.0;
1622 let mut accepted = None;
1623 for _ in 0..30 {
1624 let candidate = (
1625 inside(delta[0].mul_add(-scale, x.0), ua, ub, periodic.0),
1626 inside(delta[1].mul_add(-scale, x.1), va, vb, periodic.1),
1627 );
1628 let d = square_distance(candidate.0, candidate.1);
1629 if d < best.2 {
1630 accepted = Some((candidate, d));
1631 break;
1632 }
1633 scale *= 0.5;
1634 }
1635 let Some((next, d)) = accepted else {
1636 break;
1637 };
1638 let step = (next.0 - x.0).hypot(next.1 - x.1);
1639 x = next;
1640 best = (x.0, x.1, d);
1641 if step <= tol.parametric() {
1642 break;
1643 }
1644 }
1645
1646 let point = surface.point_at(best.0, best.1, tol)?;
1647 Ok(SurfaceProjection {
1648 parameters: (best.0, best.1),
1649 point,
1650 distance: point.distance(target),
1651 })
1652}
1653
1654fn solve_2x2(j: [[f64; 2]; 2], r: [f64; 2]) -> Option<[f64; 2]> {
1656 let (row0, row1, rhs0, rhs1) = if j[0][0].abs() >= j[1][0].abs() {
1657 (j[0], j[1], r[0], r[1])
1658 } else {
1659 (j[1], j[0], r[1], r[0])
1660 };
1661 if row0[0].abs() <= f64::EPSILON * (row1[0].abs() + row0[1].abs()).max(1.0) {
1662 return None;
1663 }
1664 let factor = row1[0] / row0[0];
1665 let denom = factor.mul_add(-row0[1], row1[1]);
1666 if denom.abs() <= f64::EPSILON * row0[1].abs().max(1.0) {
1667 return None;
1668 }
1669 let d1 = factor.mul_add(-rhs0, rhs1) / denom;
1670 let d0 = d1.mul_add(-row0[1], rhs0) / row0[0];
1671 Some([d0, d1])
1672}
1673
1674pub fn project_on_planar_curve(
1680 curve: &PlanarCurve,
1681 target: Point2,
1682 samples: usize,
1683 tol: Tolerances,
1684) -> OgeomResult<(f64, Point2, f64)> {
1685 use ogeom_geom::Curve2d;
1686
1687 let (a, b) = curve.domain();
1688 let steps = samples.max(8);
1689 let distance_at = |u: f64| -> f64 {
1690 curve
1691 .point_at(u, tol)
1692 .map_or(f64::INFINITY, |p| p.square_distance(target))
1693 };
1694
1695 let mut best = (a, distance_at(a));
1696 for i in 1..=steps {
1697 #[allow(clippy::cast_precision_loss)]
1698 let u = a + (b - a) * (i as f64 / steps as f64);
1699 let d = distance_at(u);
1700 if d < best.1 {
1701 best = (u, d);
1702 }
1703 }
1704
1705 #[allow(clippy::cast_precision_loss)]
1706 let width = (b - a) / steps as f64;
1707 let (lo, hi) = ((best.0 - width).max(a), (best.0 + width).min(b));
1708 let parameter = if hi > lo {
1709 let refined = solve::minimize(
1710 distance_at,
1711 lo,
1712 hi,
1713 solve::Criteria {
1714 residual: 0.0,
1715 step: tol.parametric(),
1716 max_iterations: 100,
1717 },
1718 )?;
1719 if distance_at(refined.value) <= best.1 {
1720 refined.value
1721 } else {
1722 best.0
1723 }
1724 } else {
1725 best.0
1726 };
1727
1728 let point = curve.point_at(parameter, tol)?;
1729 Ok((parameter, point, point.distance(target)))
1730}
1731
1732pub fn widened_to_hold(
1758 surface: &SurfaceGeometry,
1759 points: &[Point],
1760 tol: Tolerances,
1761) -> OgeomResult<SurfaceGeometry> {
1762 use ogeom_geom::SurfaceGeometry as S;
1763 use ogeom_geom::{ConeSurface, CylinderSurface, PlaneSurface};
1764 if points.is_empty() {
1765 return Ok(surface.clone());
1766 }
1767 let (mut u0, mut u1) = (f64::INFINITY, f64::NEG_INFINITY);
1768 let (mut v0, mut v1) = (f64::INFINITY, f64::NEG_INFINITY);
1769 for p in points {
1770 let projected = project_on_surface(surface, *p, 16, tol)?;
1771 let (u, v) = projected.parameters;
1772 u0 = u0.min(u);
1773 u1 = u1.max(u);
1774 v0 = v0.min(v);
1775 v1 = v1.max(v);
1776 }
1777 if !u0.is_finite() || !v0.is_finite() {
1778 return Ok(surface.clone());
1779 }
1780 let margin = |lo: f64, hi: f64| (hi - lo).mul_add(0.05, tol.confusion() * 1e3);
1783 let ((du0, du1), (dv0, dv1)) = surface.domain();
1784 Ok(match surface {
1785 S::Plane(p) => {
1786 let (mu, mv) = (margin(u0, u1), margin(v0, v1));
1787 PlaneSurface::over(
1788 p.plane(),
1789 (du0.min(u0 - mu), du1.max(u1 + mu)),
1790 (dv0.min(v0 - mv), dv1.max(v1 + mv)),
1791 )?
1792 .into()
1793 }
1794 S::Cylinder(c) => {
1795 let m = margin(v0, v1);
1796 CylinderSurface::new(c.cylinder(), (dv0.min(v0 - m), dv1.max(v1 + m)))?.into()
1797 }
1798 S::Cone(c) => {
1799 let m = margin(v0, v1);
1800 ConeSurface::new(c.cone(), (dv0.min(v0 - m), dv1.max(v1 + m)))?.into()
1801 }
1802 S::BSpline(patch) => {
1806 let mut need = [0.0_f64; 4]; let slack = tol.parametric().max(1e-9);
1808 for p in points {
1809 let projected = project_on_surface(surface, *p, 16, tol)?;
1810 if projected.distance <= tol.confusion() {
1811 continue;
1812 }
1813 let (u, v) = projected.parameters;
1814 let sides = [
1815 u <= du0 + slack,
1816 u >= du1 - slack,
1817 v <= dv0 + slack,
1818 v >= dv1 - slack,
1819 ];
1820 for (side, at) in sides.into_iter().enumerate() {
1821 if at {
1822 need[side] = need[side].max(projected.distance);
1823 }
1824 }
1825 }
1826 let mut longer = patch.clone();
1827 for (side, distance) in need.into_iter().enumerate() {
1828 if distance <= 0.0 {
1829 continue;
1830 }
1831 let (along_u, at_end) = (side < 2, side % 2 == 1);
1832 let length = distance.mul_add(1.5, tol.confusion() * 1e3);
1833 longer = longer.extended(along_u, at_end, length, 2, tol)?;
1834 }
1835 S::BSpline(longer)
1836 }
1837 other => other.clone(),
1838 })
1839}
1840
1841#[cfg(test)]
1842#[allow(clippy::unwrap_used)]
1843mod tests {
1844 use super::*;
1845 use crate::make_box;
1846 use approx::assert_relative_eq;
1847 use ogeom_geom::{
1848 BSplineCurve, CircleCurve, CylinderSurface, LineCurve, PlaneSurface, SphereSurface,
1849 TorusSurface, TrimmedCurve,
1850 };
1851 use ogeom_math::{Circle, Cylinder, Direction, Frame, KnotVector, Plane, Sphere, Torus};
1852
1853 const T: Tolerances = Tolerances::millimetres();
1854
1855 #[test]
1856 fn a_guessed_foot_lands_where_the_grid_lands() {
1857 let sphere = SurfaceGeometry::Sphere(SphereSurface::new(
1862 Sphere::new(Frame::WORLD, 5.0, T).unwrap(),
1863 ));
1864 let mut guess = (0.4, 0.1);
1865 for i in 0..40 {
1866 let t = f64::from(i) * 0.04;
1867 let (u, v) = (0.4 + t, 0.1 + t * 0.5);
1870 let on = sphere.point_at(u, v, T).unwrap();
1871 let target = on + (on - Point::ORIGIN) * 0.01;
1872
1873 let gridded = project_on_surface(&sphere, target, 24, T).unwrap();
1874 let guessed = project_on_surface_from(&sphere, target, guess, T).unwrap();
1875
1876 assert!(
1877 guessed.distance <= gridded.distance + T.confusion(),
1878 "step {i}: the guessed foot is farther than the gridded one"
1879 );
1880 assert!(
1881 guessed.point.distance(gridded.point) < 1e-6,
1882 "step {i}: the two feet are different points"
1883 );
1884 guess = guessed.parameters;
1885 }
1886 }
1887
1888 #[test]
1889 fn a_guess_in_the_wrong_basin_reports_its_distance_honestly() {
1890 let cylinder = SurfaceGeometry::Cylinder(
1894 CylinderSurface::new(Cylinder::new(Frame::WORLD, 2.0, T).unwrap(), (-5.0, 5.0))
1895 .unwrap(),
1896 );
1897 let target = Point::new(2.0, 0.0, 1.0);
1898 let opposite =
1899 project_on_surface_from(&cylinder, target, (core::f64::consts::PI, 1.0), T).unwrap();
1900 assert!(
1901 opposite.distance > 1.0 || opposite.point.distance(target) < 1e-6,
1902 "a guess on the far side either finds the point or says how far it is"
1903 );
1904 }
1905
1906 fn dense_points(curve: &Curve, n: usize) -> Vec<Point> {
1908 let (a, b) = curve.domain();
1909 (0..=n)
1910 .map(|i| {
1911 #[allow(clippy::cast_precision_loss)]
1912 let u = a + (b - a) * (i as f64 / n as f64);
1913 curve.point_at(u, T).unwrap()
1914 })
1915 .collect()
1916 }
1917
1918 #[test]
1919 fn a_line_bounds_exactly_to_its_endpoints() {
1920 let curve: Curve = LineCurve::segment(Point::ORIGIN, Point::new(3.0, 4.0, 0.0), T)
1921 .unwrap()
1922 .into();
1923 let b = curve_bounds(&curve, T).unwrap();
1924 assert_eq!(b.low(), Some(Point::ORIGIN));
1925 assert_eq!(b.high(), Some(Point::new(3.0, 4.0, 0.0)));
1926 }
1927
1928 #[test]
1929 fn every_curves_bound_contains_the_curve() {
1930 let spline_control = vec![
1934 Point::new(0.0, 0.0, 0.0),
1935 Point::new(1.0, 5.0, 0.0),
1936 Point::new(3.0, -4.0, 2.0),
1937 Point::new(5.0, 2.0, -1.0),
1938 Point::new(6.0, 0.0, 0.0),
1939 ];
1940 let tilted = Frame::new(
1941 Point::new(1.0, -2.0, 3.0),
1942 Direction::from_coords(1.0, 2.0, 3.0, T).unwrap(),
1943 Direction::X,
1944 T,
1945 )
1946 .unwrap();
1947
1948 let curves: Vec<Curve> = vec![
1949 LineCurve::segment(Point::ORIGIN, Point::new(3.0, 4.0, 0.0), T)
1950 .unwrap()
1951 .into(),
1952 CircleCurve::new(Circle::new(tilted, 2.0, T).unwrap()).into(),
1953 ogeom_geom::EllipseCurve::new(ogeom_math::Ellipse::new(tilted, 5.0, 3.0, T).unwrap())
1954 .into(),
1955 ogeom_geom::HyperbolaCurve::new(
1956 ogeom_math::Hyperbola::new(tilted, 3.0, 4.0, T).unwrap(),
1957 1.5,
1958 )
1959 .unwrap()
1960 .into(),
1961 ogeom_geom::ParabolaCurve::new(ogeom_math::Parabola::new(tilted, 2.0, T).unwrap(), 4.0)
1962 .unwrap()
1963 .into(),
1964 BSplineCurve::new(
1965 KnotVector::clamped_uniform(3, spline_control.len()).unwrap(),
1966 spline_control,
1967 T,
1968 )
1969 .unwrap()
1970 .into(),
1971 ];
1972
1973 for curve in curves {
1974 let bound = curve_bounds(&curve, T).unwrap().with_tolerance(T);
1975 for p in dense_points(&curve, 400) {
1976 assert!(
1977 bound.contains(p),
1978 "{:?} escaped its bound at {p:?}: {bound}",
1979 curve.kind()
1980 );
1981 }
1982 }
1983 }
1984
1985 #[test]
1986 fn a_splines_bound_is_its_control_hull_and_that_is_a_guarantee() {
1987 let control = vec![
1990 Point::new(0.0, 0.0, 0.0),
1991 Point::new(1.0, 10.0, 0.0),
1992 Point::new(2.0, 10.0, 0.0),
1993 Point::new(3.0, 0.0, 0.0),
1994 ];
1995 let curve: Curve = BSplineCurve::new(
1996 KnotVector::clamped_uniform(3, control.len()).unwrap(),
1997 control.clone(),
1998 T,
1999 )
2000 .unwrap()
2001 .into();
2002
2003 let bound = curve_bounds(&curve, T).unwrap();
2004 assert_eq!(bound, Aabb::of_points(&control));
2005 for p in dense_points(&curve, 200) {
2006 assert!(bound.contains(p));
2007 }
2008 let peak = dense_points(&curve, 200)
2011 .iter()
2012 .fold(0.0_f64, |m, p| m.max(p.y));
2013 assert!(peak < 10.0, "the curve should not reach its control points");
2014 }
2015
2016 #[test]
2017 fn a_trimmed_curve_reports_the_bound_of_the_whole() {
2018 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
2021 let quarter: Curve = TrimmedCurve::new(circle.clone(), 0.0, 1.5, T)
2022 .unwrap()
2023 .into();
2024
2025 let whole = curve_bounds(&circle, T).unwrap();
2026 let part = curve_bounds(&quarter, T).unwrap();
2027 assert_eq!(part, whole);
2028 for p in dense_points(&quarter, 200) {
2029 assert!(part.contains(p));
2030 }
2031 }
2032
2033 #[test]
2034 fn every_surfaces_bound_contains_the_surface() {
2035 let tilted = Frame::new(
2036 Point::new(1.0, -2.0, 3.0),
2037 Direction::from_coords(1.0, 2.0, 3.0, T).unwrap(),
2038 Direction::X,
2039 T,
2040 )
2041 .unwrap();
2042 let surfaces: Vec<SurfaceGeometry> = vec![
2043 PlaneSurface::over(Plane::new(tilted), (-5.0, 5.0), (-3.0, 3.0))
2044 .unwrap()
2045 .into(),
2046 CylinderSurface::new(Cylinder::new(tilted, 2.0, T).unwrap(), (-4.0, 4.0))
2047 .unwrap()
2048 .into(),
2049 ogeom_geom::ConeSurface::new(
2050 ogeom_math::Cone::new(tilted, 3.0, 0.6, T).unwrap(),
2051 (-1.0, 5.0),
2052 )
2053 .unwrap()
2054 .into(),
2055 SphereSurface::new(Sphere::new(tilted, 4.0, T).unwrap()).into(),
2056 TorusSurface::new(Torus::new(tilted, 5.0, 2.0, T).unwrap()).into(),
2057 ];
2058
2059 for surface in surfaces {
2060 let bound = surface_bounds(&surface, T).unwrap().with_tolerance(T);
2061 let ((ua, ub), (va, vb)) = surface.domain();
2062 for i in 0..=40 {
2063 for j in 0..=40 {
2064 let u = ua + (ub - ua) * (f64::from(i) / 40.0);
2065 let v = va + (vb - va) * (f64::from(j) / 40.0);
2066 let p = surface.point_at(u, v, T).unwrap();
2067 assert!(
2068 bound.contains(p),
2069 "{:?} escaped its bound at ({u}, {v}) -> {p:?}: {bound}",
2070 surface.kind()
2071 );
2072 }
2073 }
2074 }
2075 }
2076
2077 #[test]
2078 fn a_spheres_bound_is_exact() {
2079 let s: SurfaceGeometry =
2080 SphereSurface::new(Sphere::centred(Point::new(1.0, 2.0, 3.0), 4.0, T).unwrap()).into();
2081 let b = surface_bounds(&s, T).unwrap();
2082 assert_eq!(b.low(), Some(Point::new(-3.0, -2.0, -1.0)));
2083 assert_eq!(b.high(), Some(Point::new(5.0, 6.0, 7.0)));
2084 }
2085
2086 #[test]
2087 fn an_unbounded_plane_is_refused_rather_than_bounded_wrongly() {
2088 let s: SurfaceGeometry = PlaneSurface::new(Plane::new(Frame::WORLD)).into();
2091 assert!(surface_bounds(&s, T).is_err());
2092 }
2093
2094 #[test]
2095 fn a_shapes_bound_contains_every_vertex_it_holds() {
2096 let mut model = Model::new();
2097 let built = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T).unwrap();
2098 let bound = shape_bounds(&model, &built.shape, T).unwrap();
2099
2100 for vertex in explore_unique(&model, &built.shape, ShapeType::Vertex).unwrap() {
2101 let p = model
2102 .node(&vertex)
2103 .unwrap()
2104 .data()
2105 .as_vertex()
2106 .unwrap()
2107 .point;
2108 assert!(bound.contains(p), "vertex {p:?} escaped {bound}");
2109 }
2110 }
2111
2112 #[test]
2113 fn the_vertex_bound_of_a_box_is_tight_and_the_full_bound_contains_it() {
2114 let mut model = Model::new();
2118 let built = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T).unwrap();
2119
2120 let tight = vertex_bounds(&model, &built.shape, T).unwrap();
2121 assert_relative_eq!(tight.size().x, 2.0, epsilon = 1e-6);
2122 assert_relative_eq!(tight.size().y, 3.0, epsilon = 1e-6);
2123 assert_relative_eq!(tight.size().z, 4.0, epsilon = 1e-6);
2124
2125 let full = shape_bounds(&model, &built.shape, T).unwrap();
2126 assert!(full.contains_box(&tight));
2127 }
2128
2129 #[test]
2130 fn a_placed_shapes_bound_moves_with_it() {
2131 let mut model = Model::new();
2132 let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
2133 let here = vertex_bounds(&model, &built.shape, T).unwrap();
2134
2135 let moved = model.placed(
2136 &built.shape,
2137 ogeom_math::Transform::translation(Vector::new(10.0, 0.0, 0.0)),
2138 );
2139 let there = vertex_bounds(&model, &moved, T).unwrap();
2140
2141 assert_relative_eq!(
2142 there.centre().unwrap().x - here.centre().unwrap().x,
2143 10.0,
2144 epsilon = 1e-9
2145 );
2146 assert_relative_eq!(there.size().x, here.size().x, epsilon = 1e-9);
2147 }
2148
2149 #[test]
2150 fn projecting_onto_a_line_lands_on_the_foot_of_the_perpendicular() {
2151 let curve: Curve = LineCurve::segment(Point::ORIGIN, Point::new(10.0, 0.0, 0.0), T)
2152 .unwrap()
2153 .into();
2154 let p = project_on_curve(&curve, Point::new(3.0, 4.0, 0.0), 32, T).unwrap();
2155 assert_relative_eq!(p.parameter, 3.0, epsilon = 1e-6);
2156 assert!(p.point.is_equal(Point::new(3.0, 0.0, 0.0), T));
2157 assert_relative_eq!(p.distance, 4.0, epsilon = 1e-9);
2158 }
2159
2160 #[test]
2164 fn a_patch_widened_to_hold_a_point_is_continued_to_it() {
2165 let cylinder = Cylinder::new(Frame::WORLD, 5.0, T).unwrap();
2166 let wall: SurfaceGeometry =
2167 SurfaceGeometry::Cylinder(CylinderSurface::new(cylinder, (0.0, 10.0)).unwrap())
2168 .to_bspline(T)
2169 .unwrap()
2170 .into();
2171 let past = Point::new(0.0, 5.0, 12.0);
2172 let short = project_on_surface(&wall, past, 16, T).unwrap();
2173 assert!(short.distance > 1.0, "the point stands off the patch's top");
2174 let longer = widened_to_hold(&wall, &[past], T).unwrap();
2175 let ((ua, ub), (va, vb)) = longer.domain();
2176 let ((wa, wb), (wva, wvb)) = wall.domain();
2177 assert!((ua - wa).abs() < 1e-12 && (ub - wb).abs() < 1e-12 && (va - wva).abs() < 1e-12);
2178 assert!(vb > wvb, "the top grew: {vb} over {wvb}");
2179 let held = project_on_surface(&longer, past, 16, T).unwrap();
2180 assert!(
2181 held.distance < 1e-6,
2182 "and holds the point: {}",
2183 held.distance
2184 );
2185 }
2186
2187 #[test]
2188 fn a_window_widened_to_hold_a_point_holds_it_with_room_to_spare() {
2189 let cylinder = Cylinder::new(Frame::WORLD, 5.0, T).unwrap();
2194 let tight: SurfaceGeometry = CylinderSurface::new(cylinder, (0.0, 10.0)).unwrap().into();
2195 let just_past = Point::new(5.0, 0.0, 10.0 + 1e-6);
2196 assert!(
2197 tight.domain().1.1 < just_past.z,
2198 "the point is outside the tight window, which is the premise"
2199 );
2200
2201 let wide = widened_to_hold(&tight, &[just_past], T).unwrap();
2202 let (_, (v0, v1)) = wide.domain();
2203 assert!(
2204 v1 > just_past.z && v0 <= 0.0,
2205 "the window holds the point and gives nothing back: ({v0}, {v1})"
2206 );
2207 assert_relative_eq!(v1 - 10.0, T.confusion() * 1e3, epsilon = 1e-12);
2214 assert!(
2215 v1 - just_past.z > 0.0,
2216 "and it clears the point: {}",
2217 v1 - just_past.z
2218 );
2219
2220 let SurfaceGeometry::Cylinder(c) = &wide else {
2222 panic!("still a cylinder");
2223 };
2224 assert_relative_eq!(c.cylinder().radius(), 5.0, epsilon = 1e-15);
2225 }
2226
2227 #[test]
2228 fn a_surface_with_no_bounded_direction_comes_back_unchanged() {
2229 let sphere: SurfaceGeometry =
2233 SphereSurface::new(Sphere::new(Frame::WORLD, 4.0, T).unwrap()).into();
2234 let widened = widened_to_hold(&sphere, &[Point::new(4.0, 0.0, 0.0)], T).unwrap();
2235 assert_eq!(sphere.domain(), widened.domain());
2236
2237 let cylinder: SurfaceGeometry =
2239 CylinderSurface::new(Cylinder::new(Frame::WORLD, 2.0, T).unwrap(), (1.0, 3.0))
2240 .unwrap()
2241 .into();
2242 assert_eq!(
2243 cylinder.domain(),
2244 widened_to_hold(&cylinder, &[], T).unwrap().domain()
2245 );
2246 }
2247
2248 #[test]
2249 fn projecting_past_the_end_of_a_segment_clamps_to_the_end() {
2250 let curve: Curve = LineCurve::segment(Point::ORIGIN, Point::new(10.0, 0.0, 0.0), T)
2251 .unwrap()
2252 .into();
2253 let p = project_on_curve(&curve, Point::new(50.0, 0.0, 0.0), 32, T).unwrap();
2254 assert_relative_eq!(p.parameter, 10.0, epsilon = 1e-6);
2255 assert_relative_eq!(p.distance, 40.0, epsilon = 1e-6);
2256 }
2257
2258 #[test]
2259 fn projecting_onto_a_circle_finds_the_nearest_of_many_minima() {
2260 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 5.0, T).unwrap()).into();
2264 for angle in [0.1_f64, 1.0, 2.5, 4.0, 6.0] {
2265 let outside = Point::new(8.0 * angle.cos(), 8.0 * angle.sin(), 0.0);
2266 let p = project_on_curve(&circle, outside, 64, T).unwrap();
2267 assert_relative_eq!(p.distance, 3.0, epsilon = 1e-6);
2268 assert_relative_eq!(p.point.to_vector().magnitude(), 5.0, epsilon = 1e-9);
2269 }
2270 }
2271
2272 #[test]
2273 fn projecting_onto_a_plane_gives_the_perpendicular_foot() {
2274 let plane: SurfaceGeometry =
2275 PlaneSurface::over(Plane::new(Frame::WORLD), (-10.0, 10.0), (-10.0, 10.0))
2276 .unwrap()
2277 .into();
2278 let p = project_on_surface(&plane, Point::new(2.0, 3.0, 7.0), 8, T).unwrap();
2279 assert!(p.point.is_equal(Point::new(2.0, 3.0, 0.0), T));
2280 assert_relative_eq!(p.distance, 7.0, epsilon = 1e-9);
2281 }
2282
2283 #[test]
2284 fn projecting_onto_a_sphere_lands_on_the_radial_line() {
2285 let sphere = Sphere::centred(Point::new(1.0, 1.0, 1.0), 3.0, T).unwrap();
2286 let surface: SurfaceGeometry = SphereSurface::new(sphere).into();
2287 for target in [
2288 Point::new(10.0, 1.0, 1.0),
2289 Point::new(1.0, 1.0, 9.0),
2290 Point::new(-4.0, -2.0, 0.0),
2291 ] {
2292 let p = project_on_surface(&surface, target, 16, T).unwrap();
2293 assert_relative_eq!(sphere.centre().distance(p.point), 3.0, max_relative = 1e-7);
2295 assert_relative_eq!(
2296 p.distance,
2297 (sphere.centre().distance(target) - 3.0).abs(),
2298 max_relative = 1e-6
2299 );
2300 }
2301 }
2302
2303 #[test]
2304 fn projecting_onto_a_cylinder_is_radial() {
2305 let cylinder = Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
2306 let surface: SurfaceGeometry = CylinderSurface::new(cylinder, (-5.0, 5.0)).unwrap().into();
2307 let p = project_on_surface(&surface, Point::new(6.0, 0.0, 1.0), 16, T).unwrap();
2308 assert_relative_eq!(p.distance, 4.0, max_relative = 1e-6);
2309 assert_relative_eq!(p.point.z, 1.0, epsilon = 1e-6);
2310 assert_relative_eq!(p.point.x.hypot(p.point.y), 2.0, max_relative = 1e-7);
2311 }
2312
2313 #[test]
2314 fn shared_seeds_project_to_the_bit_where_the_per_call_grid_lands() {
2315 let cylinder = Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
2320 let surface: SurfaceGeometry = CylinderSurface::new(cylinder, (-5.0, 5.0)).unwrap().into();
2321 let seeds = SurfaceSeeds::over(&surface, 16, T).unwrap();
2322 for target in [
2323 Point::new(6.0, 0.0, 1.0),
2324 Point::new(-1.0, 3.0, -4.5),
2325 Point::new(0.5, -0.5, 0.0),
2326 Point::new(2.0, 0.0, 5.0),
2327 ] {
2328 let gridded = project_on_surface(&surface, target, 16, T).unwrap();
2329 let seeded = seeds.project(&surface, target, T).unwrap();
2330 assert_eq!(seeded.parameters, gridded.parameters);
2331 assert_eq!(seeded.point, gridded.point);
2332 assert_eq!(seeded.distance, gridded.distance);
2333 }
2334 }
2335
2336 #[test]
2337 fn projection_of_a_point_already_on_the_geometry_returns_zero_distance() {
2338 let curve: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 3.0, T).unwrap()).into();
2339 let on = curve.point_at(1.2, T).unwrap();
2340 let p = project_on_curve(&curve, on, 64, T).unwrap();
2341 assert!(p.distance < 1e-7, "distance was {}", p.distance);
2342 }
2343
2344 #[test]
2345 fn projecting_onto_a_planar_curve_works_in_parameter_space() {
2346 let curve: PlanarCurve =
2347 ogeom_geom::Line2d::segment(Point2::ORIGIN, Point2::new(10.0, 0.0), T)
2348 .unwrap()
2349 .into();
2350 let (u, point, distance) =
2351 project_on_planar_curve(&curve, Point2::new(3.0, 4.0), 32, T).unwrap();
2352 assert_relative_eq!(u, 3.0, epsilon = 1e-6);
2353 assert!(point.is_equal(Point2::new(3.0, 0.0), T));
2354 assert_relative_eq!(distance, 4.0, epsilon = 1e-9);
2355 }
2356
2357 #[test]
2370 fn a_patch_is_bounded_by_the_piece_its_trim_can_reach() {
2371 use ogeom_geom::BSplineSurface;
2372 use ogeom_math::ControlGrid;
2373 let far = Point::new(0.0, -1000.0, 0.0);
2374 let grid = ControlGrid::new(
2375 vec![
2376 far,
2377 Point::new(0.0, -1000.0, 1.0),
2378 Point::new(0.0, 0.0, 0.0),
2379 Point::new(0.0, 0.0, 1.0),
2380 Point::new(1.0, 0.0, 0.0),
2381 Point::new(1.0, 0.0, 1.0),
2382 ],
2383 3,
2384 2,
2385 )
2386 .unwrap();
2387 let patch = BSplineSurface::new(
2388 KnotVector::new(vec![-80.0, -80.0, 0.0, 1.0, 1.0], 1).unwrap(),
2389 KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1).unwrap(),
2390 &grid,
2391 T,
2392 )
2393 .unwrap();
2394
2395 let whole = surface_bounds(&SurfaceGeometry::BSpline(patch.clone()), T).unwrap();
2398 assert!(whole.low().unwrap().y < -999.0, "the net reaches a metre");
2399
2400 let over = spline_hull_over(&patch, (0.0, 1.0), (0.0, 1.0), T);
2402 let (low, high) = (over.low().unwrap(), over.high().unwrap());
2403 assert_relative_eq!(low.y, 0.0, epsilon = 1e-12);
2404 assert_relative_eq!(low.x, 0.0, epsilon = 1e-12);
2405 assert_relative_eq!(high.x, 1.0, epsilon = 1e-12);
2406 assert_relative_eq!(high.z, 1.0, epsilon = 1e-12);
2407
2408 let across = spline_hull_over(&patch, (-40.0, 1.0), (0.0, 1.0), T);
2411 assert!(across.low().unwrap().y < -400.0, "half of it is still far");
2412 }
2413
2414 fn helical_flank(turns: usize) -> SurfaceGeometry {
2419 use ogeom_geom::BSplineSurface;
2420 use ogeom_math::ControlGrid;
2421 let columns = turns * 7;
2422 let mut points = Vec::with_capacity(4 * columns);
2423 for i in 0..4 {
2424 let r = 3.0 + f64::from(i) / 3.0;
2425 for j in 0..columns {
2426 #[allow(clippy::cast_precision_loss)]
2427 let a = j as f64;
2428 points.push(Point::new(
2429 r * a.cos(),
2430 r * a.sin(),
2431 a * 1.5 / std::f64::consts::TAU,
2432 ));
2433 }
2434 }
2435 let grid = ControlGrid::new(points, 4, columns).unwrap();
2436 let u_knots = KnotVector::clamped_uniform(3, 4).unwrap();
2437 let v_knots = KnotVector::clamped_uniform(3, columns).unwrap();
2438 SurfaceGeometry::BSpline(BSplineSurface::new(u_knots, v_knots, &grid, T).unwrap())
2439 }
2440
2441 #[test]
2442 fn a_point_on_a_long_flanks_outer_helix_projects_to_its_own_turn() {
2443 use ogeom_geom::Surface as _;
2444 let flank = helical_flank(200);
2445 let ((_, ub), (va, vb)) = flank.domain();
2446 for v in [va + (vb - va) * 0.617, vb - 0.4] {
2449 let on = flank.point_at(ub, v, T).unwrap();
2450 let foot = project_on_surface(&flank, on, 24, T).unwrap();
2451 assert!(foot.distance < 1e-9, "at v={v}: {} off", foot.distance);
2452 assert_relative_eq!(foot.parameters.0, ub, epsilon = 1e-9);
2453 assert_relative_eq!(foot.parameters.1, v, epsilon = 1e-6);
2454 let seeded = SurfaceSeeds::over(&flank, 24, T).unwrap();
2455 let again = seeded.project(&flank, on, T).unwrap();
2456 assert_eq!(again.parameters, foot.parameters);
2457 }
2458 }
2459
2460 #[test]
2461 fn a_point_past_a_bound_lands_on_the_bound_not_on_its_seed() {
2462 use ogeom_geom::Surface as _;
2463 let flank = helical_flank(3);
2464 let ((_, ub), (va, vb)) = flank.domain();
2465 let v = va + (vb - va) * 0.37;
2466 let jet = flank.jet_at(ub, v, T).unwrap();
2467 let target = jet.point + jet.du.normalized(T).unwrap() * 0.01;
2470 let foot = project_on_surface(&flank, target, 24, T).unwrap();
2471 assert_relative_eq!(foot.parameters.0, ub, epsilon = 1e-12);
2472 assert_relative_eq!(foot.parameters.1, v, epsilon = 1e-5);
2473 assert!(foot.distance < 0.0101, "{} off", foot.distance);
2474 }
2475}
2476
2477#[cfg(test)]
2478#[allow(clippy::unwrap_used)]
2479mod oriented_bound_tests {
2480 use super::*;
2481 use crate::{make_box, make_cylinder};
2482 use approx::assert_relative_eq;
2483 use ogeom_math::Transform;
2484
2485 const T: Tolerances = Tolerances::millimetres();
2486
2487 fn fine() -> ogeom_mesh::Deflection {
2488 ogeom_mesh::Deflection {
2489 chord: 0.01,
2490 ..ogeom_mesh::Deflection::default()
2491 }
2492 }
2493
2494 #[test]
2495 fn an_oriented_box_around_a_box_is_that_box() {
2496 let mut model = Model::new();
2497 let size = (2.0, 5.0, 1.0);
2498 let built = make_box(&mut model, Frame::WORLD, size, T).unwrap();
2499 let obb = oriented_bounds(&model, &built.shape, fine(), T).unwrap();
2500
2501 assert_relative_eq!(obb.volume(), size.0 * size.1 * size.2, epsilon = 1e-9);
2502 assert!(
2503 obb.frame.origin().distance(Point::new(1.0, 2.5, 0.5)) < 1e-9,
2504 "got {:?}",
2505 obb.frame.origin()
2506 );
2507 let mut found = [obb.half_extent.x, obb.half_extent.y, obb.half_extent.z];
2510 found.sort_by(|a, b| a.partial_cmp(b).unwrap());
2511 let mut want = [size.0 / 2.0, size.1 / 2.0, size.2 / 2.0];
2512 want.sort_by(|a, b| a.partial_cmp(b).unwrap());
2513 for (a, b) in found.iter().zip(&want) {
2514 assert_relative_eq!(a, b, epsilon = 1e-9);
2515 }
2516 }
2517
2518 #[test]
2519 fn turning_a_box_turns_its_oriented_bound_with_it_and_not_its_volume() {
2520 let mut model = Model::new();
2524 let built = make_box(&mut model, Frame::WORLD, (1.0, 6.0, 1.0), T).unwrap();
2525 let turned = crate::transformed(
2526 &mut model,
2527 &built.shape,
2528 Transform::rotation(
2529 ogeom_math::Axis::new(Point::ORIGIN, Direction::Z),
2530 std::f64::consts::FRAC_PI_4,
2531 ),
2532 )
2533 .unwrap()
2534 .shape;
2535
2536 let obb = oriented_bounds(&model, &turned, fine(), T).unwrap();
2537 let aabb = shape_bounds(&model, &turned, T).unwrap();
2538 assert_relative_eq!(obb.volume(), 6.0, max_relative = 1e-6);
2539 assert!(
2540 aabb.volume() > obb.volume() * 1.5,
2541 "an axis-aligned box around a diagonal bar should be much emptier: \
2542 {} against {}",
2543 aabb.volume(),
2544 obb.volume()
2545 );
2546 for corner in obb.corners() {
2547 assert!(obb.contains(corner) || obb.to_aabb().contains(corner));
2548 }
2549 }
2550
2551 #[test]
2552 fn a_cylinders_oriented_bound_follows_its_axis() {
2553 let mut model = Model::new();
2554 let (radius, height) = (0.5_f64, 8.0);
2555 let built = make_cylinder(&mut model, Frame::WORLD, radius, height, T).unwrap();
2556 let obb = oriented_bounds(&model, &built.shape, fine(), T).unwrap();
2557
2558 assert!(
2561 obb.frame
2562 .x()
2563 .vector()
2564 .cross(Direction::Z.vector())
2565 .magnitude()
2566 < 1e-6,
2567 "the most-spread axis should be the cylinder's, got {:?}",
2568 obb.frame.x()
2569 );
2570 assert_relative_eq!(obb.half_extent.x, height / 2.0, max_relative = 1e-6);
2571 }
2572
2573 #[test]
2574 fn a_shape_with_nothing_to_bound_says_so() {
2575 let mut model = Model::new();
2576 let vertex = model.add_point(Point::ORIGIN);
2577 assert!(oriented_bounds(&model, &vertex, fine(), T).is_ok());
2580 }
2581}
2582
2583#[cfg(test)]
2584#[allow(clippy::unwrap_used)]
2585mod deflection_tests {
2586 use super::*;
2587 use crate::make_box;
2588 use approx::assert_relative_eq;
2589
2590 const T: Tolerances = Tolerances::millimetres();
2591
2592 #[test]
2593 fn a_relative_deflection_follows_the_shape_it_is_for() {
2594 let mut model = Model::new();
2598 let small = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2599 .unwrap()
2600 .shape;
2601 let large = make_box(&mut model, Frame::WORLD, (1000.0, 1000.0, 1000.0), T)
2602 .unwrap()
2603 .shape;
2604
2605 let a = relative_deflection(&model, &small, 1e-3, T).unwrap();
2606 let b = relative_deflection(&model, &large, 1e-3, T).unwrap();
2607 assert_relative_eq!(b.chord / a.chord, 1000.0, max_relative = 1e-3);
2612 assert_relative_eq!(a.chord, 3.0_f64.sqrt() * 1e-3, max_relative = 1e-3);
2613 }
2614
2615 #[test]
2616 fn a_shape_with_no_extent_has_no_fraction_of_itself() {
2617 let mut model = Model::new();
2621 let vertex = model.add_point(Point::ORIGIN);
2622 let err = relative_deflection(&model, &vertex, 1e-3, T).unwrap_err();
2623 assert!(
2624 err.to_string().contains("no edges or faces"),
2625 "unexpected message: {err}"
2626 );
2627
2628 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2629 .unwrap()
2630 .shape;
2631 assert!(relative_deflection(&model, &solid, 0.0, T).is_err());
2632 assert!(relative_deflection(&model, &solid, -1.0, T).is_err());
2633 assert!(relative_deflection(&model, &solid, f64::NAN, T).is_err());
2634 }
2635
2636 #[test]
2637 fn a_planar_face_is_bounded_by_its_wires_not_its_carrier() {
2638 let mut model = Model::new();
2641 let block = crate::make_box(
2642 &mut model,
2643 ogeom_math::Frame::WORLD,
2644 (8.0, 6.0, 4.0),
2645 Tolerances::millimetres(),
2646 )
2647 .unwrap();
2648 let bound = shape_bounds(&model, &block.shape, Tolerances::millimetres()).unwrap();
2649 let (Some(lo), Some(hi)) = (bound.low(), bound.high()) else {
2650 panic!("the box has a bound");
2651 };
2652 assert!(
2653 lo.distance(ogeom_math::Point::new(0.0, 0.0, 0.0)) < 1e-3,
2654 "{lo:?}"
2655 );
2656 assert!(
2657 hi.distance(ogeom_math::Point::new(8.0, 6.0, 4.0)) < 1e-3,
2658 "{hi:?}"
2659 );
2660 }
2661}