Skip to main content

ogeom_algo/
primitive.rs

1//! Primitive solids.
2//!
3//! Built from numbers rather than from existing topology, so their history has
4//! nothing to report, but their *provenance* very much does. Each face gets a
5//! role naming which face of the primitive it is ([`roles`]), so a reference to
6//! "the top of that box" survives the box being rebuilt at a different size.
7//! Without it the reference would be to a handle that no longer exists.
8
9use core::f64::consts::{FRAC_PI_2, PI, TAU};
10
11use ogeom_core::{OgeomResult, Role, Tolerances, ogeom_bail};
12use ogeom_geom::{
13    CircleCurve, Curve, LineCurve, PlanarCurve, PlaneSurface, SphereSurface, Surface,
14};
15use ogeom_math::{
16    Circle, Cone, Cylinder, Direction, Direction2, Frame, Plane, Point, Point2, Sphere, Torus,
17};
18use ogeom_topo::{Model, Shape, ShapeType};
19
20use crate::build::{make_edge_between, make_face_on, make_shell, make_solid, make_wire};
21use crate::history::Built;
22use ogeom_topo::{EdgeData, VertexData};
23
24/// Roles naming which part of a primitive an entity is.
25pub mod roles {
26    use ogeom_core::Role;
27
28    /// The face at the low end of the frame's `x` axis.
29    pub const FACE_MIN_X: Role = Role::op_defined(10);
30    /// The face at the high end of the frame's `x` axis.
31    pub const FACE_MAX_X: Role = Role::op_defined(11);
32    /// The face at the low end of the frame's `y` axis.
33    pub const FACE_MIN_Y: Role = Role::op_defined(12);
34    /// The face at the high end of the frame's `y` axis.
35    pub const FACE_MAX_Y: Role = Role::op_defined(13);
36    /// The face at the low end of the frame's `z` axis.
37    pub const FACE_MIN_Z: Role = Role::op_defined(14);
38    /// The face at the high end of the frame's `z` axis.
39    pub const FACE_MAX_Z: Role = Role::op_defined(15);
40    /// The face swept around the frame's `z` axis: a cylinder's side, a
41    /// sphere's whole surface, a cone's flank.
42    pub const FACE_LATERAL: Role = Role::op_defined(16);
43}
44
45/// The eight corners of a box, indexed so that bit 0 is `x`, bit 1 is `y` and
46/// bit 2 is `z`.
47const CORNERS: [(usize, usize, usize); 8] = [
48    (0, 0, 0),
49    (1, 0, 0),
50    (1, 1, 0),
51    (0, 1, 0),
52    (0, 0, 1),
53    (1, 0, 1),
54    (1, 1, 1),
55    (0, 1, 1),
56];
57
58/// The twelve edges, as corner index pairs in a canonical direction.
59const EDGES: [(usize, usize); 12] = [
60    (0, 1),
61    (1, 2),
62    (2, 3),
63    (3, 0), // bottom ring
64    (4, 5),
65    (5, 6),
66    (6, 7),
67    (7, 4), // top ring
68    (0, 4),
69    (1, 5),
70    (2, 6),
71    (3, 7), // verticals
72];
73
74/// The six faces, each as four corner indices wound counter-clockwise *seen
75/// from outside*, which is what makes every face normal point outward and the
76/// shell consistently oriented.
77///
78/// Getting a winding backwards produces a solid that is inside out along one
79/// face. Nothing about the geometry says so; the first thing to notice is a
80/// volume that comes out negative, or a boolean that keeps the wrong side.
81const FACES: [([usize; 4], Role); 6] = [
82    ([0, 3, 2, 1], roles::FACE_MIN_Z),
83    ([4, 5, 6, 7], roles::FACE_MAX_Z),
84    ([0, 1, 5, 4], roles::FACE_MIN_Y),
85    ([2, 3, 7, 6], roles::FACE_MAX_Y),
86    ([0, 4, 7, 3], roles::FACE_MIN_X),
87    ([1, 2, 6, 5], roles::FACE_MAX_X),
88];
89
90/// Build an axis-aligned box in `frame`, spanning `size` along each of its
91/// axes from the frame's origin.
92///
93/// # Errors
94///
95/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if any dimension
96/// is not finite and positive.
97pub fn make_box(
98    model: &mut Model,
99    frame: Frame,
100    size: (f64, f64, f64),
101    tol: Tolerances,
102) -> OgeomResult<Built> {
103    let (dx, dy, dz) = size;
104    for (name, value) in [("x", dx), ("y", dy), ("z", dz)] {
105        if !value.is_finite() || value <= tol.confusion() {
106            ogeom_bail!(
107                Construction,
108                "box {name} size {value} must be finite and positive"
109            );
110        }
111    }
112    model.begin_operation();
113
114    let extent = [dx, dy, dz];
115    let corner_points: Vec<Point> = CORNERS
116        .iter()
117        .map(|&(i, j, k)| {
118            #[allow(clippy::cast_precision_loss)]
119            let local = [
120                i as f64 * extent[0],
121                j as f64 * extent[1],
122                k as f64 * extent[2],
123            ];
124            frame.to_world(Point::new(local[0], local[1], local[2]))
125        })
126        .collect();
127
128    box_like(model, &corner_points, tol)
129}
130
131/// A parallelepiped: the solid spanned at `origin` by three edge vectors.
132///
133/// A box whose edges need not be square to each other: a corner tool's
134/// block at an oblique vertex, a sheared block to stand one on. The three
135/// vectors may come in either handedness; the layout is wound so the faces
136/// look outward whichever way they came.
137///
138/// # Errors
139///
140/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a
141/// vector is not finite or the three are coplanar.
142pub fn make_parallelepiped(
143    model: &mut Model,
144    origin: Point,
145    edges: [ogeom_math::Vector; 3],
146    tol: Tolerances,
147) -> OgeomResult<Built> {
148    let volume = edges[0].cross(edges[1]).dot(edges[2]);
149    if !volume.is_finite() || volume.abs() <= tol.confusion() {
150        ogeom_bail!(
151            Construction,
152            "a parallelepiped needs three edges that span a volume"
153        );
154    }
155    let [a, b, c] = if volume > 0.0 {
156        edges
157    } else {
158        [edges[1], edges[0], edges[2]]
159    };
160    model.begin_operation();
161    let corner_points: Vec<Point> = CORNERS
162        .iter()
163        .map(|&(i, j, k)| {
164            #[allow(clippy::cast_precision_loss)]
165            let at = origin + a * (i as f64) + b * (j as f64) + c * (k as f64);
166            at
167        })
168        .collect();
169    box_like(model, &corner_points, tol)
170}
171
172/// A hexahedron: the solid on eight corners laid out like a box's, whose
173/// six faces are planar. The corners come in a box's order: the four of
174/// the bottom face counter-clockwise from the origin corner (`(0,0,0)`,
175/// `(1,0,0)`, `(1,1,0)`, `(0,1,0)`), then the four above them in the same
176/// order.
177///
178/// A box and a parallelepiped are the square and sheared cases; a corner
179/// tool's block at an oblique vertex, bounded by the three host planes and
180/// three planes through the ball's centre, is the general one. Each face's
181/// four corners must be coplanar; a corner off its face's plane is refused.
182///
183/// # Errors
184///
185/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a
186/// corner is not finite, a face's four corners are not coplanar, or the
187/// corners span no volume.
188pub fn make_hexahedron(
189    model: &mut Model,
190    corners: [Point; 8],
191    tol: Tolerances,
192) -> OgeomResult<Built> {
193    for (face, _) in FACES {
194        let [a, b, c, d] = [
195            corners[face[0]],
196            corners[face[1]],
197            corners[face[2]],
198            corners[face[3]],
199        ];
200        let n = (b - a).cross(c - a);
201        let m = n.magnitude();
202        if !m.is_finite() || m <= tol.confusion() {
203            ogeom_bail!(Construction, "a hexahedron's face has no area");
204        }
205        let off = ((d - a).dot(n) / m).abs();
206        if off > tol.confusion() * 10.0 {
207            ogeom_bail!(
208                Construction,
209                "a hexahedron's face is not planar; its fourth corner sits {off} off"
210            );
211        }
212    }
213    let volume = (corners[1] - corners[0])
214        .cross(corners[3] - corners[0])
215        .dot(corners[4] - corners[0]);
216    if !volume.is_finite() || volume.abs() <= tol.confusion() {
217        ogeom_bail!(Construction, "a hexahedron's corners span no volume");
218    }
219    // Wound like a parallelepiped: the first two edges swapped when the
220    // three come left-handed, so the faces look outward either way.
221    let ordered: Vec<Point> = if volume > 0.0 {
222        corners.to_vec()
223    } else {
224        CORNERS
225            .iter()
226            .map(|&(i, j, k)| {
227                let at = CORNERS.iter().position(|&c| c == (j, i, k)).unwrap_or(0);
228                corners[at]
229            })
230            .collect()
231    };
232    model.begin_operation();
233    box_like(model, &ordered, tol)
234}
235
236/// A convex solid from planar rings over shared points.
237///
238/// `rings` name the faces, each a loop of indices into `points` in either
239/// winding: the builder winds every ring outward itself, judged against the
240/// centroid of all the points, which is what makes the solid's convexity a
241/// requirement rather than a courtesy. Every ring must be planar, and every
242/// edge (a pair of consecutive points on a ring) must be shared by exactly
243/// two rings, or the shell would not close. The corner tool's block at an
244/// N-edged vertex is one: the N host planes and, through the ball's centre,
245/// the N planes square to the edges.
246///
247/// # Errors
248///
249/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a
250/// point is not finite, a ring names fewer than three points or a point out
251/// of range, a ring is not planar, an edge is not shared by exactly two
252/// rings, or the rings span no volume.
253pub fn make_polyhedron(
254    model: &mut Model,
255    points: &[Point],
256    rings: &[Vec<usize>],
257    tol: Tolerances,
258) -> OgeomResult<Built> {
259    if points.len() < 4 || rings.len() < 4 {
260        ogeom_bail!(
261            Construction,
262            "a polyhedron has at least four points and four faces"
263        );
264    }
265    if points
266        .iter()
267        .any(|p| !p.to_vector().magnitude().is_finite())
268    {
269        ogeom_bail!(Construction, "a polyhedron's point is not finite");
270    }
271    let mut centroid = ogeom_math::Vector::ZERO;
272    for p in points {
273        centroid += p.to_vector();
274    }
275    let centroid =
276        Point::ORIGIN + centroid / f64::from(u32::try_from(points.len()).unwrap_or(u32::MAX));
277    let mut wound: Vec<Vec<usize>> = Vec::with_capacity(rings.len());
278    let mut uses: std::collections::HashMap<(usize, usize), usize> =
279        std::collections::HashMap::new();
280    for ring in rings {
281        if ring.len() < 3 {
282            ogeom_bail!(
283                Construction,
284                "a polyhedron's face has fewer than three corners"
285            );
286        }
287        if ring.iter().any(|&i| i >= points.len()) {
288            ogeom_bail!(
289                Construction,
290                "a polyhedron's face names a point it does not have"
291            );
292        }
293        let [a, b, c] = [points[ring[0]], points[ring[1]], points[ring[2]]];
294        let n = (b - a).cross(c - b);
295        let m = n.magnitude();
296        if !m.is_finite() || m <= tol.confusion() {
297            ogeom_bail!(Construction, "a polyhedron's face has no area");
298        }
299        let n = n / m;
300        let mut mid = ogeom_math::Vector::ZERO;
301        for &i in ring {
302            let off = (points[i] - a).dot(n).abs();
303            if off > tol.confusion() * 10.0 {
304                ogeom_bail!(
305                    Construction,
306                    "a polyhedron's face is not planar; a corner sits {off} off"
307                );
308            }
309            mid += points[i].to_vector();
310        }
311        let mid = Point::ORIGIN + mid / f64::from(u32::try_from(ring.len()).unwrap_or(u32::MAX));
312        let outward = n.dot(mid - centroid) > 0.0;
313        // Reversed about its first point, so the ring's chart keeps the
314        // origin it was named with.
315        let ring: Vec<usize> = if outward {
316            ring.clone()
317        } else {
318            std::iter::once(ring[0])
319                .chain(ring[1..].iter().rev().copied())
320                .collect()
321        };
322        for step in 0..ring.len() {
323            let (from, to) = (ring[step], ring[(step + 1) % ring.len()]);
324            if from == to {
325                ogeom_bail!(Construction, "a polyhedron's face repeats a corner");
326            }
327            *uses.entry((from.min(to), from.max(to))).or_insert(0) += 1;
328        }
329        wound.push(ring);
330    }
331    if let Some((edge, count)) = uses.iter().find(|(_, count)| **count != 2) {
332        ogeom_bail!(
333            Construction,
334            "a polyhedron's edge {edge:?} is used by {count} faces, not two; the shell would not close"
335        );
336    }
337    // The volume by divergence over the outward rings: none, and the rings
338    // are flat or their windings disagree.
339    let mut volume = 0.0;
340    for ring in &wound {
341        let a = points[ring[0]];
342        for step in 1..ring.len() - 1 {
343            let (b, c) = (points[ring[step]], points[ring[step + 1]]);
344            volume += (a - centroid).dot((b - centroid).cross(c - centroid));
345        }
346    }
347    if volume.is_nan() || volume / 6.0 <= tol.confusion() {
348        ogeom_bail!(Construction, "a polyhedron's faces span no volume");
349    }
350    let borrowed: Vec<&[usize]> = wound.iter().map(Vec::as_slice).collect();
351    model.begin_operation();
352    faceted_solid(model, points, &borrowed, tol)
353}
354
355/// Build a solid from eight corners laid out like [`CORNERS`], with the six
356/// faces of [`FACES`].
357///
358/// A box and a wedge differ only in where the corners are: both have the same
359/// eight, the same twelve edges and the same six planar faces. Sharing the
360/// construction is not just less code; it is what keeps the two from drifting
361/// apart in their winding, their roles or their pcurves.
362fn box_like(model: &mut Model, corner_points: &[Point], tol: Tolerances) -> OgeomResult<Built> {
363    let vertices: Vec<Shape> = corner_points
364        .iter()
365        .map(|p| model.add_vertex(ogeom_topo::VertexData::new(*p)))
366        .collect();
367
368    // One edge per pair, shared by the two faces that meet along it. Building
369    // an edge per face instead would leave every edge used once, and the shell
370    // would not close.
371    let mut edges = Vec::with_capacity(EDGES.len());
372    for &(from, to) in &EDGES {
373        let curve: Curve = LineCurve::segment(corner_points[from], corner_points[to], tol)?.into();
374        let length = corner_points[from].distance(corner_points[to]);
375        edges.push(
376            make_edge_between(
377                model,
378                curve,
379                (0.0, length),
380                &vertices[from],
381                &vertices[to],
382                tol,
383            )?
384            .shape,
385        );
386    }
387
388    let mut faces = Vec::with_capacity(FACES.len());
389    for (corners, role) in FACES {
390        let plane = face_plane(corner_points, corners, tol)?;
391        // One id for this face's surface, shared by the face and by every
392        // pcurve on it. Registering it per pcurve would give each its own id,
393        // and the face would find no pcurve on itself.
394        let surface = model
395            .geometry_mut()
396            .add_surface(PlaneSurface::new(plane).into());
397        let mut ring = Vec::with_capacity(4);
398        for step in 0..4 {
399            let (from, to) = (corners[step], corners[(step + 1) % 4]);
400            let (index, forward) = find_edge(from, to)?;
401            ring.push(if forward {
402                edges[index].clone()
403            } else {
404                edges[index].reversed()
405            });
406
407            // The pcurve follows the edge's own parameterization, not the
408            // face's traversal of it, so the two representations agree on what
409            // a parameter means. Following the traversal instead would leave a
410            // reversed edge's pcurve running backwards against its curve.
411            let (canonical_from, canonical_to) = EDGES[index];
412            attach_plane_pcurve(
413                model,
414                &edges[index],
415                &plane,
416                surface,
417                corner_points[canonical_from],
418                corner_points[canonical_to],
419                tol,
420            )?;
421        }
422
423        let wire = make_wire(model, &ring, tol)?.shape;
424        let face = make_face_on(model, surface, std::slice::from_ref(&wire), tol)?.shape;
425        model.set_derived(&face, &[], role)?;
426        faces.push(face);
427    }
428
429    let shell = make_shell(model, &faces)?.shape;
430    let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
431    Ok(Built::from_nothing(solid))
432}
433
434/// A solid from explicit planar rings over shared corners.
435///
436/// The generalization `box_like` is a special case of: vertices per corner,
437/// one edge per corner pair shared by both faces that meet along it, each
438/// ring wound counter-clockwise seen from outside so its first three corners
439/// give the outward normal. Every ring must be planar; the collapsed wedges
440/// are, by construction.
441fn faceted_solid(
442    model: &mut Model,
443    points: &[Point],
444    rings: &[&[usize]],
445    tol: Tolerances,
446) -> OgeomResult<Built> {
447    let vertices: Vec<Shape> = points
448        .iter()
449        .map(|p| model.add_vertex(ogeom_topo::VertexData::new(*p)))
450        .collect();
451    let mut edge_of: std::collections::HashMap<(usize, usize), Shape> =
452        std::collections::HashMap::new();
453    let mut faces = Vec::with_capacity(rings.len());
454    for ring_corners in rings {
455        let origin = points[ring_corners[0]];
456        let normal = Direction::from_cross(
457            points[ring_corners[1]] - origin,
458            points[ring_corners[2]] - points[ring_corners[1]],
459            tol,
460        )?;
461        let x = Direction::new(points[ring_corners[1]] - origin, tol)?;
462        let plane = Plane::new(Frame::new(origin, normal, x, tol)?);
463        let surface = model
464            .geometry_mut()
465            .add_surface(PlaneSurface::new(plane).into());
466        let mut ring = Vec::with_capacity(ring_corners.len());
467        for step in 0..ring_corners.len() {
468            let (from, to) = (
469                ring_corners[step],
470                ring_corners[(step + 1) % ring_corners.len()],
471            );
472            let key = (from.min(to), from.max(to));
473            let edge = match edge_of.get(&key) {
474                Some(edge) => edge.clone(),
475                None => {
476                    let curve: Curve =
477                        LineCurve::segment(points[key.0], points[key.1], tol)?.into();
478                    let length = points[key.0].distance(points[key.1]);
479                    let edge = make_edge_between(
480                        model,
481                        curve,
482                        (0.0, length),
483                        &vertices[key.0],
484                        &vertices[key.1],
485                        tol,
486                    )?
487                    .shape;
488                    edge_of.insert(key, edge.clone());
489                    edge
490                }
491            };
492            // The pcurve follows the edge's own parameterization (its
493            // canonical low-to-high corner order), not the ring's traversal.
494            attach_plane_pcurve(
495                model,
496                &edge,
497                &plane,
498                surface,
499                points[key.0],
500                points[key.1],
501                tol,
502            )?;
503            ring.push(if from == key.0 { edge } else { edge.reversed() });
504        }
505        let wire = make_wire(model, &ring, tol)?.shape;
506        faces.push(make_face_on(model, surface, std::slice::from_ref(&wire), tol)?.shape);
507    }
508    let shell = make_shell(model, &faces)?.shape;
509    let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
510    Ok(Built::from_nothing(solid))
511}
512
513/// The plane of a box face, with its normal pointing outward.
514fn face_plane(points: &[Point], corners: [usize; 4], tol: Tolerances) -> OgeomResult<Plane> {
515    let origin = points[corners[0]];
516    // The winding is counter-clockwise seen from outside, so the right-hand
517    // rule over the first three corners gives the outward normal.
518    let normal = Direction::from_cross(
519        points[corners[1]] - origin,
520        points[corners[2]] - points[corners[1]],
521        tol,
522    )?;
523    let x = Direction::new(points[corners[1]] - origin, tol)?;
524    Ok(Plane::new(Frame::new(origin, normal, x, tol)?))
525}
526
527/// Attach a line pcurve running between two points, expressed in a plane's
528/// parameter space.
529fn attach_plane_pcurve(
530    model: &mut Model,
531    edge: &Shape,
532    plane: &Plane,
533    surface: ogeom_topo::SurfaceId,
534    from: Point,
535    to: Point,
536    tol: Tolerances,
537) -> OgeomResult<()> {
538    let local = |p: Point| {
539        let l = plane.frame().to_local(p);
540        Point2::new(l.x, l.y)
541    };
542    let (a, b) = (local(from), local(to));
543    let pcurve: PlanarCurve = ogeom_geom::Line2d::segment(a, b, tol)?.into();
544    crate::build::attach_pcurve(
545        model,
546        edge,
547        pcurve,
548        surface,
549        ogeom_topo::Location::identity(),
550        (0.0, a.distance(b)),
551    )
552}
553
554/// Find the canonical edge joining two corners, and whether it runs that way.
555fn find_edge(from: usize, to: usize) -> OgeomResult<(usize, bool)> {
556    for (index, &(a, b)) in EDGES.iter().enumerate() {
557        if a == from && b == to {
558            return Ok((index, true));
559        }
560        if a == to && b == from {
561            return Ok((index, false));
562        }
563    }
564    ogeom_bail!(
565        Construction,
566        "corners {from} and {to} are not joined by a box edge"
567    )
568}
569
570/// Build a cylinder in `frame`: `radius` about its `z`, `height` along it.
571///
572/// # Errors
573///
574/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if either
575/// dimension is not finite and positive.
576pub fn make_cylinder(
577    model: &mut Model,
578    frame: Frame,
579    radius: f64,
580    height: f64,
581    tol: Tolerances,
582) -> OgeomResult<Built> {
583    check_size("cylinder radius", radius, tol)?;
584    check_size("cylinder height", height, tol)?;
585    model.begin_operation();
586
587    let top_frame = raised(frame, height, tol)?;
588    let bottom_circle = Circle::new(frame, radius, tol)?;
589    let top_circle = Circle::new(top_frame, radius, tol)?;
590
591    // One vertex on each rim, where the seam meets it. A full circle has to be
592    // bounded somewhere or it cannot join a wire, and putting the bound on the
593    // seam is what lets the lateral face close.
594    let low = model.add_vertex(VertexData::new(rim_point(bottom_circle)));
595    let high = model.add_vertex(VertexData::new(rim_point(top_circle)));
596
597    let bottom_edge = full_circle_edge(model, bottom_circle, &low, tol)?;
598    let top_edge = full_circle_edge(model, top_circle, &high, tol)?;
599    let seam = make_edge_between(
600        model,
601        LineCurve::segment(rim_point(bottom_circle), rim_point(top_circle), tol)?.into(),
602        (0.0, height),
603        &low,
604        &high,
605        tol,
606    )?
607    .shape;
608
609    let lateral_id = model.geometry_mut().add_surface(
610        ogeom_geom::CylinderSurface::new(Cylinder::new(frame, radius, tol)?, (0.0, height))?.into(),
611    );
612    let lateral = rectangle_face(
613        model,
614        lateral_id,
615        (TAU, height),
616        [&bottom_edge, &top_edge, &seam],
617        tol,
618    )?;
619    model.set_derived(&lateral, &[], roles::FACE_LATERAL)?;
620
621    let bottom = cap_face(model, frame, false, &bottom_edge, bottom_circle, tol)?;
622    model.set_derived(&bottom, &[], roles::FACE_MIN_Z)?;
623    let top = cap_face(model, top_frame, true, &top_edge, top_circle, tol)?;
624    model.set_derived(&top, &[], roles::FACE_MAX_Z)?;
625
626    let shell = make_shell(model, &[lateral, bottom, top])?.shape;
627    let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
628    Ok(Built::from_nothing(solid))
629}
630
631/// Build a sphere of `radius` centred at `frame`'s origin.
632///
633/// # Errors
634///
635/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `radius` is not
636/// finite and positive.
637pub fn make_sphere(
638    model: &mut Model,
639    frame: Frame,
640    radius: f64,
641    tol: Tolerances,
642) -> OgeomResult<Built> {
643    check_size("sphere radius", radius, tol)?;
644    model.begin_operation();
645
646    let centre = frame.origin();
647    let south = model.add_vertex(VertexData::new(centre - frame.z().vector() * radius));
648    let north = model.add_vertex(VertexData::new(centre + frame.z().vector() * radius));
649
650    // The seam is the meridian at longitude zero, pole to pole through the
651    // frame's `x`. Its plane is spanned by `x` and `z`, so the circle's normal
652    // is `-y`, which makes its angle parameter the latitude exactly, and the
653    // mapping onto the surface's `v` the identity rather than a rescaling.
654    let meridian = Circle::new(Frame::new(centre, -frame.y(), frame.x(), tol)?, radius, tol)?;
655    let seam = make_edge_between(
656        model,
657        CircleCurve::new(meridian).into(),
658        (-FRAC_PI_2, FRAC_PI_2),
659        &south,
660        &north,
661        tol,
662    )?
663    .shape;
664
665    // The poles bound the face in parameter space and have no length in space.
666    // Dropping them would leave the boundary open along the top and bottom of
667    // the parameter rectangle, with nothing for the triangulator to trim to.
668    let bottom_edge = degenerate_edge(model, &south, tol)?;
669    let top_edge = degenerate_edge(model, &north, tol)?;
670
671    let surface = model
672        .geometry_mut()
673        .add_surface(SphereSurface::new(Sphere::new(frame, radius, tol)?).into());
674    let face = rectangle_face(
675        model,
676        surface,
677        (TAU, PI),
678        [&bottom_edge, &top_edge, &seam],
679        tol,
680    )?;
681    // The sphere's `v` runs from -pi/2, not from zero, so the rectangle's
682    // corner is not at the parameter origin.
683    model.set_derived(&face, &[], roles::FACE_LATERAL)?;
684
685    let shell = make_shell(model, std::slice::from_ref(&face))?.shape;
686    let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
687    Ok(Built::from_nothing(solid))
688}
689
690/// Where a circle's own parameterization starts: its frame's `x`, one radius
691/// out. The seam has to meet the rim exactly there, not merely nearby.
692fn rim_point(circle: Circle) -> Point {
693    circle.centre() + circle.frame().x().vector() * circle.radius()
694}
695
696/// A frame moved along its own `z`.
697fn raised(frame: Frame, distance: f64, tol: Tolerances) -> OgeomResult<Frame> {
698    Frame::new(
699        frame.to_world(Point::new(0.0, 0.0, distance)),
700        frame.z(),
701        frame.x(),
702        tol,
703    )
704}
705
706/// Reject a dimension that cannot describe a solid.
707fn check_size(what: &str, value: f64, tol: Tolerances) -> OgeomResult<()> {
708    if !value.is_finite() || value <= tol.confusion() {
709        ogeom_bail!(Construction, "{what} {value} must be finite and positive");
710    }
711    Ok(())
712}
713
714/// A closed circular edge, bounded twice by the same vertex.
715fn full_circle_edge(
716    model: &mut Model,
717    circle: Circle,
718    at: &Shape,
719    tol: Tolerances,
720) -> OgeomResult<Shape> {
721    Ok(make_edge_between(
722        model,
723        CircleCurve::new(circle).into(),
724        (0.0, TAU),
725        at,
726        at,
727        tol,
728    )?
729    .shape)
730}
731
732/// An edge with no length, bounded twice by the same vertex.
733///
734/// A pole or an apex: it bounds a face in parameter space and collapses to a
735/// point in space. It carries no 3D curve, because there is no curve to carry;
736/// its pcurve is the whole story, and the `degenerate` flag says so rather than
737/// leaving a caller to notice the missing representation.
738fn degenerate_edge(model: &mut Model, at: &Shape, tol: Tolerances) -> OgeomResult<Shape> {
739    let _ = tol;
740    let mut data = EdgeData::new();
741    data.degenerate = true;
742    model.add_edge(data, &[at.clone(), at.clone()])
743}
744
745/// A face covering a rectangle of a surface's parameter space, bounded by two
746/// edges across and one seam up both sides.
747///
748/// The shape every surface of revolution has: `u` closes on itself, so the face
749/// is a rectangle whose left and right sides are the *same* edge seen twice.
750/// That edge carries two pcurves and appears in the wire twice, once each way.
751///
752/// `extent` is the size of the rectangle; its lower corner comes from the
753/// surface's own domain, so a sphere's `v` starting at `-pi/2` needs no special
754/// case here.
755fn rectangle_face(
756    model: &mut Model,
757    surface: ogeom_topo::SurfaceId,
758    extent: (f64, f64),
759    edges: [&Shape; 3],
760    tol: Tolerances,
761) -> OgeomResult<Shape> {
762    let [bottom, top, seam] = edges;
763    let Some(geometry) = model.geometry().surface(surface) else {
764        ogeom_bail!(Dangling, "surface is not in this model");
765    };
766    let ((ua, _), (va, _)) = geometry.domain();
767    let (du, dv) = extent;
768    let (ub, vb) = (ua + du, va + dv);
769
770    line_pcurve(
771        model,
772        bottom,
773        surface,
774        Point2::new(ua, va),
775        Point2::new(ub, va),
776        tol,
777    )?;
778    line_pcurve(
779        model,
780        top,
781        surface,
782        Point2::new(ua, vb),
783        Point2::new(ub, vb),
784        tol,
785    )?;
786    seam_pcurves(
787        model,
788        seam,
789        surface,
790        (Point2::new(ub, va), Point2::new(ub, vb)),
791        (Point2::new(ua, va), Point2::new(ua, vb)),
792        tol,
793    )?;
794
795    // Counter-clockwise around the rectangle: across the bottom, up the far
796    // side of the seam, back across the top, down the near side.
797    let ring = [
798        bottom.clone(),
799        seam.clone(),
800        top.reversed(),
801        seam.reversed(),
802    ];
803    let wire = make_wire(model, &ring, tol)?.shape;
804    Ok(make_face_on(model, surface, std::slice::from_ref(&wire), tol)?.shape)
805}
806
807/// A planar cap closing one end of a solid of revolution.
808///
809/// `outward` says whether the cap's normal follows the frame's `z` or opposes
810/// it, which is the difference between a solid and one that is inside out
811/// along one face, and nothing in the geometry says which was meant.
812fn cap_face(
813    model: &mut Model,
814    frame: Frame,
815    outward: bool,
816    rim: &Shape,
817    circle: Circle,
818    tol: Tolerances,
819) -> OgeomResult<Shape> {
820    let normal = if outward { frame.z() } else { -frame.z() };
821    let plane = Plane::new(Frame::new(frame.origin(), normal, frame.x(), tol)?);
822    let surface = model
823        .geometry_mut()
824        .add_surface(PlaneSurface::new(plane).into());
825
826    circle_pcurve_on_plane(model, rim, surface, circle, plane, tol)?;
827
828    // The rim runs one way round; the cap that faces the other way walks it
829    // backwards, so its boundary is traversed consistently with its normal.
830    let edge = if outward { rim.clone() } else { rim.reversed() };
831    let wire = make_wire(model, std::slice::from_ref(&edge), tol)?.shape;
832    Ok(make_face_on(model, surface, std::slice::from_ref(&wire), tol)?.shape)
833}
834
835/// Attach a straight pcurve running between two parameter points.
836fn line_pcurve(
837    model: &mut Model,
838    edge: &Shape,
839    surface: ogeom_topo::SurfaceId,
840    from: Point2,
841    to: Point2,
842    tol: Tolerances,
843) -> OgeomResult<()> {
844    let pcurve: PlanarCurve = ogeom_geom::Line2d::segment(from, to, tol)?.into();
845    crate::build::attach_pcurve(
846        model,
847        edge,
848        pcurve,
849        surface,
850        ogeom_topo::Location::identity(),
851        (0.0, from.distance(to)),
852    )
853}
854
855/// Attach a seam edge's two pcurves, one for each side of the parameter
856/// rectangle it bounds.
857fn seam_pcurves(
858    model: &mut Model,
859    edge: &Shape,
860    surface: ogeom_topo::SurfaceId,
861    forward: (Point2, Point2),
862    reversed: (Point2, Point2),
863    tol: Tolerances,
864) -> OgeomResult<()> {
865    let length = forward.0.distance(forward.1);
866    let first = model
867        .geometry_mut()
868        .add_pcurve(ogeom_geom::Line2d::segment(forward.0, forward.1, tol)?.into());
869    let second = model
870        .geometry_mut()
871        .add_pcurve(ogeom_geom::Line2d::segment(reversed.0, reversed.1, tol)?.into());
872
873    let Some(node) = model.node_mut(edge) else {
874        ogeom_bail!(Dangling, "edge is not in this model");
875    };
876    let ogeom_topo::NodeData::Edge(data) = node.data_mut() else {
877        ogeom_bail!(Construction, "edge node holds no edge data");
878    };
879    data.add(ogeom_topo::EdgeRepr::Seam {
880        forward: first,
881        reversed: second,
882        surface,
883        location: ogeom_topo::Location::identity(),
884        range: (0.0, length),
885    });
886    Ok(())
887}
888
889/// Attach the pcurve of a circle lying in a plane.
890///
891/// Built from the circle's own axes expressed in the plane's frame, rather than
892/// from the angle alone. A cap whose normal opposes the circle's sees the same
893/// circle running the other way, and taking the axes through the conversion is
894/// what makes that fall out instead of needing a sign to be remembered.
895fn circle_pcurve_on_plane(
896    model: &mut Model,
897    edge: &Shape,
898    surface: ogeom_topo::SurfaceId,
899    circle: Circle,
900    plane: Plane,
901    tol: Tolerances,
902) -> OgeomResult<()> {
903    let frame = plane.frame();
904    let flat = |p: Point| {
905        let local = frame.to_local(p);
906        Point2::new(local.x, local.y)
907    };
908    let flat_direction = |d: Direction| -> OgeomResult<Direction2> {
909        let tip = flat(frame.origin() + d.vector());
910        let base = flat(frame.origin());
911        Direction2::new(tip - base, tol)
912    };
913
914    let frame2 = ogeom_math::Frame2::from_axes(
915        flat(circle.centre()),
916        flat_direction(circle.frame().x())?,
917        flat_direction(circle.frame().y())?,
918        tol,
919    )?;
920    let pcurve: PlanarCurve =
921        ogeom_geom::Circle2d::new(ogeom_math::Circle2::new(frame2, circle.radius(), tol)?).into();
922    crate::build::attach_pcurve(
923        model,
924        edge,
925        pcurve,
926        surface,
927        ogeom_topo::Location::identity(),
928        (0.0, TAU),
929    )
930}
931
932/// Build a cone or a truncated cone in `frame`.
933///
934/// `base_radius` is at the frame's origin and `top_radius` at `height` along
935/// its `z`. A zero top radius makes a true cone, whose apex is a degenerate
936/// edge and which has no top cap.
937///
938/// # Errors
939///
940/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the height is
941/// not positive, a radius is negative, both radii are zero, or the two radii
942/// are equal; that last is a cylinder, which is a different surface with its
943/// own type, and admitting it here would ask for a cone whose apex is at
944/// infinity.
945pub fn make_cone(
946    model: &mut Model,
947    frame: Frame,
948    base_radius: f64,
949    top_radius: f64,
950    height: f64,
951    tol: Tolerances,
952) -> OgeomResult<Built> {
953    check_size("cone height", height, tol)?;
954    for (what, r) in [("base radius", base_radius), ("top radius", top_radius)] {
955        if !r.is_finite() || r < 0.0 {
956            ogeom_bail!(
957                Construction,
958                "cone {what} {r} must be finite and non-negative"
959            );
960        }
961    }
962    if (base_radius - top_radius).abs() <= tol.confusion() {
963        ogeom_bail!(
964            Construction,
965            "a cone with equal radii is a cylinder; use make_cylinder"
966        );
967    }
968    if base_radius <= tol.confusion() && top_radius <= tol.confusion() {
969        ogeom_bail!(Construction, "a cone needs one end with a radius");
970    }
971    model.begin_operation();
972
973    // The surface is built along whichever direction it *widens*, because a
974    // cone's half angle is signed and only the widening sense is a cone at all.
975    // A narrowing solid therefore gets a surface frame pointing the other way,
976    // and the rest of this function works in that frame's parameters.
977    let widening = top_radius > base_radius;
978    let (surface_frame, near_radius, far_radius) = if widening {
979        (frame, base_radius, top_radius)
980    } else {
981        (flipped(frame, height, tol)?, top_radius, base_radius)
982    };
983    let half_angle = ((far_radius - near_radius) / height).atan();
984    let cone = Cone::new(surface_frame, near_radius, half_angle, tol)?;
985
986    let near_circle = circle_at(surface_frame, near_radius, 0.0, tol);
987    let far_frame = raised(surface_frame, height, tol)?;
988    // The wide end always has a radius: the two radii differ and the wider is
989    // by definition not the collapsed one.
990    let Some(far) = circle_at(far_frame, far_radius, 0.0, tol) else {
991        ogeom_bail!(Construction, "the wide end of a cone must have a radius");
992    };
993
994    let near_vertex = model.add_vertex(VertexData::new(match near_circle {
995        Some(c) => rim_point(c),
996        None => surface_frame.origin(),
997    }));
998    let far_vertex = model.add_vertex(VertexData::new(rim_point(far)));
999
1000    // A zero radius is an apex: a rim of no length, which still bounds the face
1001    // in parameter space and still has to be there.
1002    let near_edge = match near_circle {
1003        Some(c) => full_circle_edge(model, c, &near_vertex, tol)?,
1004        None => degenerate_edge(model, &near_vertex, tol)?,
1005    };
1006    let far_edge = full_circle_edge(model, far, &far_vertex, tol)?;
1007
1008    let seam_start = near_circle.map_or_else(|| surface_frame.origin(), rim_point);
1009    let seam_end = rim_point(far);
1010    let seam = make_edge_between(
1011        model,
1012        LineCurve::segment(seam_start, seam_end, tol)?.into(),
1013        (0.0, slant(near_radius, far_radius, height)),
1014        &near_vertex,
1015        &far_vertex,
1016        tol,
1017    )?
1018    .shape;
1019
1020    let lateral_id = model
1021        .geometry_mut()
1022        .add_surface(ogeom_geom::ConeSurface::new(cone, (0.0, height))?.into());
1023    let lateral = rectangle_face(
1024        model,
1025        lateral_id,
1026        (TAU, height),
1027        [&near_edge, &far_edge, &seam],
1028        tol,
1029    )?;
1030    model.set_derived(&lateral, &[], roles::FACE_LATERAL)?;
1031
1032    let mut faces = vec![lateral];
1033    // The near end only needs a cap if it has any area.
1034    if let Some(c) = near_circle {
1035        let cap = cap_face(model, surface_frame, false, &near_edge, c, tol)?;
1036        model.set_derived(
1037            &cap,
1038            &[],
1039            if widening {
1040                roles::FACE_MIN_Z
1041            } else {
1042                roles::FACE_MAX_Z
1043            },
1044        )?;
1045        faces.push(cap);
1046    }
1047    let far_cap = cap_face(model, far_frame, true, &far_edge, far, tol)?;
1048    model.set_derived(
1049        &far_cap,
1050        &[],
1051        if widening {
1052            roles::FACE_MAX_Z
1053        } else {
1054            roles::FACE_MIN_Z
1055        },
1056    )?;
1057    faces.push(far_cap);
1058
1059    let shell = make_shell(model, &faces)?.shape;
1060    let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
1061    Ok(Built::from_nothing(solid))
1062}
1063
1064/// Build a torus in `frame`: `major` from the axis to the tube's centre,
1065/// `minor` the tube's own radius.
1066///
1067/// # Errors
1068///
1069/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if either radius
1070/// is not finite and positive.
1071pub fn make_torus(
1072    model: &mut Model,
1073    frame: Frame,
1074    major: f64,
1075    minor: f64,
1076    tol: Tolerances,
1077) -> OgeomResult<Built> {
1078    check_size("torus major radius", major, tol)?;
1079    check_size("torus minor radius", minor, tol)?;
1080    model.begin_operation();
1081
1082    // Closed in *both* directions, so the face has a seam on all four sides of
1083    // its parameter rectangle and one vertex where the two seams cross. A
1084    // cylinder's single seam is the easy case; this is the one that decides
1085    // whether seam handling is general or a special case for one primitive.
1086    let start = frame.to_world(Point::new(major + minor, 0.0, 0.0));
1087    let corner = model.add_vertex(VertexData::new(start));
1088
1089    // The outer equator, along which the tube parameter is zero.
1090    let equator = Circle::new(frame, major + minor, tol)?;
1091    let along_u = full_circle_edge(model, equator, &corner, tol)?;
1092
1093    // The tube's own circle at longitude zero: its plane holds the frame's `x`
1094    // and `z`, centred one major radius out.
1095    let tube_frame = Frame::new(
1096        frame.to_world(Point::new(major, 0.0, 0.0)),
1097        -frame.y(),
1098        frame.x(),
1099        tol,
1100    )?;
1101    let along_v = full_circle_edge(model, Circle::new(tube_frame, minor, tol)?, &corner, tol)?;
1102
1103    let surface = model
1104        .geometry_mut()
1105        .add_surface(ogeom_geom::TorusSurface::new(Torus::new(frame, major, minor, tol)?).into());
1106    let face = doubly_seamed_face(model, surface, &along_u, &along_v, tol)?;
1107    model.set_derived(&face, &[], roles::FACE_LATERAL)?;
1108
1109    let shell = make_shell(model, std::slice::from_ref(&face))?.shape;
1110    let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
1111    Ok(Built::from_nothing(solid))
1112}
1113
1114/// Build a wedge: a box whose top face is inset.
1115///
1116/// `size` is the box at the frame's origin; `top` is the `(x, y)` extent of the
1117/// upper face, over the same corner. Equal extents give a box.
1118///
1119/// Both top extents must be positive. A wedge whose top collapses to a ridge
1120/// has five faces and one whose top collapses to a point has four: different
1121/// topologies, not this one with a zero somewhere, and building them here would
1122/// produce a face with no area.
1123///
1124/// # Errors
1125///
1126/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a dimension is
1127/// not finite and positive.
1128pub fn make_wedge(
1129    model: &mut Model,
1130    frame: Frame,
1131    size: (f64, f64, f64),
1132    top: (f64, f64),
1133    tol: Tolerances,
1134) -> OgeomResult<Built> {
1135    let (dx, dy, dz) = size;
1136    for (name, value) in [("x", dx), ("y", dy), ("z", dz)] {
1137        check_size(&format!("wedge {name} size"), value, tol)?;
1138    }
1139    for (name, value) in [("x", top.0), ("y", top.1)] {
1140        if !value.is_finite() || value < 0.0 {
1141            ogeom_bail!(
1142                Construction,
1143                "wedge top {name} extent {value} must be finite and non-negative"
1144            );
1145        }
1146    }
1147    model.begin_operation();
1148
1149    // A zero top extent is a different topology, not a box with a flat face
1150    // of no area: the top collapses to a ridge (five faces) or to a point
1151    // (five faces, four of them triangles), and each is built as itself.
1152    let (dx_, dy_, dz_) = size;
1153    let collapsed = (top.0 <= tol.confusion(), top.1 <= tol.confusion());
1154    match collapsed {
1155        (true, true) => {
1156            let local = [
1157                Point::new(0.0, 0.0, 0.0),
1158                Point::new(dx_, 0.0, 0.0),
1159                Point::new(dx_, dy_, 0.0),
1160                Point::new(0.0, dy_, 0.0),
1161                Point::new(0.0, 0.0, dz_),
1162            ];
1163            let points: Vec<Point> = local.iter().map(|p| frame.to_world(*p)).collect();
1164            let rings: [&[usize]; 5] = [
1165                &[0, 3, 2, 1],
1166                &[0, 1, 4],
1167                &[1, 2, 4],
1168                &[2, 3, 4],
1169                &[3, 0, 4],
1170            ];
1171            return faceted_solid(model, &points, &rings, tol);
1172        }
1173        (false, true) => {
1174            let local = [
1175                Point::new(0.0, 0.0, 0.0),
1176                Point::new(dx_, 0.0, 0.0),
1177                Point::new(dx_, dy_, 0.0),
1178                Point::new(0.0, dy_, 0.0),
1179                Point::new(0.0, 0.0, dz_),
1180                Point::new(top.0, 0.0, dz_),
1181            ];
1182            let points: Vec<Point> = local.iter().map(|p| frame.to_world(*p)).collect();
1183            let rings: [&[usize]; 5] = [
1184                &[0, 3, 2, 1],
1185                &[0, 1, 5, 4],
1186                &[1, 2, 5],
1187                &[2, 3, 4, 5],
1188                &[3, 0, 4],
1189            ];
1190            return faceted_solid(model, &points, &rings, tol);
1191        }
1192        (true, false) => {
1193            let local = [
1194                Point::new(0.0, 0.0, 0.0),
1195                Point::new(dx_, 0.0, 0.0),
1196                Point::new(dx_, dy_, 0.0),
1197                Point::new(0.0, dy_, 0.0),
1198                Point::new(0.0, 0.0, dz_),
1199                Point::new(0.0, top.1, dz_),
1200            ];
1201            let points: Vec<Point> = local.iter().map(|p| frame.to_world(*p)).collect();
1202            let rings: [&[usize]; 5] = [
1203                &[0, 3, 2, 1],
1204                &[0, 4, 5, 3],
1205                &[0, 1, 4],
1206                &[1, 2, 5, 4],
1207                &[2, 3, 5],
1208            ];
1209            return faceted_solid(model, &points, &rings, tol);
1210        }
1211        (false, false) => {}
1212    }
1213
1214    // Same eight corners and the same six faces as a box; only where the top
1215    // four sit differs. Every side stays planar because the top face stays a
1216    // rectangle parallel to the bottom, which is what makes the wedge a
1217    // reparameterization of the box rather than a separate construction.
1218    let corners: Vec<Point> = CORNERS
1219        .iter()
1220        .map(|&(i, j, k)| {
1221            #[allow(clippy::cast_precision_loss)]
1222            let (fi, fj) = (i as f64, j as f64);
1223            let (ex, ey) = if k == 0 { (dx, dy) } else { (top.0, top.1) };
1224            #[allow(clippy::cast_precision_loss)]
1225            frame.to_world(Point::new(fi * ex, fj * ey, k as f64 * dz))
1226        })
1227        .collect();
1228    box_like(model, &corners, tol)
1229}
1230
1231/// Build the unbounded solid on one side of a face.
1232///
1233/// `inside` names the side: it is a point in the material. The face is oriented
1234/// so its normal leads *away* from that point, which is what "outward" means
1235/// for a solid, and the result is a solid bounded by that one face.
1236///
1237/// # It is only as unbounded as its surface is
1238///
1239/// A half space is genuinely infinite; a surface in this kernel is not. A plane
1240/// declares a finite domain (very large, but finite), and the solid built here
1241/// reaches exactly as far as its face's surface does. So its *volume* and its
1242/// centre of mass are properties of that declared extent rather than of a half
1243/// space, and mean nothing. What does mean something is which side of the face
1244/// a point is on, which is the question a half space exists to answer.
1245///
1246/// That is what it is for: a half space is an argument to a boolean: cut a
1247/// solid with one and you have trimmed it by a surface. `cut`, `common` and
1248/// `section` all accept one as either operand; `fuse` refuses it by name,
1249/// because an unbounded fuse has no volume to keep. Classification against it
1250/// works too and is what the tests here use.
1251///
1252/// # Errors
1253///
1254/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `face` is not a
1255/// face, or if `inside` lies on it; a point on the boundary names no side.
1256pub fn make_half_space(
1257    model: &mut Model,
1258    face: &Shape,
1259    inside: Point,
1260    tol: Tolerances,
1261) -> OgeomResult<Built> {
1262    if model.kind_of(face)? != ShapeType::Face {
1263        ogeom_bail!(Construction, "a half space is bounded by a face");
1264    }
1265    // The side is read where the surface comes nearest the point: a normal
1266    // sampled anywhere else on a closed surface (a rod's far side) can face
1267    // the point across the surface and name the wrong side.
1268    let (at, normal) = nearest_normal(model, face, inside, tol)?;
1269    let towards = inside - at;
1270    let reach = towards.magnitude();
1271    if reach <= tol.confusion() {
1272        ogeom_bail!(
1273            Construction,
1274            "the point naming the solid side lies on the face itself, so it \
1275             names no side"
1276        );
1277    }
1278    let along = normal.dot(towards) / reach;
1279    if along.abs() <= tol.angular() {
1280        ogeom_bail!(
1281            Construction,
1282            "the point naming the solid side lies in the face's own surface, so \
1283             it names no side"
1284        );
1285    }
1286    model.begin_operation();
1287
1288    // Outward means away from the material, and the material is where `inside`
1289    // is. A normal pointing towards it is pointing in.
1290    let boundary = if along > 0.0 {
1291        face.reversed()
1292    } else {
1293        face.clone()
1294    };
1295    let shell = make_shell(model, std::slice::from_ref(&boundary))?.shape;
1296    let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
1297    model.set_derived(&solid, std::slice::from_ref(face), roles::FACE_LATERAL)?;
1298
1299    let mut history = crate::history::History::new();
1300    history.generate(face, shell);
1301    history.generate(face, solid.clone());
1302    Ok(Built::new(solid, history))
1303}
1304
1305/// The point of a face's surface nearest `target`, and the face's normal
1306/// there (flipped where the face presents the surface's other side).
1307fn nearest_normal(
1308    model: &Model,
1309    face: &Shape,
1310    target: Point,
1311    tol: Tolerances,
1312) -> OgeomResult<(Point, ogeom_math::Vector)> {
1313    let Some(data) = model.node(face).and_then(|n| n.data().as_face()) else {
1314        ogeom_bail!(Construction, "face node holds no face data");
1315    };
1316    let Some(surface) = model.geometry().surface(data.surface) else {
1317        ogeom_bail!(Dangling, "face refers to a surface not in this model");
1318    };
1319    let placement = face.transform(model.datums())?;
1320    let local = placement.inverse()?.apply(target);
1321    let foot = crate::measure::project_on_surface(surface, local, 32, tol)?;
1322    let (u, v) = foot.parameters;
1323    // At a pole or an apex the surface has no normal of its own; a hair
1324    // towards the middle of the domain it has, pointing the same way out.
1325    let normal = match surface.normal_at(u, v, tol) {
1326        Ok(n) => n,
1327        Err(_) => {
1328            let ((ua, ub), (va, vb)) = surface.domain();
1329            let (mu, mv) = (f64::midpoint(ua, ub), f64::midpoint(va, vb));
1330            let nudge = |x: f64, mid: f64| x + (mid - x).signum() * 1e-6 * (1.0 + x.abs());
1331            surface.normal_at(nudge(u, mu), nudge(v, mv), tol)?
1332        }
1333    };
1334    let normal = placement.apply_vector(normal.vector());
1335    let normal = if face.orientation() == ogeom_topo::Orientation::Reversed {
1336        -normal
1337    } else {
1338        normal
1339    };
1340    Ok((placement.apply(foot.point), normal))
1341}
1342
1343/// A frame turned end for end: its origin at `height` along the old `z`, and
1344/// its `z` pointing back the way it came.
1345fn flipped(frame: Frame, height: f64, tol: Tolerances) -> OgeomResult<Frame> {
1346    Frame::new(
1347        frame.to_world(Point::new(0.0, 0.0, height)),
1348        -frame.z(),
1349        frame.x(),
1350        tol,
1351    )
1352}
1353
1354/// A circle in a frame's plane, or `None` if the radius has collapsed.
1355fn circle_at(frame: Frame, radius: f64, _at: f64, tol: Tolerances) -> Option<Circle> {
1356    if radius <= tol.confusion() {
1357        return None;
1358    }
1359    Circle::new(frame, radius, tol).ok()
1360}
1361
1362/// The slant length of a cone's side, which is what its seam edge measures.
1363fn slant(near: f64, far: f64, height: f64) -> f64 {
1364    (far - near).hypot(height)
1365}
1366
1367/// A face on a surface closed in *both* parameter directions.
1368///
1369/// A torus. Both sides of the rectangle are seams and both ends are seams, so
1370/// all four boundary edges are two edges used twice, and one vertex serves all
1371/// four corners.
1372fn doubly_seamed_face(
1373    model: &mut Model,
1374    surface: ogeom_topo::SurfaceId,
1375    along_u: &Shape,
1376    along_v: &Shape,
1377    tol: Tolerances,
1378) -> OgeomResult<Shape> {
1379    let (o, e) = (0.0, TAU);
1380    // The edge running in u is a seam in *v*: it is the same curve at v = 0 and
1381    // at v = 2pi.
1382    seam_pcurves(
1383        model,
1384        along_u,
1385        surface,
1386        (Point2::new(o, o), Point2::new(e, o)),
1387        (Point2::new(o, e), Point2::new(e, e)),
1388        tol,
1389    )?;
1390    seam_pcurves(
1391        model,
1392        along_v,
1393        surface,
1394        (Point2::new(e, o), Point2::new(e, e)),
1395        (Point2::new(o, o), Point2::new(o, e)),
1396        tol,
1397    )?;
1398
1399    let ring = [
1400        along_u.clone(),
1401        along_v.clone(),
1402        along_u.reversed(),
1403        along_v.reversed(),
1404    ];
1405    let wire = make_wire(model, &ring, tol)?.shape;
1406    Ok(make_face_on(model, surface, std::slice::from_ref(&wire), tol)?.shape)
1407}
1408
1409#[cfg(test)]
1410#[allow(clippy::unwrap_used)]
1411mod tests {
1412    use super::*;
1413    use crate::build::is_shell_closed;
1414    use ogeom_geom::Surface;
1415    use ogeom_topo::{ShapeType, explore_unique};
1416
1417    const T: Tolerances = Tolerances::millimetres();
1418
1419    #[test]
1420    fn a_box_has_the_topology_a_box_should_have() {
1421        let mut model = Model::new();
1422        let built = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T).unwrap();
1423        let solid = &built.shape;
1424
1425        assert_eq!(model.kind_of(solid).unwrap(), ShapeType::Solid);
1426        assert_eq!(
1427            explore_unique(&model, solid, ShapeType::Shell)
1428                .unwrap()
1429                .len(),
1430            1
1431        );
1432        assert_eq!(
1433            explore_unique(&model, solid, ShapeType::Face)
1434                .unwrap()
1435                .len(),
1436            6
1437        );
1438        assert_eq!(
1439            explore_unique(&model, solid, ShapeType::Wire)
1440                .unwrap()
1441                .len(),
1442            6
1443        );
1444        assert_eq!(
1445            explore_unique(&model, solid, ShapeType::Edge)
1446                .unwrap()
1447                .len(),
1448            12,
1449            "edges are shared between adjacent faces, not duplicated per face"
1450        );
1451        assert_eq!(
1452            explore_unique(&model, solid, ShapeType::Vertex)
1453                .unwrap()
1454                .len(),
1455            8
1456        );
1457    }
1458
1459    #[test]
1460    fn a_boxs_shell_is_closed() {
1461        // Every edge used by exactly two faces. Building an edge per face would
1462        // give twenty-four edges each used once, and nothing would enclose.
1463        let mut model = Model::new();
1464        let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
1465        let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap();
1466        assert!(is_shell_closed(&model, &shell[0]).unwrap());
1467    }
1468
1469    #[test]
1470    fn every_face_normal_points_out_of_the_box() {
1471        // A backwards winding gives a solid that is inside out along one face.
1472        // Nothing about the geometry says so, and the first symptom is usually
1473        // a negative volume or a boolean keeping the wrong side.
1474        let mut model = Model::new();
1475        let size = (2.0, 3.0, 4.0);
1476        let built = make_box(&mut model, Frame::WORLD, size, T).unwrap();
1477        let centre = Point::new(size.0 / 2.0, size.1 / 2.0, size.2 / 2.0);
1478
1479        let faces = explore_unique(&model, &built.shape, ShapeType::Face).unwrap();
1480        assert_eq!(faces.len(), 6);
1481        for face in &faces {
1482            let node = model.node(face).unwrap();
1483            let data = node.data().as_face().unwrap();
1484            let surface = model.geometry().surface(data.surface).unwrap();
1485            let ((ua, ub), (va, vb)) = surface.domain();
1486            let point = surface
1487                .point_at((ua + ub) / 2.0, (va + vb) / 2.0, T)
1488                .unwrap();
1489            let normal = surface
1490                .normal_at((ua + ub) / 2.0, (va + vb) / 2.0, T)
1491                .unwrap();
1492
1493            // A plane's parameter origin is a box corner, so sample at the
1494            // corner itself and check the normal leads away from the centre.
1495            let outward = surface.point_at(0.0, 0.0, T).unwrap() - centre;
1496            assert!(
1497                normal.dot_vector(outward) > 0.0,
1498                "a face normal points inward: at {point:?}, normal {normal:?}"
1499            );
1500        }
1501    }
1502
1503    #[test]
1504    fn the_six_faces_carry_distinct_roles() {
1505        // The roles are what a rebuild matches against: "the top of that box"
1506        // has to survive the box being rebuilt at a different size, and a
1507        // handle will not.
1508        let mut model = Model::new();
1509        let built = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T).unwrap();
1510        let faces = explore_unique(&model, &built.shape, ShapeType::Face).unwrap();
1511
1512        let mut roles: Vec<Role> = faces
1513            .iter()
1514            .map(|f| match model.provenance_of(f).unwrap() {
1515                ogeom_core::Provenance::Derived { role, .. } => *role,
1516                other => panic!("expected a derived face, got {other:?}"),
1517            })
1518            .collect();
1519        roles.sort_unstable();
1520        roles.dedup();
1521        assert_eq!(roles.len(), 6, "each face is identifiable on its own");
1522    }
1523
1524    #[test]
1525    fn rebuilding_at_a_different_size_gives_the_faces_the_same_roles() {
1526        // The point of provenance: a reference to a face survives a parameter
1527        // change, because the role is what identifies it and the role does not
1528        // depend on the size.
1529        let roles_of = |size| {
1530            let mut model = Model::new();
1531            let built = make_box(&mut model, Frame::WORLD, size, T).unwrap();
1532            let mut roles: Vec<Role> = explore_unique(&model, &built.shape, ShapeType::Face)
1533                .unwrap()
1534                .iter()
1535                .map(|f| match model.provenance_of(f).unwrap() {
1536                    ogeom_core::Provenance::Derived { role, .. } => *role,
1537                    other => panic!("expected a derived face, got {other:?}"),
1538                })
1539                .collect();
1540            roles.sort_unstable();
1541            roles
1542        };
1543        assert_eq!(roles_of((1.0, 1.0, 1.0)), roles_of((10.0, 0.5, 7.0)));
1544    }
1545
1546    #[test]
1547    fn every_edge_carries_a_pcurve_for_each_face_it_bounds() {
1548        // Face splitting during a boolean happens in parameter space, so an
1549        // edge without a pcurve on a face is an edge that face cannot be split
1550        // along.
1551        let mut model = Model::new();
1552        let built = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T).unwrap();
1553        for edge in explore_unique(&model, &built.shape, ShapeType::Edge).unwrap() {
1554            let data = model.node(&edge).unwrap().data().as_edge().unwrap();
1555            assert_eq!(
1556                data.parametric_surfaces().len(),
1557                2,
1558                "a box edge borders exactly two faces"
1559            );
1560            assert!(data.curve3d().is_some());
1561        }
1562    }
1563
1564    #[test]
1565    fn a_parallelepiped_spans_its_triple_product_in_either_handedness() {
1566        use ogeom_math::Vector;
1567        let edges = [
1568            Vector::new(10.0, 0.0, 0.0),
1569            Vector::new(3.0, 10.0, 0.0),
1570            Vector::new(2.0, 1.0, 10.0),
1571        ];
1572        for order in [[0, 1, 2], [1, 0, 2]] {
1573            let mut model = Model::new();
1574            let spanned = [edges[order[0]], edges[order[1]], edges[order[2]]];
1575            let solid = make_parallelepiped(&mut model, Point::new(1.0, 2.0, 3.0), spanned, T)
1576                .unwrap()
1577                .shape;
1578            assert_eq!(model.kind_of(&solid).unwrap(), ShapeType::Solid);
1579            let faces = explore_unique(&model, &solid, ShapeType::Face).unwrap();
1580            assert_eq!(faces.len(), 6);
1581            assert_eq!(
1582                explore_unique(&model, &solid, ShapeType::Edge)
1583                    .unwrap()
1584                    .len(),
1585                12
1586            );
1587            assert_eq!(
1588                explore_unique(&model, &solid, ShapeType::Vertex)
1589                    .unwrap()
1590                    .len(),
1591                8
1592            );
1593            let shell = explore_unique(&model, &solid, ShapeType::Shell)
1594                .unwrap()
1595                .remove(0);
1596            assert!(crate::is_shell_closed(&model, &shell).unwrap());
1597            // Every face looks outward: the solid's outward normals at the
1598            // face centroids point away from the block's own centre.
1599            let centre = Point::new(1.0, 2.0, 3.0) + (edges[0] + edges[1] + edges[2]) * 0.5;
1600            for face in &faces {
1601                let corners: Vec<Point> = explore_unique(&model, face, ShapeType::Vertex)
1602                    .unwrap()
1603                    .iter()
1604                    .map(|v| model.node(v).unwrap().data().as_vertex().unwrap().point)
1605                    .collect();
1606                let mut mid = ogeom_math::Vector::ZERO;
1607                for c in &corners {
1608                    mid += c.to_vector();
1609                }
1610                let mid = Point::ORIGIN + mid * 0.25;
1611                let data = model.node(face).unwrap().data().as_face().unwrap().clone();
1612                let Some(ogeom_geom::SurfaceGeometry::Plane(plane)) =
1613                    model.geometry().surface(data.surface)
1614                else {
1615                    panic!("a parallelepiped's faces are planes");
1616                };
1617                let mut normal = plane.plane().normal().vector();
1618                if face.orientation() == ogeom_topo::Orientation::Reversed {
1619                    normal = -normal;
1620                }
1621                assert!(normal.dot(mid - centre) > 0.0, "a face looks outward");
1622            }
1623        }
1624        let mut model = Model::new();
1625        assert!(
1626            make_parallelepiped(
1627                &mut model,
1628                Point::ORIGIN,
1629                [edges[0], edges[1], edges[0] + edges[1]],
1630                T
1631            )
1632            .is_err(),
1633            "coplanar edges span no volume"
1634        );
1635    }
1636
1637    /// A truncated pyramid on eight corners: planar trapezoid walls, the
1638    /// frustum's own volume, and a corner off its face's plane refused.
1639    #[test]
1640    fn a_hexahedron_is_a_frustum_when_its_corners_say_so() {
1641        let mut model = Model::new();
1642        let (h, a, b) = (3.0, 2.0, 1.0);
1643        let corners = [
1644            Point::new(-a, -a, 0.0),
1645            Point::new(a, -a, 0.0),
1646            Point::new(a, a, 0.0),
1647            Point::new(-a, a, 0.0),
1648            Point::new(-b, -b, h),
1649            Point::new(b, -b, h),
1650            Point::new(b, b, h),
1651            Point::new(-b, b, h),
1652        ];
1653        let solid = make_hexahedron(&mut model, corners, T).unwrap().shape;
1654        assert_eq!(model.kind_of(&solid).unwrap(), ShapeType::Solid);
1655        assert_eq!(
1656            explore_unique(&model, &solid, ShapeType::Face)
1657                .unwrap()
1658                .len(),
1659            6
1660        );
1661        let shell = explore_unique(&model, &solid, ShapeType::Shell)
1662            .unwrap()
1663            .remove(0);
1664        assert!(crate::is_shell_closed(&model, &shell).unwrap());
1665        let (bottom, top) = (4.0 * a * a, 4.0 * b * b);
1666        let expected = h / 3.0 * (bottom + top + (bottom * top).sqrt());
1667        let measured =
1668            crate::volume_properties(&model, &solid, ogeom_mesh::Deflection::default(), T)
1669                .unwrap()
1670                .mass;
1671        assert!(
1672            (measured - expected).abs() < expected * 1e-6,
1673            "frustum volume {measured} against {expected}"
1674        );
1675        // A corner lifted off its wall's plane is refused.
1676        let mut skewed = corners;
1677        skewed[6] = Point::new(b, b + 0.5, h);
1678        assert!(make_hexahedron(&mut model, skewed, T).is_err());
1679    }
1680
1681    /// A triangular prism from five rings named in mixed windings: the
1682    /// builder winds them outward itself, the shell closes on nine shared
1683    /// edges, and the volume is the prism's. A ring left out leaves edges
1684    /// used once, and the open shell is refused before it is built.
1685    #[test]
1686    fn a_polyhedron_winds_its_rings_outward_and_closes() {
1687        let mut model = Model::new();
1688        let points = [
1689            Point::new(0.0, 0.0, 0.0),
1690            Point::new(4.0, 0.0, 0.0),
1691            Point::new(0.0, 3.0, 0.0),
1692            Point::new(0.0, 0.0, 5.0),
1693            Point::new(4.0, 0.0, 5.0),
1694            Point::new(0.0, 3.0, 5.0),
1695        ];
1696        let rings = vec![
1697            vec![0, 1, 2],    // bottom, wound inward
1698            vec![3, 4, 5],    // top, wound outward
1699            vec![0, 1, 4, 3], // the y = 0 wall, wound inward
1700            vec![1, 2, 5, 4], // the slanted wall, wound outward
1701            vec![0, 3, 5, 2], // the x = 0 wall, wound outward
1702        ];
1703        let solid = make_polyhedron(&mut model, &points, &rings, T)
1704            .unwrap()
1705            .shape;
1706        assert_eq!(model.kind_of(&solid).unwrap(), ShapeType::Solid);
1707        assert_eq!(
1708            explore_unique(&model, &solid, ShapeType::Edge)
1709                .unwrap()
1710                .len(),
1711            9
1712        );
1713        let shell = explore_unique(&model, &solid, ShapeType::Shell)
1714            .unwrap()
1715            .remove(0);
1716        assert!(crate::is_shell_closed(&model, &shell).unwrap());
1717        let expected = 0.5 * 4.0 * 3.0 * 5.0;
1718        let measured =
1719            crate::volume_properties(&model, &solid, ogeom_mesh::Deflection::default(), T)
1720                .unwrap()
1721                .mass;
1722        assert!(
1723            (measured - expected).abs() < expected * 1e-6,
1724            "prism volume {measured} against {expected}"
1725        );
1726        // Four of the five rings leave three edges used once.
1727        let open = make_polyhedron(&mut model, &points, &rings[..4], T);
1728        assert!(open.is_err());
1729        // A ring off its plane is refused.
1730        let mut bent = points;
1731        bent[4] = Point::new(4.0, 0.5, 5.0);
1732        assert!(make_polyhedron(&mut model, &bent, &rings, T).is_err());
1733    }
1734
1735    #[test]
1736    fn a_box_in_a_tilted_frame_is_still_a_box() {
1737        let frame = Frame::new(
1738            Point::new(5.0, -2.0, 1.0),
1739            Direction::from_coords(1.0, 1.0, 1.0, T).unwrap(),
1740            Direction::from_coords(1.0, -1.0, 0.0, T).unwrap(),
1741            T,
1742        )
1743        .unwrap();
1744        let mut model = Model::new();
1745        let built = make_box(&mut model, frame, (2.0, 2.0, 2.0), T).unwrap();
1746
1747        assert_eq!(
1748            explore_unique(&model, &built.shape, ShapeType::Face)
1749                .unwrap()
1750                .len(),
1751            6
1752        );
1753        let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap();
1754        assert!(is_shell_closed(&model, &shell[0]).unwrap());
1755
1756        // The corners are where the frame puts them.
1757        let vertices = explore_unique(&model, &built.shape, ShapeType::Vertex).unwrap();
1758        let origin_corner = frame.to_world(Point::ORIGIN);
1759        assert!(
1760            vertices.iter().any(|v| {
1761                model
1762                    .node(v)
1763                    .unwrap()
1764                    .data()
1765                    .as_vertex()
1766                    .unwrap()
1767                    .point
1768                    .is_equal(origin_corner, T)
1769            }),
1770            "no vertex at the frame origin"
1771        );
1772    }
1773
1774    #[test]
1775    fn degenerate_dimensions_are_refused() {
1776        let mut model = Model::new();
1777        for size in [
1778            (0.0, 1.0, 1.0),
1779            (1.0, -1.0, 1.0),
1780            (1.0, 1.0, f64::NAN),
1781            (f64::INFINITY, 1.0, 1.0),
1782        ] {
1783            assert!(
1784                make_box(&mut model, Frame::WORLD, size, T).is_err(),
1785                "accepted {size:?}"
1786            );
1787        }
1788        assert!(make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).is_ok());
1789    }
1790
1791    #[test]
1792    fn a_primitive_reports_no_history_because_it_consumed_nothing() {
1793        let mut model = Model::new();
1794        let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
1795        assert!(
1796            built.history.is_empty(),
1797            "built from numbers, so there are no inputs to report on"
1798        );
1799    }
1800
1801    #[test]
1802    fn every_corner_pair_of_a_face_is_a_real_edge() {
1803        // Guards the tables themselves: a face naming two corners that share no
1804        // edge would silently build a wire from the wrong ones.
1805        for (corners, _) in FACES {
1806            for step in 0..4 {
1807                let (from, to) = (corners[step], corners[(step + 1) % 4]);
1808                assert!(
1809                    find_edge(from, to).is_ok(),
1810                    "face corners {from} and {to} are not joined"
1811                );
1812            }
1813        }
1814        assert!(find_edge(0, 6).is_err(), "opposite corners share no edge");
1815    }
1816
1817    #[test]
1818    fn every_edge_is_used_by_exactly_two_faces_in_the_tables() {
1819        // Checked against the tables directly, independently of the model: if
1820        // this were wrong the shell would not close, and the failure would
1821        // point at the topology rather than at the data that caused it.
1822        let mut uses = [0_usize; EDGES.len()];
1823        for (corners, _) in FACES {
1824            for step in 0..4 {
1825                let (from, to) = (corners[step], corners[(step + 1) % 4]);
1826                let (index, _) = find_edge(from, to).unwrap();
1827                uses[index] += 1;
1828            }
1829        }
1830        assert!(uses.iter().all(|&n| n == 2), "edge use counts: {uses:?}");
1831    }
1832}
1833
1834#[cfg(test)]
1835#[allow(clippy::unwrap_used)]
1836mod revolution_tests {
1837    use super::*;
1838    use crate::build::is_shell_closed;
1839    use crate::mass::{surface_properties, volume_properties};
1840    use approx::assert_relative_eq;
1841    use ogeom_mesh::{Deflection, triangulate};
1842    use ogeom_topo::{ShapeType, explore_unique};
1843
1844    const T: Tolerances = Tolerances::millimetres();
1845
1846    fn deflection(chord: f64) -> Deflection {
1847        Deflection {
1848            chord,
1849            ..Deflection::default()
1850        }
1851    }
1852
1853    #[test]
1854    fn a_cylinder_has_the_topology_a_cylinder_has() {
1855        let mut model = Model::new();
1856        let built = make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T).unwrap();
1857
1858        let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
1859        assert_eq!(counts(ShapeType::Face), 3, "a side and two caps");
1860        assert_eq!(counts(ShapeType::Edge), 3, "two rims and one seam");
1861        assert_eq!(counts(ShapeType::Vertex), 2, "one on each rim");
1862
1863        let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
1864        assert!(
1865            is_shell_closed(&model, &shell).unwrap(),
1866            "every edge should be used an even number of times"
1867        );
1868    }
1869
1870    #[test]
1871    fn a_cylinder_tessellates_into_a_closed_mesh_of_the_right_size() {
1872        // The seam is what this proves. Without both of its pcurves the lateral
1873        // face's boundary does not close in parameter space, and the mesh comes
1874        // out with a slot down one side.
1875        let (radius, height) = (2.0_f64, 5.0);
1876        let exact = PI * radius * radius * height;
1877        let mut model = Model::new();
1878        let built = make_cylinder(&mut model, Frame::WORLD, radius, height, T).unwrap();
1879
1880        // The *mesh* is inscribed and converges from below; the measurement
1881        // itself now runs on the exact surface and lands on the closed form.
1882        // Converges, not climbs: the angular deflection holds the rim at
1883        // thirty-two segments until the chord is finer than that, so the
1884        // first two chords draw the same polygon and the volume stands still.
1885        let mut previous = 0.0;
1886        for chord in [0.1_f64, 0.02, 0.005] {
1887            let mesh = triangulate(&model, &built.shape, deflection(chord), T).unwrap();
1888            assert!(mesh.is_closed(), "the mesh has a hole at chord {chord}");
1889            assert!(
1890                mesh.volume() < exact,
1891                "an inscribed volume cannot exceed it"
1892            );
1893            assert!(mesh.volume() >= previous, "refining lost volume");
1894            previous = mesh.volume();
1895        }
1896        assert!(previous > exact * 0.995, "{previous} against {exact}");
1897        let props = volume_properties(&model, &built.shape, deflection(0.005), T).unwrap();
1898        assert_relative_eq!(props.mass, exact, epsilon = 1e-9);
1899        assert_eq!(props.deflection, 0.0, "measured on the exact surface");
1900    }
1901
1902    #[test]
1903    fn a_cylinders_caps_face_outward() {
1904        // A cap wound the wrong way makes the solid inside out along one face,
1905        // and the volume comes out short by exactly that cap's contribution
1906        // rather than obviously wrong.
1907        let mut model = Model::new();
1908        let built = make_cylinder(&mut model, Frame::WORLD, 1.0, 3.0, T).unwrap();
1909        let props = volume_properties(&model, &built.shape, deflection(0.005), T).unwrap();
1910
1911        assert!(
1912            props.centre.distance(Point::new(0.0, 0.0, 1.5)) < 1e-3,
1913            "the centre of a cylinder is halfway up its axis, got {:?}",
1914            props.centre
1915        );
1916    }
1917
1918    #[test]
1919    fn a_sphere_has_the_topology_a_sphere_has() {
1920        let mut model = Model::new();
1921        let built = make_sphere(&mut model, Frame::WORLD, 3.0, T).unwrap();
1922
1923        let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
1924        assert_eq!(counts(ShapeType::Face), 1, "one surface covers a sphere");
1925        assert_eq!(counts(ShapeType::Edge), 3, "a seam and two poles");
1926        assert_eq!(counts(ShapeType::Vertex), 2, "the two poles");
1927
1928        // The poles have no length and say so, rather than leaving a caller to
1929        // discover it by dividing by their length.
1930        let degenerate = explore_unique(&model, &built.shape, ShapeType::Edge)
1931            .unwrap()
1932            .into_iter()
1933            .filter(|e| {
1934                model
1935                    .node(e)
1936                    .and_then(|n| n.data().as_edge())
1937                    .is_some_and(|d| d.degenerate)
1938            })
1939            .count();
1940        assert_eq!(degenerate, 2);
1941    }
1942
1943    #[test]
1944    fn a_sphere_converges_on_the_volume_and_area_a_sphere_has() {
1945        let radius = 4.0_f64;
1946        let volume = 4.0 / 3.0 * PI * radius.powi(3);
1947        let area = 4.0 * PI * radius * radius;
1948        let mut model = Model::new();
1949        let built = make_sphere(&mut model, Frame::WORLD, radius, T).unwrap();
1950
1951        let props = volume_properties(&model, &built.shape, deflection(0.01), T).unwrap();
1952        assert_relative_eq!(props.mass, volume, epsilon = 1e-9);
1953        assert_eq!(props.deflection, 0.0, "measured on the exact surface");
1954        assert!(
1955            props.centre.distance(Point::ORIGIN) < 1e-9,
1956            "got {:?}",
1957            props.centre
1958        );
1959
1960        let surface = surface_properties(&model, &built.shape, deflection(0.01), T).unwrap();
1961        assert_relative_eq!(surface.mass, area, epsilon = 1e-9);
1962        assert_eq!(surface.deflection, 0.0, "measured on the exact surface");
1963    }
1964
1965    #[test]
1966    fn a_placed_primitive_lands_where_it_was_placed() {
1967        let frame = Frame::new(Point::new(10.0, -5.0, 2.0), Direction::X, Direction::Y, T).unwrap();
1968        let mut model = Model::new();
1969        let built = make_cylinder(&mut model, frame, 1.0, 4.0, T).unwrap();
1970        let props = volume_properties(&model, &built.shape, deflection(0.005), T).unwrap();
1971
1972        // Half way along the frame's own z, which here is world +x.
1973        assert!(
1974            props.centre.distance(Point::new(12.0, -5.0, 2.0)) < 1e-3,
1975            "got {:?}",
1976            props.centre
1977        );
1978        // Inscribed, so a little under the exact pi r^2 h.
1979        assert_relative_eq!(props.mass, PI * 4.0, max_relative = 0.01);
1980    }
1981
1982    #[test]
1983    fn dimensions_that_describe_no_solid_are_refused() {
1984        let mut model = Model::new();
1985        for (r, h) in [(0.0, 1.0), (1.0, 0.0), (-1.0, 1.0), (f64::NAN, 1.0)] {
1986            assert!(make_cylinder(&mut model, Frame::WORLD, r, h, T).is_err());
1987        }
1988        for r in [0.0, -1.0, f64::INFINITY] {
1989            assert!(make_sphere(&mut model, Frame::WORLD, r, T).is_err());
1990        }
1991    }
1992}
1993
1994#[cfg(test)]
1995#[allow(clippy::unwrap_used)]
1996mod more_primitive_tests {
1997    use super::*;
1998    use crate::build::is_shell_closed;
1999    use crate::mass::volume_properties;
2000    use approx::assert_relative_eq;
2001    use ogeom_mesh::{Deflection, triangulate};
2002    use ogeom_topo::{ShapeType, explore_unique};
2003
2004    const T: Tolerances = Tolerances::millimetres();
2005
2006    fn deflection(chord: f64) -> Deflection {
2007        Deflection {
2008            chord,
2009            ..Deflection::default()
2010        }
2011    }
2012
2013    fn closed(model: &Model, solid: &Shape) -> bool {
2014        let shell = explore_unique(model, solid, ShapeType::Shell).unwrap()[0].clone();
2015        is_shell_closed(model, &shell).unwrap()
2016    }
2017
2018    #[test]
2019    fn a_truncated_cone_has_the_volume_a_frustum_has() {
2020        let (r0, r1, h) = (3.0_f64, 1.0_f64, 4.0_f64);
2021        let exact = PI * h / 3.0 * r1.mul_add(r1, r0.mul_add(r0, r0 * r1));
2022        let mut model = Model::new();
2023        let built = make_cone(&mut model, Frame::WORLD, r0, r1, h, T).unwrap();
2024
2025        assert!(closed(&model, &built.shape));
2026        let props = volume_properties(&model, &built.shape, deflection(0.005), T).unwrap();
2027        assert!(props.mass < exact, "an inscribed volume cannot exceed it");
2028        assert!(props.mass > exact * 0.995, "{} against {exact}", props.mass);
2029    }
2030
2031    #[test]
2032    fn a_true_cone_ends_in_an_apex_and_has_no_top_cap() {
2033        // The apex is an edge of no length. Without it the lateral face's
2034        // boundary is open along the top of its parameter rectangle; with a cap
2035        // there instead, the solid would have a face of no area.
2036        let (radius, height) = (2.0_f64, 5.0);
2037        let exact = PI * radius * radius * height / 3.0;
2038        let mut model = Model::new();
2039        let built = make_cone(&mut model, Frame::WORLD, radius, 0.0, height, T).unwrap();
2040
2041        assert_eq!(
2042            explore_unique(&model, &built.shape, ShapeType::Face)
2043                .unwrap()
2044                .len(),
2045            2,
2046            "a flank and one cap"
2047        );
2048        assert!(closed(&model, &built.shape));
2049        let props = volume_properties(&model, &built.shape, deflection(0.005), T).unwrap();
2050        assert!(props.mass < exact);
2051        assert!(props.mass > exact * 0.99, "{} against {exact}", props.mass);
2052    }
2053
2054    #[test]
2055    fn a_cone_widening_upward_is_built_the_same_way_round() {
2056        // The surface's half angle only makes sense in the widening direction,
2057        // so a narrowing solid gets a flipped surface frame. Both had better
2058        // give the same solid seen from the other end.
2059        let mut model = Model::new();
2060        let up = make_cone(&mut model, Frame::WORLD, 1.0, 3.0, 4.0, T).unwrap();
2061        let down = make_cone(&mut model, Frame::WORLD, 3.0, 1.0, 4.0, T).unwrap();
2062
2063        let a = volume_properties(&model, &up.shape, deflection(0.005), T).unwrap();
2064        let b = volume_properties(&model, &down.shape, deflection(0.005), T).unwrap();
2065        assert_relative_eq!(a.mass, b.mass, max_relative = 1e-9);
2066        // Mirrored about the middle of the height.
2067        assert_relative_eq!(a.centre.z, 4.0 - b.centre.z, epsilon = 1e-9);
2068    }
2069
2070    #[test]
2071    fn a_torus_has_two_seams_one_vertex_and_the_volume_a_torus_has() {
2072        // Closed in both parameter directions: the case that decides whether
2073        // seam handling is general or a special case for the cylinder.
2074        let (major, minor) = (5.0_f64, 2.0);
2075        let exact = 2.0 * PI * PI * major * minor * minor;
2076        let mut model = Model::new();
2077        let built = make_torus(&mut model, Frame::WORLD, major, minor, T).unwrap();
2078
2079        let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
2080        assert_eq!(counts(ShapeType::Face), 1);
2081        assert_eq!(counts(ShapeType::Edge), 2, "one seam each way");
2082        assert_eq!(counts(ShapeType::Vertex), 1, "where the two seams cross");
2083        assert!(closed(&model, &built.shape));
2084
2085        let mesh = triangulate(&model, &built.shape, deflection(0.02), T).unwrap();
2086        assert!(mesh.is_closed(), "the mesh has a hole");
2087
2088        let props = volume_properties(&model, &built.shape, deflection(0.02), T).unwrap();
2089        assert_relative_eq!(props.mass, exact, epsilon = 1e-9);
2090        assert_eq!(props.deflection, 0.0, "measured on the exact surface");
2091        assert!(props.centre.distance(Point::ORIGIN) < 1e-9);
2092    }
2093
2094    #[test]
2095    fn a_wedge_with_equal_extents_is_a_box() {
2096        let mut model = Model::new();
2097        let wedge = make_wedge(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), (2.0, 3.0), T).unwrap();
2098        let props = volume_properties(&model, &wedge.shape, deflection(0.01), T).unwrap();
2099        assert_relative_eq!(props.mass, 24.0, epsilon = 1e-9);
2100        assert!(closed(&model, &wedge.shape));
2101    }
2102
2103    #[test]
2104    fn a_tapered_wedge_has_the_volume_a_frustum_of_a_pyramid_has() {
2105        // A prismatoid: h/6 * (A_bottom + 4*A_middle + A_top).
2106        let mut model = Model::new();
2107        let wedge = make_wedge(&mut model, Frame::WORLD, (4.0, 4.0, 6.0), (2.0, 2.0), T).unwrap();
2108        let props = volume_properties(&model, &wedge.shape, deflection(0.01), T).unwrap();
2109        assert_relative_eq!(
2110            props.mass,
2111            6.0 / 6.0 * 4.0_f64.mul_add(9.0, 16.0 + 4.0),
2112            epsilon = 1e-9
2113        );
2114        assert!(closed(&model, &wedge.shape));
2115    }
2116
2117    #[test]
2118    fn a_wedge_collapsing_to_a_ridge_is_five_faces_and_a_prismatoid_volume() {
2119        // Top y extent zero: the top is a ridge along x at y = 0. The
2120        // prismatoid volume integral in closed form:
2121        // dz*dy*(dx/2 + (a - dx)/6) for ridge length a.
2122        let mut model = Model::new();
2123        let wedge = make_wedge(&mut model, Frame::WORLD, (4.0, 3.0, 6.0), (2.0, 0.0), T).unwrap();
2124        let faces = ogeom_topo::explore_unique(&model, &wedge.shape, ShapeType::Face)
2125            .unwrap()
2126            .len();
2127        assert_eq!(faces, 5, "a ridge wedge has five faces, none of them empty");
2128        let props = volume_properties(&model, &wedge.shape, deflection(0.01), T).unwrap();
2129        assert_relative_eq!(props.mass, 6.0 * 3.0 * (2.0 - 2.0 / 6.0), epsilon = 1e-9);
2130        assert!(closed(&model, &wedge.shape));
2131
2132        // And the other axis mirrors.
2133        let other = make_wedge(&mut model, Frame::WORLD, (3.0, 4.0, 6.0), (0.0, 2.0), T).unwrap();
2134        let props = volume_properties(&model, &other.shape, deflection(0.01), T).unwrap();
2135        assert_relative_eq!(props.mass, 6.0 * 3.0 * (2.0 - 2.0 / 6.0), epsilon = 1e-9);
2136        assert!(closed(&model, &other.shape));
2137    }
2138
2139    #[test]
2140    fn a_wedge_collapsing_to_a_point_is_a_pyramid() {
2141        let mut model = Model::new();
2142        let wedge = make_wedge(&mut model, Frame::WORLD, (4.0, 3.0, 6.0), (0.0, 0.0), T).unwrap();
2143        let faces = ogeom_topo::explore_unique(&model, &wedge.shape, ShapeType::Face)
2144            .unwrap()
2145            .len();
2146        assert_eq!(faces, 5, "a base and four triangles");
2147        let props = volume_properties(&model, &wedge.shape, deflection(0.01), T).unwrap();
2148        assert_relative_eq!(props.mass, 4.0 * 3.0 * 6.0 / 3.0, epsilon = 1e-9);
2149        assert!(closed(&model, &wedge.shape));
2150    }
2151
2152    #[test]
2153    fn dimensions_that_describe_no_solid_are_refused() {
2154        let mut model = Model::new();
2155        // Equal radii are a cylinder, and no radius at all is nothing.
2156        assert!(make_cone(&mut model, Frame::WORLD, 2.0, 2.0, 1.0, T).is_err());
2157        assert!(make_cone(&mut model, Frame::WORLD, 0.0, 0.0, 1.0, T).is_err());
2158        assert!(make_cone(&mut model, Frame::WORLD, 1.0, 2.0, 0.0, T).is_err());
2159        assert!(make_cone(&mut model, Frame::WORLD, -1.0, 2.0, 1.0, T).is_err());
2160
2161        assert!(make_torus(&mut model, Frame::WORLD, 0.0, 1.0, T).is_err());
2162        assert!(make_torus(&mut model, Frame::WORLD, 1.0, f64::NAN, T).is_err());
2163
2164        assert!(make_wedge(&mut model, Frame::WORLD, (0.0, 1.0, 1.0), (1.0, 1.0), T).is_err());
2165        assert!(make_wedge(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), (-1.0, 1.0), T).is_err());
2166    }
2167}
2168
2169#[cfg(test)]
2170#[allow(clippy::unwrap_used)]
2171mod half_space_tests {
2172    use super::*;
2173    use crate::classify::Containment;
2174    use crate::{classify_in_solid, make_natural_face};
2175    use ogeom_geom::PlaneSurface;
2176    use ogeom_math::Direction;
2177    use ogeom_mesh::Deflection;
2178    use ogeom_topo::explore_unique;
2179
2180    const T: Tolerances = Tolerances::millimetres();
2181
2182    fn coarse() -> Deflection {
2183        Deflection {
2184            chord: 1.0,
2185            ..Deflection::default()
2186        }
2187    }
2188
2189    /// The whole of the z = 0 plane, as a face.
2190    fn ground(model: &mut Model) -> Shape {
2191        make_natural_face(model, PlaneSurface::new(Plane::new(Frame::WORLD)).into())
2192            .unwrap()
2193            .shape
2194    }
2195
2196    #[test]
2197    fn the_face_is_oriented_away_from_the_side_that_is_solid() {
2198        // Outward means away from the material, and the material is where the
2199        // naming point is. The two calls differ only in which side was named,
2200        // so the boundary they produce must differ in orientation.
2201        let mut model = Model::new();
2202        let face = ground(&mut model);
2203        let above = make_half_space(&mut model, &face, Point::new(0.0, 0.0, 5.0), T).unwrap();
2204        let below = make_half_space(&mut model, &face, Point::new(0.0, 0.0, -5.0), T).unwrap();
2205
2206        let boundary = |built: &crate::Built| {
2207            explore_unique(&model, &built.shape, ShapeType::Face).unwrap()[0].clone()
2208        };
2209        let (a, b) = (boundary(&above), boundary(&below));
2210        assert!(a.is_partner(&b), "the same face, both times");
2211        assert_ne!(
2212            a.orientation(),
2213            b.orientation(),
2214            "naming the other side should turn the boundary round"
2215        );
2216        assert_eq!(model.kind_of(&above.shape).unwrap(), ShapeType::Solid);
2217        assert_eq!(
2218            explore_unique(&model, &above.shape, ShapeType::Face)
2219                .unwrap()
2220                .len(),
2221            1,
2222            "one face bounds a half space"
2223        );
2224    }
2225
2226    #[test]
2227    fn nothing_can_yet_be_asked_about_the_inside_of_one() {
2228        // Recorded rather than worked around. A half space's boundary is one
2229        // face with free edges all round, so it is *not* a closed shell, and
2230        // every query that needs an inside says so instead of guessing. That is
2231        // the correct answer for a shape whose boundary does not close, and it
2232        // is why a half space is only useful as a boolean argument.
2233        let mut model = Model::new();
2234        let face = ground(&mut model);
2235        let built = make_half_space(&mut model, &face, Point::new(0.0, 0.0, 5.0), T).unwrap();
2236
2237        let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
2238        assert!(!crate::is_shell_closed(&model, &shell).unwrap());
2239
2240        let err = classify_in_solid(&model, &built.shape, Point::new(0.0, 0.0, 5.0), coarse(), T)
2241            .unwrap_err();
2242        assert!(
2243            err.to_string().contains("not closed"),
2244            "unexpected message: {err}"
2245        );
2246        let _ = Containment::In;
2247    }
2248
2249    #[test]
2250    fn a_point_on_the_face_names_no_side() {
2251        let mut model = Model::new();
2252        let face = ground(&mut model);
2253        let err = make_half_space(&mut model, &face, Point::ORIGIN, T).unwrap_err();
2254        assert!(err.to_string().contains("names no side"), "got {err}");
2255
2256        // And one that is off the origin but still in the plane.
2257        let err = make_half_space(&mut model, &face, Point::new(3.0, 4.0, 0.0), T).unwrap_err();
2258        assert!(err.to_string().contains("names no side"), "got {err}");
2259    }
2260
2261    #[test]
2262    fn a_half_space_is_bounded_by_a_face_and_nothing_else() {
2263        let mut model = Model::new();
2264        let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2265            .unwrap()
2266            .shape;
2267        assert!(make_half_space(&mut model, &solid, Point::ORIGIN, T).is_err());
2268
2269        let vertex = model.add_point(Point::ORIGIN);
2270        assert!(make_half_space(&mut model, &vertex, Point::ORIGIN, T).is_err());
2271    }
2272
2273    #[test]
2274    fn it_reaches_only_as_far_as_its_surface_says() {
2275        // The documented limitation, pinned so it cannot quietly become a
2276        // claim of infinity. The face is a plane with a declared domain, so the
2277        // solid is a very large region rather than a half space, and anything
2278        // that integrates over it is measuring that domain.
2279        let mut model = Model::new();
2280        let face = ground(&mut model);
2281        let built = make_half_space(&mut model, &face, Point::new(0.0, 0.0, 1.0), T).unwrap();
2282
2283        // The bound comes back *empty*, which is the honest answer and a
2284        // better one than a very large box: `surface_bounds` refuses to bound
2285        // an unbounded plane rather than quoting its declared extent, so
2286        // nothing downstream mistakes that extent for the shape's size.
2287        let bounds = crate::shape_bounds(&model, &built.shape, T).unwrap();
2288        assert!(
2289            bounds.is_empty(),
2290            "an unbounded plane should decline to bound itself, got {bounds:?}"
2291        );
2292        let _ = Direction::Z;
2293    }
2294}