Skip to main content

ogeom_algo/
tight.rs

1//! The smallest axis-aligned box that holds a shape: how big a body is.
2//!
3//! [`shape_bounds`](crate::shape_bounds) promises to hold everything and
4//! so keeps each surface's carrier bound, which for a surface of
5//! revolution or a trimmed cone can stand well clear of the solid. Here
6//! each extreme is found where it is: on an edge, by a one-dimensional
7//! search along its curve, or inside a face, at a point of the exact
8//! surface found from the face's own mesh and held to the face's chart.
9
10use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
11use ogeom_geom::{Curve3d as _, Surface as _};
12use ogeom_math::{Aabb, Point, Point2, Vector, solve};
13use ogeom_mesh::Deflection;
14use ogeom_topo::{EdgeRepr, Model, NodeData, SameKey, Shape, ShapeType, explore_unique};
15
16const AXES: [Vector; 3] = [Vector::X, Vector::Y, Vector::Z];
17
18/// Whether a surface is swept by straight lines: a plane, a drum, a cone,
19/// a linear extrusion, or a trim of one.
20fn ruled(surface: &ogeom_geom::SurfaceGeometry) -> bool {
21    use ogeom_geom::SurfaceGeometry as S;
22    match surface {
23        S::Plane(_) | S::Cylinder(_) | S::Cone(_) | S::Extrusion(_) => true,
24        S::Trimmed(t) => ruled(t.basis()),
25        S::Sphere(_) | S::Torus(_) | S::BSpline(_) | S::Revolution(_) | S::Offset(_) => false,
26    }
27}
28
29/// The smallest axis-aligned box holding `shape`, to within `tol`: each of
30/// its six sides where the shape actually reaches, on an edge, at a vertex
31/// or inside a face.
32///
33/// Each face's box is the one the model keeps for it ([`face_bounds`]),
34/// found the first time it is asked for, where `tol` is the model's own and
35/// the face's placement keeps the axes; otherwise it is found again here.
36///
37/// # Errors
38///
39/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if a handle
40/// does not resolve; [`OgeomError::Construction`](ogeom_core::OgeomError::Construction)
41/// if the shape holds nothing to bound, or as the face meshes report.
42pub fn tight_bounds(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Aabb> {
43    let mut edges = EdgeBoxes::default();
44    let mut bound = Aabb::EMPTY;
45    let keep = tol == model.tolerances();
46    for face in explore_unique(model, shape, ShapeType::Face)? {
47        let placement = face.transform(model.datums())?;
48        let found = if keep && keeps_axes(&placement) {
49            let kept = model.kept_face_box(&face, |bare| face_box(model, bare, tol, &mut edges))?;
50            kept.transformed(&placement)
51        } else {
52            face_box(model, &face, tol, &mut edges)?
53        };
54        bound = bound.union(&found);
55    }
56    let (loose_edges, loose_vertices) = outside_faces(model, shape)?;
57    for edge in &loose_edges {
58        bound = bound.union(&edge_box(model, edge, tol, &mut edges)?);
59    }
60    for vertex in &loose_vertices {
61        if let Some(point) = vertex_point(model, vertex)? {
62            bound = bound.with_point(point);
63        }
64    }
65    if bound.is_empty() {
66        ogeom_bail!(Construction, "the shape holds nothing to bound");
67    }
68    Ok(bound)
69}
70
71/// The box the model keeps for `face`: every point of the face, its
72/// tolerance included, in the face's placement; found at the model's
73/// tolerances the first time it is asked for and kept until the face
74/// changes ([`Model::face_bounds`] reads it without finding it).
75///
76/// The box is within the model's tolerances of the face's tightest one
77/// widened by its tolerance, where the face's placement keeps the axes;
78/// under a placement that turns the face off them it is the box of the
79/// turned box.
80///
81/// # Errors
82///
83/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
84/// `face` is not a face of the model; as [`tight_bounds`].
85pub fn face_bounds(model: &Model, face: &Shape) -> OgeomResult<Aabb> {
86    let tol = model.tolerances();
87    let mut edges = EdgeBoxes::default();
88    model.kept_face_box(face, |bare| face_box(model, bare, tol, &mut edges))?;
89    if let Some(kept) = model.face_bounds(face) {
90        return Ok(kept);
91    }
92    let tolerance = model.tolerance_of(face)?.map_or(0.0, |t| t.get());
93    Ok(face_box(model, face, tol, &mut edges)?.expanded(tolerance))
94}
95
96/// Whether a placement carries a box onto a box: no turn off the axes.
97fn keeps_axes(placement: &ogeom_math::Transform) -> bool {
98    use ogeom_math::TransformKind as K;
99    matches!(
100        placement.kind(),
101        K::Identity | K::Translation | K::Scale | K::PointMirror
102    )
103}
104
105/// Each edge's box, found once for every face it bounds.
106type EdgeBoxes = ogeom_core::FastMap<SameKey, Aabb>;
107
108/// The box of a face: its edges' boxes and the extremes inside it.
109fn face_box(
110    model: &Model,
111    face: &Shape,
112    tol: Tolerances,
113    edges: &mut EdgeBoxes,
114) -> OgeomResult<Aabb> {
115    let mut bound = Aabb::EMPTY;
116    for edge in explore_unique(model, face, ShapeType::Edge)? {
117        bound = bound.union(&edge_box(model, &edge, tol, edges)?);
118    }
119    let mut points = Vec::new();
120    face_extremes(model, face, tol, &mut points)?;
121    for point in points {
122        bound = bound.with_point(point);
123    }
124    Ok(bound)
125}
126
127/// The box of an edge: its vertices and its curve's extremes.
128fn edge_box(
129    model: &Model,
130    edge: &Shape,
131    tol: Tolerances,
132    edges: &mut EdgeBoxes,
133) -> OgeomResult<Aabb> {
134    let key = SameKey(edge.clone());
135    if let Some(known) = edges.get(&key) {
136        return Ok(*known);
137    }
138    let mut points = Vec::new();
139    for vertex in model.children_of(edge)? {
140        if let Some(point) = vertex_point(model, &vertex)? {
141            points.push(point);
142        }
143    }
144    edge_extremes(model, edge, tol, &mut points)?;
145    let found = Aabb::of_points(&points);
146    edges.insert(key, found);
147    Ok(found)
148}
149
150/// Where a vertex stands, placed.
151fn vertex_point(model: &Model, vertex: &Shape) -> OgeomResult<Option<Point>> {
152    let Some(data) = model.node(vertex).and_then(|n| n.data().as_vertex()) else {
153        return Ok(None);
154    };
155    Ok(Some(vertex.transform(model.datums())?.apply(data.point)))
156}
157
158/// The edges and vertices of `shape` that no face of it holds.
159fn outside_faces(model: &Model, shape: &Shape) -> OgeomResult<(Vec<Shape>, Vec<Shape>)> {
160    let mut edges = Vec::new();
161    let mut vertices = Vec::new();
162    let mut seen = ogeom_core::FastSet::default();
163    let mut stack = vec![shape.clone()];
164    while let Some(at) = stack.pop() {
165        let Some(node) = model.node(&at) else {
166            ogeom_bail!(Dangling, "shape refers to a node not in this model");
167        };
168        match node.kind() {
169            ShapeType::Face => {}
170            ShapeType::Edge => {
171                if seen.insert(SameKey(at.clone())) {
172                    edges.push(at);
173                }
174            }
175            ShapeType::Vertex => {
176                if seen.insert(SameKey(at.clone())) {
177                    vertices.push(at);
178                }
179            }
180            _ => stack.extend(model.children_of(&at)?),
181        }
182    }
183    Ok((edges, vertices))
184}
185
186/// Along an edge, each coordinate's least and greatest, the search
187/// bracketed by the samples that led.
188fn edge_extremes(
189    model: &Model,
190    edge: &Shape,
191    tol: Tolerances,
192    points: &mut Vec<Point>,
193) -> OgeomResult<()> {
194    let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
195        return Ok(());
196    };
197    let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
198        return Ok(());
199    };
200    let Some(geometry) = model.geometry().curve(*curve) else {
201        ogeom_bail!(Dangling, "curve is not in this model");
202    };
203    // A straight edge leads at its ends, which the vertices hold.
204    if matches!(geometry, ogeom_geom::Curve::Line(_)) {
205        return Ok(());
206    }
207    let placement = edge.transform(model.datums())?;
208    let at = |t: f64| -> OgeomResult<Point> { Ok(placement.apply(geometry.point_at(t, tol)?)) };
209    // A circle leads along each side at the angle its frame names, or
210    // half a turn on: those angles, either way round the circle, are
211    // the candidates, and each that falls on the arc is a point of it.
212    if let ogeom_geom::Curve::Circle(c) = geometry {
213        let frame = c.circle().frame();
214        let (x, y) = (
215            placement.apply_vector(frame.x().vector()),
216            placement.apply_vector(frame.y().vector()),
217        );
218        let tau = core::f64::consts::TAU;
219        for axis in AXES {
220            let theta = axis.dot(y).atan2(axis.dot(x));
221            for angle in [
222                theta,
223                theta + core::f64::consts::PI,
224                -theta,
225                core::f64::consts::PI - theta,
226            ] {
227                let turns = ((range.0 - angle) / tau).ceil();
228                let t = turns.mul_add(tau, angle);
229                if t <= range.1 {
230                    points.push(at(t)?);
231                }
232            }
233        }
234        return Ok(());
235    }
236    const N: usize = 64;
237    #[allow(clippy::cast_precision_loss)]
238    let step = (range.1 - range.0) / N as f64;
239    let samples: Vec<(f64, Point)> = (0..=N)
240        .map(|i| {
241            #[allow(clippy::cast_precision_loss)]
242            let t = range.0 + step * i as f64;
243            at(t).map(|p| (t, p))
244        })
245        .collect::<OgeomResult<_>>()?;
246    for axis in AXES {
247        for sense in [1.0, -1.0] {
248            let score = |p: Point| p.to_vector().dot(axis) * sense;
249            let Some((best, _)) = samples
250                .iter()
251                .enumerate()
252                .max_by(|a, b| score(a.1.1).total_cmp(&score(b.1.1)))
253            else {
254                continue;
255            };
256            let lo = samples[best.saturating_sub(1)].0;
257            let hi = samples[(best + 1).min(N)].0;
258            points.push(samples[best].1);
259            if hi > lo {
260                let found = solve::minimize(
261                    |t| at(t).map_or(f64::INFINITY, |p| -score(p)),
262                    lo,
263                    hi,
264                    solve::Criteria::default(),
265                )?;
266                points.push(at(found.value)?);
267            }
268        }
269    }
270    Ok(())
271}
272
273/// Inside a face: the mesh's leading vertex for each side, moved on
274/// the exact surface as far as the side leads while it stays inside the
275/// face's own chart triangles.
276fn face_extremes(
277    model: &Model,
278    face: &Shape,
279    tol: Tolerances,
280    points: &mut Vec<Point>,
281) -> OgeomResult<()> {
282    let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data()) else {
283        return Ok(());
284    };
285    let Some(surface) = model.geometry().surface(data.surface) else {
286        ogeom_bail!(Dangling, "surface is not in this model");
287    };
288    // A face on a ruled surface reaches no further than its boundary:
289    // every point of it lies on a straight stretch of the surface that
290    // ends on the boundary, and a coordinate along a straight stretch
291    // leads at one end of it. The edges hold those extremes already.
292    if ruled(surface) {
293        return Ok(());
294    }
295    let placement = face.transform(model.datums())?;
296    // A sphere's and a torus's extreme along each side is closed form,
297    // and where it falls inside the face it is the face's extreme; the
298    // descent below can stall short of it in a general frame.
299    use ogeom_geom::Transformable as _;
300    let placed = surface.transformed(&placement, tol);
301    let extremes = placed
302        .as_ref()
303        .map_or([None; 6], |placed| closed_form_extremes(placed, tol));
304    // A face covering its whole surface holds every one of them, and no
305    // point of it leads past them: the mesh has nothing to add.
306    if extremes.iter().all(Option::is_some) && crate::covers_whole_surface(model, face)? {
307        points.extend(extremes.iter().flatten().map(|e| e.0));
308        return Ok(());
309    }
310    let mesh = ogeom_mesh::triangulate_face(model, face, Deflection::default(), tol)?;
311    if mesh.positions.is_empty() {
312        return Ok(());
313    }
314    let chart: Vec<[Point2; 3]> = mesh
315        .triangles
316        .iter()
317        .map(|t| {
318            t.map(|i| {
319                let (u, v) = mesh.parameters[i as usize];
320                Point2::new(u, v)
321            })
322        })
323        .collect();
324    let inside = |p: Point2| chart.iter().any(|t| in_triangle(*t, p));
325    let span = chart.iter().flatten().fold(
326        (
327            f64::INFINITY,
328            f64::NEG_INFINITY,
329            f64::INFINITY,
330            f64::NEG_INFINITY,
331        ),
332        |b, p| (b.0.min(p.x), b.1.max(p.x), b.2.min(p.y), b.3.max(p.y)),
333    );
334    let lower = [span.0, span.2];
335    let upper = [span.1, span.3];
336    if !(upper[0] > lower[0] && upper[1] > lower[1]) {
337        return Ok(());
338    }
339    // The chart triangles' own border: the sides only one triangle has.
340    // It runs on chords of the face's boundary, and where that boundary
341    // curves in the chart the chords stand outside it. A descent that
342    // ends against the border was stopped by the chords, not by the
343    // surface, and an extreme on the boundary is the edges' to find.
344    let mut sides: ogeom_core::FastMap<(u32, u32), usize> = ogeom_core::FastMap::default();
345    for t in &mesh.triangles {
346        for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
347            *sides.entry((a.min(b), a.max(b))).or_default() += 1;
348        }
349    }
350    let border: Vec<(Point2, Point2)> = sides
351        .into_iter()
352        .filter(|(_, n)| *n == 1)
353        .map(|((a, b), _)| {
354            let at = |i: u32| {
355                let (u, v) = mesh.parameters[i as usize];
356                Point2::new(u, v)
357            };
358            (at(a), at(b))
359        })
360        .collect();
361    let against = ((upper[0] - lower[0]).min(upper[1] - lower[1])) * 1e-6;
362    let on_border = |p: Point2| {
363        border
364            .iter()
365            .any(|(a, b)| distance_to_segment(p, *a, *b) <= against)
366    };
367    // A surface its placement cannot carry is refused.
368    placed?;
369    // Angles are tried a whole turn either way, as the chart may run them.
370    let tau = core::f64::consts::TAU;
371    let inside_turned = |u: f64, v: f64, periodic_v: bool| {
372        let turns: &[f64] = &[0.0, 1.0, -1.0];
373        turns.iter().any(|&a| {
374            let shifts: &[f64] = if periodic_v { turns } else { &[0.0] };
375            shifts
376                .iter()
377                .any(|&b| inside(Point2::new(u + a * tau, v + b * tau)))
378        })
379    };
380    for (p, u, v, periodic_v) in extremes.into_iter().flatten() {
381        if inside_turned(u, v, periodic_v) {
382            points.push(p);
383        }
384    }
385    for axis in AXES {
386        for sense in [1.0, -1.0] {
387            let score = |p: Point| p.to_vector().dot(axis) * sense;
388            let Some(best) = (0..mesh.positions.len())
389                .max_by(|a, b| score(mesh.positions[*a]).total_cmp(&score(mesh.positions[*b])))
390            else {
391                continue;
392            };
393            points.push(mesh.positions[best]);
394            let (u, v) = mesh.parameters[best];
395            let refined = solve_on_face(
396                |uv: &[f64]| {
397                    let p = Point2::new(uv[0], uv[1]);
398                    if !inside(p) {
399                        return f64::INFINITY;
400                    }
401                    surface
402                        .point_at(uv[0], uv[1], tol)
403                        .map_or(f64::INFINITY, |q| -score(placement.apply(q)))
404                },
405                [u, v],
406                lower,
407                upper,
408            )?;
409            if let Some([u, v]) = refined
410                && !on_border(Point2::new(u, v))
411            {
412                points.push(placement.apply(surface.point_at(u, v, tol)?));
413            }
414        }
415    }
416    Ok(())
417}
418
419/// A sphere's or a torus's extreme along each side of [`AXES`], each way:
420/// the point, its parameters, and whether `v` is periodic. `None` for
421/// every other surface, and where the point's parameters are not found.
422fn closed_form_extremes(
423    placed: &ogeom_geom::SurfaceGeometry,
424    tol: Tolerances,
425) -> [Option<(Point, f64, f64, bool)>; 6] {
426    let mut found = [None; 6];
427    for (k, toward) in AXES
428        .iter()
429        .flat_map(|axis| [*axis, *axis * -1.0])
430        .enumerate()
431    {
432        found[k] = match placed {
433            ogeom_geom::SurfaceGeometry::Sphere(s) => {
434                let sphere = s.sphere();
435                let p = sphere.centre() + toward * sphere.radius();
436                ogeom_math::elementary::sphere_parameters(&sphere, p, tol)
437                    .ok()
438                    .map(|(u, v)| (p, u, v, false))
439            }
440            ogeom_geom::SurfaceGeometry::Torus(t) => {
441                let torus = t.torus();
442                let frame = torus.frame();
443                let z = frame.z().vector();
444                let along = toward.dot(z);
445                let across = toward - z * along;
446                let reach = across.magnitude();
447                // Facing straight along the axis every parallel
448                // leads equally; any one of them will do.
449                let out = if reach > 1e-12 {
450                    across / reach
451                } else {
452                    frame.x().vector()
453                };
454                let lean = along.hypot(reach);
455                let tilt = (out * reach + z * along) / lean;
456                let p = frame.origin() + out * torus.major_radius() + tilt * torus.minor_radius();
457                ogeom_math::elementary::torus_parameters(&torus, p, tol)
458                    .ok()
459                    .map(|(u, v)| (p, u, v, true))
460            }
461            _ => None,
462        };
463    }
464    found
465}
466
467/// A local descent from `start` inside the chart window; `None` where it
468/// found nothing better than the start.
469fn solve_on_face(
470    f: impl FnMut(&[f64]) -> f64,
471    start: [f64; 2],
472    lower: [f64; 2],
473    upper: [f64; 2],
474) -> OgeomResult<Option<[f64; 2]>> {
475    let step = ((upper[0] - lower[0]).min(upper[1] - lower[1]) * 1e-2).max(1e-9);
476    let found = ogeom_math::minimize_local(f, &start, &lower, &upper, step, 1e-15, 2000)?;
477    Ok(found
478        .value
479        .is_finite()
480        .then(|| [found.point[0], found.point[1]]))
481}
482
483fn distance_to_segment(p: Point2, a: Point2, b: Point2) -> f64 {
484    let (dx, dy) = (b.x - a.x, b.y - a.y);
485    let squared = dx * dx + dy * dy;
486    let t = if squared > 0.0 {
487        (((p.x - a.x) * dx + (p.y - a.y) * dy) / squared).clamp(0.0, 1.0)
488    } else {
489        0.0
490    };
491    (p.x - a.x - dx * t).hypot(p.y - a.y - dy * t)
492}
493
494fn in_triangle(t: [Point2; 3], p: Point2) -> bool {
495    let cross =
496        |a: Point2, b: Point2, c: Point2| (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
497    let (d1, d2, d3) = (
498        cross(t[0], t[1], p),
499        cross(t[1], t[2], p),
500        cross(t[2], t[0], p),
501    );
502    let eps = 1e-12;
503    let neg = d1 < -eps || d2 < -eps || d3 < -eps;
504    let pos = d1 > eps || d2 > eps || d3 > eps;
505    !(neg && pos)
506}