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