Skip to main content

ogeom_algo/
sweep.rs

1//! Sweeping: dragging a shape through space to make one of higher dimension.
2//!
3//! A vertex sweeps into an edge, an edge into a face, a wire into a shell, a
4//! face into a solid. One rule, applied at every level, which is why a prism
5//! over a face falls out of the prism over its edges rather than being built
6//! separately.
7//!
8//! # The top is the bottom, moved
9//!
10//! The far end of a prism is not a copy of the near end. It is the *same*
11//! topology node at a different [`Location`], the shape triple's whole reason
12//! for existing (`docs/DATA_MODEL.md` ยง2). A copy would double the geometry, and
13//! then a later edit would have to find and fix both. Sharing means the two ends
14//! of a prism cannot drift apart, because there is only one of them.
15//!
16//! It also means an assembly of a thousand identical extrusions holds one
17//! profile and a thousand placements, which is the case the location chain was
18//! designed for.
19//!
20//! # History
21//!
22//! A swept edge is *both* consumed and generative: it survives as the bottom of
23//! the prism and it generates the lateral face. Recording only one of those is
24//! the classic way to break downstream naming: a reference to "that edge"
25//! resolves to nothing, or a reference to "the face from that edge" does.
26
27use ogeom_core::FastMap;
28
29use core::f64::consts::TAU;
30use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
31use ogeom_geom::{Curve3d, ExtrusionSurface, Line2d, PlanarCurve, Transformable};
32use ogeom_math::{Axis, Circle, Direction, Frame, Point, Point2, Transform, Vector};
33use ogeom_topo::{EdgeRepr, Location, Model, NodeData, Orientation, Shape, ShapeType, TShapeId};
34
35use crate::build::{
36    edges_turn, make_face_on, make_shell, make_solid, make_wire, walked_back, wire_turn,
37};
38use crate::history::{Built, History};
39
40/// Roles a sweep assigns.
41pub mod roles {
42    use ogeom_core::Role;
43
44    /// The face the sweep started from.
45    pub const SWEEP_BOTTOM: Role = Role::op_defined(20);
46    /// The face the sweep ended at.
47    pub const SWEEP_TOP: Role = Role::op_defined(21);
48    /// A face swept out by one edge of the profile.
49    pub const SWEEP_SIDE: Role = Role::op_defined(22);
50    /// An edge swept out by one vertex of the profile.
51    pub const SWEEP_RAIL: Role = Role::op_defined(23);
52}
53
54/// A map from the extrusion's chart to a canonical surface's own.
55type ChartMap = Box<dyn Fn((f64, f64)) -> (f64, f64)>;
56
57/// Extrude a shape along `vector`.
58///
59/// A face becomes a solid, a wire becomes a shell, an edge becomes a face. The
60/// result's history reports each input as generating what it swept out, and the
61/// profile itself as surviving into the near end.
62///
63/// # Errors
64///
65/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `vector` has no
66/// length, if the shape is of a kind that cannot be swept, or if an edge of the
67/// profile has no 3D curve to sweep.
68pub fn make_prism(
69    model: &mut Model,
70    profile: &Shape,
71    vector: Vector,
72    tol: Tolerances,
73) -> OgeomResult<Built> {
74    if !vector.is_finite() || vector.magnitude() <= tol.confusion() {
75        ogeom_bail!(
76            Construction,
77            "a prism needs a direction to travel; {vector:?} has no length"
78        );
79    }
80    model.begin_operation();
81
82    // One datum for the whole sweep, so every entity at the far end shares a
83    // single placement rather than each carrying its own copy of the same
84    // transform. Comparing two far-end shapes is then a comparison of one
85    // identifier, which is what makes instance detection cheap.
86    let datum = model.add_datum(Transform::translation(vector));
87    let displacement = Location::of(datum);
88
89    let rails = &mut Rails::default();
90    match model.kind_of(profile)? {
91        ShapeType::Face => {
92            crate::build::trimmed_or_fitted_where_bare(model, profile, tol)?;
93            prism_over_face(model, rails, profile, &displacement, vector, tol)
94        }
95        ShapeType::Wire => {
96            let (faces, history) =
97                prism_over_wire(model, rails, profile, &displacement, vector, tol)?;
98            let shell = make_shell(model, &faces)?.shape;
99            Ok(Built::new(shell, history))
100        }
101        ShapeType::Edge => {
102            let (face, history) =
103                prism_over_edge(model, rails, profile, &displacement, vector, tol)?;
104            Ok(Built::new(face, history))
105        }
106        other => ogeom_bail!(
107            Construction,
108            "a {other:?} cannot be swept into anything; sweep an edge, a wire or \
109             a face"
110        ),
111    }
112}
113
114/// Sweep a planar face into a tapered prism: every wall leans by `taper`.
115///
116/// The draft-prism semantics: each section is the profile's own offset at
117/// the rate the taper names (the outer loop outward, holes inward), so a
118/// positive taper widens the far end and narrows every hole, and each wall
119/// makes exactly `taper` with the travel. The far ring is genuinely new
120/// topology: a taper breaks the plain prism's the-top-is-the-bottom-moved
121/// invariant, so corners re-solve where the offset lines meet, straight
122/// walls come out as exact tilted planes, and a full circular hole's wall
123/// is an exact cone.
124///
125/// # Errors
126///
127/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
128/// profile is not a planar face, the travel is not square to it, a profile
129/// edge is neither straight nor a full circle (the curved-wall taper needs a
130/// fitted ruling), or the taper collapses a loop over the height.
131pub fn make_prism_tapered(
132    model: &mut Model,
133    profile: &Shape,
134    vector: Vector,
135    taper: f64,
136    tol: Tolerances,
137) -> OgeomResult<Built> {
138    use ogeom_geom::Curve;
139
140    if !taper.is_finite() || taper.abs() <= tol.angular() {
141        return make_prism(model, profile, vector, tol);
142    }
143    if taper.abs() >= core::f64::consts::FRAC_PI_2 - tol.angular() {
144        ogeom_bail!(Construction, "a taper of {taper} flattens the prism");
145    }
146    let travel = vector.magnitude();
147    if !travel.is_finite() || travel <= tol.confusion() {
148        ogeom_bail!(Construction, "a sweep along {vector:?} goes nowhere");
149    }
150    if model.kind_of(profile)? != ShapeType::Face {
151        ogeom_bail!(
152            Construction,
153            "a tapered prism encloses volume; sweep a planar face"
154        );
155    }
156    // A placed profile is swept where it was built, the travel taken into
157    // its frame, and the prism placed where the profile stands: the
158    // construction reads the profile's edges in their own frame.
159    let placement = profile.location().clone();
160    if !placement.is_identity() {
161        let local = placement
162            .composed(model.datums())?
163            .inverse()?
164            .apply_vector(vector);
165        let built = make_prism_tapered(
166            model,
167            &profile.located(Location::identity()),
168            local,
169            taper,
170            tol,
171        )?;
172        return Ok(Built::new(built.shape.moved(&placement), built.history));
173    }
174    let Some(plane) = crate::build::find_plane(model, profile, tol)? else {
175        ogeom_bail!(Construction, "a tapered prism sweeps a planar face");
176    };
177    // The profile is probed for which side is material, which reads its
178    // edges in its chart; a face built without pcurves gains its exact ones.
179    attach_cap_pcurves(model, profile, tol)?;
180    let mut normal = plane.normal().vector();
181    if normal.cross(vector / travel).magnitude() > tol.angular().max(1e-9) {
182        ogeom_bail!(
183            Construction,
184            "an oblique tapered sweep is ambiguous about its own sections; \
185             the travel must run square to the profile"
186        );
187    }
188    // Everything below leans on the travel direction, not the face's own
189    // normal sign.
190    if normal.dot(vector) < 0.0 {
191        normal = -normal;
192    }
193    let up = Direction::new(normal, tol)?;
194    let spread = travel * taper.tan();
195
196    let mut history = History::new();
197    let mut faces: Vec<Shape> = Vec::new();
198    let mut near_wires: Vec<Shape> = Vec::new();
199    let mut far_wires: Vec<Shape> = Vec::new();
200    for wire in model.children_of(profile)? {
201        let edges = model.ordered_children_of(&wire)?;
202        // A loop of one closed circle tapers as a cone; a loop of straight
203        // edges tapers as tilted planes with re-mitred corners.
204        let lone_circle = edges.len() == 1 && {
205            let (curve, _) = edge_geometry(model, &edges[0], tol)?;
206            matches!(curve, Curve::Circle(_))
207        };
208        if lone_circle {
209            let (curve, range) = edge_geometry(model, &edges[0], tol)?;
210            let Curve::Circle(c) = curve else {
211                unreachable!("just matched")
212            };
213            let circle = c.circle();
214            // Which way is away from the material, by measurement: probe a
215            // little outside the ring and ask the face. Windings are not to
216            // be trusted: a hole's wire may come wound either way.
217            let start = c.point_at(range.0, tol)?;
218            let radial = (start - circle.centre()) / circle.radius();
219            let sigma = away_sign(model, profile, start, radial, circle.radius(), tol)?;
220            let far_radius = sigma.mul_add(spread, circle.radius());
221            if far_radius <= tol.confusion() {
222                ogeom_bail!(
223                    Construction,
224                    "the taper collapses a circular loop of radius {} over \
225                     this height",
226                    circle.radius()
227                );
228            }
229            let near_frame = Frame::new(circle.centre(), up, circle.frame().x(), tol)?;
230            let far_frame = Frame::new(circle.centre() + vector, up, circle.frame().x(), tol)?;
231            let near_curve: Curve =
232                ogeom_geom::CircleCurve::new(Circle::new(near_frame, circle.radius(), tol)?).into();
233            let near_domain = near_curve.domain();
234            let near = crate::build::make_edge(model, near_curve, near_domain, tol)?.shape;
235            let far_curve: Curve =
236                ogeom_geom::CircleCurve::new(Circle::new(far_frame, far_radius, tol)?).into();
237            let far_domain = far_curve.domain();
238            let far = crate::build::make_edge(model, far_curve, far_domain, tol)?.shape;
239            // The wall cone: reference radius at the near plane, the radius
240            // running to the far one; the surface's own normal points away
241            // from the axis, which is outward exactly when the material is
242            // inside the ring.
243            let slope = (far_radius - circle.radius()) / travel;
244            let cone = ogeom_math::Cone::new(near_frame, circle.radius(), slope.atan(), tol)?;
245            let pad = travel * 0.1;
246            let surface: ogeom_geom::SurfaceGeometry =
247                ogeom_geom::ConeSurface::new(cone, (-pad, travel + pad))?.into();
248            let band = crate::build::make_revolution_band(model, &surface, &near, &far, tol)?;
249            let wall = if sigma > 0.0 { band } else { band.reversed() };
250            model.set_derived(&wall, std::slice::from_ref(&edges[0]), roles::SWEEP_SIDE)?;
251            history.generate(&edges[0], wall.clone());
252            faces.push(wall);
253            // Both circles turn about the travel; round a hole the caps walk
254            // them the other way, keeping the material on their left.
255            let (near, far) = if sigma > 0.0 {
256                (near, far)
257            } else {
258                (near.reversed(), far.reversed())
259            };
260            near_wires.push(make_wire(model, std::slice::from_ref(&near), tol)?.shape);
261            far_wires.push(make_wire(model, std::slice::from_ref(&far), tol)?.shape);
262            continue;
263        }
264
265        // Straight loops: offset every line away from the material and
266        // re-mitre the corners in the far plane.
267        let mut corners_near: Vec<Point> = Vec::new();
268        let mut aways: Vec<Vector> = Vec::new();
269        let mut dirs: Vec<Vector> = Vec::new();
270        for edge in &edges {
271            let (curve, range) = edge_geometry(model, edge, tol)?;
272            let Curve::Line(_) = curve else {
273                ogeom_bail!(
274                    Construction,
275                    "a tapered wall over an edge that is neither straight nor \
276                     a full circle needs a fitted ruling; see docs/CAPABILITIES.md, \
277                     offset.sweeps"
278                );
279            };
280            let reversed = edge.orientation() == Orientation::Reversed;
281            let (t0, t1) = if reversed {
282                (range.1, range.0)
283            } else {
284                (range.0, range.1)
285            };
286            let from = curve.point_at(t0, tol)?;
287            let to = curve.point_at(t1, tol)?;
288            let dir = (to - from) / from.distance(to);
289            corners_near.push(from);
290            dirs.push(dir);
291            aways.push(dir.cross(up.vector()));
292        }
293        let count = corners_near.len();
294        if count < 3 {
295            ogeom_bail!(Construction, "a straight loop needs at least three edges");
296        }
297        // The loop's material side, measured once and applied to every edge:
298        // the wire's own winding is not to be trusted for holes.
299        let flip = {
300            let mid = corners_near[0] + dirs[0] * (corners_near[0].distance(corners_near[1]) / 2.0);
301            let scale = corners_near[0].distance(corners_near[1]);
302            away_sign(model, profile, mid, aways[0], scale, tol)?
303        };
304        if flip < 0.0 {
305            for a in &mut aways {
306                *a = -*a;
307            }
308        }
309        // Far corners: each is where the two neighbouring offset lines meet,
310        // in the far plane.
311        let mut corners_far: Vec<Point> = Vec::with_capacity(count);
312        for i in 0..count {
313            let prev = (i + count - 1) % count;
314            let (d0, d1) = (dirs[prev], dirs[i]);
315            let (a0, a1) = (aways[prev], aways[i]);
316            let p0 = corners_near[i] + a0 * spread + vector;
317            let p1 = corners_near[i] + a1 * spread + vector;
318            let cross = d0.cross(d1);
319            let m = cross.magnitude();
320            let far = if m <= tol.angular() {
321                // Collinear neighbours offset to the same line.
322                p1
323            } else {
324                // Solve p0 + s d0 = p1 + t d1 in the loop's own plane.
325                let w = p1 - p0;
326                let s = w.cross(d1).dot(cross) / (m * m);
327                p0 + d0 * s
328            };
329            corners_far.push(far);
330        }
331        for (i, far) in corners_far.iter().enumerate() {
332            let next = corners_far[(i + 1) % count];
333            let d = next - *far;
334            if d.magnitude() <= tol.confusion() || d.dot(dirs[i]) <= 0.0 {
335                ogeom_bail!(
336                    Construction,
337                    "the taper collapses the profile's loop over this height"
338                );
339            }
340        }
341
342        let near_vertices: Vec<Shape> = corners_near
343            .iter()
344            .map(|p| crate::build::make_vertex(model, *p).shape)
345            .collect();
346        let far_vertices: Vec<Shape> = corners_far
347            .iter()
348            .map(|p| crate::build::make_vertex(model, *p).shape)
349            .collect();
350        let segment =
351            |model: &mut Model, from: (&Shape, Point), to: (&Shape, Point)| -> OgeomResult<Shape> {
352                let line = ogeom_geom::LineCurve::segment(from.1, to.1, tol)?;
353                let curve: Curve = line.into();
354                let domain = curve.domain();
355                Ok(crate::build::make_edge_between(model, curve, domain, from.0, to.0, tol)?.shape)
356            };
357        let mut near_edges = Vec::with_capacity(count);
358        let mut far_edges = Vec::with_capacity(count);
359        let mut rails = Vec::with_capacity(count);
360        for i in 0..count {
361            let next = (i + 1) % count;
362            near_edges.push(segment(
363                model,
364                (&near_vertices[i], corners_near[i]),
365                (&near_vertices[next], corners_near[next]),
366            )?);
367            far_edges.push(segment(
368                model,
369                (&far_vertices[i], corners_far[i]),
370                (&far_vertices[next], corners_far[next]),
371            )?);
372            rails.push(segment(
373                model,
374                (&near_vertices[i], corners_near[i]),
375                (&far_vertices[i], corners_far[i]),
376            )?);
377        }
378        for (i, edge) in edges.iter().enumerate() {
379            let next = (i + 1) % count;
380            // The wall's own plane: through the near edge, leaning with the
381            // rails. Its normal is the in-plane away tilted by the taper,
382            // which faces off the material by construction.
383            let outward = {
384                let lean = aways[i] * taper.cos() - up.vector() * taper.sin();
385                Direction::new(lean, tol)?
386            };
387            let wall_plane = ogeom_math::Plane::through(corners_near[i], outward);
388            let mut reach = travel + 1.0_f64;
389            for p in [
390                corners_near[i],
391                corners_near[next],
392                corners_far[i],
393                corners_far[next],
394            ] {
395                reach = reach.max(p.distance(corners_near[i]) * 2.0);
396            }
397            let surface: ogeom_geom::SurfaceGeometry =
398                ogeom_geom::PlaneSurface::over(wall_plane, (-reach, reach), (-reach, reach))?
399                    .into();
400            // Counter-clockwise about the outward normal: along the near edge
401            // where the loop keeps its material on the left, against it where
402            // the loop runs the other way.
403            let ring = if flip > 0.0 {
404                vec![
405                    near_edges[i].clone(),
406                    rails[next].clone(),
407                    far_edges[i].reversed(),
408                    rails[i].reversed(),
409                ]
410            } else {
411                vec![
412                    rails[i].clone(),
413                    far_edges[i].clone(),
414                    rails[next].reversed(),
415                    near_edges[i].reversed(),
416                ]
417            };
418            let wall = crate::build::make_face_with_pcurves(model, surface, &[ring], tol)?.shape;
419            model.set_derived(&wall, std::slice::from_ref(edge), roles::SWEEP_SIDE)?;
420            history.generate(edge, wall.clone());
421            faces.push(wall);
422        }
423        // The caps keep the material on the left of each ring about the
424        // travel.
425        let walked = |ring: &[Shape]| -> Vec<Shape> {
426            if flip > 0.0 {
427                ring.to_vec()
428            } else {
429                ring.iter().rev().map(Shape::reversed).collect()
430            }
431        };
432        near_wires.push(make_wire(model, &walked(&near_edges), tol)?.shape);
433        far_wires.push(make_wire(model, &walked(&far_edges), tol)?.shape);
434    }
435
436    // Caps: the near one facing backwards, both rebuilt over the fresh rings
437    // so every wall shares its vertices.
438    let near_surface: ogeom_geom::SurfaceGeometry = {
439        let base = ogeom_math::Plane::through(plane.origin(), up);
440        ogeom_geom::PlaneSurface::over(base, (-1e6, 1e6), (-1e6, 1e6))?.into()
441    };
442    let far_surface: ogeom_geom::SurfaceGeometry = {
443        let lifted = ogeom_math::Plane::through(plane.origin() + vector, up);
444        ogeom_geom::PlaneSurface::over(lifted, (-1e6, 1e6), (-1e6, 1e6))?.into()
445    };
446    let bottom = crate::build::make_face(model, near_surface, &near_wires, tol)?
447        .shape
448        .reversed();
449    let top = crate::build::make_face(model, far_surface, &far_wires, tol)?.shape;
450    attach_cap_pcurves(model, &bottom, tol)?;
451    attach_cap_pcurves(model, &top, tol)?;
452    model.set_derived(&bottom, std::slice::from_ref(profile), roles::SWEEP_BOTTOM)?;
453    model.set_derived(&top, std::slice::from_ref(profile), roles::SWEEP_TOP)?;
454    history.generate(profile, top.clone());
455    faces.push(bottom);
456    faces.push(top);
457
458    let sewn = crate::sew(model, &faces, tol)?;
459    if sewn.shells.len() != 1 || !crate::build::is_shell_closed(model, &sewn.shells[0])? {
460        ogeom_bail!(Construction, "the tapered prism did not close");
461    }
462    let solid = make_solid(model, std::slice::from_ref(&sewn.shells[0]))?.shape;
463    history.generate(profile, solid.clone());
464    Ok(Built::new(solid, history))
465}
466
467/// The sign that points `candidate` away from the face's material at `at`,
468/// probed against the face's own trim at a few scales of `scale`.
469fn away_sign(
470    model: &Model,
471    face: &Shape,
472    at: Point,
473    candidate: Vector,
474    scale: f64,
475    tol: Tolerances,
476) -> OgeomResult<f64> {
477    for factor in [1e-3, 1e-2, 5e-2] {
478        let eps = scale * factor;
479        let deflection = ogeom_mesh::Deflection {
480            chord: eps * 0.1,
481            ..ogeom_mesh::Deflection::default()
482        };
483        for sign in [1.0_f64, -1.0] {
484            let probe = at + candidate * (sign * eps);
485            if crate::classify_on_face(model, face, probe, deflection, tol)?
486                == crate::Containment::In
487            {
488                // Material on this side: away is the other one.
489                return Ok(-sign);
490            }
491        }
492    }
493    ogeom_bail!(
494        Construction,
495        "cannot read which side of the profile the material is on"
496    )
497}
498
499/// An edge's 3D curve where its occurrence stands, and its range there.
500fn edge_geometry(
501    model: &Model,
502    edge: &Shape,
503    tol: Tolerances,
504) -> OgeomResult<(ogeom_geom::Curve, (f64, f64))> {
505    let Some(placed) = crate::build::placed_edge_curve(model, edge, tol)? else {
506        ogeom_bail!(Construction, "an edge has no curve");
507    };
508    Ok(placed)
509}
510
511/// Attach exact pcurves to every edge of a planar cap that lacks one.
512fn attach_cap_pcurves(model: &mut Model, cap: &Shape, tol: Tolerances) -> OgeomResult<()> {
513    let cap_id = {
514        let Some(node) = model.node(cap) else {
515            ogeom_bail!(Dangling, "the cap just built is not in this model");
516        };
517        let NodeData::Face(data) = node.data() else {
518            ogeom_bail!(Construction, "the cap holds no face data");
519        };
520        data.surface
521    };
522    let Some(surface) = model.geometry().surface(cap_id).cloned() else {
523        ogeom_bail!(Dangling, "the cap's surface is not in this model");
524    };
525    for edge in ogeom_topo::explore(model, cap, ogeom_topo::Filter::OfType(ShapeType::Edge))? {
526        let held = model
527            .node(&edge)
528            .and_then(|n| n.data().as_edge())
529            .is_some_and(|d| d.pcurve_for(cap_id, edge.location()).is_some());
530        if held {
531            continue;
532        }
533        let (curve, range) = crate::build::charted_edge_curve(model, &edge, tol)?;
534        let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&curve, &surface, tol) else {
535            ogeom_bail!(Construction, "a cap edge has no closed-form pcurve");
536        };
537        let at = edge.location().clone();
538        crate::build::attach_pcurve(model, &edge, pcurve, cap_id, at, range)?;
539    }
540    Ok(())
541}
542
543/// A face swept into a solid.
544fn prism_over_face(
545    model: &mut Model,
546    rails: &mut Rails,
547    face: &Shape,
548    displacement: &Location,
549    vector: Vector,
550    tol: Tolerances,
551) -> OgeomResult<Built> {
552    // Which side of the profile the material lands on is decided by the sweep,
553    // not by which way the profile was handed over. A profile facing against
554    // the sweep does not describe a different solid (it describes the same one
555    // from the other side), so it is turned round here and everything below
556    // proceeds as if it had faced along all along.
557    //
558    // Left unturned, both end caps present the wrong side: the mesh still
559    // closes and the shell is still closed, so nothing topological notices, and
560    // the volume comes back short by twice the caps' contribution.
561    let (_, normal) = crate::measure::face_normal(model, face, tol)?;
562    let travel = vector.magnitude();
563    let along = normal.dot(vector) / travel;
564    if along.abs() <= tol.angular() {
565        ogeom_bail!(
566            Construction,
567            "the sweep runs along the profile's own surface, so it encloses no \
568             volume; a face swept within its own plane is not a solid"
569        );
570    }
571    let profile = if along < 0.0 {
572        face.reversed()
573    } else {
574        face.clone()
575    };
576
577    let mut history = History::new();
578    let mut faces = Vec::new();
579
580    // A closed wire on a plane bounds one region however it is walked, but a
581    // wall's side is read off its edge's direction: walked clockwise about
582    // the travel, an outer ring sweeps every wall facing into the material
583    // while the caps face out, and the solid comes back inside out. Each
584    // ring's turn about the travel is measured, the ring enclosing the most
585    // is the outer one and must turn positively, every other a hole turning
586    // the other way.
587    let wires = model.children_of(&profile)?;
588    let turns: Vec<f64> = wires
589        .iter()
590        .map(|w| wire_turn(model, w, vector, tol))
591        .collect::<OgeomResult<_>>()?;
592    let outer = turns
593        .iter()
594        .enumerate()
595        .max_by(|a, b| {
596            a.1.abs()
597                .partial_cmp(&b.1.abs())
598                .unwrap_or(core::cmp::Ordering::Equal)
599        })
600        .map_or(0, |(i, _)| i);
601    // A ring walked against that is turned round in the profile itself, so
602    // the caps and the walls built over it agree on which way each edge is
603    // walked, and each wall faces out.
604    let against: Vec<bool> = turns
605        .iter()
606        .enumerate()
607        .map(|(i, turn)| turn * if i == outer { 1.0 } else { -1.0 } < 0.0)
608        .collect();
609    let profile = if against.contains(&true) {
610        rewound(model, &profile, &against, tol)?
611    } else {
612        profile
613    };
614    let wires = model.children_of(&profile)?;
615    for wire in &wires {
616        let (sides, wire_history) = prism_over_wire(model, rails, wire, displacement, vector, tol)?;
617        history = history.then(&wire_history);
618        faces.extend(sides);
619    }
620
621    // The near end faces backwards, because the solid is on the far side of it.
622    // Getting this wrong makes a solid that is inside out along one face, and
623    // the volume comes out short by exactly that face's contribution rather
624    // than obviously wrong.
625    let bottom = profile.reversed();
626    let top = profile.moved(displacement);
627    model.set_derived(&bottom, std::slice::from_ref(face), roles::SWEEP_BOTTOM)?;
628    model.set_derived(&top, std::slice::from_ref(face), roles::SWEEP_TOP)?;
629    history.generate(face, top.clone());
630    faces.push(bottom);
631    faces.push(top);
632
633    let shell = make_shell(model, &faces)?.shape;
634    let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
635    history.generate(face, solid.clone());
636    Ok(Built::new(solid, history))
637}
638
639/// `face` again with the rings `turned` walked the other way round: a face
640/// of its own on the same surface, placed and presented as `face` is.
641fn rewound(
642    model: &mut Model,
643    face: &Shape,
644    turned: &[bool],
645    tol: Tolerances,
646) -> OgeomResult<Shape> {
647    let Some(node) = model.node(face) else {
648        ogeom_bail!(Dangling, "the profile is not in this model");
649    };
650    let Some(data) = node.data().as_face() else {
651        ogeom_bail!(Construction, "the profile holds no face data");
652    };
653    let mut rebuilt = ogeom_topo::FaceData::new(data.surface, data.location.clone());
654    rebuilt.tolerance = data.tolerance;
655    let stored = node.children().to_vec();
656    let mut wires = Vec::with_capacity(stored.len());
657    for (wire, &turn) in stored.iter().zip(turned) {
658        wires.push(if turn {
659            walked_back(model, wire, tol)?
660        } else {
661            wire.clone()
662        });
663    }
664    let built = model.add_face(rebuilt, &wires)?;
665    Ok(built.moved(face.location()).oriented(face.orientation()))
666}
667
668/// Every face a wire sweeps out.
669fn prism_over_wire(
670    model: &mut Model,
671    rails: &mut Rails,
672    wire: &Shape,
673    displacement: &Location,
674    vector: Vector,
675    tol: Tolerances,
676) -> OgeomResult<(Vec<Shape>, History)> {
677    let mut faces = Vec::new();
678    let mut history = History::new();
679    for edge in model.ordered_children_of(wire)? {
680        let (face, edge_history) = prism_over_edge(model, rails, &edge, displacement, vector, tol)?;
681        history = history.then(&edge_history);
682        faces.push(face);
683    }
684    if faces.is_empty() {
685        ogeom_bail!(Construction, "a wire with no edges sweeps out nothing");
686    }
687    Ok((faces, history))
688}
689
690/// One edge swept into one face.
691///
692/// The face's surface is the extrusion of the edge's own 3D curve, so the
693/// lateral surface is exact for whatever the edge was (a line gives a plane, an
694/// arc gives a cylinder, a spline gives an extruded spline) rather than
695/// everything becoming a plane through an approximation.
696fn prism_over_edge(
697    model: &mut Model,
698    rails: &mut Rails,
699    edge: &Shape,
700    displacement: &Location,
701    vector: Vector,
702    tol: Tolerances,
703) -> OgeomResult<(Shape, History)> {
704    let Some(node) = model.node(edge) else {
705        ogeom_bail!(Dangling, "edge is not in this model");
706    };
707    let NodeData::Edge(data) = node.data() else {
708        ogeom_bail!(Construction, "edge node holds no edge data");
709    };
710    let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
711        ogeom_bail!(
712            Construction,
713            "an edge with no curve in space has no shape to sweep; a degenerate \
714             edge sweeps out nothing and has to be handled by its face, not here"
715        );
716    };
717    let Some(geometry) = model.geometry().curve(*curve).cloned() else {
718        ogeom_bail!(Dangling, "curve is not in this model");
719    };
720
721    // The surface is built from the edge's curve *where the edge actually is*.
722    // Built from the stored curve instead, every side wall of a profile that
723    // has been placed lands back at the placement's origin, while its two ends
724    // (the profile itself, at its own location) land correctly, so
725    // the mesh comes apart along every lateral face at once.
726    //
727    // A placement may carry a uniform scale, and a scale rescales a curve's
728    // parameter with it. The edge's range is in the stored curve's parameter
729    // and the surface's `u` is in the placed one's, so the range is carried
730    // across by where it sits in the domain rather than by its value: left
731    // as it is where the domains agree, since an affine map across a line's
732    // billion-wide domain moves it by that width's rounding.
733    let placement = edge.transform(model.datums())?;
734    let stored = geometry.domain();
735    let geometry = geometry.transformed(&placement, tol)?;
736    let placed = geometry.domain();
737    let (lo, hi) = (
738        ogeom_geom::carried(range.0, stored, placed),
739        ogeom_geom::carried(range.1, stored, placed),
740    );
741    let travel = vector.magnitude();
742    let direction = ogeom_math::Direction::new(vector, tol)?;
743    // A straight profile edge sweeps a plane, and the plane is built so its
744    // chart *is* the extrusion's (origin on the line, x along it, y along
745    // the travel), so every pcurve below serves either surface unchanged.
746    // Naming the plane it actually made is what lets the boolean's
747    // same-domain resolution meet a prism wall as the plane it is.
748    // The chart the pcurves below are written in is the extrusion's (`u`
749    // the curve's own parameter, `v` the travel), and a canonical surface
750    // whose chart differs carries a map from that chart to its own. A line
751    // swept square to itself makes a plane whose chart *is* the
752    // extrusion's; swept obliquely it makes a plane still, with the
753    // extrusion's chart sheared onto the plane's orthonormal one, and every
754    // pcurve below is a straight line between chart points, which a shear
755    // keeps straight.
756    let mut chart: ChartMap = Box::new(|p| p);
757    // Whether the canonical surface's own normal points the other way from
758    // the extrusion's (the curve's tangent crossed with the travel).
759    let mut turned = false;
760    let canonical: Option<ogeom_geom::SurfaceGeometry> = if let ogeom_geom::Curve::Line(line) =
761        &geometry
762        && let Ok(normal) =
763            ogeom_math::Direction::new(line.axis().direction.vector().cross(vector), tol)
764    {
765        let axis = line.axis();
766        let frame = ogeom_math::Frame::new(axis.location, normal, axis.direction, tol)?;
767        let plane = ogeom_math::Plane::new(frame);
768        // The extrusion's `v` is a distance along the unit travel; on the
769        // plane it moves `shear` along the line and `rise` across it per
770        // unit of that distance.
771        let along = axis.direction.vector();
772        let across = frame.y().vector();
773        let (shear, rise) = (
774            direction.vector().dot(along),
775            direction.vector().dot(across),
776        );
777        if shear.abs() > tol.angular() {
778            chart = Box::new(move |(u, v): (f64, f64)| (u + v * shear, v * rise));
779        }
780        let margin = (hi - lo).abs().max(travel) * 0.1 + 1.0;
781        let (u_lo, u_hi) = (lo.min(hi), lo.max(hi));
782        let u_min = u_lo + travel * shear.min(0.0);
783        let u_max = u_hi + travel * shear.max(0.0);
784        Some(
785            ogeom_geom::PlaneSurface::over(
786                plane,
787                (u_min - margin, u_max + margin),
788                (-margin, travel * rise + margin),
789            )?
790            .into(),
791        )
792    } else if let ogeom_geom::Curve::Circle(c) = &geometry
793        && !c.is_reversed()
794        && c.circle()
795            .frame()
796            .z()
797            .vector()
798            .dot(direction.vector())
799            .abs()
800            >= 1.0 - tol.angular()
801    {
802        // A circular profile edge swept along its own axis is a cylinder,
803        // and on the circle's own frame the chart *is* the extrusion's
804        // (u the circle's angle, v the travel), so the pcurves below serve
805        // either surface unchanged, and the boolean's same-domain
806        // resolution meets a prism wall as the cylinder it is. Swept
807        // against its axis (a tool pushed down through a block), or where
808        // the circle's frame is left-handed (mirrored), it is the cylinder
809        // on the right-handed frame turned to the travel. That frame's
810        // angle runs the way the circle's does where the circle turns about
811        // the travel (`x ร— y` along it), and otherwise the other way round:
812        // the chart maps `u` to a turn less `u`.
813        let circle = c.circle();
814        let right = circle.frame().handedness() == ogeom_math::Handedness::Right;
815        let spin = circle.frame().z().vector() * circle.frame().handedness().sign();
816        let frame = if right && spin.dot(direction.vector()) > 0.0 {
817            circle.frame()
818        } else if spin.dot(direction.vector()) > 0.0 {
819            ogeom_math::Frame::new(circle.centre(), direction, circle.frame().x(), tol)?
820        } else {
821            chart = Box::new(|(u, v): (f64, f64)| (core::f64::consts::TAU - u, v));
822            // The extrusion's normal, the tangent crossed with a travel
823            // against the axis, points in; the cylinder's points out.
824            turned = true;
825            ogeom_math::Frame::new(circle.centre(), direction, circle.frame().x(), tol)?
826        };
827        let margin = travel * 0.1 + 1.0;
828        Some(
829            ogeom_geom::CylinderSurface::new(
830                ogeom_math::Cylinder::new(frame, circle.radius(), tol)?,
831                (-margin, travel + margin),
832            )?
833            .into(),
834        )
835    } else {
836        None
837    };
838    let surface = model.geometry_mut().add_surface(match canonical {
839        Some(exact) => exact,
840        None => ExtrusionSurface::new(geometry, direction, travel)?.into(),
841    });
842
843    // The extrusion's `u` is the *curve's* own parameter, and the curve does
844    // not care which way the wire walks it. The face is built over the
845    // curve's own direction, its rails standing at `u = lo` and `u = hi`,
846    // and a reversed occurrence only decides which side it presents.
847    let reversed = edge.orientation() == Orientation::Reversed;
848
849    // The four sides of the extrusion's parameter rectangle: the edge along the
850    // bottom, the same edge displaced along the top, and the two vertical rails
851    // its endpoints sweep out.
852    let bottom = edge.oriented(Orientation::Forward);
853    let top = bottom.moved(displacement);
854    let walk_start = rail(model, rails, edge, displacement, vector, false, tol)?;
855    let walk_end = rail(model, rails, edge, displacement, vector, true, tol)?;
856    let (low_rail, high_rail) = if reversed {
857        (walk_end, walk_start)
858    } else {
859        (walk_start, walk_end)
860    };
861
862    pcurve(
863        model,
864        &bottom,
865        surface,
866        chart((lo, 0.0)),
867        chart((hi, 0.0)),
868        tol,
869    )?;
870    pcurve(
871        model,
872        &top,
873        surface,
874        chart((lo, travel)),
875        chart((hi, travel)),
876        tol,
877    )?;
878    // The ring runs counter-clockwise in the extrusion's chart: along the
879    // bottom, up the rail at `hi`, back along the top, down the rail at
880    // `lo`. A chart turned the other way round runs it backward, so the
881    // surface's own chart always sees it counter-clockwise.
882    let (up, down) = if turned { (lo, hi) } else { (hi, lo) };
883    if low_rail.is_same(&high_rail) {
884        // A closed profile edge (a full circle) starts and ends at one
885        // vertex, so its two rails are one edge appearing at both `u = lo` and
886        // `u = hi`. That is a seam, and it needs both pcurves: giving it one
887        // would leave the face's boundary running up the same side twice and
888        // enclosing nothing. The ring walks it up where it climbs and down
889        // where it descends.
890        seam_pcurves(
891            model,
892            &low_rail,
893            surface,
894            (chart((up, 0.0)), chart((up, travel))),
895            (chart((down, 0.0)), chart((down, travel))),
896            tol,
897        )?;
898    } else {
899        pcurve(
900            model,
901            &low_rail,
902            surface,
903            chart((lo, 0.0)),
904            chart((lo, travel)),
905            tol,
906        )?;
907        pcurve(
908            model,
909            &high_rail,
910            surface,
911            chart((hi, 0.0)),
912            chart((hi, travel)),
913            tol,
914        )?;
915    }
916
917    let ring = if turned {
918        [
919            low_rail.clone(),
920            top.clone(),
921            high_rail.reversed(),
922            bottom.reversed(),
923        ]
924    } else {
925        [
926            bottom.clone(),
927            high_rail.clone(),
928            top.reversed(),
929            low_rail.reversed(),
930        ]
931    };
932    let boundary = make_wire(model, &ring, tol)?.shape;
933    let built = make_face_on(model, surface, std::slice::from_ref(&boundary), tol)?.shape;
934
935    // The extrusion's normal is the curve's tangent crossed with the sweep, so
936    // it follows the *curve* and not the wire's walk of it. An edge the wire
937    // walks backwards therefore makes a face whose default side points into the
938    // solid, and the occurrence has to be reversed to present the other one;
939    // reversed, it walks the edge as the wire does. Every profile with a mixed
940    // wire (four of a box's six faces) has some of each, so this cannot be
941    // decided once for the profile.
942    let face = if reversed != turned {
943        built.reversed()
944    } else {
945        built
946    };
947    model.set_derived(&face, std::slice::from_ref(edge), roles::SWEEP_SIDE)?;
948
949    let mut history = History::new();
950    // Both, not either. The edge survives as the bottom of the prism *and*
951    // makes the lateral face; recording only one is how a reference to "that
952    // edge" or to "the face from that edge" ends up resolving to nothing.
953    history.generate(edge, face.clone());
954    history.generate(edge, edge.moved(displacement));
955    Ok((face, history))
956}
957
958/// The turn a revolution makes, carried down to every entity it builds.
959struct Turn {
960    /// What it turns about.
961    axis: Axis,
962    /// How far, in radians.
963    angle: f64,
964    /// Whether the turn closes on itself, so the two ends are one seam rather
965    /// than two faces.
966    full: bool,
967    /// Where the far end sits. The identity for a full turn, because there is
968    /// no far end; it is the near end again.
969    displacement: Location,
970}
971
972/// Revolve a shape about `axis`, through `angle` radians.
973///
974/// A face becomes a solid, a wire becomes a shell, an edge becomes a face:
975/// the same rule as [`make_prism`], turning instead of travelling. A full turn
976/// closes on itself: its two ends are the *same* profile, meeting along a seam,
977/// which is the topology [`make_cylinder`](crate::make_cylinder) produces for
978/// the identical solid. A partial turn has two distinct ends and caps them.
979///
980/// The seam is not an implementation detail to be avoided. Building a full turn
981/// as two half-turns would sidestep it and give a different face count for the
982/// same solid, and a boolean later has to split along seams like any other
983/// edge.
984///
985/// # Errors
986///
987/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the angle is
988/// not in `(0, 2pi]`, if the shape is of a kind that cannot be swept, if an
989/// edge of the profile has no 3D curve, if the profile meets the axis anywhere
990/// but at its ends (it would sweep through itself), or if the profile lies in
991/// a surface the turn runs along, which encloses no volume.
992pub fn make_revolution(
993    model: &mut Model,
994    profile: &Shape,
995    axis: Axis,
996    angle: f64,
997    tol: Tolerances,
998) -> OgeomResult<Built> {
999    if !angle.is_finite() || angle <= tol.angular() || angle > TAU + tol.angular() {
1000        ogeom_bail!(
1001            Construction,
1002            "a revolution turns through (0, 2pi]; {angle} does not"
1003        );
1004    }
1005    let angle = angle.min(TAU);
1006    let full = TAU - angle <= tol.angular();
1007    model.begin_operation();
1008
1009    let turn = Turn {
1010        axis,
1011        angle,
1012        full,
1013        // One datum for the whole turn, so every entity at the far end shares a
1014        // single placement, and none at all for a full turn, whose far end is
1015        // its near end.
1016        displacement: if full {
1017            Location::identity()
1018        } else {
1019            Location::of(model.add_datum(Transform::rotation(axis, angle)))
1020        },
1021    };
1022
1023    let rails = &mut Rails::default();
1024    match model.kind_of(profile)? {
1025        ShapeType::Face => revolution_over_face(model, rails, profile, &turn, tol),
1026        ShapeType::Wire => {
1027            let (faces, history) = revolution_over_wire(model, rails, profile, &turn, tol)?;
1028            let shell = make_shell(model, &faces)?.shape;
1029            Ok(Built::new(shell, history))
1030        }
1031        ShapeType::Edge => {
1032            let (face, history) = revolution_over_edge(model, rails, profile, &turn, tol)?;
1033            let Some(face) = face else {
1034                ogeom_bail!(
1035                    Construction,
1036                    "an edge lying along the axis turns onto itself and sweeps \
1037                     out no face"
1038                );
1039            };
1040            Ok(Built::new(face, history))
1041        }
1042        other => ogeom_bail!(
1043            Construction,
1044            "a {other:?} cannot be revolved into anything; revolve an edge, a \
1045             wire or a face"
1046        ),
1047    }
1048}
1049
1050/// A face revolved into a solid.
1051fn revolution_over_face(
1052    model: &mut Model,
1053    rails: &mut Rails,
1054    face: &Shape,
1055    turn: &Turn,
1056    tol: Tolerances,
1057) -> OgeomResult<Built> {
1058    // A full turn keeps no end, so nothing of it needs a trim where no
1059    // closed form gives one; a partial turn keeps the profile as both.
1060    if turn.full {
1061        crate::build::trimmed_where_bare(model, face, tol)?;
1062    } else {
1063        crate::build::trimmed_or_fitted_where_bare(model, face, tol)?;
1064    }
1065    // The same question the prism asks, with the sweep direction read off the
1066    // turn: at the profile, revolving moves it along the tangent to its circle
1067    // about the axis, so that tangent is what its normal is compared against.
1068    let (point, normal) = crate::measure::face_normal(model, face, tol)?;
1069    let tangent = turn
1070        .axis
1071        .direction
1072        .cross_with(point - turn.axis.project(point));
1073    let reach = tangent.magnitude();
1074    if reach <= tol.confusion() {
1075        ogeom_bail!(
1076            Construction,
1077            "the profile sits on the axis, so revolving it sweeps out nothing"
1078        );
1079    }
1080    let along = normal.dot(tangent) / reach;
1081    if along.abs() <= tol.angular() {
1082        ogeom_bail!(
1083            Construction,
1084            "the turn runs along the profile's own surface, so it encloses no \
1085             volume; a face revolved within its own plane is not a solid"
1086        );
1087    }
1088    // The caps face the way the profile's surface does, turned to face
1089    // along the sweep as the prism's do.
1090    let profile = if along < 0.0 {
1091        face.reversed()
1092    } else {
1093        face.clone()
1094    };
1095    // Each wall's material side follows its wire's walk, so the walks are
1096    // measured from the traversal itself, as each loop's area vector against
1097    // the sweep tangent, as the prism measures them. The face's stated
1098    // normal cannot answer this: it speaks the carrier's chart, and one loop
1099    // reads as either hand depending on which way the chart was laid down.
1100    // The loop enclosing the most is the outline and must turn positively,
1101    // every other a hole turning the other way; a loop wound against its
1102    // role is turned round in the profile itself, so the caps and the walls
1103    // agree on which way each edge is walked.
1104    let wires = model.children_of(&profile)?;
1105    if wires.is_empty() {
1106        ogeom_bail!(Construction, "the profile has no boundary to revolve");
1107    }
1108    let turns: Vec<f64> = wires
1109        .iter()
1110        .map(|w| wire_turn(model, w, tangent, tol))
1111        .collect::<OgeomResult<_>>()?;
1112    let outer = turns
1113        .iter()
1114        .enumerate()
1115        .max_by(|a, b| {
1116            a.1.abs()
1117                .partial_cmp(&b.1.abs())
1118                .unwrap_or(core::cmp::Ordering::Equal)
1119        })
1120        .map_or(0, |(i, _)| i);
1121    let against: Vec<bool> = turns
1122        .iter()
1123        .enumerate()
1124        .map(|(i, turn)| turn * if i == outer { 1.0 } else { -1.0 } < 0.0)
1125        .collect();
1126    let profile = if against.contains(&true) {
1127        rewound(model, &profile, &against, tol)?
1128    } else {
1129        profile
1130    };
1131
1132    let mut history = History::new();
1133    let mut faces = Vec::new();
1134    for wire in &model.children_of(&profile)? {
1135        let (sides, wire_history) = revolution_over_wire(model, rails, wire, turn, tol)?;
1136        history = history.then(&wire_history);
1137        faces.extend(sides);
1138    }
1139
1140    if turn.full {
1141        // A full turn has no ends. The profile is an interior cross-section of
1142        // the result, not a face of it, so it is *deleted*, while still
1143        // generating everything its edges and vertices swept out. Reporting it
1144        // as surviving would leave a reference resolving to a face that is not
1145        // on the solid.
1146        history.delete(face);
1147    } else {
1148        let bottom = profile.reversed();
1149        let top = profile.moved(&turn.displacement);
1150        model.set_derived(&bottom, std::slice::from_ref(face), roles::SWEEP_BOTTOM)?;
1151        model.set_derived(&top, std::slice::from_ref(face), roles::SWEEP_TOP)?;
1152        history.generate(face, top.clone());
1153        faces.push(bottom);
1154        faces.push(top);
1155    }
1156
1157    let shell = make_shell(model, &faces)?.shape;
1158    let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
1159    history.generate(face, solid.clone());
1160    Ok(Built::new(solid, history))
1161}
1162
1163/// Every face a wire revolves out.
1164fn revolution_over_wire(
1165    model: &mut Model,
1166    rails: &mut Rails,
1167    wire: &Shape,
1168    turn: &Turn,
1169    tol: Tolerances,
1170) -> OgeomResult<(Vec<Shape>, History)> {
1171    let mut faces = Vec::new();
1172    let mut history = History::new();
1173    for edge in model.ordered_children_of(wire)? {
1174        let (face, edge_history) = revolution_over_edge(model, rails, &edge, turn, tol)?;
1175        history = history.then(&edge_history);
1176        // An edge lying along the axis turns onto itself. It contributes no
1177        // face, which is what makes a rectangle with one side on the axis
1178        // revolve into a cylinder (three faces) rather than into a cylinder
1179        // with a fourth face of no area down its middle.
1180        faces.extend(face);
1181    }
1182    if faces.is_empty() {
1183        ogeom_bail!(
1184            Construction,
1185            "the whole wire lies along the axis, so it revolves out nothing"
1186        );
1187    }
1188    Ok((faces, history))
1189}
1190
1191/// One edge revolved into one face.
1192///
1193/// The rectangle is transposed from the prism's: a revolution's `u` is the
1194/// angle turned and its `v` is the generating curve's own parameter, so the
1195/// profile edge runs up the *sides* of the parameter rectangle and the circles
1196/// its endpoints sweep run across the top and bottom.
1197fn revolution_over_edge(
1198    model: &mut Model,
1199    rails: &mut Rails,
1200    edge: &Shape,
1201    turn: &Turn,
1202    tol: Tolerances,
1203) -> OgeomResult<(Option<Shape>, History)> {
1204    let Some(node) = model.node(edge) else {
1205        ogeom_bail!(Dangling, "edge is not in this model");
1206    };
1207    let NodeData::Edge(data) = node.data() else {
1208        ogeom_bail!(Construction, "edge node holds no edge data");
1209    };
1210    let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1211        ogeom_bail!(
1212            Construction,
1213            "an edge with no curve in space has no shape to revolve; a \
1214             degenerate edge sweeps out nothing and has to be handled by its \
1215             face, not here"
1216        );
1217    };
1218    let Some(geometry) = model.geometry().curve(*curve).cloned() else {
1219        ogeom_bail!(Dangling, "curve is not in this model");
1220    };
1221
1222    // As for the prism: the surface is the curve where the edge actually is,
1223    // and the range is carried across the placement's effect on the parameter.
1224    let placement = edge.transform(model.datums())?;
1225    let stored = geometry.domain();
1226    let geometry = geometry.transformed(&placement, tol)?;
1227    let placed = geometry.domain();
1228    let (lo, hi) = (
1229        ogeom_geom::carried(range.0, stored, placed),
1230        ogeom_geom::carried(range.1, stored, placed),
1231    );
1232    if axis_relation(&geometry, (lo, hi), turn.axis, tol)? == AxisRelation::On {
1233        return Ok((None, History::new()));
1234    }
1235
1236    // A profile line perpendicular to the axis stays at one height as it
1237    // turns: the face it sweeps is a region of a *plane*, and naming that
1238    // plane, rather than dressing it as a revolution with a polar chart, a
1239    // seam and a degenerate centre edge, is what lets a revolved rectangle
1240    // match `make_cylinder` face for face and edge for edge.
1241    if let ogeom_geom::Curve::Line(line) = &geometry
1242        && line
1243            .axis()
1244            .direction
1245            .vector()
1246            .dot(turn.axis.direction.vector())
1247            .abs()
1248            <= tol.angular()
1249    {
1250        return flat_revolution(model, rails, edge, &geometry, (lo, hi), turn, tol);
1251    }
1252
1253    let canonical = canonical_revolution(&geometry, (lo, hi), turn, tol)?;
1254
1255    // Where the profile runs *against* the chart's own `v`, the canonical
1256    // surface's normal is the revolution's reversed. A rectangular profile has
1257    // one such side and one of the other, so both occur in a single ring and
1258    // the face's own flag has to absorb it.
1259    let opposed = canonical
1260        .as_ref()
1261        .is_some_and(|(_, (chart_lo, chart_hi))| chart_hi < chart_lo);
1262    let (lo, hi) = canonical.as_ref().map_or((lo, hi), |&(_, chart)| chart);
1263    let surface = match canonical {
1264        Some((exact, _)) => model.geometry_mut().add_surface(exact),
1265        None => model.geometry_mut().add_surface(
1266            ogeom_geom::RevolutionSurface::new(geometry, turn.axis, turn.angle)?.into(),
1267        ),
1268    };
1269
1270    let reversed = edge.orientation() == Orientation::Reversed;
1271    let (v_start, v_end) = if reversed { (hi, lo) } else { (lo, hi) };
1272    let (near, far) = (0.0, turn.angle);
1273    // Whether the walk runs down the chart's `v`: the ring is then walked
1274    // the other way round, so the chart always sees it counter-clockwise.
1275    let descending = reversed != opposed;
1276
1277    // The circles the two ends sweep. A full turn brings each back to where it
1278    // started, so it is one closed edge; a partial turn leaves an arc between
1279    // the endpoint and its rotated copy.
1280    let start_rail = revolved_rail(model, rails, edge, turn, false, tol)?;
1281    let end_rail = revolved_rail(model, rails, edge, turn, true, tol)?;
1282
1283    if start_rail.is_same(&end_rail) {
1284        // A closed profile edge (revolving a circle makes a torus) returns to
1285        // one vertex, so its two rails are one edge bounding the face across
1286        // both the bottom and the top of the parameter rectangle. That is a
1287        // seam in `v`, and it needs both pcurves for the same reason a seam in
1288        // `u` does. The ring walks it forward where it runs with the turn.
1289        let (forward, backward) = if descending {
1290            (v_end, v_start)
1291        } else {
1292            (v_start, v_end)
1293        };
1294        seam_pcurves(
1295            model,
1296            &start_rail,
1297            surface,
1298            ((near, forward), (far, forward)),
1299            ((near, backward), (far, backward)),
1300            tol,
1301        )?;
1302    } else {
1303        pcurve(
1304            model,
1305            &start_rail,
1306            surface,
1307            (near, v_start),
1308            (far, v_start),
1309            tol,
1310        )?;
1311        pcurve(model, &end_rail, surface, (near, v_end), (far, v_end), tol)?;
1312    }
1313
1314    // The profile edge itself runs up the sides. Both pcurves follow the
1315    // curve's own parameterization, `lo` to `hi`, because a pcurve describes
1316    // the edge and not the wire's walk of it.
1317    let displaced = edge.moved(&turn.displacement);
1318    if turn.full {
1319        // The two sides are one edge appearing twice, at `u = 0` and at
1320        // `u = 2pi`. Which pcurve applies is decided by the occurrence's
1321        // orientation: the ring below walks the edge's own direction up the
1322        // far side and down the near one, or the other way round where it
1323        // runs backward.
1324        let (forward, reversed_side) = if reversed != descending {
1325            (near, far)
1326        } else {
1327            (far, near)
1328        };
1329        seam_pcurves(
1330            model,
1331            edge,
1332            surface,
1333            ((forward, lo), (forward, hi)),
1334            ((reversed_side, lo), (reversed_side, hi)),
1335            tol,
1336        )?;
1337    } else {
1338        pcurve(model, edge, surface, (near, lo), (near, hi), tol)?;
1339        pcurve(model, &displaced, surface, (far, lo), (far, hi), tol)?;
1340    }
1341
1342    // Round the rectangle: across the bottom in the direction of the turn, up
1343    // the far side of the profile, back across the top, down the near side;
1344    // where the walk runs down the chart, the same ring the other way round.
1345    let ring = if descending {
1346        [
1347            edge.clone(),
1348            end_rail.clone(),
1349            displaced.reversed(),
1350            start_rail.reversed(),
1351        ]
1352    } else {
1353        [
1354            start_rail.clone(),
1355            displaced.clone(),
1356            end_rail.reversed(),
1357            edge.reversed(),
1358        ]
1359    };
1360    let boundary = make_wire(model, &ring, tol)?.shape;
1361    let built = make_face_on(model, surface, std::slice::from_ref(&boundary), tol)?.shape;
1362
1363    // A revolution's normal is the *turn's* tangent crossed with the curve's,
1364    // because the angle is `u` and the curve is `v`. The prism's is the other
1365    // way round, so the two disagree by a sign for the same walk, and an
1366    // occurrence the wire walks forwards is the one that has to be reversed
1367    // here. It is not the surface that decides which side is material; it is
1368    // which way the profile's wire goes round.
1369    //
1370    // A cylinder's `v` climbs with the axis whichever way the profile ran, so
1371    // where the two disagree the chart's normal is the revolution's reversed
1372    // and this flag carries the difference.
1373    let face = if reversed != opposed {
1374        built
1375    } else {
1376        built.reversed()
1377    };
1378    model.set_derived(&face, std::slice::from_ref(edge), roles::SWEEP_SIDE)?;
1379
1380    let mut history = History::new();
1381    // Both, not either, as for the prism. The edge makes the lateral face
1382    // *and* survives: on a partial turn as the far side, and on a full turn as
1383    // the seam, which is the same edge occurring twice on one face rather than
1384    // an edge that ceased to exist.
1385    history.generate(edge, face.clone());
1386    if !turn.full {
1387        history.generate(edge, displaced);
1388    }
1389    Ok((Some(face), history))
1390}
1391
1392/// The surface a profile sweeps, named where the vocabulary has a name for
1393/// it, with the profile's range carried into that surface's own `v`.
1394///
1395/// Named because a revolution is a surface nothing has a closed form for:
1396/// no intersection answers `Same` for one, so a revolved body can
1397/// never melt against the cylinder or cone or torus it *is*, and every
1398/// surface meeting it has to be marched into a fitted curve where an exact
1399/// section was available.
1400///
1401/// Each frame below is built so the chart *is* the revolution's (`u` stays
1402/// the angle turned from the profile's own meridian), so only `v` moves, and
1403/// it moves affinely, which is what lets the straight pcurves the caller
1404/// writes stay straight. The returned pair is the profile's own `(lo, hi)`
1405/// read in that `v`; `hi < lo` says the profile runs against the chart, which
1406/// the caller folds into the face's orientation.
1407///
1408/// A profile perpendicular to the axis is not here: it sweeps a plane, and
1409/// [`flat_revolution`] owns that case because it builds a different face.
1410fn canonical_revolution(
1411    geometry: &ogeom_geom::Curve,
1412    (lo, hi): (f64, f64),
1413    turn: &Turn,
1414    tol: Tolerances,
1415) -> OgeomResult<Option<(ogeom_geom::SurfaceGeometry, (f64, f64))>> {
1416    let axis = turn.axis;
1417    let along = axis.direction.vector();
1418    let radius_of = |p: Point| p - axis.project(p);
1419    match geometry {
1420        // A straight profile sweeps a cylinder where it runs parallel to the
1421        // axis and a cone where it leans. Both carry `v` to height along the
1422        // axis, and the lean is the only difference between them.
1423        ogeom_geom::Curve::Line(_) => {
1424            let (p_lo, p_hi) = (geometry.point_at(lo, tol)?, geometry.point_at(hi, tol)?);
1425            let (h_lo, h_hi) = (
1426                (p_lo - axis.location).dot(along),
1427                (p_hi - axis.location).dot(along),
1428            );
1429            let rise = h_hi - h_lo;
1430            if rise.abs() <= tol.confusion() {
1431                return Ok(None);
1432            }
1433            let (r_lo, r_hi) = (radius_of(p_lo).magnitude(), radius_of(p_hi).magnitude());
1434            // The radial direction is the profile's own meridian, taken from
1435            // whichever end stands off the axis: a cone's apex end has none.
1436            let stem = if r_lo > r_hi { p_lo } else { p_hi };
1437            let Ok(radial) = Direction::new(radius_of(stem), tol) else {
1438                return Ok(None);
1439            };
1440            let slope = (r_hi - r_lo) / rise;
1441            if slope.abs() <= tol.angular() {
1442                let frame = Frame::new(axis.location, axis.direction, radial, tol)?;
1443                let margin = rise.abs() * 0.1 + 1.0;
1444                let surface = ogeom_geom::CylinderSurface::new(
1445                    ogeom_math::Cylinder::new(frame, r_lo, tol)?,
1446                    (h_lo.min(h_hi) - margin, h_lo.max(h_hi) + margin),
1447                )?;
1448                return Ok(Some((surface.into(), (h_lo, h_hi))));
1449            }
1450            // The cone is measured from where the profile's own low end
1451            // stands, so its reference radius is one the profile states and
1452            // cannot come out negative.
1453            let base = axis.project(p_lo);
1454            let frame = Frame::new(base, axis.direction, radial, tol)?;
1455            let cone = ogeom_math::Cone::new(frame, r_lo, slope.atan(), tol)?;
1456            let (v_lo, v_hi) = (0.0_f64, rise);
1457            let margin = rise.abs() * 0.1 + 1.0;
1458            // The window stops at the apex. Past it the radius would come back
1459            // negative, which is the *other* nappe: a second surface wearing
1460            // this one's name, and nothing downstream expects to meet it.
1461            let apex = -r_lo / slope;
1462            let (mut low, mut high) = (v_lo.min(v_hi) - margin, v_lo.max(v_hi) + margin);
1463            if slope > 0.0 {
1464                low = low.max(apex);
1465            } else {
1466                high = high.min(apex);
1467            }
1468            let surface = ogeom_geom::ConeSurface::new(cone, (low, high))?;
1469            Ok(Some((surface.into(), (v_lo, v_hi))))
1470        }
1471        // A circle in a meridian plane sweeps a torus, whose `v` is the angle
1472        // round the tube. The circle's own parameter is an angle too, so the
1473        // two differ by a turn and possibly a sign; affine either way.
1474        ogeom_geom::Curve::Circle(c) => {
1475            let circle = c.circle();
1476            let (centre, normal) = (circle.frame().origin(), circle.frame().z().vector());
1477            let offset = radius_of(centre);
1478            let (major, minor) = (offset.magnitude(), circle.radius());
1479            // The circle's plane must contain the axis (its normal square to
1480            // the axis and to the offset), or the sweep is no torus. A tube
1481            // that reaches its own axis is a spindle, which this vocabulary
1482            // does not name and the revolution still describes.
1483            if normal.dot(along).abs() > tol.angular()
1484                || normal.dot(offset).abs() > tol.confusion()
1485                || major <= minor + tol.confusion()
1486            {
1487                return Ok(None);
1488            }
1489            let Ok(radial) = Direction::new(offset, tol) else {
1490                return Ok(None);
1491            };
1492            let frame = Frame::new(axis.project(centre), axis.direction, radial, tol)?;
1493            let torus = ogeom_math::Torus::new(frame, major, minor, tol)?;
1494
1495            // Where the profile's parameter stands round the tube, and which
1496            // way it runs: `v = atan2(z, radial)` about the tube's centre, so
1497            // the sense is the sign of that angle's derivative.
1498            let (at, tangent) = (geometry.point_at(lo, tol)?, geometry.d1_at(lo, tol)?);
1499            let spoke = at - centre;
1500            let (a, b) = (spoke.dot(radial.vector()), spoke.dot(along));
1501            let (da, db) = (tangent.dot(radial.vector()), tangent.dot(along));
1502            let v_lo = b.atan2(a);
1503            let sense = a.mul_add(db, -(b * da));
1504            if sense.abs() <= tol.confusion() {
1505                return Ok(None);
1506            }
1507            let v_hi = (hi - lo).copysign(sense) + v_lo;
1508            Ok(Some((
1509                ogeom_geom::TorusSurface::new(torus).into(),
1510                (v_lo, v_hi),
1511            )))
1512        }
1513        _ => Ok(None),
1514    }
1515}
1516
1517/// The planar face a radial profile line sweeps: a disc, an annulus or a
1518/// pie, on the plane it actually turns in.
1519fn flat_revolution(
1520    model: &mut Model,
1521    rails: &mut Rails,
1522    edge: &Shape,
1523    geometry: &ogeom_geom::Curve,
1524    range: (f64, f64),
1525    turn: &Turn,
1526    tol: Tolerances,
1527) -> OgeomResult<(Option<Shape>, History)> {
1528    use ogeom_geom::Curve3d as _;
1529    let (lo, hi) = range;
1530    let axis_dir = turn.axis.direction;
1531    let at = geometry.point_at(lo, tol)?;
1532    let height = (at - turn.axis.location).dot(axis_dir.vector());
1533    let foot = turn.axis.location + axis_dir.vector() * height;
1534    let radius_of = |p: ogeom_math::Point| (p - foot).magnitude();
1535    let far = geometry.point_at(hi, tol)?;
1536    let reach = radius_of(at).max(radius_of(far)) * 2.0 + 1.0;
1537    let plane = ogeom_math::Plane::new(ogeom_math::Frame::about(foot, axis_dir));
1538    let plane_surface: ogeom_geom::SurfaceGeometry =
1539        ogeom_geom::PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
1540    let surface = model.geometry_mut().add_surface(plane_surface.clone());
1541
1542    let start_rail = revolved_rail(model, rails, edge, turn, false, tol)?;
1543    let end_rail = revolved_rail(model, rails, edge, turn, true, tol)?;
1544    let is_degenerate = |model: &Model, e: &Shape| -> bool {
1545        model
1546            .node(e)
1547            .and_then(|n| n.data().as_edge())
1548            .is_some_and(|d| d.curve3d().is_none())
1549    };
1550
1551    // Exact Cartesian pcurves for whichever edges bound the face: a polar
1552    // chart's degenerate centre has no place here.
1553    let attach = |model: &mut Model, occurrence: &Shape| -> OgeomResult<()> {
1554        let Some(data) = model.node(occurrence).and_then(|n| n.data().as_edge()) else {
1555            ogeom_bail!(Construction, "a flat revolution edge holds no data");
1556        };
1557        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1558            ogeom_bail!(Construction, "a flat revolution edge has no curve");
1559        };
1560        let (curve, range) = (*curve, *range);
1561        let Some(stored) = model.geometry().curve(curve) else {
1562            ogeom_bail!(Dangling, "curve is not in this model");
1563        };
1564        let placed = stored
1565            .clone()
1566            .transformed(&occurrence.transform(model.datums())?, tol)?;
1567        let Some(pc) = ogeom_intersect::exact_pcurve_of(&placed, &plane_surface, tol) else {
1568            ogeom_bail!(
1569                Construction,
1570                "a flat revolution edge has no closed-form pcurve on its plane"
1571            );
1572        };
1573        crate::build::attach_pcurve(
1574            model,
1575            occurrence,
1576            pc,
1577            surface,
1578            occurrence.location().clone(),
1579            range,
1580        )
1581    };
1582
1583    let reversed = edge.orientation() == Orientation::Reversed;
1584    // Each ring runs counter-clockwise about the plane's normal, the axis,
1585    // where it bounds the face from outside, and clockwise round a hole.
1586    let wound = |model: &mut Model, ring: Vec<Shape>, outside: bool| -> OgeomResult<Shape> {
1587        let turning = edges_turn(model, &ring, axis_dir.vector(), tol)?;
1588        let ring = if (turning > 0.0) == outside {
1589            ring
1590        } else {
1591            ring.iter().rev().map(Shape::reversed).collect()
1592        };
1593        Ok(make_wire(model, &ring, tol)?.shape)
1594    };
1595    let built = if turn.full {
1596        let mut circles = Vec::new();
1597        for rail in [&start_rail, &end_rail] {
1598            if is_degenerate(model, rail) {
1599                continue;
1600            }
1601            attach(model, rail)?;
1602            let turning = edges_turn(model, std::slice::from_ref(rail), axis_dir.vector(), tol)?;
1603            circles.push((turning.abs(), rail.clone()));
1604        }
1605        if circles.is_empty() {
1606            ogeom_bail!(Construction, "a flat revolution swept out no boundary");
1607        }
1608        circles.sort_by(|a, b| b.0.total_cmp(&a.0));
1609        let mut wires = Vec::with_capacity(circles.len());
1610        for (i, (_, rail)) in circles.into_iter().enumerate() {
1611            wires.push(wound(model, vec![rail], i == 0)?);
1612        }
1613        make_face_on(model, surface, &wires, tol)?.shape
1614    } else {
1615        let displaced = edge.moved(&turn.displacement);
1616        let mut ring: Vec<Shape> = Vec::new();
1617        if !is_degenerate(model, &start_rail) {
1618            attach(model, &start_rail)?;
1619            ring.push(start_rail.clone());
1620        }
1621        attach(model, &displaced)?;
1622        ring.push(displaced.clone());
1623        if !is_degenerate(model, &end_rail) {
1624            attach(model, &end_rail)?;
1625            ring.push(end_rail.reversed());
1626        }
1627        attach(model, edge)?;
1628        ring.push(edge.reversed());
1629        let boundary = wound(model, ring, true)?;
1630        make_face_on(model, surface, std::slice::from_ref(&boundary), tol)?.shape
1631    };
1632
1633    // The material side is the profile wire's business, as for every sweep;
1634    // the plane's own normal relates to the revolution's by the sign of the
1635    // line's outward sense.
1636    let outward_sense = {
1637        let radial = if radius_of(far) >= radius_of(at) {
1638            far - foot
1639        } else {
1640            at - foot
1641        };
1642        let d = (far - at).dot(radial);
1643        d < 0.0
1644    };
1645    let face = if reversed != outward_sense {
1646        built.reversed()
1647    } else {
1648        built
1649    };
1650    model.set_derived(&face, std::slice::from_ref(edge), roles::SWEEP_SIDE)?;
1651
1652    let mut history = History::new();
1653    history.generate(edge, face.clone());
1654    if !turn.full {
1655        history.generate(edge, edge.moved(&turn.displacement));
1656    }
1657    Ok((Some(face), history))
1658}
1659
1660/// The circle or arc one endpoint of the profile sweeps out.
1661///
1662/// Shared between the two faces that meet along it, exactly as the prism's
1663/// rails are; building one per face would leave every rail used once and the
1664/// solid open along every corner.
1665///
1666/// An endpoint *on* the axis sweeps out nothing, and gets a degenerate edge: it
1667/// still bounds the face across its side of the parameter rectangle, and
1668/// leaving it out would leave the boundary open there with nothing to trim to.
1669fn revolved_rail(
1670    model: &mut Model,
1671    rails: &mut Rails,
1672    edge: &Shape,
1673    turn: &Turn,
1674    at_end: bool,
1675    tol: Tolerances,
1676) -> OgeomResult<Shape> {
1677    let Some((start, end)) = crate::build::edge_vertices(model, edge)? else {
1678        ogeom_bail!(
1679            Construction,
1680            "an unbounded edge has no endpoints to sweep into rails"
1681        );
1682    };
1683    let base = if at_end { end } else { start };
1684    if let Some(existing) = rails.get(&base.node()) {
1685        return Ok(existing.clone());
1686    }
1687
1688    let Some(node) = model.node(&base) else {
1689        ogeom_bail!(Dangling, "vertex is not in this model");
1690    };
1691    let Some(data) = node.data().as_vertex() else {
1692        ogeom_bail!(Construction, "vertex node holds no point");
1693    };
1694    let from = base.transform(model.datums())?.apply(data.point);
1695
1696    // A full turn brings the endpoint back to itself, so the rail is one closed
1697    // edge named twice by the same vertex, which is what keeps "walk to the
1698    // end" meaningful all the way round.
1699    let raised = if turn.full {
1700        base.clone()
1701    } else {
1702        base.moved(&turn.displacement)
1703    };
1704
1705    let radius = from - turn.axis.project(from);
1706    let built = if radius.magnitude() <= tol.confusion() {
1707        let mut data = ogeom_topo::EdgeData::new();
1708        data.degenerate = true;
1709        model.add_edge(data, &[base.clone(), raised])?
1710    } else {
1711        // The circle's `x` points from the axis out to the endpoint, so its
1712        // angle parameter *is* the revolution's `u`: at zero it lands on the
1713        // endpoint exactly, rather than merely nearby.
1714        let frame = Frame::new(
1715            turn.axis.project(from),
1716            turn.axis.direction,
1717            Direction::new(radius, tol)?,
1718            tol,
1719        )?;
1720        let circle = Circle::new(frame, radius.magnitude(), tol)?;
1721        crate::build::make_edge_between(
1722            model,
1723            ogeom_geom::CircleCurve::new(circle).into(),
1724            (0.0, turn.angle),
1725            &base,
1726            &raised,
1727            tol,
1728        )?
1729        .shape
1730    };
1731
1732    model.set_derived(&built, std::slice::from_ref(&base), roles::SWEEP_RAIL)?;
1733    rails.insert(base.node(), built.clone());
1734    Ok(built)
1735}
1736
1737/// How many places along an edge are checked against the axis.
1738const AXIS_SAMPLES: usize = 32;
1739
1740/// Where an edge stands in relation to the axis it is to be turned about.
1741#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1742enum AxisRelation {
1743    /// Clear of it, except possibly at its ends, which sweep out poles.
1744    Clear,
1745    /// Lying along it, so it sweeps out nothing at all.
1746    On,
1747}
1748
1749/// Where an edge stands in relation to the axis, refusing the case that cannot
1750/// be built.
1751///
1752/// An edge *crossing* the axis sweeps out a surface that passes through itself,
1753/// and the solid built on it is wrong in a way nothing downstream can detect:
1754/// its volume is finite and plausible and counts part of space twice. That is
1755/// refused. An edge lying *along* the axis sweeps out nothing (the inner side
1756/// of a rectangle that revolves into a cylinder) and gets no face. An edge
1757/// merely touching the axis at an end sweeps out a pole, which is ordinary.
1758///
1759/// Solved, not sampled. Watching the radial vector reverse direction across
1760/// a row of samples misses a profile grazing the axis *tangentially*
1761/// between samples, where the radial direction never reverses and no sample
1762/// lands on the touch. The decision is exact in two layers: the intersector
1763/// names every point where the curve meets the axis within tolerance, and
1764/// the extrema machinery names every stationary nearest approach, which is
1765/// what catches the tangential graze.
1766fn axis_relation(
1767    curve: &ogeom_geom::Curve,
1768    range: (f64, f64),
1769    axis: Axis,
1770    tol: Tolerances,
1771) -> OgeomResult<AxisRelation> {
1772    // The sampled layer answers one case exactly: an edge every one of
1773    // whose samples sits on the axis is a straight edge lying along it.
1774    let mut points = Vec::with_capacity(AXIS_SAMPLES + 1);
1775    for i in 0..=AXIS_SAMPLES {
1776        #[allow(clippy::cast_precision_loss)]
1777        let t = i as f64 / AXIS_SAMPLES as f64;
1778        let at = range.0 + (range.1 - range.0) * t;
1779        points.push(curve.point_at(at, tol)?);
1780    }
1781    let on_axis = |p: &ogeom_math::Point| (*p - axis.project(*p)).magnitude() <= tol.confusion();
1782    if points.iter().all(on_axis) {
1783        return Ok(AxisRelation::On);
1784    }
1785
1786    // The axis as a bounded line covering the edge's whole extent along it,
1787    // with room to spare either side.
1788    let along = |p: &ogeom_math::Point| (*p - axis.location).dot(axis.direction.vector());
1789    let mut lo = f64::INFINITY;
1790    let mut hi = f64::NEG_INFINITY;
1791    for p in &points {
1792        lo = lo.min(along(p));
1793        hi = hi.max(along(p));
1794    }
1795    let margin = (hi - lo).max(1.0);
1796    let line: ogeom_geom::Curve =
1797        ogeom_geom::LineCurve::over(axis, lo - margin, hi + margin)?.into();
1798    let trimmed: ogeom_geom::Curve = if (range.0, range.1) == curve.domain() {
1799        curve.clone()
1800    } else {
1801        ogeom_geom::TrimmedCurve::new(curve.clone(), range.0, range.1, tol)?.into()
1802    };
1803
1804    // A contact at an end is a pole and is ordinary; anywhere else the
1805    // revolution would pinch or pass through itself.
1806    let (start, end) = (points[0], points[AXIS_SAMPLES]);
1807    let interior = |p: ogeom_math::Point| {
1808        p.distance(start) > tol.confusion() && p.distance(end) > tol.confusion()
1809    };
1810
1811    let hits = ogeom_intersect::intersect_curves(
1812        &trimmed,
1813        &line,
1814        ogeom_intersect::CurveCurveOptions::default(),
1815        tol,
1816    )?;
1817    if !hits.overlaps.is_empty() {
1818        // Lying along the axis in part but not whole: the part that veers off
1819        // still meets the axis away from the ends. Whole-edge overlap was the
1820        // all-samples case above.
1821        ogeom_bail!(
1822            Construction,
1823            "the profile touches the axis away from its ends; revolving it \
1824             would sweep a surface through itself. Split the profile where it \
1825             meets the axis"
1826        );
1827    }
1828    if hits.crossings.iter().any(|c| interior(c.point)) {
1829        ogeom_bail!(
1830            Construction,
1831            "the profile passes through the axis away from its ends; \
1832             revolving it would sweep a surface through itself. Split the \
1833             profile where it meets the axis"
1834        );
1835    }
1836
1837    // The tangential graze: no crossing, but a stationary nearest approach
1838    // reaching the axis at an interior parameter.
1839    let near = ogeom_intersect::extrema_curve_curve(
1840        &trimmed,
1841        &line,
1842        ogeom_intersect::ExtremaOptions::default(),
1843        tol,
1844    )?;
1845    if near
1846        .approaches
1847        .iter()
1848        .any(|a| a.distance <= tol.confusion() && interior(a.point_a))
1849    {
1850        ogeom_bail!(
1851            Construction,
1852            "the profile touches the axis away from its ends; revolving it \
1853             would sweep a surface through itself. Split the profile where it \
1854             meets the axis"
1855        );
1856    }
1857    Ok(AxisRelation::Clear)
1858}
1859
1860/// The edge one endpoint of the profile sweeps out.
1861///
1862/// Shared between the two faces that meet along it (the previous edge's sweep
1863/// and this one's), which is what makes the shell close. Building a rail per
1864/// face instead leaves every one used once and the prism open along every
1865/// corner.
1866fn rail(
1867    model: &mut Model,
1868    rails: &mut Rails,
1869    edge: &Shape,
1870    displacement: &Location,
1871    vector: Vector,
1872    at_end: bool,
1873    tol: Tolerances,
1874) -> OgeomResult<Shape> {
1875    let Some((start, end)) = crate::build::edge_vertices(model, edge)? else {
1876        ogeom_bail!(
1877            Construction,
1878            "an unbounded edge has no endpoints to sweep into rails"
1879        );
1880    };
1881    let base = if at_end { end } else { start };
1882    let raised = base.moved(displacement);
1883
1884    // A rail between the same two vertices already exists if a neighbouring
1885    // edge swept it. Reusing it is not an optimization: two rails between one
1886    // pair of vertices would leave each used once, and the shell open.
1887    if let Some(existing) = rails.get(&base.node()) {
1888        return Ok(existing.clone());
1889    }
1890
1891    let Some(node) = model.node(&base) else {
1892        ogeom_bail!(Dangling, "vertex is not in this model");
1893    };
1894    let Some(data) = node.data().as_vertex() else {
1895        ogeom_bail!(Construction, "vertex node holds no point");
1896    };
1897    let from = base.transform(model.datums())?.apply(data.point);
1898    // The rail is where the vertex travels, and holds its doubt: a vertex
1899    // standing off the profile's curves by its tolerance stands off the
1900    // walls they sweep by as much, all along the rail.
1901    let doubt = data.tolerance;
1902
1903    // Along the travel itself, its parameter the distance travelled: a
1904    // segment between `from` and `from + vector` would measure its length
1905    // off two far points and could come out short of the travel.
1906    let travel = vector.magnitude();
1907    let axis = Axis::new(from, Direction::new(vector, tol)?);
1908    let line = ogeom_geom::LineCurve::over(axis, 0.0, travel)?;
1909    let built =
1910        crate::build::make_edge_between(model, line.into(), (0.0, travel), &base, &raised, tol)?;
1911    model.widen(&built.shape, doubt)?;
1912    model.set_derived(&built.shape, std::slice::from_ref(&base), roles::SWEEP_RAIL)?;
1913    rails.insert(base.node(), built.shape.clone());
1914    Ok(built.shape)
1915}
1916
1917/// The rails built so far in one sweep, keyed by the vertex each rose from.
1918///
1919/// Threaded through rather than looked up in the model, because "is there
1920/// already an edge between these two vertices" is a question the model cannot
1921/// answer without a search, and the answer is only ever about *this* sweep.
1922type Rails = FastMap<TShapeId, Shape>;
1923
1924/// Attach a seam edge's two pcurves, one for each side of the rectangle it
1925/// bounds twice, keyed by the occurrence's own placement as [`pcurve`]
1926/// keys its one: a placed profile edge runs along the seam where it stands,
1927/// not where its node's curve does.
1928fn seam_pcurves(
1929    model: &mut Model,
1930    edge: &Shape,
1931    surface: ogeom_topo::SurfaceId,
1932    forward: ((f64, f64), (f64, f64)),
1933    reversed: ((f64, f64), (f64, f64)),
1934    tol: Tolerances,
1935) -> OgeomResult<()> {
1936    let flat = |p: (f64, f64)| Point2::new(p.0, p.1);
1937    let length = flat(forward.0).distance(flat(forward.1));
1938    let first = model
1939        .geometry_mut()
1940        .add_pcurve(Line2d::segment(flat(forward.0), flat(forward.1), tol)?.into());
1941    let second = model
1942        .geometry_mut()
1943        .add_pcurve(Line2d::segment(flat(reversed.0), flat(reversed.1), tol)?.into());
1944
1945    let Some(node) = model.node_mut(edge) else {
1946        ogeom_bail!(Dangling, "edge is not in this model");
1947    };
1948    let NodeData::Edge(data) = node.data_mut() else {
1949        ogeom_bail!(Construction, "edge node holds no edge data");
1950    };
1951    data.add(EdgeRepr::Seam {
1952        forward: first,
1953        reversed: second,
1954        surface,
1955        location: edge.location().clone(),
1956        range: (0.0, length),
1957    });
1958    Ok(())
1959}
1960
1961/// Attach a straight pcurve between two points of a surface's parameter space.
1962fn pcurve(
1963    model: &mut Model,
1964    edge: &Shape,
1965    surface: ogeom_topo::SurfaceId,
1966    from: (f64, f64),
1967    to: (f64, f64),
1968    tol: Tolerances,
1969) -> OgeomResult<()> {
1970    let (a, b) = (Point2::new(from.0, from.1), Point2::new(to.0, to.1));
1971    let curve: PlanarCurve = Line2d::segment(a, b, tol)?.into();
1972    // Keyed by the occurrence's own placement. The bottom and top of a prism
1973    // are one edge node at two locations, running along two different lines of
1974    // the same parameter space; attached without the placement they would be
1975    // indistinguishable and the face would collapse onto one of them.
1976    crate::build::attach_pcurve(
1977        model,
1978        edge,
1979        curve,
1980        surface,
1981        edge.location().clone(),
1982        (0.0, a.distance(b)),
1983    )
1984}
1985
1986#[cfg(test)]
1987#[allow(clippy::unwrap_used, clippy::expect_used)]
1988mod tests {
1989    use super::*;
1990    use crate::build::is_shell_closed;
1991    use crate::mass::volume_properties;
1992    use approx::assert_relative_eq;
1993    use ogeom_geom::SurfaceKind;
1994    use ogeom_math::{Frame, Point};
1995    use ogeom_mesh::{Deflection, triangulate};
1996    use ogeom_topo::{ShapeType, explore_unique};
1997
1998    const T: Tolerances = Tolerances::millimetres();
1999
2000    fn deflection(chord: f64) -> Deflection {
2001        Deflection {
2002            chord,
2003            ..Deflection::default()
2004        }
2005    }
2006
2007    /// One face of a box of `side`, named by its role.
2008    fn box_face(model: &mut Model, side: f64, role: ogeom_core::Role) -> Shape {
2009        let built = crate::make_box(model, Frame::WORLD, (side, side, side), T).unwrap();
2010        explore_unique(model, &built.shape, ShapeType::Face)
2011            .unwrap()
2012            .into_iter()
2013            .find(|f| {
2014                model
2015                    .provenance_of(f)
2016                    .and_then(ogeom_core::Provenance::role)
2017                    == Some(role)
2018            })
2019            .expect("the box has a face with that role")
2020    }
2021
2022    /// A square face in the xy plane, one unit on a side from the origin.
2023    fn square(model: &mut Model, side: f64) -> Shape {
2024        box_face(model, side, crate::primitive::roles::FACE_MAX_Z)
2025    }
2026
2027    /// A 10 mm square on the xy plane with a round hole, the hole's circle
2028    /// walked the other way from the square's corners or the same way.
2029    fn holed_square(model: &mut Model, hole_like_outline: bool) -> Shape {
2030        let plane = ogeom_math::Plane::new(Frame::WORLD);
2031        let corners = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]
2032            .map(|(x, y)| Point::new(x, y, 0.0));
2033        let outer = crate::build::make_polygon(model, &corners, true, T)
2034            .unwrap()
2035            .shape;
2036        let frame = Frame::new(
2037            Point::new(4.0, 5.0, 0.0),
2038            ogeom_math::Direction::Z,
2039            ogeom_math::Direction::X,
2040            T,
2041        )
2042        .unwrap();
2043        let curve: ogeom_geom::Curve =
2044            ogeom_geom::CircleCurve::new(Circle::new(frame, 2.0, T).unwrap()).into();
2045        let domain = curve.domain();
2046        let ring = crate::build::make_edge(model, curve, domain, T)
2047            .unwrap()
2048            .shape;
2049        let ring = if hole_like_outline {
2050            ring
2051        } else {
2052            ring.reversed()
2053        };
2054        let surface: ogeom_geom::SurfaceGeometry =
2055            ogeom_geom::PlaneSurface::over(plane, (-20.0, 20.0), (-20.0, 20.0))
2056                .unwrap()
2057                .into();
2058        let outer_edges = model.ordered_children_of(&outer).unwrap();
2059        crate::build::make_face_with_pcurves(model, surface, &[outer_edges, vec![ring]], T)
2060            .unwrap()
2061            .shape
2062    }
2063
2064    /// Every edge between two faces of a swept solid is walked once each
2065    /// way, each face keeping its material on the left of its rings: prisms
2066    /// along a profile's normal and against it, of a profile presented the
2067    /// other way round, of a hole wound either way, tapered, and turns part
2068    /// of the way and all the way round, clear of the axis and on it.
2069    #[test]
2070    fn every_sweep_walks_each_edge_once_each_way() {
2071        use crate::check::edges_walked_one_way;
2072        let mut model = Model::new();
2073        let face = square(&mut model, 2.0);
2074        let mut solids = Vec::new();
2075        for (profile, travel) in [
2076            (face.clone(), 3.0),
2077            (face.clone(), -3.0),
2078            (face.reversed(), 3.0),
2079        ] {
2080            let vector = Vector::new(0.0, 0.0, travel);
2081            solids.push(make_prism(&mut model, &profile, vector, T).unwrap().shape);
2082        }
2083        for hole_like_outline in [false, true] {
2084            let profile = holed_square(&mut model, hole_like_outline);
2085            let up = Vector::new(0.0, 0.0, 4.0);
2086            solids.push(make_prism(&mut model, &profile, up, T).unwrap().shape);
2087            solids.push(
2088                make_prism_tapered(&mut model, &profile, up, 0.1, T)
2089                    .unwrap()
2090                    .shape,
2091            );
2092        }
2093        let side = box_face(&mut model, 2.0, crate::primitive::roles::FACE_MIN_Y);
2094        let clear = Axis::new(Point::new(-5.0, 0.0, 0.0), ogeom_math::Direction::Z);
2095        let on = Axis::new(Point::ORIGIN, ogeom_math::Direction::Z);
2096        for (axis, angle) in [(clear, 1.0), (clear, TAU), (on, 4.0), (on, TAU)] {
2097            solids.push(
2098                make_revolution(&mut model, &side, axis, angle, T)
2099                    .unwrap()
2100                    .shape,
2101            );
2102        }
2103        for (i, solid) in solids.iter().enumerate() {
2104            assert_eq!(edges_walked_one_way(&model, solid), 0, "solid {i}");
2105        }
2106    }
2107
2108    /// An edge stretching part of an unbounded line, whose domain reaches
2109    /// a billion either side, sweeps a wall whose pcurves end where the
2110    /// edge does. Carried across the line's domain by the affine map from
2111    /// its far end, the edge's end at 4.2987... came back 1.1e-7 along,
2112    /// past the edge's tolerance of 1e-7, and the wall's pcurves left the
2113    /// curve there.
2114    #[test]
2115    fn a_stretch_of_an_unbounded_line_sweeps_a_wall_its_pcurves_end_on() {
2116        let mut model = Model::new();
2117        let length = 4.298_734_790_813_969_5;
2118        let (a, b) = (Point::ORIGIN, Point::new(0.0, 0.0, length));
2119        let va = crate::make_vertex(&mut model, a).shape;
2120        let vb = crate::make_vertex(&mut model, b).shape;
2121        let line = ogeom_geom::LineCurve::new(ogeom_math::Axis::new(a, ogeom_math::Direction::Z));
2122        let edge = crate::make_edge_between(&mut model, line.into(), (0.0, length), &va, &vb, T)
2123            .unwrap()
2124            .shape;
2125        let wall = make_prism(&mut model, &edge, Vector::new(0.0, 2.0, 0.0), T)
2126            .unwrap()
2127            .shape;
2128        let diagnosis = crate::check(&model, &wall, T).unwrap();
2129        assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
2130    }
2131
2132    #[test]
2133    fn a_segment_swept_obliquely_makes_a_plane_with_a_sheared_chart() {
2134        // A line swept along a vector leaning off its perpendicular still
2135        // sweeps a plane; the extrusion's chart is sheared onto the plane's
2136        // own, and the four corners land where the sweep puts them.
2137        use ogeom_geom::Curve3d as _;
2138        let mut model = Model::new();
2139        let (a, b) = (Point::new(0.0, 0.0, 0.0), Point::new(10.0, 0.0, 0.0));
2140        let va = crate::make_vertex(&mut model, a).shape;
2141        let vb = crate::make_vertex(&mut model, b).shape;
2142        let edge = crate::make_edge_between(
2143            &mut model,
2144            ogeom_geom::LineCurve::segment(a, b, T).unwrap().into(),
2145            (0.0, 10.0),
2146            &va,
2147            &vb,
2148            T,
2149        )
2150        .unwrap()
2151        .shape;
2152        let lean = Vector::new(2.0, 0.0, 5.0);
2153        let face = make_prism(&mut model, &edge, lean, T).unwrap().shape;
2154        let data = model.node(&face).unwrap().data().as_face().unwrap().clone();
2155        let surface = model.geometry().surface(data.surface).unwrap().clone();
2156        assert!(
2157            matches!(surface, ogeom_geom::SurfaceGeometry::Plane(_)),
2158            "an oblique sweep of a line is a plane"
2159        );
2160        // Every edge's pcurve on the plane evaluates to the edge's own
2161        // points: the sheared chart describes.
2162        use ogeom_geom::Surface as _;
2163        for e in explore_unique(&model, &face, ShapeType::Edge).unwrap() {
2164            let ed = model.node(&e).unwrap().data().as_edge().unwrap().clone();
2165            let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) = ed.curve3d() else {
2166                panic!("an edge has a curve");
2167            };
2168            let world = model
2169                .geometry()
2170                .curve(*curve)
2171                .unwrap()
2172                .clone()
2173                .transformed(&e.transform(model.datums()).unwrap(), T)
2174                .unwrap();
2175            let Some(ogeom_topo::EdgeRepr::PCurve {
2176                curve: pc,
2177                range: prange,
2178                ..
2179            }) = ed.pcurve_for(data.surface, e.location())
2180            else {
2181                panic!("an edge has a pcurve on the face");
2182            };
2183            let planar = model.geometry().pcurve(*pc).unwrap();
2184            for k in 0..=4 {
2185                let f = f64::from(k) / 4.0;
2186                let t = range.0 + (range.1 - range.0) * f;
2187                let u = prange.0 + (prange.1 - prange.0) * f;
2188                let on_curve = world.point_at(t, T).unwrap();
2189                let q = ogeom_geom::Curve2d::point_at(planar, u, T).unwrap();
2190                let on_surface = surface.point_at(q.x, q.y, T).unwrap();
2191                assert!(
2192                    on_curve.distance(on_surface) < 1e-9,
2193                    "pcurve and curve agree: {on_curve:?} vs {on_surface:?}"
2194                );
2195            }
2196        }
2197    }
2198
2199    /// A disc bounded by a circle on a left-handed (mirrored) frame, swept
2200    /// either way along its axis: its angle turns about `-z`, so the wall
2201    /// is the cylinder on the right-handed frame turned to the travel, the
2202    /// chart flipped where the circle turns against the travel. Each prism
2203    /// is valid, its wall a cylinder, and holds `ฯ€ rยฒ h`.
2204    #[test]
2205    fn a_mirrored_circle_sweeps_its_cylinder_either_way() {
2206        let (r, h) = (2.0, 3.0);
2207        let frame = Frame::from_axes(
2208            Point::new(1.0, -1.0, 0.0),
2209            ogeom_math::Direction::X,
2210            ogeom_math::Direction::Y,
2211            -ogeom_math::Direction::Z,
2212            T,
2213        )
2214        .unwrap();
2215        assert_eq!(frame.handedness(), ogeom_math::Handedness::Left);
2216        for travel in [h, -h] {
2217            let mut model = Model::new();
2218            let curve: ogeom_geom::Curve =
2219                ogeom_geom::CircleCurve::new(Circle::new(frame, r, T).unwrap()).into();
2220            let domain = curve.domain();
2221            let ring = crate::build::make_edge(&mut model, curve, domain, T)
2222                .unwrap()
2223                .shape;
2224            let plane = ogeom_math::Plane::new(Frame::WORLD);
2225            let surface: ogeom_geom::SurfaceGeometry =
2226                ogeom_geom::PlaneSurface::over(plane, (-20.0, 20.0), (-20.0, 20.0))
2227                    .unwrap()
2228                    .into();
2229            let disc = crate::build::make_face_with_pcurves(&mut model, surface, &[vec![ring]], T)
2230                .unwrap()
2231                .shape;
2232            let built = make_prism(&mut model, &disc, Vector::new(0.0, 0.0, travel), T).unwrap();
2233            let diagnosis = crate::check(&model, &built.shape, T).unwrap();
2234            assert!(
2235                diagnosis.is_valid(),
2236                "travel {travel}: {:?}",
2237                diagnosis.problems
2238            );
2239            let kinds = surface_kinds(&model, &built.shape);
2240            assert_eq!(
2241                kinds,
2242                vec![
2243                    SurfaceKind::Cylinder,
2244                    SurfaceKind::Plane,
2245                    SurfaceKind::Plane
2246                ],
2247                "travel {travel}"
2248            );
2249            let v = volume_properties(&model, &built.shape, deflection(1e-3), T)
2250                .unwrap()
2251                .mass;
2252            let want = core::f64::consts::PI * r * r * h;
2253            assert_relative_eq!(v, want, max_relative = 1e-12);
2254        }
2255    }
2256
2257    #[test]
2258    fn a_tapered_square_prism_is_the_frustum_the_closed_form_names() {
2259        let mut model = Model::new();
2260        let profile = square(&mut model, 10.0);
2261        let taper = 5.0_f64.to_radians();
2262        let built =
2263            crate::make_prism_tapered(&mut model, &profile, Vector::new(0.0, 0.0, 10.0), taper, T)
2264                .unwrap();
2265        let diagnosis = crate::check(&model, &built.shape, T).unwrap();
2266        assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
2267
2268        // The far face measures 10 plus twice the height times the tangent
2269        // per side: its corner vertex says so directly.
2270        let d = 10.0 * taper.tan();
2271        let has_far_corner = explore_unique(&model, &built.shape, ShapeType::Vertex)
2272            .unwrap()
2273            .into_iter()
2274            .any(|v| {
2275                model
2276                    .node(&v)
2277                    .and_then(|n| n.data().as_vertex().map(|data| data.point))
2278                    .is_some_and(|p| {
2279                        (p.z - 20.0).abs() < 1e-9
2280                            && (p.x + d).abs() < 1e-9
2281                            && (p.y + d).abs() < 1e-9
2282                    })
2283            });
2284        assert!(has_far_corner, "the far ring widened by the taper");
2285
2286        // All-planar, so the mesh integrates the frustum exactly.
2287        let (a0, a1) = (100.0, (10.0 + 2.0 * d) * (10.0 + 2.0 * d));
2288        let expected = 10.0 / 3.0 * (a1.mul_add(1.0, a0) + (a0 * a1).sqrt());
2289        let measured = volume_properties(&model, &built.shape, Deflection::default(), T)
2290            .unwrap()
2291            .mass;
2292        assert!(
2293            (measured - expected).abs() < 1e-6,
2294            "tapered prism volume {measured} against {expected}"
2295        );
2296        assert!(!built.history.generated(&profile).is_empty());
2297    }
2298
2299    /// A profile straight from `make_face`, with no pcurves, and the same
2300    /// face placed elsewhere, each taper into the frustum, the placed one
2301    /// where its profile stands.
2302    #[test]
2303    fn a_bare_or_placed_profile_tapers() {
2304        let mut model = Model::new();
2305        let corners = [
2306            Point::new(0.0, 0.0, 0.0),
2307            Point::new(10.0, 0.0, 0.0),
2308            Point::new(10.0, 10.0, 0.0),
2309            Point::new(0.0, 10.0, 0.0),
2310        ];
2311        let wire = crate::make_polygon(&mut model, &corners, true, T)
2312            .unwrap()
2313            .shape;
2314        let bare = crate::make_face(
2315            &mut model,
2316            ogeom_geom::SurfaceGeometry::Plane(ogeom_geom::PlaneSurface::new(
2317                ogeom_math::Plane::new(Frame::WORLD),
2318            )),
2319            &[wire],
2320            T,
2321        )
2322        .unwrap()
2323        .shape;
2324        let shift = Vector::new(100.0, 50.0, 7.0);
2325        let placed = bare.located(Location::of(
2326            model.add_datum(ogeom_math::Transform::translation(shift)),
2327        ));
2328        let taper = 0.1_f64;
2329        let d = 5.0 * taper.tan();
2330        let (a0, a1) = (100.0, (10.0 + 2.0 * d) * (10.0 + 2.0 * d));
2331        let expected = 5.0 / 3.0 * (a0 + a1 + (a0 * a1).sqrt());
2332        for (profile, offset) in [(bare.clone(), Vector::ZERO), (placed, shift)] {
2333            let built = crate::make_prism_tapered(
2334                &mut model,
2335                &profile,
2336                Vector::new(0.0, 0.0, 5.0),
2337                taper,
2338                T,
2339            )
2340            .unwrap();
2341            let diagnosis = crate::check(&model, &built.shape, T).unwrap();
2342            assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
2343            let properties =
2344                volume_properties(&model, &built.shape, Deflection::default(), T).unwrap();
2345            assert!(
2346                (properties.mass - expected).abs() < 1e-9 * expected,
2347                "{} against {expected}",
2348                properties.mass
2349            );
2350            let centre = Point::new(5.0, 5.0, 0.0) + offset;
2351            assert!((properties.centre.x - centre.x).abs() < 1e-9);
2352            assert!((properties.centre.y - centre.y).abs() < 1e-9);
2353            assert!(properties.centre.z > centre.z && properties.centre.z < centre.z + 5.0);
2354        }
2355    }
2356
2357    #[test]
2358    fn a_hole_tapers_with_its_profile_into_a_cone() {
2359        let mut model = Model::new();
2360        // A 10 mm square with an off-centre round hole.
2361        let plane = ogeom_math::Plane::new(Frame::WORLD);
2362        let corners = [
2363            Point::new(0.0, 0.0, 0.0),
2364            Point::new(10.0, 0.0, 0.0),
2365            Point::new(10.0, 10.0, 0.0),
2366            Point::new(0.0, 10.0, 0.0),
2367        ];
2368        let outer = crate::build::make_polygon(&mut model, &corners, true, T)
2369            .unwrap()
2370            .shape;
2371        let hole_centre = Point::new(3.5, 6.0, 0.0);
2372        let hole_r = 2.0;
2373        let circle = Circle::new(
2374            Frame::new(
2375                hole_centre,
2376                ogeom_math::Direction::Z,
2377                ogeom_math::Direction::X,
2378                T,
2379            )
2380            .unwrap(),
2381            hole_r,
2382            T,
2383        )
2384        .unwrap();
2385        let curve: ogeom_geom::Curve = ogeom_geom::CircleCurve::new(circle).into();
2386        let domain = curve.domain();
2387        let ring = crate::build::make_edge(&mut model, curve, domain, T)
2388            .unwrap()
2389            .shape;
2390        let hole = make_wire(&mut model, std::slice::from_ref(&ring), T)
2391            .unwrap()
2392            .shape;
2393        let surface: ogeom_geom::SurfaceGeometry =
2394            ogeom_geom::PlaneSurface::over(plane, (-20.0, 20.0), (-20.0, 20.0))
2395                .unwrap()
2396                .into();
2397        let outer_edges = model.ordered_children_of(&outer).unwrap();
2398        let profile = crate::build::make_face_with_pcurves(
2399            &mut model,
2400            surface,
2401            &[outer_edges, vec![ring.clone()]],
2402            T,
2403        )
2404        .unwrap()
2405        .shape;
2406        let _ = hole;
2407
2408        let taper = 5.0_f64.to_radians();
2409        let built =
2410            crate::make_prism_tapered(&mut model, &profile, Vector::new(0.0, 0.0, 10.0), taper, T)
2411                .unwrap();
2412        let diagnosis = crate::check(&model, &built.shape, T).unwrap();
2413        assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
2414
2415        // The hole's wall is a genuine cone, narrowing with height wherever
2416        // the hole sits in the profile.
2417        let cones = explore_unique(&model, &built.shape, ShapeType::Face)
2418            .unwrap()
2419            .into_iter()
2420            .filter(|f| {
2421                model
2422                    .node(f)
2423                    .and_then(|n| n.data().as_face())
2424                    .and_then(|d| model.geometry().surface(d.surface))
2425                    .is_some_and(|s| matches!(s, ogeom_geom::SurfaceGeometry::Cone(_)))
2426            })
2427            .count();
2428        assert_eq!(cones, 1, "the hole wall is a cone");
2429
2430        let pi = core::f64::consts::PI;
2431        let d = 10.0 * taper.tan();
2432        let (a0, a1) = (100.0, (10.0 + 2.0 * d) * (10.0 + 2.0 * d));
2433        let outer_frustum = 10.0 / 3.0 * (a1.mul_add(1.0, a0) + (a0 * a1).sqrt());
2434        let r1 = hole_r - d;
2435        let hole_frustum = pi * 10.0 / 3.0 * (hole_r.mul_add(hole_r, hole_r * r1) + r1 * r1);
2436        let expected = outer_frustum - hole_frustum;
2437        let measured = volume_properties(&model, &built.shape, deflection(1e-3), T)
2438            .unwrap()
2439            .mass;
2440        assert!(
2441            (measured - expected).abs() < 5e-2,
2442            "holed tapered prism volume {measured} against {expected}"
2443        );
2444    }
2445
2446    #[test]
2447    fn a_taper_that_collapses_a_hole_is_refused_by_name() {
2448        let mut model = Model::new();
2449        let plane = ogeom_math::Plane::new(Frame::WORLD);
2450        let corners = [
2451            Point::new(0.0, 0.0, 0.0),
2452            Point::new(10.0, 0.0, 0.0),
2453            Point::new(10.0, 10.0, 0.0),
2454            Point::new(0.0, 10.0, 0.0),
2455        ];
2456        let outer = crate::build::make_polygon(&mut model, &corners, true, T)
2457            .unwrap()
2458            .shape;
2459        let circle = Circle::new(
2460            Frame::new(
2461                Point::new(5.0, 5.0, 0.0),
2462                ogeom_math::Direction::Z,
2463                ogeom_math::Direction::X,
2464                T,
2465            )
2466            .unwrap(),
2467            1.0,
2468            T,
2469        )
2470        .unwrap();
2471        let curve: ogeom_geom::Curve = ogeom_geom::CircleCurve::new(circle).into();
2472        let domain = curve.domain();
2473        let ring = crate::build::make_edge(&mut model, curve, domain, T)
2474            .unwrap()
2475            .shape;
2476        let hole = make_wire(&mut model, std::slice::from_ref(&ring), T)
2477            .unwrap()
2478            .shape;
2479        let surface: ogeom_geom::SurfaceGeometry =
2480            ogeom_geom::PlaneSurface::over(plane, (-20.0, 20.0), (-20.0, 20.0))
2481                .unwrap()
2482                .into();
2483        let outer_edges = model.ordered_children_of(&outer).unwrap();
2484        let profile = crate::build::make_face_with_pcurves(
2485            &mut model,
2486            surface,
2487            &[outer_edges, vec![ring.clone()]],
2488            T,
2489        )
2490        .unwrap()
2491        .shape;
2492        let _ = hole;
2493        let err = crate::make_prism_tapered(
2494            &mut model,
2495            &profile,
2496            Vector::new(0.0, 0.0, 10.0),
2497            8.0_f64.to_radians(),
2498            T,
2499        )
2500        .unwrap_err();
2501        assert!(err.to_string().contains("collapses"), "{err}");
2502    }
2503
2504    #[test]
2505    fn a_curved_profile_edge_is_refused_by_name() {
2506        let mut model = Model::new();
2507        let plane = ogeom_math::Plane::new(Frame::WORLD);
2508        let ellipse = ogeom_math::Ellipse::new(Frame::WORLD, 4.0, 2.0, T).unwrap();
2509        let curve: ogeom_geom::Curve = ogeom_geom::EllipseCurve::new(ellipse).into();
2510        let domain = curve.domain();
2511        let ring = crate::build::make_edge(&mut model, curve, domain, T)
2512            .unwrap()
2513            .shape;
2514        let wire = make_wire(&mut model, std::slice::from_ref(&ring), T)
2515            .unwrap()
2516            .shape;
2517        let surface: ogeom_geom::SurfaceGeometry =
2518            ogeom_geom::PlaneSurface::over(plane, (-10.0, 10.0), (-10.0, 10.0))
2519                .unwrap()
2520                .into();
2521        let profile =
2522            crate::build::make_face_with_pcurves(&mut model, surface, &[vec![ring.clone()]], T)
2523                .unwrap()
2524                .shape;
2525        let _ = wire;
2526        let err = crate::make_prism_tapered(
2527            &mut model,
2528            &profile,
2529            Vector::new(0.0, 0.0, 5.0),
2530            5.0_f64.to_radians(),
2531            T,
2532        )
2533        .unwrap_err();
2534        assert!(err.to_string().contains("fitted ruling"), "{err}");
2535    }
2536
2537    #[test]
2538    fn a_profile_facing_away_from_the_sweep_gives_the_same_solid_as_one_facing_along_it() {
2539        // The defect this pins: the `-Z` face of a box has all four of its
2540        // edges reversed within its wire, and the `+Z` face has none. Sweeping
2541        // either along `+Z` describes the same solid, so the two had better
2542        // agree about it: in face count, in mesh closure and in volume.
2543        for (role, centre) in [
2544            // The `+Z` face sits at z = 2 and sweeps to z = 5; the `-Z` face
2545            // sits at z = 0 and sweeps to z = 3.
2546            (
2547                crate::primitive::roles::FACE_MAX_Z,
2548                Point::new(1.0, 1.0, 3.5),
2549            ),
2550            (
2551                crate::primitive::roles::FACE_MIN_Z,
2552                Point::new(1.0, 1.0, 1.5),
2553            ),
2554        ] {
2555            let mut model = Model::new();
2556            let face = box_face(&mut model, 2.0, role);
2557            let built = make_prism(&mut model, &face, Vector::new(0.0, 0.0, 3.0), T).unwrap();
2558
2559            let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
2560            assert_eq!(counts(ShapeType::Face), 6, "{role:?}");
2561            assert_eq!(counts(ShapeType::Edge), 12, "{role:?}");
2562
2563            // Every face triangulates: a lateral face whose boundary ring runs
2564            // up the same side twice encloses nothing and fails outright.
2565            for face in explode(&model, &built.shape) {
2566                ogeom_mesh::triangulate_face(&model, &face, deflection(0.01), T)
2567                    .unwrap_or_else(|e| panic!("{role:?}: a face would not triangulate: {e}"));
2568            }
2569
2570            let mesh = triangulate(&model, &built.shape, deflection(0.01), T).unwrap();
2571            assert!(mesh.is_closed(), "{role:?}: the mesh has a slit in it");
2572            // Positive, and 2 * 2 * 3. A cap left facing inward keeps the mesh
2573            // closed and takes its own contribution out of the volume twice,
2574            // which is wrong by an amount nothing else reports.
2575            assert_relative_eq!(mesh.volume(), 12.0, epsilon = 1e-9);
2576
2577            let props = volume_properties(&model, &built.shape, deflection(0.01), T).unwrap();
2578            assert_relative_eq!(props.mass, 12.0, epsilon = 1e-9);
2579            assert!(
2580                props.centre.distance(centre) < 1e-9,
2581                "{role:?}: got {:?}",
2582                props.centre
2583            );
2584
2585            assert!(
2586                crate::check_tessellation(&model, &built.shape, deflection(0.01), T)
2587                    .unwrap()
2588                    .is_valid(),
2589                "{role:?}: the mesh disagrees with the topology"
2590            );
2591        }
2592    }
2593
2594    #[test]
2595    fn every_face_of_a_box_sweeps_into_a_solid_of_the_right_volume() {
2596        // Four of the six have their wire's edges mixed (some forward, some
2597        // reversed), which is the case a per-face flip would not have caught.
2598        use crate::primitive::roles;
2599        let roles = [
2600            (roles::FACE_MIN_X, Vector::new(-3.0, 0.0, 0.0)),
2601            (roles::FACE_MAX_X, Vector::new(3.0, 0.0, 0.0)),
2602            (roles::FACE_MIN_Y, Vector::new(0.0, -3.0, 0.0)),
2603            (roles::FACE_MAX_Y, Vector::new(0.0, 3.0, 0.0)),
2604            (roles::FACE_MIN_Z, Vector::new(0.0, 0.0, -3.0)),
2605            (roles::FACE_MAX_Z, Vector::new(0.0, 0.0, 3.0)),
2606        ];
2607        for (role, vector) in roles {
2608            let mut model = Model::new();
2609            let face = box_face(&mut model, 2.0, role);
2610            let built = make_prism(&mut model, &face, vector, T).unwrap();
2611            let mesh = triangulate(&model, &built.shape, deflection(0.01), T).unwrap();
2612            assert!(mesh.is_closed(), "{role:?}: the mesh has a slit in it");
2613            assert_relative_eq!(mesh.volume(), 12.0, epsilon = 1e-9);
2614        }
2615    }
2616
2617    /// Every face below a shape.
2618    fn explode(model: &Model, shape: &Shape) -> Vec<Shape> {
2619        ogeom_topo::explore(model, shape, ogeom_topo::Filter::OfType(ShapeType::Face)).unwrap()
2620    }
2621
2622    /// A square profile in the xz plane, `offset` out from the z axis, `side`
2623    /// on a side, built as a face so it can be revolved.
2624    ///
2625    /// The corners run counter-clockwise about `-y`, so that is the plane's
2626    /// normal: a face whose wire winds against its own normal is inside out,
2627    /// and would sweep into a solid that measures negative, which is a
2628    /// property of the profile, not of the sweep.
2629    fn ring_profile(model: &mut Model, offset: f64, side: f64) -> Shape {
2630        let frame = Frame::new(
2631            Point::new(offset, 0.0, 0.0),
2632            -ogeom_math::Direction::Y,
2633            ogeom_math::Direction::X,
2634            T,
2635        )
2636        .unwrap();
2637        let corners = [
2638            Point::new(offset, 0.0, 0.0),
2639            Point::new(offset + side, 0.0, 0.0),
2640            Point::new(offset + side, 0.0, side),
2641            Point::new(offset, 0.0, side),
2642        ];
2643        let vertices: Vec<Shape> = corners
2644            .iter()
2645            .map(|p| model.add_vertex(ogeom_topo::VertexData::new(*p)))
2646            .collect();
2647        let edges: Vec<Shape> = (0..4)
2648            .map(|i| {
2649                let (a, b) = (corners[i], corners[(i + 1) % 4]);
2650                crate::build::make_edge_between(
2651                    model,
2652                    ogeom_geom::LineCurve::segment(a, b, T).unwrap().into(),
2653                    (0.0, a.distance(b)),
2654                    &vertices[i],
2655                    &vertices[(i + 1) % 4],
2656                    T,
2657                )
2658                .unwrap()
2659                .shape
2660            })
2661            .collect();
2662        let wire = crate::make_wire(model, &edges, T).unwrap().shape;
2663        let surface = model
2664            .geometry_mut()
2665            .add_surface(ogeom_geom::PlaneSurface::new(ogeom_math::Plane::new(frame)).into());
2666        for (i, edge) in edges.iter().enumerate() {
2667            let (a, b) = (corners[i], corners[(i + 1) % 4]);
2668            let flat = |p: ogeom_math::Point| {
2669                let l = frame.to_local(p);
2670                Point2::new(l.x, l.y)
2671            };
2672            crate::attach_pcurve(
2673                model,
2674                edge,
2675                Line2d::segment(flat(a), flat(b), T).unwrap().into(),
2676                surface,
2677                ogeom_topo::Location::identity(),
2678                (0.0, a.distance(b)),
2679            )
2680            .unwrap();
2681        }
2682        crate::make_face_on(model, surface, std::slice::from_ref(&wire), T)
2683            .unwrap()
2684            .shape
2685    }
2686
2687    #[test]
2688    fn a_square_revolved_a_full_turn_is_a_ring_that_agrees_with_itself() {
2689        // The failure a mishandled seam produces: correct topology, a closed
2690        // shell, per-face triangulations matching Pappus, and unshared
2691        // triangle edges at the seam, where the two sides of a face closed in
2692        // `u` do not weld together.
2693        let (offset, side) = (3.0_f64, 2.0_f64);
2694        let mut model = Model::new();
2695        let profile = ring_profile(&mut model, offset, side);
2696        let built = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
2697
2698        let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
2699        assert_eq!(counts(ShapeType::Face), 4, "one per profile edge, no caps");
2700        assert_eq!(
2701            counts(ShapeType::Edge),
2702            6,
2703            "a rail per profile vertex, and a seam only on the cylindrical \
2704             walls; the flat annuli are plane faces bounded by their rails \
2705             alone"
2706        );
2707        assert_eq!(counts(ShapeType::Vertex), 4, "a full turn adds none");
2708
2709        let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
2710        assert!(is_shell_closed(&model, &shell).unwrap());
2711        assert!(
2712            crate::check(&model, &built.shape, T).unwrap().is_valid(),
2713            "{}",
2714            crate::check(&model, &built.shape, T).unwrap()
2715        );
2716
2717        for face in explode(&model, &built.shape) {
2718            ogeom_mesh::triangulate_face(&model, &face, deflection(0.01), T)
2719                .unwrap_or_else(|e| panic!("a face would not triangulate: {e}"));
2720        }
2721
2722        // Pappus: the volume is the profile's area times the distance its
2723        // centroid travels.
2724        let exact = side * side * TAU * (offset + side / 2.0);
2725        let found = crate::check_tessellation(&model, &built.shape, deflection(0.005), T).unwrap();
2726        assert!(found.is_valid(), "the mesh came apart: {found}");
2727
2728        let mesh = triangulate(&model, &built.shape, deflection(0.005), T).unwrap();
2729        assert!(mesh.is_closed(), "the mesh has a slit in it");
2730        assert!(mesh.volume() > 0.0, "the solid is inside out");
2731        // Not bounded below by the exact value the way a convex solid's mesh
2732        // is: chords across the *inner* wall cut into the hole rather than into
2733        // the material, so they add volume where the outer wall's take it away.
2734        assert_relative_eq!(mesh.volume(), exact, max_relative = 1e-3);
2735    }
2736
2737    #[test]
2738    fn a_square_revolved_part_way_has_two_ends_and_the_volume_of_that_wedge() {
2739        let (offset, side) = (3.0_f64, 2.0_f64);
2740        let angle = std::f64::consts::FRAC_PI_2;
2741        let mut model = Model::new();
2742        let profile = ring_profile(&mut model, offset, side);
2743        let built = make_revolution(&mut model, &profile, Axis::Z, angle, T).unwrap();
2744
2745        let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
2746        assert_eq!(counts(ShapeType::Face), 6, "four sides and two ends");
2747
2748        let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
2749        assert!(is_shell_closed(&model, &shell).unwrap());
2750
2751        let exact = side * side * angle * (offset + side / 2.0);
2752        let mesh = triangulate(&model, &built.shape, deflection(0.005), T).unwrap();
2753        assert!(mesh.is_closed(), "the mesh has a slit in it");
2754        assert!(mesh.volume() > 0.0, "the solid is inside out");
2755        assert_relative_eq!(mesh.volume(), exact, max_relative = 1e-3);
2756        assert!(
2757            crate::check_tessellation(&model, &built.shape, deflection(0.005), T)
2758                .unwrap()
2759                .is_valid()
2760        );
2761    }
2762
2763    /// A narrow face far from its chart's origin, whose pcurves close only
2764    /// within its edges' tolerance, still reads which side of each edge it
2765    /// lies on. A wedge's outer wall is a strip 0.44 round and 0.036 high,
2766    /// its pcurves moved a whole turn along the chart (the same surface)
2767    /// and one of its axial pcurves starting 4e-3 short of its corner within
2768    /// a tolerance widened to cover it. The loop's pieces then run 0.036 down
2769    /// one side and 0.032 up the other, and their `x dy` taken without the
2770    /// step closing that corner is off by the step times the chart's
2771    /// distance from its origin, more than the strip's whole area and the
2772    /// other way.
2773    ///
2774    /// The face is still the wedge's, facing out: the walks must tie every
2775    /// face into one agreeing set, and the volume, the step closing the
2776    /// corner along the piece's own chart line, is Pappus's to rounding,
2777    /// closed form.
2778    #[test]
2779    fn a_narrow_face_far_from_its_charts_origin_reads_its_side_across_its_gaps() {
2780        let (offset, side, angle) = (1.4_f64, 0.036_f64, 0.44_f64);
2781        let short_by = 4e-3;
2782        let mut model = Model::new();
2783        let profile = ring_profile(&mut model, offset, side);
2784        let built = make_revolution(&mut model, &profile, Axis::Z, angle, T).unwrap();
2785        let outer = explode(&model, &built.shape)
2786            .into_iter()
2787            .find(|face| {
2788                let data = model.node(face).unwrap().data().as_face().unwrap();
2789                matches!(
2790                    model.geometry().surface(data.surface),
2791                    Some(ogeom_geom::SurfaceGeometry::Cylinder(c))
2792                        if (c.cylinder().radius() - (offset + side)).abs() < 1e-12
2793                )
2794            })
2795            .unwrap();
2796        let surface = model
2797            .node(&outer)
2798            .unwrap()
2799            .data()
2800            .as_face()
2801            .unwrap()
2802            .surface;
2803        let mut pieces = Vec::new();
2804        for edge in explore_unique(&model, &outer, ShapeType::Edge).unwrap() {
2805            let data = model.node(&edge).unwrap().data().as_edge().unwrap();
2806            let Some(EdgeRepr::PCurve { curve, range, .. }) =
2807                data.pcurve_for(surface, edge.location()).cloned()
2808            else {
2809                panic!("the wall's edges each have one pcurve on it");
2810            };
2811            pieces.push((edge, curve, range));
2812        }
2813        let turn = ogeom_math::Transform2::translation(ogeom_math::Vector2::new(TAU, 0.0));
2814        for (_, id, _) in &pieces {
2815            let held = model.geometry_mut().pcurve_mut(*id).unwrap();
2816            *held = held.transformed(&turn, T).unwrap();
2817        }
2818        // The axial piece at the wedge's far end, drawn from short of its
2819        // corner along its own line.
2820        let edge = pieces
2821            .iter()
2822            .filter_map(|(edge, id, range)| {
2823                let curve = model.geometry().pcurve(*id).unwrap();
2824                use ogeom_geom::Curve2d as _;
2825                let (a, b) = (
2826                    curve.point_at(range.0, T).unwrap(),
2827                    curve.point_at(range.1, T).unwrap(),
2828                );
2829                ((a.x - b.x).abs() < 1e-12).then_some((edge.clone(), *id, *range, a, b))
2830            })
2831            .max_by(|p, q| p.3.x.total_cmp(&q.3.x))
2832            .map(|(edge, id, range, a, b)| {
2833                let short = a + (b - a) * (short_by / a.distance(b));
2834                let knots =
2835                    ogeom_math::KnotVector::new(vec![range.0, range.0, range.1, range.1], 1)
2836                        .unwrap();
2837                *model.geometry_mut().pcurve_mut(id).unwrap() =
2838                    ogeom_geom::BSpline2d::new(knots, vec![short, b], T)
2839                        .unwrap()
2840                        .into();
2841                edge
2842            })
2843            .unwrap();
2844        let widened = ogeom_core::Tolerance::new(short_by * 1.25).unwrap();
2845        model.widen(&edge, widened).unwrap();
2846        for vertex in explore_unique(&model, &edge, ShapeType::Vertex).unwrap() {
2847            model.widen(&vertex, widened).unwrap();
2848        }
2849
2850        let flags = crate::mass::flags_agree(&model, &built.shape, T).unwrap();
2851        assert!(flags.agree, "the walks tie the faces consistently");
2852        assert!(
2853            flags.sets.is_empty(),
2854            "every face is in one set, none turned against it: {:?}",
2855            flags
2856                .sets
2857                .iter()
2858                .map(|set| set.iter().filter(|(_, against)| *against).count())
2859                .collect::<Vec<_>>()
2860        );
2861        let exact = side * side * angle * (offset + side / 2.0);
2862        let found = crate::mass::volume_as_flagged(&model, &built.shape, deflection(1e-3), None, T)
2863            .unwrap();
2864        assert_eq!(found.deflection, 0.0, "the closed form measured it");
2865        assert_relative_eq!(found.mass, exact, max_relative = 1e-9);
2866    }
2867
2868    /// A quadrilateral profile in the xz plane, wound counter-clockwise about
2869    /// `-y` so its wire agrees with its own normal.
2870    fn profile_from(model: &mut Model, corners: &[Point]) -> Shape {
2871        let frame = Frame::new(
2872            corners[0],
2873            -ogeom_math::Direction::Y,
2874            ogeom_math::Direction::X,
2875            T,
2876        )
2877        .unwrap();
2878        let n = corners.len();
2879        let vertices: Vec<Shape> = corners
2880            .iter()
2881            .map(|p| model.add_vertex(ogeom_topo::VertexData::new(*p)))
2882            .collect();
2883        let surface = model
2884            .geometry_mut()
2885            .add_surface(ogeom_geom::PlaneSurface::new(ogeom_math::Plane::new(frame)).into());
2886        let flat = |p: ogeom_math::Point| {
2887            let l = frame.to_local(p);
2888            Point2::new(l.x, l.y)
2889        };
2890
2891        let mut edges = Vec::with_capacity(n);
2892        for i in 0..n {
2893            let (a, b) = (corners[i], corners[(i + 1) % n]);
2894            let edge = crate::build::make_edge_between(
2895                model,
2896                ogeom_geom::LineCurve::segment(a, b, T).unwrap().into(),
2897                (0.0, a.distance(b)),
2898                &vertices[i],
2899                &vertices[(i + 1) % n],
2900                T,
2901            )
2902            .unwrap()
2903            .shape;
2904            crate::attach_pcurve(
2905                model,
2906                &edge,
2907                Line2d::segment(flat(a), flat(b), T).unwrap().into(),
2908                surface,
2909                ogeom_topo::Location::identity(),
2910                (0.0, a.distance(b)),
2911            )
2912            .unwrap();
2913            edges.push(edge);
2914        }
2915        let wire = crate::make_wire(model, &edges, T).unwrap().shape;
2916        crate::make_face_on(model, surface, std::slice::from_ref(&wire), T)
2917            .unwrap()
2918            .shape
2919    }
2920
2921    #[test]
2922    fn a_rectangle_with_a_side_on_the_axis_revolves_into_a_cylinder_face_for_face() {
2923        // The claim the seam decision rests on: the same solid gets the same
2924        // counts whichever way it was built. Each lateral face is one face
2925        // closed on itself at a seam rather than two halves; a side lying
2926        // along the axis turns onto itself and contributes no face; and a
2927        // radial side sweeps a *plane*: the sweep names it as one, so the
2928        // caps are plane faces bounded by their rim circles alone, with no
2929        // seam and no degenerate centre, exactly as `make_cylinder` builds
2930        // them. Faces, edges and vertices all agree.
2931        let (radius, height) = (2.0_f64, 5.0_f64);
2932        let mut model = Model::new();
2933        let profile = profile_from(
2934            &mut model,
2935            &[
2936                Point::new(0.0, 0.0, 0.0),
2937                Point::new(radius, 0.0, 0.0),
2938                Point::new(radius, 0.0, height),
2939                Point::new(0.0, 0.0, height),
2940            ],
2941        );
2942        let revolved = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
2943        let primitive = crate::make_cylinder(&mut model, Frame::WORLD, radius, height, T).unwrap();
2944
2945        let counts = |shape: &Shape, kind| explore_unique(&model, shape, kind).unwrap().len();
2946        assert_eq!(
2947            counts(&revolved.shape, ShapeType::Face),
2948            counts(&primitive.shape, ShapeType::Face),
2949            "a side and two caps, the same as make_cylinder"
2950        );
2951        assert_eq!(counts(&revolved.shape, ShapeType::Face), 3);
2952        for kind in [ShapeType::Edge, ShapeType::Vertex] {
2953            assert_eq!(
2954                counts(&revolved.shape, kind),
2955                counts(&primitive.shape, kind),
2956                "canonical caps carry a rim circle and nothing else: {kind:?}"
2957            );
2958        }
2959
2960        let shell = explore_unique(&model, &revolved.shape, ShapeType::Shell).unwrap()[0].clone();
2961        assert!(is_shell_closed(&model, &shell).unwrap());
2962        assert!(
2963            crate::check(&model, &revolved.shape, T).unwrap().is_valid(),
2964            "{}",
2965            crate::check(&model, &revolved.shape, T).unwrap()
2966        );
2967        assert!(
2968            crate::check_tessellation(&model, &revolved.shape, deflection(0.005), T)
2969                .unwrap()
2970                .is_valid()
2971        );
2972
2973        let exact = std::f64::consts::PI * radius * radius * height;
2974        let mesh = triangulate(&model, &revolved.shape, deflection(0.005), T).unwrap();
2975        assert!(mesh.is_closed());
2976        assert!(mesh.volume() > 0.0, "the solid is inside out");
2977        assert!(
2978            mesh.volume() < exact,
2979            "an inscribed volume cannot exceed it"
2980        );
2981        // Against the primitive at the same deflection, not against a bound
2982        // pulled out of the air: both inscribe the same cylinder with the same
2983        // chord, so they should agree to far better than either agrees with the
2984        // exact value.
2985        let reference = triangulate(&model, &primitive.shape, deflection(0.005), T).unwrap();
2986        assert_relative_eq!(mesh.volume(), reference.volume(), max_relative = 1e-6);
2987        assert!(
2988            mesh.volume() > exact * 0.995,
2989            "{} against {exact}",
2990            mesh.volume()
2991        );
2992    }
2993
2994    #[test]
2995    fn a_wall_parallel_to_the_axis_names_the_cylinder_it_is() {
2996        // A ring profile runs one side up the axis's direction and the other
2997        // back down it, so both senses occur in a single wire, which is what
2998        // makes the chart's normal disagree with the revolution's on exactly
2999        // one of them, and the face's own flag carry the difference. If it did
3000        // not, one wall would stand inside out and the volume would come back
3001        // wrong or the shell would not close.
3002        let (inner, outer, height) = (3.0_f64, 5.0_f64, 4.0_f64);
3003        for flip in [false, true] {
3004            let mut model = Model::new();
3005            let mut corners = [
3006                Point::new(inner, 0.0, 0.0),
3007                Point::new(outer, 0.0, 0.0),
3008                Point::new(outer, 0.0, height),
3009                Point::new(inner, 0.0, height),
3010            ];
3011            if flip {
3012                corners.reverse();
3013            }
3014            let profile = profile_from(&mut model, &corners);
3015            let revolved = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
3016
3017            for face in explore_unique(&model, &revolved.shape, ShapeType::Face).unwrap() {
3018                let NodeData::Face(data) = model.node(&face).unwrap().data() else {
3019                    panic!("face data");
3020                };
3021                let surface = model.geometry().surface(data.surface).unwrap();
3022                assert!(
3023                    matches!(
3024                        surface,
3025                        ogeom_geom::SurfaceGeometry::Cylinder(_)
3026                            | ogeom_geom::SurfaceGeometry::Plane(_)
3027                    ),
3028                    "flip {flip}: a ring's face is a {surface:?}, not the \
3029                     cylinder or plane it is"
3030                );
3031            }
3032
3033            assert!(
3034                crate::check(&model, &revolved.shape, T).unwrap().is_valid(),
3035                "flip {flip}: {}",
3036                crate::check(&model, &revolved.shape, T).unwrap()
3037            );
3038            let exact = std::f64::consts::PI * outer.mul_add(outer, -(inner * inner)) * height;
3039            let measured = volume_properties(&model, &revolved.shape, deflection(0.005), T)
3040                .unwrap()
3041                .mass;
3042            assert!(
3043                (measured - exact).abs() < exact * 1e-3,
3044                "flip {flip}: ring volume {measured} against {exact}"
3045            );
3046        }
3047    }
3048
3049    #[test]
3050    fn a_triangle_touching_the_axis_revolves_into_a_cone() {
3051        // The endpoint on the axis sweeps out nothing, so its rail is a
3052        // degenerate edge: an apex. Leaving it out would leave the flank's
3053        // boundary open along one side of its parameter rectangle with nothing
3054        // for the triangulator to trim to.
3055        let (radius, height) = (3.0_f64, 4.0_f64);
3056        let mut model = Model::new();
3057        let profile = profile_from(
3058            &mut model,
3059            &[
3060                Point::new(0.0, 0.0, 0.0),
3061                Point::new(radius, 0.0, 0.0),
3062                Point::new(0.0, 0.0, height),
3063            ],
3064        );
3065        let built = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
3066
3067        assert_eq!(
3068            explore_unique(&model, &built.shape, ShapeType::Face)
3069                .unwrap()
3070                .len(),
3071            2,
3072            "a flank and one cap; the side on the axis sweeps out nothing"
3073        );
3074        assert_eq!(
3075            surface_kinds(&model, &built.shape),
3076            vec![SurfaceKind::Cone, SurfaceKind::Plane],
3077            "the flank is the cone it sweeps, and the cap its plane"
3078        );
3079        let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
3080        assert!(is_shell_closed(&model, &shell).unwrap());
3081        assert!(
3082            crate::check_tessellation(&model, &built.shape, deflection(0.005), T)
3083                .unwrap()
3084                .is_valid()
3085        );
3086
3087        let exact = std::f64::consts::PI * radius * radius * height / 3.0;
3088        let mesh = triangulate(&model, &built.shape, deflection(0.005), T).unwrap();
3089        assert!(mesh.volume() > 0.0, "the solid is inside out");
3090        assert!(mesh.volume() < exact);
3091        assert!(
3092            mesh.volume() > exact * 0.99,
3093            "{} against {exact}",
3094            mesh.volume()
3095        );
3096    }
3097
3098    /// Every distinct surface a shape stands on, sorted, so two shapes can be
3099    /// compared by what they are made of rather than by how many faces they have.
3100    fn surface_kinds(model: &Model, shape: &Shape) -> Vec<ogeom_geom::SurfaceKind> {
3101        use ogeom_geom::Surface as _;
3102        let mut kinds: Vec<ogeom_geom::SurfaceKind> = explore_unique(model, shape, ShapeType::Face)
3103            .unwrap()
3104            .iter()
3105            .map(|face| {
3106                let NodeData::Face(data) = model.node(face).unwrap().data() else {
3107                    panic!("face data");
3108                };
3109                model.geometry().surface(data.surface).unwrap().kind()
3110            })
3111            .collect();
3112        kinds.sort_by_key(|k| format!("{k:?}"));
3113        kinds
3114    }
3115
3116    #[test]
3117    fn a_frustum_profile_names_a_cone_on_each_leaning_side() {
3118        // A cone the profile never brings to its apex, and both walls lean:
3119        // one outward and one inward, so the two run opposite ways round the
3120        // chart and the face flag has to carry the difference for each.
3121        let mut model = Model::new();
3122        let profile = profile_from(
3123            &mut model,
3124            &[
3125                Point::new(3.0, 0.0, 0.0),
3126                Point::new(5.0, 0.0, 0.0),
3127                Point::new(4.0, 0.0, 4.0),
3128                Point::new(2.0, 0.0, 4.0),
3129            ],
3130        );
3131        let built = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
3132
3133        assert_eq!(
3134            surface_kinds(&model, &built.shape),
3135            vec![
3136                SurfaceKind::Cone,
3137                SurfaceKind::Cone,
3138                SurfaceKind::Plane,
3139                SurfaceKind::Plane
3140            ],
3141            "two leaning walls and two flat ends"
3142        );
3143        assert!(
3144            crate::check(&model, &built.shape, T).unwrap().is_valid(),
3145            "{}",
3146            crate::check(&model, &built.shape, T).unwrap()
3147        );
3148
3149        // Pappus: the annulus between the two walls, turned about the axis.
3150        // Outer wall 5..4 and inner 3..2 over a height of 4, so each ring
3151        // section is a trapezium and the solid is the difference of two
3152        // frusta: (pi h / 3)(R1^2 + R1 R2 + R2^2) with the inner subtracted.
3153        let frustum =
3154            |a: f64, b: f64| std::f64::consts::PI * 4.0 / 3.0 * a.mul_add(a, b.mul_add(b, a * b));
3155        let exact = frustum(5.0, 4.0) - frustum(3.0, 2.0);
3156        let measured = volume_properties(&model, &built.shape, deflection(0.005), T)
3157            .unwrap()
3158            .mass;
3159        assert!(
3160            (measured - exact).abs() < exact * 1e-3,
3161            "frustum volume {measured} against {exact}"
3162        );
3163    }
3164
3165    #[test]
3166    fn a_disc_revolved_a_full_turn_is_a_torus_seamed_both_ways() {
3167        // The case that decides whether seam handling is general: the profile
3168        // edge is *closed*, so the circle its one vertex sweeps bounds the face
3169        // across both the top and the bottom of the parameter rectangle. That
3170        // is a seam in `v` on a face already seamed in `u`, and the result has
3171        // to come out with the same counts `make_torus` gives for the same
3172        // solid.
3173        let (major, minor) = (5.0_f64, 2.0_f64);
3174        let mut model = Model::new();
3175
3176        let frame = Frame::new(
3177            Point::new(major, 0.0, 0.0),
3178            -ogeom_math::Direction::Y,
3179            ogeom_math::Direction::X,
3180            T,
3181        )
3182        .unwrap();
3183        let circle = ogeom_math::Circle::new(frame, minor, T).unwrap();
3184        let start = model.add_vertex(ogeom_topo::VertexData::new(Point::new(
3185            major + minor,
3186            0.0,
3187            0.0,
3188        )));
3189        let edge = crate::build::make_edge_between(
3190            &mut model,
3191            ogeom_geom::CircleCurve::new(circle).into(),
3192            (0.0, TAU),
3193            &start,
3194            &start,
3195            T,
3196        )
3197        .unwrap()
3198        .shape;
3199        let surface = model
3200            .geometry_mut()
3201            .add_surface(ogeom_geom::PlaneSurface::new(ogeom_math::Plane::new(frame)).into());
3202        crate::attach_pcurve(
3203            &mut model,
3204            &edge,
3205            ogeom_geom::Circle2d::new(
3206                ogeom_math::Circle2::new(
3207                    ogeom_math::Frame2::new(Point2::ORIGIN, ogeom_math::Direction2::X),
3208                    minor,
3209                    T,
3210                )
3211                .unwrap(),
3212            )
3213            .into(),
3214            surface,
3215            ogeom_topo::Location::identity(),
3216            (0.0, TAU),
3217        )
3218        .unwrap();
3219        let wire = crate::make_wire(&mut model, std::slice::from_ref(&edge), T)
3220            .unwrap()
3221            .shape;
3222        let profile = crate::make_face_on(&mut model, surface, std::slice::from_ref(&wire), T)
3223            .unwrap()
3224            .shape;
3225
3226        let built = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
3227        let primitive = crate::make_torus(&mut model, Frame::WORLD, major, minor, T).unwrap();
3228
3229        let counts = |shape: &Shape, kind| explore_unique(&model, shape, kind).unwrap().len();
3230        for kind in [ShapeType::Face, ShapeType::Edge, ShapeType::Vertex] {
3231            assert_eq!(
3232                counts(&built.shape, kind),
3233                counts(&primitive.shape, kind),
3234                "{kind:?} count differs from make_torus's"
3235            );
3236        }
3237        assert_eq!(
3238            counts(&built.shape, ShapeType::Edge),
3239            2,
3240            "one seam each way"
3241        );
3242        assert_eq!(
3243            surface_kinds(&model, &built.shape),
3244            surface_kinds(&model, &primitive.shape),
3245            "a revolved disc is the torus make_torus builds, and should say so"
3246        );
3247
3248        let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
3249        assert!(is_shell_closed(&model, &shell).unwrap());
3250        assert!(
3251            crate::check_tessellation(&model, &built.shape, deflection(0.02), T)
3252                .unwrap()
3253                .is_valid()
3254        );
3255
3256        let exact = 2.0 * std::f64::consts::PI * std::f64::consts::PI * major * minor * minor;
3257        let mesh = triangulate(&model, &built.shape, deflection(0.02), T).unwrap();
3258        assert!(mesh.is_closed(), "the mesh has a slit in it");
3259        assert!(mesh.volume() > 0.0, "the solid is inside out");
3260        assert!(
3261            mesh.volume() > exact * 0.99 && mesh.volume() < exact,
3262            "{} against {exact}",
3263            mesh.volume()
3264        );
3265    }
3266
3267    #[test]
3268    fn a_full_turn_consumes_the_profile_face_but_not_its_edges() {
3269        // The profile of a full turn is an interior cross-section of the
3270        // result: no face of the solid is it, so it is deleted. Its edges are a
3271        // different matter: each survives as the seam of the face it made, and
3272        // reporting them deleted would break a reference to an edge that is
3273        // still right there.
3274        let mut model = Model::new();
3275        let profile = ring_profile(&mut model, 3.0, 2.0);
3276        let edge = model
3277            .children_of(&model.children_of(&profile).unwrap()[0])
3278            .unwrap()[0]
3279            .clone();
3280
3281        let built = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
3282        assert!(
3283            built.history.is_deleted(&profile),
3284            "the profile is interior"
3285        );
3286        assert!(!built.history.is_deleted(&edge), "its edges are not");
3287        assert_eq!(
3288            built.history.generated(&edge).len(),
3289            1,
3290            "the lateral face it made"
3291        );
3292
3293        // A partial turn keeps the profile as its near cap, so nothing is
3294        // deleted at all.
3295        let mut model = Model::new();
3296        let profile = ring_profile(&mut model, 3.0, 2.0);
3297        let partial = make_revolution(&mut model, &profile, Axis::Z, 1.0, T).unwrap();
3298        assert!(!partial.history.is_deleted(&profile));
3299    }
3300
3301    #[test]
3302    fn a_profile_crossing_the_axis_is_refused_rather_than_swept_through_itself() {
3303        // The solid would have a finite, plausible volume that counts part of
3304        // space twice, and nothing downstream could tell.
3305        let mut model = Model::new();
3306        let profile = profile_from(
3307            &mut model,
3308            &[
3309                Point::new(-1.0, 0.0, 0.0),
3310                Point::new(2.0, 0.0, 0.0),
3311                Point::new(2.0, 0.0, 1.0),
3312                Point::new(-1.0, 0.0, 1.0),
3313            ],
3314        );
3315        let err = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap_err();
3316        assert!(
3317            err.to_string().contains("through itself"),
3318            "unexpected message: {err}"
3319        );
3320
3321        // And the crossing is a third of the way along the bottom edge, which
3322        // no evenly spaced sample lands on. Testing the distance to the axis
3323        // would have missed it; testing which side of the axis the profile is
3324        // on does not.
3325        let mut model = Model::new();
3326        let grazing = profile_from(
3327            &mut model,
3328            &[
3329                Point::new(-1.0, 0.0, 0.0),
3330                Point::new(2.0, 0.0, 0.0),
3331                Point::new(2.0, 0.0, 3.0),
3332                Point::new(-1.0, 0.0, 3.0),
3333            ],
3334        );
3335        assert!(make_revolution(&mut model, &grazing, Axis::Z, TAU, T).is_err());
3336    }
3337
3338    #[test]
3339    fn a_profile_grazing_the_axis_between_samples_is_refused_exactly() {
3340        // The case no sampled check sees. The bottom of this profile is the
3341        // quadratic Bezier x(t) = (1 - 3t)^2: it dips to touch the axis
3342        // tangentially at t = 1/3 (not on any evenly spaced sample grid) and
3343        // comes back without ever changing side, so the radial direction
3344        // never reverses either. Sampling sees a profile clear of the axis;
3345        // the extrema layer sees the stationary approach that reaches it,
3346        // and the revolution would pinch to a point mid-face there.
3347        let mut model = Model::new();
3348        let frame = Frame::new(
3349            Point::new(1.0, 0.0, 0.0),
3350            -ogeom_math::Direction::Y,
3351            ogeom_math::Direction::X,
3352            T,
3353        )
3354        .unwrap();
3355        let surface = model
3356            .geometry_mut()
3357            .add_surface(ogeom_geom::PlaneSurface::new(ogeom_math::Plane::new(frame)).into());
3358        let flat = |p: ogeom_math::Point| {
3359            let l = frame.to_local(p);
3360            Point2::new(l.x, l.y)
3361        };
3362
3363        let controls = [
3364            Point::new(1.0, 0.0, 0.0),
3365            Point::new(-2.0, 0.0, 0.5),
3366            Point::new(4.0, 0.0, 1.0),
3367        ];
3368        let corners = [
3369            controls[0],
3370            controls[2],
3371            Point::new(5.0, 0.0, 1.0),
3372            Point::new(5.0, 0.0, 0.0),
3373        ];
3374        let vertices: Vec<Shape> = corners
3375            .iter()
3376            .map(|p| model.add_vertex(ogeom_topo::VertexData::new(*p)))
3377            .collect();
3378
3379        let knots = ogeom_math::KnotVector::new(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], 2).unwrap();
3380        let dip: ogeom_geom::Curve =
3381            ogeom_geom::BSplineCurve::new(knots.clone(), controls.to_vec(), T)
3382                .unwrap()
3383                .into();
3384        let mut edges = vec![
3385            crate::build::make_edge_between(
3386                &mut model,
3387                dip,
3388                (0.0, 1.0),
3389                &vertices[0],
3390                &vertices[1],
3391                T,
3392            )
3393            .unwrap()
3394            .shape,
3395        ];
3396        crate::attach_pcurve(
3397            &mut model,
3398            &edges[0],
3399            ogeom_geom::BSpline2d::new(knots, controls.iter().map(|p| flat(*p)).collect(), T)
3400                .unwrap()
3401                .into(),
3402            surface,
3403            ogeom_topo::Location::identity(),
3404            (0.0, 1.0),
3405        )
3406        .unwrap();
3407        for i in 1..corners.len() {
3408            let (a, b) = (corners[i], corners[(i + 1) % corners.len()]);
3409            let edge = crate::build::make_edge_between(
3410                &mut model,
3411                ogeom_geom::LineCurve::segment(a, b, T).unwrap().into(),
3412                (0.0, a.distance(b)),
3413                &vertices[i],
3414                &vertices[(i + 1) % corners.len()],
3415                T,
3416            )
3417            .unwrap()
3418            .shape;
3419            crate::attach_pcurve(
3420                &mut model,
3421                &edge,
3422                Line2d::segment(flat(a), flat(b), T).unwrap().into(),
3423                surface,
3424                ogeom_topo::Location::identity(),
3425                (0.0, a.distance(b)),
3426            )
3427            .unwrap();
3428            edges.push(edge);
3429        }
3430        let wire = crate::build::make_wire(&mut model, &edges, T)
3431            .unwrap()
3432            .shape;
3433        let profile = crate::build::make_face_on(&mut model, surface, &[wire], T)
3434            .unwrap()
3435            .shape;
3436
3437        let err = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap_err();
3438        assert!(
3439            err.to_string().contains("touches the axis"),
3440            "unexpected message: {err}"
3441        );
3442    }
3443
3444    #[test]
3445    fn a_turn_that_goes_nowhere_or_too_far_is_refused() {
3446        let mut model = Model::new();
3447        let profile = ring_profile(&mut model, 3.0, 2.0);
3448        for angle in [0.0, -1.0, TAU * 1.5, f64::NAN, f64::INFINITY] {
3449            assert!(
3450                make_revolution(&mut model, &profile, Axis::Z, angle, T).is_err(),
3451                "accepted {angle}"
3452            );
3453        }
3454    }
3455
3456    #[test]
3457    fn a_square_swept_upward_is_a_box() {
3458        let mut model = Model::new();
3459        let face = square(&mut model, 2.0);
3460        let built = make_prism(&mut model, &face, Vector::new(0.0, 0.0, 3.0), T).unwrap();
3461
3462        let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
3463        assert_eq!(counts(ShapeType::Face), 6);
3464        assert_eq!(counts(ShapeType::Edge), 12);
3465        assert_eq!(counts(ShapeType::Vertex), 8);
3466
3467        let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
3468        assert!(is_shell_closed(&model, &shell).unwrap());
3469
3470        let props = volume_properties(&model, &built.shape, deflection(0.01), T).unwrap();
3471        assert_relative_eq!(props.mass, 12.0, epsilon = 1e-9);
3472    }
3473
3474    #[test]
3475    fn the_far_end_is_the_same_topology_at_a_different_place() {
3476        // The point of using a location rather than a copy: one profile, two
3477        // placements. A copy would double the geometry and let the two ends
3478        // drift apart under a later edit.
3479        let mut model = Model::new();
3480        let face = square(&mut model, 1.0);
3481        let before = model.node_count();
3482        let built = make_prism(&mut model, &face, Vector::new(0.0, 0.0, 1.0), T).unwrap();
3483
3484        let faces = explore_unique(&model, &built.shape, ShapeType::Face).unwrap();
3485        let ends: Vec<&Shape> = faces.iter().filter(|f| f.is_partner(&face)).collect();
3486        assert_eq!(ends.len(), 2, "both ends share the profile's node");
3487        assert!(
3488            !ends[0].is_same(ends[1]),
3489            "and are still distinct, because their placements differ"
3490        );
3491
3492        // Four side faces, four rails, one wire, a shell and a solid, but no
3493        // second copy of the profile's four edges or four vertices.
3494        assert!(
3495            model.node_count() - before < 20,
3496            "sweeping copied more than it should have: {} new nodes",
3497            model.node_count() - before
3498        );
3499    }
3500
3501    #[test]
3502    fn a_swept_edge_is_reported_as_both_surviving_and_generating() {
3503        // A swept edge is consumed into the bottom of the prism *and* makes the
3504        // lateral face. Recording one and not the other is how a reference to
3505        // "that edge", or to "the face it made", resolves to nothing.
3506        let mut model = Model::new();
3507        let face = square(&mut model, 1.0);
3508        let edge = model
3509            .children_of(&model.children_of(&face).unwrap()[0])
3510            .unwrap()[0]
3511            .clone();
3512
3513        let built = make_prism(&mut model, &face, Vector::new(0.0, 0.0, 1.0), T).unwrap();
3514        let generated = built.history.generated(&edge);
3515        assert_eq!(
3516            generated.len(),
3517            2,
3518            "the lateral face and the displaced edge, got {generated:?}"
3519        );
3520        assert!(!built.history.is_deleted(&edge), "the edge survives");
3521    }
3522
3523    #[test]
3524    fn an_arc_sweeps_into_a_cylindrical_face_not_a_flat_one() {
3525        // The lateral surface is the extrusion of the edge's own curve, so it is
3526        // exact for whatever the edge was. Approximating every side as a plane
3527        // would make a swept arc visibly faceted and its area wrong.
3528        let mut model = Model::new();
3529        let (radius, height) = (2.0_f64, 5.0);
3530        let cylinder = crate::make_cylinder(&mut model, Frame::WORLD, radius, 1.0, T).unwrap();
3531        let rim = explore_unique(&model, &cylinder.shape, ShapeType::Edge)
3532            .unwrap()
3533            .into_iter()
3534            .find(|e| {
3535                model
3536                    .node(e)
3537                    .and_then(|n| n.data().as_edge())
3538                    .and_then(ogeom_topo::EdgeData::curve3d)
3539                    .is_some_and(|r| matches!(r, EdgeRepr::Curve3d { range, .. } if range.1 > 6.0))
3540            })
3541            .expect("the cylinder has a full circular rim");
3542
3543        let built = make_prism(&mut model, &rim, Vector::new(0.0, 0.0, height), T).unwrap();
3544        assert_eq!(model.kind_of(&built.shape).unwrap(), ShapeType::Face);
3545
3546        let mesh = triangulate(&model, &built.shape, deflection(0.005), T).unwrap();
3547        let area = mesh.area();
3548        let exact = std::f64::consts::TAU * radius * height;
3549        assert!(
3550            area < exact,
3551            "an inscribed area cannot exceed the surface's"
3552        );
3553        assert!(area > exact * 0.999, "{area} against {exact}");
3554    }
3555
3556    #[test]
3557    fn a_wire_sweeps_into_an_open_shell() {
3558        let mut model = Model::new();
3559        let face = square(&mut model, 2.0);
3560        let wire = model.children_of(&face).unwrap()[0].clone();
3561
3562        let built = make_prism(&mut model, &wire, Vector::new(0.0, 0.0, 3.0), T).unwrap();
3563        assert_eq!(model.kind_of(&built.shape).unwrap(), ShapeType::Shell);
3564        assert_eq!(
3565            explore_unique(&model, &built.shape, ShapeType::Face)
3566                .unwrap()
3567                .len(),
3568            4,
3569            "one side per edge, and no ends"
3570        );
3571    }
3572
3573    #[test]
3574    fn a_sweep_that_goes_nowhere_is_refused() {
3575        let mut model = Model::new();
3576        let face = square(&mut model, 1.0);
3577        for vector in [
3578            Vector::ZERO,
3579            Vector::new(f64::NAN, 0.0, 0.0),
3580            Vector::new(0.0, 0.0, f64::INFINITY),
3581        ] {
3582            assert!(make_prism(&mut model, &face, vector, T).is_err());
3583        }
3584    }
3585
3586    #[test]
3587    fn a_profile_that_has_been_placed_sweeps_where_it_actually_sits() {
3588        // A placed profile's edges arrive at a location, and the lateral
3589        // surface is built from the edge's *stored* curve. Building it without
3590        // the placement puts every side wall back at the origin.
3591        let mut model = Model::new();
3592        let face = square(&mut model, 2.0);
3593        let moved = crate::transformed(
3594            &mut model,
3595            &face,
3596            Transform::translation(Vector::new(10.0, 0.0, 0.0)),
3597        )
3598        .unwrap()
3599        .shape;
3600
3601        let built = make_prism(&mut model, &moved, Vector::new(0.0, 0.0, 3.0), T).unwrap();
3602        let mesh = triangulate(&model, &built.shape, deflection(0.01), T).unwrap();
3603        assert!(mesh.is_closed(), "the mesh has a slit in it");
3604        assert_relative_eq!(mesh.volume(), 12.0, epsilon = 1e-9);
3605
3606        let props = volume_properties(&model, &built.shape, deflection(0.01), T).unwrap();
3607        assert!(
3608            props.centre.distance(Point::new(11.0, 1.0, 3.5)) < 1e-9,
3609            "got {:?}",
3610            props.centre
3611        );
3612    }
3613
3614    #[test]
3615    fn a_profile_placed_with_a_scale_sweeps_at_the_size_it_is_now() {
3616        // A placement may carry a uniform scale, and a scale stretches a line's
3617        // parameter with it, because that parameter is a length. The edge's
3618        // range is in the stored curve's parameter and the lateral surface's
3619        // `u` is in the placed one's, so a range copied across unchanged would
3620        // trim the surface at the wrong place: here, at half of it.
3621        let mut model = Model::new();
3622        let face = square(&mut model, 2.0);
3623        let scaled = crate::transformed(
3624            &mut model,
3625            &face,
3626            Transform::scaling(Point::ORIGIN, 2.0, T).unwrap(),
3627        )
3628        .unwrap()
3629        .shape;
3630
3631        let built = make_prism(&mut model, &scaled, Vector::new(0.0, 0.0, 3.0), T).unwrap();
3632        let mesh = triangulate(&model, &built.shape, deflection(0.01), T).unwrap();
3633        assert!(mesh.is_closed(), "the mesh has a slit in it");
3634        // A four-by-four square, three tall.
3635        assert_relative_eq!(mesh.volume(), 48.0, epsilon = 1e-9);
3636    }
3637
3638    #[test]
3639    fn a_face_swept_within_its_own_plane_is_refused() {
3640        // It encloses no volume, and the two ends would land on top of each
3641        // other. Building it anyway gives a solid whose faces all have area and
3642        // which measures zero, which is the shape of answer that gets trusted.
3643        let mut model = Model::new();
3644        let face = square(&mut model, 1.0);
3645        let err = make_prism(&mut model, &face, Vector::new(1.0, 1.0, 0.0), T).unwrap_err();
3646        assert!(
3647            err.to_string().contains("encloses no volume"),
3648            "unexpected message: {err}"
3649        );
3650        // A wire has no side for the sweep to lie in, so the same vector is
3651        // fine there; it makes a perfectly good open shell.
3652        let wire = model.children_of(&face).unwrap()[0].clone();
3653        assert!(make_prism(&mut model, &wire, Vector::new(1.0, 1.0, 0.0), T).is_ok());
3654    }
3655
3656    #[test]
3657    fn a_vertex_is_not_something_this_sweeps() {
3658        // A vertex sweeps into an edge, which is a real operation, but it is
3659        // not one this returns, and claiming otherwise by returning something
3660        // of the wrong kind would be worse than saying so.
3661        let mut model = Model::new();
3662        let vertex = model.add_point(Point::ORIGIN);
3663        assert!(make_prism(&mut model, &vertex, Vector::Z, T).is_err());
3664    }
3665
3666    /// A face whose hole is wound like its outline revolves into the ring
3667    /// with the hole's tube taken out, as one wound the other way does.
3668    #[test]
3669    fn a_hole_wound_either_way_revolves_hollow() {
3670        use core::f64::consts::{PI, TAU};
3671        let upright = Frame::new(
3672            Point::ORIGIN,
3673            -ogeom_math::Direction::Y,
3674            ogeom_math::Direction::X,
3675            T,
3676        )
3677        .unwrap();
3678        for (hole_normal, turn) in [(1.0, TAU), (-1.0, TAU), (1.0, 2.0), (-1.0, 2.0)] {
3679            let mut model = Model::new();
3680            let corners: Vec<Point> = [(5.0, 0.0), (9.0, 0.0), (9.0, 4.0), (5.0, 4.0)]
3681                .iter()
3682                .map(|&(x, y)| upright.to_world(Point::new(x, y, 0.0)))
3683                .collect();
3684            let outline = crate::build::make_polygon(&mut model, &corners, true, T)
3685                .unwrap()
3686                .shape;
3687            let seat = Frame::new(
3688                upright.to_world(Point::new(7.0, 2.0, 0.0)),
3689                ogeom_math::Direction::new(upright.z().vector() * hole_normal, T).unwrap(),
3690                upright.x(),
3691                T,
3692            )
3693            .unwrap();
3694            let circle = Circle::new(seat, 1.0, T).unwrap();
3695            let ring = crate::build::make_edge(
3696                &mut model,
3697                ogeom_geom::CircleCurve::new(circle).into(),
3698                (0.0, TAU),
3699                T,
3700            )
3701            .unwrap()
3702            .shape;
3703            let hole = make_wire(&mut model, &[ring], T).unwrap().shape;
3704            let profile = crate::build::make_face(
3705                &mut model,
3706                ogeom_geom::PlaneSurface::new(ogeom_math::Plane::new(upright)).into(),
3707                &[outline, hole],
3708                T,
3709            )
3710            .unwrap()
3711            .shape;
3712            let axis = ogeom_math::Axis::new(Point::ORIGIN, ogeom_math::Direction::Z);
3713            let built = crate::make_revolution(&mut model, &profile, axis, turn, T).unwrap();
3714            let volume = volume_properties(&model, &built.shape, Deflection::default(), T)
3715                .unwrap()
3716                .mass;
3717            // Pappus: the section's area swept round its centroid's circle.
3718            let want = turn * 7.0 * (16.0 - PI);
3719            assert_relative_eq!(volume, want, max_relative = 1e-9);
3720        }
3721    }
3722}