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