Skip to main content

ogeom_offset/
sweep.rs

1//! Pipes and lofts: the sweeps whose surfaces are already in the vocabulary.
2//!
3//! A circular profile along a straight spine is a cylinder; around a full
4//! circle it is a torus; along an arc it is a torus segment, built from two
5//! half-tube patches and two meridian caps so that every edge is a circle
6//! with a closed-form chart. A ruled loft between two parallel sections is
7//! walls of planes and cones: segment to segment gives the planar quad,
8//! coaxial circle to circle gives the frustum the cone primitive already
9//! builds, and a skew ruled wall between two segments is the bilinear patch
10//! through its corners, exact. The sweeps that need surfaces with no closed
11//! form (a free-form spine, a smoothed skin through many sections) are
12//! fitted: the skin holds every section to a stated tolerance and refuses
13//! when it cannot reach it.
14
15use ogeom_algo::{
16    Built, History, edge_vertices, make_cone, make_cylinder, make_edge, make_edge_between,
17    make_face_with_pcurves, make_solid, make_torus, make_vertex, sew,
18};
19use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
20use ogeom_geom::Curve3d as _;
21use ogeom_geom::Transformable as _;
22use ogeom_geom::{
23    CircleCurve, Curve, Line2d, LineCurve, PlaneSurface, SurfaceGeometry, TorusSurface,
24};
25use ogeom_math::{Circle, Direction, Frame, Plane, Point, Point2, Torus, Transform, Vector};
26use ogeom_topo::{EdgeData, EdgeRepr, Filter, Model, Shape, ShapeType, VertexData, explore};
27
28mod corners;
29mod evolving;
30mod inside_turn;
31mod meeting;
32mod mitred_arcs;
33mod open_lines;
34mod open_skinned;
35mod placement;
36
37pub use evolving::{
38    PipeEvolution, PipeLaws, SpineFunction, SpineLaw, make_pipe_shell_evolving,
39    make_pipe_shell_laws,
40};
41pub use placement::{PipeContact, PipePlacement, place_on_spine};
42
43/// Sweep a circular profile of `radius` along a spine edge or wire.
44///
45/// A straight spine gives a cylinder, a full circular spine a torus, an arc
46/// a torus segment. A wire of lines and arcs, open or closed, is the
47/// [`make_pipe_shell`] of the disc standing square to its start under the
48/// rotation-minimizing frame, its sharp corners mitred, wherever that is
49/// exact: legs meeting tangent are the cylinders and torus segments they
50/// sweep, fused on the sections they share, straight legs are mitred
51/// on the planes halving their corners, and a corner between two lines or
52/// turning in the plane of the arcs beside it is the legs' union with
53/// their straight run-ons outside the turn, cut on the plane halving it.
54/// The history generates the solid from the spine.
55///
56/// # Errors
57///
58/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the spine is
59/// not straight or circular, the profile radius is not usable, or the tube
60/// would swallow its own spine; for a wire, if an edge is neither a line
61/// nor an arc (which [`make_pipe_skinned`] sweeps to a stated tolerance),
62/// where [`make_pipe_shell`] refuses the wire, or where it would sweep a
63/// face of the pipe as a skin, which it does to a stated tolerance.
64pub fn make_pipe(
65    model: &mut Model,
66    spine: &Shape,
67    radius: f64,
68    tol: Tolerances,
69) -> OgeomResult<Built> {
70    if !radius.is_finite() || radius <= tol.confusion() {
71        ogeom_bail!(Construction, "a pipe of radius {radius} holds nothing");
72    }
73    // The spine is swept where it stands.
74    let placed = placed_path(model, spine, tol)?;
75    if placed != *spine {
76        let mut built = make_pipe(model, &placed, radius, tol)?;
77        built.history.generate(spine, built.shape.clone());
78        return Ok(built);
79    }
80    if model.kind_of(spine)? == ShapeType::Wire {
81        let edges = model.ordered_children_of(spine)?;
82        if let [edge] = edges.as_slice() {
83            let mut built = make_pipe(model, edge, radius, tol)?;
84            built.history.generate(spine, built.shape.clone());
85            return Ok(built);
86        }
87        return pipe_along_wire(model, spine, &edges, radius, tol);
88    }
89    let (curve, range) = {
90        let Some(data) = model.node(spine).and_then(|n| n.data().as_edge()) else {
91            ogeom_bail!(Construction, "a pipe runs along an edge");
92        };
93        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
94            ogeom_bail!(Construction, "the spine has no curve");
95        };
96        let Some(geometry) = model.geometry().curve(*curve) else {
97            ogeom_bail!(Dangling, "curve is not in this model");
98        };
99        (geometry.clone(), *range)
100    };
101    let mut built = match &curve {
102        Curve::Line(line) => {
103            let start = curve.point_at(range.0, tol)?;
104            let length = range.1 - range.0;
105            let frame = Frame::about(start, line.axis().direction);
106            make_cylinder(model, frame, radius, length, tol)?
107        }
108        Curve::Circle(c) => {
109            let circle = c.circle();
110            if radius >= circle.radius() - tol.confusion() {
111                ogeom_bail!(
112                    Construction,
113                    "a tube of radius {radius} swallows its spine of radius {}",
114                    circle.radius()
115                );
116            }
117            let closed = curve
118                .point_at(range.0, tol)?
119                .distance(curve.point_at(range.1, tol)?)
120                <= tol.confusion();
121            if closed {
122                make_torus(model, circle.frame(), circle.radius(), radius, tol)?
123            } else {
124                pipe_segment(model, circle, range, radius, tol)?
125            }
126        }
127        _ => ogeom_bail!(
128            Construction,
129            "a pipe along a free-form spine needs the sweep-surface \
130             machinery; see docs/CAPABILITIES.md, offset.sweeps"
131        ),
132    };
133    built.history.generate(spine, built.shape.clone());
134    Ok(built)
135}
136
137/// [`make_pipe`] along a wire of several edges: the disc square to the
138/// wire's start swept by the pipe shell, where that is exact.
139fn pipe_along_wire(
140    model: &mut Model,
141    spine: &Shape,
142    edges: &[Shape],
143    radius: f64,
144    tol: Tolerances,
145) -> OgeomResult<Built> {
146    for edge in edges {
147        let (curve, _) = spine_curve_of(model, edge)?;
148        let basis = match &curve {
149            Curve::Trimmed(t) => t.basis().clone(),
150            other => other.clone(),
151        };
152        if !matches!(basis, Curve::Line(_) | Curve::Circle(_)) {
153            ogeom_bail!(
154                Construction,
155                "a pipe runs along lines and arcs; along a free-form spine it is \
156                 make_pipe_skinned's or make_pipe_shell's, to a stated tolerance"
157            );
158        }
159    }
160    let disc = disc_at_start(model, spine, edges, radius, tol)?;
161    // The exact legs and mitres ask nothing of the skin; one asked of it
162    // all the same is held to the model's own confusion, and a skin that
163    // misses it or a fitted face it leaves is refused below.
164    let built = match make_pipe_shell(model, &disc, spine, false, tol.confusion(), tol) {
165        Err(ogeom_core::OgeomError::NotDone(_)) => ogeom_bail!(
166            Construction,
167            "a pipe along this wire is not swept exactly; make_pipe_shell sweeps it \
168             to a stated tolerance"
169        ),
170        built => built?,
171    };
172    for face in explore(model, &built.shape, Filter::OfType(ShapeType::Face))? {
173        let Some(data) = model.node(&face).and_then(|n| n.data().as_face()) else {
174            continue;
175        };
176        if !matches!(
177            model.geometry().surface(data.surface),
178            Some(
179                SurfaceGeometry::Plane(_)
180                    | SurfaceGeometry::Cylinder(_)
181                    | SurfaceGeometry::Torus(_)
182            )
183        ) {
184            ogeom_bail!(
185                Construction,
186                "a pipe along this wire is not swept exactly; make_pipe_shell sweeps it \
187                 to a stated tolerance"
188            );
189        }
190    }
191    let mut history = History::new();
192    history.generate(spine, built.shape.clone());
193    Ok(Built::new(built.shape, history))
194}
195
196/// The disc of `radius` centred on the start of the wire `spine` and
197/// square to it there, refused where an arc of the wire bends tighter than
198/// the tube is wide.
199fn disc_at_start(
200    model: &mut Model,
201    spine: &Shape,
202    edges: &[Shape],
203    radius: f64,
204    tol: Tolerances,
205) -> OgeomResult<Shape> {
206    for edge in edges {
207        let (curve, _) = spine_curve_of(model, edge)?;
208        let basis = match &curve {
209            Curve::Trimmed(t) => t.basis().clone(),
210            other => other.clone(),
211        };
212        if let Curve::Circle(c) = basis
213            && radius >= c.circle().radius() - tol.confusion()
214        {
215            ogeom_bail!(
216                Construction,
217                "a tube of radius {radius} swallows its spine of radius {}",
218                c.circle().radius()
219            );
220        }
221    }
222    let stations = shell_stations(model, spine, tol)?;
223    let (at, heading) = (stations[0].at, stations[0].tangent);
224    let frame = Frame::about(at, Direction::new(heading, tol)?);
225    let circle = Circle::new(frame, radius, tol)?;
226    let rim = make_edge(
227        model,
228        Curve::Circle(CircleCurve::new(circle)),
229        (0.0, core::f64::consts::TAU),
230        tol,
231    )?
232    .shape;
233    let ring = ogeom_algo::make_wire(model, &[rim], tol)?.shape;
234    Ok(ogeom_algo::make_face(
235        model,
236        PlaneSurface::new(Plane::new(frame)).into(),
237        &[ring],
238        tol,
239    )?
240    .shape)
241}
242
243/// The torus segment: two half-tube patches and two meridian caps.
244///
245/// The tube circles are framed so their own parameter *is* the torus tube
246/// angle, which makes every tube pcurve a vertical line in the chart and the
247/// two patches the clean rectangles `v ∈ [0, π]` and `[π, 2π]`. The outer
248/// equator is then the seam between the halves across the period (one edge,
249/// two chart rows), which is exactly what [`ogeom_algo::attach_seam`] exists to
250/// say.
251fn pipe_segment(
252    model: &mut Model,
253    spine: Circle,
254    range: (f64, f64),
255    radius: f64,
256    tol: Tolerances,
257) -> OgeomResult<Built> {
258    let frame = spine.frame();
259    let (x, y, z) = (frame.x().vector(), frame.y().vector(), frame.z().vector());
260    let major = spine.radius();
261    let radial = |u: f64| x * u.cos() + y * u.sin();
262    let tangent = |u: f64| x * -u.sin() + y * u.cos();
263    let tube_point = |u: f64, v: f64| {
264        frame.origin() + radial(u) * radius.mul_add(v.cos(), major) + z * (radius * v.sin())
265    };
266    let pi = core::f64::consts::PI;
267    let tau = core::f64::consts::TAU;
268
269    let torus: SurfaceGeometry = TorusSurface::new(Torus::new(frame, major, radius, tol)?).into();
270    let surface_id = model.geometry_mut().add_surface(torus);
271
272    // Vertices at the tube's v = 0 and v = π points of each end.
273    let ends = [range.0, range.1];
274    let mut verts: Vec<Vec<Shape>> = Vec::new();
275    for &u in &ends {
276        verts.push(vec![
277            make_vertex(model, tube_point(u, 0.0)).shape,
278            make_vertex(model, tube_point(u, pi)).shape,
279        ]);
280    }
281
282    // The tube circles at each end, split at v = 0 and v = π, framed so that
283    // the circle's parameter equals the torus tube angle: `z` against the
284    // spine tangent makes the frame's `y` the torus's own axis.
285    let mut tube_arcs: Vec<Vec<Shape>> = Vec::new();
286    for (k, &u) in ends.iter().enumerate() {
287        let centre = frame.origin() + radial(u) * major;
288        let circle = Circle::new(
289            Frame::new(
290                centre,
291                Direction::new(-tangent(u), tol)?,
292                Direction::new(radial(u), tol)?,
293                tol,
294            )?,
295            radius,
296            tol,
297        )?;
298        let curve = Curve::Circle(CircleCurve::new(circle));
299        let arcs = vec![
300            make_edge_between(
301                model,
302                curve.clone(),
303                (0.0, pi),
304                &verts[k][0],
305                &verts[k][1],
306                tol,
307            )?
308            .shape,
309            make_edge_between(model, curve, (pi, tau), &verts[k][1], &verts[k][0], tol)?.shape,
310        ];
311        // In the chart both arcs run up the column at this end's angle.
312        let column = Line2d::over(
313            ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
314            0.0,
315            tau,
316        )?;
317        ogeom_algo::attach_pcurve(
318            model,
319            &arcs[0],
320            column.into(),
321            surface_id,
322            ogeom_topo::Location::identity(),
323            (0.0, pi),
324        )?;
325        ogeom_algo::attach_pcurve(
326            model,
327            &arcs[1],
328            column.into(),
329            surface_id,
330            ogeom_topo::Location::identity(),
331            (pi, tau),
332        )?;
333        tube_arcs.push(arcs);
334    }
335
336    // The long edges: the parallels at v = 0 and v = π, parameterized by the
337    // spine's own angle.
338    let parallel = |model: &mut Model, v: f64, from: &Shape, to: &Shape| -> OgeomResult<Shape> {
339        let height = radius * v.sin();
340        let ring = radius.mul_add(v.cos(), major);
341        let circle = Circle::new(
342            Frame::new(frame.origin() + z * height, frame.z(), frame.x(), tol)?,
343            ring,
344            tol,
345        )?;
346        let curve = Curve::Circle(CircleCurve::new(circle));
347        Ok(make_edge_between(model, curve, range, from, to, tol)?.shape)
348    };
349    let row = |v: f64| -> OgeomResult<Line2d> {
350        Line2d::over(
351            ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
352            range.0 - 1.0,
353            range.1 + 1.0,
354        )
355    };
356    let inner = parallel(model, pi, &verts[0][1], &verts[1][1])?;
357    ogeom_algo::attach_pcurve(
358        model,
359        &inner,
360        row(pi)?.into(),
361        surface_id,
362        ogeom_topo::Location::identity(),
363        range,
364    )?;
365    // The outer equator bounds both halves across the period: v = 0 for its
366    // forward use under the lower patch, v = 2π for its reversed use under
367    // the upper: a seam, said as one.
368    let outer = parallel(model, 0.0, &verts[0][0], &verts[1][0])?;
369    ogeom_algo::attach_seam(
370        model,
371        &outer,
372        row(0.0)?.into(),
373        row(tau)?.into(),
374        surface_id,
375        ogeom_topo::Location::identity(),
376        range,
377    )?;
378
379    // The two half-tube patches, on the one registered surface, each ring
380    // counter-clockwise in the chart: along the spine, up the far end's
381    // tube, back, and down the near end's.
382    let lower = {
383        let wire = ogeom_algo::make_wire(
384            model,
385            &[
386                outer.clone(),
387                tube_arcs[1][0].clone(),
388                inner.reversed(),
389                tube_arcs[0][0].reversed(),
390            ],
391            tol,
392        )?
393        .shape;
394        ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape
395    };
396    let upper = {
397        let wire = ogeom_algo::make_wire(
398            model,
399            &[
400                inner.clone(),
401                tube_arcs[1][1].clone(),
402                outer.reversed(),
403                tube_arcs[0][1].reversed(),
404            ],
405            tol,
406        )?
407        .shape;
408        ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape
409    };
410
411    // The meridian caps, their outward normals along the spine and away from
412    // the material between the ends.
413    let mut caps: Vec<Shape> = Vec::new();
414    for (k, &u) in ends.iter().enumerate() {
415        let outward = if k == 0 { -tangent(u) } else { tangent(u) };
416        let centre = frame.origin() + radial(u) * major;
417        let plane = Plane::through(centre, Direction::new(outward, tol)?);
418        let reach = (major + radius) * 2.0;
419        let surface: SurfaceGeometry =
420            PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
421        // The tube circles turn about the spine's backward tangent: the near
422        // cap walks its circle forward, the far one walks it back.
423        let ring = if k == 0 {
424            vec![tube_arcs[k][0].clone(), tube_arcs[k][1].clone()]
425        } else {
426            vec![tube_arcs[k][1].reversed(), tube_arcs[k][0].reversed()]
427        };
428        caps.push(make_face_with_pcurves(model, surface, &[ring], tol)?.shape);
429    }
430
431    let faces = [lower, upper, caps[0].clone(), caps[1].clone()];
432    let sewn = sew(model, &faces, tol)?;
433    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
434        ogeom_bail!(Construction, "the pipe segment did not close");
435    }
436    make_solid(model, std::slice::from_ref(&sewn.shells[0]))
437}
438
439/// Loft two parallel closed sections into a solid, ruled.
440///
441/// Two coaxial circles give the cylinder or the cone frustum; two polygons
442/// with the same corner count give planar walls. The sections pair edge by
443/// edge in traversal order.
444///
445/// A wall between two segments that are not coplanar is the bilinear patch
446/// through its four corners (the ruled surface between them, exact), so
447/// sections may be turned against each other or differ in shape.
448///
449/// # Errors
450///
451/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the sections
452/// are not both circles or both polygons of the same count, or are not on
453/// parallel planes.
454pub fn make_loft(
455    model: &mut Model,
456    bottom: &Shape,
457    top: &Shape,
458    tol: Tolerances,
459) -> OgeomResult<Built> {
460    // A vertex for either section is the loft to a point: a cone over a
461    // circle, an exact pyramid over a polygon.
462    match (model.kind_of(bottom)?, model.kind_of(top)?) {
463        (ShapeType::Wire, ShapeType::Vertex) => {
464            return loft_to_point(model, bottom, top, tol);
465        }
466        (ShapeType::Vertex, ShapeType::Wire) => {
467            let mut built = loft_to_point(model, top, bottom, tol)?;
468            // The apex was named first: the same solid, the history the same.
469            built.history.generate(bottom, built.shape.clone());
470            return Ok(built);
471        }
472        _ => {}
473    }
474    for wire in [bottom, top] {
475        if model.kind_of(wire)? != ShapeType::Wire {
476            ogeom_bail!(Construction, "a loft runs between wires");
477        }
478        if !ogeom_algo::is_wire_closed(model, wire, tol)? {
479            ogeom_bail!(Construction, "a loft section must be closed");
480        }
481    }
482    let circle_of = |model: &Model, wire: &Shape| -> OgeomResult<Option<Circle>> {
483        let edges = explore(model, wire, Filter::OfType(ShapeType::Edge))?;
484        if edges.len() != 1 {
485            return Ok(None);
486        }
487        let Some(data) = model.node(&edges[0]).and_then(|n| n.data().as_edge()) else {
488            return Ok(None);
489        };
490        let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d() else {
491            return Ok(None);
492        };
493        match model.geometry().curve(*curve) {
494            Some(Curve::Circle(c)) => Ok(Some(c.circle())),
495            _ => Ok(None),
496        }
497    };
498
499    if let (Some(lower), Some(upper)) = (circle_of(model, bottom)?, circle_of(model, top)?) {
500        // Coaxial circles: the revolved primitives already build these.
501        let axis = lower.frame().z().vector();
502        let rise = upper.centre() - lower.centre();
503        let height = rise.dot(axis);
504        if rise.cross(axis).magnitude() > tol.confusion() * 10.0 || height.abs() <= tol.confusion()
505        {
506            ogeom_bail!(
507                Construction,
508                "lofted circles must be coaxial on parallel planes; the \
509                 oblique loft needs the sweep machinery; see the deferred \
510                 table"
511            );
512        }
513        let frame = if height > 0.0 {
514            Frame::new(lower.centre(), lower.frame().z(), lower.frame().x(), tol)?
515        } else {
516            Frame::new(
517                lower.centre(),
518                lower.frame().z().reversed(),
519                lower.frame().x(),
520                tol,
521            )?
522        };
523        let mut built = if (lower.radius() - upper.radius()).abs() <= tol.confusion() {
524            make_cylinder(model, frame, lower.radius(), height.abs(), tol)?
525        } else {
526            make_cone(
527                model,
528                frame,
529                lower.radius(),
530                upper.radius(),
531                height.abs(),
532                tol,
533            )?
534        };
535        built.history.generate(bottom, built.shape.clone());
536        built.history.generate(top, built.shape.clone());
537        return Ok(built);
538    }
539
540    // Polygonal sections: matched corners, planar walls.
541    let corners_of = |model: &Model, wire: &Shape| -> OgeomResult<Vec<Point>> {
542        let mut out = Vec::new();
543        for edge in explore(model, wire, Filter::OfType(ShapeType::Edge))? {
544            let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
545                ogeom_bail!(Construction, "a section edge holds no data");
546            };
547            let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d() else {
548                ogeom_bail!(Construction, "a section edge has no curve");
549            };
550            let Some(Curve::Line(_)) = model.geometry().curve(*curve) else {
551                ogeom_bail!(
552                    Construction,
553                    "a mixed or curved section needs the skinning machinery; see \
554                     docs/CAPABILITIES.md, offset.sweeps"
555                );
556            };
557            let Some((sv, _)) = edge_vertices(model, &edge)? else {
558                ogeom_bail!(Construction, "a section edge has no vertices");
559            };
560            let Some(data) = model.node(&sv).and_then(|n| n.data().as_vertex()) else {
561                ogeom_bail!(Construction, "a section vertex holds no point");
562            };
563            out.push(sv.transform(model.datums())?.apply(data.point));
564        }
565        Ok(out)
566    };
567    let low = corners_of(model, bottom)?;
568    let high = corners_of(model, top)?;
569    if low.len() != high.len() {
570        ogeom_bail!(
571            Construction,
572            "lofted sections must have the same corner count, found {} and {}",
573            low.len(),
574            high.len()
575        );
576    }
577    let n = low.len();
578    let centroid = {
579        let mut c = Vector::new(0.0, 0.0, 0.0);
580        for p in low.iter().chain(high.iter()) {
581            c += p.to_vector();
582        }
583        #[allow(clippy::cast_precision_loss)]
584        let count = 2.0 * n as f64;
585        Point::from_vector(c / count)
586    };
587
588    // Shared vertices and edges, then walls and caps referencing them.
589    let vl: Vec<Shape> = low.iter().map(|p| make_vertex(model, *p).shape).collect();
590    let vh: Vec<Shape> = high.iter().map(|p| make_vertex(model, *p).shape).collect();
591    let seg = |model: &mut Model, a: (&Shape, Point), b: (&Shape, Point)| -> OgeomResult<Shape> {
592        let line = LineCurve::segment(a.1, b.1, tol)?;
593        let curve = Curve::Line(line);
594        let domain = curve.domain();
595        Ok(make_edge_between(model, curve, domain, a.0, b.0, tol)?.shape)
596    };
597    let mut low_edges = Vec::with_capacity(n);
598    let mut high_edges = Vec::with_capacity(n);
599    let mut rails = Vec::with_capacity(n);
600    for i in 0..n {
601        let j = (i + 1) % n;
602        low_edges.push(seg(model, (&vl[i], low[i]), (&vl[j], low[j]))?);
603        high_edges.push(seg(model, (&vh[i], high[i]), (&vh[j], high[j]))?);
604        rails.push(seg(model, (&vl[i], low[i]), (&vh[i], high[i]))?);
605    }
606
607    let planar = |model: &mut Model, corners: &[Point], edges: Vec<Shape>| -> OgeomResult<Shape> {
608        let normal = {
609            let mut n = (corners[1] - corners[0]).cross(corners[2] - corners[0]);
610            let m = n.magnitude();
611            if m <= tol.confusion() {
612                ogeom_bail!(Construction, "a loft wall is degenerate");
613            }
614            n /= m;
615            if n.dot(corners[0] - centroid) < 0.0 {
616                -n
617            } else {
618                n
619            }
620        };
621        let skew = corners.iter().any(|p| {
622            Plane::through(
623                corners[0],
624                Direction::new(normal, tol).unwrap_or(Direction::Z),
625            )
626            .distance_to(*p)
627                > tol.confusion() * 10.0
628        });
629        if skew {
630            // A wall between two segments that do not lie in one plane is
631            // the ruled surface between them, and that is exact: bilinear
632            // in the four corners, a B-spline of degree one each way.
633            if corners.len() != 4 || edges.len() != 4 {
634                ogeom_bail!(Construction, "a skew ruled wall has four corners");
635            }
636            return bilinear_wall(model, corners, &edges, centroid, tol);
637        }
638        let plane = Plane::through(corners[0], Direction::new(normal, tol)?);
639        let mut reach = 1.0_f64;
640        for p in corners {
641            reach = reach.max(p.distance(corners[0]) * 2.0);
642        }
643        let surface: SurfaceGeometry =
644            PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
645        // The ring keeps the material on its left about the outward normal:
646        // one walking the corners clockwise about it is walked back.
647        let turn = (0..corners.len())
648            .map(|i| {
649                corners[i]
650                    .to_vector()
651                    .cross(corners[(i + 1) % corners.len()].to_vector())
652            })
653            .fold(Vector::new(0.0, 0.0, 0.0), |sum, v| sum + v);
654        let ring = if turn.dot(normal) < 0.0 {
655            walked_back(&edges)
656        } else {
657            edges
658        };
659        Ok(make_face_with_pcurves(model, surface, &[ring], tol)?.shape)
660    };
661
662    let mut faces: Vec<Shape> = Vec::with_capacity(n + 2);
663    for i in 0..n {
664        let j = (i + 1) % n;
665        faces.push(planar(
666            model,
667            &[low[i], low[j], high[j], high[i]],
668            vec![
669                low_edges[i].clone(),
670                rails[j].clone(),
671                high_edges[i].reversed(),
672                rails[i].reversed(),
673            ],
674        )?);
675    }
676    faces.push(planar(model, &low, low_edges.clone())?);
677    faces.push(planar(model, &high, high_edges.clone())?);
678
679    let sewn = sew(model, &faces, tol)?;
680    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
681        ogeom_bail!(Construction, "the loft did not close");
682    }
683    let mut built = make_solid(model, std::slice::from_ref(&sewn.shells[0]))?;
684    built.history.generate(bottom, built.shape.clone());
685    built.history.generate(top, built.shape.clone());
686    Ok(built)
687}
688
689/// The ruled wall between two straight segments that are not coplanar: the
690/// bilinear patch through its four corners, exact, as a B-spline of degree
691/// one each way.
692///
693/// `corners` run round the wall (low start, low end, high end, high
694/// start) and `edges` walk them in that order, the third and fourth
695/// reversed as the caller's wire has them. Each edge's pcurve is the chart
696/// side it lies on, parameterized by the edge's own range so the two agree
697/// point for point; the wall faces away from `centroid`.
698fn bilinear_wall(
699    model: &mut Model,
700    corners: &[Point],
701    edges: &[Shape],
702    centroid: Point,
703    tol: Tolerances,
704) -> OgeomResult<Shape> {
705    use ogeom_geom::Surface as _;
706    let (p00, p10, p11, p01) = (corners[0], corners[1], corners[2], corners[3]);
707    let grid = ogeom_math::ControlGrid::new(vec![p00, p01, p10, p11], 2, 2)?;
708    let line = ogeom_math::KnotVector::clamped_uniform(1, 2)?;
709    let patch = ogeom_geom::BSplineSurface::new(line.clone(), line, &grid, tol)?;
710    let outward = {
711        let (du, dv) = patch.d1_at(0.5, 0.5, tol)?;
712        let centre = patch.point_at(0.5, 0.5, tol)?;
713        du.cross(dv).dot(centre - centroid) >= 0.0
714    };
715    let surface_id = model
716        .geometry_mut()
717        .add_surface(SurfaceGeometry::BSpline(patch));
718
719    // Each edge's chart side, run in the edge's own direction over the
720    // edge's own parameter range: a degree-one B-spline in the chart,
721    // which is what makes the pcurve the edge's equal parameter for
722    // parameter whatever length the edge has.
723    let sides: [(Point2, Point2); 4] = [
724        (Point2::new(0.0, 0.0), Point2::new(1.0, 0.0)),
725        (Point2::new(1.0, 0.0), Point2::new(1.0, 1.0)),
726        (Point2::new(0.0, 1.0), Point2::new(1.0, 1.0)),
727        (Point2::new(0.0, 0.0), Point2::new(0.0, 1.0)),
728    ];
729    for (edge, (from, to)) in edges.iter().zip(sides) {
730        let range = {
731            let Some(node) = model.node(edge) else {
732                ogeom_bail!(Dangling, "a loft edge is not in this model");
733            };
734            let Some(data) = node.data().as_edge() else {
735                ogeom_bail!(Construction, "a loft edge holds no edge data");
736            };
737            let Some(EdgeRepr::Curve3d { range, .. }) = data.curve3d() else {
738                ogeom_bail!(Construction, "a loft edge has no curve");
739            };
740            *range
741        };
742        let knots = ogeom_math::KnotVector::new(vec![range.0, range.0, range.1, range.1], 1)?;
743        let pcurve = ogeom_geom::BSpline2d::new(knots, vec![from, to], tol)?;
744        ogeom_algo::attach_pcurve(
745            model,
746            edge,
747            pcurve.into(),
748            surface_id,
749            ogeom_topo::Location::identity(),
750            range,
751        )?;
752    }
753    let wire = ogeom_algo::make_wire(model, edges, tol)?.shape;
754    let face = ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
755    Ok(if outward { face } else { face.reversed() })
756}
757
758/// What a skin is fitted to: its rows of samples, and the geometry they
759/// sample where the builder knows it.
760struct Skin<'a> {
761    /// `rows[j][i]` runs along row `j`: once round a section without the
762    /// closing point where `round`, across an open strip otherwise.
763    rows: Vec<Vec<Point>>,
764    /// Whether each row runs once round, its end its start.
765    round: bool,
766    /// Whether bare rows sit across the skin by chord length (sections a
767    /// caller spaced) rather than centripetally.
768    by_spacing: bool,
769    /// The geometry the rows sample, if known: `point(u, v)` with `u` along
770    /// the rows over the sampling's `u` range (once round where `round`, its
771    /// end the start again) and `v` across them, and where the fit starts
772    /// sampling it.
773    traced: Option<Traced<'a>>,
774    /// The section's exact form carried along the skin, where every row is
775    /// that form moved: the fit is then made in its control points.
776    carried: Option<Carried<'a>>,
777}
778
779/// A section in exact rational form carried along a skin: every row is the
780/// section's curve with its control points moved, its knots and weights
781/// kept. Each control point's path across the skin is fitted, and the
782/// surface spoken through those paths with the section's knots and weights
783/// along the rows. A similarity carries a rational curve to the curve of its
784/// moved control points, and the rows' basis functions are positive and sum
785/// to one, so the surface stands off the carried section by at most the
786/// widest miss of any path; the paths keep their end points, so the end
787/// rows are the given sections exactly.
788struct Carried<'a> {
789    /// The first row: the section's exact form.
790    curve: ogeom_geom::BSplineCurve,
791    /// Control point `i` of the row at `v` across the skin.
792    control: Box<dyn Fn(usize, f64) -> OgeomResult<Point> + 'a>,
793    /// Where the fit starts sampling the paths across the skin, the first
794    /// and the last its ends.
795    vs: Vec<f64>,
796    /// Whether the section closes on its first control point, which its
797    /// last then repeats.
798    closed: bool,
799}
800
801impl Carried<'_> {
802    /// The skin's surface through the fitted paths, with the widest miss of
803    /// any path between the samples as well as at them.
804    fn fit(
805        &self,
806        tolerance: f64,
807        tol: Tolerances,
808    ) -> OgeomResult<ogeom_geom::fit::Fitted<ogeom_geom::BSplineSurface>> {
809        let control = self.curve.control_points();
810        let n = control.len();
811        let mut paths: Vec<ogeom_geom::BSplineCurve> = Vec::with_capacity(n);
812        let mut error = 0.0_f64;
813        for i in 0..n {
814            if self.closed && i + 1 == n {
815                paths.push(paths[0].clone());
816                continue;
817            }
818            let fitted = ogeom_geom::fit::fit_curve_sampled(
819                |v| (self.control)(i, v),
820                &self.vs,
821                false,
822                3,
823                tolerance,
824                tol,
825            )?;
826            error = error.max(fitted.error);
827            paths.push(fitted.curve);
828        }
829        let paths = crate::sheet::made_compatible(&paths, tol)?;
830        let (v0, v1) = (self.vs[0], self.vs[self.vs.len() - 1]);
831        let mut net = Vec::with_capacity(n * paths[0].control_points().len());
832        for (i, path) in paths.iter().enumerate() {
833            let points: Vec<Point> = path.control_points().iter().map(|w| w.point()).collect();
834            let last = points.len() - 1;
835            // The ends pinned to the given rows, each move stated in the
836            // error: moving one control point moves the path by no more.
837            let i = if self.closed && i + 1 == n { 0 } else { i };
838            let ends = ((self.control)(i, v0)?, (self.control)(i, v1)?);
839            error = error
840                .max(points[0].distance(ends.0))
841                .max(points[last].distance(ends.1));
842            let weight = control[i].weight;
843            for (j, p) in points.iter().enumerate() {
844                let p = match j {
845                    0 => ends.0,
846                    j if j == last => ends.1,
847                    _ => *p,
848                };
849                net.push(ogeom_math::Weighted::new(p, weight, tol)?);
850            }
851        }
852        let grid = ogeom_math::ControlGrid::new(net, n, paths[0].control_points().len())?;
853        let surface = ogeom_geom::BSplineSurface::rational(
854            self.curve.knots().clone(),
855            paths[0].knots().clone(),
856            grid,
857        )?;
858        Ok(ogeom_geom::fit::Fitted {
859            curve: surface,
860            error,
861            met: error <= tolerance,
862        })
863    }
864}
865
866/// The geometry a skin traces and where its fit starts sampling it.
867struct Traced<'a> {
868    point: Box<dyn Fn(f64, f64) -> OgeomResult<Point> + 'a>,
869    sampling: ogeom_geom::fit::Sampling,
870}
871
872/// One section a skin passes through: a closed loop read by arc length,
873/// or the point a skin narrows to.
874#[derive(Clone)]
875enum Section {
876    Loop(ArcLoop),
877    Point(Point),
878    /// A loop moved halfway toward a point, every point of it.
879    Halfway(ArcLoop, Point),
880}
881
882impl Section {
883    /// The point a fraction `f` of the way round.
884    fn at(&self, f: f64) -> Point {
885        match self {
886            Self::Loop(arc) => arc.at(f),
887            Self::Point(p) => *p,
888            Self::Halfway(arc, to) => {
889                Point::from_vector((arc.at(f).to_vector() + to.to_vector()) * 0.5)
890            }
891        }
892    }
893}
894
895/// The samples a skin's sections take round: an even fraction apart, from
896/// each section's own start.
897const AROUND_SECTION: usize = 48;
898
899/// `n + 1` even fractions of one, from naught to one.
900fn fractions(n: usize) -> Vec<f64> {
901    (0..=n)
902        .map(|i| {
903            #[allow(clippy::cast_precision_loss, reason = "a small count")]
904            let f = i as f64 / n as f64;
905            f
906        })
907        .collect()
908}
909
910impl<'a> Skin<'a> {
911    /// A skin over bare rows, each once round: fitted through them,
912    /// measured at them.
913    fn rows(rows: Vec<Vec<Point>>) -> Self {
914        Self {
915            rows,
916            round: true,
917            by_spacing: false,
918            traced: None,
919            carried: None,
920        }
921    }
922
923    /// A skin whose every column is a function of its parameter along the
924    /// rows: `column(u)` is the column of points at `u`, one per row,
925    /// sampled at `us` to start with and checked and refined between them.
926    /// Across the rows the samples are all there is of the skin: the rows
927    /// sit there at the parameters [`ogeom_geom::fit::grid_parameters`]
928    /// gives them, by chord length where `by_spacing`, centripetally
929    /// otherwise. Where `round`, the last of `us` is the first again; where
930    /// `closed_v`, the rows loop and the first row closes it.
931    fn columns(
932        column: impl Fn(f64) -> OgeomResult<Vec<Point>> + 'a,
933        us: Vec<f64>,
934        round: bool,
935        by_spacing: bool,
936        closed_v: bool,
937    ) -> OgeomResult<Self> {
938        let sampled: Vec<Vec<Point>> = us[..us.len() - usize::from(round)]
939            .iter()
940            .map(|u| column(*u))
941            .collect::<OgeomResult<_>>()?;
942        let count = sampled[0].len();
943        let rows: Vec<Vec<Point>> = (0..count)
944            .map(|j| sampled.iter().map(|c| c[j]).collect())
945            .collect();
946        let mut grid: Vec<Vec<Point>> = rows
947            .iter()
948            .map(|r| {
949                let mut r = r.clone();
950                if round {
951                    r.push(r[0]);
952                }
953                r
954            })
955            .collect();
956        if closed_v {
957            grid.push(grid[0].clone());
958        }
959        let bare = Self {
960            rows: rows.clone(),
961            round,
962            by_spacing,
963            traced: None,
964            carried: None,
965        };
966        let (_, vs) = ogeom_geom::fit::grid_parameters(&grid, (false, by_spacing))?;
967        if vs.windows(2).any(|w| w[1] <= w[0]) {
968            // Rows that coincide have no order across the skin; the bare
969            // rows decide what the fit makes of them.
970            return Ok(bare);
971        }
972        let row_vs = vs.clone();
973        let (u0, u1) = (us[0], us[us.len() - 1]);
974        let held: std::cell::RefCell<ogeom_core::FastMap<u64, Vec<Point>>> =
975            std::cell::RefCell::default();
976        let point = move |u: f64, v: f64| -> OgeomResult<Point> {
977            let Some(j) = row_vs.iter().position(|w| w.to_bits() == v.to_bits()) else {
978                ogeom_bail!(Construction, "a skin is known across only at its rows");
979            };
980            // The end of the way round is its start, to the bit, so the
981            // seam closes exactly.
982            let u = if round && u >= u1 { u0 } else { u };
983            if let Some(c) = held.borrow().get(&u.to_bits()) {
984                return Ok(c[j % count]);
985            }
986            let c = column(u)?;
987            if c.len() != count {
988                ogeom_bail!(Construction, "a skin's column changed its length");
989            }
990            let p = c[j % count];
991            held.borrow_mut().insert(u.to_bits(), c);
992            Ok(p)
993        };
994        Ok(Self {
995            traced: Some(Traced {
996                point: Box::new(point),
997                sampling: ogeom_geom::fit::Sampling {
998                    us,
999                    vs,
1000                    between: (true, false),
1001                    closed_v,
1002                    most: 512,
1003                },
1004            }),
1005            ..bare
1006        })
1007    }
1008
1009    /// A skin known everywhere: `point(u, s)` with `u` along the rows and
1010    /// `s` a station index across them, a whole number at a station and a
1011    /// fraction between two. It is sampled at `us` and at every station
1012    /// from `stations.0` to `stations.1` to start with, and checked and
1013    /// refined between them both ways. Across, the skin's parameter runs
1014    /// from naught to one over the stations, evenly. Where `round`, the
1015    /// last of `us` is the first again.
1016    fn swept(
1017        point: impl Fn(f64, f64) -> OgeomResult<Point> + 'a,
1018        us: Vec<f64>,
1019        stations: (usize, usize),
1020        round: bool,
1021    ) -> OgeomResult<Self> {
1022        let (u0, u1) = (us[0], us[us.len() - 1]);
1023        #[allow(clippy::cast_precision_loss, reason = "station counts")]
1024        let (first, steps) = (stations.0 as f64, (stations.1 - stations.0) as f64);
1025        if steps < 1.0 {
1026            ogeom_bail!(Construction, "a swept skin needs two stations");
1027        }
1028        let vs: Vec<f64> = (stations.0..=stations.1)
1029            .map(|i| {
1030                #[allow(clippy::cast_precision_loss, reason = "station counts")]
1031                let step = (i - stations.0) as f64;
1032                step / steps
1033            })
1034            .collect();
1035        // The end of the way round is its start, to the bit, so the seam
1036        // closes exactly; a station is its whole index, to the bit.
1037        let point = move |u: f64, v: f64| -> OgeomResult<Point> {
1038            let s = first + v * steps;
1039            let whole = s.round();
1040            let s = if (s - whole).abs() <= 1e-9 { whole } else { s };
1041            point(if round && u >= u1 { u0 } else { u }, s)
1042        };
1043        let rows: Vec<Vec<Point>> = vs
1044            .iter()
1045            .map(|v| {
1046                us[..us.len() - usize::from(round)]
1047                    .iter()
1048                    .map(|u| point(*u, *v))
1049                    .collect::<OgeomResult<Vec<Point>>>()
1050            })
1051            .collect::<OgeomResult<_>>()?;
1052        Ok(Self {
1053            rows,
1054            round,
1055            by_spacing: false,
1056            traced: Some(Traced {
1057                point: Box::new(point),
1058                sampling: ogeom_geom::fit::Sampling {
1059                    us,
1060                    vs,
1061                    between: (true, true),
1062                    closed_v: false,
1063                    most: 512,
1064                },
1065            }),
1066            carried: None,
1067        })
1068    }
1069
1070    /// A skin through `sections`, each sampled at [`AROUND_SECTION`] even
1071    /// fractions of its length to start with (see [`Self::columns`]).
1072    fn sections(sections: Vec<Section>, closed: bool) -> OgeomResult<Self> {
1073        Self::columns(
1074            move |u| Ok(sections.iter().map(|s| s.at(u)).collect()),
1075            fractions(AROUND_SECTION),
1076            true,
1077            false,
1078            closed,
1079        )
1080    }
1081
1082    /// The parameter across the skin each row sits at in its fitted
1083    /// surface (open across).
1084    fn row_parameters(&self) -> OgeomResult<Vec<f64>> {
1085        if let Some(traced) = &self.traced {
1086            return Ok(traced.sampling.vs.clone());
1087        }
1088        let grid: Vec<Vec<Point>> = self
1089            .rows
1090            .iter()
1091            .map(|row| {
1092                let mut r = row.clone();
1093                if self.round {
1094                    r.push(row[0]);
1095                }
1096                r
1097            })
1098            .collect();
1099        Ok(ogeom_geom::fit::grid_parameters(&grid, (false, self.by_spacing))?.1)
1100    }
1101
1102    /// The skin's fitted surface: where `round`, closed the way round (each
1103    /// row's end its start, so the row fits' pinned ends make the two
1104    /// border control columns equal); where `closed_v`, smoothly across the
1105    /// loop of its rows.
1106    fn fit(
1107        &self,
1108        closed_v: bool,
1109        tolerance: f64,
1110        tol: Tolerances,
1111    ) -> OgeomResult<ogeom_geom::fit::Fitted<ogeom_geom::BSplineSurface>> {
1112        // A carried section's paths that cannot be fitted alike, or miss,
1113        // leave the skin fitted as if nothing were carried.
1114        if let Some(carried) = &self.carried
1115            && !closed_v
1116            && let Ok(fitted) = carried.fit(tolerance, tol)
1117            && fitted.met
1118        {
1119            return Ok(fitted);
1120        }
1121        match &self.traced {
1122            None => {
1123                let mut grid: Vec<Vec<Point>> = self
1124                    .rows
1125                    .iter()
1126                    .map(|row| {
1127                        let mut r = row.clone();
1128                        if self.round {
1129                            r.push(row[0]);
1130                        }
1131                        r
1132                    })
1133                    .collect();
1134                if closed_v {
1135                    grid.push(grid[0].clone());
1136                    ogeom_geom::fit::fit_surface_grid_closed_v(&grid, 3, tolerance, tol)
1137                } else if self.by_spacing {
1138                    ogeom_geom::fit::fit_surface_grid_sections(&grid, 3, tolerance, tol)
1139                } else {
1140                    ogeom_geom::fit::fit_surface_grid(&grid, 3, tolerance, tol)
1141                }
1142            }
1143            Some(traced) => {
1144                let mut sampling = traced.sampling.clone();
1145                sampling.closed_v = closed_v;
1146                // The sampled fit states the error it reads densely along
1147                // the skin's rows; a skin that strays over the tolerance
1148                // all the same is refitted to a tighter target.
1149                const TIGHTER: usize = 3;
1150                let mut target = tolerance;
1151                let mut best: Option<ogeom_geom::fit::Fitted<ogeom_geom::BSplineSurface>> = None;
1152                for round in 0..=TIGHTER {
1153                    let fit = ogeom_geom::fit::fit_surface_sampled(
1154                        |u, v| (traced.point)(u, v),
1155                        &sampling,
1156                        3,
1157                        target,
1158                        tol,
1159                    );
1160                    // A tighter fit that cannot be made leaves the best so
1161                    // far standing, its error stated as read.
1162                    let mut fitted = match fit {
1163                        Ok(fitted) => fitted,
1164                        Err(e) if round == 0 => return Err(e),
1165                        Err(_) => break,
1166                    };
1167                    fitted.met = fitted.error <= tolerance;
1168                    let strays = !fitted.met;
1169                    let error = fitted.error;
1170                    if best.as_ref().is_none_or(|b| error < b.error) {
1171                        best = Some(fitted);
1172                    }
1173                    if !strays {
1174                        break;
1175                    }
1176                    target *= 0.9 * tolerance / error;
1177                }
1178                best.ok_or_else(|| {
1179                    ogeom_core::ogeom_err!(Construction, "the skin could not be fitted")
1180                })
1181            }
1182        }
1183    }
1184}
1185
1186/// A closed polyline read by arc length.
1187#[derive(Clone)]
1188struct ArcLoop {
1189    dense: Vec<Point>,
1190    /// The length run to each point of `dense`.
1191    lengths: Vec<f64>,
1192    /// The length round, the closing chord included.
1193    total: f64,
1194}
1195
1196impl ArcLoop {
1197    fn new(dense: Vec<Point>) -> Self {
1198        let mut lengths = Vec::with_capacity(dense.len());
1199        lengths.push(0.0);
1200        for w in dense.windows(2) {
1201            let last = lengths[lengths.len() - 1];
1202            lengths.push(last + w[0].distance(w[1]));
1203        }
1204        let total = lengths[lengths.len() - 1] + dense[dense.len() - 1].distance(dense[0]);
1205        Self {
1206            dense,
1207            lengths,
1208            total,
1209        }
1210    }
1211
1212    /// The point a fraction `f` of the length round from the first point;
1213    /// a whole turn is the first point again.
1214    fn at(&self, f: f64) -> Point {
1215        let f = if (0.0..1.0).contains(&f) { f } else { 0.0 };
1216        let target = self.total * f;
1217        let n = self.dense.len();
1218        let cursor = self.lengths[1..]
1219            .partition_point(|l| *l < target)
1220            .min(n - 1);
1221        let (a, b) = (self.dense[cursor], self.dense[(cursor + 1) % n]);
1222        let la = self.lengths[cursor];
1223        let lb = if cursor + 1 < n {
1224            self.lengths[cursor + 1]
1225        } else {
1226            self.total
1227        };
1228        let t = if lb > la {
1229            (target - la) / (lb - la)
1230        } else {
1231            0.0
1232        };
1233        a + (b - a) * t.clamp(0.0, 1.0)
1234    }
1235
1236    /// The loop's vector area: normal to a flat loop, as long as the area
1237    /// it encloses, and pointing the way it winds round by the right hand.
1238    fn winding(&self) -> Vector {
1239        let o = self.dense[0];
1240        let mut sum = Vector::new(0.0, 0.0, 0.0);
1241        for w in self.dense[1..].windows(2) {
1242            sum += (w[0] - o).cross(w[1] - o);
1243        }
1244        sum * 0.5
1245    }
1246
1247    /// The same loop walked the other way round, from the point of it that
1248    /// rows up best with `with`: the least sum of squared distances between
1249    /// the two loops' points at even fractions of the way round.
1250    fn reversed_against(&self, with: &Self) -> Self {
1251        let mut back = self.dense.clone();
1252        back[1..].reverse();
1253        let marks = fractions(AROUND_SECTION);
1254        let theirs: Vec<Point> = marks.iter().map(|&f| with.at(f)).collect();
1255        let starts = back.len();
1256        let step = (starts / (4 * AROUND_SECTION)).max(1);
1257        let mut best = (f64::INFINITY, 0);
1258        for start in (0..starts).step_by(step) {
1259            let mut turned = back.clone();
1260            turned.rotate_left(start);
1261            let turned = Self::new(turned);
1262            let cost: f64 = marks
1263                .iter()
1264                .zip(&theirs)
1265                .map(|(&f, p)| {
1266                    let d = turned.at(f) - *p;
1267                    d.dot(d)
1268                })
1269                .sum();
1270            if cost < best.0 {
1271                best = (cost, start);
1272            }
1273        }
1274        back.rotate_left(best.1);
1275        Self::new(back)
1276    }
1277}
1278
1279/// Each section after the first turned to wind round the way the one
1280/// before it does, where it winds the other way.
1281///
1282/// The skin joins the sections' points at equal fractions of the way
1283/// round, so two sections wound opposite ways (two sketches drawn facing
1284/// each other) join each point to one across the loop: the wall passes
1285/// through itself and the solid has no inside. Such a section is walked
1286/// backwards instead, from its point that rows up best with the section
1287/// before it. Sections standing at right angles to each other have no
1288/// way round in common and are left as they are.
1289fn wound_alike(loops: &mut [Section]) {
1290    for i in 1..loops.len() {
1291        let (before, after) = loops.split_at_mut(i);
1292        let (Section::Loop(prev), Section::Loop(this)) = (&before[i - 1], &mut after[0]) else {
1293            continue;
1294        };
1295        let (a, b) = (prev.winding(), this.winding());
1296        if a.dot(b) < -1e-6 * a.magnitude() * b.magnitude() {
1297            *this = this.reversed_against(prev);
1298        }
1299    }
1300}
1301
1302/// A skinned wall and the pieces a caller needs to close it: the rings at
1303/// both ends, their exact border curves off the control net, and the chart's
1304/// `u` window the ring pcurves span.
1305struct SkinnedWall {
1306    face: Shape,
1307    ring0: Shape,
1308    ring1: Shape,
1309    curve0: ogeom_geom::Curve,
1310    curve1: ogeom_geom::Curve,
1311    u_dom: (f64, f64),
1312}
1313
1314/// The wall of a skin over a grid of section samples, closed the way round.
1315///
1316/// The wall is the skin's fitted surface ([`Skin::fit`]), with each row's
1317/// first sample repeated at its end: the row fits pin their ends, so the two
1318/// border control columns are *equal* and the seam closes exactly, not
1319/// within tolerance. The border iso-curves come straight off the control
1320/// net (the v-borders are the fitted sections, planar whenever the
1321/// sections are, which is what lets the caps be planes), and every pcurve
1322/// is an iso line in the fitted chart, same-parameter by construction.
1323fn skinned_wall(
1324    model: &mut Model,
1325    skin: &Skin<'_>,
1326    shared: (Option<&Shape>, Option<&Shape>),
1327    tolerance: f64,
1328    tol: Tolerances,
1329) -> OgeomResult<SkinnedWall> {
1330    use ogeom_geom::Surface as _;
1331    let rows = &skin.rows;
1332    let fitted = skin.fit(false, tolerance, tol)?;
1333    if !fitted.met {
1334        ogeom_bail!(
1335            NotDone,
1336            "the skin reached {} against a target of {tolerance}",
1337            fitted.error
1338        );
1339    }
1340    let surface = fitted.curve;
1341    let (u_knots, v_knots) = (surface.u_knots().clone(), surface.v_knots().clone());
1342    let (k, l, net) = {
1343        let grid = surface.grid();
1344        (grid.u_count(), grid.v_count(), grid.points().to_vec())
1345    };
1346    let point_at = |i: usize, j: usize| net[i * l + j];
1347    let (u_dom, v_dom) = surface.domain();
1348
1349    // Border curves straight off the net, weights and all: v-borders are
1350    // the end sections, the u-border is the seam.
1351    let border_v = |j: usize| -> OgeomResult<ogeom_geom::Curve> {
1352        let control: Vec<_> = (0..k).map(|i| point_at(i, j)).collect();
1353        Ok(ogeom_geom::Curve::BSpline(
1354            ogeom_geom::BSplineCurve::rational(u_knots.clone(), control)?,
1355        ))
1356    };
1357    let seam_curve = {
1358        let control: Vec<_> = (0..l).map(|j| point_at(0, j)).collect();
1359        ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::rational(
1360            v_knots.clone(),
1361            control,
1362        )?)
1363    };
1364
1365    let surface_geo: SurfaceGeometry = surface.into();
1366    let surface_id = model.geometry_mut().add_surface(surface_geo.clone());
1367
1368    // An end ring the neighbouring wall already built is adopted, not
1369    // refitted (see `adopt_border`).
1370    let slack = fitted.error + tol.confusion();
1371    let ring_of = |model: &mut Model,
1372                   j: usize,
1373                   given: Option<&Shape>|
1374     -> OgeomResult<(Shape, ogeom_geom::Curve)> {
1375        match given {
1376            Some(edge) => {
1377                adopt_border(model, edge, &surface_geo, slack, tol)?;
1378                Ok((edge.clone(), spine_curve_of(model, edge)?.0))
1379            }
1380            None => {
1381                let curve = border_v(j)?;
1382                Ok((make_edge(model, curve.clone(), u_dom, tol)?.shape, curve))
1383            }
1384        }
1385    };
1386    let (ring0, curve0) = ring_of(model, 0, shared.0)?;
1387    let (ring1, curve1) = ring_of(model, l - 1, shared.1)?;
1388    let anchor0 = ogeom_algo::edge_vertices(model, &ring0)?
1389        .map(|(a, _)| a)
1390        .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a skinned ring has no vertex"))?;
1391    let anchor1 = ogeom_algo::edge_vertices(model, &ring1)?
1392        .map(|(a, _)| a)
1393        .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a skinned ring has no vertex"))?;
1394    let seam = make_edge_between(model, seam_curve, v_dom, &anchor0, &anchor1, tol)?.shape;
1395
1396    // Pcurves: rows for the rings, both columns for the seam.
1397    let row_line = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1398        Ok(Line2d::over(
1399            ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
1400            u_dom.0 - 1.0,
1401            u_dom.1 + 1.0,
1402        )?
1403        .into())
1404    };
1405    let column_line = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1406        Ok(Line2d::over(
1407            ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
1408            v_dom.0 - 1.0,
1409            v_dom.1 + 1.0,
1410        )?
1411        .into())
1412    };
1413    ogeom_algo::attach_pcurve(
1414        model,
1415        &ring0,
1416        row_line(v_dom.0)?,
1417        surface_id,
1418        ogeom_topo::Location::identity(),
1419        u_dom,
1420    )?;
1421    ogeom_algo::attach_pcurve(
1422        model,
1423        &ring1,
1424        row_line(v_dom.1)?,
1425        surface_id,
1426        ogeom_topo::Location::identity(),
1427        u_dom,
1428    )?;
1429    ogeom_algo::attach_seam(
1430        model,
1431        &seam,
1432        column_line(u_dom.0)?,
1433        column_line(u_dom.1)?,
1434        surface_id,
1435        ogeom_topo::Location::identity(),
1436        v_dom,
1437    )?;
1438
1439    let wall = {
1440        let wire = ogeom_algo::make_wire(
1441            model,
1442            &[
1443                ring0.clone(),
1444                seam.clone(),
1445                ring1.reversed(),
1446                seam.reversed(),
1447            ],
1448            tol,
1449        )?
1450        .shape;
1451        let face =
1452            ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
1453        // Outward by measurement at the middle of the skin: away from the
1454        // centre of the section the point lies on. The centre of the whole
1455        // skin will not do: along a bent spine it lies inside the bend,
1456        // on the far side of the inner wall.
1457        let mid_u = f64::midpoint(u_dom.0, u_dom.1);
1458        let mid_v = f64::midpoint(v_dom.0, v_dom.1);
1459        let s_mid = surface_geo.point_at(mid_u, mid_v, tol)?;
1460        let (du, dv) = surface_geo.d1_at(mid_u, mid_v, tol)?;
1461        if du.cross(dv).dot(s_mid - section_centre(rows, s_mid)) >= 0.0 {
1462            face
1463        } else {
1464            face.reversed()
1465        }
1466    };
1467    Ok(SkinnedWall {
1468        face: wall,
1469        ring0,
1470        ring1,
1471        curve0,
1472        curve1,
1473        u_dom,
1474    })
1475}
1476
1477/// The centre of the section (row) passing nearest `at`: the mean of its
1478/// points.
1479fn section_centre(rows: &[Vec<Point>], at: Point) -> Point {
1480    let nearest = rows.iter().filter(|row| !row.is_empty()).min_by(|a, b| {
1481        let d = |row: &Vec<Point>| {
1482            row.iter()
1483                .map(|p| p.distance(at))
1484                .fold(f64::INFINITY, f64::min)
1485        };
1486        d(a).total_cmp(&d(b))
1487    });
1488    let Some(row) = nearest else {
1489        return at;
1490    };
1491    let sum = row
1492        .iter()
1493        .fold(Vector::new(0.0, 0.0, 0.0), |acc, p| acc + p.to_vector());
1494    #[allow(clippy::cast_precision_loss)]
1495    Point::from_vector(sum / row.len() as f64)
1496}
1497
1498/// A strip closed the *long* way: open across its own width, a smooth loop
1499/// along the sweep: one face of a faceted ring, [`skinned_wall`]'s
1500/// construction with the chart's roles swapped and the loop made C1 by
1501/// [`ogeom_geom::fit::fit_surface_grid_closed_v`]. The rails are the two
1502/// closed border loops; the seam is one station's column, used twice.
1503fn skinned_ring_strip(
1504    model: &mut Model,
1505    skin: &Skin<'_>,
1506    outward_hint: Point,
1507    shared: [Option<&Shape>; 2],
1508    tolerance: f64,
1509    tol: Tolerances,
1510) -> OgeomResult<(Shape, Shape, Shape)> {
1511    use ogeom_geom::Surface as _;
1512    // The loop: first row repeated at the end, as the closed fit demands.
1513    let fitted = skin.fit(true, tolerance, tol)?;
1514    if !fitted.met {
1515        ogeom_bail!(
1516            NotDone,
1517            "the ring strip reached {} against a target of {tolerance}",
1518            fitted.error
1519        );
1520    }
1521    let surface = fitted.curve;
1522    let (u_knots, v_knots) = (surface.u_knots().clone(), surface.v_knots().clone());
1523    let (k, l, net) = {
1524        let grid = surface.grid();
1525        let net: Vec<Point> = grid.points().iter().map(|w| (*w).point()).collect();
1526        (grid.u_count(), grid.v_count(), net)
1527    };
1528    let point_at = |i: usize, j: usize| -> Point { net[i * l + j] };
1529    let (u_dom, v_dom) = surface.domain();
1530
1531    // The chart's roles, straight: `u` runs across the strip (open), `v`
1532    // around the loop (closed). The rails are v-curves (the closed border
1533    // loops at the two u-borders), and the seam is the u-row at the loop's
1534    // join, bounding the chart twice as every seam does.
1535    let rail_curve = |i: usize| -> OgeomResult<ogeom_geom::Curve> {
1536        let control: Vec<Point> = (0..l).map(|j| point_at(i, j)).collect();
1537        Ok(ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
1538            v_knots.clone(),
1539            control,
1540            tol,
1541        )?))
1542    };
1543    let seam_curve = {
1544        let control: Vec<Point> = (0..k).map(|i| point_at(i, 0)).collect();
1545        ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
1546            u_knots.clone(),
1547            control,
1548            tol,
1549        )?)
1550    };
1551    let surface_geo: SurfaceGeometry = surface.into();
1552    let surface_id = model.geometry_mut().add_surface(surface_geo.clone());
1553
1554    // A corner loop shared with the neighbouring strip is one edge for
1555    // both: the neighbour built it from its own fit, and this strip's
1556    // border is another fit of the same loop, so the edge widens to how
1557    // far it honestly sits from this surface. Two independent fits of one
1558    // loop can disagree by more than either fit's own error (the sew refuses
1559    // that gap under a frame that turns fast), and one edge cannot disagree
1560    // with itself.
1561    let slack = fitted.error + tol.confusion();
1562    let rail_of = |model: &mut Model, i: usize, given: Option<&Shape>| -> OgeomResult<Shape> {
1563        let Some(edge) = given else {
1564            let edge = make_edge(model, rail_curve(i)?, v_dom, tol)?.shape;
1565            model.widen(&edge, ogeom_core::Tolerance::new(slack)?)?;
1566            return Ok(edge);
1567        };
1568        let (curve, range) = spine_curve_of(model, edge)?;
1569        let mut off: f64 = 0.0;
1570        for step in 0..=32 {
1571            #[allow(clippy::cast_precision_loss)]
1572            let t = range.0 + (range.1 - range.0) * (step as f64) / 32.0;
1573            let p = curve.point_at(t, tol)?;
1574            off = off.max(ogeom_algo::project_on_surface(&surface_geo, p, 16, tol)?.distance);
1575        }
1576        model.widen(edge, ogeom_core::Tolerance::new(off + slack)?)?;
1577        if let Some((a, b)) = ogeom_algo::edge_vertices(model, edge)? {
1578            for v in [&a, &b] {
1579                model.widen(v, ogeom_core::Tolerance::new(off + slack)?)?;
1580            }
1581        }
1582        Ok(edge.clone())
1583    };
1584    let rail0 = rail_of(model, 0, shared[0])?;
1585    let rail1 = rail_of(model, k - 1, shared[1])?;
1586    let anchor0 = ogeom_algo::edge_vertices(model, &rail0)?
1587        .map(|(a, _)| a)
1588        .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a strip rail has no vertex"))?;
1589    let anchor1 = ogeom_algo::edge_vertices(model, &rail1)?
1590        .map(|(a, _)| a)
1591        .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a strip rail has no vertex"))?;
1592    let seam = make_edge_between(model, seam_curve, u_dom, &anchor0, &anchor1, tol)?.shape;
1593
1594    let row_line = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1595        Ok(Line2d::over(
1596            ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
1597            u_dom.0 - 1.0,
1598            u_dom.1 + 1.0,
1599        )?
1600        .into())
1601    };
1602    let column_line = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1603        Ok(Line2d::over(
1604            ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
1605            v_dom.0 - 1.0,
1606            v_dom.1 + 1.0,
1607        )?
1608        .into())
1609    };
1610    ogeom_algo::attach_pcurve(
1611        model,
1612        &rail0,
1613        column_line(u_dom.0)?,
1614        surface_id,
1615        ogeom_topo::Location::identity(),
1616        v_dom,
1617    )?;
1618    ogeom_algo::attach_pcurve(
1619        model,
1620        &rail1,
1621        column_line(u_dom.1)?,
1622        surface_id,
1623        ogeom_topo::Location::identity(),
1624        v_dom,
1625    )?;
1626    ogeom_algo::attach_seam(
1627        model,
1628        &seam,
1629        row_line(v_dom.0)?,
1630        row_line(v_dom.1)?,
1631        surface_id,
1632        ogeom_topo::Location::identity(),
1633        u_dom,
1634    )?;
1635    let wire = ogeom_algo::make_wire(
1636        model,
1637        &[
1638            rail0.clone(),
1639            seam.clone(),
1640            rail1.reversed(),
1641            seam.reversed(),
1642        ],
1643        tol,
1644    )?
1645    .shape;
1646    let face = ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
1647    let mid_u = f64::midpoint(u_dom.0, u_dom.1);
1648    let mid_v = f64::midpoint(v_dom.0, v_dom.1);
1649    let s_mid = surface_geo.point_at(mid_u, mid_v, tol)?;
1650    let (du, dv) = surface_geo.d1_at(mid_u, mid_v, tol)?;
1651    let face = if du.cross(dv).dot(s_mid - outward_hint) >= 0.0 {
1652        face
1653    } else {
1654        face.reversed()
1655    };
1656    Ok((face, rail0, rail1))
1657}
1658
1659/// Every edge of `built` that none of `given` holds widened to what its
1660/// pcurves measure along it. A skin's error is measured where its fit
1661/// sampled it, and a border two skins share is fitted on one and read on
1662/// the other at samples; the edges state what they measure between those
1663/// too. The given shapes' own edges are the caller's, and are left as they
1664/// were.
1665fn state_new_gaps(
1666    model: &mut Model,
1667    given: &[Shape],
1668    built: &Shape,
1669    tol: Tolerances,
1670) -> OgeomResult<()> {
1671    let mut held = ogeom_core::FastSet::default();
1672    for shape in given {
1673        for edge in ogeom_topo::explore_unique(model, shape, ShapeType::Edge)? {
1674            held.insert(edge.node());
1675        }
1676    }
1677    let made: Vec<Shape> = ogeom_topo::explore_unique(model, built, ShapeType::Edge)?
1678        .into_iter()
1679        .filter(|e| !held.contains(&e.node()))
1680        .collect();
1681    ogeom_algo::state_pcurve_gaps_of(model, &made, tol)
1682}
1683
1684/// A border edge the neighbouring skin built from its own fit, adopted by
1685/// this skin: the edge widens to how far it honestly sits from `surface`
1686/// plus this fit's own slack, its vertices with it. Two independent fits of
1687/// one row can disagree by more than either fit's own error, and one edge
1688/// cannot disagree with itself.
1689fn adopt_border(
1690    model: &mut Model,
1691    edge: &Shape,
1692    surface: &SurfaceGeometry,
1693    slack: f64,
1694    tol: Tolerances,
1695) -> OgeomResult<()> {
1696    let (curve, range) = spine_curve_of(model, edge)?;
1697    let mut off: f64 = 0.0;
1698    for step in 0..=32 {
1699        #[allow(clippy::cast_precision_loss)]
1700        let t = range.0 + (range.1 - range.0) * (step as f64) / 32.0;
1701        let p = curve.point_at(t, tol)?;
1702        off = off.max(ogeom_algo::project_on_surface(surface, p, 16, tol)?.distance);
1703    }
1704    model.widen(edge, ogeom_core::Tolerance::new(off + slack)?)?;
1705    if let Some((a, b)) = ogeom_algo::edge_vertices(model, edge)? {
1706        for v in [&a, &b] {
1707            model.widen(v, ogeom_core::Tolerance::new(off + slack)?)?;
1708        }
1709    }
1710    Ok(())
1711}
1712
1713/// An adopted border's image on a surface it was not fitted on: the
1714/// border's own points, each read off the surface at its nearest point,
1715/// fitted at the border's own parameters so the image is same-parameter
1716/// with it. Returned with the parameter range it spans and how far the
1717/// surface at the image stands from the border, measured between the
1718/// samples as well as at them.
1719fn adopted_image(
1720    model: &Model,
1721    edge: &Shape,
1722    surface: &SurfaceGeometry,
1723    tol: Tolerances,
1724) -> OgeomResult<(ogeom_geom::PlanarCurve, (f64, f64), f64)> {
1725    const SAMPLES: u32 = 64;
1726    let (curve, range) = spine_curve_of(model, edge)?;
1727    let mut params = Vec::with_capacity(SAMPLES as usize + 1);
1728    let mut image = Vec::with_capacity(SAMPLES as usize + 1);
1729    let mut guess: Option<(f64, f64)> = None;
1730    for step in 0..=SAMPLES {
1731        let t = range.0 + (range.1 - range.0) * f64::from(step) / f64::from(SAMPLES);
1732        let p = curve.point_at(t, tol)?;
1733        let foot = match guess {
1734            Some(g) => ogeom_algo::project_on_surface_from(surface, p, g, tol)?,
1735            None => ogeom_algo::project_on_surface(surface, p, 16, tol)?,
1736        };
1737        guess = Some(foot.parameters);
1738        params.push(t);
1739        image.push(Point2::new(foot.parameters.0, foot.parameters.1));
1740    }
1741    use ogeom_geom::Surface as _;
1742    let fitted = ogeom_geom::fit::fit_points_2d_at(&params, &image, 3, tol.confusion(), tol)?;
1743    // A border's feet lie on the chart's edge, and the fit through them
1744    // wanders across it between them. Its control points are held to the
1745    // chart where it does not wrap, and the curve, inside their hull, with
1746    // them: the surface is read only where it is defined. Moving a control
1747    // point moves the curve by no more, and the standoff is measured after.
1748    let ((u0, u1), (v0, v1)) = surface.domain();
1749    let (wraps_u, wraps_v) = (surface.is_periodic_u(), surface.is_periodic_v());
1750    let held: Vec<ogeom_math::Weighted<Point2>> = fitted
1751        .curve
1752        .control_points()
1753        .iter()
1754        .map(|w| {
1755            let p = w.point();
1756            ogeom_math::Weighted::new(
1757                Point2::new(
1758                    if wraps_u { p.x } else { p.x.clamp(u0, u1) },
1759                    if wraps_v { p.y } else { p.y.clamp(v0, v1) },
1760                ),
1761                w.weight,
1762                tol,
1763            )
1764        })
1765        .collect::<OgeomResult<_>>()?;
1766    let image = ogeom_geom::BSpline2d::rational(fitted.curve.knots().clone(), held)?;
1767    let mut off = 0.0_f64;
1768    for step in 0..=SAMPLES * 4 {
1769        let t = range.0 + (range.1 - range.0) * f64::from(step) / f64::from(SAMPLES * 4);
1770        let at = ogeom_geom::Curve2d::point_at(&image, t, tol)?;
1771        off = off.max(
1772            surface
1773                .point_at(at.x, at.y, tol)?
1774                .distance(curve.point_at(t, tol)?),
1775        );
1776    }
1777    Ok((image.into(), range, off))
1778}
1779
1780/// A solid skinned over a grid of section samples: [`skinned_wall`] with a
1781/// planar cap over each end ring.
1782/// How a skinned solid's end is closed.
1783#[derive(Debug, Clone, Copy)]
1784enum EndCap {
1785    /// The section is planar: a plane face, its normal pointing out.
1786    Plane(Vector),
1787    /// The section is not: a patch skinned from the ring down to a point
1788    /// inside it, sharing the wall's ring edge.
1789    Skinned,
1790    /// The solid closes on a point past the section: the cone from the ring
1791    /// to it, every ruling straight, sharing the wall's ring edge.
1792    Apex(Point),
1793}
1794
1795fn skinned_solid(
1796    model: &mut Model,
1797    skin: &Skin<'_>,
1798    caps: (EndCap, EndCap),
1799    tolerance: f64,
1800    tol: Tolerances,
1801) -> OgeomResult<Built> {
1802    let wall = skinned_wall(model, skin, (None, None), tolerance, tol)?;
1803    let rows = &skin.rows;
1804    let u_dom = wall.u_dom;
1805
1806    let cap = |model: &mut Model,
1807               ring: &Shape,
1808               curve: ogeom_geom::Curve,
1809               outward: Vector|
1810     -> OgeomResult<Shape> {
1811        let at = curve.point_at(u_dom.0, tol)?;
1812        let plane = Plane::through(at, Direction::new(outward, tol)?);
1813        let mut reach = 1.0_f64;
1814        for t in 0..8 {
1815            let p = curve.point_at(u_dom.0 + (u_dom.1 - u_dom.0) * f64::from(t) / 8.0, tol)?;
1816            reach = reach.max(p.distance(at) * 2.0);
1817        }
1818        let cap_surface: SurfaceGeometry =
1819            PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
1820        let walked = walked_about(model, std::slice::from_ref(ring), outward, true, tol)?;
1821        let wire = ogeom_algo::make_wire(model, &walked, tol)?.shape;
1822        let face =
1823            ogeom_algo::make_face(model, cap_surface.clone(), std::slice::from_ref(&wire), tol)?
1824                .shape;
1825        let id = {
1826            let Some(node) = model.node(&face) else {
1827                ogeom_bail!(Dangling, "the cap just built is not in this model");
1828            };
1829            let ogeom_topo::NodeData::Face(data) = node.data() else {
1830                ogeom_bail!(Construction, "the cap holds no face data");
1831            };
1832            data.surface
1833        };
1834        let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&curve, &cap_surface, tol) else {
1835            ogeom_bail!(Construction, "a cap edge has no closed-form pcurve");
1836        };
1837        ogeom_algo::attach_pcurve(
1838            model,
1839            ring,
1840            pcurve,
1841            id,
1842            ogeom_topo::Location::identity(),
1843            u_dom,
1844        )?;
1845        Ok(face)
1846    };
1847    let close = |model: &mut Model,
1848                 end: EndCap,
1849                 ring: &Shape,
1850                 curve: &ogeom_geom::Curve,
1851                 row: &[Point],
1852                 last: bool| {
1853        match end {
1854            EndCap::Plane(outward) => cap(model, ring, curve.clone(), outward),
1855            EndCap::Skinned | EndCap::Apex(_) => {
1856                // The cone from the section to the point its rows collapse
1857                // to (the section's own centroid, which a closed section
1858                // winds round, or the apex the loft closes on): the ring, a
1859                // row halfway in, and the point. Every column is straight,
1860                // so no row of it turns over on the way to the point.
1861                let apex = match end {
1862                    EndCap::Apex(apex) => apex,
1863                    _ => centroid_of(std::slice::from_ref(&row.to_vec())),
1864                };
1865                let patch = match &skin.traced {
1866                    // Known between the samples where the skin knows its
1867                    // end section there, and checked there too.
1868                    Some(traced) => {
1869                        let vs = &traced.sampling.vs;
1870                        let at = if last { vs[vs.len() - 1] } else { vs[0] };
1871                        Skin::swept(
1872                            move |f, s| {
1873                                let p = (traced.point)(f, at)?;
1874                                Ok(apex + (p - apex) * (1.0 - s * 0.5))
1875                            },
1876                            traced.sampling.us.clone(),
1877                            (0, 2),
1878                            skin.round,
1879                        )?
1880                    }
1881                    None => {
1882                        let half: Vec<Point> = row
1883                            .iter()
1884                            .map(|p| Point::from_vector((p.to_vector() + apex.to_vector()) * 0.5))
1885                            .collect();
1886                        Skin::rows(vec![row.to_vec(), half, vec![apex; row.len()]])
1887                    }
1888                };
1889                // Inside the solid behind a cap: the middle of the next
1890                // section in. A cone to an apex has the ring's own middle
1891                // behind it (see `apex_patch`).
1892                let inside = match end {
1893                    EndCap::Apex(_) => None,
1894                    _ => {
1895                        let next = if last {
1896                            &rows[rows.len() - 2]
1897                        } else {
1898                            &rows[1]
1899                        };
1900                        Some(centroid_of(std::slice::from_ref(next)))
1901                    }
1902                };
1903                let Some((face, _)) =
1904                    apex_patch(model, &patch, Some(ring), inside, None, tolerance, tol)?
1905                else {
1906                    ogeom_bail!(Construction, "a cone over a section was refused");
1907                };
1908                Ok(face)
1909            }
1910        }
1911    };
1912    let cap0 = close(model, caps.0, &wall.ring0, &wall.curve0, &rows[0], false)?;
1913    let cap1 = close(
1914        model,
1915        caps.1,
1916        &wall.ring1,
1917        &wall.curve1,
1918        &rows[rows.len() - 1],
1919        true,
1920    )?;
1921
1922    let faces = [wall.face, cap0, cap1];
1923    let sewn = sew(model, &faces, tol)?;
1924    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
1925        ogeom_bail!(Construction, "the skinned solid did not close");
1926    }
1927    make_solid(model, std::slice::from_ref(&sewn.shells[0]))
1928}
1929
1930/// A patch skinned from a ring down to a point: [`skinned_wall`]'s
1931/// construction with the top ring replaced by the apex: a degenerate
1932/// edge on one vertex, bounding the chart's whole top row the way a cone's
1933/// apex bounds a countersink. The ring edge is adopted from `shared`
1934/// where a neighbour already built it, and the face is turned to point
1935/// away from `inside` where given (a point within the solid behind the
1936/// patch), from the section it passes through otherwise. Returns the face
1937/// and its ring edge.
1938fn apex_patch(
1939    model: &mut Model,
1940    skin: &Skin<'_>,
1941    shared: Option<&Shape>,
1942    inside: Option<Point>,
1943    unfolded_from: Option<f64>,
1944    tolerance: f64,
1945    tol: Tolerances,
1946) -> OgeomResult<Option<(Shape, Shape)>> {
1947    use ogeom_geom::Surface as _;
1948    let rows = &skin.rows;
1949    let fitted = skin.fit(false, tolerance, tol)?;
1950    if !fitted.met {
1951        ogeom_bail!(
1952            NotDone,
1953            "the skin reached {} against a target of {tolerance}",
1954            fitted.error
1955        );
1956    }
1957    let surface = fitted.curve;
1958    let (u_knots, v_knots) = (surface.u_knots().clone(), surface.v_knots().clone());
1959    let (k, l, net) = {
1960        let grid = surface.grid();
1961        let net: Vec<Point> = grid.points().iter().map(|w| (*w).point()).collect();
1962        (grid.u_count(), grid.v_count(), net)
1963    };
1964    let point_at = |i: usize, j: usize| -> Point { net[i * l + j] };
1965    let (u_dom, v_dom) = surface.domain();
1966    let apex = rows[rows.len() - 1][0];
1967    let surface_geo: SurfaceGeometry = surface.into();
1968    // The face points away from `behind` when the cone it spans from there,
1969    // the integral of (p - behind) . (du x dv) over the whole chart, holds
1970    // positive volume: the whole patch votes, where a normal read at one
1971    // point says only which way the patch faces there. Without a point
1972    // inside, behind is the ring's middle: the fan from it to the ring adds
1973    // nothing to the integral (every fan triangle contains it), so this is
1974    // the signed volume the patch closes with that fan, a planar ring's
1975    // cap.
1976    let behind = inside.unwrap_or_else(|| centroid_of(std::slice::from_ref(&rows[0])));
1977    let spans = |knots: &ogeom_math::KnotVector| -> Vec<(f64, f64)> {
1978        let breaks: Vec<f64> = knots.distinct().into_iter().map(|(t, _)| t).collect();
1979        breaks.windows(2).map(|w| (w[0], w[1])).collect()
1980    };
1981    let mut held = 0.0;
1982    for (u0, u1) in spans(&u_knots) {
1983        for (v0, v1) in spans(&v_knots) {
1984            for (u, wu) in ogeom_math::integrate::gauss_legendre_rule(u0, u1) {
1985                for (v, wv) in ogeom_math::integrate::gauss_legendre_rule(v0, v1) {
1986                    let p = surface_geo.point_at(u, v, tol)?;
1987                    let (du, dv) = surface_geo.d1_at(u, v, tol)?;
1988                    held += wu * wv * du.cross(dv).dot(p - behind);
1989                }
1990            }
1991        }
1992    }
1993    if let Some(v_from) = unfolded_from {
1994        let from = centroid_of(std::slice::from_ref(&rows[rows.len() - 2]));
1995        let u_count = (spans(&u_knots).len() * 4).max(256);
1996        if folds_toward(
1997            &surface_geo,
1998            (u_dom, (v_from, v_dom.1)),
1999            (from, apex),
2000            held,
2001            u_count,
2002            tol,
2003        )? {
2004            return Ok(None);
2005        }
2006    }
2007
2008    let ring_curve = {
2009        let control: Vec<Point> = (0..k).map(|i| point_at(i, 0)).collect();
2010        ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
2011            u_knots.clone(),
2012            control,
2013            tol,
2014        )?)
2015    };
2016    let seam_curve = {
2017        let control: Vec<Point> = (0..l).map(|j| point_at(0, j)).collect();
2018        ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
2019            v_knots.clone(),
2020            control,
2021            tol,
2022        )?)
2023    };
2024    let surface_id = model.geometry_mut().add_surface(surface_geo.clone());
2025
2026    // The ring a neighbour built is adopted, not refitted (see `adopt_border`).
2027    let ring0 = match shared {
2028        Some(edge) => {
2029            adopt_border(
2030                model,
2031                edge,
2032                &surface_geo,
2033                fitted.error + tol.confusion(),
2034                tol,
2035            )?;
2036            edge.clone()
2037        }
2038        None => make_edge(model, ring_curve.clone(), u_dom, tol)?.shape,
2039    };
2040    let anchor0 = ogeom_algo::edge_vertices(model, &ring0)?
2041        .map(|(a, _)| a)
2042        .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a skinned ring has no vertex"))?;
2043    let apex_vertex = model.add_vertex(VertexData::new(apex));
2044    let apex_edge = {
2045        let mut data = EdgeData::new();
2046        data.degenerate = true;
2047        model.add_edge(data, &[apex_vertex.clone(), apex_vertex.clone()])?
2048    };
2049    let seam = make_edge_between(model, seam_curve, v_dom, &anchor0, &apex_vertex, tol)?.shape;
2050
2051    let row_line = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
2052        Ok(Line2d::over(
2053            ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
2054            u_dom.0 - 1.0,
2055            u_dom.1 + 1.0,
2056        )?
2057        .into())
2058    };
2059    let column_line = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
2060        Ok(Line2d::over(
2061            ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
2062            v_dom.0 - 1.0,
2063            v_dom.1 + 1.0,
2064        )?
2065        .into())
2066    };
2067    ogeom_algo::attach_pcurve(
2068        model,
2069        &ring0,
2070        row_line(v_dom.0)?,
2071        surface_id,
2072        ogeom_topo::Location::identity(),
2073        u_dom,
2074    )?;
2075    // The apex bounds the chart's whole top row while covering no distance:
2076    // the degenerate edge carries the row's pcurve, exactly as a cone's apex
2077    // does after the reader synthesises it.
2078    ogeom_algo::attach_pcurve(
2079        model,
2080        &apex_edge,
2081        row_line(v_dom.1)?,
2082        surface_id,
2083        ogeom_topo::Location::identity(),
2084        u_dom,
2085    )?;
2086    ogeom_algo::attach_seam(
2087        model,
2088        &seam,
2089        column_line(u_dom.0)?,
2090        column_line(u_dom.1)?,
2091        surface_id,
2092        ogeom_topo::Location::identity(),
2093        v_dom,
2094    )?;
2095
2096    let wire = ogeom_algo::make_wire(
2097        model,
2098        &[
2099            ring0.clone(),
2100            seam.clone(),
2101            apex_edge.reversed(),
2102            seam.reversed(),
2103        ],
2104        tol,
2105    )?
2106    .shape;
2107    let face = ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
2108    let face = if held >= 0.0 { face } else { face.reversed() };
2109    Ok(Some((face, ring0)))
2110}
2111
2112/// Whether `surface` folds between the row at `v` naught of `chart` and the
2113/// point its last row collapses to, `ends.1`. Read about the line from the
2114/// section's middle `ends.0` to that point, the stretch is unfolded where
2115/// it is a graph over the angle round that line and the height along it:
2116/// the map from the chart to (angle, height) keeps one orientation (its
2117/// Jacobian is the normal's part away from the line, over the radius, so
2118/// du x dv leans away from the line on the side `outward` says faces out),
2119/// every column climbs toward the point, and every row goes once round the
2120/// line the same way. Measured at `u_count` columns, on rows evenly up the
2121/// stretch and then halving the way in to the point, where the rows shrink
2122/// to the shape of the surface's tangent cone.
2123fn folds_toward(
2124    surface: &SurfaceGeometry,
2125    chart: ((f64, f64), (f64, f64)),
2126    ends: (Point, Point),
2127    outward: f64,
2128    u_count: usize,
2129    tol: Tolerances,
2130) -> OgeomResult<bool> {
2131    use ogeom_geom::Surface as _;
2132    /// Rows evenly up the stretch.
2133    const EVEN: u32 = 48;
2134    /// Rows halving the way to the point after the even ones.
2135    const CLOSING: i32 = 12;
2136    let ((u0, u1), (v0, v1)) = chart;
2137    let (from, to) = ends;
2138    let along = to - from;
2139    let length = along.magnitude();
2140    if length <= tol.confusion() {
2141        return Ok(true);
2142    }
2143    let axis = along / length;
2144    let outward = outward.signum();
2145    let mut vs: Vec<f64> = (0..EVEN)
2146        .map(|k| v0 + (v1 - v0) * f64::from(k) / f64::from(EVEN))
2147        .collect();
2148    vs.extend((1..=CLOSING).map(|k| v1 - (v1 - v0) / f64::from(EVEN) * 0.5_f64.powi(k)));
2149    #[allow(clippy::cast_precision_loss, reason = "a sample count")]
2150    let us: Vec<f64> = (0..u_count)
2151        .map(|i| u0 + (u1 - u0) * i as f64 / u_count as f64)
2152        .collect();
2153    let mut heights: Vec<f64> = vec![f64::NEG_INFINITY; us.len()];
2154    let mut sense = 0.0;
2155    for &v in &vs {
2156        let mut turned = 0.0;
2157        let mut first: Option<Vector> = None;
2158        let mut previous: Option<Vector> = None;
2159        for (i, &u) in us.iter().enumerate() {
2160            let p = surface.point_at(u, v, tol)?;
2161            let (du, dv) = surface.d1_at(u, v, tol)?;
2162            let off = p - from;
2163            let height = off.dot(axis);
2164            let radial = off - axis * height;
2165            if radial.magnitude() <= tol.confusion()
2166                || height <= heights[i]
2167                || du.cross(dv).dot(radial) * outward <= 0.0
2168            {
2169                return Ok(true);
2170            }
2171            heights[i] = height;
2172            if let Some(last) = previous {
2173                turned += last.cross(radial).dot(axis).atan2(last.dot(radial));
2174            }
2175            first.get_or_insert(radial);
2176            previous = Some(radial);
2177        }
2178        if let (Some(last), Some(start)) = (previous, first) {
2179            turned += last.cross(start).dot(axis).atan2(last.dot(start));
2180        }
2181        let round = turned / core::f64::consts::TAU;
2182        if (round.abs() - 1.0).abs() > 0.5 || round * sense < 0.0 {
2183            return Ok(true);
2184        }
2185        sense = round;
2186    }
2187    Ok(false)
2188}
2189
2190/// The mean of every point in every row.
2191fn centroid_of(rows: &[Vec<Point>]) -> Point {
2192    let mut c = Vector::new(0.0, 0.0, 0.0);
2193    let mut n = 0.0;
2194    for row in rows {
2195        for p in row {
2196            c += p.to_vector();
2197            n += 1.0;
2198        }
2199    }
2200    Point::from_vector(c / n)
2201}
2202
2203/// A solid skinned down to a point: [`apex_patch`] for the wall, and one
2204/// cap at the open end; the apex end closes by construction.
2205fn skinned_solid_to_apex(
2206    model: &mut Model,
2207    skin: &Skin<'_>,
2208    cap_outward: Vector,
2209    unfolded_from: Option<f64>,
2210    tolerance: f64,
2211    tol: Tolerances,
2212) -> OgeomResult<Option<Built>> {
2213    use ogeom_geom::Curve3d as _;
2214    let Some((wall, ring0)) = apex_patch(model, skin, None, None, unfolded_from, tolerance, tol)?
2215    else {
2216        return Ok(None);
2217    };
2218    let (ring_curve, u_dom) = {
2219        let (curve, range) = spine_curve_of(model, &ring0)?;
2220        (curve, range)
2221    };
2222
2223    // One cap, on the open end; the machinery is skinned_solid's, inlined
2224    // for the single ring.
2225    let cap = {
2226        let at = ring_curve.point_at(u_dom.0, tol)?;
2227        let plane = Plane::through(at, Direction::new(cap_outward, tol)?);
2228        let mut reach = 1.0_f64;
2229        for t in 0..8 {
2230            let p = ring_curve.point_at(u_dom.0 + (u_dom.1 - u_dom.0) * f64::from(t) / 8.0, tol)?;
2231            reach = reach.max(p.distance(at) * 2.0);
2232        }
2233        let cap_surface: SurfaceGeometry =
2234            PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
2235        let walked = walked_about(model, std::slice::from_ref(&ring0), cap_outward, true, tol)?;
2236        let wire = ogeom_algo::make_wire(model, &walked, tol)?.shape;
2237        let face =
2238            ogeom_algo::make_face(model, cap_surface.clone(), std::slice::from_ref(&wire), tol)?
2239                .shape;
2240        let id = {
2241            let Some(node) = model.node(&face) else {
2242                ogeom_bail!(Dangling, "the cap just built is not in this model");
2243            };
2244            let ogeom_topo::NodeData::Face(data) = node.data() else {
2245                ogeom_bail!(Construction, "the cap holds no face data");
2246            };
2247            data.surface
2248        };
2249        let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&ring_curve, &cap_surface, tol) else {
2250            ogeom_bail!(Construction, "a cap edge has no closed-form pcurve");
2251        };
2252        ogeom_algo::attach_pcurve(
2253            model,
2254            &ring0,
2255            pcurve,
2256            id,
2257            ogeom_topo::Location::identity(),
2258            u_dom,
2259        )?;
2260        face
2261    };
2262
2263    let faces = [wall, cap];
2264    let sewn = sew(model, &faces, tol)?;
2265    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
2266        ogeom_bail!(Construction, "the skinned apex solid did not close");
2267    }
2268    make_solid(model, std::slice::from_ref(&sewn.shells[0])).map(Some)
2269}
2270
2271/// A solid skinned over a grid of sections that loops back on itself: the
2272/// wall is one face closed in both chart directions, no caps at all.
2273///
2274/// The `u` seam closes the way every skin's does (pinned row ends), and
2275/// the `v` loop closes through [`ogeom_geom::fit::fit_surface_grid_closed_v`],
2276/// C1 across the join. All four boundary traversals are two seam edges used
2277/// twice, anchored at one shared vertex, exactly as a torus bounds itself.
2278fn closed_skinned_solid(
2279    model: &mut Model,
2280    skin: &Skin<'_>,
2281    tolerance: f64,
2282    tol: Tolerances,
2283) -> OgeomResult<Built> {
2284    let shell = closed_skinned_shell(model, skin, tolerance, tol)?;
2285    make_solid(model, std::slice::from_ref(&shell))
2286}
2287
2288/// The closed skin as a shell, for callers assembling solids with voids;
2289/// a holed profile's ring is one outer shell and one per tunnel.
2290fn closed_skinned_shell(
2291    model: &mut Model,
2292    skin: &Skin<'_>,
2293    tolerance: f64,
2294    tol: Tolerances,
2295) -> OgeomResult<Shape> {
2296    use ogeom_geom::Surface as _;
2297    let rows = &skin.rows;
2298    let fitted = skin.fit(true, tolerance, tol)?;
2299    if !fitted.met {
2300        ogeom_bail!(
2301            NotDone,
2302            "the closed skin reached {} against a target of {tolerance}",
2303            fitted.error
2304        );
2305    }
2306    let surface = fitted.curve;
2307    let (u_knots, v_knots) = (surface.u_knots().clone(), surface.v_knots().clone());
2308    let (k, l, net) = {
2309        let grid = surface.grid();
2310        let net: Vec<Point> = grid.points().iter().map(|w| (*w).point()).collect();
2311        (grid.u_count(), grid.v_count(), net)
2312    };
2313    let point_at = |i: usize, j: usize| -> Point { net[i * l + j] };
2314    let (u_dom, v_dom) = surface.domain();
2315
2316    // Both seams straight off the net: the u-run at v's join, and the v-run
2317    // at u's.
2318    let along_u = {
2319        let control: Vec<Point> = (0..k).map(|i| point_at(i, 0)).collect();
2320        ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(u_knots, control, tol)?)
2321    };
2322    let along_v = {
2323        let control: Vec<Point> = (0..l).map(|j| point_at(0, j)).collect();
2324        ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(v_knots, control, tol)?)
2325    };
2326    let surface_geo: SurfaceGeometry = surface.into();
2327    let surface_id = model.geometry_mut().add_surface(surface_geo.clone());
2328
2329    let u_edge = make_edge(model, along_u, u_dom, tol)?.shape;
2330    let Some((corner, _)) = ogeom_algo::edge_vertices(model, &u_edge)? else {
2331        ogeom_bail!(Construction, "the closed skin's seam has no vertex");
2332    };
2333    let v_edge = make_edge_between(model, along_v, v_dom, &corner, &corner, tol)?.shape;
2334
2335    let row_line = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
2336        Ok(Line2d::over(
2337            ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
2338            u_dom.0 - 1.0,
2339            u_dom.1 + 1.0,
2340        )?
2341        .into())
2342    };
2343    let column_line = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
2344        Ok(Line2d::over(
2345            ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
2346            v_dom.0 - 1.0,
2347            v_dom.1 + 1.0,
2348        )?
2349        .into())
2350    };
2351    // The u-run is a seam in v (the same curve at both rows), and the
2352    // v-run a seam in u.
2353    ogeom_algo::attach_seam(
2354        model,
2355        &u_edge,
2356        row_line(v_dom.0)?,
2357        row_line(v_dom.1)?,
2358        surface_id,
2359        ogeom_topo::Location::identity(),
2360        u_dom,
2361    )?;
2362    ogeom_algo::attach_seam(
2363        model,
2364        &v_edge,
2365        column_line(u_dom.1)?,
2366        column_line(u_dom.0)?,
2367        surface_id,
2368        ogeom_topo::Location::identity(),
2369        v_dom,
2370    )?;
2371
2372    let wire = ogeom_algo::make_wire(
2373        model,
2374        &[
2375            u_edge.clone(),
2376            v_edge.clone(),
2377            u_edge.reversed(),
2378            v_edge.reversed(),
2379        ],
2380        tol,
2381    )?
2382    .shape;
2383    let face = ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
2384    // Away from the centre of the section the point lies on; a closed
2385    // skin's centre as a whole sits in its hole.
2386    let mid_u = f64::midpoint(u_dom.0, u_dom.1);
2387    let mid_v = f64::midpoint(v_dom.0, v_dom.1);
2388    let s_mid = surface_geo.point_at(mid_u, mid_v, tol)?;
2389    let (du, dv) = surface_geo.d1_at(mid_u, mid_v, tol)?;
2390    let face = if du.cross(dv).dot(s_mid - section_centre(rows, s_mid)) >= 0.0 {
2391        face
2392    } else {
2393        face.reversed()
2394    };
2395
2396    let sewn = sew(model, std::slice::from_ref(&face), tol)?;
2397    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
2398        ogeom_bail!(Construction, "the closed skin did not close");
2399    }
2400    Ok(sewn.shells[0].clone())
2401}
2402
2403/// The loft to a point: a wire section closing onto a single apex vertex.
2404///
2405/// A circle takes the cone the revolved primitives already build, apex on
2406/// its axis or refused; a polygon takes exact planar triangle walls, sound
2407/// for *any* apex: a skew pyramid's walls are still triangles.
2408fn loft_to_point(
2409    model: &mut Model,
2410    section: &Shape,
2411    apex: &Shape,
2412    tol: Tolerances,
2413) -> OgeomResult<Built> {
2414    if !ogeom_algo::is_wire_closed(model, section, tol)? {
2415        ogeom_bail!(Construction, "a loft section must be closed");
2416    }
2417    let apex_point = {
2418        let Some(data) = model.node(apex).and_then(|n| n.data().as_vertex()) else {
2419            ogeom_bail!(Construction, "the apex vertex holds no data");
2420        };
2421        apex.transform(model.datums())?.apply(data.point)
2422    };
2423
2424    // The circular case: a cone, apex on the axis.
2425    let edges = explore(model, section, Filter::OfType(ShapeType::Edge))?;
2426    if edges.len() == 1
2427        && let Some((Curve::Circle(c), _)) = ogeom_algo::placed_edge_curve(model, &edges[0], tol)?
2428    {
2429        let circle = c.circle();
2430        let axis = circle.frame().z().vector();
2431        let rise = apex_point - circle.centre();
2432        let height = rise.dot(axis);
2433        if rise.cross(axis).magnitude() > tol.confusion() * 10.0 {
2434            ogeom_bail!(
2435                Construction,
2436                "a circle lofts to a point on its own axis; the oblique cone \
2437                 needs the skinned machinery; see docs/CAPABILITIES.md, offset.loft"
2438            );
2439        }
2440        if height.abs() <= tol.confusion() {
2441            ogeom_bail!(Construction, "the apex sits in the section's own plane");
2442        }
2443        let base = if height > 0.0 {
2444            circle.frame()
2445        } else {
2446            Frame::new(
2447                circle.centre(),
2448                -circle.frame().z(),
2449                circle.frame().x(),
2450                tol,
2451            )?
2452        };
2453        let mut built =
2454            ogeom_algo::make_cone(model, base, circle.radius(), 0.0, height.abs(), tol)?;
2455        built.history.generate(section, built.shape.clone());
2456        built.history.generate(apex, built.shape.clone());
2457        return Ok(built);
2458    }
2459
2460    // The polygonal case: exact triangle walls to a shared apex.
2461    let mut corners: Vec<Point> = Vec::new();
2462    for edge in model.ordered_children_of(section)? {
2463        let Some((curve, range)) = ogeom_algo::placed_edge_curve(model, &edge, tol)? else {
2464            ogeom_bail!(Construction, "a section edge has no curve");
2465        };
2466        let Curve::Line(line) = curve else {
2467            ogeom_bail!(
2468                Construction,
2469                "a mixed or curved section lofts to a point through the \
2470                 skinned machinery; see docs/CAPABILITIES.md, offset.loft"
2471            );
2472        };
2473        let t = if edge.orientation() == ogeom_topo::Orientation::Reversed {
2474            range.1
2475        } else {
2476            range.0
2477        };
2478        corners.push(ogeom_geom::Curve::Line(line).point_at(t, tol)?);
2479    }
2480    if corners.len() < 3 {
2481        ogeom_bail!(Construction, "a pyramid needs at least three base corners");
2482    }
2483    let apex_vertex = ogeom_algo::make_vertex(model, apex_point).shape;
2484    let base_vertices: Vec<Shape> = corners
2485        .iter()
2486        .map(|p| ogeom_algo::make_vertex(model, *p).shape)
2487        .collect();
2488    let segment =
2489        |model: &mut Model, from: (&Shape, Point), to: (&Shape, Point)| -> OgeomResult<Shape> {
2490            let line = ogeom_geom::LineCurve::segment(from.1, to.1, tol)?;
2491            let curve: Curve = line.into();
2492            let domain = curve.domain();
2493            Ok(make_edge_between(model, curve, domain, from.0, to.0, tol)?.shape)
2494        };
2495    let count = corners.len();
2496    let mut base_edges = Vec::with_capacity(count);
2497    let mut rails = Vec::with_capacity(count);
2498    for i in 0..count {
2499        let next = (i + 1) % count;
2500        base_edges.push(segment(
2501            model,
2502            (&base_vertices[i], corners[i]),
2503            (&base_vertices[next], corners[next]),
2504        )?);
2505        rails.push(segment(
2506            model,
2507            (&base_vertices[i], corners[i]),
2508            (&apex_vertex, apex_point),
2509        )?);
2510    }
2511    let centroid = {
2512        let mut c = Vector::new(0.0, 0.0, 0.0);
2513        for p in &corners {
2514            c += p.to_vector();
2515        }
2516        #[allow(clippy::cast_precision_loss)]
2517        Point::from_vector(c / count as f64 / 4.0 * 3.0 + apex_point.to_vector() / 4.0)
2518    };
2519    let planar = |model: &mut Model, pts: [Point; 3], walk: Vec<Shape>| -> OgeomResult<Shape> {
2520        let n = (pts[1] - pts[0]).cross(pts[2] - pts[0]);
2521        let m = n.magnitude();
2522        if m <= tol.confusion() {
2523            ogeom_bail!(Construction, "a wall of the pyramid is degenerate");
2524        }
2525        let mut outward = n / m;
2526        if outward.dot(pts[0] - centroid) < 0.0 {
2527            outward = -outward;
2528        }
2529        let plane = ogeom_math::Plane::through(pts[0], Direction::new(outward, tol)?);
2530        let mut reach = 1.0_f64;
2531        for p in pts {
2532            reach = reach.max(p.distance(pts[0]) * 2.0);
2533        }
2534        let surface: SurfaceGeometry =
2535            PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
2536        let id = model.geometry_mut().add_surface(surface.clone());
2537        let signed = {
2538            let (du, dv) = {
2539                use ogeom_geom::Surface as _;
2540                surface.d1_at(0.0, 0.0, tol)?
2541            };
2542            du.cross(dv).dot(outward) >= 0.0
2543        };
2544        let mut wired = Vec::with_capacity(walk.len());
2545        for used in &walk {
2546            let (curve, range) = spine_curve_of(model, used)?;
2547            let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&curve, &surface, tol) else {
2548                ogeom_bail!(Construction, "a wall edge has no closed-form pcurve");
2549            };
2550            ogeom_algo::attach_pcurve(
2551                model,
2552                used,
2553                pcurve,
2554                id,
2555                ogeom_topo::Location::identity(),
2556                range,
2557            )?;
2558            wired.push(used.clone());
2559        }
2560        let wire = ogeom_algo::make_wire(model, &wired, tol)?.shape;
2561        let face = ogeom_algo::make_face_on(model, id, std::slice::from_ref(&wire), tol)?.shape;
2562        Ok(if signed { face } else { face.reversed() })
2563    };
2564    let mut faces = Vec::with_capacity(count + 1);
2565    for i in 0..count {
2566        let next = (i + 1) % count;
2567        faces.push(planar(
2568            model,
2569            [corners[i], corners[next], apex_point],
2570            vec![
2571                base_edges[i].clone(),
2572                rails[next].clone(),
2573                rails[i].reversed(),
2574            ],
2575        )?);
2576    }
2577    // The base cap: all corners, wound against the walls.
2578    let base_walk: Vec<Shape> = (0..count).rev().map(|i| base_edges[i].reversed()).collect();
2579    faces.push({
2580        let n = (corners[1] - corners[0]).cross(corners[2] - corners[0]);
2581        let mut outward = n / n.magnitude();
2582        if outward.dot(corners[0] - centroid) < 0.0 {
2583            outward = -outward;
2584        }
2585        let plane = ogeom_math::Plane::through(corners[0], Direction::new(outward, tol)?);
2586        let mut reach = 1.0_f64;
2587        for p in &corners {
2588            reach = reach.max(p.distance(corners[0]) * 2.0);
2589        }
2590        let surface: SurfaceGeometry =
2591            PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
2592        let id = model.geometry_mut().add_surface(surface.clone());
2593        for used in &base_walk {
2594            let (curve, range) = spine_curve_of(model, used)?;
2595            let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&curve, &surface, tol) else {
2596                ogeom_bail!(Construction, "a base edge has no closed-form pcurve");
2597            };
2598            ogeom_algo::attach_pcurve(
2599                model,
2600                used,
2601                pcurve,
2602                id,
2603                ogeom_topo::Location::identity(),
2604                range,
2605            )?;
2606        }
2607        let wire = ogeom_algo::make_wire(model, &base_walk, tol)?.shape;
2608        let face = ogeom_algo::make_face_on(model, id, std::slice::from_ref(&wire), tol)?.shape;
2609        let signed = {
2610            use ogeom_geom::Surface as _;
2611            let (du, dv) = surface.d1_at(0.0, 0.0, tol)?;
2612            du.cross(dv).dot(outward) >= 0.0
2613        };
2614        if signed { face } else { face.reversed() }
2615    });
2616
2617    let sewn = sew(model, &faces, tol)?;
2618    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
2619        ogeom_bail!(Construction, "the pyramid did not close");
2620    }
2621    let mut built = make_solid(model, std::slice::from_ref(&sewn.shells[0]))?;
2622    built.history.generate(section, built.shape.clone());
2623    built.history.generate(apex, built.shape.clone());
2624    Ok(built)
2625}
2626
2627/// Loft through sections with the start of each row named by the caller.
2628///
2629/// [`make_loft_skinned`] leaves alignment to each section's own traversal
2630/// start; this sibling takes one hint per section (a point near where its
2631/// row should begin) and rotates each sampling there, which is how a
2632/// caller untwists a loft whose wires happen to start in different places.
2633///
2634/// # Errors
2635///
2636/// As [`make_loft_skinned`], and additionally if the hints do not pair up
2637/// with the sections.
2638pub fn make_loft_skinned_aligned(
2639    model: &mut Model,
2640    sections: &[Shape],
2641    hints: &[Point],
2642    tolerance: f64,
2643    tol: Tolerances,
2644) -> OgeomResult<Built> {
2645    if hints.len() != sections.len() {
2646        ogeom_bail!(
2647            Construction,
2648            "{} hints against {} sections; each section names its own start",
2649            hints.len(),
2650            sections.len()
2651        );
2652    }
2653    if sections.len() < 2 {
2654        ogeom_bail!(Construction, "a loft needs at least two sections");
2655    }
2656    let mut loops: Vec<Section> = Vec::with_capacity(sections.len());
2657    let mut planes: Vec<Plane> = Vec::with_capacity(sections.len());
2658    for (wire, hint) in sections.iter().zip(hints) {
2659        if model.kind_of(wire)? != ShapeType::Wire {
2660            ogeom_bail!(Construction, "a loft section is a closed wire");
2661        }
2662        if !ogeom_algo::is_wire_closed(model, wire, tol)? {
2663            ogeom_bail!(Construction, "a loft section must be closed");
2664        }
2665        let Some(plane) = ogeom_algo::find_plane(model, wire, tol)? else {
2666            ogeom_bail!(Construction, "a loft section must be planar");
2667        };
2668        planes.push(plane);
2669        loops.push(Section::Loop(section_loop(model, wire, Some(*hint), tol)?));
2670    }
2671    let skin = Skin::sections(loops, false)?;
2672    let rows = &skin.rows;
2673    let outward0 = {
2674        let towards = rows[1][0] - rows[0][0];
2675        let n = planes[0].normal().vector();
2676        if n.dot(towards) > 0.0 { -n } else { n }
2677    };
2678    let outward1 = {
2679        let towards = rows[rows.len() - 2][0] - rows[rows.len() - 1][0];
2680        let n = planes[planes.len() - 1].normal().vector();
2681        if n.dot(towards) > 0.0 { -n } else { n }
2682    };
2683    let mut built = skinned_solid(
2684        model,
2685        &skin,
2686        (EndCap::Plane(outward0), EndCap::Plane(outward1)),
2687        tolerance,
2688        tol,
2689    )?;
2690    for section in sections {
2691        built.history.generate(section, built.shape.clone());
2692    }
2693    state_new_gaps(model, sections, &built.shape, tol)?;
2694    Ok(built)
2695}
2696
2697/// Loft a ring through closed planar sections that loop back to the first.
2698///
2699/// [`make_loft_skinned`]'s closed sibling: the sections are sampled the same
2700/// way, the skin runs through all of them and back to the start, C1 across
2701/// the loop, and there are no caps: the result bounds itself the way a
2702/// torus does. The sections are *not* repeated: the loop-back is the
2703/// construction's own.
2704///
2705/// The closed join costs freedom: a sparse loop fits only loosely, and the
2706/// refusal quotes the deviation it honestly reached. A loop that wants a
2707/// tight tolerance wants sections dense enough to bend around: in
2708/// practice, a dozen and up.
2709///
2710/// # Errors
2711///
2712/// As [`make_loft_skinned`], needing at least three sections;
2713/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the closed
2714/// skin cannot reach the tolerance.
2715pub fn make_loft_skinned_closed(
2716    model: &mut Model,
2717    sections: &[Shape],
2718    tolerance: f64,
2719    tol: Tolerances,
2720) -> OgeomResult<Built> {
2721    if sections.len() < 3 {
2722        ogeom_bail!(Construction, "a closed loft needs at least three sections");
2723    }
2724    let mut loops: Vec<Section> = Vec::with_capacity(sections.len());
2725    for wire in sections {
2726        if model.kind_of(wire)? != ShapeType::Wire {
2727            ogeom_bail!(Construction, "a loft section is a closed wire");
2728        }
2729        if !ogeom_algo::is_wire_closed(model, wire, tol)? {
2730            ogeom_bail!(Construction, "a loft section must be closed");
2731        }
2732        if ogeom_algo::find_plane(model, wire, tol)?.is_none() {
2733            ogeom_bail!(Construction, "a loft section must be planar");
2734        }
2735        loops.push(Section::Loop(section_loop(model, wire, None, tol)?));
2736    }
2737    let skin = Skin::sections(loops, true)?;
2738    let mut built = closed_skinned_solid(model, &skin, tolerance, tol)?;
2739    for section in sections {
2740        built.history.generate(section, built.shape.clone());
2741    }
2742    state_new_gaps(model, sections, &built.shape, tol)?;
2743    Ok(built)
2744}
2745
2746/// A skinned strip: one open patch of a sweep, with its border edges.
2747///
2748/// The wall of a *faceted* profile cannot be one closed skin (a fit cannot
2749/// speak a corner), so each profile edge sweeps its own strip, cornered at
2750/// the caller's shared vertices, and the strips weld along their rails by
2751/// the tolerance the fit honestly carries.
2752struct SkinnedStrip {
2753    face: Shape,
2754    /// The border along the first station, from `corners.0` to `corners.1`.
2755    bottom: Shape,
2756    /// The border along the last station, from `corners.2` to `corners.3`.
2757    top: Shape,
2758    /// The rail along the profile edge's start, from `corners.0` to `corners.2`.
2759    rail0: Shape,
2760    /// The rail along the profile edge's end, from `corners.1` to `corners.3`.
2761    rail1: Shape,
2762}
2763
2764/// Skin an open grid of samples (stations by profile-edge samples) into
2765/// one strip. `corners` are the caller's vertices at (first station, edge
2766/// start), (first, end), (last, start), (last, end), shared with the
2767/// neighbouring strips so the wires chain. `shared` are borders a
2768/// neighbouring strip already built (bottom, top, start rail, end rail),
2769/// adopted as they are (see `adopt_border`).
2770#[allow(clippy::too_many_arguments, reason = "one strip, spelled out")]
2771fn skinned_strip(
2772    model: &mut Model,
2773    skin: &Skin<'_>,
2774    corners: (&Shape, &Shape, &Shape, &Shape),
2775    shared: [Option<&Shape>; 4],
2776    outward_hint: Point,
2777    hole: bool,
2778    tolerance: f64,
2779    tol: Tolerances,
2780) -> OgeomResult<SkinnedStrip> {
2781    use ogeom_geom::Surface as _;
2782    let rows = &skin.rows;
2783    // A strip whose every row lies in one plane (a straight profile edge
2784    // down a straight run, a flat face of the profile along a planar
2785    // spine) is that plane, exactly: a coplanar neighbour then melts with
2786    // it on the one surface two fits of it would never agree on.
2787    if let Some(plane) = plane_of_rows(rows, tol) {
2788        return planar_strip(
2789            model,
2790            skin,
2791            plane,
2792            corners,
2793            shared,
2794            outward_hint,
2795            hole,
2796            tolerance,
2797            tol,
2798        );
2799    }
2800    // Sections a caller placed follow their own spacing across the skin;
2801    // stations a sweep placed keep the fit's centripetal assignment, or
2802    // their index where the sweep is known between them.
2803    let fitted = skin.fit(false, tolerance, tol)?;
2804    if !fitted.met {
2805        ogeom_bail!(
2806            NotDone,
2807            "the strip reached {} against a target of {tolerance}",
2808            fitted.error
2809        );
2810    }
2811    let error = fitted.error.max(tol.confusion());
2812    let surface = fitted.curve;
2813    let (u_knots, v_knots) = (surface.u_knots().clone(), surface.v_knots().clone());
2814    let (k, l, net) = {
2815        let grid = surface.grid();
2816        let net: Vec<Point> = grid.points().iter().map(|w| (*w).point()).collect();
2817        (grid.u_count(), grid.v_count(), net)
2818    };
2819    let point_at = |i: usize, j: usize| -> Point { net[i * l + j] };
2820    let (u_dom, v_dom) = surface.domain();
2821
2822    let u_curve = |j: usize| -> OgeomResult<ogeom_geom::Curve> {
2823        let control: Vec<Point> = (0..k).map(|i| point_at(i, j)).collect();
2824        Ok(ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
2825            u_knots.clone(),
2826            control,
2827            tol,
2828        )?))
2829    };
2830    let v_curve = |i: usize| -> OgeomResult<ogeom_geom::Curve> {
2831        let control: Vec<Point> = (0..l).map(|j| point_at(i, j)).collect();
2832        Ok(ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
2833            v_knots.clone(),
2834            control,
2835            tol,
2836        )?))
2837    };
2838    let surface_geo: SurfaceGeometry = surface.into();
2839    let surface_id = model.geometry_mut().add_surface(surface_geo.clone());
2840
2841    let (c00, c10, c01, c11) = corners;
2842    // A border the neighbouring strip already built is adopted, not
2843    // refitted (see `adopt_border`).
2844    let border = |model: &mut Model,
2845                  given: Option<&Shape>,
2846                  curve: ogeom_geom::Curve,
2847                  range: (f64, f64),
2848                  from: &Shape,
2849                  to: &Shape|
2850     -> OgeomResult<Shape> {
2851        match given {
2852            Some(edge) => {
2853                adopt_border(model, edge, &surface_geo, error + tol.confusion(), tol)?;
2854                Ok(edge.clone())
2855            }
2856            None => Ok(make_edge_between(model, curve, range, from, to, tol)?.shape),
2857        }
2858    };
2859    let bottom = border(model, shared[0], u_curve(0)?, u_dom, c00, c10)?;
2860    let top = border(model, shared[1], u_curve(l - 1)?, u_dom, c01, c11)?;
2861    let rail0 = border(model, shared[2], v_curve(0)?, v_dom, c00, c01)?;
2862    let rail1 = border(model, shared[3], v_curve(k - 1)?, v_dom, c10, c11)?;
2863
2864    let row_line = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
2865        Ok(Line2d::over(
2866            ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
2867            u_dom.0 - 1.0,
2868            u_dom.1 + 1.0,
2869        )?
2870        .into())
2871    };
2872    let column_line = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
2873        Ok(Line2d::over(
2874            ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
2875            v_dom.0 - 1.0,
2876            v_dom.1 + 1.0,
2877        )?
2878        .into())
2879    };
2880    // A border this strip fitted runs along its own row or column, and its
2881    // image is that straight line. One adopted from the neighbour was fitted
2882    // at the neighbour's pace along the sweep, which is not this strip's:
2883    // its image here is read off this surface point by point.
2884    for (edge, given, straight, span) in [
2885        (&bottom, shared[0].is_some(), row_line(v_dom.0)?, u_dom),
2886        (&top, shared[1].is_some(), row_line(v_dom.1)?, u_dom),
2887        (&rail0, shared[2].is_some(), column_line(u_dom.0)?, v_dom),
2888        (&rail1, shared[3].is_some(), column_line(u_dom.1)?, v_dom),
2889    ] {
2890        let (image, range) = if given {
2891            let (image, range, off) = adopted_image(model, edge, &surface_geo, tol)?;
2892            // The image stands off the border by what it was measured at;
2893            // the edge and its ends carry that.
2894            if off > tol.confusion() {
2895                let held = ogeom_core::Tolerance::new(off)?;
2896                model.widen(edge, held)?;
2897                if let Some((a, b)) = ogeom_algo::edge_vertices(model, edge)? {
2898                    for v in [&a, &b] {
2899                        model.widen(v, held)?;
2900                    }
2901                }
2902            }
2903            (image, range)
2904        } else {
2905            (straight, span)
2906        };
2907        ogeom_algo::attach_pcurve(
2908            model,
2909            edge,
2910            image,
2911            surface_id,
2912            ogeom_topo::Location::identity(),
2913            range,
2914        )?;
2915    }
2916    // The rails carry the fit's honest budget: the neighbouring strip fitted
2917    // the same transported corners independently, and the weld between them
2918    // is only as tight as both fits.
2919    for edge in [&bottom, &top, &rail0, &rail1] {
2920        model.widen(edge, ogeom_core::Tolerance::new(error)?)?;
2921    }
2922
2923    let wire = ogeom_algo::make_wire(
2924        model,
2925        &[
2926            bottom.clone(),
2927            rail1.clone(),
2928            top.reversed(),
2929            rail0.reversed(),
2930        ],
2931        tol,
2932    )?
2933    .shape;
2934    let face = ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
2935    let mid_u = f64::midpoint(u_dom.0, u_dom.1);
2936    let mid_v = f64::midpoint(v_dom.0, v_dom.1);
2937    let s_mid = surface_geo.point_at(mid_u, mid_v, tol)?;
2938    let (du, dv) = surface_geo.d1_at(mid_u, mid_v, tol)?;
2939    let natural_out = du.cross(dv).dot(s_mid - outward_hint) >= 0.0;
2940    let face = if natural_out == !hole {
2941        face
2942    } else {
2943        face.reversed()
2944    };
2945    Ok(SkinnedStrip {
2946        face,
2947        bottom,
2948        top,
2949        rail0,
2950        rail1,
2951    })
2952}
2953
2954/// The plane every point of the rows lies in, if there is one.
2955fn plane_of_rows(rows: &[Vec<Point>], tol: Tolerances) -> Option<Plane> {
2956    let first = rows.first()?;
2957    let last = rows.last()?;
2958    let origin = *first.first()?;
2959    let across = *first.last()? - origin;
2960    let along = *last.first()? - origin;
2961    let normal = across.cross(along);
2962    if normal.magnitude() <= tol.confusion() * across.magnitude().max(along.magnitude()) {
2963        return None;
2964    }
2965    let normal = Direction::new(normal, tol).ok()?;
2966    let plane = Plane::through(origin, normal);
2967    rows.iter()
2968        .flatten()
2969        .all(|p| plane.distance_to(*p) <= tol.confusion())
2970        .then_some(plane)
2971}
2972
2973/// A border of a strip: the row or the column of the skin at an index.
2974#[derive(Clone, Copy)]
2975enum Side {
2976    Row(usize),
2977    Column(usize),
2978}
2979
2980/// A strip on its own exact plane: the borders fitted through the rows and
2981/// the end columns, the face on the plane.
2982#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
2983fn planar_strip(
2984    model: &mut Model,
2985    skin: &Skin<'_>,
2986    plane: Plane,
2987    corners: (&Shape, &Shape, &Shape, &Shape),
2988    shared: [Option<&Shape>; 4],
2989    outward_hint: Point,
2990    hole: bool,
2991    tolerance: f64,
2992    tol: Tolerances,
2993) -> OgeomResult<SkinnedStrip> {
2994    let rows = &skin.rows;
2995    // Splines, as every swept border is: the caps and the neighbouring
2996    // strips read them so, and a spline through collinear points is the
2997    // straight segment itself. A row or a column whose geometry the skin
2998    // knows is fitted to it, measured between its samples too.
2999    let through = |points: &[Point], side: Side| -> OgeomResult<ogeom_geom::Curve> {
3000        let fitted = match (&skin.traced, side) {
3001            (Some(traced), Side::Row(j)) => {
3002                let v = traced.sampling.vs[j];
3003                ogeom_geom::fit::fit_curve_sampled(
3004                    |u| (traced.point)(u, v),
3005                    &traced.sampling.us,
3006                    false,
3007                    3,
3008                    tolerance * 0.5,
3009                    tol,
3010                )?
3011            }
3012            (Some(traced), Side::Column(i)) if traced.sampling.between.1 => {
3013                let u = traced.sampling.us[i];
3014                ogeom_geom::fit::fit_curve_sampled(
3015                    |v| (traced.point)(u, v),
3016                    &traced.sampling.vs,
3017                    false,
3018                    3,
3019                    tolerance * 0.5,
3020                    tol,
3021                )?
3022            }
3023            _ => ogeom_geom::fit::fit_points(points, 3, tolerance * 0.5, tol)?,
3024        };
3025        if !fitted.met {
3026            ogeom_bail!(
3027                NotDone,
3028                "a planar strip's border reached {} against a target of {tolerance}",
3029                fitted.error
3030            );
3031        }
3032        Ok(ogeom_geom::Curve::BSpline(fitted.curve))
3033    };
3034    let column = |i: usize| -> Vec<Point> { rows.iter().map(|row| row[i]).collect() };
3035    let last = rows[0].len() - 1;
3036    let (c00, c10, c01, c11) = corners;
3037    let border = |model: &mut Model,
3038                  given: Option<&Shape>,
3039                  points: Vec<Point>,
3040                  side: Side,
3041                  from: &Shape,
3042                  to: &Shape|
3043     -> OgeomResult<Shape> {
3044        if let Some(edge) = given {
3045            return Ok(edge.clone());
3046        }
3047        let curve = through(&points, side)?;
3048        let domain = curve.domain();
3049        Ok(make_edge_between(model, curve, domain, from, to, tol)?.shape)
3050    };
3051    let bottom = border(model, shared[0], rows[0].clone(), Side::Row(0), c00, c10)?;
3052    let top_row = rows.len() - 1;
3053    let top = border(
3054        model,
3055        shared[1],
3056        rows[top_row].clone(),
3057        Side::Row(top_row),
3058        c01,
3059        c11,
3060    )?;
3061    let rail0 = border(model, shared[2], column(0), Side::Column(0), c00, c01)?;
3062    let rail1 = border(model, shared[3], column(last), Side::Column(last), c10, c11)?;
3063
3064    // The loop runs across, up, back and down: counter-clockwise about the
3065    // normal it turns about, read from the whole border sampled in that
3066    // order. The first row's chord and the first column's are no guide: on
3067    // a strip starting from a corner's join row they can lie along one line.
3068    let border: Vec<Point> = rows[0]
3069        .iter()
3070        .copied()
3071        .chain(rows.iter().skip(1).map(|row| row[last]))
3072        .chain(rows[top_row].iter().rev().skip(1).copied())
3073        .chain(
3074            rows.iter()
3075                .rev()
3076                .skip(1)
3077                .take(top_row.saturating_sub(1))
3078                .map(|row| row[0]),
3079        )
3080        .collect();
3081    let turn = (0..border.len())
3082        .map(|i| (border[i] - border[0]).cross(border[(i + 1) % border.len()] - border[0]))
3083        .fold(Vector::new(0.0, 0.0, 0.0), |sum, v| sum + v);
3084    let normal = if turn.dot(plane.normal().vector()) >= 0.0 {
3085        plane.normal()
3086    } else {
3087        plane.normal().reversed()
3088    };
3089    let wound = Plane::through(plane.origin(), normal);
3090    let reach = rows
3091        .iter()
3092        .flatten()
3093        .map(|p| p.distance(plane.origin()))
3094        .fold(1.0_f64, f64::max)
3095        * 2.0;
3096    let surface: SurfaceGeometry =
3097        PlaneSurface::over(wound, (-reach, reach), (-reach, reach))?.into();
3098    let face = ogeom_algo::make_face_with_pcurves(
3099        model,
3100        surface.clone(),
3101        &[vec![
3102            bottom.clone(),
3103            rail1.clone(),
3104            top.reversed(),
3105            rail0.reversed(),
3106        ]],
3107        tol,
3108    )?
3109    .shape;
3110    let mid = rows[rows.len() / 2][last / 2];
3111    let natural_out = normal.vector().dot(mid - outward_hint) >= 0.0;
3112    let face = if natural_out == !hole {
3113        face
3114    } else {
3115        face.reversed()
3116    };
3117    Ok(SkinnedStrip {
3118        face,
3119        bottom,
3120        top,
3121        rail0,
3122        rail1,
3123    })
3124}
3125
3126/// A loft through circles standing coaxial on parallel planes: the solid of
3127/// revolution of the meridian through their radii, a spline through them
3128/// in the half-plane of the first circle's start. `None` where the
3129/// sections are anything else.
3130fn coaxial_circles_loft(
3131    model: &mut Model,
3132    sections: &[Shape],
3133    tol: Tolerances,
3134) -> OgeomResult<Option<Built>> {
3135    let mut circles = Vec::with_capacity(sections.len());
3136    for wire in sections {
3137        if model.kind_of(wire)? != ShapeType::Wire {
3138            return Ok(None);
3139        }
3140        let edges = model.ordered_children_of(wire)?;
3141        let [edge] = edges.as_slice() else {
3142            return Ok(None);
3143        };
3144        let (curve, _) = spine_curve_of(model, edge)?;
3145        let ogeom_geom::Curve::Circle(c) = curve else {
3146            return Ok(None);
3147        };
3148        let placed = c
3149            .circle()
3150            .transformed(&edge.transform(model.datums())?, tol)?;
3151        circles.push(placed);
3152    }
3153    let first = circles[0].frame();
3154    let (c0, z0) = (first.origin(), first.z().vector());
3155    let last = circles[circles.len() - 1].centre();
3156    let rise = last - c0;
3157    if rise.magnitude() <= tol.confusion() {
3158        return Ok(None);
3159    }
3160    let z = rise / rise.magnitude();
3161    if z.cross(z0).magnitude() > tol.angular() {
3162        return Ok(None);
3163    }
3164    let mut heights = Vec::with_capacity(circles.len());
3165    for c in &circles {
3166        let off = c.centre() - c0;
3167        if off.cross(z).magnitude() > tol.confusion() * 10.0
3168            || c.frame().z().vector().cross(z).magnitude() > tol.angular()
3169        {
3170            return Ok(None);
3171        }
3172        heights.push(off.dot(z));
3173    }
3174    if heights.windows(2).any(|w| w[1] <= w[0] + tol.confusion()) {
3175        return Ok(None);
3176    }
3177    let x = first.x().vector();
3178    let meridian: Vec<Point> = circles
3179        .iter()
3180        .zip(&heights)
3181        .map(|(c, h)| c0 + z * *h + x * c.radius())
3182        .collect();
3183    let degree = (meridian.len() - 1).min(3);
3184    let fitted = ogeom_geom::fit::fit_points(&meridian, degree, tol.confusion() * 1e-3, tol)?;
3185    let spline: ogeom_geom::Curve = fitted.curve.into();
3186    let domain = spline.domain();
3187    let top = c0 + z * heights[heights.len() - 1];
3188    let vertex = |model: &mut Model, p: Point| ogeom_algo::make_vertex(model, p).shape;
3189    let (v_axis0, v_axis1) = (vertex(model, c0), vertex(model, top));
3190    let (v_rim0, v_rim1) = (
3191        vertex(model, meridian[0]),
3192        vertex(model, meridian[meridian.len() - 1]),
3193    );
3194    let segment =
3195        |model: &mut Model, a: (&Shape, Point), b: (&Shape, Point)| -> OgeomResult<Shape> {
3196            let line: ogeom_geom::Curve = LineCurve::segment(a.1, b.1, tol)?.into();
3197            let range = line.domain();
3198            Ok(make_edge_between(model, line, range, a.0, b.0, tol)?.shape)
3199        };
3200    let bottom = segment(model, (&v_axis0, c0), (&v_rim0, meridian[0]))?;
3201    let side = make_edge_between(model, spline, domain, &v_rim0, &v_rim1, tol)?.shape;
3202    let top_edge = segment(
3203        model,
3204        (&v_rim1, meridian[meridian.len() - 1]),
3205        (&v_axis1, top),
3206    )?;
3207    let axis_edge = segment(model, (&v_axis1, top), (&v_axis0, c0))?;
3208    let wire = ogeom_algo::make_wire(model, &[bottom, side, top_edge, axis_edge], tol)?.shape;
3209    // Framed from a point inside the profile: a plane's own origin is
3210    // where its face is read when it carries no trims.
3211    let inside = c0
3212        + z * (heights[heights.len() - 1] * 0.5)
3213        + x * (circles
3214            .iter()
3215            .map(|c| c.radius())
3216            .fold(f64::INFINITY, f64::min)
3217            * 0.5);
3218    let plane = Plane::new(Frame::new(
3219        inside,
3220        Direction::new(z.cross(x), tol)?,
3221        Direction::new(x, tol)?,
3222        tol,
3223    )?);
3224    let face = ogeom_algo::make_face(model, PlaneSurface::new(plane).into(), &[wire], tol)?.shape;
3225    let axis = ogeom_math::Axis {
3226        location: c0,
3227        direction: Direction::new(z, tol)?,
3228    };
3229    let built = ogeom_algo::make_revolution(model, &face, axis, core::f64::consts::TAU, tol)?;
3230    Ok(Some(built))
3231}
3232
3233/// A loft through sections of one edge count, every vertex a corner: one
3234/// strip per edge through all the sections, meeting its neighbours along
3235/// seams through the matched corners, each strip a plane wherever its rows
3236/// share one. `None` where the sections do not pair edge for edge. Where
3237/// `along` moves the first section onto the others, the strips are held to
3238/// that motion between the sections too.
3239fn cornered_loft(
3240    model: &mut Model,
3241    sections: &[Shape],
3242    along: Option<SectionMotion<'_>>,
3243    tolerance: f64,
3244    tol: Tolerances,
3245) -> OgeomResult<Option<Built>> {
3246    let mut rings: Vec<Vec<Shape>> = Vec::with_capacity(sections.len());
3247    for wire in sections {
3248        if model.kind_of(wire)? != ShapeType::Wire || !ogeom_algo::is_wire_closed(model, wire, tol)?
3249        {
3250            return Ok(None);
3251        }
3252        rings.push(model.ordered_children_of(wire)?);
3253    }
3254    let count = rings[0].len();
3255    if count < 2 || rings.iter().any(|r| r.len() != count) {
3256        return Ok(None);
3257    }
3258    let (Some(plane0), Some(plane1)) = (
3259        ogeom_algo::find_plane(model, &sections[0], tol)?,
3260        ogeom_algo::find_plane(model, &sections[sections.len() - 1], tol)?,
3261    ) else {
3262        return Ok(None);
3263    };
3264    const ALONG: usize = 16;
3265    // Every edge of every section, read in its ring's sense at a fraction
3266    // of its parameter range.
3267    let mut edge_curves: Vec<Vec<(ogeom_geom::Curve, (f64, f64))>> =
3268        Vec::with_capacity(rings.len());
3269    for ring in &rings {
3270        let mut per_edge = Vec::with_capacity(count);
3271        for edge in ring {
3272            let (curve, range) = spine_curve_of(model, edge)?;
3273            let curve = curve.transformed(&edge.transform(model.datums())?, tol)?;
3274            let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
3275            per_edge.push((curve, if reversed { (range.1, range.0) } else { range }));
3276        }
3277        edge_curves.push(per_edge);
3278    }
3279    let edge_at = |s: usize, e: usize, f: f64| -> OgeomResult<Point> {
3280        let (curve, (a, b)) = &edge_curves[s][e];
3281        curve.point_at(a + (b - a) * f, tol)
3282    };
3283    let samples: Vec<Vec<Vec<Point>>> = (0..rings.len())
3284        .map(|s| {
3285            (0..count)
3286                .map(|e| fractions(ALONG).iter().map(|f| edge_at(s, e, *f)).collect())
3287                .collect()
3288        })
3289        .collect::<OgeomResult<_>>()?;
3290    let corners = |model: &mut Model, s: usize| -> Vec<Shape> {
3291        (0..count)
3292            .map(|e| ogeom_algo::make_vertex(model, samples[s][e][0]).shape)
3293            .collect()
3294    };
3295    let (from, to) = (corners(model, 0), corners(model, sections.len() - 1));
3296    let middle = &samples[sections.len() / 2];
3297    let hint = {
3298        let all: Vec<Point> = middle.iter().flatten().copied().collect();
3299        #[allow(clippy::cast_precision_loss)]
3300        let n = all.len() as f64;
3301        Point::from_vector(
3302            all.iter()
3303                .fold(Vector::new(0.0, 0.0, 0.0), |acc, p| acc + p.to_vector())
3304                / n,
3305        )
3306    };
3307    let mut faces = Vec::with_capacity(count + 2);
3308    let (mut bottoms, mut tops) = (Vec::with_capacity(count), Vec::with_capacity(count));
3309    let mut first_rail: Option<Shape> = None;
3310    let mut prev_rail: Option<Shape> = None;
3311    for e in 0..count {
3312        let skin = match along {
3313            Some(motion) => Skin::swept(
3314                move |f, s| {
3315                    if s.fract() == 0.0 {
3316                        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3317                        let i = s as usize;
3318                        return edge_at(i, e, f);
3319                    }
3320                    Ok(motion(s)?.apply(edge_at(0, e, f)?))
3321                },
3322                fractions(ALONG),
3323                (0, rings.len() - 1),
3324                false,
3325            )?,
3326            None => Skin::columns(
3327                |f| (0..rings.len()).map(|s| edge_at(s, e, f)).collect(),
3328                fractions(ALONG),
3329                false,
3330                true,
3331                false,
3332            )?,
3333        };
3334        let next = (e + 1) % count;
3335        let last_rail = if e + 1 == count {
3336            first_rail.clone()
3337        } else {
3338            None
3339        };
3340        let strip = skinned_strip(
3341            model,
3342            &skin,
3343            (&from[e], &from[next], &to[e], &to[next]),
3344            [None, None, prev_rail.as_ref(), last_rail.as_ref()],
3345            hint,
3346            false,
3347            tolerance,
3348            tol,
3349        )?;
3350        if e == 0 {
3351            first_rail = Some(strip.rail0.clone());
3352        }
3353        prev_rail = Some(strip.rail1.clone());
3354        faces.push(strip.face.clone());
3355        bottoms.push(strip.bottom);
3356        tops.push(strip.top);
3357    }
3358    let towards = hint - samples[0][0][0];
3359    let n0 = plane0.normal().vector();
3360    let n0 = if n0.dot(towards) > 0.0 { -n0 } else { n0 };
3361    let away = hint - samples[sections.len() - 1][0][0];
3362    let n1 = plane1.normal().vector();
3363    let n1 = if n1.dot(away) > 0.0 { -n1 } else { n1 };
3364    faces.push(plane_cap(model, samples[0][0][0], n0, &[bottoms], tol)?);
3365    faces.push(plane_cap(
3366        model,
3367        samples[sections.len() - 1][0][0],
3368        n1,
3369        &[tops],
3370        tol,
3371    )?);
3372    let sewn = sew(model, &faces, tol)?;
3373    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
3374        ogeom_bail!(Construction, "the cornered loft did not close");
3375    }
3376    Ok(Some(make_solid(model, &sewn.shells)?))
3377}
3378
3379/// A planar cap through `at`, facing `outward`, bounded by loops of spline
3380/// or line edges lying in it; each edge's trim is its exact projection into
3381/// the plane's chart.
3382fn plane_cap(
3383    model: &mut Model,
3384    at: Point,
3385    outward: Vector,
3386    loops: &[Vec<Shape>],
3387    tol: Tolerances,
3388) -> OgeomResult<Shape> {
3389    let cap_plane = Plane::through(at, Direction::new(outward, tol)?);
3390    let mut reach = 1.0_f64;
3391    for edges in loops {
3392        for edge in edges {
3393            let (curve, range) = spine_curve_of(model, edge)?;
3394            for k in 0..8 {
3395                let p = curve.point_at(range.0 + (range.1 - range.0) * f64::from(k) / 8.0, tol)?;
3396                reach = reach.max(p.distance(at) * 2.0);
3397            }
3398        }
3399    }
3400    let surface: SurfaceGeometry =
3401        PlaneSurface::over(cap_plane, (-reach, reach), (-reach, reach))?.into();
3402    // Each loop keeps the material on its left about `outward`: the one
3403    // enclosing the most turns positively, any other negatively, and a loop
3404    // walked against that is walked back.
3405    let turns: Vec<f64> = loops
3406        .iter()
3407        .map(|edges| ring_turning(model, edges, outward, tol))
3408        .collect::<OgeomResult<_>>()?;
3409    let outer = turns
3410        .iter()
3411        .enumerate()
3412        .max_by(|a, b| a.1.abs().total_cmp(&b.1.abs()))
3413        .map_or(0, |(i, _)| i);
3414    let mut wires = Vec::with_capacity(loops.len());
3415    for (i, (edges, turn)) in loops.iter().zip(&turns).enumerate() {
3416        let ring = if (*turn > 0.0) == (i == outer) {
3417            edges.clone()
3418        } else {
3419            walked_back(edges)
3420        };
3421        wires.push(ogeom_algo::make_wire(model, &ring, tol)?.shape);
3422    }
3423    let face = ogeom_algo::make_face(model, surface, &wires, tol)?.shape;
3424    let cap_id = {
3425        let Some(ogeom_topo::NodeData::Face(data)) = model.node(&face).map(|n| n.data()) else {
3426            ogeom_bail!(Construction, "the cap holds no face data");
3427        };
3428        data.surface
3429    };
3430    let frame = cap_plane.frame();
3431    let flat = |p: Point| {
3432        let local = frame.to_local(p);
3433        Point2::new(local.x, local.y)
3434    };
3435    for edges in loops {
3436        for edge in edges {
3437            let (curve, range) = spine_curve_of(model, edge)?;
3438            let pcurve: ogeom_geom::PlanarCurve = match &curve {
3439                ogeom_geom::Curve::BSpline(bs) => {
3440                    let control2: Vec<Point2> = bs
3441                        .control_points()
3442                        .iter()
3443                        .map(|w| flat(w.point()))
3444                        .collect();
3445                    ogeom_geom::BSpline2d::new(bs.knots().clone(), control2, tol)?.into()
3446                }
3447                ogeom_geom::Curve::Line(line) => {
3448                    let axis = line.axis();
3449                    let origin = flat(axis.location);
3450                    let ahead = flat(axis.location + axis.direction.vector());
3451                    ogeom_geom::Line2d::over(
3452                        ogeom_math::Axis2::through(origin, ahead, tol)?,
3453                        range.0,
3454                        range.1,
3455                    )?
3456                    .into()
3457                }
3458                _ => ogeom_bail!(Construction, "a cap edge is neither a spline nor a line"),
3459            };
3460            ogeom_algo::attach_pcurve(
3461                model,
3462                edge,
3463                pcurve,
3464                cap_id,
3465                ogeom_topo::Location::identity(),
3466                range,
3467            )?;
3468        }
3469    }
3470    Ok(face)
3471}
3472
3473/// Loft a solid through many closed planar sections, skinned smoothly.
3474///
3475/// The sections are sampled at matched arc-length fractions from their own
3476/// traversal starts (aligning those starts is the caller's authorship),
3477/// and the skin holds every section to `tolerance`, between the samples as
3478/// well as at them: the sampling round a section is refined where the skin
3479/// misses it. The caps are the first and last sections' own planes.
3480///
3481/// A trailing vertex closes the solid on that point instead of a last cap.
3482/// The skin runs smoothly on to it wherever that wall stays unfolded
3483/// between the last section and the point; where it would fold through
3484/// itself (sections turned against each other bend its rows into a figure
3485/// eight there), the last section reaches the point by the straight cone
3486/// over it instead, with an edge where the two meet.
3487///
3488/// # Errors
3489///
3490/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if fewer than
3491/// two sections, a section is open or not planar;
3492/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if
3493/// the skin cannot reach the tolerance.
3494pub fn make_loft_skinned(
3495    model: &mut Model,
3496    sections: &[Shape],
3497    tolerance: f64,
3498    tol: Tolerances,
3499) -> OgeomResult<Built> {
3500    let built = loft_skinned_along(model, sections, None, false, tolerance, tol)?;
3501    state_new_gaps(model, sections, &built.shape, tol)?;
3502    Ok(built)
3503}
3504
3505/// The motion carrying a loft's first section to where the sections stand
3506/// at `s`, a section's index (a fraction of the way between two sections
3507/// between them): the sections of a sweep are one section moved.
3508type SectionMotion<'a> = &'a dyn Fn(f64) -> OgeomResult<Transform>;
3509
3510/// [`make_loft_skinned`], and where `along` is given the sections are the
3511/// first one moved by it and the skin is held to that motion between them
3512/// as well as to the sections. Where `exact_ends` asks it too, a loop of
3513/// one edge with an exact rational form is carried in that form, so the
3514/// skin's end rows are the end sections exactly (see [`Carried`]).
3515fn loft_skinned_along(
3516    model: &mut Model,
3517    sections: &[Shape],
3518    along: Option<SectionMotion<'_>>,
3519    exact_ends: bool,
3520    tolerance: f64,
3521    tol: Tolerances,
3522) -> OgeomResult<Built> {
3523    if sections.len() < 2 {
3524        ogeom_bail!(Construction, "a loft needs at least two sections");
3525    }
3526    let to_point = model.kind_of(&sections[sections.len() - 1])? == ShapeType::Vertex;
3527    // The smooth skin through two sections is the ruled one, and that is
3528    // built exactly: a drum or cone between circles, planes between
3529    // polygons.
3530    if sections.len() == 2
3531        && !to_point
3532        && along.is_none()
3533        && let Ok(built) = make_loft(model, &sections[0], &sections[1], tol)
3534    {
3535        return Ok(built);
3536    }
3537    if sections.len() > 2 && !to_point {
3538        if let Some(built) = coaxial_circles_loft(model, sections, tol)? {
3539            return Ok(built);
3540        }
3541        if let Some(built) = cornered_loft(model, sections, along, tolerance, tol)? {
3542            return Ok(built);
3543        }
3544    }
3545    // A trailing vertex is the apex form: the skin narrows to a point and
3546    // the solid closes there without a cap.
3547    let apex = match model.kind_of(&sections[sections.len() - 1])? {
3548        ShapeType::Vertex => {
3549            if sections.len() < 2 {
3550                ogeom_bail!(
3551                    Construction,
3552                    "a loft to a point needs a section to start from"
3553                );
3554            }
3555            let Some(data) = model
3556                .node(&sections[sections.len() - 1])
3557                .and_then(|n| n.data().as_vertex())
3558            else {
3559                ogeom_bail!(Construction, "the apex vertex holds no point");
3560            };
3561            Some(data.point)
3562        }
3563        _ => None,
3564    };
3565    let wires = &sections[..sections.len() - usize::from(apex.is_some())];
3566    let mut loops: Vec<Section> = Vec::with_capacity(sections.len() + 1);
3567    let mut cap_planes: Vec<Option<Plane>> = Vec::with_capacity(wires.len());
3568    for wire in wires {
3569        if model.kind_of(wire)? != ShapeType::Wire {
3570            ogeom_bail!(Construction, "a loft section is a wire");
3571        }
3572        if !ogeom_algo::is_wire_closed(model, wire, tol)? {
3573            ogeom_bail!(Construction, "a loft section must be closed");
3574        }
3575        // Planarity is a *cap's* requirement, not the fit's: only the
3576        // sections a cap will stand on must hold a plane. A wavy middle
3577        // section skins fine.
3578        cap_planes.push(ogeom_algo::find_plane(model, wire, tol)?);
3579        loops.push(Section::Loop(section_loop(model, wire, None, tol)?));
3580    }
3581    // Sections moved along a path are one section, wound alike already; a
3582    // loft to a point settles for itself what a section turned against the
3583    // ones before it makes.
3584    if along.is_none() && apex.is_none() {
3585        wound_alike(&mut loops);
3586    }
3587    // A planar end is capped by its plane; one that is not (a wavy rim),
3588    // by a patch skinned from the ring to a point inside it.
3589    let outward_at = |rows: &[Vec<Point>], planes: &[Option<Plane>], end: bool| -> EndCap {
3590        let (i, j) = if end {
3591            (rows.len() - 1, rows.len() - 2)
3592        } else {
3593            (0, 1)
3594        };
3595        let Some(plane) = &planes[i] else {
3596            return EndCap::Skinned;
3597        };
3598        let towards = rows[j][0] - rows[i][0];
3599        let n = plane.normal().vector();
3600        EndCap::Plane(if n.dot(towards) > 0.0 { -n } else { n })
3601    };
3602    let mut built = if let Some(apex) = apex {
3603        // The skin runs smoothly through the sections and on to the point,
3604        // where that holds: past a section turned against the ones before
3605        // it, its rows shrink through a figure eight and come out wound the
3606        // other way, the wall near the point folded through itself and
3607        // facing into the material there. Such a skin is not built; the
3608        // last section closes on the point by the straight cone over it
3609        // instead, which cannot fold.
3610        let sections_only = (loops.len() >= 2).then(|| loops.clone());
3611        if sections_only.is_none()
3612            && let Some(Section::Loop(ring)) = loops.first()
3613        {
3614            // One ring to a point is exact machinery's job when it can be;
3615            // the skin still needs two rows to shape the wall, so a middle
3616            // row is interpolated halfway toward the apex. That skin is
3617            // the cone already.
3618            let half = Section::Halfway(ring.clone(), apex);
3619            loops.push(half);
3620        }
3621        loops.push(Section::Point(apex));
3622        let skin = Skin::sections(loops, false)?;
3623        let smooth = match outward_at(&skin.rows, &cap_planes, false) {
3624            EndCap::Plane(outward0) => {
3625                let unfolded_from = match sections_only {
3626                    Some(_) => {
3627                        let vs = skin.row_parameters()?;
3628                        Some(vs[vs.len() - 2])
3629                    }
3630                    None => None,
3631                };
3632                skinned_solid_to_apex(model, &skin, outward0, unfolded_from, tolerance, tol)?
3633            }
3634            EndCap::Skinned | EndCap::Apex(_) => {
3635                ogeom_bail!(
3636                    Construction,
3637                    "a loft to a point starts from a planar section; a cap stands on it"
3638                );
3639            }
3640        };
3641        match (smooth, sections_only) {
3642            (Some(built), _) => built,
3643            (None, Some(sections)) => {
3644                let skin = Skin::sections(sections, false)?;
3645                let outward0 = outward_at(&skin.rows, &cap_planes, false);
3646                skinned_solid(model, &skin, (outward0, EndCap::Apex(apex)), tolerance, tol)?
3647            }
3648            (None, None) => {
3649                ogeom_bail!(Construction, "a cone over one section folded");
3650            }
3651        }
3652    } else {
3653        let skin = match along {
3654            Some(motion) => {
3655                let Some(Section::Loop(first)) = loops.first().cloned() else {
3656                    ogeom_bail!(Construction, "a loft's first section is a loop");
3657                };
3658                let carried = if exact_ends {
3659                    carried_section(model, sections, motion, tol)?
3660                } else {
3661                    None
3662                };
3663                let mut skin = Skin::swept(
3664                    move |f, s| {
3665                        if s.fract() == 0.0 {
3666                            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3667                            let i = s as usize;
3668                            return Ok(loops[i].at(f));
3669                        }
3670                        Ok(motion(s)?.apply(first.at(f)))
3671                    },
3672                    fractions(AROUND_SECTION),
3673                    (0, sections.len() - 1),
3674                    true,
3675                )?;
3676                skin.carried = carried;
3677                skin
3678            }
3679            None => Skin::sections(loops, false)?,
3680        };
3681        let outward0 = outward_at(&skin.rows, &cap_planes, false);
3682        let outward1 = outward_at(&skin.rows, &cap_planes, true);
3683        skinned_solid(model, &skin, (outward0, outward1), tolerance, tol)?
3684    };
3685    for section in sections {
3686        built.history.generate(section, built.shape.clone());
3687    }
3688    Ok(built)
3689}
3690
3691/// A sweep's sections carried in exact rational form, where each is one
3692/// closed edge with such a form and all share its knots and weights: the
3693/// skin's rows are then those forms (see [`Carried`]). At a section the
3694/// control points are that section's own, between two the first's moved by
3695/// `motion`. `None` where a section has no such form or the forms differ
3696/// in anything but their control points.
3697fn carried_section<'a>(
3698    model: &Model,
3699    sections: &[Shape],
3700    motion: SectionMotion<'a>,
3701    tol: Tolerances,
3702) -> OgeomResult<Option<Carried<'a>>> {
3703    let mut forms: Vec<ogeom_geom::BSplineCurve> = Vec::with_capacity(sections.len());
3704    for wire in sections {
3705        if model.kind_of(wire)? != ShapeType::Wire {
3706            return Ok(None);
3707        }
3708        let edges = model.ordered_children_of(wire)?;
3709        let [edge] = edges.as_slice() else {
3710            return Ok(None);
3711        };
3712        let (curve, range) = spine_curve_of(model, edge)?;
3713        let curve = curve.transformed(&edge.transform(model.datums())?, tol)?;
3714        let Ok(form) = curve.to_bspline_over(range, tol) else {
3715            return Ok(None);
3716        };
3717        forms.push(form);
3718    }
3719    let first = &forms[0];
3720    let alike = forms.iter().all(|f| {
3721        f.knots().knots().len() == first.knots().knots().len()
3722            && f.knots()
3723                .knots()
3724                .iter()
3725                .zip(first.knots().knots())
3726                .all(|(a, b)| a.to_bits() == b.to_bits())
3727            && f.control_points()
3728                .iter()
3729                .zip(first.control_points())
3730                .all(|(a, b)| (a.weight - b.weight).abs() <= 1e-12 * b.weight)
3731    });
3732    if !alike {
3733        return Ok(None);
3734    }
3735    let control: Vec<Vec<Point>> = forms
3736        .iter()
3737        .map(|f| f.control_points().iter().map(|w| w.point()).collect())
3738        .collect();
3739    let n = control[0].len();
3740    // A closed section's last control point is its first: one path for
3741    // both, so the skin's seam closes exactly.
3742    let weights = first.control_points();
3743    let closed = (weights[0].weight - weights[n - 1].weight).abs() <= 1e-12 * weights[0].weight
3744        && control
3745            .iter()
3746            .all(|c| c[0].distance(c[n - 1]) <= tol.confusion());
3747    let mut curve = forms[0].clone();
3748    if closed {
3749        let mut points = curve.control_points().to_vec();
3750        points[n - 1] = points[0];
3751        curve = ogeom_geom::BSplineCurve::rational(curve.knots().clone(), points)?;
3752    }
3753    let steps = sections.len() - 1;
3754    #[allow(clippy::cast_precision_loss, reason = "section counts")]
3755    let vs: Vec<f64> = (0..=steps).map(|i| i as f64 / steps as f64).collect();
3756    let point = move |i: usize, v: f64| -> OgeomResult<Point> {
3757        let i = if closed && i + 1 == n { 0 } else { i };
3758        #[allow(clippy::cast_precision_loss, reason = "section counts")]
3759        let s = v * steps as f64;
3760        let whole = s.round();
3761        if (s - whole).abs() <= 1e-9 {
3762            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3763            return Ok(control[whole as usize][i]);
3764        }
3765        Ok(motion(s)?.apply(control[0][i]))
3766    };
3767    Ok(Some(Carried {
3768        curve,
3769        control: Box::new(point),
3770        vs,
3771        closed,
3772    }))
3773}
3774
3775/// The closed wire `ring` as one edge, where its edges are pieces of one
3776/// curve, placed and walked alike, running end to end over a span of it
3777/// that closes: that span, wound as the ring is. `None` where the ring is
3778/// one edge already or is not so.
3779pub(crate) fn whole_ring(
3780    model: &mut Model,
3781    ring: &Shape,
3782    tol: Tolerances,
3783) -> OgeomResult<Option<Shape>> {
3784    let edges = model.ordered_children_of(ring)?;
3785    let [first, ..] = edges.as_slice() else {
3786        return Ok(None);
3787    };
3788    if edges.len() < 2 {
3789        return Ok(None);
3790    }
3791    let place = first.transform(model.datums())?;
3792    let (curve, _) = spine_curve_of(model, first)?;
3793    let mut ranges = Vec::with_capacity(edges.len());
3794    for edge in &edges {
3795        let (other, span) = spine_curve_of(model, edge)?;
3796        if other != curve
3797            || edge.transform(model.datums())? != place
3798            || edge.orientation() != first.orientation()
3799        {
3800            return Ok(None);
3801        }
3802        ranges.push((span.0.min(span.1), span.0.max(span.1)));
3803    }
3804    ranges.sort_by(|a, b| a.0.total_cmp(&b.0));
3805    if ranges
3806        .windows(2)
3807        .any(|w| (w[1].0 - w[0].1).abs() > tol.parametric())
3808    {
3809        return Ok(None);
3810    }
3811    let span = (ranges[0].0, ranges[ranges.len() - 1].1);
3812    if curve
3813        .point_at(span.0, tol)?
3814        .distance(curve.point_at(span.1, tol)?)
3815        > tol.confusion()
3816    {
3817        return Ok(None);
3818    }
3819    let edge = make_edge(model, curve.transformed(&place, tol)?, span, tol)?.shape;
3820    let edge = if first.orientation() == ogeom_topo::Orientation::Reversed {
3821        edge.reversed()
3822    } else {
3823        edge
3824    };
3825    let wire = ogeom_algo::make_wire(model, &[edge], tol)?.shape;
3826    Ok(Some(
3827        if ring.orientation() == ogeom_topo::Orientation::Reversed {
3828            wire.reversed()
3829        } else {
3830            wire
3831        },
3832    ))
3833}
3834
3835/// Sample a closed wire at `count` matched arc-length fractions.
3836fn sample_wire(
3837    model: &Model,
3838    wire: &Shape,
3839    count: usize,
3840    tol: Tolerances,
3841) -> OgeomResult<Vec<Point>> {
3842    sample_wire_from(model, wire, count, None, tol)
3843}
3844
3845/// As [`sample_wire`], with the arc-length origin rotated to the dense
3846/// sample nearest `start_hint`: how a caller says which point of each
3847/// section rows up with which, instead of leaning on traversal starts.
3848fn sample_wire_from(
3849    model: &Model,
3850    wire: &Shape,
3851    count: usize,
3852    start_hint: Option<Point>,
3853    tol: Tolerances,
3854) -> OgeomResult<Vec<Point>> {
3855    let arc = section_loop(model, wire, start_hint, tol)?;
3856    Ok((0..count)
3857        .map(|s| {
3858            #[allow(clippy::cast_precision_loss, reason = "a small count")]
3859            let f = s as f64 / count as f64;
3860            arc.at(f)
3861        })
3862        .collect())
3863}
3864
3865/// A closed wire read by arc length from its traversal start, or from the
3866/// dense sample nearest `start_hint` where one is given.
3867fn section_loop(
3868    model: &Model,
3869    wire: &Shape,
3870    start_hint: Option<Point>,
3871    tol: Tolerances,
3872) -> OgeomResult<ArcLoop> {
3873    let mut dense = dense_wire(model, wire, tol)?;
3874    if let Some(hint) = start_hint {
3875        let mut best = 0usize;
3876        let mut held = f64::INFINITY;
3877        for (i, p) in dense.iter().enumerate() {
3878            let d = p.distance(hint);
3879            if d < held {
3880                held = d;
3881                best = i;
3882            }
3883        }
3884        dense.rotate_left(best);
3885    }
3886    Ok(ArcLoop::new(dense))
3887}
3888
3889/// A closed wire as a dense polyline, by traversal, without the closing
3890/// point.
3891fn dense_wire(model: &Model, wire: &Shape, tol: Tolerances) -> OgeomResult<Vec<Point>> {
3892    let mut dense: Vec<Point> = Vec::new();
3893    for edge in explore(model, wire, Filter::OfType(ShapeType::Edge))? {
3894        let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
3895            ogeom_bail!(Construction, "a section edge holds no data");
3896        };
3897        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
3898            ogeom_bail!(Construction, "a section edge has no curve");
3899        };
3900        let Some(geometry) = model.geometry().curve(*curve) else {
3901            ogeom_bail!(Dangling, "curve is not in this model");
3902        };
3903        let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
3904        // Where the edge stands: a placed wire's edges carry its placement.
3905        let placement = edge.transform(model.datums())?;
3906        let at = |f: f64| -> OgeomResult<Point> {
3907            let t = if reversed {
3908                range.1 - (range.1 - range.0) * f
3909            } else {
3910                range.0 + (range.1 - range.0) * f
3911            };
3912            Ok(placement.apply(geometry.point_at(t, tol)?))
3913        };
3914        // Samples are read along the chords between these points, so the
3915        // chords keep within a micron of the curve: each of `FIRST` even
3916        // pieces is halved until the curve at its quarter points stands
3917        // within that of its chord. One sag measured over the first pass
3918        // and spread evenly will not do: round a tight corner the sag
3919        // falls with the spacing, not its square, and only the pieces
3920        // there need more.
3921        const FIRST: u32 = 64;
3922        let allowed = tol.confusion() * 10.0;
3923        for i in 0..FIRST {
3924            let (f0, f1) = (
3925                f64::from(i) / f64::from(FIRST),
3926                f64::from(i + 1) / f64::from(FIRST),
3927            );
3928            chords_within(&at, (f0, at(f0)?), (f1, at(f1)?), allowed, 0, &mut dense)?;
3929        }
3930    }
3931    if dense.is_empty() {
3932        ogeom_bail!(Construction, "a section has no edges");
3933    }
3934    Ok(dense)
3935}
3936
3937/// The points of the curve `at` from `a` up to `b`, `b` itself left to the
3938/// next piece, whose chords stand within `allowed` of the curve at their
3939/// quarter points: a piece is halved where they do not, at most `DEEPEST`
3940/// times.
3941pub(super) fn chords_within(
3942    at: &impl Fn(f64) -> OgeomResult<Point>,
3943    a: (f64, Point),
3944    b: (f64, Point),
3945    allowed: f64,
3946    depth: u32,
3947    out: &mut Vec<Point>,
3948) -> OgeomResult<()> {
3949    // A 64th of an edge halved this often is a few hundred-millionths of
3950    // its parameter, past any corner a section is drawn with.
3951    const DEEPEST: u32 = 20;
3952    let chord = b.1 - a.1;
3953    let length = chord.dot(chord);
3954    let off = |p: Point| {
3955        let t = if length > 0.0 {
3956            ((p - a.1).dot(chord) / length).clamp(0.0, 1.0)
3957        } else {
3958            0.0
3959        };
3960        p.distance(a.1 + chord * t)
3961    };
3962    let mid = f64::midpoint(a.0, b.0);
3963    let m = at(mid)?;
3964    let sags = depth < DEEPEST
3965        && (off(m) > allowed
3966            || off(at(f64::midpoint(a.0, mid))?) > allowed
3967            || off(at(f64::midpoint(mid, b.0))?) > allowed);
3968    if sags {
3969        chords_within(at, a, (mid, m), allowed, depth + 1, out)?;
3970        chords_within(at, (mid, m), b, allowed, depth + 1, out)
3971    } else {
3972        out.push(a.1);
3973        Ok(())
3974    }
3975}
3976
3977/// A section's dense polyline in its own frame, started and run where its
3978/// `count` samples best match the section before (`previous`, in its
3979/// frame): a section's start and sense are accidents of how it was drawn,
3980/// and matched as given they twist the blend. Every start of the dense
3981/// polyline is a candidate both ways; the given start and sense win a tie.
3982///
3983/// A start's samples are read off one arc-length parametrisation per sense,
3984/// shifted by the start's length. The cost is a smooth function of that
3985/// shift, so it is read at about `8 * count` starts first and then at every
3986/// start next to a coarse local minimum, in the order an exhaustive scan
3987/// would visit them.
3988fn matched_loop(dense: &[Point], count: usize, previous: &[Point]) -> ArcLoop {
3989    let n = dense.len();
3990    let ways = [
3991        ArcLoop::new(dense.to_vec()),
3992        ArcLoop::new(dense.iter().rev().copied().collect()),
3993    ];
3994    #[allow(clippy::cast_precision_loss, reason = "a sample count")]
3995    fn samples(arc: &ArcLoop, start: usize, count: usize) -> impl Iterator<Item = Point> + '_ {
3996        let offset = arc.lengths[start] / arc.total;
3997        (0..count).map(move |k| {
3998            let f = offset + k as f64 / count as f64;
3999            arc.at(if f >= 1.0 { f - 1.0 } else { f })
4000        })
4001    }
4002    let cost = |arc: &ArcLoop, start: usize| -> f64 {
4003        samples(arc, start, count)
4004            .zip(previous)
4005            .map(|(p, q)| (p - *q).dot(p - *q))
4006            .sum()
4007    };
4008    let scale: f64 = previous
4009        .iter()
4010        .copied()
4011        .chain(samples(&ways[0], 0, count))
4012        .map(|p| (p - Point::ORIGIN).dot(p - Point::ORIGIN))
4013        .sum();
4014    let slack = scale * 1e-12;
4015    let coarse = 8 * count.max(1);
4016    let mut held = cost(&ways[0], 0);
4017    let mut best = (0, 0);
4018    for (w, arc) in ways.iter().enumerate() {
4019        let mut tried = vec![n <= 2 * coarse; n];
4020        if n > 2 * coarse {
4021            let picks: Vec<usize> = (0..coarse).map(|j| j * n / coarse).collect();
4022            let costs: Vec<f64> = picks.iter().map(|&i| cost(arc, i)).collect();
4023            for j in 0..coarse {
4024                let (before, after) = ((j + coarse - 1) % coarse, (j + 1) % coarse);
4025                if costs[j] <= costs[before] && costs[j] <= costs[after] {
4026                    let (from, to) = (picks[before], picks[after]);
4027                    let mut i = from;
4028                    loop {
4029                        tried[i] = true;
4030                        if i == to {
4031                            break;
4032                        }
4033                        i = (i + 1) % n;
4034                    }
4035                }
4036            }
4037        }
4038        for start in (0..n).filter(|&i| tried[i]) {
4039            let c = cost(arc, start);
4040            if c < held - slack {
4041                held = c;
4042                best = (w, start);
4043            }
4044        }
4045    }
4046    let [forward, backward] = ways;
4047    let (way, start) = best;
4048    let arc = if way == 0 { forward } else { backward };
4049    if start == 0 {
4050        return arc;
4051    }
4052    let mut turned = arc.dense;
4053    turned.rotate_left(start);
4054    ArcLoop::new(turned)
4055}
4056
4057/// Sweep a circular profile along a free-form spine, skinned.
4058///
4059/// Frames along the spine are rotation-minimizing (the double-reflection
4060/// construction), so the tube neither twists nor kinks where the spine
4061/// bends; the skin holds the tube to `tolerance`, measured between its
4062/// stations and round its circles as well as at the samples and refined
4063/// where it misses, and the caps sit perpendicular to the spine's ends.
4064///
4065/// A wire of several edges, open or closed, is the [`make_pipe_shell`] of
4066/// the disc square to its start under the rotation-minimizing frame, its
4067/// sharp corners mitred: each line and arc leg swept exactly where that
4068/// path sweeps it so, the rest held to `tolerance`.
4069///
4070/// # Errors
4071///
4072/// As [`make_pipe`], plus [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if
4073/// the skin cannot reach the
4074/// tolerance; along a wire, as [`make_pipe_shell`].
4075pub fn make_pipe_skinned(
4076    model: &mut Model,
4077    spine: &Shape,
4078    radius: f64,
4079    tolerance: f64,
4080    tol: Tolerances,
4081) -> OgeomResult<Built> {
4082    if !radius.is_finite() || radius <= tol.confusion() {
4083        ogeom_bail!(Construction, "a pipe of radius {radius} holds nothing");
4084    }
4085    // The spine is swept where it stands.
4086    let placed = placed_path(model, spine, tol)?;
4087    if placed != *spine {
4088        let mut built = make_pipe_skinned(model, &placed, radius, tolerance, tol)?;
4089        built.history.generate(spine, built.shape.clone());
4090        return Ok(built);
4091    }
4092    if model.kind_of(spine)? == ShapeType::Wire {
4093        let edges = model.ordered_children_of(spine)?;
4094        if let [edge] = edges.as_slice() {
4095            let mut built = make_pipe_skinned(model, edge, radius, tolerance, tol)?;
4096            built.history.generate(spine, built.shape.clone());
4097            return Ok(built);
4098        }
4099        let disc = disc_at_start(model, spine, &edges, radius, tol)?;
4100        let built = make_pipe_shell(model, &disc, spine, false, tolerance, tol)?;
4101        let mut history = History::new();
4102        history.generate(spine, built.shape.clone());
4103        return Ok(Built::new(built.shape, history));
4104    }
4105    let (curve, range) = {
4106        let Some(data) = model.node(spine).and_then(|n| n.data().as_edge()) else {
4107            ogeom_bail!(Construction, "a pipe runs along an edge");
4108        };
4109        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
4110            ogeom_bail!(Construction, "the spine has no curve");
4111        };
4112        let Some(geometry) = model.geometry().curve(*curve) else {
4113            ogeom_bail!(Dangling, "curve is not in this model");
4114        };
4115        (geometry.clone(), *range)
4116    };
4117    // The skin starts from 33 stations; the frame is carried along 64
4118    // times as many, so the frame between stations turns smoothly enough
4119    // for the fit refining between them.
4120    const STATIONS: usize = 33;
4121    const FRAMES: usize = 64 * (STATIONS - 1) + 1;
4122    const AROUND: usize = 40;
4123    let mut stations: Vec<SpineStation> = Vec::with_capacity(FRAMES);
4124    for i in 0..FRAMES {
4125        #[allow(clippy::cast_precision_loss)]
4126        let t = range.0 + (range.1 - range.0) * (i as f64) / ((FRAMES - 1) as f64);
4127        let p = curve.point_at(t, tol)?;
4128        let d = curve.d1_at(t, tol)?;
4129        let m = d.magnitude();
4130        if m <= tol.confusion() {
4131            ogeom_bail!(Construction, "the spine is degenerate at {t}");
4132        }
4133        stations.push(SpineStation {
4134            at: p,
4135            tangent: d / m,
4136            edge: 0,
4137            t,
4138        });
4139    }
4140    let normals = rmf_normals(&stations);
4141    // The tube anywhere: the circle about the spine point at `t`, in the
4142    // frame carried one rotation-minimizing step from the frame station
4143    // behind, `u` the fraction of a turn. At a station that is the
4144    // station's own frame, so the tube runs continuously through every
4145    // station.
4146    let tube = |u: f64, t: f64| -> OgeomResult<Point> {
4147        let k = stations.partition_point(|s| s.t <= t).clamp(1, FRAMES) - 1;
4148        let p = curve.point_at(t, tol)?;
4149        let d = curve.d1_at(t, tol)?;
4150        let m = d.magnitude();
4151        if m <= tol.confusion() {
4152            ogeom_bail!(Construction, "the spine is degenerate at {t}");
4153        }
4154        let tangent = d / m;
4155        let x = rmf_step(stations[k].at, stations[k].tangent, normals[k], p, tangent);
4156        let y = tangent.cross(x);
4157        let ang = core::f64::consts::TAU * u;
4158        Ok(p + (x * ang.cos() + y * ang.sin()) * radius)
4159    };
4160    let fraction = |i: usize, n: usize| -> f64 {
4161        #[allow(clippy::cast_precision_loss, reason = "a small count")]
4162        let f = i as f64 / n as f64;
4163        f
4164    };
4165    let us: Vec<f64> = (0..=AROUND).map(|a| fraction(a, AROUND)).collect();
4166    let vs: Vec<f64> = stations.iter().step_by(64).map(|s| s.t).collect();
4167    let rows = vs
4168        .iter()
4169        .map(|&t| us[..AROUND].iter().map(|&u| tube(u, t)).collect())
4170        .collect::<OgeomResult<Vec<Vec<Point>>>>()?;
4171    let skin = Skin {
4172        rows,
4173        round: true,
4174        by_spacing: false,
4175        traced: Some(Traced {
4176            // The end of the way round is its start, to the bit, so the
4177            // seam closes exactly.
4178            point: Box::new(move |u, t| tube(if u >= 1.0 { 0.0 } else { u }, t)),
4179            sampling: ogeom_geom::fit::Sampling {
4180                us,
4181                vs,
4182                between: (true, true),
4183                closed_v: false,
4184                most: 512,
4185            },
4186        }),
4187        carried: None,
4188    };
4189    let mut built = skinned_solid(
4190        model,
4191        &skin,
4192        (
4193            EndCap::Plane(-stations[0].tangent),
4194            EndCap::Plane(stations[FRAMES - 1].tangent),
4195        ),
4196        tolerance,
4197        tol,
4198    )?;
4199    built.history.generate(spine, built.shape.clone());
4200    state_new_gaps(model, std::slice::from_ref(spine), &built.shape, tol)?;
4201    Ok(built)
4202}
4203
4204/// One sampled spine station: where the spine is and which way it runs.
4205#[derive(Clone, Copy)]
4206pub(crate) struct SpineStation {
4207    pub(crate) at: Point,
4208    /// The unit tangent, in the direction of travel.
4209    pub(crate) tangent: Vector,
4210    /// The spine edge this station stands on, by position in the spine.
4211    pub(crate) edge: usize,
4212    /// The station's parameter on that edge's curve.
4213    pub(crate) t: f64,
4214}
4215
4216/// One profile wire's closed shell round the spine: smooth wires skin as a
4217/// single closed face, faceted ones as one ring strip per facet.
4218///
4219/// `carried(s, (a, b))` is the point `(a, b)` of the profile, in the start
4220/// frame, at `s` along the way round `stations`: the first station's frame
4221/// again at the last.
4222#[allow(clippy::too_many_arguments, reason = "one frame, spelled out")]
4223fn closed_loop_shell(
4224    model: &mut Model,
4225    profile_loop: &Shape,
4226    edges: &[Shape],
4227    smooth: bool,
4228    stations: &[SpineStation],
4229    carried: &dyn Fn(f64, (f64, f64)) -> OgeomResult<Point>,
4230    frame0: (Point, Vector),
4231    tolerance: f64,
4232    tol: Tolerances,
4233) -> OgeomResult<Shape> {
4234    const AROUND: usize = 40;
4235    let (origin, x0) = frame0;
4236    let t0 = stations[0].tangent;
4237    let y0 = t0.cross(x0);
4238    // The way round runs from the first station to its return home, the
4239    // last of the walk's stations.
4240    let home = stations.len() - 1;
4241    if !smooth {
4242        // A faceted profile: one ring strip per profile edge; a fit cannot
4243        // speak a corner, so each facet gets its own v-closed skin and the
4244        // strips sew along the corner loops they share within tolerance.
4245        const ALONG_EDGE: usize = 8;
4246        // Outward for a ring strip means away from the spine's own line,
4247        // not from the loop's centroid: a ring's inner side *faces* the
4248        // centroid. The hint is the station the strip's midpoint rides.
4249        let mid_station = stations[stations.len() / 2].at;
4250        let mut faces = Vec::with_capacity(edges.len());
4251        // Each corner loop is one rail edge shared by the two strips that
4252        // meet along it, the wrap included.
4253        let mut rails: Vec<Option<Shape>> = vec![None; edges.len()];
4254        for (index, edge) in edges.iter().enumerate() {
4255            let (curve, range) = spine_curve_of(model, edge)?;
4256            let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
4257            // rows[j = station][i = across the facet]: each column one
4258            // point of the edge, at a fraction of its parameter range,
4259            // carried through the stations.
4260            let skin = Skin::swept(
4261                |f, s| {
4262                    let t = if reversed {
4263                        range.1 - (range.1 - range.0) * f
4264                    } else {
4265                        range.0 + (range.1 - range.0) * f
4266                    };
4267                    let p = curve.point_at(t, tol)?;
4268                    carried(s, ((p - origin).dot(x0), (p - origin).dot(y0)))
4269                },
4270                fractions(ALONG_EDGE),
4271                (0, home),
4272                false,
4273            )?;
4274            let next = (index + 1) % edges.len();
4275            let shared = [rails[index].clone(), rails[next].clone()];
4276            let (face, rail0, rail1) = skinned_ring_strip(
4277                model,
4278                &skin,
4279                mid_station,
4280                [shared[0].as_ref(), shared[1].as_ref()],
4281                tolerance,
4282                tol,
4283            )?;
4284            rails[index] = Some(rail0);
4285            rails[next] = Some(rail1);
4286            faces.push(face);
4287        }
4288        let sewn = sew(model, &faces, tol)?;
4289        if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
4290            if std::env::var_os("OGEOM_DEBUG_RING").is_some() {
4291                use ogeom_geom::Curve3d as _;
4292                eprintln!(
4293                    "RING: {} shells from {} strips",
4294                    sewn.shells.len(),
4295                    faces.len()
4296                );
4297                for shell in &sewn.shells {
4298                    for edge in ogeom_topo::explore_unique(model, shell, ShapeType::Edge)? {
4299                        let mut uses = 0;
4300                        for f in explore(model, shell, Filter::OfType(ShapeType::Face))? {
4301                            for w in model.children_of(&f)? {
4302                                for e in model.children_of(&w)? {
4303                                    if e.node() == edge.node() {
4304                                        uses += 1;
4305                                    }
4306                                }
4307                            }
4308                        }
4309                        if uses == 1
4310                            && let Some(d) = model.node(&edge).and_then(|n| n.data().as_edge())
4311                            && let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) =
4312                                d.curve3d()
4313                            && let Some(g) = model.geometry().curve(*curve)
4314                        {
4315                            eprintln!(
4316                                "RING open edge tol {:.2e}: {:?} -> {:?}",
4317                                d.tolerance.get(),
4318                                g.point_at(range.0, tol)?,
4319                                g.point_at(range.1, tol)?
4320                            );
4321                        }
4322                    }
4323                }
4324            }
4325            ogeom_bail!(Construction, "the faceted ring did not close");
4326        }
4327        return Ok(sewn.shells[0].clone());
4328    }
4329    // Each column one point of the profile, read by arc length round it,
4330    // carried through the stations.
4331    let around = section_loop(model, profile_loop, None, tol)?;
4332    let skin = Skin::swept(
4333        |f, s| {
4334            let p = around.at(f);
4335            carried(s, ((p - origin).dot(x0), (p - origin).dot(y0)))
4336        },
4337        fractions(AROUND),
4338        (0, home),
4339        true,
4340    )?;
4341    closed_skinned_shell(model, &skin, tolerance, tol)
4342}
4343
4344/// One profile wire's closed shell round the spine where the twist brings
4345/// the section home turned by part of a turn, onto itself by its own
4346/// symmetry: `carried` at the last station reads the section turned by the
4347/// whole twist, and `share(s)` is the share of the twist turned by `s`.
4348///
4349/// A faceted wire's edges each sweep a strip open at both ends, from the
4350/// edge at the first station to where it comes home: on another edge of
4351/// the wire, the next edge on the next, so the strips close on one
4352/// another, each edge at the start the end of the strip that lands on it.
4353/// A smooth wire (one closed edge) is read from a point that slides round
4354/// it as the section turns, by the share of the turn that carries its
4355/// start onto where its start comes home, so every column of the skin
4356/// comes home to itself and the skin closes as an untwisted one does.
4357///
4358/// Where the wire's image at home is not the wire itself, edge onto edge
4359/// (or for a smooth wire the curve onto itself, slid), the twist is
4360/// refused by name.
4361#[allow(clippy::too_many_arguments, reason = "one frame, spelled out")]
4362fn turned_loop_shell(
4363    model: &mut Model,
4364    edges: &[Shape],
4365    smooth: bool,
4366    stations: &[SpineStation],
4367    carried: &dyn Fn(f64, (f64, f64)) -> OgeomResult<Point>,
4368    share: &dyn Fn(f64) -> OgeomResult<f64>,
4369    frame0: (Point, Vector),
4370    tolerance: f64,
4371    tol: Tolerances,
4372) -> OgeomResult<Shape> {
4373    const ALONG_EDGE: usize = 8;
4374    const AROUND: usize = 40;
4375    // Samples each edge's image is checked at.
4376    const CHECKED: usize = 32;
4377    let refused = || -> OgeomResult<Shape> {
4378        ogeom_bail!(
4379            Construction,
4380            "a twist round a closed spine is a whole number of turns, or a part of one \
4381             the section's symmetry brings home onto itself; this section comes home \
4382             turned off itself"
4383        )
4384    };
4385    let (origin, x0) = frame0;
4386    let y0 = stations[0].tangent.cross(x0);
4387    let flat = |p: Point| ((p - origin).dot(x0), (p - origin).dot(y0));
4388    let home = stations.len() - 1;
4389    #[allow(clippy::cast_precision_loss, reason = "a station count")]
4390    let end = home as f64;
4391    let near = tol.confusion();
4392    // Each edge read as the wire walks it, by a fraction of its range.
4393    let mut walked: Vec<(Curve, (f64, f64), bool)> = Vec::with_capacity(edges.len());
4394    for edge in edges {
4395        let (curve, range) = spine_curve_of(model, edge)?;
4396        let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
4397        walked.push((curve, range, reversed));
4398    }
4399    let at = |e: usize, f: f64| -> OgeomResult<Point> {
4400        let (curve, range, reversed) = &walked[e];
4401        let t = if *reversed {
4402            range.1 - (range.1 - range.0) * f
4403        } else {
4404            range.0 + (range.1 - range.0) * f
4405        };
4406        curve.point_at(t, tol)
4407    };
4408    let mid = stations[home / 2].at;
4409    #[allow(clippy::cast_precision_loss, reason = "a small count")]
4410    let checked = |k: usize| k as f64 / CHECKED as f64;
4411
4412    if smooth {
4413        // The fraction round the curve its start comes home at.
4414        let (curve, range, reversed) = &walked[0];
4415        let span = range.1 - range.0;
4416        let target = carried(end, flat(at(0, 0.0)?))?;
4417        let found = ogeom_algo::project_on_curve(curve, target, 4 * CHECKED, tol)?;
4418        if found.distance > near {
4419            return refused();
4420        }
4421        let slide = if *reversed {
4422            (range.1 - found.parameter) / span
4423        } else {
4424            (found.parameter - range.0) / span
4425        }
4426        .rem_euclid(1.0);
4427        let round = |e: usize, f: f64| at(e, f.rem_euclid(1.0));
4428        for k in 0..CHECKED {
4429            let f = checked(k);
4430            if carried(end, flat(round(0, f)?))?.distance(round(0, f + slide)?) > near {
4431                return refused();
4432            }
4433        }
4434        let skin = Skin::swept(
4435            |f, s| carried(s, flat(round(0, f - slide * share(s)?)?)),
4436            fractions(AROUND),
4437            (0, home),
4438            true,
4439        )?;
4440        return closed_skinned_shell(model, &skin, tolerance, tol);
4441    }
4442
4443    // Where each edge comes home: on the edge whose start its start comes
4444    // home at, each next edge on the next, the image lying on it.
4445    let n = edges.len();
4446    let starts: Vec<Point> = (0..n).map(|e| at(e, 0.0)).collect::<OgeomResult<_>>()?;
4447    let first = carried(end, flat(starts[0]))?;
4448    let Some(onto) = (0..n).find(|&j| starts[j].distance(first) <= near) else {
4449        return refused();
4450    };
4451    let lands: Vec<usize> = (0..n).map(|i| (i + onto) % n).collect();
4452    for i in 0..n {
4453        if carried(end, flat(starts[i]))?.distance(starts[lands[i]]) > near {
4454            return refused();
4455        }
4456        let (curve, range, _) = &walked[lands[i]];
4457        let (lo, hi) = (range.0.min(range.1), range.0.max(range.1));
4458        for k in 0..=CHECKED {
4459            let image = carried(end, flat(at(i, checked(k))?))?;
4460            let found = ogeom_algo::project_on_curve(curve, image, CHECKED, tol)?;
4461            let inside = found.parameter >= lo - tol.parametric()
4462                && found.parameter <= hi + tol.parametric();
4463            if !inside || found.distance > near {
4464                return refused();
4465            }
4466        }
4467    }
4468    let corners: Vec<Shape> = starts
4469        .iter()
4470        .map(|p| -> OgeomResult<Shape> {
4471            Ok(model.add_vertex(VertexData::new(carried(0.0, flat(*p))?)))
4472        })
4473        .collect::<OgeomResult<_>>()?;
4474    // Each edge at the start is one edge, the bottom of its own strip and
4475    // the top of the strip landing on it; each corner's path one rail, the
4476    // end of one strip and the start of the next.
4477    let mut bottoms: Vec<Option<Shape>> = vec![None; n];
4478    let mut rails: Vec<Option<Shape>> = vec![None; n];
4479    let mut faces = Vec::with_capacity(n);
4480    for i in 0..n {
4481        let next = (i + 1) % n;
4482        let (j, j_next) = (lands[i], lands[next]);
4483        let skin = Skin::swept(
4484            |f, s| carried(s, flat(at(i, f)?)),
4485            fractions(ALONG_EDGE),
4486            (0, home),
4487            false,
4488        )?;
4489        // A border is adopted by projecting it onto this strip from its
4490        // start; where that start is a corner the strip holds twice (the
4491        // strip landing beside the edge it left), the projection cannot
4492        // tell the two apart, and the border is fitted afresh and sewn.
4493        let at_corner = [i, next, j, j_next];
4494        let twice = |c: usize| at_corner.iter().filter(|k| **k == c).count() > 1;
4495        let shared = [
4496            (bottoms[i].as_ref(), i),
4497            (bottoms[j].as_ref(), j),
4498            (rails[i].as_ref(), i),
4499            (rails[next].as_ref(), next),
4500        ]
4501        .map(|(edge, start)| edge.filter(|_| !twice(start)));
4502        let strip = skinned_strip(
4503            model,
4504            &skin,
4505            (&corners[i], &corners[next], &corners[j], &corners[j_next]),
4506            shared,
4507            mid,
4508            false,
4509            tolerance,
4510            tol,
4511        )?;
4512        bottoms[i].get_or_insert(strip.bottom);
4513        bottoms[j].get_or_insert(strip.top);
4514        rails[i].get_or_insert(strip.rail0);
4515        rails[next].get_or_insert(strip.rail1);
4516        faces.push(strip.face);
4517    }
4518    let sewn = sew(model, &faces, tol)?;
4519    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
4520        ogeom_bail!(Construction, "the turned faceted ring did not close");
4521    }
4522    Ok(sewn.shells[0].clone())
4523}
4524
4525/// Rotation-minimizing normals along the stations, by double reflection:
4526/// reflect in each chord's plane, then in the plane bisecting the tangents.
4527/// Self-contained (it needs only the station list) and shared by every
4528/// sweep that must not twist where its spine bends.
4529fn rmf_normals(stations: &[SpineStation]) -> Vec<Vector> {
4530    let mut normals: Vec<Vector> = Vec::with_capacity(stations.len());
4531    let t0 = stations[0].tangent;
4532    let seed = if t0.cross(ogeom_math::Vector::Z).magnitude() > 0.5 {
4533        ogeom_math::Vector::Z
4534    } else {
4535        ogeom_math::Vector::X
4536    };
4537    let n0 = {
4538        let v = seed - t0 * seed.dot(t0);
4539        v / v.magnitude()
4540    };
4541    normals.push(n0);
4542    for i in 1..stations.len() {
4543        let (p0, t0) = (stations[i - 1].at, stations[i - 1].tangent);
4544        let (p1, t1) = (stations[i].at, stations[i].tangent);
4545        let n = normals[i - 1];
4546        let v1 = p1 - p0;
4547        let c1 = v1.dot(v1);
4548        if c1 <= 1e-20 {
4549            // A corner's twin station: no travel to reflect through. The
4550            // normal is reflected across the corner's mitre plane instead;
4551            // for a vector square to the incoming tangent that is exactly
4552            // the parallel transport about the corner's own axis, and the
4553            // mirror symmetry is what lands both legs' sheared sections on
4554            // one ring. A planar corner's normal lies in the mitre plane
4555            // already and carries straight across; a skew corner's does
4556            // not, and carried unchanged it leaves the far leg's section
4557            // off the mitre.
4558            let bisector = t0 + t1;
4559            let m = bisector.magnitude();
4560            if m <= 1e-12 {
4561                normals.push(n);
4562                continue;
4563            }
4564            let b = bisector / m;
4565            normals.push(n - b * (2.0 * n.dot(b)));
4566            continue;
4567        }
4568        normals.push(rmf_step(p0, t0, n, p1, t1));
4569    }
4570    normals
4571}
4572
4573/// One rotation-minimizing step: the normal `n0` at `(p0, t0)` carried to
4574/// `(p1, t1)` by double reflection. No travel means no change.
4575fn rmf_step(p0: Point, t0: Vector, n0: Vector, p1: Point, t1: Vector) -> Vector {
4576    let v1 = p1 - p0;
4577    let c1 = v1.dot(v1);
4578    if c1 <= 1e-20 {
4579        return n0;
4580    }
4581    let nl = n0 - v1 * (2.0 / c1 * v1.dot(n0));
4582    let tl = t0 - v1 * (2.0 / c1 * v1.dot(t0));
4583    let v2 = t1 - tl;
4584    let c2 = v2.dot(v2);
4585    let next = if c2 > 1e-20 {
4586        nl - v2 * (2.0 / c2 * v2.dot(nl))
4587    } else {
4588        nl
4589    };
4590    next / next.magnitude()
4591}
4592
4593/// A leg's generators, evaluated anywhere: the spine's own curve between
4594/// stations with the rotation-minimizing normal carried one step from the
4595/// station behind (turned to meet the next station's normal where the law
4596/// differs from that step), and a straight extension past either end in
4597/// the end frame: the surface a mitre trims against. Parameters are
4598/// station indices; a unit beyond an end is one station spacing.
4599struct SpineWalk<'a> {
4600    curves: Vec<WalkCurve>,
4601    stations: &'a [SpineStation],
4602    normals: &'a [Vector],
4603}
4604
4605/// A spine edge's curve, range and whether it is travelled reversed.
4606type WalkCurve = (ogeom_geom::Curve, (f64, f64), bool);
4607
4608/// Every spine edge as a [`WalkCurve`], in the spine's order.
4609fn walk_curves(model: &Model, spine: &Shape) -> OgeomResult<Vec<WalkCurve>> {
4610    let edges: Vec<Shape> = match model.kind_of(spine)? {
4611        ShapeType::Edge => vec![spine.clone()],
4612        _ => model.ordered_children_of(spine)?,
4613    };
4614    let mut out = Vec::with_capacity(edges.len());
4615    for edge in &edges {
4616        let (curve, range) = spine_curve_of(model, edge)?;
4617        out.push((
4618            curve,
4619            range,
4620            edge.orientation() == ogeom_topo::Orientation::Reversed,
4621        ));
4622    }
4623    Ok(out)
4624}
4625
4626/// Where two legs' generators for one profile point meet at a corner: the
4627/// point, each leg's parameter, and how far the two generators actually
4628/// miss each other (zero when the corner turns in the plane).
4629struct CornerJoin {
4630    at: Point,
4631    s1: f64,
4632    s2: f64,
4633    gap: f64,
4634}
4635
4636impl SpineWalk<'_> {
4637    /// The spine edge and its parameter a fraction `f` of the way from
4638    /// station `j` to the next: the next station's edge, walked from the
4639    /// station on it or from the edge's start.
4640    fn place_between(&self, j: usize, f: f64) -> (usize, f64) {
4641        let st = self.stations;
4642        let edge = st[j + 1].edge;
4643        let (_, range, reversed) = &self.curves[edge];
4644        let t_from = if st[j].edge == edge {
4645            st[j].t
4646        } else if *reversed {
4647            range.1
4648        } else {
4649            range.0
4650        };
4651        (edge, t_from + (st[j + 1].t - t_from) * f)
4652    }
4653
4654    /// The spine point, unit tangent and frame normal at `s` within the run
4655    /// `(rs, re)`.
4656    fn frame_at(
4657        &self,
4658        s: f64,
4659        (rs, re): (usize, usize),
4660        tol: Tolerances,
4661    ) -> OgeomResult<(Point, Vector, Vector)> {
4662        let st = self.stations;
4663        let at = |i: usize| (st[i].at, st[i].tangent, self.normals[i]);
4664        #[allow(clippy::cast_precision_loss)]
4665        let (rsf, ref_) = (rs as f64, re as f64);
4666        if s <= rsf {
4667            let (p, t, n) = at(rs);
4668            let h = st[rs].at.distance(st[(rs + 1).min(re)].at);
4669            return Ok((p + t * ((s - rsf) * h), t, n));
4670        }
4671        if s >= ref_ {
4672            let (p, t, n) = at(re);
4673            let h = st[re].at.distance(st[re.saturating_sub(1).max(rs)].at);
4674            return Ok((p + t * ((s - ref_) * h), t, n));
4675        }
4676        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
4677        let j = s.floor() as usize;
4678        #[allow(clippy::cast_precision_loss)]
4679        let f = s - j as f64;
4680        if f <= 0.0 {
4681            return Ok(at(j));
4682        }
4683        let (edge, t) = self.place_between(j, f);
4684        let (curve, _, reversed) = &self.curves[edge];
4685        let p = curve.point_at(t, tol)?;
4686        let d = curve.d1_at(t, tol)?;
4687        let m = d.magnitude();
4688        if m <= tol.confusion() {
4689            ogeom_bail!(Construction, "the spine is degenerate at {t}");
4690        }
4691        let tangent = if *reversed { -(d / m) } else { d / m };
4692        let n = rmf_step(st[j].at, st[j].tangent, self.normals[j], p, tangent);
4693        // A law whose normals are not carried by this step (Frenet, or a
4694        // ring's twist spread round it) turns from the carried normal by
4695        // the angle it reaches at the next station, a share of it in
4696        // proportion to the way there, so the frame meets both stations.
4697        let carried = rmf_step(
4698            st[j].at,
4699            st[j].tangent,
4700            self.normals[j],
4701            st[j + 1].at,
4702            st[j + 1].tangent,
4703        );
4704        let ahead = self.normals[j + 1];
4705        let turn = carried
4706            .cross(ahead)
4707            .dot(st[j + 1].tangent)
4708            .atan2(carried.dot(ahead));
4709        if turn == 0.0 {
4710            return Ok((p, tangent, n));
4711        }
4712        let phi = turn * f;
4713        let n = n * phi.cos() + tangent.cross(n) * phi.sin();
4714        let n = n - tangent * n.dot(tangent);
4715        Ok((p, tangent, n / n.magnitude()))
4716    }
4717
4718    /// The generator of profile point `(a, b)` at `s` within the run.
4719    fn generator(
4720        &self,
4721        s: f64,
4722        run: (usize, usize),
4723        (a, b): (f64, f64),
4724        tol: Tolerances,
4725    ) -> OgeomResult<Point> {
4726        let (p, t, x) = self.frame_at(s, run, tol)?;
4727        let y = t.cross(x);
4728        Ok(p + x * a + y * b)
4729    }
4730
4731    /// Where the generators of one profile point on the leg `before` and
4732    /// the leg `after` a corner meet: Gauss-Newton on both parameters from
4733    /// the corner itself, minimising the distance between the two.
4734    fn join(
4735        &self,
4736        before: (usize, usize),
4737        after: (usize, usize),
4738        ab: (f64, f64),
4739        tol: Tolerances,
4740    ) -> OgeomResult<CornerJoin> {
4741        const STEP: f64 = 1e-4;
4742        #[allow(clippy::cast_precision_loss)]
4743        let (mut s1, mut s2) = (before.1 as f64, after.0 as f64);
4744        for _ in 0..60 {
4745            let g1 = self.generator(s1, before, ab, tol)?;
4746            let g2 = self.generator(s2, after, ab, tol)?;
4747            let f = g1 - g2;
4748            let d1 = (self.generator(s1 + STEP, before, ab, tol)?
4749                - self.generator(s1 - STEP, before, ab, tol)?)
4750                / (2.0 * STEP);
4751            let d2 = (self.generator(s2 + STEP, after, ab, tol)?
4752                - self.generator(s2 - STEP, after, ab, tol)?)
4753                / (2.0 * STEP);
4754            let (a11, a12, a22) = (d1.dot(d1), -d1.dot(d2), d2.dot(d2));
4755            let (b1, b2) = (-f.dot(d1), f.dot(d2));
4756            let det = a11 * a22 - a12 * a12;
4757            if det.abs() <= 1e-30 {
4758                break;
4759            }
4760            let e1 = (b1 * a22 - a12 * b2) / det;
4761            let e2 = (a11 * b2 - a12 * b1) / det;
4762            s1 += e1;
4763            s2 += e2;
4764            if e1.abs().max(e2.abs()) <= 1e-12 {
4765                break;
4766            }
4767        }
4768        let g1 = self.generator(s1, before, ab, tol)?;
4769        let g2 = self.generator(s2, after, ab, tol)?;
4770        Ok(CornerJoin {
4771            at: g1.midpoint(g2),
4772            s1,
4773            s2,
4774            gap: g1.distance(g2),
4775        })
4776    }
4777}
4778
4779/// Sweep a planar profile along a helix about `axis`: a screw motion,
4780/// every point of the profile running its own helix. The thread and spring
4781/// operation, with the profile in a plane through the axis; a profile in
4782/// any other plane clear of the axis (square to it, a ramp or a stair's
4783/// tread) climbs the same way.
4784///
4785/// `pitch` is the advance per turn along `axis`, `turns` how far the
4786/// profile turns, `left_handed` turns it the other way about the axis for
4787/// the same advance, and `taper_per_turn` moves every point away from the
4788/// axis by that much per turn (a conical helix; zero for a cylindrical
4789/// one). A pitch of zero with a taper is a flat spiral, every point
4790/// turning in its plane square to the axis while moving out. The walls are
4791/// fitted through each profile edge's exact screw images; the caps are the
4792/// profile where it starts and where it ends.
4793///
4794/// # Errors
4795///
4796/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
4797/// the profile is not a planar face, meets the axis, would meet itself one
4798/// turn on (its extent along the axis is not less than the pitch, or for a
4799/// flat spiral its extent away from the axis not less than the taper), is
4800/// carried partly forward through its own plane and partly back, or tapers
4801/// onto the axis; if a taper is asked of a profile whose plane neither
4802/// holds the axis nor stands square to it; if `turns` is not positive,
4803/// `pitch` is negative, or both `pitch` and the taper are zero.
4804/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if a wall
4805/// cannot be fitted.
4806#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
4807pub fn make_helical_sweep(
4808    model: &mut Model,
4809    profile: &Shape,
4810    axis: ogeom_math::Axis,
4811    pitch: f64,
4812    turns: f64,
4813    left_handed: bool,
4814    taper_per_turn: f64,
4815    tol: Tolerances,
4816) -> OgeomResult<Built> {
4817    if !(pitch.is_finite() && pitch >= 0.0) || !(turns.is_finite() && turns > 0.0) {
4818        ogeom_bail!(
4819            Construction,
4820            "a helical sweep needs a pitch of zero or more and a positive turn count; \
4821             got {pitch} and {turns}"
4822        );
4823    }
4824    if !taper_per_turn.is_finite() {
4825        ogeom_bail!(Construction, "a taper of {taper_per_turn} is not a length");
4826    }
4827    let flat = pitch <= tol.confusion();
4828    if flat && taper_per_turn.abs() <= tol.confusion() {
4829        ogeom_bail!(
4830            Construction,
4831            "a helical sweep with no pitch and no taper turns the profile onto itself"
4832        );
4833    }
4834    if model.kind_of(profile)? != ShapeType::Face {
4835        ogeom_bail!(Construction, "a helical sweep sweeps a planar face");
4836    }
4837    let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
4838        ogeom_bail!(Construction, "a helical sweep sweeps a planar face");
4839    };
4840    let z = axis.direction.vector();
4841    let n = plane.normal().vector();
4842    let holds_axis = n.dot(z).abs() <= tol.angular()
4843        && plane.distance_to(axis.location) <= tol.confusion() * 100.0;
4844    let level = (n.dot(z).abs() - 1.0).abs() <= tol.angular();
4845    // A taper moves every point away from the axis by the same amount, which
4846    // keeps a profile in a plane only where that plane holds the axis or
4847    // stands square to it.
4848    if taper_per_turn.abs() > tol.confusion() && !holds_axis && !level {
4849        ogeom_bail!(
4850            Construction,
4851            "a tapered helical sweep carries a profile in a plane through the \
4852             axis or square to it; an oblique profile would leave its plane"
4853        );
4854    }
4855    // A profile the axis runs through has points with no helix to follow:
4856    // where the axis pierces the plane is asked of the profile's rings,
4857    // read in the plane.
4858    if !holds_axis && n.dot(z).abs() > tol.angular() {
4859        let reach = (plane.origin() - axis.location).dot(n) / z.dot(n);
4860        let pierce = axis.location + z * reach;
4861        let frame = plane.frame();
4862        let flat = |p: Point| {
4863            let l = frame.to_local(p);
4864            Point2::new(l.x, l.y)
4865        };
4866        let at = flat(pierce);
4867        let mut inside = false;
4868        let mut touches = false;
4869        for wire in explore(model, profile, Filter::OfType(ShapeType::Wire))? {
4870            let ring: Vec<Point2> = sample_wire(model, &wire, 256, tol)?
4871                .into_iter()
4872                .map(flat)
4873                .collect();
4874            for (k, a) in ring.iter().enumerate() {
4875                let b = ring[(k + 1) % ring.len()];
4876                let (ab, ap) = (b - *a, at - *a);
4877                let t = (ap.dot(ab) / ab.dot(ab).max(f64::MIN_POSITIVE)).clamp(0.0, 1.0);
4878                touches |= (*a + ab * t).distance(at) <= tol.confusion() * 100.0;
4879                if (a.y > at.y) != (b.y > at.y) && at.x < a.x + (at.y - a.y) / (b.y - a.y) * ab.x {
4880                    inside = !inside;
4881                }
4882            }
4883        }
4884        if inside || touches {
4885            ogeom_bail!(
4886                Construction,
4887                "the profile meets the axis, where a helical sweep has no helix \
4888                 to follow"
4889            );
4890        }
4891    }
4892    let total = core::f64::consts::TAU * turns;
4893    let sense = if left_handed { -1.0 } else { 1.0 };
4894    // How a point sets off at the start of the turn: up the axis by the
4895    // pitch, round it, and out by the taper, each per radian.
4896    let travel = |p: Point| -> Vector {
4897        let foot = axis.project(p);
4898        let out = p - foot;
4899        let rho = out.magnitude().max(f64::MIN_POSITIVE);
4900        let radial = out / rho;
4901        (z * pitch + radial * taper_per_turn) / core::f64::consts::TAU
4902            + z.cross(radial) * (rho * sense)
4903    };
4904    // The screw image of a point after turning through `theta`.
4905    let screw = |p: Point, theta: f64| -> OgeomResult<Point> {
4906        let foot = axis.project(p);
4907        let out = p - foot;
4908        let rho = out.magnitude();
4909        let grown = rho + taper_per_turn * theta / core::f64::consts::TAU;
4910        if rho <= tol.confusion() || grown <= tol.confusion() {
4911            ogeom_bail!(
4912                Construction,
4913                "the profile reaches the axis, where a helical sweep has no \
4914                 helix to follow"
4915            );
4916        }
4917        let radial = out / rho;
4918        let across = z.cross(radial);
4919        let (sin, cos) = (sense * theta).sin_cos();
4920        let turned = radial * cos + across * sin;
4921        Ok(foot + z * (pitch * theta / core::f64::consts::TAU) + turned * grown)
4922    };
4923
4924    // The profile's loops, their edges in ring order, each sampled.
4925    let loops = explore(model, profile, Filter::OfType(ShapeType::Wire))?;
4926    if loops.is_empty() {
4927        ogeom_bail!(Construction, "the profile has no loop to sweep");
4928    }
4929    // Every point leaves the profile's plane the same way, or the profile
4930    // sweeps back through where it has been; a plane the motion runs
4931    // along everywhere (a level profile with no pitch) sweeps nothing.
4932    {
4933        let (mut ahead, mut behind) = (0.0_f64, 0.0_f64);
4934        for wire in &loops {
4935            for p in sample_wire(model, wire, 64, tol)? {
4936                let t = travel(p);
4937                let across = t.dot(n) / t.magnitude().max(f64::MIN_POSITIVE);
4938                ahead = ahead.max(across);
4939                behind = behind.max(-across);
4940            }
4941        }
4942        if ahead > tol.angular() && behind > tol.angular() {
4943            ogeom_bail!(
4944                Construction,
4945                "the screw carries part of the profile forward through its plane \
4946                 and part back; the sweep runs into itself"
4947            );
4948        }
4949        if ahead <= tol.angular() && behind <= tol.angular() {
4950            ogeom_bail!(
4951                Construction,
4952                "the screw carries the profile along its own plane; it sweeps \
4953                 no volume"
4954            );
4955        }
4956    }
4957    // One turn on, the profile must clear itself: along the axis by the
4958    // pitch, or for a flat spiral away from it by the taper.
4959    if turns > 1.0 {
4960        let mut low = f64::INFINITY;
4961        let mut high = f64::NEG_INFINITY;
4962        for wire in &loops {
4963            for p in sample_wire(model, wire, 64, tol)? {
4964                let h = if flat {
4965                    p.distance(axis.project(p))
4966                } else {
4967                    (p - axis.location).dot(z)
4968                };
4969                low = low.min(h);
4970                high = high.max(h);
4971            }
4972        }
4973        let (clearance, across) = if flat {
4974            (taper_per_turn.abs(), "away from the axis")
4975        } else {
4976            (pitch, "along the axis")
4977        };
4978        if high - low >= clearance - tol.confusion() {
4979            ogeom_bail!(
4980                Construction,
4981                "the profile spans {} {across}, not less than the turn's advance \
4982                 {clearance}; a turn on it meets itself",
4983                high - low
4984            );
4985        }
4986    }
4987    let tolerance = tol.confusion() * 100.0;
4988    // The walls in quarter turns, each a strip of its own sharing its
4989    // borders with the next: one fit down many turns of a helix cannot
4990    // reach the tolerance, a quarter turn's can. The borders stand an
4991    // eighth of a turn off the profile's own plane and the planes square to
4992    // it: a strip's border is a copy of the profile, and a plane through the
4993    // axis at those angles (a block's face set on the axis) would otherwise
4994    // meet a strip only along its border, where no section starts.
4995    let quarter = core::f64::consts::FRAC_PI_2;
4996    let mut borders = vec![0.0];
4997    let mut next_border = quarter / 2.0;
4998    while next_border < total - quarter * 1e-3 {
4999        borders.push(next_border);
5000        next_border += quarter;
5001    }
5002    borders.push(total);
5003    let segments = borders.len() - 1;
5004    // The fit keeps fewer controls than samples, so its reach at the
5005    // samples is set by how many there are.
5006    const PER_SEGMENT: usize = 48;
5007    // The turn at `s` stations into a segment, `s` a whole station or any
5008    // fraction between.
5009    #[allow(clippy::cast_precision_loss)]
5010    let theta_at = |seg: usize, s: f64| {
5011        borders[seg] + (borders[seg + 1] - borders[seg]) * s / (PER_SEGMENT as f64)
5012    };
5013
5014    let mut faces: Vec<Shape> = Vec::new();
5015    let mut cap_loops: [Vec<Vec<Shape>>; 2] = [Vec::new(), Vec::new()];
5016    for (li, wire) in loops.iter().enumerate() {
5017        let hole = li != 0;
5018        let edges = model.ordered_children_of(wire)?;
5019        let centre = {
5020            let samples = sample_wire(model, wire, 32, tol)?;
5021            #[allow(clippy::cast_precision_loss)]
5022            let n = samples.len() as f64;
5023            let sum = samples
5024                .iter()
5025                .fold(Vector::new(0.0, 0.0, 0.0), |acc, p| acc + p.to_vector());
5026            Point::from_vector(sum / n)
5027        };
5028        // The ring's pieces: every edge in the ring's sense, a closed one
5029        // (a circle, the whole ring) cut in quarters so each strip's fit
5030        // spans a quarter turn round it at most.
5031        let mut pieces: Vec<(ogeom_geom::Curve, f64, f64)> = Vec::new();
5032        for edge in &edges {
5033            let Some((curve, range)) = ogeom_algo::placed_edge_curve(model, edge, tol)? else {
5034                ogeom_bail!(Construction, "a profile edge has no curve");
5035            };
5036            let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
5037            let (t0, t1) = if reversed { (range.1, range.0) } else { range };
5038            let closed =
5039                ogeom_algo::edge_vertices(model, edge)?.is_some_and(|(a, b)| a.is_same(&b));
5040            let parts = if closed { 4 } else { 1 };
5041            for k in 0..parts {
5042                #[allow(clippy::cast_precision_loss)]
5043                let (f0, f1) = (k as f64 / parts as f64, (k + 1) as f64 / parts as f64);
5044                pieces.push((curve.clone(), t0 + (t1 - t0) * f0, t0 + (t1 - t0) * f1));
5045            }
5046        }
5047        // Each piece's samples, in the ring's sense, ends on its corners
5048        // exactly.
5049        let starts: Vec<Point> = pieces
5050            .iter()
5051            .map(|(curve, a, _)| curve.point_at(*a, tol))
5052            .collect::<OgeomResult<_>>()?;
5053        let count = pieces.len();
5054        // A piece at a fraction of its range, its ends on its corners
5055        // exactly.
5056        let piece_at = |pi: usize, f: f64| -> OgeomResult<Point> {
5057            let (curve, a, b) = &pieces[pi];
5058            if f <= 0.0 {
5059                Ok(starts[pi])
5060            } else if f >= 1.0 {
5061                Ok(starts[(pi + 1) % count])
5062            } else {
5063                curve.point_at(a + (b - a) * f, tol)
5064            }
5065        };
5066        let along_of = |pi: usize| -> usize {
5067            if matches!(pieces[pi].0, ogeom_geom::Curve::Line(_)) {
5068                8
5069            } else {
5070                24
5071            }
5072        };
5073        // A piece's strip over one segment: the piece's points screwed
5074        // through the segment, the station index (an even step of the
5075        // turn) its parameter across, known between the stations as at
5076        // them.
5077        let skin_of = |pi: usize, seg: usize| -> OgeomResult<Skin<'_>> {
5078            Skin::swept(
5079                move |f, s| screw(piece_at(pi, f)?, theta_at(seg, s)),
5080                fractions(along_of(pi)),
5081                (0, PER_SEGMENT),
5082                false,
5083            )
5084        };
5085
5086        // A vertex set at every segment boundary.
5087        let mut corners: Vec<Vec<Shape>> = Vec::with_capacity(segments + 1);
5088        for b in 0..=segments {
5089            let theta = if b == segments {
5090                total
5091            } else {
5092                theta_at(b, 0.0)
5093            };
5094            let mut set = Vec::with_capacity(count);
5095            for p in &starts {
5096                set.push(ogeom_algo::make_vertex(model, screw(*p, theta)?).shape);
5097            }
5098            corners.push(set);
5099        }
5100        let mut bottoms = Vec::with_capacity(count);
5101        let mut tops = Vec::with_capacity(count);
5102        // Per edge, the previous segment's top border, for the next to
5103        // start on.
5104        let mut held_tops: Vec<Option<Shape>> = vec![None; count];
5105        for seg in 0..segments {
5106            #[allow(clippy::cast_precision_loss)]
5107            let hint = screw(centre, theta_at(seg, (PER_SEGMENT / 2) as f64))?;
5108            let mut first_rail: Option<Shape> = None;
5109            let mut prev_rail: Option<Shape> = None;
5110            for ei in 0..count {
5111                let skin = skin_of(ei, seg)?;
5112                let next = (ei + 1) % count;
5113                let last_rail = if ei + 1 == count {
5114                    first_rail.clone()
5115                } else {
5116                    None
5117                };
5118                let (from, to) = (&corners[seg], &corners[seg + 1]);
5119                let strip = skinned_strip(
5120                    model,
5121                    &skin,
5122                    (&from[ei], &from[next], &to[ei], &to[next]),
5123                    [
5124                        held_tops[ei].as_ref(),
5125                        None,
5126                        prev_rail.as_ref(),
5127                        last_rail.as_ref(),
5128                    ],
5129                    hint,
5130                    hole,
5131                    tolerance,
5132                    tol,
5133                )?;
5134                if ei == 0 {
5135                    first_rail = Some(strip.rail0.clone());
5136                }
5137                prev_rail = Some(strip.rail1.clone());
5138                faces.push(strip.face.clone());
5139                if seg == 0 {
5140                    bottoms.push(strip.bottom.clone());
5141                }
5142                if seg + 1 == segments {
5143                    tops.push(strip.top.clone());
5144                }
5145                held_tops[ei] = Some(strip.top);
5146            }
5147        }
5148        cap_loops[0].push(bottoms);
5149        cap_loops[1].push(tops);
5150    }
5151
5152    // The caps: the profile's plane where it starts, and that plane
5153    // screwed on to where it ends.
5154    for (end, loops) in cap_loops.iter().enumerate() {
5155        let theta = if end == 0 { 0.0 } else { total };
5156        let centre = centre_of(model, profile, tol)?;
5157        let at = screw(centre, theta)?;
5158        // The profile's plane, turned with it: out of the solid, back
5159        // against the motion at the start and on with it at the end.
5160        let normal = {
5161            let (sin, cos) = (sense * theta).sin_cos();
5162            let along = z * n.dot(z);
5163            let square = n - along;
5164            let turned = along + square * cos + z.cross(square) * sin;
5165            let forward = if turned.dot(travel(at)) >= 0.0 {
5166                turned
5167            } else {
5168                -turned
5169            };
5170            if end == 0 { -forward } else { forward }
5171        };
5172        let cap_plane = Plane::through(at, Direction::new(normal, tol)?);
5173        let mut reach = 1.0_f64;
5174        for edges in loops {
5175            for edge in edges {
5176                let (curve, range) = spine_curve_of(model, edge)?;
5177                for k in 0..8 {
5178                    let p =
5179                        curve.point_at(range.0 + (range.1 - range.0) * f64::from(k) / 8.0, tol)?;
5180                    reach = reach.max(p.distance(at) * 2.0);
5181                }
5182            }
5183        }
5184        let surface: SurfaceGeometry =
5185            PlaneSurface::over(cap_plane, (-reach, reach), (-reach, reach))?.into();
5186        // The material on the left of each ring about the outward normal:
5187        // the profile's first ring is its outer one.
5188        let mut wires = Vec::with_capacity(loops.len());
5189        for (li, edges) in loops.iter().enumerate() {
5190            let ring = walked_about(model, edges, normal, li == 0, tol)?;
5191            wires.push(ogeom_algo::make_wire(model, &ring, tol)?.shape);
5192        }
5193        let face = ogeom_algo::make_face(model, surface, &wires, tol)?.shape;
5194        let cap_id = {
5195            let Some(ogeom_topo::NodeData::Face(data)) = model.node(&face).map(|n| n.data()) else {
5196                ogeom_bail!(Construction, "the cap holds no face data");
5197            };
5198            data.surface
5199        };
5200        let frame = cap_plane.frame();
5201        for edges in loops {
5202            for edge in edges {
5203                let (curve, range) = spine_curve_of(model, edge)?;
5204                let ogeom_geom::Curve::BSpline(bs) = &curve else {
5205                    ogeom_bail!(Construction, "a swept ring is not a spline");
5206                };
5207                let control2: Vec<Point2> = bs
5208                    .control_points()
5209                    .iter()
5210                    .map(|w| {
5211                        let local = frame.to_local(w.point());
5212                        Point2::new(local.x, local.y)
5213                    })
5214                    .collect();
5215                let pcurve: ogeom_geom::PlanarCurve =
5216                    ogeom_geom::BSpline2d::new(bs.knots().clone(), control2, tol)?.into();
5217                ogeom_algo::attach_pcurve(
5218                    model,
5219                    edge,
5220                    pcurve,
5221                    cap_id,
5222                    ogeom_topo::Location::identity(),
5223                    range,
5224                )?;
5225            }
5226        }
5227        faces.push(face);
5228    }
5229
5230    let sewn = sew(model, &faces, tol)?;
5231    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
5232        ogeom_bail!(Construction, "the helical sweep did not close");
5233    }
5234    let solid = make_solid(model, &sewn.shells)?.shape;
5235    let mut history = History::new();
5236    history.generate(profile, solid.clone());
5237    state_new_gaps(model, std::slice::from_ref(profile), &solid, tol)?;
5238    Ok(Built::new(solid, history))
5239}
5240
5241/// Revolve `profile` (a planar face) about `axis`, each point turning the
5242/// right-handed way about it until its circle first meets the surface of
5243/// `limit`, a face taken as its whole surface the way
5244/// [`make_half_space`](ogeom_algo::make_half_space) takes it: the revolution
5245/// "up to face", stopping on the target at a different angle for each
5246/// point.
5247///
5248/// Built as the half turn of the profile kept on the profile's side of the
5249/// limit: exact wherever every circle meets the surface within that half
5250/// turn and does not come back to the profile's side before it ends.
5251///
5252/// # Errors
5253///
5254/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
5255/// profile is not a planar face whose plane holds the axis, a point's
5256/// circle never meets the surface, or one meets it only past half a turn or
5257/// crosses it back within the half turn; as the revolution, the half space
5258/// and the boolean otherwise.
5259pub fn make_revolution_until(
5260    model: &mut Model,
5261    profile: &Shape,
5262    axis: ogeom_math::Axis,
5263    limit: &Shape,
5264    tol: Tolerances,
5265) -> OgeomResult<Built> {
5266    use ogeom_geom::Surface as _;
5267    if model.kind_of(profile)? != ShapeType::Face {
5268        ogeom_bail!(
5269            Construction,
5270            "a revolution up to a face revolves a planar face"
5271        );
5272    }
5273    if model.kind_of(limit)? != ShapeType::Face {
5274        ogeom_bail!(Construction, "a revolution stops on a face");
5275    }
5276    let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
5277        ogeom_bail!(
5278            Construction,
5279            "a revolution up to a face revolves a planar face"
5280        );
5281    };
5282    let z = axis.direction.vector();
5283    if plane.normal().vector().dot(z).abs() > tol.angular()
5284        || plane.distance_to(axis.location) > tol.confusion() * 100.0
5285    {
5286        ogeom_bail!(
5287            Construction,
5288            "the profile's plane does not hold the axis; a revolution up to a \
5289             face turns a profile about an axis in its own plane"
5290        );
5291    }
5292    // The limit's surface, placed, and which side of it a point stands.
5293    let (surface, flip) = {
5294        let Some(data) = model.node(limit).and_then(|n| n.data().as_face()) else {
5295            ogeom_bail!(Construction, "the limit holds no face data");
5296        };
5297        let Some(surface) = model.geometry().surface(data.surface) else {
5298            ogeom_bail!(Dangling, "the limit's surface is not in this model");
5299        };
5300        (
5301            surface
5302                .clone()
5303                .transformed(&limit.transform(model.datums())?, tol)?,
5304            limit.orientation() == ogeom_topo::Orientation::Reversed,
5305        )
5306    };
5307    let side = |p: Point| -> OgeomResult<f64> {
5308        let foot = ogeom_algo::project_on_surface(&surface, p, 16, tol)?;
5309        let (u, v) = foot.parameters;
5310        let n = surface.normal_at(u, v, tol)?.vector();
5311        let s = (p - foot.point).dot(n);
5312        Ok(if flip { -s } else { s })
5313    };
5314    // Every boundary point's circle: its first meeting within half a turn,
5315    // and no return to the profile's side before the half turn ends.
5316    let turn_of = |p: Point, theta: f64| Transform::rotation(axis, theta).apply(p);
5317    let mut rings = Vec::new();
5318    for wire in explore(model, profile, Filter::OfType(ShapeType::Wire))? {
5319        rings.extend(sample_wire(model, &wire, 64, tol)?);
5320    }
5321    let start_side = side(centre_of(model, profile, tol)?)?.signum();
5322    const STEPS: u32 = 360;
5323    for p in &rings {
5324        let mut met: Option<f64> = None;
5325        for k in 1..=STEPS {
5326            let theta = core::f64::consts::TAU * f64::from(k) / f64::from(STEPS);
5327            let here = side(turn_of(*p, theta))?;
5328            match met {
5329                None if here.signum() != start_side && here.abs() > tol.confusion() => {
5330                    met = Some(theta);
5331                }
5332                Some(first)
5333                    if theta <= core::f64::consts::PI
5334                        && here.signum() == start_side
5335                        && here.abs() > tol.confusion() =>
5336                {
5337                    ogeom_bail!(
5338                        Construction,
5339                        "the circle through {p:?} meets the limit at {first} and \
5340                         crosses back within half a turn; a revolution up to it \
5341                         is not built there"
5342                    );
5343                }
5344                _ => {}
5345            }
5346        }
5347        match met {
5348            None => ogeom_bail!(
5349                Construction,
5350                "the circle through {p:?} never meets the limit's surface"
5351            ),
5352            Some(first) if first > core::f64::consts::PI => ogeom_bail!(
5353                Construction,
5354                "the circle through {p:?} meets the limit only past half a turn"
5355            ),
5356            _ => {}
5357        }
5358    }
5359    let half = ogeom_algo::make_revolution(model, profile, axis, core::f64::consts::PI, tol)?;
5360    let inside = centre_of(model, profile, tol)?;
5361    let bound = ogeom_algo::make_half_space(model, limit, inside, tol)?.shape;
5362    let mut built = ogeom_bool::common(model, &half.shape, &bound, tol)?;
5363    built.history.generate(profile, built.shape.clone());
5364    Ok(built)
5365}
5366
5367/// The centroid of a face's outer ring's samples.
5368fn centre_of(model: &Model, profile: &Shape, tol: Tolerances) -> OgeomResult<Point> {
5369    let Some(wire) = explore(model, profile, Filter::OfType(ShapeType::Wire))?
5370        .into_iter()
5371        .next()
5372    else {
5373        ogeom_bail!(Construction, "the profile has no loop");
5374    };
5375    let samples = sample_wire(model, &wire, 32, tol)?;
5376    #[allow(clippy::cast_precision_loss)]
5377    let n = samples.len() as f64;
5378    let sum = samples
5379        .iter()
5380        .fold(Vector::new(0.0, 0.0, 0.0), |acc, p| acc + p.to_vector());
5381    Ok(Point::from_vector(sum / n))
5382}
5383
5384/// One edge of a spine of lines and arcs: where it starts, its heading at
5385/// either end, and the motion that carries the section down it.
5386struct Leg {
5387    start: Point,
5388    heading: (Vector, Vector),
5389    motion: Transform,
5390    along: LegKind,
5391}
5392
5393/// How a [`Leg`] carries its section: by its displacement, or about the
5394/// arc's axis (through its centre, turning the way the walk does) by its
5395/// angle.
5396#[derive(Clone, Copy)]
5397enum LegKind {
5398    Line(Vector),
5399    Arc(ogeom_math::Axis, f64),
5400}
5401
5402/// The pipe along a spine of lines and circular arcs meeting tangent to
5403/// one another, built exactly: down a line the section is extruded, round
5404/// an arc it is revolved about the arc's axis (the rotation-minimizing
5405/// frame of a circle is its own rotation), and the legs are fused on the
5406/// sections they share. Every wall is then the closed form its profile
5407/// edge sweeps: a plane, drum, cone, ball or torus where the edge is a
5408/// line or circle. `None` where the spine or profile is not of that kind,
5409/// for the general construction to take.
5410fn exact_legs(
5411    model: &mut Model,
5412    profile: &Shape,
5413    spine: &Shape,
5414    frenet: bool,
5415    tolerance: f64,
5416    tol: Tolerances,
5417) -> OgeomResult<Option<Built>> {
5418    use ogeom_geom::{Curve, Curve3d as _};
5419    let edges: Vec<Shape> = match model.kind_of(spine)? {
5420        ShapeType::Edge => vec![spine.clone()],
5421        ShapeType::Wire => model.ordered_children_of(spine)?,
5422        _ => return Ok(None),
5423    };
5424    let solid = match model.kind_of(profile)? {
5425        ShapeType::Face => true,
5426        ShapeType::Wire => false,
5427        _ => return Ok(None),
5428    };
5429    if edges.is_empty() || (!solid && edges.len() > 1) {
5430        return Ok(None);
5431    }
5432    let mut legs = Vec::with_capacity(edges.len());
5433    for edge in &edges {
5434        let (curve, range) = spine_curve_of(model, edge)?;
5435        let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
5436        let (t0, t1) = if reversed { (range.1, range.0) } else { range };
5437        let (a, b) = (curve.point_at(t0, tol)?, curve.point_at(t1, tol)?);
5438        let sense = if reversed { -1.0 } else { 1.0 };
5439        let heading = (curve.d1_at(t0, tol)? * sense, curve.d1_at(t1, tol)? * sense);
5440        let basis = match &curve {
5441            Curve::Trimmed(t) => t.basis().clone(),
5442            other => other.clone(),
5443        };
5444        let (motion, along) = match basis {
5445            Curve::Line(_) => {
5446                if frenet {
5447                    return Ok(None);
5448                }
5449                (Transform::translation(b - a), LegKind::Line(b - a))
5450            }
5451            Curve::Circle(c) => {
5452                let frame = c.circle().frame();
5453                // Turning the way the walk heads at its start.
5454                let turn = (a - frame.origin()).cross(heading.0);
5455                let direction = if turn.dot(frame.z().vector()) > 0.0 {
5456                    frame.z()
5457                } else {
5458                    -frame.z()
5459                };
5460                let axis = ogeom_math::Axis {
5461                    location: frame.origin(),
5462                    direction,
5463                };
5464                let angle = (t1 - t0).abs();
5465                (Transform::rotation(axis, angle), LegKind::Arc(axis, angle))
5466            }
5467            _ => return Ok(None),
5468        };
5469        legs.push(Leg {
5470            start: a,
5471            heading,
5472            motion,
5473            along,
5474        });
5475    }
5476    // Legs meet tangent, or all are straight and their corners are mitred
5477    // exactly below, or a face turns corners in the planes of the arcs
5478    // beside them, a disc as its legs' union and any other face as its
5479    // walls; any other corner is mitred by the general path.
5480    let cornered = legs.windows(2).any(|pair| {
5481        let (x, y) = (pair[0].heading.1, pair[1].heading.0);
5482        x.cross(y).magnitude() > tol.angular() * x.magnitude() * y.magnitude() || x.dot(y) <= 0.0
5483    });
5484    if cornered {
5485        let lines: Option<Vec<(Point, Vector)>> = legs
5486            .iter()
5487            .map(|leg| match leg.along {
5488                LegKind::Line(v) => Some((leg.start, v)),
5489                LegKind::Arc(..) => None,
5490            })
5491            .collect();
5492        return match lines {
5493            Some(lines) if solid && !frenet => mitred_lines(model, profile, spine, &lines, tol),
5494            None if solid && !frenet => {
5495                match mitred_arcs::mitred_arcs(model, profile, spine, &legs, tol)? {
5496                    Some(built) => Ok(Some(built)),
5497                    None => open_lines::face_legs(model, profile, spine, tolerance, tol),
5498                }
5499            }
5500            _ => Ok(None),
5501        };
5502    }
5503    // The profile square to the spine's exact start tangent, and on it.
5504    let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
5505        return Ok(None);
5506    };
5507    let t0 = legs[0].heading.0;
5508    if plane.normal().vector().cross(t0).magnitude() > tol.angular() * t0.magnitude()
5509        || plane.distance_to(legs[0].start) > tol.confusion() * 100.0
5510    {
5511        return Ok(None);
5512    }
5513
5514    let mut joined: Vec<Shape> = Vec::new();
5515    let mut carried = Transform::IDENTITY;
5516    for leg in &legs {
5517        // Rebuilt even where it stands: the caps are this face, and it
5518        // carries its trims on its own plane.
5519        let heading = carried.apply_vector(legs[0].heading.0);
5520        let section = realized_profile_wound(model, profile, &carried, Some(heading), tol)?;
5521        let piece = match leg.along {
5522            LegKind::Line(v) => ogeom_algo::make_prism(model, &section, v, tol)?.shape,
5523            LegKind::Arc(axis, angle) => {
5524                ogeom_algo::make_revolution(model, &section, axis, angle, tol)?.shape
5525            }
5526        };
5527        carried = leg.motion * carried;
5528        joined.push(piece);
5529    }
5530    let result = fuse_in_order(model, joined, tol)?;
5531    Ok(result.map(|shape| {
5532        let mut history = History::new();
5533        history.generate(spine, shape.clone());
5534        history.generate(profile, shape.clone());
5535        Built::new(shape, history)
5536    }))
5537}
5538
5539/// A face swept down a spine of straight legs with corners, exactly: each
5540/// leg is the profile's prism, run on past its corners and trimmed by the
5541/// mitre plane that halves each corner, and the pieces are fused. The
5542/// section turns at each corner by the least rotation taking one leg's
5543/// heading to the next's, as the rotation-minimizing frame does, and every
5544/// wall is the closed form its profile edge sweeps along a line.
5545///
5546/// `None` where the spine closes on itself (a ring's section may come back
5547/// turned, which the general construction settles), a corner all but
5548/// doubles back, or a leg is too short for the corners at its ends to trim
5549/// it apart.
5550fn mitred_lines(
5551    model: &mut Model,
5552    profile: &Shape,
5553    spine: &Shape,
5554    legs: &[(Point, Vector)],
5555    tol: Tolerances,
5556) -> OgeomResult<Option<Built>> {
5557    if let (Some(first), Some(last)) = (legs.first(), legs.last())
5558        && first.0.distance(last.0 + last.1) <= tol.confusion() * 100.0
5559    {
5560        return Ok(None);
5561    }
5562    let bounds = ogeom_algo::shape_bounds(model, profile, tol)?;
5563    let (Some(low), Some(high)) = (bounds.low(), bounds.high()) else {
5564        return Ok(None);
5565    };
5566    let width = low.distance(high);
5567    let unit = |v: Vector| v / v.magnitude();
5568    // Each corner's mitre: its point and the normal of the plane halving it,
5569    // pointing on along the next leg.
5570    let mut mitres: Vec<(Point, Vector)> = Vec::with_capacity(legs.len().saturating_sub(1));
5571    for pair in legs.windows(2) {
5572        let (d0, d1) = (unit(pair[0].1), unit(pair[1].1));
5573        // A corner turning past a right angle and a half reaches too far
5574        // along its legs for the mitre to stay on them.
5575        if d0.dot(d1) < DOUBLED_BACK {
5576            return Ok(None);
5577        }
5578        mitres.push((pair[1].0, unit(d0 + d1)));
5579    }
5580    // How far a section reaches along its leg from a corner's mitre.
5581    let reach = width * 2.0;
5582    for (i, (_, v)) in legs.iter().enumerate() {
5583        let corners = f64::from(u8::from(i > 0) + u8::from(i + 1 < legs.len()));
5584        if v.magnitude() <= reach * corners * 0.5 {
5585            return Ok(None);
5586        }
5587    }
5588
5589    let mut carried = Transform::IDENTITY;
5590    let mut joined: Vec<Shape> = Vec::new();
5591    for (i, &(_, v)) in legs.iter().enumerate() {
5592        let d = unit(v);
5593        let before = if i > 0 { reach } else { 0.0 };
5594        let after = if i + 1 < legs.len() { reach } else { 0.0 };
5595        let section = realized_profile_wound(
5596            model,
5597            profile,
5598            &(Transform::translation(-d * before) * carried),
5599            Some(d),
5600            tol,
5601        )?;
5602        let mut piece =
5603            ogeom_algo::make_prism(model, &section, v + d * (before + after), tol)?.shape;
5604        // Trimmed back to the mitres at either end.
5605        for (at, normal, keep_ahead) in [
5606            (i > 0).then(|| (mitres[i - 1].0, mitres[i - 1].1, true)),
5607            (i + 1 < legs.len()).then(|| (mitres[i].0, mitres[i].1, false)),
5608        ]
5609        .into_iter()
5610        .flatten()
5611        {
5612            let plane = Plane::through(at, Direction::new(normal, tol)?);
5613            let face = ogeom_algo::make_natural_face(
5614                model,
5615                SurfaceGeometry::Plane(ogeom_geom::PlaneSurface::over(
5616                    plane,
5617                    (-reach * 4.0 - width, reach * 4.0 + width),
5618                    (-reach * 4.0 - width, reach * 4.0 + width),
5619                )?),
5620            )?
5621            .shape;
5622            let side = if keep_ahead { normal } else { -normal };
5623            let half = ogeom_algo::make_half_space(model, &face, at + side * width, tol)?.shape;
5624            piece = ogeom_bool::common(model, &piece, &half, tol)?.shape;
5625        }
5626        // On to the next leg: along this one, then turned at the corner by
5627        // the least rotation between the headings.
5628        carried = Transform::translation(v) * carried;
5629        if let Some(&(corner, next)) = legs.get(i + 1) {
5630            let n = unit(next);
5631            let axis = d.cross(n);
5632            if axis.magnitude() > tol.angular() {
5633                let angle = d.dot(n).clamp(-1.0, 1.0).acos();
5634                let rotation = Transform::rotation(
5635                    ogeom_math::Axis {
5636                        location: corner,
5637                        direction: Direction::new(axis, tol)?,
5638                    },
5639                    angle,
5640                );
5641                carried = rotation * carried;
5642            }
5643        }
5644        joined.push(piece);
5645    }
5646    let result = fuse_in_order(model, joined, tol)?;
5647    Ok(result.map(|shape| {
5648        let mut history = History::new();
5649        history.generate(spine, shape.clone());
5650        history.generate(profile, shape.clone());
5651        Built::new(shape, history)
5652    }))
5653}
5654
5655/// Sweep a planar profile (a wire, or a face whose holes ride along)
5656/// down an arbitrary spine, one skinned wall per profile loop.
5657///
5658/// The spine may be a single edge or a wire of edges of any curve the
5659/// vocabulary evaluates: lines, arcs, splines, helices. Frames along it are
5660/// rotation-minimizing by default (the double-reflection construction), so
5661/// the profile neither twists nor kinks where the spine bends; `frenet`
5662/// asks for the Frenet frame instead, which turns with the spine's own
5663/// curvature, the law a thread wants. Stations are placed by each edge's
5664/// own turning, the skin holds every transported section to `tolerance`
5665/// (along the profile between its samples as well as at them, the
5666/// sampling refined where the skin misses), and the caps sit
5667/// perpendicular to the spine's ends, holes and all.
5668///
5669/// Each spine edge skins its own run of wall, and neighbouring runs share
5670/// the section where their edges meet, so a join where the curvature steps
5671/// (an arc running on into its tangent line) is followed exactly rather
5672/// than smoothed by one fit across it. A run whose sections all lie in one
5673/// plane is that plane.
5674///
5675/// A sharp corner is mitred. Between straight legs the mitre is a plane and
5676/// each wall is sheared onto it; where a leg is curved the two legs' walls
5677/// end on the crossing of their generators (each profile point's own path
5678/// down either leg, run straight on past the corner), which is exact where
5679/// the corner turns in the leg's plane; there a disc centred on an open
5680/// spine is the union of its legs' cylinders and tori and their straight
5681/// run-ons outside the turn, cut on the mitre plane, and any other face on
5682/// an open spine of lines and arcs is the solid its rings' closed-form
5683/// walls bound, each cut on the mitre plane outside the turn and on the
5684/// crossing of its points' paths inside it. Where it turns a
5685/// curved leg out of its plane the generators miss, and the spine is swept
5686/// in pieces instead: each side runs on straight past the corner, is
5687/// trimmed by the mitre plane, and the pieces are fused, the difference
5688/// between their sections standing as a face of the mitre plane.
5689///
5690/// # Errors
5691///
5692/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
5693/// profile is not planar, leans along the spine, or does not sit at the
5694/// spine's start; if `frenet` is asked of a spine that never bends, or of a
5695/// cornered one; if a wire (not a face) is swept round a corner that turns
5696/// a curved leg out of its plane, which only solid pieces can mitre; if the
5697/// spine all but doubles back at a corner; or if a leg is shorter than its
5698/// corner's reach.
5699/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the skin
5700/// cannot reach the tolerance.
5701pub fn make_pipe_shell(
5702    model: &mut Model,
5703    profile: &Shape,
5704    spine: &Shape,
5705    frenet: bool,
5706    tolerance: f64,
5707    tol: Tolerances,
5708) -> OgeomResult<Built> {
5709    let law = if frenet {
5710        PipeLaw::Frenet
5711    } else {
5712        PipeLaw::RotationMinimizing
5713    };
5714    make_pipe_shell_with(
5715        model,
5716        profile,
5717        spine,
5718        &law,
5719        PipeCorners::Mitre,
5720        tolerance,
5721        tol,
5722    )
5723}
5724
5725/// How a pipe's section turns about its spine.
5726#[derive(Debug, Clone, Copy)]
5727pub enum PipeLaw<'a> {
5728    /// Double-reflection rotation-minimizing frames: the section neither
5729    /// twists nor kinks where the spine bends.
5730    RotationMinimizing,
5731    /// The Frenet frame, turning with the spine's own curvature.
5732    Frenet,
5733    /// The section's normal axis points at `guide`, a curve running beside
5734    /// the spine: at each station, where the guide crosses the plane square
5735    /// to the spine there.
5736    Auxiliary {
5737        /// An edge or wire beside the spine.
5738        guide: &'a Shape,
5739    },
5740    /// The section keeps this direction as its binormal, square to the
5741    /// spine's tangent.
5742    Binormal(Direction),
5743    /// The section keeps the frame it has at the spine's start all the
5744    /// way: carried by translation along the spine, never turned.
5745    Fixed,
5746}
5747
5748/// How a pipe turns a sharp corner of its spine.
5749#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
5750pub enum PipeCorners {
5751    /// The walls sheared onto the plane halving the corner.
5752    #[default]
5753    Mitre,
5754    /// Each leg runs on straight past the corner until its section clears
5755    /// the other leg's far side, and the legs are fused: the outside of the
5756    /// corner square.
5757    Extended,
5758    /// Each leg ends square at the corner and the section turns about the
5759    /// corner through its angle, joining the two ends: the outside of the
5760    /// corner rounded.
5761    ///
5762    /// A face's corner is the union of the legs and its section's half
5763    /// outside the turn revolved about the corner's axis, and a closed
5764    /// wire's is that solid's walls. An open wire bounds no solid: its
5765    /// sheet is rounded point by point, each point of the section outside
5766    /// the turn turning about the axis and each point inside cut on the
5767    /// plane halving the corner. A closed wire split into open ones sweeps
5768    /// its walls so only where each point of it inside the turn reaches
5769    /// the corner's axis across the section's interior; a section turned
5770    /// about the spine so that its walls inside the turn are covered by
5771    /// the other leg (a tilted square) does not.
5772    Round,
5773}
5774
5775/// [`make_pipe_shell`] with a frame law: the section turns about the spine
5776/// the way `law` says.
5777///
5778/// # Errors
5779///
5780/// As [`make_pipe_shell_with`].
5781pub fn make_pipe_shell_law(
5782    model: &mut Model,
5783    profile: &Shape,
5784    spine: &Shape,
5785    law: PipeLaw<'_>,
5786    tolerance: f64,
5787    tol: Tolerances,
5788) -> OgeomResult<Built> {
5789    make_pipe_shell_with(
5790        model,
5791        profile,
5792        spine,
5793        &law,
5794        PipeCorners::Mitre,
5795        tolerance,
5796        tol,
5797    )
5798}
5799
5800/// [`make_pipe_shell`] with a frame law and a way of turning corners.
5801///
5802/// Extended and round corners, and the auxiliary and binormal laws round
5803/// a sharp corner, sweep a face run by run (as do extended and round
5804/// corners under the Frenet frame): the spine is cut at its sharp
5805/// corners, each smooth run swept on its own (a straight run whose section
5806/// keeps its frame as the prism of it), and the runs joined at each corner,
5807/// open spine or closed. A mitre runs each side on straight past the
5808/// corner and trims it by the plane halving the corner; an extended corner
5809/// runs each side on until its section clears the other's far side; a
5810/// round corner turns the half of the section outside the corner about the
5811/// corner's axis. The rotation-minimizing frame carries the section across
5812/// a corner by that turn; the Frenet, auxiliary and binormal laws place it
5813/// themselves (the Frenet frame by each run's own curvature), so their runs may meet a mitre with different sections, the
5814/// difference standing as a face of the mitre plane. A straight run
5815/// meeting a skinned run at a mitre takes, for each ring the two share
5816/// within `tolerance`, the skin's own fit of it, so the two cut the mitre
5817/// plane alike. A closed wire sweeps the walls of the face it bounds, the
5818/// end caps taken off. Between straight legs
5819/// with the rotation-minimizing frame on an open spine, every corner is
5820/// built as before: each leg a prism and each round corner the section
5821/// revolved. An auxiliary or binormal law round a closed spine with no
5822/// sharp corner sweeps as the rotation-minimizing frame does where its
5823/// frames are those turned by a fixed angle; elsewhere its own frames,
5824/// single-valued round the spine, are skinned round it and held to
5825/// `tolerance` against the law, each hole a void tunnel of its own shell.
5826/// Under those laws each hole of a face swept along an open spine sweeps
5827/// its own solid, cut from the outer one. On a spine with no sharp corner
5828/// every way of turning corners is the same solid. An open wire down an
5829/// open spine of lines and arcs under the rotation-minimizing frame sweeps
5830/// one exact sheet: each line leg the prism of its section and each arc
5831/// leg its revolution about the arc's axis, run on straight past each
5832/// mitred corner and cut on the plane halving it, and at a round corner
5833/// the section's part outside the turn revolved about the corner. Inside a
5834/// corner each leg's wall runs on until it meets the other's: on the plane
5835/// halving a corner between two lines, and on the conic a straight profile
5836/// edge's two walls share where a corner against an arc turns in the arc's
5837/// plane, or on a curve fitted through a curved edge's points' crossings,
5838/// its error stated as its tolerance and held to `tolerance`. Round a
5839/// closed spine of lines and arcs an open wire sweeps a ring of that
5840/// sheet, from the middle of the first leg round to there, where it closes
5841/// on itself, the corner where the spine closes turned as any other. The
5842/// Frenet (round corners only), auxiliary and binormal laws sweep an open
5843/// wire round sharp corners where their frames are the
5844/// rotation-minimizing ones turned by a fixed angle, as that sheet. Down
5845/// an open spine with a leg that is neither a line nor an arc, or under a
5846/// law whose frames are not those turned by a fixed angle along a spine
5847/// with no sharp corner, an open wire sweeps one sheet of fitted skins held
5848/// to `tolerance`: each profile edge's strip run by run, neighbouring
5849/// strips sharing their rails and neighbouring runs their rows, nothing
5850/// capping the ends. Round its sharp corners each profile point follows
5851/// the exact sheet's rules: outside a mitred corner against a curved leg
5852/// its end row runs on straight to the plane halving the corner, as two
5853/// ruled faces sharing their cut; outside a round one it turns about the
5854/// corner's axis; inside either it runs on until its paths down the two
5855/// legs cross.
5856///
5857/// # Errors
5858///
5859/// As [`make_pipe_shell`], and
5860/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) where
5861/// the law or the corners are asked of a spine they do not serve: an open
5862/// wire swept round a corner run by run, or round an extended corner, or
5863/// down legs too short for their corners' reach, or into itself, or round
5864/// a round corner turning so little that its round falls within the
5865/// confusion distance of its square end, or reaching inside a corner that
5866/// turns an arc out of its plane, or so far that a point's two paths there miss each other
5867/// or meet past a leg's end, or inside a corner against a curved leg whose
5868/// paths pass each other, or under a law that turns its section other than the
5869/// rotation-minimizing frame does round a sharp corner, the Frenet
5870/// frame along a straight run
5871/// between sharp corners, a round corner where the law sets the next run's
5872/// section other than the first's turned about the corner (or the frame
5873/// comes home turned round a closed spine); an open wire round a closed
5874/// spine with an edge neither a line nor an arc, or round which the
5875/// rotation-minimizing frame comes home turned; where the guide does not
5876/// cross a station's plane, or the spine's tangent runs along the binormal
5877/// or toward the guide.
5878#[allow(clippy::too_many_arguments)]
5879pub fn make_pipe_shell_with(
5880    model: &mut Model,
5881    profile: &Shape,
5882    spine: &Shape,
5883    law: &PipeLaw<'_>,
5884    corners: PipeCorners,
5885    tolerance: f64,
5886    tol: Tolerances,
5887) -> OgeomResult<Built> {
5888    pipe_shell_ends(model, profile, spine, law, corners, false, tolerance, tol)
5889}
5890
5891/// [`make_pipe_shell_with`], and where `exact_ends` asks it, a pipe swept
5892/// by a skin under the auxiliary or binormal law ends on its end sections
5893/// exactly wherever their loops have an exact rational form (see
5894/// [`Carried`]).
5895#[allow(clippy::too_many_arguments)]
5896pub(crate) fn pipe_shell_ends(
5897    model: &mut Model,
5898    profile: &Shape,
5899    spine: &Shape,
5900    law: &PipeLaw<'_>,
5901    corners: PipeCorners,
5902    exact_ends: bool,
5903    tolerance: f64,
5904    tol: Tolerances,
5905) -> OgeomResult<Built> {
5906    // The spine and an auxiliary guide are swept where they stand.
5907    let placed = placed_path(model, spine, tol)?;
5908    let guide = match law {
5909        PipeLaw::Auxiliary { guide } => Some(placed_path(model, guide, tol)?),
5910        _ => None,
5911    };
5912    let placed_law = match (law, &guide) {
5913        (PipeLaw::Auxiliary { .. }, Some(guide)) => PipeLaw::Auxiliary { guide },
5914        _ => *law,
5915    };
5916    let guide_moved =
5917        matches!((law, &guide), (PipeLaw::Auxiliary { guide: given }, Some(g)) if *given != g);
5918    if placed != *spine || guide_moved {
5919        let mut built = pipe_shell_ends(
5920            model,
5921            profile,
5922            &placed,
5923            &placed_law,
5924            corners,
5925            exact_ends,
5926            tolerance,
5927            tol,
5928        )?;
5929        built.history.generate(spine, built.shape.clone());
5930        return Ok(built);
5931    }
5932    // A section carried by translation turns no corner: every way of
5933    // turning one is the same solid.
5934    if matches!(law, PipeLaw::Fixed) {
5935        return crate::fixed::fixed_pipe(model, profile, spine, tol);
5936    }
5937    // Whether a wire closes is asked of it as stored: walked backward it
5938    // closes or not as it does.
5939    let stored = match profile.orientation() {
5940        ogeom_topo::Orientation::Reversed => profile.reversed(),
5941        _ => profile.clone(),
5942    };
5943    if model.kind_of(profile)? == ShapeType::Wire
5944        && !ogeom_algo::is_wire_closed(model, &stored, tol)?
5945        && !spine_closes(model, spine, tol)?
5946    {
5947        if !open_lines::lines_and_arcs(model, spine)? {
5948            return open_skinned::open_wire_skinned(
5949                model, profile, spine, law, corners, tolerance, tol,
5950            );
5951        }
5952        if matches!(law, PipeLaw::RotationMinimizing) {
5953            return open_lines::open_wire_legs(model, profile, spine, corners, tolerance, tol);
5954        }
5955        let sharp = has_sharp_corner(model, spine, tol)?;
5956        // The Frenet frame has no direction at a mitre, as for a face.
5957        if !(matches!(law, PipeLaw::Frenet) && corners == PipeCorners::Mitre) && sharp {
5958            return open_lines::open_wire_law(model, profile, spine, law, corners, tolerance, tol);
5959        }
5960        if !sharp {
5961            return open_skinned::open_wire_skinned(
5962                model, profile, spine, law, corners, tolerance, tol,
5963            );
5964        }
5965    }
5966    // Round a closed spine of lines and arcs an open wire sweeps a ring of
5967    // the same sheet.
5968    if model.kind_of(profile)? == ShapeType::Wire
5969        && !ogeom_algo::is_wire_closed(model, &stored, tol)?
5970        && spine_closes(model, spine, tol)?
5971    {
5972        if !open_lines::lines_and_arcs(model, spine)? {
5973            ogeom_bail!(
5974                Construction,
5975                "an open wire sweeps a ring round a closed spine of lines and arcs; \
5976                 round a closed spine with any other edge it is not built"
5977            );
5978        }
5979        if matches!(law, PipeLaw::RotationMinimizing) {
5980            return open_lines::open_wire_legs(model, profile, spine, corners, tolerance, tol);
5981        }
5982        let sharp = has_sharp_corner(model, spine, tol)? || closes_on_a_corner(model, spine, tol)?;
5983        if matches!(law, PipeLaw::Frenet) && corners == PipeCorners::Mitre && sharp {
5984            ogeom_bail!(
5985                Construction,
5986                "a Frenet frame has no direction at a mitred corner; an open wire \
5987                 round a cornered closed spine turns its corners round under it"
5988            );
5989        }
5990        return open_lines::open_wire_law(model, profile, spine, law, corners, tolerance, tol);
5991    }
5992    if corners != PipeCorners::Mitre
5993        && matches!(law, PipeLaw::RotationMinimizing)
5994        && model.kind_of(profile)? == ShapeType::Face
5995        && let Some(legs) = straight_legs(model, spine, tol)?
5996        && legs.len() > 1
5997        && !spine_closes(model, spine, tol)?
5998    {
5999        return cornered_lines(model, profile, spine, &legs, corners, tol);
6000    }
6001    let classic = matches!(law, PipeLaw::RotationMinimizing | PipeLaw::Frenet);
6002    let sharp = has_sharp_corner(model, spine, tol)? || closes_on_a_corner(model, spine, tol)?;
6003    if sharp && (corners != PipeCorners::Mitre || !classic) {
6004        return corners::law_cornered(model, profile, spine, law, corners, None, tolerance, tol);
6005    }
6006    if !classic && !sharp && spine_closes(model, spine, tol)? {
6007        // A law round a smooth closed spine whose frames are the
6008        // rotation-minimizing ones sweeps as that frame does.
6009        if !corners::law_is_rotation_minimizing(model, spine, law, tolerance, tol)? {
6010            // Any other law's frames are its own, single-valued round the
6011            // spine: the skin closes on them.
6012            let still = evolving::Ramp::level(0.0);
6013            let built =
6014                evolving::closed_law_pipe(model, profile, spine, law, &still, tolerance, tol)?;
6015            state_new_gaps(model, &[profile.clone(), spine.clone()], &built.shape, tol)?;
6016            return Ok(built);
6017        }
6018        return make_pipe_shell_with(
6019            model,
6020            profile,
6021            spine,
6022            &PipeLaw::RotationMinimizing,
6023            corners,
6024            tolerance,
6025            tol,
6026        );
6027    }
6028    let built = pipe_shell_law(model, profile, spine, law, exact_ends, tolerance, tol)?;
6029    state_new_gaps(model, &[profile.clone(), spine.clone()], &built.shape, tol)?;
6030    Ok(built)
6031}
6032
6033/// Whether the spine comes back to where it starts.
6034fn spine_closes(model: &Model, spine: &Shape, tol: Tolerances) -> OgeomResult<bool> {
6035    let stations = shell_stations(model, spine, tol)?;
6036    Ok(stations[0].at.distance(stations[stations.len() - 1].at) <= tol.confusion() * 10.0)
6037}
6038
6039/// Whether a closed spine turns sharply where it comes back to its start.
6040fn closes_on_a_corner(model: &Model, spine: &Shape, tol: Tolerances) -> OgeomResult<bool> {
6041    if !spine_closes(model, spine, tol)? {
6042        return Ok(false);
6043    }
6044    let stations = shell_stations(model, spine, tol)?;
6045    let (a, b) = (stations[stations.len() - 1].tangent, stations[0].tangent);
6046    Ok(a.cross(b).magnitude() > tol.angular() || a.dot(b) < 0.0)
6047}
6048
6049/// Refuse a profile on `plane` that does not stand square to the spine at
6050/// its start `at`: square to the start tangent, read exactly off the first
6051/// edge, to an angle's tolerance, and through the start point.
6052pub(crate) fn profile_square_at_start(
6053    model: &Model,
6054    plane: &Plane,
6055    spine: &Shape,
6056    at: Point,
6057    tol: Tolerances,
6058) -> OgeomResult<()> {
6059    let exact_t0 = {
6060        let first = match model.kind_of(spine)? {
6061            ShapeType::Edge => spine.clone(),
6062            _ => model.ordered_children_of(spine)?[0].clone(),
6063        };
6064        let (curve, range) = spine_curve_of(model, &first)?;
6065        let reversed = first.orientation() == ogeom_topo::Orientation::Reversed;
6066        let d = curve.d1_at(if reversed { range.1 } else { range.0 }, tol)?;
6067        let d = if reversed { -d } else { d };
6068        d / d.magnitude()
6069    };
6070    if plane.normal().vector().cross(exact_t0).magnitude() > tol.angular() {
6071        ogeom_bail!(
6072            Construction,
6073            "the profile leans along its spine; a pipe shell runs square to \
6074             the start"
6075        );
6076    }
6077    if plane.distance_to(at) > tol.confusion() * 100.0 {
6078        ogeom_bail!(
6079            Construction,
6080            "the profile does not sit at the spine's start"
6081        );
6082    }
6083    Ok(())
6084}
6085
6086/// The legs of a spine made only of straight edges, in order, each as its
6087/// start and its displacement; `None` where any edge is curved.
6088fn straight_legs(
6089    model: &Model,
6090    spine: &Shape,
6091    tol: Tolerances,
6092) -> OgeomResult<Option<Vec<(Point, Vector)>>> {
6093    use ogeom_geom::Curve;
6094    let edges: Vec<Shape> = match model.kind_of(spine)? {
6095        ShapeType::Edge => vec![spine.clone()],
6096        ShapeType::Wire => model.ordered_children_of(spine)?,
6097        _ => return Ok(None),
6098    };
6099    let mut legs = Vec::with_capacity(edges.len());
6100    for edge in &edges {
6101        let (curve, range) = spine_curve_of(model, edge)?;
6102        let basis = match &curve {
6103            Curve::Trimmed(t) => t.basis().clone(),
6104            other => other.clone(),
6105        };
6106        if !matches!(basis, Curve::Line(_)) {
6107            return Ok(None);
6108        }
6109        let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
6110        let (t0, t1) = if reversed { (range.1, range.0) } else { range };
6111        let (a, b) = (curve.point_at(t0, tol)?, curve.point_at(t1, tol)?);
6112        legs.push((a, b - a));
6113    }
6114    Ok(Some(legs))
6115}
6116
6117/// Whether the spine turns sharply anywhere between its edges.
6118pub(crate) fn has_sharp_corner(model: &Model, spine: &Shape, tol: Tolerances) -> OgeomResult<bool> {
6119    let stations = shell_stations(model, spine, tol)?;
6120    Ok((0..stations.len() - 1).any(|i| {
6121        stations[i].at.distance(stations[i + 1].at) <= tol.confusion()
6122            && (stations[i]
6123                .tangent
6124                .cross(stations[i + 1].tangent)
6125                .magnitude()
6126                > tol.angular()
6127                || stations[i].tangent.dot(stations[i + 1].tangent) < 0.0)
6128    }))
6129}
6130
6131/// A face down an open spine of straight legs with extended or round
6132/// corners: each leg the prism of its section, run on past its corners
6133/// for extended ones, and each round corner the section revolved about the
6134/// corner through its turn; the pieces fused.
6135fn cornered_lines(
6136    model: &mut Model,
6137    profile: &Shape,
6138    spine: &Shape,
6139    legs: &[(Point, Vector)],
6140    corners: PipeCorners,
6141    tol: Tolerances,
6142) -> OgeomResult<Built> {
6143    if let (Some(first), Some(last)) = (legs.first(), legs.last())
6144        && first.0.distance(last.0 + last.1) <= tol.confusion() * 100.0
6145    {
6146        ogeom_bail!(
6147            Construction,
6148            "extended and round corners are built along an open spine"
6149        );
6150    }
6151    let unit = |v: Vector| v / v.magnitude();
6152    let Some(outer) = explore(model, profile, Filter::OfType(ShapeType::Wire))?
6153        .into_iter()
6154        .next()
6155    else {
6156        ogeom_bail!(Construction, "the profile has no loop");
6157    };
6158    let samples = sample_wire(model, &outer, 256, tol)?;
6159    let (carried, turns) = carried_down_lines(legs, tol)?;
6160    // How far each leg runs on past a corner for an extended corner: until
6161    // its section passes the far side of the other leg's section there.
6162    let reach_past = |section: &Transform, corner: Point, along: Vector, turn: f64| {
6163        let far = samples
6164            .iter()
6165            .map(|p| (section.apply(*p) - corner).dot(along))
6166            .fold(0.0_f64, f64::max);
6167        far / turn.sin().max(LEAST_TURN_SINE)
6168    };
6169    let mut joined: Vec<Shape> = Vec::new();
6170    for (i, &(_, v)) in legs.iter().enumerate() {
6171        let d = unit(v);
6172        let (mut before, mut after) = (0.0, 0.0);
6173        if corners == PipeCorners::Extended {
6174            if i > 0
6175                && let Some((_, angle)) = turns[i - 1]
6176            {
6177                // The previous leg's section at the corner, reaching back.
6178                let at_corner = Transform::translation(legs[i - 1].1) * carried[i - 1];
6179                before = reach_past(&at_corner, legs[i].0, -d, angle);
6180            }
6181            if let Some((_, angle)) = turns[i] {
6182                // The next leg's section at the corner, reaching on.
6183                after = reach_past(&carried[i + 1], legs[i + 1].0, d, angle);
6184            }
6185        }
6186        let section = realized_profile_wound(
6187            model,
6188            profile,
6189            &(Transform::translation(-d * before) * carried[i]),
6190            Some(d),
6191            tol,
6192        )?;
6193        let mut piece =
6194            ogeom_algo::make_prism(model, &section, v + d * (before + after), tol)?.shape;
6195        if corners == PipeCorners::Round
6196            && let Some((axis, angle)) = turns[i]
6197        {
6198            let end = realized_profile_wound(
6199                model,
6200                profile,
6201                &(Transform::translation(v) * carried[i]),
6202                Some(d),
6203                tol,
6204            )?;
6205            // The half of the section inside the turn sweeps within the two
6206            // legs; the half outside it turns the corner's round. It
6207            // touches the axis along its cut, which the revolution takes.
6208            let next = unit(legs[i + 1].1);
6209            let inward = next - d * next.dot(d);
6210            let outer = outside_half(model, &end, axis.location, d, -inward, tol)?;
6211            let bend = ogeom_algo::make_revolution(model, &outer, axis, angle, tol)?.shape;
6212            piece = ogeom_bool::fuse(model, &piece, &bend, tol)?.shape;
6213        }
6214        joined.push(piece);
6215    }
6216    let result = fuse_in_order(model, joined, tol)?;
6217    let Some(shape) = result else {
6218        ogeom_bail!(Construction, "the spine has no leg");
6219    };
6220    let mut history = History::new();
6221    history.generate(spine, shape.clone());
6222    history.generate(profile, shape.clone());
6223    Ok(Built::new(shape, history))
6224}
6225
6226/// A leg's turn at its far corner: the corner's axis and the angle, where
6227/// the leg turns.
6228type Turn = Option<(ogeom_math::Axis, f64)>;
6229
6230/// The motion carrying the profile to each straight leg's start, along the
6231/// legs before it and turned at each corner by the least rotation between
6232/// the headings, as the rotation-minimizing frame turns; and each leg's
6233/// turn at its far corner, about the corner's axis, where it turns.
6234///
6235/// # Errors
6236///
6237/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) where
6238/// the spine all but doubles back at a corner.
6239fn carried_down_lines(
6240    legs: &[(Point, Vector)],
6241    tol: Tolerances,
6242) -> OgeomResult<(Vec<Transform>, Vec<Turn>)> {
6243    let unit = |v: Vector| v / v.magnitude();
6244    let mut carried: Vec<Transform> = Vec::with_capacity(legs.len());
6245    let mut turns: Vec<Turn> = Vec::with_capacity(legs.len());
6246    let mut motion = Transform::IDENTITY;
6247    for (i, &(_, v)) in legs.iter().enumerate() {
6248        carried.push(motion);
6249        motion = Transform::translation(v) * motion;
6250        let turn = match legs.get(i + 1) {
6251            Some(&(corner, next)) => {
6252                let (d, n) = (unit(v), unit(next));
6253                if d.dot(n) < DOUBLED_BACK {
6254                    ogeom_bail!(
6255                        Construction,
6256                        "the spine all but doubles back at a corner; no corner \
6257                         of that kind turns it"
6258                    );
6259                }
6260                // The turn is read off the cross product's length, not
6261                // the dot product's, which rounds a turn of an angle's
6262                // tolerance to none.
6263                let axis = d.cross(n);
6264                let sine = axis.magnitude();
6265                if sine > tol.angular() {
6266                    let about = ogeom_math::Axis {
6267                        location: corner,
6268                        direction: Direction::unit(axis / sine, tol)?,
6269                    };
6270                    let angle = sine.atan2(d.dot(n));
6271                    motion = Transform::rotation(about, angle) * motion;
6272                    Some((about, angle))
6273                } else {
6274                    None
6275                }
6276            }
6277            None => None,
6278        };
6279        turns.push(turn);
6280    }
6281    Ok((carried, turns))
6282}
6283
6284/// The part of a planar section lying on the `outward` side of the line
6285/// through `at` square to it in the section's plane (whose normal is
6286/// `normal`), as a face on that plane: the section's slab kept on that
6287/// side by a half space, and the slab's face on the section's plane.
6288fn outside_half(
6289    model: &mut Model,
6290    section: &Shape,
6291    at: Point,
6292    normal: Vector,
6293    outward: Vector,
6294    tol: Tolerances,
6295) -> OgeomResult<Shape> {
6296    let width = {
6297        let bounds = ogeom_algo::shape_bounds(model, section, tol)?;
6298        match (bounds.low(), bounds.high()) {
6299            (Some(a), Some(b)) => a.distance(b),
6300            _ => ogeom_bail!(Construction, "the section has no extent"),
6301        }
6302    };
6303    let n = normal / normal.magnitude();
6304    let slab = ogeom_algo::make_prism(model, section, n * width, tol)?.shape;
6305    let cut = Plane::through(at, Direction::new(outward, tol)?);
6306    let face = ogeom_algo::make_natural_face(
6307        model,
6308        SurfaceGeometry::Plane(ogeom_geom::PlaneSurface::over(
6309            cut,
6310            (-width * 4.0, width * 4.0),
6311            (-width * 4.0, width * 4.0),
6312        )?),
6313    )?
6314    .shape;
6315    let side = outward / outward.magnitude() * width;
6316    let half = ogeom_algo::make_half_space(model, &face, at + side, tol)?.shape;
6317    let kept = ogeom_bool::common(model, &slab, &half, tol)?.shape;
6318    for face in explore(model, &kept, Filter::OfType(ShapeType::Face))? {
6319        let Ok((point, facing)) = ogeom_algo::face_normal(model, &face, tol) else {
6320            continue;
6321        };
6322        if (point - at).dot(n).abs() <= tol.confusion() * 100.0
6323            && facing.cross(n).magnitude() <= tol.angular() * 10.0
6324        {
6325            // Facing along the section's own normal, as the section does.
6326            return Ok(if facing.dot(n) > 0.0 {
6327                face
6328            } else {
6329                face.reversed()
6330            });
6331        }
6332    }
6333    ogeom_bail!(
6334        Construction,
6335        "the section has no part outside the corner's axis to turn"
6336    )
6337}
6338
6339/// Stations added between neighbours on one edge wherever a law's frame
6340/// turns by more than a few degrees between them: a straight spine has
6341/// stations only at its ends, and a section twisting down it would be
6342/// skinned straight across.
6343fn densified(
6344    model: &Model,
6345    spine: &Shape,
6346    stations: Vec<SpineStation>,
6347    law: &PipeLaw<'_>,
6348    tolerance: f64,
6349    tol: Tolerances,
6350) -> OgeomResult<Vec<SpineStation>> {
6351    let edges: Vec<Shape> = match model.kind_of(spine)? {
6352        ShapeType::Edge => vec![spine.clone()],
6353        ShapeType::Wire => model.ordered_children_of(spine)?,
6354        _ => return Ok(stations),
6355    };
6356    let mut curves = Vec::with_capacity(edges.len());
6357    for edge in &edges {
6358        curves.push(spine_curve_of(model, edge)?.0);
6359    }
6360    let mut stations = stations;
6361    for _ in 0..12 {
6362        let normals = law_normals(model, &stations, law, tolerance, tol)?;
6363        let mut out: Vec<SpineStation> = Vec::with_capacity(stations.len() * 2);
6364        let mut added = false;
6365        for i in 0..stations.len() {
6366            out.push(stations[i]);
6367            let Some(next) = stations.get(i + 1) else {
6368                continue;
6369            };
6370            let here = stations[i];
6371            if here.edge != next.edge || here.at.distance(next.at) <= tol.confusion() {
6372                continue;
6373            }
6374            let turn = normals[i].dot(normals[i + 1]).clamp(-1.0, 1.0).acos();
6375            if turn <= MOST_TURN {
6376                continue;
6377            }
6378            let curve = &curves[here.edge];
6379            let t = f64::midpoint(here.t, next.t);
6380            let d = curve.d1_at(t, tol)?;
6381            let sense = if curve.d1_at(here.t, tol)?.dot(here.tangent) >= 0.0 {
6382                1.0
6383            } else {
6384                -1.0
6385            };
6386            out.push(SpineStation {
6387                at: curve.point_at(t, tol)?,
6388                tangent: d * sense / d.magnitude(),
6389                edge: here.edge,
6390                t,
6391            });
6392            added = true;
6393        }
6394        stations = out;
6395        if !added {
6396            break;
6397        }
6398        // A law whose frame jumps (a guide's nearest point switching
6399        // branch) never settles: each round doubles the stations at the
6400        // jump. Past this many the skin is refused, not grown.
6401        if stations.len() > MOST_STATIONS {
6402            ogeom_bail!(
6403                NotDone,
6404                "the frame law turns faster than {MOST_STATIONS} sections can follow; \
6405                 it jumps somewhere along the spine"
6406            );
6407        }
6408    }
6409    Ok(stations)
6410}
6411
6412/// The cosine between two legs' headings below which the spine all but
6413/// doubles back at their corner, which no corner turns.
6414const DOUBLED_BACK: f64 = -0.7;
6415
6416/// The least sine of a corner's turn an extended corner's reach is divided
6417/// by: a corner turning less runs on as one turning this much.
6418const LEAST_TURN_SINE: f64 = 1e-3;
6419
6420/// The most a law's frame turns, in radians, between two neighbouring
6421/// stations of a law pipe.
6422const MOST_TURN: f64 = 0.05;
6423
6424/// The most sections a law pipe's skin is built through.
6425const MOST_STATIONS: usize = 2048;
6426
6427/// The frame a law gives at a station: the spine's tangent as its `z`, the
6428/// law's normal as its `x`.
6429pub(crate) fn station_frame(
6430    station: &SpineStation,
6431    normal: Vector,
6432    tol: Tolerances,
6433) -> OgeomResult<Frame> {
6434    Frame::new(
6435        station.at,
6436        Direction::new(station.tangent, tol)?,
6437        Direction::new(normal, tol)?,
6438        tol,
6439    )
6440}
6441
6442/// The range over which `curve` moved by `motion` runs between the moved
6443/// ends of `range`. A rigid motion keeps every parameter; under a scaling
6444/// a parameter that is a length scales with it, and an angle or a
6445/// spline's parameter stays.
6446fn moved_range(
6447    curve: &ogeom_geom::Curve,
6448    range: (f64, f64),
6449    motion: &Transform,
6450    tol: Tolerances,
6451) -> OgeomResult<(f64, f64)> {
6452    let k = motion.scale_factor().abs();
6453    if k == 1.0 {
6454        return Ok(range);
6455    }
6456    let ends = (
6457        motion.apply(curve.point_at(range.0, tol)?),
6458        motion.apply(curve.point_at(range.1, tol)?),
6459    );
6460    let moved = curve.transformed(motion, tol)?;
6461    for candidate in [range, (range.0 * k, range.1 * k)] {
6462        // A candidate outside the moved curve's domain is not its range.
6463        let (Ok(a), Ok(b)) = (
6464            moved.point_at(candidate.0, tol),
6465            moved.point_at(candidate.1, tol),
6466        ) else {
6467            continue;
6468        };
6469        if a.distance(ends.0) <= tol.confusion() && b.distance(ends.1) <= tol.confusion() {
6470            return Ok(candidate);
6471        }
6472    }
6473    ogeom_bail!(
6474        Construction,
6475        "a scaled ring's edge parameter is neither a length nor kept"
6476    )
6477}
6478
6479/// A copy of a closed wire moved by `motion`, its curves restated where
6480/// they land rather than placed: every edge in the wire's order and sense,
6481/// on vertices shared end to end.
6482fn moved_ring(
6483    model: &mut Model,
6484    ring: &Shape,
6485    motion: &Transform,
6486    tol: Tolerances,
6487) -> OgeomResult<Shape> {
6488    let edges = model.ordered_children_of(ring)?;
6489    let mut vertices: Vec<(Point, Shape)> = Vec::new();
6490    let mut vertex = |model: &mut Model, at: Point| -> Shape {
6491        if let Some((_, v)) = vertices
6492            .iter()
6493            .find(|(p, _)| p.distance(at) <= tol.confusion() * 10.0)
6494        {
6495            return v.clone();
6496        }
6497        let v = ogeom_algo::make_vertex(model, at).shape;
6498        vertices.push((at, v.clone()));
6499        v
6500    };
6501    let mut moved = Vec::with_capacity(edges.len());
6502    for edge in &edges {
6503        let (curve, range) = spine_curve_of(model, edge)?;
6504        let placement = edge.transform(model.datums())?;
6505        let whole = *motion * placement;
6506        let range = moved_range(&curve, range, &whole, tol)?;
6507        let curve = curve.transformed(&whole, tol)?;
6508        let (a, b) = (curve.point_at(range.0, tol)?, curve.point_at(range.1, tol)?);
6509        let (va, vb) = (vertex(model, a), vertex(model, b));
6510        let built = ogeom_algo::make_edge_between(model, curve, range, &va, &vb, tol)?.shape;
6511        moved.push(if edge.orientation() == ogeom_topo::Orientation::Reversed {
6512            built.reversed()
6513        } else {
6514            built
6515        });
6516    }
6517    Ok(ogeom_algo::make_wire(model, &moved, tol)?.shape)
6518}
6519
6520/// Whether every edge of the closed wire `ring` is an arc of one circle
6521/// centred on the station's point, in the plane square to its tangent, to
6522/// the confusion distance and an angle's tolerance.
6523fn centred_circle(
6524    model: &Model,
6525    ring: &Shape,
6526    station: &SpineStation,
6527    tol: Tolerances,
6528) -> OgeomResult<bool> {
6529    let mut radius: Option<f64> = None;
6530    for edge in model.ordered_children_of(ring)? {
6531        let (curve, _) = spine_curve_of(model, &edge)?;
6532        let curve = curve.transformed(&edge.transform(model.datums())?, tol)?;
6533        let basis = match &curve {
6534            Curve::Trimmed(t) => t.basis().clone(),
6535            other => other.clone(),
6536        };
6537        let Curve::Circle(c) = basis else {
6538            return Ok(false);
6539        };
6540        let circle = c.circle();
6541        let r = *radius.get_or_insert(circle.radius());
6542        if circle.centre().distance(station.at) > tol.confusion()
6543            || (circle.radius() - r).abs() > tol.confusion()
6544            || circle
6545                .frame()
6546                .z()
6547                .vector()
6548                .cross(station.tangent)
6549                .magnitude()
6550                > tol.angular()
6551        {
6552            return Ok(false);
6553        }
6554    }
6555    Ok(radius.is_some())
6556}
6557
6558/// The face `ring` bounds on the plane of `profile`, facing along the
6559/// station's tangent.
6560fn disc_of(
6561    model: &mut Model,
6562    profile: &Shape,
6563    ring: &Shape,
6564    station: &SpineStation,
6565    tol: Tolerances,
6566) -> OgeomResult<Shape> {
6567    let Some(ogeom_topo::NodeData::Face(data)) = model.node(profile).map(|n| n.data()) else {
6568        ogeom_bail!(Construction, "a holed section is a face");
6569    };
6570    let Some(surface) = model.geometry().surface(data.surface).cloned() else {
6571        ogeom_bail!(Dangling, "surface is not in this model");
6572    };
6573    let edges = model.ordered_children_of(ring)?;
6574    let disc = make_face_with_pcurves(model, surface, &[edges], tol)?.shape;
6575    realized_profile_wound(
6576        model,
6577        &disc,
6578        &Transform::IDENTITY,
6579        Some(station.tangent),
6580        tol,
6581    )
6582}
6583
6584/// A profile swept under a law that turns it about the spine, as the loft
6585/// through its outer ring placed at every station by the law's frame: the
6586/// section keeps the place it has in the frame at the spine's start, and
6587/// where `evolution` is given it is also scaled and twisted there by the
6588/// share of the spine's length run.
6589#[allow(clippy::too_many_arguments)]
6590fn law_loft(
6591    model: &mut Model,
6592    profile: &Shape,
6593    spine: &Shape,
6594    stations: &[SpineStation],
6595    law: &PipeLaw<'_>,
6596    evolution: Option<&evolving::Evolution>,
6597    exact_ends: bool,
6598    tolerance: f64,
6599    tol: Tolerances,
6600) -> OgeomResult<Built> {
6601    let rings: Vec<Shape> = match model.kind_of(profile)? {
6602        ShapeType::Face => model.ordered_children_of(profile)?,
6603        ShapeType::Wire => vec![profile.clone()],
6604        _ => ogeom_bail!(Construction, "a pipe sweeps a planar face or wire"),
6605    };
6606    if rings.len() > 1 {
6607        // Each hole sweeps its own solid under the same frames, cut from
6608        // the outer one. A ring that is a circle about the spine's start,
6609        // square to it, is carried onto itself by every frame: where the
6610        // section neither scales nor twists, it sweeps the tube the
6611        // rotation-minimizing frame does. Where the run is to end on its
6612        // sections exactly, that tube is built exactly down lines and arcs,
6613        // so two runs meeting at a mitre cut it from that plane in one
6614        // curve.
6615        let mut solids = Vec::with_capacity(rings.len());
6616        for ring in &rings {
6617            let tube = if exact_ends
6618                && evolution.is_none()
6619                && centred_circle(model, ring, &stations[0], tol)?
6620            {
6621                let disc = disc_of(model, profile, ring, &stations[0], tol)?;
6622                exact_legs(model, &disc, spine, false, tolerance, tol)?
6623            } else {
6624                None
6625            };
6626            solids.push(match tube {
6627                Some(tube) => tube.shape,
6628                None => {
6629                    law_loft(
6630                        model, ring, spine, stations, law, evolution, exact_ends, tolerance, tol,
6631                    )?
6632                    .shape
6633                }
6634            });
6635        }
6636        let mut shape = solids[0].clone();
6637        for hole in &solids[1..] {
6638            shape = ogeom_bool::cut(model, &shape, hole, tol)?.shape;
6639        }
6640        let mut history = History::new();
6641        history.generate(spine, shape.clone());
6642        history.generate(profile, shape.clone());
6643        return Ok(Built::new(shape, history));
6644    }
6645    let normals = law_normals(model, stations, law, tolerance, tol)?;
6646    let start = station_frame(&stations[0], normals[0], tol)?;
6647    let mut kept: Vec<SpineStation> = Vec::with_capacity(stations.len());
6648    let mut kept_normals: Vec<Vector> = Vec::with_capacity(stations.len());
6649    for (station, normal) in stations.iter().zip(&normals) {
6650        if kept
6651            .last()
6652            .is_some_and(|p| p.at.distance(station.at) <= tol.confusion())
6653        {
6654            continue;
6655        }
6656        kept.push(*station);
6657        kept_normals.push(*normal);
6658    }
6659    // Between two stations the spine is read off its curve and the law
6660    // asked for its normal there, as at a station; the rotation-minimizing
6661    // and Frenet laws carry it from the station before.
6662    let walk = SpineWalk {
6663        curves: walk_curves(model, spine)?,
6664        stations: &kept,
6665        normals: &kept_normals,
6666    };
6667    let lengths = match evolution {
6668        Some(_) => Some(evolving::SpineLengths::of(&walk.curves, tol)?),
6669        None => None,
6670    };
6671    // The scale and twist at `s`, in the start frame's coordinates.
6672    let evolved = |s: f64| -> OgeomResult<Transform> {
6673        let (Some(evolution), Some(lengths)) = (evolution, &lengths) else {
6674            return Ok(Transform::IDENTITY);
6675        };
6676        #[allow(clippy::cast_precision_loss)]
6677        let last = (kept.len() - 1) as f64;
6678        let s = s.clamp(0.0, last);
6679        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
6680        let j = s.floor() as usize;
6681        #[allow(clippy::cast_precision_loss)]
6682        let f = s - j as f64;
6683        let (edge, t) = if f <= 0.0 {
6684            (kept[j].edge, kept[j].t)
6685        } else {
6686            walk.place_between(j, f)
6687        };
6688        evolution.local(lengths.fraction(&walk.curves, edge, t, tol)?, tol)
6689    };
6690    let mut sections: Vec<Shape> = Vec::with_capacity(kept.len());
6691    for (i, (station, normal)) in kept.iter().zip(&kept_normals).enumerate() {
6692        let frame = station_frame(station, *normal, tol)?;
6693        #[allow(clippy::cast_precision_loss)]
6694        let local = evolved(i as f64)?;
6695        let motion = Transform::from_frame(&frame) * local * Transform::to_frame(&start);
6696        sections.push(moved_ring(model, &rings[0], &motion, tol)?);
6697    }
6698    let carried = matches!(law, PipeLaw::RotationMinimizing | PipeLaw::Frenet);
6699    let held: std::cell::RefCell<ogeom_core::FastMap<u64, Transform>> =
6700        std::cell::RefCell::default();
6701    let guide = guide_curves(model, law)?;
6702    let motion = |s: f64| -> OgeomResult<Transform> {
6703        if let Some(m) = held.borrow().get(&s.to_bits()) {
6704            return Ok(*m);
6705        }
6706        let (at, tangent, carried_normal) = walk.frame_at(s, (0, kept.len() - 1), tol)?;
6707        let here = SpineStation {
6708            at,
6709            tangent,
6710            ..kept[0]
6711        };
6712        let normal = if carried {
6713            carried_normal
6714        } else {
6715            law_normals_on(&guide, &[here], law, tolerance, tol)?[0]
6716        };
6717        let frame = station_frame(&here, normal, tol)?;
6718        let m = Transform::from_frame(&frame) * evolved(s)? * Transform::to_frame(&start);
6719        held.borrow_mut().insert(s.to_bits(), m);
6720        Ok(m)
6721    };
6722    let mut built =
6723        loft_skinned_along(model, &sections, Some(&motion), exact_ends, tolerance, tol)?;
6724    built.history.generate(spine, built.shape.clone());
6725    built.history.generate(profile, built.shape.clone());
6726    Ok(built)
6727}
6728
6729/// Sweep several planar sections down one spine, the section changing
6730/// shape along the path: a multisection pipe.
6731///
6732/// Each section stands where the spine crosses its plane, and is read in
6733/// the spine's moving frame there (rotation-minimizing, or Frenet where
6734/// `frenet` asks). Between two sections the section is blended in that
6735/// frame by the length run along the spine, matched point to point from
6736/// each section's own start, so the result follows the spine rather than
6737/// the chord between the sections. The sections are closed wires or
6738/// faces; the ends are capped. Faces may have holes, as many in every
6739/// section: each hole, the `k`th of every section in turn, is swept the
6740/// same way as a solid of its own and cut from the outer loop's. The first
6741/// or last section may be a vertex where the spine starts or ends, where
6742/// no section has a hole: the pipe closes to that point, uncapped there.
6743///
6744/// # Errors
6745///
6746/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
6747/// there are fewer than two sections, a section is not planar, the
6748/// sections have holes and differ in their count or one is a point, the
6749/// spine does not cross a section's plane, or the sections stand out of
6750/// order along the spine;
6751/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the skin
6752/// cannot reach `tolerance`.
6753pub fn make_pipe_sections(
6754    model: &mut Model,
6755    sections: &[Shape],
6756    spine: &Shape,
6757    frenet: bool,
6758    tolerance: f64,
6759    tol: Tolerances,
6760) -> OgeomResult<Built> {
6761    const AROUND: usize = 64;
6762    if sections.len() < 2 {
6763        ogeom_bail!(
6764            Construction,
6765            "a multisection pipe needs at least two sections"
6766        );
6767    }
6768    // The spine is swept where it stands.
6769    let placed = placed_path(model, spine, tol)?;
6770    if placed != *spine {
6771        let mut built = make_pipe_sections(model, sections, &placed, frenet, tolerance, tol)?;
6772        built.history.generate(spine, built.shape.clone());
6773        return Ok(built);
6774    }
6775    if let Some(loops) = holed_sections(model, sections)? {
6776        // Each loop through the sections swept on its own, every hole's
6777        // solid cut from the outer one.
6778        let mut shape: Option<Shape> = None;
6779        for rings in &loops {
6780            let swept = make_pipe_sections(model, rings, spine, frenet, tolerance, tol)?.shape;
6781            shape = Some(match shape {
6782                None => swept,
6783                Some(outer) => ogeom_bool::cut(model, &outer, &swept, tol)?.shape,
6784            });
6785        }
6786        let Some(shape) = shape else {
6787            ogeom_bail!(Construction, "a multisection pipe's sections have no loop");
6788        };
6789        let mut history = History::new();
6790        history.generate(spine, shape.clone());
6791        for section in sections {
6792            history.generate(section, shape.clone());
6793        }
6794        return Ok(Built::new(shape, history));
6795    }
6796    let law = if frenet {
6797        PipeLaw::Frenet
6798    } else {
6799        PipeLaw::RotationMinimizing
6800    };
6801    let stations = shell_stations(model, spine, tol)?;
6802    let stations = evenly(model, spine, stations, 24, false, tol)?;
6803    let normals = law_normals(model, &stations, &law, tolerance, tol)?;
6804    // Length run along the spine at each station.
6805    let mut run = vec![0.0_f64];
6806    for pair in stations.windows(2) {
6807        let held = run[run.len() - 1];
6808        run.push(held + pair[0].at.distance(pair[1].at));
6809    }
6810    // Each section: where along the spine it stands, and its samples in
6811    // the frame there.
6812    // Each section where it stands along the spine, read by arc length in
6813    // its own frame, and its samples there for the next to match.
6814    let mut placed: Vec<(f64, Section, Vec<Point>)> = Vec::with_capacity(sections.len());
6815    // A point section: where it stands, and whether at the spine's end.
6816    let mut apex: Option<(Point, bool)> = None;
6817    // The last section's ring read, for the straight pipe to a point.
6818    let mut last_ring: Option<Shape> = None;
6819    for (index, section) in sections.iter().enumerate() {
6820        if model.kind_of(section)? == ShapeType::Vertex {
6821            let Some(data) = model.node(section).and_then(|n| n.data().as_vertex()) else {
6822                ogeom_bail!(Dangling, "vertex is not in this model");
6823            };
6824            let point = section.transform(model.datums())?.apply(data.point);
6825            let reach = tolerance.max(tol.confusion() * 1e3);
6826            let (Some(head), Some(tail)) = (stations.first(), stations.last()) else {
6827                ogeom_bail!(Construction, "the spine has no stations");
6828            };
6829            let at_end = if index + 1 == sections.len() && point.distance(tail.at) <= reach {
6830                true
6831            } else if index == 0 && point.distance(head.at) <= reach {
6832                false
6833            } else {
6834                ogeom_bail!(
6835                    Construction,
6836                    "a point section stands first or last, where the spine \
6837                     starts or ends"
6838                );
6839            };
6840            apex = Some((point, at_end));
6841            // Every sample at the frame's origin: the section shrinks onto
6842            // the spine there.
6843            let along = if at_end { run[run.len() - 1] } else { 0.0 };
6844            placed.push((
6845                along,
6846                Section::Point(Point::ORIGIN),
6847                vec![Point::ORIGIN; AROUND],
6848            ));
6849            continue;
6850        }
6851        let ring = match model.kind_of(section)? {
6852            ShapeType::Face => {
6853                let rings = model.ordered_children_of(section)?;
6854                rings[0].clone()
6855            }
6856            ShapeType::Wire => section.clone(),
6857            _ => ogeom_bail!(
6858                Construction,
6859                "a section is a planar face or wire, or a point at an end"
6860            ),
6861        };
6862        let Some(plane) = ogeom_algo::find_plane(model, &ring, tol)? else {
6863            ogeom_bail!(Construction, "a section is not planar");
6864        };
6865        last_ring = Some(ring.clone());
6866        let side = |p: Point| (p - plane.origin()).dot(plane.normal().vector());
6867        let mut found: Option<(f64, usize, f64)> = None;
6868        for i in 0..stations.len() {
6869            let here = side(stations[i].at);
6870            let crossing = if here.abs() <= tol.confusion() {
6871                Some((run[i], i, 0.0))
6872            } else if let Some(next) = stations.get(i + 1) {
6873                let there = side(next.at);
6874                (here.signum() != there.signum() && there.abs() > tol.confusion()).then(|| {
6875                    let f = here / (here - there);
6876                    (run[i] + (run[i + 1] - run[i]) * f, i, f)
6877                })
6878            } else {
6879                None
6880            };
6881            if crossing.is_some() {
6882                found = crossing;
6883                break;
6884            }
6885        }
6886        let Some((along, i, f)) = found else {
6887            ogeom_bail!(Construction, "the spine does not cross a section's plane");
6888        };
6889        // The frame there, between the two stations it falls between.
6890        let j = (i + 1).min(stations.len() - 1);
6891        let at = stations[i].at + (stations[j].at - stations[i].at) * f;
6892        let tangent = stations[i].tangent * (1.0 - f) + stations[j].tangent * f;
6893        let normal = normals[i] * (1.0 - f) + normals[j] * f;
6894        let normal = normal - tangent * normal.dot(tangent) / tangent.dot(tangent);
6895        let frame = Frame::new(
6896            at,
6897            Direction::new(tangent, tol)?,
6898            Direction::new(normal, tol)?,
6899            tol,
6900        )?;
6901        let into = Transform::to_frame(&frame);
6902        let dense: Vec<Point> = dense_wire(model, &ring, tol)?
6903            .iter()
6904            .map(|p| into.apply(*p))
6905            .collect();
6906        let around = match placed.last() {
6907            Some((_, _, previous)) => matched_loop(&dense, AROUND, previous),
6908            None => ArcLoop::new(dense),
6909        };
6910        let samples = fractions(AROUND)[..AROUND]
6911            .iter()
6912            .map(|f| around.at(*f))
6913            .collect();
6914        placed.push((along, Section::Loop(around), samples));
6915    }
6916    for pair in placed.windows(2) {
6917        if pair[1].0 <= pair[0].0 + tol.confusion() {
6918            ogeom_bail!(
6919                Construction,
6920                "the sections stand out of order along the spine"
6921            );
6922        }
6923    }
6924    // One section closing to a point down a straight spine: every sample
6925    // runs straight to the point, which is the exact pyramid or cone over
6926    // the section.
6927    if let (Some((point, _)), Some(ring), 2) = (apex, &last_ring, sections.len())
6928        && straight_legs(model, spine, tol)?.is_some_and(|legs| legs.len() == 1)
6929    {
6930        let tip = model.add_vertex(VertexData::new(point));
6931        let mut built = make_loft(model, ring, &tip, tol)?;
6932        built.history.generate(spine, built.shape.clone());
6933        for section in sections {
6934            built.history.generate(section, built.shape.clone());
6935        }
6936        return Ok(built);
6937    }
6938    // A ring at every station from the first section's to the last's, the
6939    // two sections either side blended by the length between them.
6940    let (first, last) = (placed[0].0, placed[placed.len() - 1].0);
6941    let mut rings: Vec<Shape> = Vec::new();
6942    let mut ring_at = |model: &mut Model, along: f64, frame: &Frame| -> OgeomResult<()> {
6943        let k = placed
6944            .windows(2)
6945            .position(|w| along <= w[1].0 + tol.confusion())
6946            .unwrap_or(placed.len() - 2);
6947        let (a, b) = (&placed[k], &placed[k + 1]);
6948        let f = ((along - a.0) / (b.0 - a.0)).clamp(0.0, 1.0);
6949        let out = Transform::from_frame(frame);
6950        // The blend round at a fraction of each section's length, held to
6951        // its share of the tolerance between the samples as well as at
6952        // them.
6953        let target = tolerance * 0.1;
6954        let fitted = ogeom_geom::fit::fit_curve_sampled(
6955            |g| {
6956                let (p, q) = (a.1.at(g), b.1.at(g));
6957                Ok(out.apply(p + (q - p) * f))
6958            },
6959            &fractions(AROUND),
6960            true,
6961            3,
6962            target,
6963            tol,
6964        )?;
6965        if !fitted.met {
6966            ogeom_bail!(
6967                NotDone,
6968                "a blended section reached {} against a target of {target}",
6969                fitted.error
6970            );
6971        }
6972        let curve: ogeom_geom::Curve = fitted.curve.into();
6973        let range = curve.domain();
6974        let edge = ogeom_algo::make_edge(model, curve, range, tol)?.shape;
6975        rings.push(ogeom_algo::make_wire(model, &[edge], tol)?.shape);
6976        Ok(())
6977    };
6978    let frame_at = |i: usize| station_frame(&stations[i], normals[i], tol);
6979    // The first section's own station, the stations strictly between, and
6980    // the last section's.
6981    let at_along = |along: f64| -> OgeomResult<Frame> {
6982        let i = run
6983            .windows(2)
6984            .position(|w| along <= w[1] + tol.confusion())
6985            .unwrap_or(run.len() - 2);
6986        let f = ((along - run[i]) / (run[i + 1] - run[i]).max(f64::MIN_POSITIVE)).clamp(0.0, 1.0);
6987        let at = stations[i].at + (stations[i + 1].at - stations[i].at) * f;
6988        let tangent = stations[i].tangent * (1.0 - f) + stations[i + 1].tangent * f;
6989        let normal = normals[i] * (1.0 - f) + normals[i + 1] * f;
6990        let normal = normal - tangent * normal.dot(tangent) / tangent.dot(tangent);
6991        Frame::new(
6992            at,
6993            Direction::new(tangent, tol)?,
6994            Direction::new(normal, tol)?,
6995            tol,
6996        )
6997    };
6998    // The end a point section stands at has no ring: the skin closes on the
6999    // point itself, which the loft takes last.
7000    if apex.is_none_or(|(_, at_end)| at_end) {
7001        ring_at(model, first, &at_along(first)?)?;
7002    }
7003    for (i, &along) in run.iter().enumerate() {
7004        if along > first + tol.confusion() && along < last - tol.confusion() {
7005            ring_at(model, along, &frame_at(i)?)?;
7006        }
7007    }
7008    if apex.is_none_or(|(_, at_end)| !at_end) {
7009        ring_at(model, last, &at_along(last)?)?;
7010    }
7011    if let Some((point, at_end)) = apex {
7012        if !at_end {
7013            rings.reverse();
7014        }
7015        rings.push(model.add_vertex(VertexData::new(point)));
7016    }
7017    let mut built = make_loft_skinned(model, &rings, tolerance, tol)?;
7018    built.history.generate(spine, built.shape.clone());
7019    for section in sections {
7020        built.history.generate(section, built.shape.clone());
7021    }
7022    Ok(built)
7023}
7024
7025/// A multisection pipe's sections read loop by loop where any has a hole:
7026/// the outer loops first, then each hole's loops, the `k`th hole of every
7027/// section together. `None` where no section has a hole.
7028fn holed_sections(model: &Model, sections: &[Shape]) -> OgeomResult<Option<Vec<Vec<Shape>>>> {
7029    let mut read: Vec<Vec<Shape>> = Vec::with_capacity(sections.len());
7030    for section in sections {
7031        read.push(match model.kind_of(section)? {
7032            ShapeType::Face => model.ordered_children_of(section)?,
7033            _ => vec![section.clone()],
7034        });
7035    }
7036    if read.iter().all(|rings| rings.len() == 1) {
7037        return Ok(None);
7038    }
7039    for section in sections {
7040        if model.kind_of(section)? == ShapeType::Vertex {
7041            ogeom_bail!(
7042                Construction,
7043                "a multisection pipe with holes runs between faces; a point section \
7044                 closes no hole"
7045            );
7046        }
7047    }
7048    let count = read[0].len();
7049    if let Some(k) = read.iter().position(|rings| rings.len() != count) {
7050        ogeom_bail!(
7051            Construction,
7052            "every section of a multisection pipe has as many holes; section {k} has {} \
7053             and section 0 has {}",
7054            read[k].len() - 1,
7055            count - 1
7056        );
7057    }
7058    Ok(Some(
7059        (0..count)
7060            .map(|j| read.iter().map(|rings| rings[j].clone()).collect())
7061            .collect(),
7062    ))
7063}
7064
7065/// Stations added so every edge of the spine holds at least `count`
7066/// stations evenly along its parameter, besides the ones it has.
7067fn evenly(
7068    model: &Model,
7069    spine: &Shape,
7070    stations: Vec<SpineStation>,
7071    count: u32,
7072    keep: bool,
7073    tol: Tolerances,
7074) -> OgeomResult<Vec<SpineStation>> {
7075    evenly_by(model, spine, stations, &|_| count, keep, tol)
7076}
7077
7078/// [`evenly`], with each edge's own count: `count(e)` for edge `e`.
7079fn evenly_by(
7080    model: &Model,
7081    spine: &Shape,
7082    stations: Vec<SpineStation>,
7083    count: &dyn Fn(usize) -> u32,
7084    keep: bool,
7085    tol: Tolerances,
7086) -> OgeomResult<Vec<SpineStation>> {
7087    let edges: Vec<Shape> = match model.kind_of(spine)? {
7088        ShapeType::Edge => vec![spine.clone()],
7089        ShapeType::Wire => model.ordered_children_of(spine)?,
7090        _ => return Ok(stations),
7091    };
7092    let mut out: Vec<SpineStation> = Vec::with_capacity(stations.len() + edges.len() * 32);
7093    for (e, edge) in edges.iter().enumerate() {
7094        let on: Vec<SpineStation> = stations.iter().copied().filter(|s| s.edge == e).collect();
7095        let (Some(first), Some(last)) = (on.first().copied(), on.last().copied()) else {
7096            continue;
7097        };
7098        let (curve, _) = spine_curve_of(model, edge)?;
7099        let sense = if curve.d1_at(first.t, tol)?.dot(first.tangent) >= 0.0 {
7100            1.0
7101        } else {
7102            -1.0
7103        };
7104        let mut ts: Vec<f64> = if keep {
7105            on.iter().map(|s| s.t).collect()
7106        } else {
7107            vec![first.t, last.t]
7108        };
7109        let count = count(e);
7110        for k in 1..count {
7111            ts.push(first.t + (last.t - first.t) * f64::from(k) / f64::from(count));
7112        }
7113        ts.sort_by(|a, b| {
7114            if first.t <= last.t {
7115                a.total_cmp(b)
7116            } else {
7117                b.total_cmp(a)
7118            }
7119        });
7120        ts.dedup_by(|a, b| (*a - *b).abs() <= tol.parametric());
7121        for t in ts {
7122            let d = curve.d1_at(t, tol)?;
7123            out.push(SpineStation {
7124                at: curve.point_at(t, tol)?,
7125                tangent: d * sense / d.magnitude(),
7126                edge: e,
7127                t,
7128            });
7129        }
7130    }
7131    Ok(out)
7132}
7133
7134/// The frame normals a law gives at each station.
7135///
7136/// An auxiliary guide ending within `reach` of a station's plane (a
7137/// sketch's end in single precision) is taken to cross it, carried on
7138/// along its end tangent.
7139pub(crate) fn law_normals(
7140    model: &Model,
7141    stations: &[SpineStation],
7142    law: &PipeLaw<'_>,
7143    reach: f64,
7144    tol: Tolerances,
7145) -> OgeomResult<Vec<Vector>> {
7146    law_normals_on(&guide_curves(model, law)?, stations, law, reach, tol)
7147}
7148
7149/// An auxiliary law's guide, each edge's curve and range in the guide's
7150/// order; nothing for any other law.
7151fn guide_curves(
7152    model: &Model,
7153    law: &PipeLaw<'_>,
7154) -> OgeomResult<Vec<(ogeom_geom::Curve, (f64, f64))>> {
7155    let PipeLaw::Auxiliary { guide } = law else {
7156        return Ok(Vec::new());
7157    };
7158    let edges: Vec<Shape> = match model.kind_of(guide)? {
7159        ShapeType::Edge => vec![(*guide).clone()],
7160        ShapeType::Wire => model.ordered_children_of(guide)?,
7161        _ => ogeom_bail!(Construction, "an auxiliary spine is an edge or a wire"),
7162    };
7163    let mut curves = Vec::with_capacity(edges.len());
7164    for edge in &edges {
7165        curves.push(spine_curve_of(model, edge)?);
7166    }
7167    Ok(curves)
7168}
7169
7170/// [`law_normals`], an auxiliary law's guide read already
7171/// ([`guide_curves`]).
7172fn law_normals_on(
7173    curves: &[(ogeom_geom::Curve, (f64, f64))],
7174    stations: &[SpineStation],
7175    law: &PipeLaw<'_>,
7176    reach: f64,
7177    tol: Tolerances,
7178) -> OgeomResult<Vec<Vector>> {
7179    match law {
7180        // Carried by translation, the section has no frame turning with the
7181        // spine; the fixed pipe is built without stations.
7182        PipeLaw::Fixed => ogeom_bail!(
7183            Construction,
7184            "a fixed section keeps its own frame; it has no frame normals"
7185        ),
7186        PipeLaw::RotationMinimizing => Ok(rmf_normals(stations)),
7187        PipeLaw::Frenet => frenet_normals(stations, tol),
7188        PipeLaw::Binormal(b) => stations
7189            .iter()
7190            .map(|s| {
7191                let t = s.tangent;
7192                let b = b.vector() - t * b.vector().dot(t);
7193                if b.magnitude() <= tol.angular() {
7194                    ogeom_bail!(
7195                        Construction,
7196                        "the spine runs along the binormal at {:?}; no frame keeps it",
7197                        s.at
7198                    );
7199                }
7200                let n = (b / b.magnitude()).cross(t);
7201                Ok(n / n.magnitude())
7202            })
7203            .collect(),
7204        PipeLaw::Auxiliary { .. } => {
7205            let mut out = Vec::with_capacity(stations.len());
7206            let mut last: Option<Point> = None;
7207            for s in stations {
7208                let (p, t) = (s.at, s.tangent);
7209                // Where the guide crosses this station's plane: a sign
7210                // change of the height along each guide edge, refined; the
7211                // crossing nearest the last one found.
7212                let mut best: Option<Point> = None;
7213                for (curve, range) in curves {
7214                    const STEPS: u32 = 256;
7215                    let height = |u: f64| -> OgeomResult<(f64, Point)> {
7216                        let q = curve.point_at(u, tol)?;
7217                        Ok(((q - p).dot(t), q))
7218                    };
7219                    let at =
7220                        |k: u32| range.0 + (range.1 - range.0) * f64::from(k) / f64::from(STEPS);
7221                    let mut prev = height(at(0))?;
7222                    for k in 1..=STEPS {
7223                        let here = height(at(k))?;
7224                        if prev.0 == 0.0 || prev.0.signum() != here.0.signum() {
7225                            // A sample on the plane is the crossing itself.
7226                            let q = if prev.0 == 0.0 {
7227                                prev.1
7228                            } else {
7229                                let (mut lo, mut hi) = (at(k - 1), at(k));
7230                                let mut f_lo = prev.0;
7231                                for _ in 0..60 {
7232                                    let mid = f64::midpoint(lo, hi);
7233                                    let (f_mid, _) = height(mid)?;
7234                                    if f_mid.signum() == f_lo.signum() {
7235                                        lo = mid;
7236                                        f_lo = f_mid;
7237                                    } else {
7238                                        hi = mid;
7239                                    }
7240                                }
7241                                height(f64::midpoint(lo, hi))?.1
7242                            };
7243                            let near = last.unwrap_or(p);
7244                            if best.is_none_or(|b| q.distance(near) < b.distance(near)) {
7245                                best = Some(q);
7246                            }
7247                        }
7248                        prev = here;
7249                    }
7250                }
7251                if best.is_none() {
7252                    let near = last.unwrap_or(p);
7253                    for (curve, range) in curves {
7254                        for u in [range.0, range.1] {
7255                            let q = curve.point_at(u, tol)?;
7256                            let h = (q - p).dot(t);
7257                            if h.abs() > reach {
7258                                continue;
7259                            }
7260                            let d = curve.d1_at(u, tol)?;
7261                            let along = d.dot(t);
7262                            let onto = if along.abs() > tol.angular() * d.magnitude() {
7263                                q - d * (h / along)
7264                            } else {
7265                                q
7266                            };
7267                            // A tangent nearly in the plane carries the end
7268                            // far; the end itself is then as good.
7269                            let q = if onto.distance(q) <= 2.0 * reach {
7270                                onto
7271                            } else {
7272                                q
7273                            };
7274                            if best.is_none_or(|b| q.distance(near) < b.distance(near)) {
7275                                best = Some(q);
7276                            }
7277                        }
7278                    }
7279                }
7280                let Some(q) = best else {
7281                    ogeom_bail!(
7282                        Construction,
7283                        "the auxiliary spine does not cross the plane square to the \
7284                         spine at {p:?}"
7285                    );
7286                };
7287                last = Some(q);
7288                let toward = (q - p) - t * (q - p).dot(t);
7289                if toward.magnitude() <= tol.confusion() {
7290                    ogeom_bail!(
7291                        Construction,
7292                        "the auxiliary spine meets the spine at {p:?}; no direction \
7293                         points at it"
7294                    );
7295                }
7296                out.push(toward / toward.magnitude());
7297            }
7298            Ok(out)
7299        }
7300    }
7301}
7302
7303/// The stations a law's skin is taken at: as many as the law's turning
7304/// asks, spread evenly along the spine.
7305fn law_stations(
7306    model: &Model,
7307    spine: &Shape,
7308    stations: Vec<SpineStation>,
7309    law: &PipeLaw<'_>,
7310    tolerance: f64,
7311    tol: Tolerances,
7312) -> OgeomResult<Vec<SpineStation>> {
7313    // As many stations as the law's turning asks, spread evenly: the
7314    // skin takes its sections at even steps of its own parameter.
7315    let wanted = densified(model, spine, stations.clone(), law, tolerance, tol)?.len();
7316    // Shared out along the spine by length, so the spacing is the same
7317    // on every edge: the skin steps evenly along its whole run.
7318    let mut lengths: Vec<f64> = Vec::new();
7319    for pair in stations.windows(2) {
7320        let e = pair[1].edge;
7321        if lengths.len() <= e {
7322            lengths.resize(e + 1, 0.0);
7323        }
7324        if pair[0].edge == e {
7325            lengths[e] += pair[0].at.distance(pair[1].at);
7326        }
7327    }
7328    let total: f64 = lengths.iter().sum();
7329    #[allow(
7330        clippy::cast_precision_loss,
7331        clippy::cast_possible_truncation,
7332        clippy::cast_sign_loss
7333    )]
7334    let share = |e: usize| -> u32 {
7335        let length = lengths.get(e).copied().unwrap_or(0.0);
7336        if total <= 0.0 {
7337            return 2;
7338        }
7339        ((wanted as f64 * length / total).ceil() as u32).max(2)
7340    };
7341    evenly_by(model, spine, stations, &share, false, tol)
7342}
7343
7344/// The pipe shell under a frame law, mitred at corners.
7345#[allow(clippy::too_many_lines)]
7346fn pipe_shell_law(
7347    model: &mut Model,
7348    profile: &Shape,
7349    spine: &Shape,
7350    law: &PipeLaw<'_>,
7351    exact_ends: bool,
7352    tolerance: f64,
7353    tol: Tolerances,
7354) -> OgeomResult<Built> {
7355    const AROUND: usize = 40;
7356    let frenet = matches!(law, PipeLaw::Frenet);
7357    let classic = matches!(law, PipeLaw::RotationMinimizing | PipeLaw::Frenet);
7358
7359    if classic && let Some(exact) = exact_legs(model, profile, spine, frenet, tolerance, tol)? {
7360        return Ok(exact);
7361    }
7362    let stations = shell_stations(model, spine, tol)?;
7363    let stations = if classic {
7364        stations
7365    } else {
7366        law_stations(model, spine, stations, law, tolerance, tol)?
7367    };
7368    // Corners: twin stations standing on one point with different headings.
7369    let corners: Vec<usize> = (0..stations.len() - 1)
7370        .filter(|&i| {
7371            stations[i].at.distance(stations[i + 1].at) <= tol.confusion()
7372                && (stations[i]
7373                    .tangent
7374                    .cross(stations[i + 1].tangent)
7375                    .magnitude()
7376                    > tol.angular()
7377                    || stations[i].tangent.dot(stations[i + 1].tangent) < 0.0)
7378        })
7379        .collect();
7380    // Every twin, corners and smooth junctions alike: each ends one run of
7381    // skin and starts the next, the two runs sharing the section there. A
7382    // smooth junction's mitre plane is its own section, so nothing shears.
7383    let kinks: Vec<usize> = (0..stations.len() - 1)
7384        .filter(|&i| stations[i].at.distance(stations[i + 1].at) <= tol.confusion())
7385        .collect();
7386    let ring = stations[0].at.distance(stations[stations.len() - 1].at) <= tol.confusion() * 10.0;
7387    if !classic && (ring || !corners.is_empty()) {
7388        ogeom_bail!(
7389            Construction,
7390            "an auxiliary or binormal law sweeps an open spine with no sharp corner"
7391        );
7392    }
7393    if !classic {
7394        return law_loft(
7395            model, profile, spine, &stations, law, None, exact_ends, tolerance, tol,
7396        );
7397    }
7398    if ring && kinks.is_empty() {
7399        let still = evolving::Ramp::level(0.0);
7400        return closed_pipe_shell(model, profile, spine, stations, law, &still, tolerance, tol);
7401    }
7402    // The cornered ring closes at its own wrap. Seamed on a corner, the
7403    // wrap is one more mitre; seamed mid-leg, the wrap's "mitre" plane is
7404    // the leg's own cross-section (both twin tangents are the leg's), and
7405    // the shear onto it moves nothing, so the two halves of that leg butt
7406    // together on the seam's own ring, coplanar walls meeting on it. The
7407    // solid is exact either way; the mid-leg seam merely leaves its leg in
7408    // two pieces.
7409    if frenet && !corners.is_empty() {
7410        ogeom_bail!(
7411            Construction,
7412            "a Frenet frame has no direction at a corner; sweep a cornered \
7413             spine with the rotation-minimizing frame"
7414        );
7415    }
7416    let normals = law_normals(model, &stations, law, tolerance, tol)?;
7417    // A ring's frame must come home: carry once more across the wrap
7418    // corner, read the twist between departure and return, and spread it
7419    // along the arc: the smooth loop's own reconciliation, ending at a
7420    // mitre instead of a tangent join.
7421    let normals = if ring {
7422        let mut extended = stations.clone();
7423        extended.push(stations[0]);
7424        let carried = rmf_normals(&extended);
7425        let (n0, n_home) = (carried[0], carried[carried.len() - 1]);
7426        let t0 = stations[0].tangent;
7427        let twist = (n0.cross(n_home).dot(t0)).atan2(n0.dot(n_home));
7428        let mut lengths = vec![0.0_f64];
7429        for pair in extended.windows(2) {
7430            let held = lengths[lengths.len() - 1];
7431            lengths.push(held + pair[0].at.distance(pair[1].at));
7432        }
7433        let total = lengths[lengths.len() - 1];
7434        carried
7435            .iter()
7436            .take(stations.len())
7437            .enumerate()
7438            .map(|(i, n)| {
7439                let phi = -twist * lengths[i] / total;
7440                let t = extended[i].tangent;
7441                let v = *n * phi.cos() + t.cross(*n) * phi.sin();
7442                let v = v - t * v.dot(t);
7443                v / v.magnitude()
7444            })
7445            .collect()
7446    } else {
7447        normals
7448    };
7449    // At each corner both twin sections are thrown onto the bisector plane
7450    // along their own tangents; the mirror symmetry of the rotation-
7451    // minimizing frame lands them on one ring, the mitre both runs share.
7452    let mitre: Vec<Option<(Point, Vector)>> = {
7453        let mut out: Vec<Option<(Point, Vector)>> = vec![None; stations.len()];
7454        for &k in &kinks {
7455            let n = stations[k].tangent + stations[k + 1].tangent;
7456            if n.magnitude() <= tol.angular() {
7457                ogeom_bail!(
7458                    Construction,
7459                    "the spine doubles straight back on itself; no mitre \
7460                     plane divides that corner"
7461                );
7462            }
7463            out[k] = Some((stations[k].at, n));
7464            out[k + 1] = Some((stations[k + 1].at, n));
7465        }
7466        if ring {
7467            let wrap = stations.len() - 1;
7468            let n = stations[wrap].tangent + stations[0].tangent;
7469            if n.magnitude() <= tol.angular() {
7470                ogeom_bail!(
7471                    Construction,
7472                    "the spine doubles straight back on itself; no mitre \
7473                     plane divides that corner"
7474                );
7475            }
7476            out[wrap] = Some((stations[wrap].at, n));
7477            out[0] = Some((stations[0].at, n));
7478        }
7479        out
7480    };
7481    let runs: Vec<(usize, usize)> = {
7482        let mut out = Vec::with_capacity(kinks.len() + 1);
7483        let mut start = 0;
7484        for &k in &kinks {
7485            out.push((start, k));
7486            start = k + 1;
7487        }
7488        out.push((start, stations.len() - 1));
7489        out
7490    };
7491    // A mitred end between straight legs is a *shear*: the honest wall is
7492    // the run's own surface trimmed by the mitre plane, which for a
7493    // straight leg is exactly the ruled skin between its two end rings.
7494    // Under a law that turns the section along a straight leg, the leg's
7495    // wall is no ruled skin between its end rings.
7496    let straight = |rs: usize, re: usize| -> bool {
7497        if !classic {
7498            return false;
7499        }
7500        let t0 = stations[rs].tangent;
7501        (rs..=re).all(|i| stations[i].tangent.cross(t0).magnitude() <= tol.angular())
7502    };
7503    // Every corner as the pair of runs it stands between, the wrap
7504    // included, and whether either leg is curved. A curved leg's trim is
7505    // not a loft of its rows: the two legs' generators for one profile
7506    // point are followed (the leg's own curve, run straight on past the
7507    // corner) to where they meet, and each wall ends on that crossing.
7508    // Where the corner turns in the plane the crossing is exact; a skew
7509    // corner's generators miss each other, and that miss is refused.
7510    struct CornerPair {
7511        before: (usize, usize),
7512        after: (usize, usize),
7513        curved: bool,
7514    }
7515    let corner_pairs: Vec<CornerPair> = {
7516        let mut out = Vec::new();
7517        // A smooth junction joins its runs on their shared section; only a
7518        // corner that turns asks the generators where the walls meet.
7519        for pair in runs.windows(2) {
7520            out.push(CornerPair {
7521                before: pair[0],
7522                after: pair[1],
7523                curved: corners.contains(&pair[0].1)
7524                    && (!straight(pair[0].0, pair[0].1) || !straight(pair[1].0, pair[1].1)),
7525            });
7526        }
7527        if ring && runs.len() > 1 {
7528            let (before, after) = (runs[runs.len() - 1], runs[0]);
7529            let turns = stations[before.1]
7530                .tangent
7531                .cross(stations[after.0].tangent)
7532                .magnitude()
7533                > tol.angular()
7534                || stations[before.1].tangent.dot(stations[after.0].tangent) < 0.0;
7535            out.push(CornerPair {
7536                before,
7537                after,
7538                curved: turns && (!straight(before.0, before.1) || !straight(after.0, after.1)),
7539            });
7540        }
7541        out
7542    };
7543    let walk = SpineWalk {
7544        curves: walk_curves(model, spine)?,
7545        stations: &stations,
7546        normals: &normals,
7547    };
7548    let join_reach = tolerance.max(tol.confusion() * 100.0);
7549    let curved_join = |pair: &CornerPair, ab: (f64, f64)| -> OgeomResult<CornerJoin> {
7550        let join = walk.join(pair.before, pair.after, ab, tol)?;
7551        if join.gap > join_reach {
7552            ogeom_bail!(
7553                Construction,
7554                "a skew corner against a curved leg is still owed its frame \
7555                 law: the legs' generators miss by {}; see docs/CAPABILITIES.md, \
7556                 offset.sweeps",
7557                join.gap
7558            );
7559        }
7560        Ok(join)
7561    };
7562    // The curved corner a station is a twin of, if any.
7563    let curved_at = |i: usize| -> Option<&CornerPair> {
7564        corner_pairs
7565            .iter()
7566            .find(|pair| pair.curved && (pair.before.1 == i || pair.after.0 == i))
7567    };
7568
7569    // The profile's loops: a face contributes every wire, holes included;
7570    // a bare wire is one loop.
7571    let loops: Vec<Shape> = match model.kind_of(profile)? {
7572        ShapeType::Face => explore(model, profile, Filter::OfType(ShapeType::Wire))?,
7573        ShapeType::Wire => vec![profile.clone()],
7574        other => ogeom_bail!(
7575            Construction,
7576            "a pipe shell sweeps a planar wire or face, not a {other:?}"
7577        ),
7578    };
7579    if loops.is_empty() {
7580        ogeom_bail!(Construction, "the profile has no loop to sweep");
7581    }
7582    let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
7583        ogeom_bail!(Construction, "a pipe shell sweeps a planar profile");
7584    };
7585    let t0 = stations[0].tangent;
7586    profile_square_at_start(model, &plane, spine, stations[0].at, tol)?;
7587
7588    // Transport: each loop expressed in the start frame's own 2D
7589    // coordinates, then re-expressed in every station's frame. A loop of one
7590    // smooth closed edge skins as one wall; a faceted loop skins one strip
7591    // per edge, cornered at shared vertices, because no single fit can
7592    // speak a corner.
7593    let x0 = normals[0];
7594    let y0 = t0.cross(x0);
7595    let origin = stations[0].at;
7596    let flat = |p: Point| -> (f64, f64) { ((p - origin).dot(x0), (p - origin).dot(y0)) };
7597    // Points round the profile, in the start frame's coordinates.
7598    let probes: Vec<(f64, f64)> = {
7599        let mut out = Vec::new();
7600        for wire in &loops {
7601            out.extend(sample_wire(model, wire, AROUND, tol)?.into_iter().map(flat));
7602        }
7603        out
7604    };
7605    // A skew corner against a curved leg: the legs' generators miss, so
7606    // no join row closes the walls. Such a corner is a mitre instead: the
7607    // spine is split there, each side swept on straight past the corner
7608    // and trimmed by the mitre plane, and the pieces fused.
7609    let skew: Vec<(usize, usize)> = {
7610        let mut out = Vec::new();
7611        for pair in corner_pairs.iter().filter(|pair| pair.curved) {
7612            let mut worst = 0.0_f64;
7613            for ab in &probes {
7614                worst = worst.max(walk.join(pair.before, pair.after, *ab, tol)?.gap);
7615            }
7616            if worst > join_reach {
7617                out.push((pair.before.1, pair.after.0));
7618            }
7619        }
7620        out
7621    };
7622    if !skew.is_empty() {
7623        return mitred_pieces(
7624            model, profile, spine, &stations, &skew, ring, &probes, tolerance, tol,
7625        );
7626    }
7627    let place = |i: usize, (a, b): (f64, f64)| -> OgeomResult<Point> {
7628        if let Some(pair) = curved_at(i) {
7629            return Ok(curved_join(pair, (a, b))?.at);
7630        }
7631        let x = normals[i];
7632        let y = stations[i].tangent.cross(x);
7633        let p = stations[i].at + x * a + y * b;
7634        Ok(match mitre[i] {
7635            Some((corner, n)) => {
7636                let t = stations[i].tangent;
7637                p + t * ((corner - p).dot(n) / t.dot(n))
7638            }
7639            None => p,
7640        })
7641    };
7642    // Where a run's first section is the previous run's last (a smooth
7643    // junction, a corner's mitre or a curved corner's join row: the twin
7644    // stations place every profile point on one point), the two skins
7645    // share that section as one edge: a run adopts the previous run's end
7646    // row at its start, and the last run of a cornered ring adopts the
7647    // first run's start row at its end. Two fits of one section each stand
7648    // within their own error of it, but not within the sew's tolerance of
7649    // each other unless they happen to be the same fit. Away from a curved
7650    // corner placing is affine in the profile's coordinates, so agreement
7651    // on the probes round the profile is agreement everywhere.
7652    let one_section = |i: usize, j: usize| -> OgeomResult<bool> {
7653        for ab in &probes {
7654            if place(i, *ab)?.distance(place(j, *ab)?) > tol.confusion() {
7655                return Ok(false);
7656            }
7657        }
7658        Ok(true)
7659    };
7660    let mut butted_start = vec![false; runs.len()];
7661    for ri in 1..runs.len() {
7662        butted_start[ri] = corner_pairs
7663            .iter()
7664            .any(|pair| pair.curved && pair.after == runs[ri])
7665            || one_section(runs[ri - 1].1, runs[ri].0)?;
7666    }
7667    let butted_wrap = ring
7668        && runs.len() > 1
7669        && (corner_pairs.iter().any(|pair| {
7670            pair.curved && pair.before == runs[runs.len() - 1] && pair.after == runs[0]
7671        }) || one_section(runs[runs.len() - 1].1, runs[0].0)?);
7672    let shares_start = |ri: usize| -> bool { butted_start[ri] };
7673    let shares_end = |ri: usize| -> bool { butted_wrap && ri + 1 == runs.len() };
7674    // One profile point's generator along a re-rowed run: its parameter
7675    // against the length run from the column's start, and the whole length.
7676    struct RerowColumn {
7677        along: Vec<(f64, f64)>,
7678        length: f64,
7679    }
7680    // Where one profile point's generator on `run` meets the next leg's (at
7681    // its end) or the previous leg's (at its start), at a curved corner.
7682    let crossing =
7683        |run: (usize, usize), ab: (f64, f64), at_start: bool| -> OgeomResult<Option<CornerJoin>> {
7684            let pair = corner_pairs.iter().find(|pair| {
7685                pair.curved
7686                    && if at_start {
7687                        pair.after == run
7688                    } else {
7689                        pair.before == run
7690                    }
7691            });
7692            pair.map(|pair| curved_join(pair, ab)).transpose()
7693        };
7694    // Each end of one profile point's column on `run`: the crossing where
7695    // the run meets a curved corner there, else the end station. A crossing
7696    // past the middle of the run means the leg is shorter than the corner's
7697    // reach.
7698    let column_ends = |(rs, re): (usize, usize),
7699                       ab: (f64, f64)|
7700     -> OgeomResult<(Option<CornerJoin>, Option<CornerJoin>)> {
7701        let run = (rs, re);
7702        let (start, end) = (crossing(run, ab, true)?, crossing(run, ab, false)?);
7703        #[allow(clippy::cast_precision_loss)]
7704        let (rsf, ref_) = (rs as f64, re as f64);
7705        if start.as_ref().is_some_and(|j| j.s2 >= ref_ - 0.5)
7706            || end.as_ref().is_some_and(|j| j.s1 <= rsf + 0.5)
7707        {
7708            ogeom_bail!(
7709                Construction,
7710                "a leg is shorter than its corner's reach; the mitre would \
7711                 run off its far end"
7712            );
7713        }
7714        Ok((start, end))
7715    };
7716    // A straight run's column is its two ends, ruled: the trimmed prism
7717    // itself, whether an end is sheared onto a mitre plane or stands on a
7718    // curved corner's crossing.
7719    let straight_column = |(rs, re): (usize, usize), ab: (f64, f64)| -> OgeomResult<Vec<Point>> {
7720        let (start, end) = column_ends((rs, re), ab)?;
7721        Ok(vec![
7722            match start {
7723                Some(j) => j.at,
7724                None => place(rs, ab)?,
7725            },
7726            match end {
7727                Some(j) => j.at,
7728                None => place(re, ab)?,
7729            },
7730        ])
7731    };
7732    // A curved run with a crossing at either end is re-rowed whole: every
7733    // column runs its own generator from its start to its end, read at
7734    // fractions of its own arc length, so the skin's parameter across is
7735    // one for all columns. A column's pace differs between the leg's curve
7736    // and its straight extension, and a stretch skewed only near the corner
7737    // would pace each column differently. The skin is known everywhere
7738    // along its columns, so it is checked between its rows as well.
7739    let rerow_column = |(rs, re): (usize, usize), ab: (f64, f64)| -> OgeomResult<RerowColumn> {
7740        let run = (rs, re);
7741        let (start, end) = column_ends(run, ab)?;
7742        #[allow(clippy::cast_precision_loss)]
7743        let lo = start.map_or(rs as f64, |j| j.s2);
7744        #[allow(clippy::cast_precision_loss)]
7745        let hi = end.map_or(re as f64, |j| j.s1);
7746        // The length is read in eighths of a station on the stations' own
7747        // grid, so the run's end station, where the leg's curve gives way to
7748        // its straight extension and a generator off the spine changes its
7749        // pace, is a step's end. Read across it, one step would mix the two
7750        // paces and kink the column's parameter, which a cubic fit cannot
7751        // follow.
7752        let mut nodes: Vec<f64> = vec![lo];
7753        #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
7754        {
7755            let mut m = (lo * 8.0).floor() as i64 + 1;
7756            while (m as f64) / 8.0 < hi {
7757                let sp = (m as f64) / 8.0;
7758                if sp - lo > 1e-9 && hi - sp > 1e-9 {
7759                    nodes.push(sp);
7760                }
7761                m += 1;
7762            }
7763        }
7764        nodes.push(hi);
7765        let mut along: Vec<(f64, f64)> = Vec::with_capacity(nodes.len());
7766        let mut prev: Option<Point> = None;
7767        let mut length = 0.0;
7768        for sp in nodes {
7769            let p = walk.generator(sp, run, ab, tol)?;
7770            if let Some(q) = prev {
7771                length += q.distance(p);
7772            }
7773            along.push((length, sp));
7774            prev = Some(p);
7775        }
7776        Ok(RerowColumn { along, length })
7777    };
7778    // The point a fraction `f` of the way along a re-rowed column.
7779    let rerowed_at =
7780        |run: (usize, usize), column: &RerowColumn, ab: (f64, f64), f: f64| -> OgeomResult<Point> {
7781            let along = &column.along;
7782            let target = column.length * f;
7783            let at = along
7784                .partition_point(|(l, _)| *l < target)
7785                .clamp(1, along.len() - 1);
7786            let ((l0, s0), (l1, s1)) = (along[at - 1], along[at]);
7787            let g = if l1 > l0 {
7788                ((target - l0) / (l1 - l0)).clamp(0.0, 1.0)
7789            } else {
7790                0.0
7791            };
7792            walk.generator(s0 + (s1 - s0) * g, run, ab, tol)
7793        };
7794    // A re-rowed run's skin known everywhere: the point `s` stations' worth
7795    // of the way along the column of profile point `ab` at `u`, each column
7796    // built once into `held`.
7797    #[allow(clippy::type_complexity)]
7798    let rerowed_point = |(rs, re): (usize, usize),
7799                         held: &std::cell::RefCell<ogeom_core::FastMap<u64, RerowColumn>>,
7800                         u: f64,
7801                         ab: (f64, f64),
7802                         s: f64|
7803     -> OgeomResult<Point> {
7804        if !held.borrow().contains_key(&u.to_bits()) {
7805            let column = rerow_column((rs, re), ab)?;
7806            held.borrow_mut().insert(u.to_bits(), column);
7807        }
7808        let held = held.borrow();
7809        #[allow(clippy::cast_precision_loss)]
7810        let f = (s - rs as f64) / (re - rs) as f64;
7811        rerowed_at((rs, re), &held[&u.to_bits()], ab, f)
7812    };
7813    // A curved run with no crossing at either end has its stations for
7814    // rows, and the frame carried between them places the profile anywhere
7815    // along it: its skin is checked between the stations too, its
7816    // parameter across even in the station index (an affine image of the
7817    // spine's own parameter along the run).
7818    let plain = |run: (usize, usize)| -> bool {
7819        !straight(run.0, run.1)
7820            && !corner_pairs
7821                .iter()
7822                .any(|pair| pair.curved && (pair.after == run || pair.before == run))
7823    };
7824    let along_run = |run: (usize, usize), s: f64, ab: (f64, f64)| -> OgeomResult<Point> {
7825        if s.fract() == 0.0 {
7826            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
7827            let i = s as usize;
7828            place(i, ab)
7829        } else {
7830            walk.generator(s, run, ab, tol)
7831        }
7832    };
7833    let last = stations.len() - 1;
7834
7835    enum LoopWall {
7836        Ring {
7837            ring0: Shape,
7838            ring1: Shape,
7839        },
7840        Chain {
7841            bottoms: Vec<Shape>,
7842            tops: Vec<Shape>,
7843        },
7844    }
7845    let mut faces: Vec<Shape> = Vec::new();
7846    let mut ends: Vec<LoopWall> = Vec::with_capacity(loops.len());
7847    for (li, wire) in loops.iter().enumerate() {
7848        let hole = li != 0;
7849        let edges = model.ordered_children_of(wire)?;
7850        let single_smooth = edges.len() == 1 && {
7851            let (curve, _) = spine_curve_of(model, &edges[0])?;
7852            !matches!(curve, ogeom_geom::Curve::Line(_))
7853                && ogeom_algo::edge_vertices(model, &edges[0])?.is_some_and(|(a, b)| a.is_same(&b))
7854        };
7855        if single_smooth {
7856            let profile_loop = section_loop(model, wire, None, tol)?;
7857            // One wall per smooth run: a fit across a corner speaks nothing,
7858            // and the twin stations put both runs' boundary rows on the one
7859            // mitred ring, where the sew joins them.
7860            let mut ring0: Option<Shape> = None;
7861            let mut ring1: Option<Shape> = None;
7862            for (ri, &(rs, re)) in runs.iter().enumerate() {
7863                // Each column the run's rows of one point of the profile,
7864                // read by arc length round it.
7865                let held = std::cell::RefCell::default();
7866                let skin = if plain((rs, re)) {
7867                    Skin::swept(
7868                        |f, s| along_run((rs, re), s, flat(profile_loop.at(f))),
7869                        fractions(AROUND),
7870                        (rs, re),
7871                        true,
7872                    )?
7873                } else if straight(rs, re) {
7874                    Skin::columns(
7875                        |f| straight_column((rs, re), flat(profile_loop.at(f))),
7876                        fractions(AROUND),
7877                        true,
7878                        false,
7879                        false,
7880                    )?
7881                } else {
7882                    Skin::swept(
7883                        |f, s| rerowed_point((rs, re), &held, f, flat(profile_loop.at(f)), s),
7884                        fractions(AROUND),
7885                        (rs, re),
7886                        true,
7887                    )?
7888                };
7889                let shared_start = shares_start(ri).then_some(()).and(ring1.as_ref());
7890                let shared_end = shares_end(ri).then_some(()).and(ring0.as_ref());
7891                let wall = skinned_wall(model, &skin, (shared_start, shared_end), tolerance, tol)?;
7892                faces.push(if hole {
7893                    wall.face.reversed()
7894                } else {
7895                    wall.face.clone()
7896                });
7897                if ring0.is_none() {
7898                    ring0 = Some(wall.ring0);
7899                }
7900                ring1 = Some(wall.ring1);
7901            }
7902            let (Some(ring0), Some(ring1)) = (ring0, ring1) else {
7903                ogeom_bail!(Construction, "the sweep produced no wall");
7904            };
7905            ends.push(LoopWall::Ring { ring0, ring1 });
7906        } else {
7907            // Shared corner vertices at both ends of every edge junction.
7908            let count = edges.len();
7909            let mut corner_flat: Vec<(f64, f64)> = Vec::with_capacity(count);
7910            for edge in &edges {
7911                // Already in the ring's own sense: a reversed edge's
7912                // vertices come back end first.
7913                let Some((start, _)) = ogeom_algo::edge_vertices(model, edge)? else {
7914                    ogeom_bail!(Construction, "a profile edge has no vertices");
7915                };
7916                let Some(data) = model.node(&start).and_then(|n| n.data().as_vertex()) else {
7917                    ogeom_bail!(Construction, "a profile vertex holds no data");
7918                };
7919                corner_flat.push(flat(data.point));
7920            }
7921            let make_corners = |model: &mut Model, station: usize| -> OgeomResult<Vec<Shape>> {
7922                let mut out = Vec::with_capacity(corner_flat.len());
7923                for ab in &corner_flat {
7924                    out.push(ogeom_algo::make_vertex(model, place(station, *ab)?).shape);
7925                }
7926                Ok(out)
7927            };
7928            // Corner vertex sets at every run boundary; a kink's twin
7929            // stations land on the same mitred points, so both runs take
7930            // the same vertex objects.
7931            let mut corners_at: Vec<Option<Vec<Shape>>> = vec![None; stations.len()];
7932            if ring {
7933                // The wrap is one corner: both runs take the same vertex
7934                // objects. Every corner's two sheared sections must land on
7935                // one ring for the loop to close; a planar ring's do
7936                // exactly, and a skew ring's (whose parallel-carried frame
7937                // leaves the far tangent's plane) do not, so the residue
7938                // is measured and the skew ring refused by name rather
7939                // than sewn hoping.
7940                let mut worst = 0.0_f64;
7941                for ab in &corner_flat {
7942                    worst = worst.max(place(last, *ab)?.distance(place(0, *ab)?));
7943                    for &k in &kinks {
7944                        worst = worst.max(place(k, *ab)?.distance(place(k + 1, *ab)?));
7945                    }
7946                }
7947                if worst > tolerance.max(tol.confusion() * 100.0) {
7948                    ogeom_bail!(
7949                        Construction,
7950                        "a skew-cornered ring's sections do not meet on \
7951                         their mitres; the out-of-plane corner's frame law \
7952                         is still owed; see docs/CAPABILITIES.md, offset.sweeps"
7953                    );
7954                }
7955                let set = make_corners(model, 0)?;
7956                if worst > tol.confusion() {
7957                    for v in &set {
7958                        model.widen(v, ogeom_core::Tolerance::new(worst * 2.0)?)?;
7959                    }
7960                }
7961                corners_at[0] = Some(set.clone());
7962                corners_at[last] = Some(set);
7963            } else {
7964                corners_at[0] = Some(make_corners(model, 0)?);
7965                corners_at[last] = Some(make_corners(model, last)?);
7966            }
7967            for &k in &kinks {
7968                let set = make_corners(model, k)?;
7969                corners_at[k] = Some(set.clone());
7970                corners_at[k + 1] = Some(set);
7971            }
7972
7973            // The loop's own centroid line, for orienting each strip.
7974            let hint_flat = {
7975                let mut a = 0.0;
7976                let mut b = 0.0;
7977                for (fa, fb) in &corner_flat {
7978                    a += fa;
7979                    b += fb;
7980                }
7981                #[allow(clippy::cast_precision_loss)]
7982                let n = count as f64;
7983                (a / n, b / n)
7984            };
7985
7986            let mut bottoms = Vec::with_capacity(count);
7987            let mut tops = Vec::with_capacity(count);
7988            // Per run: the first strip's start rail, for the last strip to
7989            // close the loop on, and the previous strip's end rail, for
7990            // the next to start from: one edge for both, never two fits.
7991            let mut run_rails: Vec<(Option<Shape>, Option<Shape>)> = vec![(None, None); runs.len()];
7992            for (ei, edge) in edges.iter().enumerate() {
7993                let (curve, range) = spine_curve_of(model, edge)?;
7994                let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
7995                const ALONG_EDGE: usize = 8;
7996                let edge_flat = |f: f64| -> OgeomResult<(f64, f64)> {
7997                    let t = if reversed {
7998                        range.1 - (range.1 - range.0) * f
7999                    } else {
8000                        range.0 + (range.1 - range.0) * f
8001                    };
8002                    Ok(flat(curve.point_at(t, tol)?))
8003                };
8004                let next = (ei + 1) % count;
8005                let mut bottom: Option<Shape> = None;
8006                let mut top: Option<Shape> = None;
8007                for (ri, &(rs, re)) in runs.iter().enumerate() {
8008                    // Each column the run's rows of one point of the edge,
8009                    // at a fraction of its parameter range.
8010                    let held = std::cell::RefCell::default();
8011                    let skin = if plain((rs, re)) {
8012                        Skin::swept(
8013                            |f, s| along_run((rs, re), s, edge_flat(f)?),
8014                            fractions(ALONG_EDGE),
8015                            (rs, re),
8016                            false,
8017                        )?
8018                    } else if straight(rs, re) {
8019                        Skin::columns(
8020                            |f| straight_column((rs, re), edge_flat(f)?),
8021                            fractions(ALONG_EDGE),
8022                            false,
8023                            false,
8024                            false,
8025                        )?
8026                    } else {
8027                        Skin::swept(
8028                            |f, s| rerowed_point((rs, re), &held, f, edge_flat(f)?, s),
8029                            fractions(ALONG_EDGE),
8030                            (rs, re),
8031                            false,
8032                        )?
8033                    };
8034                    let shared_start = shares_start(ri).then_some(()).and(top.as_ref());
8035                    let shared_end = shares_end(ri).then_some(()).and(bottom.as_ref());
8036                    let mid_i = usize::midpoint(rs, re);
8037                    let hint = {
8038                        let x = normals[mid_i];
8039                        let y = stations[mid_i].tangent.cross(x);
8040                        stations[mid_i].at + x * hint_flat.0 + y * hint_flat.1
8041                    };
8042                    let (Some(from), Some(to)) = (&corners_at[rs], &corners_at[re]) else {
8043                        ogeom_bail!(Construction, "a run boundary has no corners");
8044                    };
8045                    let (first_rail0, prev_rail1) = run_rails[ri].clone();
8046                    let shared_rail0 = if ei > 0 { prev_rail1 } else { None };
8047                    let shared_rail1 = if ei + 1 == count && count > 1 {
8048                        first_rail0.clone()
8049                    } else {
8050                        None
8051                    };
8052                    let strip = skinned_strip(
8053                        model,
8054                        &skin,
8055                        (&from[ei], &from[next], &to[ei], &to[next]),
8056                        [
8057                            shared_start,
8058                            shared_end,
8059                            shared_rail0.as_ref(),
8060                            shared_rail1.as_ref(),
8061                        ],
8062                        hint,
8063                        hole,
8064                        tolerance,
8065                        tol,
8066                    )?;
8067                    run_rails[ri] = (
8068                        if ei == 0 {
8069                            Some(strip.rail0.clone())
8070                        } else {
8071                            first_rail0
8072                        },
8073                        Some(strip.rail1.clone()),
8074                    );
8075                    faces.push(strip.face.clone());
8076                    if bottom.is_none() {
8077                        bottom = Some(strip.bottom);
8078                    }
8079                    top = Some(strip.top);
8080                }
8081                let (Some(bottom), Some(top)) = (bottom, top) else {
8082                    ogeom_bail!(Construction, "the sweep produced no strip");
8083                };
8084                bottoms.push(bottom);
8085                tops.push(top);
8086            }
8087            ends.push(LoopWall::Chain { bottoms, tops });
8088        }
8089    }
8090
8091    // A cap per end: one plane, one wire per loop, each edge's pcurve the
8092    // exact projection of its control net into the plane's chart. A ring
8093    // has no ends: its two boundary rings stand on one mitre plane and the
8094    // sew joins them.
8095    for end in 0..if ring { 0 } else { 2 } {
8096        let (at, outward) = if end == 0 {
8097            (stations[0].at, -stations[0].tangent)
8098        } else {
8099            (stations[last].at, stations[last].tangent)
8100        };
8101        let cap_plane = Plane::through(at, Direction::new(outward, tol)?);
8102        let mut loop_edges: Vec<Vec<Shape>> = Vec::with_capacity(ends.len());
8103        for wall in &ends {
8104            loop_edges.push(match wall {
8105                LoopWall::Ring { ring0, ring1 } => {
8106                    vec![if end == 0 {
8107                        ring0.clone()
8108                    } else {
8109                        ring1.clone()
8110                    }]
8111                }
8112                LoopWall::Chain { bottoms, tops } => {
8113                    if end == 0 {
8114                        bottoms.clone()
8115                    } else {
8116                        tops.clone()
8117                    }
8118                }
8119            });
8120        }
8121        let mut reach = 1.0_f64;
8122        for edges in &loop_edges {
8123            for edge in edges {
8124                let (curve, range) = spine_curve_of(model, edge)?;
8125                for t in 0..8 {
8126                    let p =
8127                        curve.point_at(range.0 + (range.1 - range.0) * f64::from(t) / 8.0, tol)?;
8128                    reach = reach.max(p.distance(at) * 2.0);
8129                }
8130            }
8131        }
8132        let cap_surface: SurfaceGeometry =
8133            PlaneSurface::over(cap_plane, (-reach, reach), (-reach, reach))?.into();
8134        // The material on the left of each ring about the outward normal:
8135        // the profile's first loop is its outer one.
8136        let mut wires: Vec<Shape> = Vec::with_capacity(loop_edges.len());
8137        for (li, edges) in loop_edges.iter().enumerate() {
8138            let ring = walked_about(model, edges, outward, li == 0, tol)?;
8139            wires.push(ogeom_algo::make_wire(model, &ring, tol)?.shape);
8140        }
8141        let face = ogeom_algo::make_face(model, cap_surface.clone(), &wires, tol)?.shape;
8142        let cap_id = {
8143            let Some(node) = model.node(&face) else {
8144                ogeom_bail!(Dangling, "the cap just built is not in this model");
8145            };
8146            let ogeom_topo::NodeData::Face(data) = node.data() else {
8147                ogeom_bail!(Construction, "the cap holds no face data");
8148            };
8149            data.surface
8150        };
8151        let frame = cap_plane.frame();
8152        for edges in &loop_edges {
8153            for edge in edges {
8154                let (curve, range) = spine_curve_of(model, edge)?;
8155                let ogeom_geom::Curve::BSpline(bs) = &curve else {
8156                    ogeom_bail!(Construction, "a swept ring is not a spline");
8157                };
8158                // A planar polynomial spline's chart image is the same-degree
8159                // spline of the projected control points: affine, so exact.
8160                let control2: Vec<Point2> = bs
8161                    .control_points()
8162                    .iter()
8163                    .map(|w| {
8164                        let local = frame.to_local(w.point());
8165                        Point2::new(local.x, local.y)
8166                    })
8167                    .collect();
8168                let pcurve: ogeom_geom::PlanarCurve =
8169                    ogeom_geom::BSpline2d::new(bs.knots().clone(), control2, tol)?.into();
8170                ogeom_algo::attach_pcurve(
8171                    model,
8172                    edge,
8173                    pcurve,
8174                    cap_id,
8175                    ogeom_topo::Location::identity(),
8176                    range,
8177                )?;
8178            }
8179        }
8180        faces.push(face);
8181    }
8182
8183    let sewn = sew(model, &faces, tol)?;
8184    let mut built = if ring {
8185        // A holed ring sews into one shell per profile loop: the outer
8186        // bounds the material, each hole a void tunnel. Largest bound
8187        // first, the voids' faces already turned at build.
8188        if sewn.shells.is_empty() {
8189            ogeom_bail!(Construction, "the pipe shell did not close");
8190        }
8191        for shell in &sewn.shells {
8192            if !ogeom_algo::is_shell_closed(model, shell)? {
8193                ogeom_bail!(Construction, "the pipe shell did not close");
8194            }
8195        }
8196        let mut ordered = sewn.shells.clone();
8197        let mut sized: Vec<(f64, Shape)> = Vec::with_capacity(ordered.len());
8198        for shell in ordered.drain(..) {
8199            let bound = ogeom_algo::shape_bounds(model, &shell, tol)?;
8200            sized.push((bound.diagonal(), shell));
8201        }
8202        sized.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(core::cmp::Ordering::Equal));
8203        let shells: Vec<Shape> = sized.into_iter().map(|(_, s)| s).collect();
8204        make_solid(model, &shells)?
8205    } else {
8206        if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
8207            if std::env::var_os("OGEOM_DEBUG_SWEEP").is_some() {
8208                eprintln!(
8209                    "SWEEP: {} shells, {} free edges from {} faces",
8210                    sewn.shells.len(),
8211                    sewn.free_edges.len(),
8212                    faces.len()
8213                );
8214                for edge in &sewn.free_edges {
8215                    let (curve, range) = spine_curve_of(model, edge)?;
8216                    let a = curve.point_at(range.0, tol)?;
8217                    let b = curve.point_at(range.1, tol)?;
8218                    let m = curve.point_at(f64::midpoint(range.0, range.1), tol)?;
8219                    let t = model.tolerance_of(edge)?.map_or(0.0, |t| t.get());
8220                    eprintln!(
8221                        "  free ({:.3},{:.3},{:.3}) -> ({:.3},{:.3},{:.3}) via ({:.3},{:.3},{:.3}) tol {t:.2e}",
8222                        a.x, a.y, a.z, b.x, b.y, b.z, m.x, m.y, m.z
8223                    );
8224                }
8225            }
8226            ogeom_bail!(Construction, "the pipe shell did not close");
8227        }
8228        make_solid(model, std::slice::from_ref(&sewn.shells[0]))?
8229    };
8230    built.history.generate(profile, built.shape.clone());
8231    built.history.generate(spine, built.shape.clone());
8232    Ok(built)
8233}
8234
8235/// The planar face a closed planar wire bounds.
8236pub(crate) fn bounded_face(model: &mut Model, wire: &Shape, tol: Tolerances) -> OgeomResult<Shape> {
8237    let Some(plane) = ogeom_algo::find_plane(model, wire, tol)? else {
8238        ogeom_bail!(Construction, "a pipe shell sweeps a planar profile");
8239    };
8240    let reach = 1e4_f64;
8241    let surface: SurfaceGeometry =
8242        PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
8243    let face = ogeom_algo::make_face(model, surface, std::slice::from_ref(wire), tol)?.shape;
8244    realized_profile(model, &face, &Transform::IDENTITY, tol)
8245}
8246
8247/// The walls of a solid swept from the face a closed planar wire bounds:
8248/// every face but the end caps, the planes through each of `ends` square
8249/// to its heading there, sewn into a shell (or a compound of shells).
8250pub(crate) fn walls_of(
8251    model: &mut Model,
8252    solid: &Shape,
8253    ends: &[(Point, Vector)],
8254    tol: Tolerances,
8255) -> OgeomResult<Shape> {
8256    let mut walls = Vec::new();
8257    for f in explore(model, solid, Filter::OfType(ShapeType::Face))? {
8258        let Some(ogeom_topo::NodeData::Face(data)) = model.node(&f).map(|n| n.data()) else {
8259            continue;
8260        };
8261        let cap = match model.geometry().surface(data.surface) {
8262            Some(SurfaceGeometry::Plane(p)) => {
8263                let placed = p.plane();
8264                ends.iter().any(|(at, n)| {
8265                    placed.normal().vector().cross(*n).magnitude() <= tol.angular()
8266                        && placed.distance_to(*at) <= tol.confusion() * 100.0
8267                })
8268            }
8269            _ => false,
8270        };
8271        if !cap {
8272            walls.push(f);
8273        }
8274    }
8275    let sewn = sew(model, &walls, tol)?;
8276    Ok(match sewn.shells.as_slice() {
8277        [shell] => shell.clone(),
8278        _ => ogeom_algo::make_compound(model, &sewn.shells)?.shape,
8279    })
8280}
8281
8282/// An edge's 3D curve and range, cloned out of the model.
8283/// A pipe shell whose spine turns a skew corner against a curved leg,
8284/// built as pieces between such corners and fused.
8285///
8286/// With the frame reflected across the mitre plane, a straight leg's walls
8287/// and a curved leg's cut that plane in sections that differ on the inside
8288/// of the turn: no single join row closes both. Each piece is swept on
8289/// straight past its corners (the frame carries unchanged along a straight
8290/// run), trimmed by each corner's mitre plane, and the pieces fused: where
8291/// their sections on the plane coincide the caps melt, and where they
8292/// differ the difference stands as a face of the mitre plane. Exact, and
8293/// the plain mitre wherever the two sections agree.
8294#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
8295fn mitred_pieces(
8296    model: &mut Model,
8297    profile: &Shape,
8298    spine: &Shape,
8299    stations: &[SpineStation],
8300    skew: &[(usize, usize)],
8301    ring: bool,
8302    probes: &[(f64, f64)],
8303    tolerance: f64,
8304    tol: Tolerances,
8305) -> OgeomResult<Built> {
8306    if model.kind_of(profile)? == ShapeType::Wire {
8307        // A closed planar wire sweeps the walls of the face it bounds: the
8308        // solid pieces are mitred and fused as for that face, and its end
8309        // caps (on the planes square to the spine's ends) are taken off.
8310        if !ogeom_algo::is_wire_closed(model, profile, tol)? {
8311            ogeom_bail!(
8312                Construction,
8313                "a skew corner against a curved leg is mitred by fusing solid \
8314                 pieces; an open wire bounds no face to sweep round it"
8315            );
8316        }
8317        let face = bounded_face(model, profile, tol)?;
8318        let solid = mitred_pieces(
8319            model, &face, spine, stations, skew, ring, probes, tolerance, tol,
8320        )?
8321        .shape;
8322        let ends: Vec<(Point, Vector)> = if ring {
8323            Vec::new()
8324        } else {
8325            vec![
8326                (stations[0].at, stations[0].tangent),
8327                (
8328                    stations[stations.len() - 1].at,
8329                    stations[stations.len() - 1].tangent,
8330                ),
8331            ]
8332        };
8333        let shape = walls_of(model, &solid, &ends, tol)?;
8334        let mut history = History::new();
8335        history.generate(profile, shape.clone());
8336        history.generate(spine, shape.clone());
8337        return Ok(Built::new(shape, history));
8338    }
8339    if model.kind_of(profile)? != ShapeType::Face {
8340        ogeom_bail!(Construction, "a pipe shell sweeps a planar wire or face");
8341    }
8342    let edges: Vec<Shape> = match model.kind_of(spine)? {
8343        ShapeType::Edge => vec![spine.clone()],
8344        _ => model.ordered_children_of(spine)?,
8345    };
8346    let normals = rmf_normals(stations);
8347    let reach_out = probes
8348        .iter()
8349        .map(|(a, b)| a.hypot(*b))
8350        .fold(0.0_f64, f64::max);
8351
8352    // Each split corner: the junction's edges, its point, the tangents
8353    // either side, the frame the far side starts in, and how far each side
8354    // runs on past it to cover the mitre plane across the whole profile.
8355    struct Split {
8356        edge_before: usize,
8357        at: Point,
8358        before: Vector,
8359        after: Vector,
8360        frame_after: Vector,
8361        run_on: f64,
8362    }
8363    let mut splits: Vec<Split> = Vec::with_capacity(skew.len());
8364    for &(k, next) in skew {
8365        let (before, after) = (stations[k].tangent, stations[next].tangent);
8366        let turn = before.dot(after).clamp(-1.0, 1.0).acos();
8367        let half = (turn * 0.5).cos();
8368        if half < 0.05 {
8369            ogeom_bail!(
8370                Construction,
8371                "the spine all but doubles back at a corner; a mitre there \
8372                 runs off to infinity"
8373            );
8374        }
8375        splits.push(Split {
8376            edge_before: stations[k].edge,
8377            at: stations[k].at,
8378            before,
8379            after,
8380            frame_after: normals[next],
8381            // The mitre plane stands at most `R·tan(φ/2)` past the corner
8382            // along either leg for a profile reaching `R` from the spine;
8383            // half as far again clears it with room.
8384            run_on: reach_out * ((turn * 0.5).tan() * 1.5 + 0.1),
8385        });
8386    }
8387    splits.sort_by_key(|s| s.edge_before);
8388
8389    // Pieces as runs of spine edges, each between split corners (or the
8390    // open spine's own ends).
8391    let count = edges.len();
8392    let mut pieces: Vec<(Vec<usize>, Option<usize>, Option<usize>)> = Vec::new();
8393    if ring {
8394        for (i, split) in splits.iter().enumerate() {
8395            let next = &splits[(i + 1) % splits.len()];
8396            let mut run = Vec::new();
8397            let mut e = (split.edge_before + 1) % count;
8398            loop {
8399                run.push(e);
8400                if e == next.edge_before {
8401                    break;
8402                }
8403                e = (e + 1) % count;
8404            }
8405            pieces.push((run, Some(i), Some((i + 1) % splits.len())));
8406        }
8407    } else {
8408        let mut first = 0;
8409        for (i, split) in splits.iter().enumerate() {
8410            pieces.push((
8411                (first..=split.edge_before).collect(),
8412                i.checked_sub(1),
8413                Some(i),
8414            ));
8415            first = split.edge_before + 1;
8416        }
8417        pieces.push(((first..count).collect(), splits.len().checked_sub(1), None));
8418    }
8419
8420    let x0 = normals[0];
8421    let start_frame = Frame::new(
8422        stations[0].at,
8423        Direction::new(stations[0].tangent, tol)?,
8424        Direction::new(x0, tol)?,
8425        tol,
8426    )?;
8427    // One block per split, standing on the mitre plane on the far side of
8428    // the corner: the piece before the corner is cut by it and the piece
8429    // after keeps what it shares with it, so both sides' caps are pieces
8430    // of the block's one face, on one surface and one chart, which is what
8431    // lets the fuse melt them.
8432    let mut blocks: Vec<Shape> = Vec::with_capacity(splits.len());
8433    for split in &splits {
8434        let n = (split.before + split.after) / (split.before + split.after).magnitude();
8435        let normal = Direction::new(n, tol)?;
8436        let plane = Plane::through(split.at, normal);
8437        let reach = (split.run_on + reach_out) * 4.0;
8438        let frame = plane.frame();
8439        let (u, v) = (frame.x().vector(), frame.y().vector());
8440        let corners: Vec<Point> = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
8441            .iter()
8442            .map(|(a, b)| split.at + u * (a * reach) + v * (b * reach))
8443            .collect();
8444        let wire = ogeom_algo::make_polygon(model, &corners, true, tol)?.shape;
8445        let edges = explore(model, &wire, Filter::OfType(ShapeType::Edge))?;
8446        let surface: SurfaceGeometry = PlaneSurface::over(
8447            plane,
8448            (-reach * 2.0, reach * 2.0),
8449            (-reach * 2.0, reach * 2.0),
8450        )?
8451        .into();
8452        let base = ogeom_algo::make_face_with_pcurves(model, surface, &[edges], tol)?.shape;
8453        let block = ogeom_algo::make_prism(model, &base, n * (reach * 2.0), tol)?.shape;
8454        blocks.push(block);
8455    }
8456
8457    let mut joined: Vec<Shape> = Vec::new();
8458    for (run, start, end) in pieces {
8459        let mut wire_edges: Vec<Shape> = Vec::new();
8460        let traversal = |model: &Model, e: usize, at_start: bool| -> OgeomResult<Shape> {
8461            // In the spine's own sense: a reversed edge's vertices come
8462            // back end first.
8463            let Some((a, b)) = ogeom_algo::edge_vertices(model, &edges[e])? else {
8464                ogeom_bail!(Construction, "a spine edge has no vertices");
8465            };
8466            Ok(if at_start { a } else { b })
8467        };
8468        if let Some(i) = start {
8469            let split = &splits[i];
8470            let far = ogeom_algo::make_vertex(model, split.at - split.after * split.run_on).shape;
8471            let near = traversal(model, run[0], true)?;
8472            let line: ogeom_geom::Curve =
8473                LineCurve::segment(split.at - split.after * split.run_on, split.at, tol)?.into();
8474            let domain = line.domain();
8475            wire_edges
8476                .push(ogeom_algo::make_edge_between(model, line, domain, &far, &near, tol)?.shape);
8477        }
8478        wire_edges.extend(run.iter().map(|&e| edges[e].clone()));
8479        if let Some(i) = end {
8480            let split = &splits[i];
8481            let near = traversal(model, run[run.len() - 1], false)?;
8482            let far = ogeom_algo::make_vertex(model, split.at + split.before * split.run_on).shape;
8483            let line: ogeom_geom::Curve =
8484                LineCurve::segment(split.at, split.at + split.before * split.run_on, tol)?.into();
8485            let domain = line.domain();
8486            wire_edges
8487                .push(ogeom_algo::make_edge_between(model, line, domain, &near, &far, tol)?.shape);
8488        }
8489        let sub_spine = ogeom_algo::make_wire(model, &wire_edges, tol)?.shape;
8490        // The profile where this piece starts: the spine's own start keeps
8491        // the caller's; a piece starting past a corner takes the profile
8492        // moved into the frame the corner's far side starts in, set back
8493        // along its run-on.
8494        let placed = match start {
8495            None => profile.clone(),
8496            Some(i) => {
8497                let split = &splits[i];
8498                let target = Frame::new(
8499                    split.at - split.after * split.run_on,
8500                    Direction::new(split.after, tol)?,
8501                    Direction::new(split.frame_after, tol)?,
8502                    tol,
8503                )?;
8504                let motion = Transform::from_frame(&target) * Transform::to_frame(&start_frame);
8505                realized_profile(model, profile, &motion, tol)?
8506            }
8507        };
8508        let mut piece = make_pipe_shell(model, &placed, &sub_spine, false, tolerance, tol)?.shape;
8509        if model.kind_of(&piece)? != ShapeType::Solid {
8510            ogeom_bail!(Construction, "a mitred piece did not sweep into a solid");
8511        }
8512        if let Some(i) = start {
8513            piece = ogeom_bool::common(model, &piece, &blocks[i], tol)?.shape;
8514        }
8515        if let Some(i) = end {
8516            piece = ogeom_bool::cut(model, &piece, &blocks[i], tol)?.shape;
8517        }
8518        joined.push(piece);
8519    }
8520    let result = fuse_in_order(model, joined, tol)?;
8521    let Some(shape) = result else {
8522        ogeom_bail!(Construction, "the spine produced no piece to sweep");
8523    };
8524    let mut history = History::new();
8525    history.generate(profile, shape.clone());
8526    for edge in &edges {
8527        history.generate(edge, shape.clone());
8528    }
8529    Ok(Built::new(shape, history))
8530}
8531
8532/// A planar profile face rebuilt under a rigid motion: every edge's curve
8533/// moved and re-bounded, vertices shared, the face on the moved plane.
8534fn realized_profile(
8535    model: &mut Model,
8536    profile: &Shape,
8537    motion: &Transform,
8538    tol: Tolerances,
8539) -> OgeomResult<Shape> {
8540    realized_profile_wound(model, profile, motion, None, tol)
8541}
8542
8543/// As [`realized_profile`], each ring wound about `about` where given: the
8544/// outer ring turning positively, every hole the other way.
8545fn realized_profile_wound(
8546    model: &mut Model,
8547    profile: &Shape,
8548    motion: &Transform,
8549    about: Option<Vector>,
8550    tol: Tolerances,
8551) -> OgeomResult<Shape> {
8552    let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
8553        ogeom_bail!(Construction, "a pipe shell sweeps a planar profile");
8554    };
8555    let moved_plane = Plane::through(
8556        motion.apply(plane.origin()),
8557        Direction::new(motion.apply_vector(plane.normal().vector()), tol)?,
8558    );
8559    let mut wires = realized_rings(model, profile, motion, tol)?;
8560    if let Some(axis) = about {
8561        for (k, ring) in wires.iter_mut().enumerate() {
8562            let turning = ring_turning(model, ring, axis, tol)?;
8563            if (turning > 0.0) != (k == 0) {
8564                *ring = ring.iter().rev().map(Shape::reversed).collect();
8565            }
8566        }
8567    }
8568    let reach = 1e4_f64;
8569    let surface: SurfaceGeometry =
8570        PlaneSurface::over(moved_plane, (-reach, reach), (-reach, reach))?.into();
8571    Ok(ogeom_algo::make_face_with_pcurves(model, surface, &wires, tol)?.shape)
8572}
8573
8574/// Each ring of a profile (a face's loops, outer first, or a wire) rebuilt
8575/// under a rigid motion: every edge's curve moved and re-bounded, the
8576/// vertices shared, each edge used as the ring uses it.
8577fn realized_rings(
8578    model: &mut Model,
8579    profile: &Shape,
8580    motion: &Transform,
8581    tol: Tolerances,
8582) -> OgeomResult<Vec<Vec<Shape>>> {
8583    use ogeom_geom::Transformable as _;
8584    let mut vertices: ogeom_core::FastMap<ogeom_topo::TShapeId, Shape> =
8585        ogeom_core::FastMap::default();
8586    let mut edge_copies: ogeom_core::FastMap<ogeom_topo::TShapeId, Shape> =
8587        ogeom_core::FastMap::default();
8588    let mut wires: Vec<Vec<Shape>> = Vec::new();
8589    for wire in explore(model, profile, Filter::OfType(ShapeType::Wire))? {
8590        let mut ring = Vec::new();
8591        for edge in model.ordered_children_of(&wire)? {
8592            let copy = match edge_copies.get(&edge.node()) {
8593                Some(done) => done.clone(),
8594                None => {
8595                    let (curve, range) = spine_curve_of(model, &edge)?;
8596                    let placed = curve.transformed(&edge.transform(model.datums())?, tol)?;
8597                    let moved = placed.transformed(motion, tol)?;
8598                    // The copy is of the edge itself, in its own sense; the
8599                    // ring's use of it is reapplied below.
8600                    let own = if edge.orientation() == ogeom_topo::Orientation::Reversed {
8601                        edge.reversed()
8602                    } else {
8603                        edge.clone()
8604                    };
8605                    let Some((a, b)) = ogeom_algo::edge_vertices(model, &own)? else {
8606                        ogeom_bail!(Construction, "a profile edge has no vertices");
8607                    };
8608                    let mut ends = Vec::with_capacity(2);
8609                    for v in [a, b] {
8610                        let key = v.node();
8611                        let held = match vertices.get(&key) {
8612                            Some(done) => done.clone(),
8613                            None => {
8614                                let Some(data) = model.node(&v).and_then(|n| n.data().as_vertex())
8615                                else {
8616                                    ogeom_bail!(Construction, "a profile vertex holds no data");
8617                                };
8618                                let at = v.transform(model.datums())?.apply(data.point);
8619                                let fresh = ogeom_algo::make_vertex(model, motion.apply(at)).shape;
8620                                vertices.insert(key, fresh.clone());
8621                                fresh
8622                            }
8623                        };
8624                        ends.push(held);
8625                    }
8626                    let fresh = ogeom_algo::make_edge_between(
8627                        model, moved, range, &ends[0], &ends[1], tol,
8628                    )?
8629                    .shape;
8630                    edge_copies.insert(edge.node(), fresh.clone());
8631                    fresh
8632                }
8633            };
8634            ring.push(if edge.orientation() == ogeom_topo::Orientation::Reversed {
8635                copy.reversed()
8636            } else {
8637                copy
8638            });
8639        }
8640        wires.push(ring);
8641    }
8642    Ok(wires)
8643}
8644
8645/// `ring` walked so it turns about `axis` positively where `outer`,
8646/// negatively otherwise: a face whose normal is `axis` keeps its material
8647/// on the left of an outer ring walked so, and of a hole walked the other
8648/// way.
8649fn walked_about(
8650    model: &Model,
8651    ring: &[Shape],
8652    axis: Vector,
8653    outer: bool,
8654    tol: Tolerances,
8655) -> OgeomResult<Vec<Shape>> {
8656    let turn = ring_turning(model, ring, axis, tol)?;
8657    Ok(if (turn > 0.0) == outer {
8658        ring.to_vec()
8659    } else {
8660        walked_back(ring)
8661    })
8662}
8663
8664/// `ring` walked the other way round: its edges in reverse order, each
8665/// reversed.
8666fn walked_back(ring: &[Shape]) -> Vec<Shape> {
8667    ring.iter().rev().map(Shape::reversed).collect()
8668}
8669
8670/// Twice the signed area a ring of edges encloses about `axis`, from its
8671/// edges sampled in the ring's own sense.
8672fn ring_turning(model: &Model, ring: &[Shape], axis: Vector, tol: Tolerances) -> OgeomResult<f64> {
8673    let mut points: Vec<Point> = Vec::new();
8674    for edge in ring {
8675        let (curve, range) = spine_curve_of(model, edge)?;
8676        let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
8677        for i in 0..32 {
8678            let f = f64::from(i) / 32.0;
8679            let t = if reversed {
8680                range.1 - (range.1 - range.0) * f
8681            } else {
8682                range.0 + (range.1 - range.0) * f
8683            };
8684            points.push(curve.point_at(t, tol)?);
8685        }
8686    }
8687    let Some(&origin) = points.first() else {
8688        return Ok(0.0);
8689    };
8690    let n = points.len();
8691    Ok((0..n)
8692        .map(|i| {
8693            (points[i] - origin)
8694                .cross(points[(i + 1) % n] - origin)
8695                .dot(axis)
8696        })
8697        .sum())
8698}
8699
8700pub(crate) fn spine_curve_of(
8701    model: &Model,
8702    edge: &Shape,
8703) -> OgeomResult<(ogeom_geom::Curve, (f64, f64))> {
8704    let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
8705        ogeom_bail!(Construction, "an edge holds no data");
8706    };
8707    let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
8708        ogeom_bail!(Construction, "an edge has no curve");
8709    };
8710    let Some(geometry) = model.geometry().curve(*curve) else {
8711        ogeom_bail!(Dangling, "curve is not in this model");
8712    };
8713    Ok((geometry.clone(), *range))
8714}
8715
8716/// A spine or guide (an edge or a wire) with each edge's placement baked
8717/// into its curve: the shape itself where no edge is placed, else an edge
8718/// or a wire of its own over the placed curves, each vertex the stated
8719/// vertex where it stands, so a wire that closes still closes. Every
8720/// reader of a spine's curves reads them as stored, so the sweeps read
8721/// their spine through this.
8722pub(crate) fn placed_path(model: &mut Model, path: &Shape, tol: Tolerances) -> OgeomResult<Shape> {
8723    let kind = model.kind_of(path)?;
8724    let edges = match kind {
8725        ShapeType::Edge => vec![path.clone()],
8726        ShapeType::Wire => model.ordered_children_of(path)?,
8727        _ => return Ok(path.clone()),
8728    };
8729    let curve_placed = |e: &Shape| {
8730        model
8731            .node(e)
8732            .and_then(|n| n.data().as_edge())
8733            .and_then(ogeom_topo::EdgeData::curve3d)
8734            .and_then(EdgeRepr::location)
8735            .is_some_and(|l| !l.is_identity())
8736    };
8737    if edges
8738        .iter()
8739        .all(|e| e.location().is_identity() && !curve_placed(e))
8740    {
8741        return Ok(path.clone());
8742    }
8743    // Each vertex occurrence, by node and placement, to its twin.
8744    let mut twins: Vec<(Shape, Shape)> = Vec::new();
8745    let mut twin_of = |model: &mut Model, vertex: &Shape| -> OgeomResult<Shape> {
8746        if let Some((_, twin)) = twins.iter().find(|(v, _)| v.is_same(vertex)) {
8747            return Ok(twin.clone());
8748        }
8749        let Some(data) = model.node(vertex).and_then(|n| n.data().as_vertex()) else {
8750            ogeom_bail!(Construction, "a spine vertex holds no point");
8751        };
8752        let at = vertex.transform(model.datums())?.apply(data.point);
8753        let held = data.tolerance;
8754        let twin = make_vertex(model, at).shape;
8755        model.widen(&twin, held)?;
8756        twins.push((vertex.clone(), twin.clone()));
8757        Ok(twin)
8758    };
8759    let mut placed: Vec<Shape> = Vec::with_capacity(edges.len());
8760    for edge in &edges {
8761        let Some((curve, range)) = ogeom_algo::placed_edge_curve(model, edge, tol)? else {
8762            ogeom_bail!(Construction, "an edge has no curve");
8763        };
8764        let Some((first, last)) = edge_vertices(model, edge)? else {
8765            ogeom_bail!(Construction, "a spine edge has no vertices");
8766        };
8767        let (a, b) = (twin_of(model, &first)?, twin_of(model, &last)?);
8768        let built = if edge.orientation() == ogeom_topo::Orientation::Reversed {
8769            make_edge_between(model, curve, range, &b, &a, tol)?
8770                .shape
8771                .reversed()
8772        } else {
8773            make_edge_between(model, curve, range, &a, &b, tol)?.shape
8774        };
8775        if let Some(held) = model.tolerance_of(edge)? {
8776            model.widen(&built, held)?;
8777        }
8778        placed.push(built);
8779    }
8780    if kind == ShapeType::Edge {
8781        return Ok(placed.swap_remove(0));
8782    }
8783    Ok(ogeom_algo::make_wire(model, &placed, tol)?.shape)
8784}
8785
8786/// The pipe shell around a spine that loops back on itself: one wall,
8787/// closed both ways round, no caps at all.
8788///
8789/// The frames are rotation-minimizing with the loop's holonomy paid off:
8790/// transported round a closed spine, the frame comes home twisted by some
8791/// angle, and that twist is spread back along the arc so the last station's
8792/// frame *is* the first's; without it the closed fit fights a helical
8793/// grid. The profile may be smooth or faceted, and each hole sweeps a void
8794/// tunnel of its own shell; the Frenet law rides the loop as well.
8795///
8796/// The auxiliary and binormal laws set a frame at each point of the spine,
8797/// single-valued round it, so theirs need no reconciling: the law gives
8798/// the normal at each station and between them, and the skin is held to
8799/// `tolerance` against it. A `twist` law of the length run turns the
8800/// section about the spine, ending on whole turns, or a part of one the
8801/// section's symmetry brings home onto itself (see `turned_loop_shell`),
8802/// refused by name otherwise.
8803#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
8804fn closed_pipe_shell(
8805    model: &mut Model,
8806    profile: &Shape,
8807    spine: &Shape,
8808    mut stations: Vec<SpineStation>,
8809    law: &PipeLaw<'_>,
8810    twist: &evolving::Ramp,
8811    tolerance: f64,
8812    tol: Tolerances,
8813) -> OgeomResult<Built> {
8814    let frenet = matches!(law, PipeLaw::Frenet);
8815    let classic = matches!(law, PipeLaw::RotationMinimizing | PipeLaw::Frenet);
8816    // The walk visits the join twice; the loop owns it once.
8817    let Some(home) = stations.pop() else {
8818        ogeom_bail!(Construction, "a closed spine needs room to turn");
8819    };
8820    if stations.len() < 3 {
8821        ogeom_bail!(Construction, "a closed spine needs room to turn");
8822    }
8823    // A sharp corner is a kink, and a kinked ring is mitred by the caller;
8824    // one arriving here has a heading that jumps between two stations.
8825    for i in 0..stations.len() {
8826        let next = &stations[(i + 1) % stations.len()];
8827        if stations[i].tangent.dot(next.tangent) < 0.9 {
8828            ogeom_bail!(
8829                Construction,
8830                "a closed spine turns too sharply between two of its stations to skin"
8831            );
8832        }
8833    }
8834
8835    let loops: Vec<Shape> = match model.kind_of(profile)? {
8836        ShapeType::Face => explore(model, profile, Filter::OfType(ShapeType::Wire))?,
8837        ShapeType::Wire => vec![profile.clone()],
8838        other => ogeom_bail!(
8839            Construction,
8840            "a pipe shell sweeps a planar wire or face, not a {other:?}"
8841        ),
8842    };
8843    // Every wire sweeps its own closed shell: the outer boundary first,
8844    // each hole a void tunnel inside it.
8845    let profile_loop = &loops[0];
8846    let edges = model.ordered_children_of(profile_loop)?;
8847    let smooth = edges.len() == 1
8848        && ogeom_algo::edge_vertices(model, &edges[0])?.is_some_and(|(a, b)| a.is_same(&b));
8849    let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
8850        ogeom_bail!(Construction, "a pipe shell sweeps a planar profile");
8851    };
8852    let t0 = stations[0].tangent;
8853    profile_square_at_start(model, &plane, spine, stations[0].at, tol)?;
8854
8855    // Frames with the loop's mismatch paid off: carry once more back to the
8856    // start, read the twist between departure and return, and spread it
8857    // along the arc. Rotation-minimizing frames owe this for their
8858    // holonomy; the Frenet law owes it too, because straight stretches
8859    // carry the frame through by continuation and the continuation is
8860    // path-dependent. One reconciliation serves both.
8861    let mut normals: Vec<Vector> = if !classic {
8862        law_normals(model, &stations, law, tolerance, tol)?
8863    } else if frenet {
8864        // The Frenet frame is the spine's own, single-valued round a loop:
8865        // read with wrapped neighbours it closes on itself and owes no
8866        // reconciliation. Read from a walk that visits the join twice it
8867        // does not: the one-sided differences at the walk's two ends
8868        // disagree with the interior, and the strips built on them miss
8869        // each other at the join by that kink.
8870        frenet_normals_closed(&stations, tol)?
8871    } else {
8872        let mut extended = stations.clone();
8873        extended.push(stations[0]);
8874        let carried = rmf_normals(&extended);
8875        let (n0, n_home) = (carried[0], carried[carried.len() - 1]);
8876        let twist = (n0.cross(n_home).dot(t0)).atan2(n0.dot(n_home));
8877        let mut lengths = vec![0.0_f64];
8878        for pair in extended.windows(2) {
8879            let last = lengths[lengths.len() - 1];
8880            lengths.push(last + pair[0].at.distance(pair[1].at));
8881        }
8882        let total = lengths[lengths.len() - 1];
8883        carried
8884            .iter()
8885            .take(stations.len())
8886            .enumerate()
8887            .map(|(i, n)| {
8888                let phi = -twist * lengths[i] / total;
8889                let t = extended[i].tangent;
8890                *n * phi.cos() + t.cross(*n) * phi.sin()
8891            })
8892            .collect()
8893    };
8894    for (n, station) in normals.iter_mut().zip(&stations) {
8895        // Re-square each corrected normal against its own tangent.
8896        let v = *n - station.tangent * n.dot(station.tangent);
8897        *n = v / v.magnitude();
8898    }
8899
8900    let x0 = normals[0];
8901    let origin = stations[0].at;
8902    // The way round once more to its start, at the end of the spine's
8903    // range, so the frame is carried between the last station and home.
8904    let (round, round_normals) = {
8905        let mut round = stations.clone();
8906        round.push(SpineStation {
8907            tangent: stations[0].tangent,
8908            ..home
8909        });
8910        let mut normals = normals.clone();
8911        normals.push(normals[0]);
8912        (round, normals)
8913    };
8914    let walk = SpineWalk {
8915        curves: walk_curves(model, spine)?,
8916        stations: &round,
8917        normals: &round_normals,
8918    };
8919    let guide = guide_curves(model, law)?;
8920    let lengths = if twist.is_level(0.0) {
8921        None
8922    } else {
8923        Some(evolving::SpineLengths::of(&walk.curves, tol)?)
8924    };
8925    let home = round.len() - 1;
8926    // A twist of part of a turn brings the section home turned onto
8927    // itself only where its symmetry closes it; each loop's shell sees to
8928    // that, and the section reads the whole twist at home.
8929    let end = twist.end();
8930    let turned = (end - (end / core::f64::consts::TAU).round() * core::f64::consts::TAU).abs()
8931        > tol.angular();
8932    // The share of the length run to `s`, which the twist turns the
8933    // section in proportion to.
8934    let share = |s: f64| -> OgeomResult<f64> {
8935        let Some(lengths) = &lengths else {
8936            return Ok(0.0);
8937        };
8938        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8939        let station = (s.fract() == 0.0).then(|| (s as usize) % home);
8940        let (edge, at_t) = match station {
8941            Some(0) => return Ok(if turned && s > 0.0 { 1.0 } else { 0.0 }),
8942            Some(i) => (round[i].edge, round[i].t),
8943            None => {
8944                let j = s.floor();
8945                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8946                let place = walk.place_between(j as usize, s - j);
8947                place
8948            }
8949        };
8950        lengths.fraction(&walk.curves, edge, at_t, tol)
8951    };
8952    // A station's own frame at a station, the home station the first
8953    // again; between them the frame carried, or the law's own; then the
8954    // twist the length run to there turns the section by.
8955    let carried = |s: f64, (a, b): (f64, f64)| -> OgeomResult<Point> {
8956        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8957        let station = (s.fract() == 0.0).then(|| (s as usize) % home);
8958        let (p, t, x) = if let Some(i) = station {
8959            let station = &round[i];
8960            (station.at, station.tangent, round_normals[i])
8961        } else {
8962            let (p, t, x) = walk.frame_at(s, (0, home), tol)?;
8963            let x = if classic {
8964                x
8965            } else {
8966                let here = SpineStation {
8967                    at: p,
8968                    tangent: t,
8969                    ..round[0]
8970                };
8971                law_normals_on(&guide, &[here], law, tolerance, tol)?[0]
8972            };
8973            (p, t, x)
8974        };
8975        let (a, b) = if lengths.is_some() {
8976            let (sin, cos) = twist.at(share(s)?).sin_cos();
8977            (a * cos - b * sin, a * sin + b * cos)
8978        } else {
8979            (a, b)
8980        };
8981        Ok(p + x * a + t.cross(x) * b)
8982    };
8983    let mut shells: Vec<Shape> = Vec::with_capacity(loops.len());
8984    for (li, wire) in loops.iter().enumerate() {
8985        let wire_edges = model.ordered_children_of(wire)?;
8986        let wire_smooth = wire_edges.len() == 1
8987            && ogeom_algo::edge_vertices(model, &wire_edges[0])?
8988                .is_some_and(|(a, b)| a.is_same(&b));
8989        let shell = if turned {
8990            turned_loop_shell(
8991                model,
8992                &wire_edges,
8993                wire_smooth,
8994                &round,
8995                &carried,
8996                &share,
8997                (origin, x0),
8998                tolerance,
8999                tol,
9000            )?
9001        } else {
9002            closed_loop_shell(
9003                model,
9004                wire,
9005                &wire_edges,
9006                wire_smooth,
9007                &round,
9008                &carried,
9009                (origin, x0),
9010                tolerance,
9011                tol,
9012            )?
9013        };
9014        // The material side follows each loop's own winding, so it is
9015        // read off the shell itself: the outer shell faces out of what it
9016        // encloses, a void's faces toward its tunnel.
9017        let enclosed = shell_signed_volume(model, &shell, tol)?;
9018        let outward = enclosed > 0.0;
9019        shells.push(if outward == (li == 0) {
9020            shell
9021        } else {
9022            shell.reversed()
9023        });
9024    }
9025    let mut built = make_solid(model, &shells)?;
9026    built.history.generate(profile, built.shape.clone());
9027    built.history.generate(spine, built.shape.clone());
9028    let _ = (smooth, profile_loop, edges);
9029    Ok(built)
9030}
9031
9032/// The volume a closed shell encloses as it faces, from its mesh: negative
9033/// where its faces point into what it bounds.
9034fn shell_signed_volume(model: &Model, shell: &Shape, tol: Tolerances) -> OgeomResult<f64> {
9035    Ok(ogeom_mesh::triangulate(model, shell, ogeom_mesh::Deflection::default(), tol)?.volume())
9036}
9037
9038/// Sample a spine (one edge or a wire of them) into stations, each edge
9039/// given a station count by its own turning.
9040fn shell_stations(model: &Model, spine: &Shape, tol: Tolerances) -> OgeomResult<Vec<SpineStation>> {
9041    let edges: Vec<Shape> = match model.kind_of(spine)? {
9042        ShapeType::Edge => vec![spine.clone()],
9043        ShapeType::Wire => model.ordered_children_of(spine)?,
9044        other => ogeom_bail!(
9045            Construction,
9046            "a pipe shell runs along an edge or a wire, not a {other:?}"
9047        ),
9048    };
9049    if edges.is_empty() {
9050        ogeom_bail!(Construction, "the spine has no edge to run along");
9051    }
9052    let mut stations: Vec<SpineStation> = Vec::new();
9053    for (ei, edge) in edges.iter().enumerate() {
9054        let (curve, range) = {
9055            let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
9056                ogeom_bail!(Construction, "a spine edge holds no data");
9057            };
9058            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
9059                ogeom_bail!(Construction, "a spine edge has no curve");
9060            };
9061            let Some(geometry) = model.geometry().curve(*curve) else {
9062                ogeom_bail!(Dangling, "curve is not in this model");
9063            };
9064            (geometry.clone(), *range)
9065        };
9066        let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
9067        // Stations by turning: sample tangents coarsely, sum the angles, and
9068        // give each edge enough stations that no step turns more than a few
9069        // degrees. A straight edge keeps a healthy minimum for the fit.
9070        let turning = {
9071            let mut sum = 0.0_f64;
9072            let mut last: Option<Vector> = None;
9073            for i in 0..=16 {
9074                let t = range.0 + (range.1 - range.0) * f64::from(i) / 16.0;
9075                let d = curve.d1_at(t, tol)?;
9076                let m = d.magnitude();
9077                if m <= tol.confusion() {
9078                    continue;
9079                }
9080                let u = d / m;
9081                if let Some(prev) = last {
9082                    sum += prev.dot(u).clamp(-1.0, 1.0).acos() * 16.0 / 16.0;
9083                }
9084                last = Some(u);
9085            }
9086            sum
9087        };
9088        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
9089        let count = (turning / (core::f64::consts::TAU / 64.0)).ceil().max(8.0) as usize;
9090        for i in 0..=count {
9091            #[allow(clippy::cast_precision_loss)]
9092            let f = (i as f64) / (count as f64);
9093            let t = if reversed {
9094                range.1 - (range.1 - range.0) * f
9095            } else {
9096                range.0 + (range.1 - range.0) * f
9097            };
9098            let p = curve.point_at(t, tol)?;
9099            let d = curve.d1_at(t, tol)?;
9100            let m = d.magnitude();
9101            if m <= tol.confusion() {
9102                ogeom_bail!(Construction, "the spine is degenerate at {t}");
9103            }
9104            let tangent = if reversed { -(d / m) } else { d / m };
9105            if let Some(prev) = stations.last()
9106                && prev.edge == ei
9107                && prev.at.distance(p) <= tol.confusion()
9108                && prev.tangent.cross(tangent).magnitude() <= tol.angular()
9109                && prev.tangent.dot(tangent) > 0.0
9110            {
9111                continue;
9112            }
9113            // A station coincident with the last but on the next edge is a
9114            // twin: heading elsewhere it is a *corner* the sweep mitres, and
9115            // heading on it is a smooth junction where the next edge's own
9116            // run of skin begins, since one fit across two curves' joins
9117            // cannot follow the step in their curvature.
9118            stations.push(SpineStation {
9119                at: p,
9120                tangent,
9121                edge: ei,
9122                t,
9123            });
9124        }
9125    }
9126    if stations.len() < 2 {
9127        ogeom_bail!(Construction, "the spine collapses to a point");
9128    }
9129    Ok(stations)
9130}
9131
9132/// Frenet normals: each station's frame turns with the spine's own
9133/// curvature, read from the tangents' finite differences. Straight runs
9134/// carry the last bending station's normal forward; a spine that never
9135/// bends has no Frenet frame at all and is refused by name.
9136fn frenet_normals(stations: &[SpineStation], tol: Tolerances) -> OgeomResult<Vec<Vector>> {
9137    let mut normals: Vec<Option<Vector>> = Vec::with_capacity(stations.len());
9138    for i in 0..stations.len() {
9139        let (before, after) = (
9140            &stations[i.saturating_sub(1)],
9141            &stations[(i + 1).min(stations.len() - 1)],
9142        );
9143        let dt = after.tangent - before.tangent;
9144        let t = stations[i].tangent;
9145        let bend = dt - t * dt.dot(t);
9146        let m = bend.magnitude();
9147        normals.push(if m > tol.angular().max(1e-9) {
9148            Some(bend / m)
9149        } else {
9150            None
9151        });
9152    }
9153    // Carry forward, then backward, so straight lead-ins take the first
9154    // bend's frame rather than none.
9155    let mut carried: Vec<Vector> = Vec::with_capacity(stations.len());
9156    let mut last: Option<Vector> = None;
9157    for n in &normals {
9158        if let Some(n) = n {
9159            last = Some(*n);
9160        }
9161        carried.push(last.unwrap_or(Vector::new(0.0, 0.0, 0.0)));
9162    }
9163    let mut ahead: Option<Vector> = None;
9164    for i in (0..stations.len()).rev() {
9165        if let Some(n) = normals[i] {
9166            ahead = Some(n);
9167        } else if carried[i].magnitude() < 0.5
9168            && let Some(n) = ahead
9169        {
9170            carried[i] = n;
9171        }
9172    }
9173    if carried.iter().any(|n| n.magnitude() < 0.5) {
9174        ogeom_bail!(
9175            Construction,
9176            "a straight spine has no Frenet frame; use the \
9177             rotation-minimizing default"
9178        );
9179    }
9180    Ok(carried)
9181}
9182
9183/// Frenet normals round a closed loop: each station's bend read from its
9184/// neighbours across the join as well, so the field is periodic. A loop
9185/// with a straight stretch carries the last bend's normal through it, as
9186/// the open form does; a loop that never bends has no Frenet frame.
9187fn frenet_normals_closed(stations: &[SpineStation], tol: Tolerances) -> OgeomResult<Vec<Vector>> {
9188    let n = stations.len();
9189    let mut normals: Vec<Option<Vector>> = Vec::with_capacity(n);
9190    for i in 0..n {
9191        let (before, after) = (&stations[(i + n - 1) % n], &stations[(i + 1) % n]);
9192        let dt = after.tangent - before.tangent;
9193        let t = stations[i].tangent;
9194        let bend = dt - t * dt.dot(t);
9195        let m = bend.magnitude();
9196        normals.push(if m > tol.angular().max(1e-9) {
9197            Some(bend / m)
9198        } else {
9199            None
9200        });
9201    }
9202    let Some(first_bend) = normals.iter().position(Option::is_some) else {
9203        ogeom_bail!(
9204            Construction,
9205            "a straight spine has no Frenet frame; use the \
9206             rotation-minimizing default"
9207        );
9208    };
9209    // Carry forward round the loop from the first bend, so a straight
9210    // stretch anywhere takes the bend behind it.
9211    let mut carried: Vec<Vector> = vec![Vector::new(0.0, 0.0, 0.0); n];
9212    let mut last = normals[first_bend].unwrap_or_else(|| unreachable!());
9213    for k in 0..n {
9214        let i = (first_bend + k) % n;
9215        if let Some(bend) = normals[i] {
9216            last = bend;
9217        }
9218        carried[i] = last;
9219    }
9220    Ok(carried)
9221}
9222
9223// --- the evolved shape -------------------------------------------------------
9224
9225/// One station of the spine: where it is, which way it runs, and how it gets
9226/// there.
9227struct Station {
9228    /// Where the traversal enters and leaves this edge.
9229    from: Point,
9230    to: Point,
9231    /// The unit tangent at each end, in the direction of travel.
9232    tangent_in: Vector,
9233    tangent_out: Vector,
9234    /// A straight run, or a turn about an axis through an angle.
9235    turn: Option<(ogeom_math::Axis, f64)>,
9236}
9237
9238/// Sweep a profile along a spine, the way a moulding runs round a frame.
9239///
9240/// The spine is a **planar** wire, or a planar face whose outer wire is taken.
9241/// The profile is a wire standing in a plane that contains the spine's own
9242/// normal, positioned where the spine starts. What comes back is what the
9243/// profile sweeps out as it travels the spine, always square to it:
9244///
9245/// - a straight spine edge extrudes the profile (a prism);
9246/// - a circular one turns it about that arc's own axis (a revolution);
9247/// - and each corner between them turns it about the corner, through exactly
9248///   the angle the spine turns there, which is the join the 2D offset makes
9249///   for the same reason.
9250///
9251/// Every piece is exact: the surfaces are the ones a prism and a revolution
9252/// give for the profile's own curves, and nothing is fitted. The pieces are
9253/// then unioned, which is the assembly's real name: consecutive pieces meet
9254/// on the *same* placed profile, and a coincident face is what the boolean
9255/// identifies rather than probes across.
9256///
9257/// # Volume or shell
9258///
9259/// The result is always a volume, and which spine is given is what says
9260/// whether there is one to have. A **closed** profile bounds its own section
9261/// and sweeps a solid along either kind of spine. An **open** one does not,
9262/// and there is exactly one honest way to close it: against the plane a
9263/// **face** spine was drawn in, whose own plane the profile's two ends must
9264/// reach. An open profile along a wire spine is refused, and the refusal says
9265/// which spine would close it.
9266///
9267/// # Errors
9268///
9269/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the spine is
9270/// not a planar wire or face, if it carries an edge that is neither straight
9271/// nor circular, if the profile is not planar, if the profile's plane does not
9272/// contain the spine's normal or does not cut across it (a profile that leans
9273/// or lies along is not square to the spine), or if an open profile has no
9274/// spine plane to close against.
9275/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) where a corner's turn
9276/// would sweep the profile across the corner itself, which no revolution can
9277/// express.
9278pub fn make_evolved(
9279    model: &mut Model,
9280    spine: &Shape,
9281    profile: &Shape,
9282    tol: Tolerances,
9283) -> OgeomResult<Built> {
9284    use ogeom_algo::{make_prism, make_revolution, transformed};
9285
9286    let (wire, capped_by_spine_plane) = match model.kind_of(spine)? {
9287        ShapeType::Face => {
9288            let wires = explore(model, spine, Filter::OfType(ShapeType::Wire))?;
9289            let Some(outer) = wires.first().cloned() else {
9290                ogeom_bail!(Construction, "a face with no wire has no spine to run");
9291            };
9292            (outer, true)
9293        }
9294        ShapeType::Wire => (spine.clone(), false),
9295        other => ogeom_bail!(
9296            Construction,
9297            "a {other:?} is not a spine; sweep along a wire or a planar face"
9298        ),
9299    };
9300
9301    let stations = spine_stations(model, &wire, tol)?;
9302    if stations.is_empty() {
9303        ogeom_bail!(Construction, "a spine with no edges goes nowhere");
9304    }
9305    let normal = spine_normal(&stations, tol)?;
9306    let (profile_origin, profile_normal) = profile_plane(model, profile, tol)?;
9307    if profile_normal.dot(normal.vector()).abs() > tol.angular() {
9308        ogeom_bail!(
9309            Construction,
9310            "the profile's plane must contain the spine's normal, or the \
9311             profile is not square to the spine it travels"
9312        );
9313    }
9314    let start = &stations[0];
9315    if profile_normal.cross(start.tangent_in).magnitude() > tol.angular() {
9316        ogeom_bail!(
9317            Construction,
9318            "the profile's plane must cut the spine across, not run along it: \
9319             the profile is not square to the spine it travels"
9320        );
9321    }
9322    let _ = profile_origin;
9323    let reference = (start.from, start.tangent_in);
9324
9325    // The profile as a face, which is what makes each swept piece a *solid*
9326    // and the assembly a union rather than a hopeful sew. An open profile is
9327    // closed against the spine's own plane, which is exactly what a face
9328    // spine offers and a wire spine does not.
9329    let section = profile_face(
9330        model,
9331        profile,
9332        profile_normal,
9333        capped_by_spine_plane.then(|| Plane::through(start.from, normal)),
9334        tol,
9335    )?;
9336
9337    let mut pieces: Vec<Shape> = Vec::new();
9338    for (index, station) in stations.iter().enumerate() {
9339        // The corner *before* this station, so the pieces come out in the
9340        // order the spine runs them.
9341        if index > 0 {
9342            let previous = &stations[index - 1];
9343            if let Some(piece) = corner_piece(
9344                model,
9345                &section,
9346                reference,
9347                previous.to,
9348                previous.tangent_out,
9349                station.tangent_in,
9350                normal,
9351                tol,
9352            )? {
9353                pieces.push(piece);
9354            }
9355        }
9356        let placed = transformed(
9357            model,
9358            &section,
9359            station_transform(reference, station.from, station.tangent_in, normal, tol)?,
9360        )?
9361        .shape;
9362        pieces.push(match station.turn {
9363            None => make_prism(model, &placed, station.to - station.from, tol)?.shape,
9364            Some((axis, angle)) => make_revolution(model, &placed, axis, angle, tol)?.shape,
9365        });
9366    }
9367    // A closed spine turns at the join between its last edge and its first
9368    // just as it does anywhere else.
9369    let last = &stations[stations.len() - 1];
9370    if last.to.distance(start.from) <= tol.confusion()
9371        && let Some(piece) = corner_piece(
9372            model,
9373            &section,
9374            reference,
9375            last.to,
9376            last.tangent_out,
9377            start.tangent_in,
9378            normal,
9379            tol,
9380        )?
9381    {
9382        pieces.push(piece);
9383    }
9384
9385    // The union, in the order the spine runs: consecutive pieces meet on the
9386    // *same* placed profile, which is the coincident-face case the boolean
9387    // resolves by identifying it rather than by probing across it.
9388    let mut history = History::new();
9389    let Some(shape) = fuse_in_order(model, pieces, tol)? else {
9390        ogeom_bail!(Construction, "the spine produced no piece to sweep");
9391    };
9392    history.generate(spine, shape.clone());
9393    history.generate(profile, shape.clone());
9394    Ok(Built::new(shape, history))
9395}
9396
9397/// The profile as a face.
9398///
9399/// A closed profile bounds its own area. An open one does not, and there is
9400/// exactly one honest way to close it: against the plane the spine was given
9401/// as a face *in*, which is what a face spine says to do and a wire spine has
9402/// no answer for. The closing segment runs between the profile's two ends, and
9403/// both have to be on that plane or the profile does not reach it.
9404fn profile_face(
9405    model: &mut Model,
9406    profile: &Shape,
9407    profile_normal: Vector,
9408    against: Option<Plane>,
9409    tol: Tolerances,
9410) -> OgeomResult<Shape> {
9411    if model.kind_of(profile)? == ShapeType::Face {
9412        return Ok(profile.clone());
9413    }
9414    if model.kind_of(profile)? != ShapeType::Wire {
9415        ogeom_bail!(Construction, "a profile is a wire or a face");
9416    }
9417    let mut edges = ogeom_topo::explore(model, profile, Filter::OfType(ShapeType::Edge))?;
9418    let closed = ogeom_algo::is_wire_closed(model, profile, tol)?;
9419    if !closed {
9420        let Some(plane) = against else {
9421            ogeom_bail!(
9422                Construction,
9423                "an open profile sweeps a shell, not a volume; give the spine \
9424                 as a planar face for its plane to close the profile against, \
9425                 or close the profile itself"
9426            );
9427        };
9428        let [(from, v0), (to, v1)] = wire_ends(model, profile, tol)?;
9429        for end in [from, to] {
9430            if plane.signed_distance_to(end).abs() > tol.confusion() * 1e2 {
9431                ogeom_bail!(
9432                    Construction,
9433                    "an open profile is closed against the spine face's own \
9434                     plane, and this one does not reach it"
9435                );
9436            }
9437        }
9438        // Built on the profile's *own* end vertices, so the closed ring is a
9439        // wire rather than edges that merely touch.
9440        let line = LineCurve::new(ogeom_math::Axis {
9441            location: from,
9442            direction: Direction::new(to - from, tol)?,
9443        });
9444        edges.push(
9445            make_edge_between(model, line.into(), (0.0, from.distance(to)), &v0, &v1, tol)?.shape,
9446        );
9447    }
9448    let ordered = ogeom_algo::order_edges(model, &edges, tol)?;
9449    let mut bound = ogeom_math::Aabb::EMPTY;
9450    for edge in &ordered {
9451        bound = bound.union(&ogeom_algo::shape_bounds(model, edge, tol)?);
9452    }
9453    let Some(centre) = bound.centre() else {
9454        ogeom_bail!(Construction, "a profile with no extent sweeps nothing");
9455    };
9456    let reach = bound.diagonal().mul_add(2.0, 1.0);
9457    let plane = Plane::through(centre, Direction::new(profile_normal, tol)?);
9458    let surface = PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?;
9459    Ok(make_face_with_pcurves(model, surface.into(), &[ordered], tol)?.shape)
9460}
9461
9462/// Where an open wire begins and ends: the point, and the vertex there.
9463fn wire_ends(model: &Model, wire: &Shape, tol: Tolerances) -> OgeomResult<[(Point, Shape); 2]> {
9464    let mut counts: Vec<(Point, Shape, usize)> = Vec::new();
9465    for edge in explore(model, wire, Filter::OfType(ShapeType::Edge))? {
9466        for v in explore(model, &edge, Filter::OfType(ShapeType::Vertex))? {
9467            let Some(data) = model.node(&v).and_then(|n| n.data().as_vertex()) else {
9468                continue;
9469            };
9470            let at = v.transform(model.datums())?.apply(data.point);
9471            match counts
9472                .iter_mut()
9473                .find(|(p, _, _)| p.distance(at) <= tol.confusion() * 10.0)
9474            {
9475                Some((_, _, n)) => *n += 1,
9476                None => counts.push((at, v.clone(), 1)),
9477            }
9478        }
9479    }
9480    let free: Vec<(Point, Shape)> = counts
9481        .into_iter()
9482        .filter(|(_, _, n)| *n == 1)
9483        .map(|(p, v, _)| (p, v))
9484        .collect();
9485    if free.len() != 2 {
9486        ogeom_bail!(
9487            Construction,
9488            "an open profile has exactly two ends; this one has {}",
9489            free.len()
9490        );
9491    }
9492    let mut ends = free.into_iter();
9493    let (Some(a), Some(b)) = (ends.next(), ends.next()) else {
9494        ogeom_bail!(Construction, "the profile lost an end between checks");
9495    };
9496    Ok([a, b])
9497}
9498
9499/// The spine, edge by edge, in the order the wire runs it.
9500fn spine_stations(model: &Model, wire: &Shape, tol: Tolerances) -> OgeomResult<Vec<Station>> {
9501    let mut out = Vec::new();
9502    for edge in explore(model, wire, Filter::OfType(ShapeType::Edge))? {
9503        let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
9504            ogeom_bail!(Construction, "a spine edge is not in this model");
9505        };
9506        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
9507            ogeom_bail!(Construction, "a spine edge with no curve runs nowhere");
9508        };
9509        let Some(geometry) = model.geometry().curve(*curve) else {
9510            ogeom_bail!(Dangling, "curve is not in this model");
9511        };
9512        let placed = geometry.transformed(&edge.transform(model.datums())?, tol)?;
9513        let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
9514        let (t0, t1) = if reversed {
9515            (range.1, range.0)
9516        } else {
9517            (range.0, range.1)
9518        };
9519        let sign = if reversed { -1.0 } else { 1.0 };
9520        let unit = |t: f64| -> OgeomResult<Vector> {
9521            let d = placed.d1_at(t, tol)? * sign;
9522            if d.magnitude() <= tol.confusion() {
9523                ogeom_bail!(Construction, "a spine edge has no direction at {t}");
9524            }
9525            Ok(d / d.magnitude())
9526        };
9527        let station = match &placed {
9528            Curve::Line(_) => Station {
9529                from: placed.point_at(t0, tol)?,
9530                to: placed.point_at(t1, tol)?,
9531                tangent_in: unit(t0)?,
9532                tangent_out: unit(t1)?,
9533                turn: None,
9534            },
9535            Curve::Circle(c) => {
9536                let circle = c.circle();
9537                let swept = (range.1 - range.0).abs();
9538                let axis = ogeom_math::Axis {
9539                    location: circle.centre(),
9540                    direction: if reversed {
9541                        -circle.frame().z()
9542                    } else {
9543                        circle.frame().z()
9544                    },
9545                };
9546                Station {
9547                    from: placed.point_at(t0, tol)?,
9548                    to: placed.point_at(t1, tol)?,
9549                    tangent_in: unit(t0)?,
9550                    tangent_out: unit(t1)?,
9551                    turn: Some((axis, swept)),
9552                }
9553            }
9554            other => ogeom_bail!(
9555                Construction,
9556                "a spine runs on straight and circular edges; a {:?} sweeps a \
9557                 surface this construction does not have",
9558                other.kind()
9559            ),
9560        };
9561        out.push(station);
9562    }
9563    Ok(out)
9564}
9565
9566/// The spine's own normal, and the check that it has one.
9567///
9568/// Taken from the first turn the spine makes (a corner or an arc) because
9569/// that is exact, and then measured against every station: a spine that
9570/// leaves its own plane has no square profile to carry, and says so here
9571/// rather than by producing a shape nobody asked for.
9572fn spine_normal(stations: &[Station], tol: Tolerances) -> OgeomResult<Direction> {
9573    let mut best: Option<(f64, Vector)> = None;
9574    let mut consider = |a: Vector, b: Vector| {
9575        let cross = a.cross(b);
9576        let magnitude = cross.magnitude();
9577        if magnitude > best.map_or(tol.angular(), |(m, _)| m) {
9578            best = Some((magnitude, cross / magnitude));
9579        }
9580    };
9581    for (index, station) in stations.iter().enumerate() {
9582        consider(station.tangent_in, station.tangent_out);
9583        if index + 1 < stations.len() {
9584            consider(station.tangent_out, stations[index + 1].tangent_in);
9585        }
9586    }
9587    if stations.len() > 1 {
9588        consider(
9589            stations[stations.len() - 1].tangent_out,
9590            stations[0].tangent_in,
9591        );
9592    }
9593    let Some((_, normal)) = best else {
9594        ogeom_bail!(
9595            Construction,
9596            "a spine that never turns has no plane of its own; give the \
9597             profile's own orientation a spine with at least one corner or arc"
9598        );
9599    };
9600    for station in stations {
9601        for tangent in [station.tangent_in, station.tangent_out] {
9602            if tangent.dot(normal).abs() > tol.angular() {
9603                ogeom_bail!(
9604                    Construction,
9605                    "the spine leaves its own plane; an evolved sweep runs a \
9606                     planar spine"
9607                );
9608            }
9609        }
9610        if let Some((axis, _)) = &station.turn
9611            && axis.direction.vector().cross(normal).magnitude() > tol.angular()
9612        {
9613            ogeom_bail!(
9614                Construction,
9615                "a spine arc turns about an axis off the spine's own normal"
9616            );
9617        }
9618    }
9619    Direction::new(normal, tol)
9620}
9621
9622/// The profile's plane: a point on it and its normal.
9623fn profile_plane(model: &Model, profile: &Shape, tol: Tolerances) -> OgeomResult<(Point, Vector)> {
9624    let mut points: Vec<Point> = Vec::new();
9625    for edge in explore(model, profile, Filter::OfType(ShapeType::Edge))? {
9626        let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
9627            continue;
9628        };
9629        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
9630            continue;
9631        };
9632        let Some(geometry) = model.geometry().curve(*curve) else {
9633            ogeom_bail!(Dangling, "curve is not in this model");
9634        };
9635        let placed = geometry.transformed(&edge.transform(model.datums())?, tol)?;
9636        for i in 0..=8 {
9637            let t = range.0 + (range.1 - range.0) * f64::from(i) / 8.0;
9638            points.push(placed.point_at(t, tol)?);
9639        }
9640    }
9641    if points.len() < 3 {
9642        ogeom_bail!(Construction, "a profile needs an extent to sweep");
9643    }
9644    let origin = points[0];
9645    // The widest cross product among the sampled offsets: the plane's normal,
9646    // taken where it is best conditioned rather than from the first three
9647    // points that happen to be there.
9648    let mut best: Option<(f64, Vector)> = None;
9649    for (i, a) in points.iter().enumerate() {
9650        for b in points.iter().skip(i + 1) {
9651            let cross = (*a - origin).cross(*b - origin);
9652            let magnitude = cross.magnitude();
9653            if magnitude > best.map_or(tol.confusion(), |(m, _)| m) {
9654                best = Some((magnitude, cross / magnitude));
9655            }
9656        }
9657    }
9658    let Some((_, normal)) = best else {
9659        ogeom_bail!(Construction, "a profile with no area has no plane");
9660    };
9661    for p in &points {
9662        if (*p - origin).dot(normal).abs() > tol.confusion() * 1e2 {
9663            ogeom_bail!(Construction, "the profile is not planar");
9664        }
9665    }
9666    Ok((origin, normal))
9667}
9668
9669/// The rigid motion that carries the profile from the spine's start to a
9670/// station: a turn about the spine's normal, then a translation.
9671fn station_transform(
9672    reference: (Point, Vector),
9673    at: Point,
9674    tangent: Vector,
9675    normal: Direction,
9676    tol: Tolerances,
9677) -> OgeomResult<Transform> {
9678    let (origin, from) = reference;
9679    let n = normal.vector();
9680    let angle = from.cross(tangent).dot(n).atan2(from.dot(tangent));
9681    let turn = if angle.abs() <= tol.angular() {
9682        Transform::IDENTITY
9683    } else {
9684        Transform::rotation(
9685            ogeom_math::Axis {
9686                location: origin,
9687                direction: normal,
9688            },
9689            angle,
9690        )
9691    };
9692    Ok(Transform::translation(at - origin) * turn)
9693}
9694
9695/// The wedge a corner adds: the profile turned about the corner, through
9696/// exactly the angle the spine turns there.
9697///
9698/// `None` where the spine does not turn; two edges meeting smoothly leave no
9699/// wedge to fill.
9700#[allow(clippy::too_many_arguments)]
9701fn corner_piece(
9702    model: &mut Model,
9703    profile: &Shape,
9704    reference: (Point, Vector),
9705    corner: Point,
9706    incoming: Vector,
9707    outgoing: Vector,
9708    normal: Direction,
9709    tol: Tolerances,
9710) -> OgeomResult<Option<Shape>> {
9711    let n = normal.vector();
9712    let angle = incoming
9713        .cross(outgoing)
9714        .dot(n)
9715        .atan2(incoming.dot(outgoing));
9716    if angle.abs() <= tol.angular() {
9717        return Ok(None);
9718    }
9719    let placed = ogeom_algo::transformed(
9720        model,
9721        profile,
9722        station_transform(reference, corner, incoming, normal, tol)?,
9723    )?
9724    .shape;
9725    let axis = ogeom_math::Axis {
9726        location: corner,
9727        direction: if angle > 0.0 { normal } else { -normal },
9728    };
9729    let turned = ogeom_algo::make_revolution(model, &placed, axis, angle.abs(), tol);
9730    match turned {
9731        Ok(built) => Ok(Some(built.shape)),
9732        Err(_) => ogeom_bail!(
9733            NotDone,
9734            "the profile straddles the spine at a corner, so turning it about \
9735             that corner sweeps it through itself; there is no revolution for \
9736             that wedge"
9737        ),
9738    }
9739}
9740
9741/// Pieces laid end to end, fused: pairs of neighbours first, then pairs of
9742/// those, so each fuse joins two runs of about equal size where one at a
9743/// time fused every piece onto everything before it, redoing the whole run
9744/// so far each time. Neighbours stay neighbours, so every fuse still meets
9745/// its partner across the shared section. `None` for no pieces.
9746fn fuse_in_order(
9747    model: &mut Model,
9748    pieces: Vec<Shape>,
9749    tol: Tolerances,
9750) -> OgeomResult<Option<Shape>> {
9751    let mut runs = pieces;
9752    while runs.len() > 1 {
9753        let mut next = Vec::with_capacity(runs.len().div_ceil(2));
9754        let mut pending = runs.into_iter();
9755        while let Some(first) = pending.next() {
9756            match pending.next() {
9757                Some(second) => next.push(ogeom_bool::fuse(model, &first, &second, tol)?.shape),
9758                None => next.push(first),
9759            }
9760        }
9761        runs = next;
9762    }
9763    Ok(runs.pop())
9764}