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
28/// Sweep a circular profile of `radius` along a spine edge.
29///
30/// A straight spine gives a cylinder, a full circular spine a torus, an arc
31/// a torus segment. The history generates the solid from the spine.
32///
33/// # Errors
34///
35/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the spine is
36/// not straight or circular, the profile radius is not usable, or the tube
37/// would swallow its own spine.
38pub fn make_pipe(
39    model: &mut Model,
40    spine: &Shape,
41    radius: f64,
42    tol: Tolerances,
43) -> OgeomResult<Built> {
44    if !radius.is_finite() || radius <= tol.confusion() {
45        ogeom_bail!(Construction, "a pipe of radius {radius} holds nothing");
46    }
47    let (curve, range) = {
48        let Some(data) = model.node(spine).and_then(|n| n.data().as_edge()) else {
49            ogeom_bail!(Construction, "a pipe runs along an edge");
50        };
51        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
52            ogeom_bail!(Construction, "the spine has no curve");
53        };
54        let Some(geometry) = model.geometry().curve(*curve) else {
55            ogeom_bail!(Dangling, "curve is not in this model");
56        };
57        (geometry.clone(), *range)
58    };
59    let mut built = match &curve {
60        Curve::Line(line) => {
61            let start = curve.point_at(range.0, tol)?;
62            let length = range.1 - range.0;
63            let frame = Frame::about(start, line.axis().direction);
64            make_cylinder(model, frame, radius, length, tol)?
65        }
66        Curve::Circle(c) => {
67            let circle = c.circle();
68            if radius >= circle.radius() - tol.confusion() {
69                ogeom_bail!(
70                    Construction,
71                    "a tube of radius {radius} swallows its spine of radius {}",
72                    circle.radius()
73                );
74            }
75            let closed = curve
76                .point_at(range.0, tol)?
77                .distance(curve.point_at(range.1, tol)?)
78                <= tol.confusion();
79            if closed {
80                make_torus(model, circle.frame(), circle.radius(), radius, tol)?
81            } else {
82                pipe_segment(model, circle, range, radius, tol)?
83            }
84        }
85        _ => ogeom_bail!(
86            Construction,
87            "a pipe along a free-form spine needs the sweep-surface \
88             machinery; see docs/PARITY.md, offset.sweeps"
89        ),
90    };
91    built.history.generate(spine, built.shape.clone());
92    Ok(built)
93}
94
95/// The torus segment: two half-tube patches and two meridian caps.
96///
97/// The tube circles are framed so their own parameter *is* the torus tube
98/// angle, which makes every tube pcurve a vertical line in the chart and the
99/// two patches the clean rectangles `v ∈ [0, π]` and `[π, 2π]`. The outer
100/// equator is then the seam between the halves across the period (one edge,
101/// two chart rows), which is exactly what [`ogeom_algo::attach_seam`] exists to
102/// say.
103fn pipe_segment(
104    model: &mut Model,
105    spine: Circle,
106    range: (f64, f64),
107    radius: f64,
108    tol: Tolerances,
109) -> OgeomResult<Built> {
110    let frame = spine.frame();
111    let (x, y, z) = (frame.x().vector(), frame.y().vector(), frame.z().vector());
112    let major = spine.radius();
113    let radial = |u: f64| x * u.cos() + y * u.sin();
114    let tangent = |u: f64| x * -u.sin() + y * u.cos();
115    let tube_point = |u: f64, v: f64| {
116        frame.origin() + radial(u) * radius.mul_add(v.cos(), major) + z * (radius * v.sin())
117    };
118    let pi = core::f64::consts::PI;
119    let tau = core::f64::consts::TAU;
120
121    let torus: SurfaceGeometry = TorusSurface::new(Torus::new(frame, major, radius, tol)?).into();
122    let surface_id = model.geometry_mut().add_surface(torus);
123
124    // Vertices at the tube's v = 0 and v = π points of each end.
125    let ends = [range.0, range.1];
126    let mut verts: Vec<Vec<Shape>> = Vec::new();
127    for &u in &ends {
128        verts.push(vec![
129            make_vertex(model, tube_point(u, 0.0)).shape,
130            make_vertex(model, tube_point(u, pi)).shape,
131        ]);
132    }
133
134    // The tube circles at each end, split at v = 0 and v = π, framed so that
135    // the circle's parameter equals the torus tube angle: `z` against the
136    // spine tangent makes the frame's `y` the torus's own axis.
137    let mut tube_arcs: Vec<Vec<Shape>> = Vec::new();
138    for (k, &u) in ends.iter().enumerate() {
139        let centre = frame.origin() + radial(u) * major;
140        let circle = Circle::new(
141            Frame::new(
142                centre,
143                Direction::new(-tangent(u), tol)?,
144                Direction::new(radial(u), tol)?,
145                tol,
146            )?,
147            radius,
148            tol,
149        )?;
150        let curve = Curve::Circle(CircleCurve::new(circle));
151        let arcs = vec![
152            make_edge_between(
153                model,
154                curve.clone(),
155                (0.0, pi),
156                &verts[k][0],
157                &verts[k][1],
158                tol,
159            )?
160            .shape,
161            make_edge_between(model, curve, (pi, tau), &verts[k][1], &verts[k][0], tol)?.shape,
162        ];
163        // In the chart both arcs run up the column at this end's angle.
164        let column = Line2d::over(
165            ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
166            0.0,
167            tau,
168        )?;
169        ogeom_algo::attach_pcurve(
170            model,
171            &arcs[0],
172            column.into(),
173            surface_id,
174            ogeom_topo::Location::identity(),
175            (0.0, pi),
176        )?;
177        ogeom_algo::attach_pcurve(
178            model,
179            &arcs[1],
180            column.into(),
181            surface_id,
182            ogeom_topo::Location::identity(),
183            (pi, tau),
184        )?;
185        tube_arcs.push(arcs);
186    }
187
188    // The long edges: the parallels at v = 0 and v = π, parameterized by the
189    // spine's own angle.
190    let parallel = |model: &mut Model, v: f64, from: &Shape, to: &Shape| -> OgeomResult<Shape> {
191        let height = radius * v.sin();
192        let ring = radius.mul_add(v.cos(), major);
193        let circle = Circle::new(
194            Frame::new(frame.origin() + z * height, frame.z(), frame.x(), tol)?,
195            ring,
196            tol,
197        )?;
198        let curve = Curve::Circle(CircleCurve::new(circle));
199        Ok(make_edge_between(model, curve, range, from, to, tol)?.shape)
200    };
201    let row = |v: f64| -> OgeomResult<Line2d> {
202        Line2d::over(
203            ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
204            range.0 - 1.0,
205            range.1 + 1.0,
206        )
207    };
208    let inner = parallel(model, pi, &verts[0][1], &verts[1][1])?;
209    ogeom_algo::attach_pcurve(
210        model,
211        &inner,
212        row(pi)?.into(),
213        surface_id,
214        ogeom_topo::Location::identity(),
215        range,
216    )?;
217    // The outer equator bounds both halves across the period: v = 0 for its
218    // forward use under the lower patch, v = 2π for its reversed use under
219    // the upper: a seam, said as one.
220    let outer = parallel(model, 0.0, &verts[0][0], &verts[1][0])?;
221    ogeom_algo::attach_seam(
222        model,
223        &outer,
224        row(0.0)?.into(),
225        row(tau)?.into(),
226        surface_id,
227        ogeom_topo::Location::identity(),
228        range,
229    )?;
230
231    // The two half-tube patches, on the one registered surface, each ring
232    // counter-clockwise in the chart: along the spine, up the far end's
233    // tube, back, and down the near end's.
234    let lower = {
235        let wire = ogeom_algo::make_wire(
236            model,
237            &[
238                outer.clone(),
239                tube_arcs[1][0].clone(),
240                inner.reversed(),
241                tube_arcs[0][0].reversed(),
242            ],
243            tol,
244        )?
245        .shape;
246        ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape
247    };
248    let upper = {
249        let wire = ogeom_algo::make_wire(
250            model,
251            &[
252                inner.clone(),
253                tube_arcs[1][1].clone(),
254                outer.reversed(),
255                tube_arcs[0][1].reversed(),
256            ],
257            tol,
258        )?
259        .shape;
260        ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape
261    };
262
263    // The meridian caps, their outward normals along the spine and away from
264    // the material between the ends.
265    let mut caps: Vec<Shape> = Vec::new();
266    for (k, &u) in ends.iter().enumerate() {
267        let outward = if k == 0 { -tangent(u) } else { tangent(u) };
268        let centre = frame.origin() + radial(u) * major;
269        let plane = Plane::through(centre, Direction::new(outward, tol)?);
270        let reach = (major + radius) * 2.0;
271        let surface: SurfaceGeometry =
272            PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
273        // The tube circles turn about the spine's backward tangent: the near
274        // cap walks its circle forward, the far one walks it back.
275        let ring = if k == 0 {
276            vec![tube_arcs[k][0].clone(), tube_arcs[k][1].clone()]
277        } else {
278            vec![tube_arcs[k][1].reversed(), tube_arcs[k][0].reversed()]
279        };
280        caps.push(make_face_with_pcurves(model, surface, &[ring], tol)?.shape);
281    }
282
283    let faces = [lower, upper, caps[0].clone(), caps[1].clone()];
284    let sewn = sew(model, &faces, tol)?;
285    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
286        ogeom_bail!(Construction, "the pipe segment did not close");
287    }
288    make_solid(model, std::slice::from_ref(&sewn.shells[0]))
289}
290
291/// Loft two parallel closed sections into a solid, ruled.
292///
293/// Two coaxial circles give the cylinder or the cone frustum; two polygons
294/// with the same corner count give planar walls. The sections pair edge by
295/// edge in traversal order.
296///
297/// A wall between two segments that are not coplanar is the bilinear patch
298/// through its four corners (the ruled surface between them, exact), so
299/// sections may be turned against each other or differ in shape.
300///
301/// # Errors
302///
303/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the sections
304/// are not both circles or both polygons of the same count, or are not on
305/// parallel planes.
306pub fn make_loft(
307    model: &mut Model,
308    bottom: &Shape,
309    top: &Shape,
310    tol: Tolerances,
311) -> OgeomResult<Built> {
312    // A vertex for either section is the loft to a point: a cone over a
313    // circle, an exact pyramid over a polygon.
314    match (model.kind_of(bottom)?, model.kind_of(top)?) {
315        (ShapeType::Wire, ShapeType::Vertex) => {
316            return loft_to_point(model, bottom, top, tol);
317        }
318        (ShapeType::Vertex, ShapeType::Wire) => {
319            let mut built = loft_to_point(model, top, bottom, tol)?;
320            // The apex was named first: the same solid, the history the same.
321            built.history.generate(bottom, built.shape.clone());
322            return Ok(built);
323        }
324        _ => {}
325    }
326    for wire in [bottom, top] {
327        if model.kind_of(wire)? != ShapeType::Wire {
328            ogeom_bail!(Construction, "a loft runs between wires");
329        }
330        if !ogeom_algo::is_wire_closed(model, wire, tol)? {
331            ogeom_bail!(Construction, "a loft section must be closed");
332        }
333    }
334    let circle_of = |model: &Model, wire: &Shape| -> OgeomResult<Option<Circle>> {
335        let edges = explore(model, wire, Filter::OfType(ShapeType::Edge))?;
336        if edges.len() != 1 {
337            return Ok(None);
338        }
339        let Some(data) = model.node(&edges[0]).and_then(|n| n.data().as_edge()) else {
340            return Ok(None);
341        };
342        let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d() else {
343            return Ok(None);
344        };
345        match model.geometry().curve(*curve) {
346            Some(Curve::Circle(c)) => Ok(Some(c.circle())),
347            _ => Ok(None),
348        }
349    };
350
351    if let (Some(lower), Some(upper)) = (circle_of(model, bottom)?, circle_of(model, top)?) {
352        // Coaxial circles: the revolved primitives already build these.
353        let axis = lower.frame().z().vector();
354        let rise = upper.centre() - lower.centre();
355        let height = rise.dot(axis);
356        if rise.cross(axis).magnitude() > tol.confusion() * 10.0 || height.abs() <= tol.confusion()
357        {
358            ogeom_bail!(
359                Construction,
360                "lofted circles must be coaxial on parallel planes; the \
361                 oblique loft needs the sweep machinery; see the deferred \
362                 table"
363            );
364        }
365        let frame = if height > 0.0 {
366            Frame::new(lower.centre(), lower.frame().z(), lower.frame().x(), tol)?
367        } else {
368            Frame::new(
369                lower.centre(),
370                lower.frame().z().reversed(),
371                lower.frame().x(),
372                tol,
373            )?
374        };
375        let mut built = if (lower.radius() - upper.radius()).abs() <= tol.confusion() {
376            make_cylinder(model, frame, lower.radius(), height.abs(), tol)?
377        } else {
378            make_cone(
379                model,
380                frame,
381                lower.radius(),
382                upper.radius(),
383                height.abs(),
384                tol,
385            )?
386        };
387        built.history.generate(bottom, built.shape.clone());
388        built.history.generate(top, built.shape.clone());
389        return Ok(built);
390    }
391
392    // Polygonal sections: matched corners, planar walls.
393    let corners_of = |model: &Model, wire: &Shape| -> OgeomResult<Vec<Point>> {
394        let mut out = Vec::new();
395        for edge in explore(model, wire, Filter::OfType(ShapeType::Edge))? {
396            let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
397                ogeom_bail!(Construction, "a section edge holds no data");
398            };
399            let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d() else {
400                ogeom_bail!(Construction, "a section edge has no curve");
401            };
402            let Some(Curve::Line(_)) = model.geometry().curve(*curve) else {
403                ogeom_bail!(
404                    Construction,
405                    "a mixed or curved section needs the skinning machinery; see \
406                     docs/PARITY.md, offset.sweeps"
407                );
408            };
409            let Some((sv, _)) = edge_vertices(model, &edge)? else {
410                ogeom_bail!(Construction, "a section edge has no vertices");
411            };
412            let Some(data) = model.node(&sv).and_then(|n| n.data().as_vertex()) else {
413                ogeom_bail!(Construction, "a section vertex holds no point");
414            };
415            out.push(sv.transform(model.datums())?.apply(data.point));
416        }
417        Ok(out)
418    };
419    let low = corners_of(model, bottom)?;
420    let high = corners_of(model, top)?;
421    if low.len() != high.len() {
422        ogeom_bail!(
423            Construction,
424            "lofted sections must have the same corner count, found {} and {}",
425            low.len(),
426            high.len()
427        );
428    }
429    let n = low.len();
430    let centroid = {
431        let mut c = Vector::new(0.0, 0.0, 0.0);
432        for p in low.iter().chain(high.iter()) {
433            c += p.to_vector();
434        }
435        #[allow(clippy::cast_precision_loss)]
436        let count = 2.0 * n as f64;
437        Point::from_vector(c / count)
438    };
439
440    // Shared vertices and edges, then walls and caps referencing them.
441    let vl: Vec<Shape> = low.iter().map(|p| make_vertex(model, *p).shape).collect();
442    let vh: Vec<Shape> = high.iter().map(|p| make_vertex(model, *p).shape).collect();
443    let seg = |model: &mut Model, a: (&Shape, Point), b: (&Shape, Point)| -> OgeomResult<Shape> {
444        let line = LineCurve::segment(a.1, b.1, tol)?;
445        let curve = Curve::Line(line);
446        let domain = curve.domain();
447        Ok(make_edge_between(model, curve, domain, a.0, b.0, tol)?.shape)
448    };
449    let mut low_edges = Vec::with_capacity(n);
450    let mut high_edges = Vec::with_capacity(n);
451    let mut rails = Vec::with_capacity(n);
452    for i in 0..n {
453        let j = (i + 1) % n;
454        low_edges.push(seg(model, (&vl[i], low[i]), (&vl[j], low[j]))?);
455        high_edges.push(seg(model, (&vh[i], high[i]), (&vh[j], high[j]))?);
456        rails.push(seg(model, (&vl[i], low[i]), (&vh[i], high[i]))?);
457    }
458
459    let planar = |model: &mut Model, corners: &[Point], edges: Vec<Shape>| -> OgeomResult<Shape> {
460        let normal = {
461            let mut n = (corners[1] - corners[0]).cross(corners[2] - corners[0]);
462            let m = n.magnitude();
463            if m <= tol.confusion() {
464                ogeom_bail!(Construction, "a loft wall is degenerate");
465            }
466            n /= m;
467            if n.dot(corners[0] - centroid) < 0.0 {
468                -n
469            } else {
470                n
471            }
472        };
473        let skew = corners.iter().any(|p| {
474            Plane::through(
475                corners[0],
476                Direction::new(normal, tol).unwrap_or(Direction::Z),
477            )
478            .distance_to(*p)
479                > tol.confusion() * 10.0
480        });
481        if skew {
482            // A wall between two segments that do not lie in one plane is
483            // the ruled surface between them, and that is exact: bilinear
484            // in the four corners, a B-spline of degree one each way.
485            if corners.len() != 4 || edges.len() != 4 {
486                ogeom_bail!(Construction, "a skew ruled wall has four corners");
487            }
488            return bilinear_wall(model, corners, &edges, centroid, tol);
489        }
490        let plane = Plane::through(corners[0], Direction::new(normal, tol)?);
491        let mut reach = 1.0_f64;
492        for p in corners {
493            reach = reach.max(p.distance(corners[0]) * 2.0);
494        }
495        let surface: SurfaceGeometry =
496            PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
497        // The ring keeps the material on its left about the outward normal:
498        // one walking the corners clockwise about it is walked back.
499        let turn = (0..corners.len())
500            .map(|i| {
501                corners[i]
502                    .to_vector()
503                    .cross(corners[(i + 1) % corners.len()].to_vector())
504            })
505            .fold(Vector::new(0.0, 0.0, 0.0), |sum, v| sum + v);
506        let ring = if turn.dot(normal) < 0.0 {
507            walked_back(&edges)
508        } else {
509            edges
510        };
511        Ok(make_face_with_pcurves(model, surface, &[ring], tol)?.shape)
512    };
513
514    let mut faces: Vec<Shape> = Vec::with_capacity(n + 2);
515    for i in 0..n {
516        let j = (i + 1) % n;
517        faces.push(planar(
518            model,
519            &[low[i], low[j], high[j], high[i]],
520            vec![
521                low_edges[i].clone(),
522                rails[j].clone(),
523                high_edges[i].reversed(),
524                rails[i].reversed(),
525            ],
526        )?);
527    }
528    faces.push(planar(model, &low, low_edges.clone())?);
529    faces.push(planar(model, &high, high_edges.clone())?);
530
531    let sewn = sew(model, &faces, tol)?;
532    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
533        ogeom_bail!(Construction, "the loft did not close");
534    }
535    let mut built = make_solid(model, std::slice::from_ref(&sewn.shells[0]))?;
536    built.history.generate(bottom, built.shape.clone());
537    built.history.generate(top, built.shape.clone());
538    Ok(built)
539}
540
541/// The ruled wall between two straight segments that are not coplanar: the
542/// bilinear patch through its four corners, exact, as a B-spline of degree
543/// one each way.
544///
545/// `corners` run round the wall (low start, low end, high end, high
546/// start) and `edges` walk them in that order, the third and fourth
547/// reversed as the caller's wire has them. Each edge's pcurve is the chart
548/// side it lies on, parameterized by the edge's own range so the two agree
549/// point for point; the wall faces away from `centroid`.
550fn bilinear_wall(
551    model: &mut Model,
552    corners: &[Point],
553    edges: &[Shape],
554    centroid: Point,
555    tol: Tolerances,
556) -> OgeomResult<Shape> {
557    use ogeom_geom::Surface as _;
558    let (p00, p10, p11, p01) = (corners[0], corners[1], corners[2], corners[3]);
559    let grid = ogeom_math::ControlGrid::new(vec![p00, p01, p10, p11], 2, 2)?;
560    let line = ogeom_math::KnotVector::clamped_uniform(1, 2)?;
561    let patch = ogeom_geom::BSplineSurface::new(line.clone(), line, &grid, tol)?;
562    let outward = {
563        let (du, dv) = patch.d1_at(0.5, 0.5, tol)?;
564        let centre = patch.point_at(0.5, 0.5, tol)?;
565        du.cross(dv).dot(centre - centroid) >= 0.0
566    };
567    let surface_id = model
568        .geometry_mut()
569        .add_surface(SurfaceGeometry::BSpline(patch));
570
571    // Each edge's chart side, run in the edge's own direction over the
572    // edge's own parameter range: a degree-one B-spline in the chart,
573    // which is what makes the pcurve the edge's equal parameter for
574    // parameter whatever length the edge has.
575    let sides: [(Point2, Point2); 4] = [
576        (Point2::new(0.0, 0.0), Point2::new(1.0, 0.0)),
577        (Point2::new(1.0, 0.0), Point2::new(1.0, 1.0)),
578        (Point2::new(0.0, 1.0), Point2::new(1.0, 1.0)),
579        (Point2::new(0.0, 0.0), Point2::new(0.0, 1.0)),
580    ];
581    for (edge, (from, to)) in edges.iter().zip(sides) {
582        let range = {
583            let Some(node) = model.node(edge) else {
584                ogeom_bail!(Dangling, "a loft edge is not in this model");
585            };
586            let Some(data) = node.data().as_edge() else {
587                ogeom_bail!(Construction, "a loft edge holds no edge data");
588            };
589            let Some(EdgeRepr::Curve3d { range, .. }) = data.curve3d() else {
590                ogeom_bail!(Construction, "a loft edge has no curve");
591            };
592            *range
593        };
594        let knots = ogeom_math::KnotVector::new(vec![range.0, range.0, range.1, range.1], 1)?;
595        let pcurve = ogeom_geom::BSpline2d::new(knots, vec![from, to], tol)?;
596        ogeom_algo::attach_pcurve(
597            model,
598            edge,
599            pcurve.into(),
600            surface_id,
601            ogeom_topo::Location::identity(),
602            range,
603        )?;
604    }
605    let wire = ogeom_algo::make_wire(model, edges, tol)?.shape;
606    let face = ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
607    Ok(if outward { face } else { face.reversed() })
608}
609
610/// What a skin is fitted to: its rows of samples, and the geometry they
611/// sample where the builder knows it.
612struct Skin<'a> {
613    /// `rows[j][i]` runs along row `j`: once round a section without the
614    /// closing point where `round`, across an open strip otherwise.
615    rows: Vec<Vec<Point>>,
616    /// Whether each row runs once round, its end its start.
617    round: bool,
618    /// Whether bare rows sit across the skin by chord length (sections a
619    /// caller spaced) rather than centripetally.
620    by_spacing: bool,
621    /// The geometry the rows sample, if known: `point(u, v)` with `u` along
622    /// the rows over the sampling's `u` range (once round where `round`, its
623    /// end the start again) and `v` across them, and where the fit starts
624    /// sampling it.
625    traced: Option<Traced<'a>>,
626}
627
628/// The geometry a skin traces and where its fit starts sampling it.
629struct Traced<'a> {
630    point: Box<dyn Fn(f64, f64) -> OgeomResult<Point> + 'a>,
631    sampling: ogeom_geom::fit::Sampling,
632}
633
634/// One section a skin passes through: a closed loop read by arc length,
635/// or the point a skin narrows to.
636#[derive(Clone)]
637enum Section {
638    Loop(ArcLoop),
639    Point(Point),
640    /// A loop moved halfway toward a point, every point of it.
641    Halfway(ArcLoop, Point),
642}
643
644impl Section {
645    /// The point a fraction `f` of the way round.
646    fn at(&self, f: f64) -> Point {
647        match self {
648            Self::Loop(arc) => arc.at(f),
649            Self::Point(p) => *p,
650            Self::Halfway(arc, to) => {
651                Point::from_vector((arc.at(f).to_vector() + to.to_vector()) * 0.5)
652            }
653        }
654    }
655}
656
657/// The samples a skin's sections take round: an even fraction apart, from
658/// each section's own start.
659const AROUND_SECTION: usize = 48;
660
661/// `n + 1` even fractions of one, from naught to one.
662fn fractions(n: usize) -> Vec<f64> {
663    (0..=n)
664        .map(|i| {
665            #[allow(clippy::cast_precision_loss, reason = "a small count")]
666            let f = i as f64 / n as f64;
667            f
668        })
669        .collect()
670}
671
672impl<'a> Skin<'a> {
673    /// A skin over bare rows, each once round: fitted through them,
674    /// measured at them.
675    fn rows(rows: Vec<Vec<Point>>) -> Self {
676        Self {
677            rows,
678            round: true,
679            by_spacing: false,
680            traced: None,
681        }
682    }
683
684    /// A skin whose every column is a function of its parameter along the
685    /// rows: `column(u)` is the column of points at `u`, one per row,
686    /// sampled at `us` to start with and checked and refined between them.
687    /// Across the rows the samples are all there is of the skin: the rows
688    /// sit there at the parameters [`ogeom_geom::fit::grid_parameters`]
689    /// gives them, by chord length where `by_spacing`, centripetally
690    /// otherwise. Where `round`, the last of `us` is the first again; where
691    /// `closed_v`, the rows loop and the first row closes it.
692    fn columns(
693        column: impl Fn(f64) -> OgeomResult<Vec<Point>> + 'a,
694        us: Vec<f64>,
695        round: bool,
696        by_spacing: bool,
697        closed_v: bool,
698    ) -> OgeomResult<Self> {
699        let sampled: Vec<Vec<Point>> = us[..us.len() - usize::from(round)]
700            .iter()
701            .map(|u| column(*u))
702            .collect::<OgeomResult<_>>()?;
703        let count = sampled[0].len();
704        let rows: Vec<Vec<Point>> = (0..count)
705            .map(|j| sampled.iter().map(|c| c[j]).collect())
706            .collect();
707        let mut grid: Vec<Vec<Point>> = rows
708            .iter()
709            .map(|r| {
710                let mut r = r.clone();
711                if round {
712                    r.push(r[0]);
713                }
714                r
715            })
716            .collect();
717        if closed_v {
718            grid.push(grid[0].clone());
719        }
720        let bare = Self {
721            rows: rows.clone(),
722            round,
723            by_spacing,
724            traced: None,
725        };
726        let (_, vs) = ogeom_geom::fit::grid_parameters(&grid, (false, by_spacing))?;
727        if vs.windows(2).any(|w| w[1] <= w[0]) {
728            // Rows that coincide have no order across the skin; the bare
729            // rows decide what the fit makes of them.
730            return Ok(bare);
731        }
732        let row_vs = vs.clone();
733        let (u0, u1) = (us[0], us[us.len() - 1]);
734        let held: std::cell::RefCell<ogeom_core::FastMap<u64, Vec<Point>>> =
735            std::cell::RefCell::default();
736        let point = move |u: f64, v: f64| -> OgeomResult<Point> {
737            let Some(j) = row_vs.iter().position(|w| w.to_bits() == v.to_bits()) else {
738                ogeom_bail!(Construction, "a skin is known across only at its rows");
739            };
740            // The end of the way round is its start, to the bit, so the
741            // seam closes exactly.
742            let u = if round && u >= u1 { u0 } else { u };
743            if let Some(c) = held.borrow().get(&u.to_bits()) {
744                return Ok(c[j % count]);
745            }
746            let c = column(u)?;
747            if c.len() != count {
748                ogeom_bail!(Construction, "a skin's column changed its length");
749            }
750            let p = c[j % count];
751            held.borrow_mut().insert(u.to_bits(), c);
752            Ok(p)
753        };
754        Ok(Self {
755            traced: Some(Traced {
756                point: Box::new(point),
757                sampling: ogeom_geom::fit::Sampling {
758                    us,
759                    vs,
760                    between: (true, false),
761                    closed_v,
762                    most: 512,
763                },
764            }),
765            ..bare
766        })
767    }
768
769    /// A skin known everywhere: `point(u, s)` with `u` along the rows and
770    /// `s` a station index across them, a whole number at a station and a
771    /// fraction between two. It is sampled at `us` and at every station
772    /// from `stations.0` to `stations.1` to start with, and checked and
773    /// refined between them both ways. Across, the skin's parameter runs
774    /// from naught to one over the stations, evenly. Where `round`, the
775    /// last of `us` is the first again.
776    fn swept(
777        point: impl Fn(f64, f64) -> OgeomResult<Point> + 'a,
778        us: Vec<f64>,
779        stations: (usize, usize),
780        round: bool,
781    ) -> OgeomResult<Self> {
782        let (u0, u1) = (us[0], us[us.len() - 1]);
783        #[allow(clippy::cast_precision_loss, reason = "station counts")]
784        let (first, steps) = (stations.0 as f64, (stations.1 - stations.0) as f64);
785        if steps < 1.0 {
786            ogeom_bail!(Construction, "a swept skin needs two stations");
787        }
788        let vs: Vec<f64> = (stations.0..=stations.1)
789            .map(|i| {
790                #[allow(clippy::cast_precision_loss, reason = "station counts")]
791                let step = (i - stations.0) as f64;
792                step / steps
793            })
794            .collect();
795        // The end of the way round is its start, to the bit, so the seam
796        // closes exactly; a station is its whole index, to the bit.
797        let point = move |u: f64, v: f64| -> OgeomResult<Point> {
798            let s = first + v * steps;
799            let whole = s.round();
800            let s = if (s - whole).abs() <= 1e-9 { whole } else { s };
801            point(if round && u >= u1 { u0 } else { u }, s)
802        };
803        let rows: Vec<Vec<Point>> = vs
804            .iter()
805            .map(|v| {
806                us[..us.len() - usize::from(round)]
807                    .iter()
808                    .map(|u| point(*u, *v))
809                    .collect::<OgeomResult<Vec<Point>>>()
810            })
811            .collect::<OgeomResult<_>>()?;
812        Ok(Self {
813            rows,
814            round,
815            by_spacing: false,
816            traced: Some(Traced {
817                point: Box::new(point),
818                sampling: ogeom_geom::fit::Sampling {
819                    us,
820                    vs,
821                    between: (true, true),
822                    closed_v: false,
823                    most: 512,
824                },
825            }),
826        })
827    }
828
829    /// A skin through `sections`, each sampled at [`AROUND_SECTION`] even
830    /// fractions of its length to start with (see [`Self::columns`]).
831    fn sections(sections: Vec<Section>, closed: bool) -> OgeomResult<Self> {
832        Self::columns(
833            move |u| Ok(sections.iter().map(|s| s.at(u)).collect()),
834            fractions(AROUND_SECTION),
835            true,
836            false,
837            closed,
838        )
839    }
840
841    /// The skin's fitted surface: where `round`, closed the way round (each
842    /// row's end its start, so the row fits' pinned ends make the two
843    /// border control columns equal); where `closed_v`, smoothly across the
844    /// loop of its rows.
845    fn fit(
846        &self,
847        closed_v: bool,
848        tolerance: f64,
849        tol: Tolerances,
850    ) -> OgeomResult<ogeom_geom::fit::Fitted<ogeom_geom::BSplineSurface>> {
851        match &self.traced {
852            None => {
853                let mut grid: Vec<Vec<Point>> = self
854                    .rows
855                    .iter()
856                    .map(|row| {
857                        let mut r = row.clone();
858                        if self.round {
859                            r.push(row[0]);
860                        }
861                        r
862                    })
863                    .collect();
864                if closed_v {
865                    grid.push(grid[0].clone());
866                    ogeom_geom::fit::fit_surface_grid_closed_v(&grid, 3, tolerance, tol)
867                } else if self.by_spacing {
868                    ogeom_geom::fit::fit_surface_grid_sections(&grid, 3, tolerance, tol)
869                } else {
870                    ogeom_geom::fit::fit_surface_grid(&grid, 3, tolerance, tol)
871                }
872            }
873            Some(traced) => {
874                let mut sampling = traced.sampling.clone();
875                sampling.closed_v = closed_v;
876                ogeom_geom::fit::fit_surface_sampled(
877                    |u, v| (traced.point)(u, v),
878                    &sampling,
879                    3,
880                    tolerance,
881                    tol,
882                )
883            }
884        }
885    }
886}
887
888/// A closed polyline read by arc length.
889#[derive(Clone)]
890struct ArcLoop {
891    dense: Vec<Point>,
892    /// The length run to each point of `dense`.
893    lengths: Vec<f64>,
894    /// The length round, the closing chord included.
895    total: f64,
896}
897
898impl ArcLoop {
899    fn new(dense: Vec<Point>) -> Self {
900        let mut lengths = Vec::with_capacity(dense.len());
901        lengths.push(0.0);
902        for w in dense.windows(2) {
903            let last = lengths[lengths.len() - 1];
904            lengths.push(last + w[0].distance(w[1]));
905        }
906        let total = lengths[lengths.len() - 1] + dense[dense.len() - 1].distance(dense[0]);
907        Self {
908            dense,
909            lengths,
910            total,
911        }
912    }
913
914    /// The point a fraction `f` of the length round from the first point;
915    /// a whole turn is the first point again.
916    fn at(&self, f: f64) -> Point {
917        let f = if (0.0..1.0).contains(&f) { f } else { 0.0 };
918        let target = self.total * f;
919        let n = self.dense.len();
920        let cursor = self.lengths[1..]
921            .partition_point(|l| *l < target)
922            .min(n - 1);
923        let (a, b) = (self.dense[cursor], self.dense[(cursor + 1) % n]);
924        let la = self.lengths[cursor];
925        let lb = if cursor + 1 < n {
926            self.lengths[cursor + 1]
927        } else {
928            self.total
929        };
930        let t = if lb > la {
931            (target - la) / (lb - la)
932        } else {
933            0.0
934        };
935        a + (b - a) * t.clamp(0.0, 1.0)
936    }
937}
938
939/// A skinned wall and the pieces a caller needs to close it: the rings at
940/// both ends, their exact border curves off the control net, and the chart's
941/// `u` window the ring pcurves span.
942struct SkinnedWall {
943    face: Shape,
944    ring0: Shape,
945    ring1: Shape,
946    curve0: ogeom_geom::Curve,
947    curve1: ogeom_geom::Curve,
948    u_dom: (f64, f64),
949}
950
951/// The wall of a skin over a grid of section samples, closed the way round.
952///
953/// The wall is the skin's fitted surface ([`Skin::fit`]), with each row's
954/// first sample repeated at its end: the row fits pin their ends, so the two
955/// border control columns are *equal* and the seam closes exactly, not
956/// within tolerance. The border iso-curves come straight off the control
957/// net (the v-borders are the fitted sections, planar whenever the
958/// sections are, which is what lets the caps be planes), and every pcurve
959/// is an iso line in the fitted chart, same-parameter by construction.
960fn skinned_wall(
961    model: &mut Model,
962    skin: &Skin<'_>,
963    shared: (Option<&Shape>, Option<&Shape>),
964    tolerance: f64,
965    tol: Tolerances,
966) -> OgeomResult<SkinnedWall> {
967    use ogeom_geom::Surface as _;
968    let rows = &skin.rows;
969    let fitted = skin.fit(false, tolerance, tol)?;
970    if !fitted.met {
971        ogeom_bail!(
972            NotDone,
973            "the skin reached {} against a target of {tolerance}",
974            fitted.error
975        );
976    }
977    let surface = fitted.curve;
978    let (u_knots, v_knots) = (surface.u_knots().clone(), surface.v_knots().clone());
979    let (k, l, net) = {
980        let grid = surface.grid();
981        let net: Vec<Point> = grid.points().iter().map(|w| (*w).point()).collect();
982        (grid.u_count(), grid.v_count(), net)
983    };
984    let point_at = |i: usize, j: usize| -> Point { net[i * l + j] };
985    let (u_dom, v_dom) = surface.domain();
986
987    // Border curves straight off the net: v-borders are the end sections,
988    // the u-border is the seam.
989    let border_v = |j: usize| -> OgeomResult<ogeom_geom::Curve> {
990        let control: Vec<Point> = (0..k).map(|i| point_at(i, j)).collect();
991        Ok(ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
992            u_knots.clone(),
993            control,
994            tol,
995        )?))
996    };
997    let seam_curve = {
998        let control: Vec<Point> = (0..l).map(|j| point_at(0, j)).collect();
999        ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
1000            v_knots.clone(),
1001            control,
1002            tol,
1003        )?)
1004    };
1005
1006    let surface_geo: SurfaceGeometry = surface.into();
1007    let surface_id = model.geometry_mut().add_surface(surface_geo.clone());
1008
1009    // An end ring the neighbouring wall already built is adopted, not
1010    // refitted (see `adopt_border`).
1011    let slack = fitted.error + tol.confusion();
1012    let ring_of = |model: &mut Model,
1013                   j: usize,
1014                   given: Option<&Shape>|
1015     -> OgeomResult<(Shape, ogeom_geom::Curve)> {
1016        match given {
1017            Some(edge) => {
1018                adopt_border(model, edge, &surface_geo, slack, tol)?;
1019                Ok((edge.clone(), spine_curve_of(model, edge)?.0))
1020            }
1021            None => {
1022                let curve = border_v(j)?;
1023                Ok((make_edge(model, curve.clone(), u_dom, tol)?.shape, curve))
1024            }
1025        }
1026    };
1027    let (ring0, curve0) = ring_of(model, 0, shared.0)?;
1028    let (ring1, curve1) = ring_of(model, l - 1, shared.1)?;
1029    let anchor0 = ogeom_algo::edge_vertices(model, &ring0)?
1030        .map(|(a, _)| a)
1031        .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a skinned ring has no vertex"))?;
1032    let anchor1 = ogeom_algo::edge_vertices(model, &ring1)?
1033        .map(|(a, _)| a)
1034        .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a skinned ring has no vertex"))?;
1035    let seam = make_edge_between(model, seam_curve, v_dom, &anchor0, &anchor1, tol)?.shape;
1036
1037    // Pcurves: rows for the rings, both columns for the seam.
1038    let row_line = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1039        Ok(Line2d::over(
1040            ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
1041            u_dom.0 - 1.0,
1042            u_dom.1 + 1.0,
1043        )?
1044        .into())
1045    };
1046    let column_line = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1047        Ok(Line2d::over(
1048            ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
1049            v_dom.0 - 1.0,
1050            v_dom.1 + 1.0,
1051        )?
1052        .into())
1053    };
1054    ogeom_algo::attach_pcurve(
1055        model,
1056        &ring0,
1057        row_line(v_dom.0)?,
1058        surface_id,
1059        ogeom_topo::Location::identity(),
1060        u_dom,
1061    )?;
1062    ogeom_algo::attach_pcurve(
1063        model,
1064        &ring1,
1065        row_line(v_dom.1)?,
1066        surface_id,
1067        ogeom_topo::Location::identity(),
1068        u_dom,
1069    )?;
1070    ogeom_algo::attach_seam(
1071        model,
1072        &seam,
1073        column_line(u_dom.0)?,
1074        column_line(u_dom.1)?,
1075        surface_id,
1076        ogeom_topo::Location::identity(),
1077        v_dom,
1078    )?;
1079
1080    let wall = {
1081        let wire = ogeom_algo::make_wire(
1082            model,
1083            &[
1084                ring0.clone(),
1085                seam.clone(),
1086                ring1.reversed(),
1087                seam.reversed(),
1088            ],
1089            tol,
1090        )?
1091        .shape;
1092        let face =
1093            ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
1094        // Outward by measurement at the middle of the skin: away from the
1095        // centre of the section the point lies on. The centre of the whole
1096        // skin will not do: along a bent spine it lies inside the bend,
1097        // on the far side of the inner wall.
1098        let mid_u = f64::midpoint(u_dom.0, u_dom.1);
1099        let mid_v = f64::midpoint(v_dom.0, v_dom.1);
1100        let s_mid = surface_geo.point_at(mid_u, mid_v, tol)?;
1101        let (du, dv) = surface_geo.d1_at(mid_u, mid_v, tol)?;
1102        if du.cross(dv).dot(s_mid - section_centre(rows, s_mid)) >= 0.0 {
1103            face
1104        } else {
1105            face.reversed()
1106        }
1107    };
1108    Ok(SkinnedWall {
1109        face: wall,
1110        ring0,
1111        ring1,
1112        curve0,
1113        curve1,
1114        u_dom,
1115    })
1116}
1117
1118/// The centre of the section (row) passing nearest `at`: the mean of its
1119/// points.
1120fn section_centre(rows: &[Vec<Point>], at: Point) -> Point {
1121    let nearest = rows.iter().filter(|row| !row.is_empty()).min_by(|a, b| {
1122        let d = |row: &Vec<Point>| {
1123            row.iter()
1124                .map(|p| p.distance(at))
1125                .fold(f64::INFINITY, f64::min)
1126        };
1127        d(a).total_cmp(&d(b))
1128    });
1129    let Some(row) = nearest else {
1130        return at;
1131    };
1132    let sum = row
1133        .iter()
1134        .fold(Vector::new(0.0, 0.0, 0.0), |acc, p| acc + p.to_vector());
1135    #[allow(clippy::cast_precision_loss)]
1136    Point::from_vector(sum / row.len() as f64)
1137}
1138
1139/// A strip closed the *long* way: open across its own width, a smooth loop
1140/// along the sweep: one face of a faceted ring, [`skinned_wall`]'s
1141/// construction with the chart's roles swapped and the loop made C1 by
1142/// [`ogeom_geom::fit::fit_surface_grid_closed_v`]. The rails are the two
1143/// closed border loops; the seam is one station's column, used twice.
1144fn skinned_ring_strip(
1145    model: &mut Model,
1146    skin: &Skin<'_>,
1147    outward_hint: Point,
1148    shared: [Option<&Shape>; 2],
1149    tolerance: f64,
1150    tol: Tolerances,
1151) -> OgeomResult<(Shape, Shape, Shape)> {
1152    use ogeom_geom::Surface as _;
1153    // The loop: first row repeated at the end, as the closed fit demands.
1154    let fitted = skin.fit(true, tolerance, tol)?;
1155    if !fitted.met {
1156        ogeom_bail!(
1157            NotDone,
1158            "the ring strip reached {} against a target of {tolerance}",
1159            fitted.error
1160        );
1161    }
1162    let surface = fitted.curve;
1163    let (u_knots, v_knots) = (surface.u_knots().clone(), surface.v_knots().clone());
1164    let (k, l, net) = {
1165        let grid = surface.grid();
1166        let net: Vec<Point> = grid.points().iter().map(|w| (*w).point()).collect();
1167        (grid.u_count(), grid.v_count(), net)
1168    };
1169    let point_at = |i: usize, j: usize| -> Point { net[i * l + j] };
1170    let (u_dom, v_dom) = surface.domain();
1171
1172    // The chart's roles, straight: `u` runs across the strip (open), `v`
1173    // around the loop (closed). The rails are v-curves (the closed border
1174    // loops at the two u-borders), and the seam is the u-row at the loop's
1175    // join, bounding the chart twice as every seam does.
1176    let rail_curve = |i: usize| -> OgeomResult<ogeom_geom::Curve> {
1177        let control: Vec<Point> = (0..l).map(|j| point_at(i, j)).collect();
1178        Ok(ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
1179            v_knots.clone(),
1180            control,
1181            tol,
1182        )?))
1183    };
1184    let seam_curve = {
1185        let control: Vec<Point> = (0..k).map(|i| point_at(i, 0)).collect();
1186        ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
1187            u_knots.clone(),
1188            control,
1189            tol,
1190        )?)
1191    };
1192    let surface_geo: SurfaceGeometry = surface.into();
1193    let surface_id = model.geometry_mut().add_surface(surface_geo.clone());
1194
1195    // A corner loop shared with the neighbouring strip is one edge for
1196    // both: the neighbour built it from its own fit, and this strip's
1197    // border is another fit of the same loop, so the edge widens to how
1198    // far it honestly sits from this surface. Two independent fits of one
1199    // loop can disagree by more than either fit's own error (the sew refuses
1200    // that gap under a frame that turns fast), and one edge cannot disagree
1201    // with itself.
1202    let slack = fitted.error + tol.confusion();
1203    let rail_of = |model: &mut Model, i: usize, given: Option<&Shape>| -> OgeomResult<Shape> {
1204        let Some(edge) = given else {
1205            let edge = make_edge(model, rail_curve(i)?, v_dom, tol)?.shape;
1206            model.widen(&edge, ogeom_core::Tolerance::new(slack)?)?;
1207            return Ok(edge);
1208        };
1209        let (curve, range) = spine_curve_of(model, edge)?;
1210        let mut off: f64 = 0.0;
1211        for step in 0..=32 {
1212            #[allow(clippy::cast_precision_loss)]
1213            let t = range.0 + (range.1 - range.0) * (step as f64) / 32.0;
1214            let p = curve.point_at(t, tol)?;
1215            off = off.max(ogeom_algo::project_on_surface(&surface_geo, p, 16, tol)?.distance);
1216        }
1217        model.widen(edge, ogeom_core::Tolerance::new(off + slack)?)?;
1218        if let Some((a, b)) = ogeom_algo::edge_vertices(model, edge)? {
1219            for v in [&a, &b] {
1220                model.widen(v, ogeom_core::Tolerance::new(off + slack)?)?;
1221            }
1222        }
1223        Ok(edge.clone())
1224    };
1225    let rail0 = rail_of(model, 0, shared[0])?;
1226    let rail1 = rail_of(model, k - 1, shared[1])?;
1227    let anchor0 = ogeom_algo::edge_vertices(model, &rail0)?
1228        .map(|(a, _)| a)
1229        .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a strip rail has no vertex"))?;
1230    let anchor1 = ogeom_algo::edge_vertices(model, &rail1)?
1231        .map(|(a, _)| a)
1232        .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a strip rail has no vertex"))?;
1233    let seam = make_edge_between(model, seam_curve, u_dom, &anchor0, &anchor1, tol)?.shape;
1234
1235    let row_line = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1236        Ok(Line2d::over(
1237            ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
1238            u_dom.0 - 1.0,
1239            u_dom.1 + 1.0,
1240        )?
1241        .into())
1242    };
1243    let column_line = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1244        Ok(Line2d::over(
1245            ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
1246            v_dom.0 - 1.0,
1247            v_dom.1 + 1.0,
1248        )?
1249        .into())
1250    };
1251    ogeom_algo::attach_pcurve(
1252        model,
1253        &rail0,
1254        column_line(u_dom.0)?,
1255        surface_id,
1256        ogeom_topo::Location::identity(),
1257        v_dom,
1258    )?;
1259    ogeom_algo::attach_pcurve(
1260        model,
1261        &rail1,
1262        column_line(u_dom.1)?,
1263        surface_id,
1264        ogeom_topo::Location::identity(),
1265        v_dom,
1266    )?;
1267    ogeom_algo::attach_seam(
1268        model,
1269        &seam,
1270        row_line(v_dom.0)?,
1271        row_line(v_dom.1)?,
1272        surface_id,
1273        ogeom_topo::Location::identity(),
1274        u_dom,
1275    )?;
1276    let wire = ogeom_algo::make_wire(
1277        model,
1278        &[
1279            rail0.clone(),
1280            seam.clone(),
1281            rail1.reversed(),
1282            seam.reversed(),
1283        ],
1284        tol,
1285    )?
1286    .shape;
1287    let face = ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
1288    let mid_u = f64::midpoint(u_dom.0, u_dom.1);
1289    let mid_v = f64::midpoint(v_dom.0, v_dom.1);
1290    let s_mid = surface_geo.point_at(mid_u, mid_v, tol)?;
1291    let (du, dv) = surface_geo.d1_at(mid_u, mid_v, tol)?;
1292    let face = if du.cross(dv).dot(s_mid - outward_hint) >= 0.0 {
1293        face
1294    } else {
1295        face.reversed()
1296    };
1297    Ok((face, rail0, rail1))
1298}
1299
1300/// A border edge the neighbouring skin built from its own fit, adopted by
1301/// this skin: the edge widens to how far it honestly sits from `surface`
1302/// plus this fit's own slack, its vertices with it. Two independent fits of
1303/// one row can disagree by more than either fit's own error, and one edge
1304/// cannot disagree with itself.
1305fn adopt_border(
1306    model: &mut Model,
1307    edge: &Shape,
1308    surface: &SurfaceGeometry,
1309    slack: f64,
1310    tol: Tolerances,
1311) -> OgeomResult<()> {
1312    let (curve, range) = spine_curve_of(model, edge)?;
1313    let mut off: f64 = 0.0;
1314    for step in 0..=32 {
1315        #[allow(clippy::cast_precision_loss)]
1316        let t = range.0 + (range.1 - range.0) * (step as f64) / 32.0;
1317        let p = curve.point_at(t, tol)?;
1318        off = off.max(ogeom_algo::project_on_surface(surface, p, 16, tol)?.distance);
1319    }
1320    model.widen(edge, ogeom_core::Tolerance::new(off + slack)?)?;
1321    if let Some((a, b)) = ogeom_algo::edge_vertices(model, edge)? {
1322        for v in [&a, &b] {
1323            model.widen(v, ogeom_core::Tolerance::new(off + slack)?)?;
1324        }
1325    }
1326    Ok(())
1327}
1328
1329/// An adopted border's image on a surface it was not fitted on: the
1330/// border's own points, each read off the surface at its nearest point,
1331/// fitted at the border's own parameters so the image is same-parameter
1332/// with it. Returned with the parameter range it spans and how far the
1333/// surface at the image stands from the border, measured between the
1334/// samples as well as at them.
1335fn adopted_image(
1336    model: &Model,
1337    edge: &Shape,
1338    surface: &SurfaceGeometry,
1339    tol: Tolerances,
1340) -> OgeomResult<(ogeom_geom::PlanarCurve, (f64, f64), f64)> {
1341    const SAMPLES: u32 = 64;
1342    let (curve, range) = spine_curve_of(model, edge)?;
1343    let mut params = Vec::with_capacity(SAMPLES as usize + 1);
1344    let mut image = Vec::with_capacity(SAMPLES as usize + 1);
1345    let mut guess: Option<(f64, f64)> = None;
1346    for step in 0..=SAMPLES {
1347        let t = range.0 + (range.1 - range.0) * f64::from(step) / f64::from(SAMPLES);
1348        let p = curve.point_at(t, tol)?;
1349        let foot = match guess {
1350            Some(g) => ogeom_algo::project_on_surface_from(surface, p, g, tol)?,
1351            None => ogeom_algo::project_on_surface(surface, p, 16, tol)?,
1352        };
1353        guess = Some(foot.parameters);
1354        params.push(t);
1355        image.push(Point2::new(foot.parameters.0, foot.parameters.1));
1356    }
1357    use ogeom_geom::Surface as _;
1358    let fitted = ogeom_geom::fit::fit_points_2d_at(&params, &image, 3, tol.confusion(), tol)?;
1359    let mut off = 0.0_f64;
1360    for step in 0..=SAMPLES * 4 {
1361        let t = range.0 + (range.1 - range.0) * f64::from(step) / f64::from(SAMPLES * 4);
1362        let at = ogeom_geom::Curve2d::point_at(&fitted.curve, t, tol)?;
1363        // A border image runs along the chart's edge, and rounding may set
1364        // it a hair outside; the surface is read at the edge there.
1365        let ((u0, u1), (v0, v1)) = surface.domain();
1366        let at = Point2::new(at.x.clamp(u0, u1), at.y.clamp(v0, v1));
1367        off = off.max(
1368            surface
1369                .point_at(at.x, at.y, tol)?
1370                .distance(curve.point_at(t, tol)?),
1371        );
1372    }
1373    Ok((fitted.curve.into(), range, off))
1374}
1375
1376/// A solid skinned over a grid of section samples: [`skinned_wall`] with a
1377/// planar cap over each end ring.
1378/// How a skinned solid's end is closed.
1379#[derive(Debug, Clone, Copy)]
1380enum EndCap {
1381    /// The section is planar: a plane face, its normal pointing out.
1382    Plane(Vector),
1383    /// The section is not: a patch skinned from the ring down to a point
1384    /// inside it, sharing the wall's ring edge.
1385    Skinned,
1386}
1387
1388fn skinned_solid(
1389    model: &mut Model,
1390    skin: &Skin<'_>,
1391    caps: (EndCap, EndCap),
1392    tolerance: f64,
1393    tol: Tolerances,
1394) -> OgeomResult<Built> {
1395    let wall = skinned_wall(model, skin, (None, None), tolerance, tol)?;
1396    let rows = &skin.rows;
1397    let u_dom = wall.u_dom;
1398
1399    let cap = |model: &mut Model,
1400               ring: &Shape,
1401               curve: ogeom_geom::Curve,
1402               outward: Vector|
1403     -> OgeomResult<Shape> {
1404        let at = curve.point_at(u_dom.0, tol)?;
1405        let plane = Plane::through(at, Direction::new(outward, tol)?);
1406        let mut reach = 1.0_f64;
1407        for t in 0..8 {
1408            let p = curve.point_at(u_dom.0 + (u_dom.1 - u_dom.0) * f64::from(t) / 8.0, tol)?;
1409            reach = reach.max(p.distance(at) * 2.0);
1410        }
1411        let cap_surface: SurfaceGeometry =
1412            PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
1413        let walked = walked_about(model, std::slice::from_ref(ring), outward, true, tol)?;
1414        let wire = ogeom_algo::make_wire(model, &walked, tol)?.shape;
1415        let face =
1416            ogeom_algo::make_face(model, cap_surface.clone(), std::slice::from_ref(&wire), tol)?
1417                .shape;
1418        let id = {
1419            let Some(node) = model.node(&face) else {
1420                ogeom_bail!(Dangling, "the cap just built is not in this model");
1421            };
1422            let ogeom_topo::NodeData::Face(data) = node.data() else {
1423                ogeom_bail!(Construction, "the cap holds no face data");
1424            };
1425            data.surface
1426        };
1427        let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&curve, &cap_surface, tol) else {
1428            ogeom_bail!(Construction, "a cap edge has no closed-form pcurve");
1429        };
1430        ogeom_algo::attach_pcurve(
1431            model,
1432            ring,
1433            pcurve,
1434            id,
1435            ogeom_topo::Location::identity(),
1436            u_dom,
1437        )?;
1438        Ok(face)
1439    };
1440    let close = |model: &mut Model,
1441                 end: EndCap,
1442                 ring: &Shape,
1443                 curve: &ogeom_geom::Curve,
1444                 row: &[Point],
1445                 last: bool| {
1446        match end {
1447            EndCap::Plane(outward) => cap(model, ring, curve.clone(), outward),
1448            EndCap::Skinned => {
1449                // The cone from the section to the point its rows collapse
1450                // to (the section's own centroid, which a closed section
1451                // winds round): the ring, a row halfway in, and the point.
1452                let apex = centroid_of(std::slice::from_ref(&row.to_vec()));
1453                let patch = match &skin.traced {
1454                    // Known between the samples where the skin knows its
1455                    // end section there, and checked there too.
1456                    Some(traced) => {
1457                        let vs = &traced.sampling.vs;
1458                        let at = if last { vs[vs.len() - 1] } else { vs[0] };
1459                        Skin::swept(
1460                            move |f, s| {
1461                                let p = (traced.point)(f, at)?;
1462                                Ok(apex + (p - apex) * (1.0 - s * 0.5))
1463                            },
1464                            traced.sampling.us.clone(),
1465                            (0, 2),
1466                            skin.round,
1467                        )?
1468                    }
1469                    None => {
1470                        let half: Vec<Point> = row
1471                            .iter()
1472                            .map(|p| Point::from_vector((p.to_vector() + apex.to_vector()) * 0.5))
1473                            .collect();
1474                        Skin::rows(vec![row.to_vec(), half, vec![apex; row.len()]])
1475                    }
1476                };
1477                // Inside the solid behind the cap: the middle of the next
1478                // section in.
1479                let next = if last {
1480                    &rows[rows.len() - 2]
1481                } else {
1482                    &rows[1]
1483                };
1484                let inside = centroid_of(std::slice::from_ref(next));
1485                Ok(apex_patch(model, &patch, Some(ring), Some(inside), tolerance, tol)?.0)
1486            }
1487        }
1488    };
1489    let cap0 = close(model, caps.0, &wall.ring0, &wall.curve0, &rows[0], false)?;
1490    let cap1 = close(
1491        model,
1492        caps.1,
1493        &wall.ring1,
1494        &wall.curve1,
1495        &rows[rows.len() - 1],
1496        true,
1497    )?;
1498
1499    let faces = [wall.face, cap0, cap1];
1500    let sewn = sew(model, &faces, tol)?;
1501    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
1502        ogeom_bail!(Construction, "the skinned solid did not close");
1503    }
1504    make_solid(model, std::slice::from_ref(&sewn.shells[0]))
1505}
1506
1507/// A patch skinned from a ring down to a point: [`skinned_wall`]'s
1508/// construction with the top ring replaced by the apex: a degenerate
1509/// edge on one vertex, bounding the chart's whole top row the way a cone's
1510/// apex bounds a countersink. The ring edge is adopted from `shared`
1511/// where a neighbour already built it, and the face is turned to point
1512/// away from `inside` where given (a point within the solid behind the
1513/// patch), from the section it passes through otherwise. Returns the face
1514/// and its ring edge.
1515fn apex_patch(
1516    model: &mut Model,
1517    skin: &Skin<'_>,
1518    shared: Option<&Shape>,
1519    inside: Option<Point>,
1520    tolerance: f64,
1521    tol: Tolerances,
1522) -> OgeomResult<(Shape, Shape)> {
1523    use ogeom_geom::Surface as _;
1524    let rows = &skin.rows;
1525    let fitted = skin.fit(false, tolerance, tol)?;
1526    if !fitted.met {
1527        ogeom_bail!(
1528            NotDone,
1529            "the skin reached {} against a target of {tolerance}",
1530            fitted.error
1531        );
1532    }
1533    let surface = fitted.curve;
1534    let (u_knots, v_knots) = (surface.u_knots().clone(), surface.v_knots().clone());
1535    let (k, l, net) = {
1536        let grid = surface.grid();
1537        let net: Vec<Point> = grid.points().iter().map(|w| (*w).point()).collect();
1538        (grid.u_count(), grid.v_count(), net)
1539    };
1540    let point_at = |i: usize, j: usize| -> Point { net[i * l + j] };
1541    let (u_dom, v_dom) = surface.domain();
1542    let apex = rows[rows.len() - 1][0];
1543
1544    let ring_curve = {
1545        let control: Vec<Point> = (0..k).map(|i| point_at(i, 0)).collect();
1546        ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
1547            u_knots.clone(),
1548            control,
1549            tol,
1550        )?)
1551    };
1552    let seam_curve = {
1553        let control: Vec<Point> = (0..l).map(|j| point_at(0, j)).collect();
1554        ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
1555            v_knots.clone(),
1556            control,
1557            tol,
1558        )?)
1559    };
1560    let surface_geo: SurfaceGeometry = surface.into();
1561    let surface_id = model.geometry_mut().add_surface(surface_geo.clone());
1562
1563    // The ring a neighbour built is adopted, not refitted (see `adopt_border`).
1564    let ring0 = match shared {
1565        Some(edge) => {
1566            adopt_border(
1567                model,
1568                edge,
1569                &surface_geo,
1570                fitted.error + tol.confusion(),
1571                tol,
1572            )?;
1573            edge.clone()
1574        }
1575        None => make_edge(model, ring_curve.clone(), u_dom, tol)?.shape,
1576    };
1577    let anchor0 = ogeom_algo::edge_vertices(model, &ring0)?
1578        .map(|(a, _)| a)
1579        .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "a skinned ring has no vertex"))?;
1580    let apex_vertex = model.add_vertex(VertexData::new(apex));
1581    let apex_edge = {
1582        let mut data = EdgeData::new();
1583        data.degenerate = true;
1584        model.add_edge(data, &[apex_vertex.clone(), apex_vertex.clone()])?
1585    };
1586    let seam = make_edge_between(model, seam_curve, v_dom, &anchor0, &apex_vertex, tol)?.shape;
1587
1588    let row_line = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1589        Ok(Line2d::over(
1590            ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
1591            u_dom.0 - 1.0,
1592            u_dom.1 + 1.0,
1593        )?
1594        .into())
1595    };
1596    let column_line = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1597        Ok(Line2d::over(
1598            ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
1599            v_dom.0 - 1.0,
1600            v_dom.1 + 1.0,
1601        )?
1602        .into())
1603    };
1604    ogeom_algo::attach_pcurve(
1605        model,
1606        &ring0,
1607        row_line(v_dom.0)?,
1608        surface_id,
1609        ogeom_topo::Location::identity(),
1610        u_dom,
1611    )?;
1612    // The apex bounds the chart's whole top row while covering no distance:
1613    // the degenerate edge carries the row's pcurve, exactly as a cone's apex
1614    // does after the reader synthesises it.
1615    ogeom_algo::attach_pcurve(
1616        model,
1617        &apex_edge,
1618        row_line(v_dom.1)?,
1619        surface_id,
1620        ogeom_topo::Location::identity(),
1621        u_dom,
1622    )?;
1623    ogeom_algo::attach_seam(
1624        model,
1625        &seam,
1626        column_line(u_dom.0)?,
1627        column_line(u_dom.1)?,
1628        surface_id,
1629        ogeom_topo::Location::identity(),
1630        v_dom,
1631    )?;
1632
1633    let wire = ogeom_algo::make_wire(
1634        model,
1635        &[
1636            ring0.clone(),
1637            seam.clone(),
1638            apex_edge.reversed(),
1639            seam.reversed(),
1640        ],
1641        tol,
1642    )?
1643    .shape;
1644    let face = ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
1645    let mid_u = f64::midpoint(u_dom.0, u_dom.1);
1646    let mid_v = f64::midpoint(v_dom.0, v_dom.1);
1647    let s_mid = surface_geo.point_at(mid_u, mid_v, tol)?;
1648    let (du, dv) = surface_geo.d1_at(mid_u, mid_v, tol)?;
1649    // Without a point inside, inside is judged from the section the patch
1650    // passes through at that row, not from the whole skin's centroid: a
1651    // skin that bends puts that centroid outside itself, in the crook of
1652    // the bend. A patch spanning a section (a cap) has its normal square to
1653    // the way out from that section's middle, and is told where inside is.
1654    let local = match inside {
1655        Some(at) => at,
1656        None => {
1657            let mut sum = Vector::ZERO;
1658            for k in 0..16 {
1659                let u = u_dom.0 + (u_dom.1 - u_dom.0) * f64::from(k) / 16.0;
1660                sum += surface_geo.point_at(u, mid_v, tol)?.to_vector();
1661            }
1662            Point::from_vector(sum / 16.0)
1663        }
1664    };
1665    let face = if du.cross(dv).dot(s_mid - local) >= 0.0 {
1666        face
1667    } else {
1668        face.reversed()
1669    };
1670    Ok((face, ring0))
1671}
1672
1673/// The mean of every point in every row.
1674fn centroid_of(rows: &[Vec<Point>]) -> Point {
1675    let mut c = Vector::new(0.0, 0.0, 0.0);
1676    let mut n = 0.0;
1677    for row in rows {
1678        for p in row {
1679            c += p.to_vector();
1680            n += 1.0;
1681        }
1682    }
1683    Point::from_vector(c / n)
1684}
1685
1686/// A solid skinned down to a point: [`apex_patch`] for the wall, and one
1687/// cap at the open end; the apex end closes by construction.
1688fn skinned_solid_to_apex(
1689    model: &mut Model,
1690    skin: &Skin<'_>,
1691    cap_outward: Vector,
1692    tolerance: f64,
1693    tol: Tolerances,
1694) -> OgeomResult<Built> {
1695    use ogeom_geom::Curve3d as _;
1696    let (wall, ring0) = apex_patch(model, skin, None, None, tolerance, tol)?;
1697    let (ring_curve, u_dom) = {
1698        let (curve, range) = spine_curve_of(model, &ring0)?;
1699        (curve, range)
1700    };
1701
1702    // One cap, on the open end; the machinery is skinned_solid's, inlined
1703    // for the single ring.
1704    let cap = {
1705        let at = ring_curve.point_at(u_dom.0, tol)?;
1706        let plane = Plane::through(at, Direction::new(cap_outward, tol)?);
1707        let mut reach = 1.0_f64;
1708        for t in 0..8 {
1709            let p = ring_curve.point_at(u_dom.0 + (u_dom.1 - u_dom.0) * f64::from(t) / 8.0, tol)?;
1710            reach = reach.max(p.distance(at) * 2.0);
1711        }
1712        let cap_surface: SurfaceGeometry =
1713            PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
1714        let walked = walked_about(model, std::slice::from_ref(&ring0), cap_outward, true, tol)?;
1715        let wire = ogeom_algo::make_wire(model, &walked, tol)?.shape;
1716        let face =
1717            ogeom_algo::make_face(model, cap_surface.clone(), std::slice::from_ref(&wire), tol)?
1718                .shape;
1719        let id = {
1720            let Some(node) = model.node(&face) else {
1721                ogeom_bail!(Dangling, "the cap just built is not in this model");
1722            };
1723            let ogeom_topo::NodeData::Face(data) = node.data() else {
1724                ogeom_bail!(Construction, "the cap holds no face data");
1725            };
1726            data.surface
1727        };
1728        let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&ring_curve, &cap_surface, tol) else {
1729            ogeom_bail!(Construction, "a cap edge has no closed-form pcurve");
1730        };
1731        ogeom_algo::attach_pcurve(
1732            model,
1733            &ring0,
1734            pcurve,
1735            id,
1736            ogeom_topo::Location::identity(),
1737            u_dom,
1738        )?;
1739        face
1740    };
1741
1742    let faces = [wall, cap];
1743    let sewn = sew(model, &faces, tol)?;
1744    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
1745        ogeom_bail!(Construction, "the skinned apex solid did not close");
1746    }
1747    make_solid(model, std::slice::from_ref(&sewn.shells[0]))
1748}
1749
1750/// A solid skinned over a grid of sections that loops back on itself: the
1751/// wall is one face closed in both chart directions, no caps at all.
1752///
1753/// The `u` seam closes the way every skin's does (pinned row ends), and
1754/// the `v` loop closes through [`ogeom_geom::fit::fit_surface_grid_closed_v`],
1755/// C1 across the join. All four boundary traversals are two seam edges used
1756/// twice, anchored at one shared vertex, exactly as a torus bounds itself.
1757fn closed_skinned_solid(
1758    model: &mut Model,
1759    skin: &Skin<'_>,
1760    tolerance: f64,
1761    tol: Tolerances,
1762) -> OgeomResult<Built> {
1763    let shell = closed_skinned_shell(model, skin, tolerance, tol)?;
1764    make_solid(model, std::slice::from_ref(&shell))
1765}
1766
1767/// The closed skin as a shell, for callers assembling solids with voids;
1768/// a holed profile's ring is one outer shell and one per tunnel.
1769fn closed_skinned_shell(
1770    model: &mut Model,
1771    skin: &Skin<'_>,
1772    tolerance: f64,
1773    tol: Tolerances,
1774) -> OgeomResult<Shape> {
1775    use ogeom_geom::Surface as _;
1776    let rows = &skin.rows;
1777    let fitted = skin.fit(true, tolerance, tol)?;
1778    if !fitted.met {
1779        ogeom_bail!(
1780            NotDone,
1781            "the closed skin reached {} against a target of {tolerance}",
1782            fitted.error
1783        );
1784    }
1785    let surface = fitted.curve;
1786    let (u_knots, v_knots) = (surface.u_knots().clone(), surface.v_knots().clone());
1787    let (k, l, net) = {
1788        let grid = surface.grid();
1789        let net: Vec<Point> = grid.points().iter().map(|w| (*w).point()).collect();
1790        (grid.u_count(), grid.v_count(), net)
1791    };
1792    let point_at = |i: usize, j: usize| -> Point { net[i * l + j] };
1793    let (u_dom, v_dom) = surface.domain();
1794
1795    // Both seams straight off the net: the u-run at v's join, and the v-run
1796    // at u's.
1797    let along_u = {
1798        let control: Vec<Point> = (0..k).map(|i| point_at(i, 0)).collect();
1799        ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(u_knots, control, tol)?)
1800    };
1801    let along_v = {
1802        let control: Vec<Point> = (0..l).map(|j| point_at(0, j)).collect();
1803        ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(v_knots, control, tol)?)
1804    };
1805    let surface_geo: SurfaceGeometry = surface.into();
1806    let surface_id = model.geometry_mut().add_surface(surface_geo.clone());
1807
1808    let u_edge = make_edge(model, along_u, u_dom, tol)?.shape;
1809    let Some((corner, _)) = ogeom_algo::edge_vertices(model, &u_edge)? else {
1810        ogeom_bail!(Construction, "the closed skin's seam has no vertex");
1811    };
1812    let v_edge = make_edge_between(model, along_v, v_dom, &corner, &corner, tol)?.shape;
1813
1814    let row_line = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1815        Ok(Line2d::over(
1816            ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
1817            u_dom.0 - 1.0,
1818            u_dom.1 + 1.0,
1819        )?
1820        .into())
1821    };
1822    let column_line = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1823        Ok(Line2d::over(
1824            ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
1825            v_dom.0 - 1.0,
1826            v_dom.1 + 1.0,
1827        )?
1828        .into())
1829    };
1830    // The u-run is a seam in v (the same curve at both rows), and the
1831    // v-run a seam in u.
1832    ogeom_algo::attach_seam(
1833        model,
1834        &u_edge,
1835        row_line(v_dom.0)?,
1836        row_line(v_dom.1)?,
1837        surface_id,
1838        ogeom_topo::Location::identity(),
1839        u_dom,
1840    )?;
1841    ogeom_algo::attach_seam(
1842        model,
1843        &v_edge,
1844        column_line(u_dom.1)?,
1845        column_line(u_dom.0)?,
1846        surface_id,
1847        ogeom_topo::Location::identity(),
1848        v_dom,
1849    )?;
1850
1851    let wire = ogeom_algo::make_wire(
1852        model,
1853        &[
1854            u_edge.clone(),
1855            v_edge.clone(),
1856            u_edge.reversed(),
1857            v_edge.reversed(),
1858        ],
1859        tol,
1860    )?
1861    .shape;
1862    let face = ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
1863    // Away from the centre of the section the point lies on; a closed
1864    // skin's centre as a whole sits in its hole.
1865    let mid_u = f64::midpoint(u_dom.0, u_dom.1);
1866    let mid_v = f64::midpoint(v_dom.0, v_dom.1);
1867    let s_mid = surface_geo.point_at(mid_u, mid_v, tol)?;
1868    let (du, dv) = surface_geo.d1_at(mid_u, mid_v, tol)?;
1869    let face = if du.cross(dv).dot(s_mid - section_centre(rows, s_mid)) >= 0.0 {
1870        face
1871    } else {
1872        face.reversed()
1873    };
1874
1875    let sewn = sew(model, std::slice::from_ref(&face), tol)?;
1876    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
1877        ogeom_bail!(Construction, "the closed skin did not close");
1878    }
1879    Ok(sewn.shells[0].clone())
1880}
1881
1882/// The loft to a point: a wire section closing onto a single apex vertex.
1883///
1884/// A circle takes the cone the revolved primitives already build, apex on
1885/// its axis or refused; a polygon takes exact planar triangle walls, sound
1886/// for *any* apex: a skew pyramid's walls are still triangles.
1887fn loft_to_point(
1888    model: &mut Model,
1889    section: &Shape,
1890    apex: &Shape,
1891    tol: Tolerances,
1892) -> OgeomResult<Built> {
1893    if !ogeom_algo::is_wire_closed(model, section, tol)? {
1894        ogeom_bail!(Construction, "a loft section must be closed");
1895    }
1896    let apex_point = {
1897        let Some(data) = model.node(apex).and_then(|n| n.data().as_vertex()) else {
1898            ogeom_bail!(Construction, "the apex vertex holds no data");
1899        };
1900        data.point
1901    };
1902
1903    // The circular case: a cone, apex on the axis.
1904    let edges = explore(model, section, Filter::OfType(ShapeType::Edge))?;
1905    if edges.len() == 1
1906        && let Some(data) = model.node(&edges[0]).and_then(|n| n.data().as_edge())
1907        && let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d()
1908        && let Some(Curve::Circle(c)) = model.geometry().curve(*curve)
1909    {
1910        let circle = c.circle();
1911        let axis = circle.frame().z().vector();
1912        let rise = apex_point - circle.centre();
1913        let height = rise.dot(axis);
1914        if rise.cross(axis).magnitude() > tol.confusion() * 10.0 {
1915            ogeom_bail!(
1916                Construction,
1917                "a circle lofts to a point on its own axis; the oblique cone \
1918                 needs the skinned machinery; see docs/PARITY.md, offset.loft"
1919            );
1920        }
1921        if height.abs() <= tol.confusion() {
1922            ogeom_bail!(Construction, "the apex sits in the section's own plane");
1923        }
1924        let base = if height > 0.0 {
1925            circle.frame()
1926        } else {
1927            Frame::new(
1928                circle.centre(),
1929                -circle.frame().z(),
1930                circle.frame().x(),
1931                tol,
1932            )?
1933        };
1934        let mut built =
1935            ogeom_algo::make_cone(model, base, circle.radius(), 0.0, height.abs(), tol)?;
1936        built.history.generate(section, built.shape.clone());
1937        built.history.generate(apex, built.shape.clone());
1938        return Ok(built);
1939    }
1940
1941    // The polygonal case: exact triangle walls to a shared apex.
1942    let mut corners: Vec<Point> = Vec::new();
1943    for edge in model.ordered_children_of(section)? {
1944        let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1945            ogeom_bail!(Construction, "a section edge holds no data");
1946        };
1947        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1948            ogeom_bail!(Construction, "a section edge has no curve");
1949        };
1950        let Some(Curve::Line(line)) = model.geometry().curve(*curve).cloned() else {
1951            ogeom_bail!(
1952                Construction,
1953                "a mixed or curved section lofts to a point through the \
1954                 skinned machinery; see docs/PARITY.md, offset.loft"
1955            );
1956        };
1957        let t = if edge.orientation() == ogeom_topo::Orientation::Reversed {
1958            range.1
1959        } else {
1960            range.0
1961        };
1962        corners.push(ogeom_geom::Curve::Line(line).point_at(t, tol)?);
1963    }
1964    if corners.len() < 3 {
1965        ogeom_bail!(Construction, "a pyramid needs at least three base corners");
1966    }
1967    let apex_vertex = ogeom_algo::make_vertex(model, apex_point).shape;
1968    let base_vertices: Vec<Shape> = corners
1969        .iter()
1970        .map(|p| ogeom_algo::make_vertex(model, *p).shape)
1971        .collect();
1972    let segment =
1973        |model: &mut Model, from: (&Shape, Point), to: (&Shape, Point)| -> OgeomResult<Shape> {
1974            let line = ogeom_geom::LineCurve::segment(from.1, to.1, tol)?;
1975            let curve: Curve = line.into();
1976            let domain = curve.domain();
1977            Ok(make_edge_between(model, curve, domain, from.0, to.0, tol)?.shape)
1978        };
1979    let count = corners.len();
1980    let mut base_edges = Vec::with_capacity(count);
1981    let mut rails = Vec::with_capacity(count);
1982    for i in 0..count {
1983        let next = (i + 1) % count;
1984        base_edges.push(segment(
1985            model,
1986            (&base_vertices[i], corners[i]),
1987            (&base_vertices[next], corners[next]),
1988        )?);
1989        rails.push(segment(
1990            model,
1991            (&base_vertices[i], corners[i]),
1992            (&apex_vertex, apex_point),
1993        )?);
1994    }
1995    let centroid = {
1996        let mut c = Vector::new(0.0, 0.0, 0.0);
1997        for p in &corners {
1998            c += p.to_vector();
1999        }
2000        #[allow(clippy::cast_precision_loss)]
2001        Point::from_vector(c / count as f64 / 4.0 * 3.0 + apex_point.to_vector() / 4.0)
2002    };
2003    let planar = |model: &mut Model, pts: [Point; 3], walk: Vec<Shape>| -> OgeomResult<Shape> {
2004        let n = (pts[1] - pts[0]).cross(pts[2] - pts[0]);
2005        let m = n.magnitude();
2006        if m <= tol.confusion() {
2007            ogeom_bail!(Construction, "a wall of the pyramid is degenerate");
2008        }
2009        let mut outward = n / m;
2010        if outward.dot(pts[0] - centroid) < 0.0 {
2011            outward = -outward;
2012        }
2013        let plane = ogeom_math::Plane::through(pts[0], Direction::new(outward, tol)?);
2014        let mut reach = 1.0_f64;
2015        for p in pts {
2016            reach = reach.max(p.distance(pts[0]) * 2.0);
2017        }
2018        let surface: SurfaceGeometry =
2019            PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
2020        let id = model.geometry_mut().add_surface(surface.clone());
2021        let signed = {
2022            let (du, dv) = {
2023                use ogeom_geom::Surface as _;
2024                surface.d1_at(0.0, 0.0, tol)?
2025            };
2026            du.cross(dv).dot(outward) >= 0.0
2027        };
2028        let mut wired = Vec::with_capacity(walk.len());
2029        for used in &walk {
2030            let (curve, range) = spine_curve_of(model, used)?;
2031            let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&curve, &surface, tol) else {
2032                ogeom_bail!(Construction, "a wall edge has no closed-form pcurve");
2033            };
2034            ogeom_algo::attach_pcurve(
2035                model,
2036                used,
2037                pcurve,
2038                id,
2039                ogeom_topo::Location::identity(),
2040                range,
2041            )?;
2042            wired.push(used.clone());
2043        }
2044        let wire = ogeom_algo::make_wire(model, &wired, tol)?.shape;
2045        let face = ogeom_algo::make_face_on(model, id, std::slice::from_ref(&wire), tol)?.shape;
2046        Ok(if signed { face } else { face.reversed() })
2047    };
2048    let mut faces = Vec::with_capacity(count + 1);
2049    for i in 0..count {
2050        let next = (i + 1) % count;
2051        faces.push(planar(
2052            model,
2053            [corners[i], corners[next], apex_point],
2054            vec![
2055                base_edges[i].clone(),
2056                rails[next].clone(),
2057                rails[i].reversed(),
2058            ],
2059        )?);
2060    }
2061    // The base cap: all corners, wound against the walls.
2062    let base_walk: Vec<Shape> = (0..count).rev().map(|i| base_edges[i].reversed()).collect();
2063    faces.push({
2064        let n = (corners[1] - corners[0]).cross(corners[2] - corners[0]);
2065        let mut outward = n / n.magnitude();
2066        if outward.dot(corners[0] - centroid) < 0.0 {
2067            outward = -outward;
2068        }
2069        let plane = ogeom_math::Plane::through(corners[0], Direction::new(outward, tol)?);
2070        let mut reach = 1.0_f64;
2071        for p in &corners {
2072            reach = reach.max(p.distance(corners[0]) * 2.0);
2073        }
2074        let surface: SurfaceGeometry =
2075            PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
2076        let id = model.geometry_mut().add_surface(surface.clone());
2077        for used in &base_walk {
2078            let (curve, range) = spine_curve_of(model, used)?;
2079            let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&curve, &surface, tol) else {
2080                ogeom_bail!(Construction, "a base edge has no closed-form pcurve");
2081            };
2082            ogeom_algo::attach_pcurve(
2083                model,
2084                used,
2085                pcurve,
2086                id,
2087                ogeom_topo::Location::identity(),
2088                range,
2089            )?;
2090        }
2091        let wire = ogeom_algo::make_wire(model, &base_walk, tol)?.shape;
2092        let face = ogeom_algo::make_face_on(model, id, std::slice::from_ref(&wire), tol)?.shape;
2093        let signed = {
2094            use ogeom_geom::Surface as _;
2095            let (du, dv) = surface.d1_at(0.0, 0.0, tol)?;
2096            du.cross(dv).dot(outward) >= 0.0
2097        };
2098        if signed { face } else { face.reversed() }
2099    });
2100
2101    let sewn = sew(model, &faces, tol)?;
2102    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
2103        ogeom_bail!(Construction, "the pyramid did not close");
2104    }
2105    let mut built = make_solid(model, std::slice::from_ref(&sewn.shells[0]))?;
2106    built.history.generate(section, built.shape.clone());
2107    built.history.generate(apex, built.shape.clone());
2108    Ok(built)
2109}
2110
2111/// Loft through sections with the start of each row named by the caller.
2112///
2113/// [`make_loft_skinned`] leaves alignment to each section's own traversal
2114/// start; this sibling takes one hint per section (a point near where its
2115/// row should begin) and rotates each sampling there, which is how a
2116/// caller untwists a loft whose wires happen to start in different places.
2117///
2118/// # Errors
2119///
2120/// As [`make_loft_skinned`], and additionally if the hints do not pair up
2121/// with the sections.
2122pub fn make_loft_skinned_aligned(
2123    model: &mut Model,
2124    sections: &[Shape],
2125    hints: &[Point],
2126    tolerance: f64,
2127    tol: Tolerances,
2128) -> OgeomResult<Built> {
2129    if hints.len() != sections.len() {
2130        ogeom_bail!(
2131            Construction,
2132            "{} hints against {} sections; each section names its own start",
2133            hints.len(),
2134            sections.len()
2135        );
2136    }
2137    if sections.len() < 2 {
2138        ogeom_bail!(Construction, "a loft needs at least two sections");
2139    }
2140    let mut loops: Vec<Section> = Vec::with_capacity(sections.len());
2141    let mut planes: Vec<Plane> = Vec::with_capacity(sections.len());
2142    for (wire, hint) in sections.iter().zip(hints) {
2143        if model.kind_of(wire)? != ShapeType::Wire {
2144            ogeom_bail!(Construction, "a loft section is a closed wire");
2145        }
2146        if !ogeom_algo::is_wire_closed(model, wire, tol)? {
2147            ogeom_bail!(Construction, "a loft section must be closed");
2148        }
2149        let Some(plane) = ogeom_algo::find_plane(model, wire, tol)? else {
2150            ogeom_bail!(Construction, "a loft section must be planar");
2151        };
2152        planes.push(plane);
2153        loops.push(Section::Loop(section_loop(model, wire, Some(*hint), tol)?));
2154    }
2155    let skin = Skin::sections(loops, false)?;
2156    let rows = &skin.rows;
2157    let outward0 = {
2158        let towards = rows[1][0] - rows[0][0];
2159        let n = planes[0].normal().vector();
2160        if n.dot(towards) > 0.0 { -n } else { n }
2161    };
2162    let outward1 = {
2163        let towards = rows[rows.len() - 2][0] - rows[rows.len() - 1][0];
2164        let n = planes[planes.len() - 1].normal().vector();
2165        if n.dot(towards) > 0.0 { -n } else { n }
2166    };
2167    let mut built = skinned_solid(
2168        model,
2169        &skin,
2170        (EndCap::Plane(outward0), EndCap::Plane(outward1)),
2171        tolerance,
2172        tol,
2173    )?;
2174    for section in sections {
2175        built.history.generate(section, built.shape.clone());
2176    }
2177    Ok(built)
2178}
2179
2180/// Loft a ring through closed planar sections that loop back to the first.
2181///
2182/// [`make_loft_skinned`]'s closed sibling: the sections are sampled the same
2183/// way, the skin runs through all of them and back to the start, C1 across
2184/// the loop, and there are no caps: the result bounds itself the way a
2185/// torus does. The sections are *not* repeated: the loop-back is the
2186/// construction's own.
2187///
2188/// The closed join costs freedom: a sparse loop fits only loosely, and the
2189/// refusal quotes the deviation it honestly reached. A loop that wants a
2190/// tight tolerance wants sections dense enough to bend around: in
2191/// practice, a dozen and up.
2192///
2193/// # Errors
2194///
2195/// As [`make_loft_skinned`], needing at least three sections;
2196/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the closed
2197/// skin cannot reach the tolerance.
2198pub fn make_loft_skinned_closed(
2199    model: &mut Model,
2200    sections: &[Shape],
2201    tolerance: f64,
2202    tol: Tolerances,
2203) -> OgeomResult<Built> {
2204    if sections.len() < 3 {
2205        ogeom_bail!(Construction, "a closed loft needs at least three sections");
2206    }
2207    let mut loops: Vec<Section> = Vec::with_capacity(sections.len());
2208    for wire in sections {
2209        if model.kind_of(wire)? != ShapeType::Wire {
2210            ogeom_bail!(Construction, "a loft section is a closed wire");
2211        }
2212        if !ogeom_algo::is_wire_closed(model, wire, tol)? {
2213            ogeom_bail!(Construction, "a loft section must be closed");
2214        }
2215        if ogeom_algo::find_plane(model, wire, tol)?.is_none() {
2216            ogeom_bail!(Construction, "a loft section must be planar");
2217        }
2218        loops.push(Section::Loop(section_loop(model, wire, None, tol)?));
2219    }
2220    let skin = Skin::sections(loops, true)?;
2221    let mut built = closed_skinned_solid(model, &skin, tolerance, tol)?;
2222    for section in sections {
2223        built.history.generate(section, built.shape.clone());
2224    }
2225    Ok(built)
2226}
2227
2228/// A skinned strip: one open patch of a sweep, with its border edges.
2229///
2230/// The wall of a *faceted* profile cannot be one closed skin (a fit cannot
2231/// speak a corner), so each profile edge sweeps its own strip, cornered at
2232/// the caller's shared vertices, and the strips weld along their rails by
2233/// the tolerance the fit honestly carries.
2234struct SkinnedStrip {
2235    face: Shape,
2236    /// The border along the first station, from `corners.0` to `corners.1`.
2237    bottom: Shape,
2238    /// The border along the last station, from `corners.2` to `corners.3`.
2239    top: Shape,
2240    /// The rail along the profile edge's start, from `corners.0` to `corners.2`.
2241    rail0: Shape,
2242    /// The rail along the profile edge's end, from `corners.1` to `corners.3`.
2243    rail1: Shape,
2244}
2245
2246/// Skin an open grid of samples (stations by profile-edge samples) into
2247/// one strip. `corners` are the caller's vertices at (first station, edge
2248/// start), (first, end), (last, start), (last, end), shared with the
2249/// neighbouring strips so the wires chain. `shared` are borders a
2250/// neighbouring strip already built (bottom, top, start rail, end rail),
2251/// adopted as they are (see `adopt_border`).
2252#[allow(clippy::too_many_arguments, reason = "one strip, spelled out")]
2253fn skinned_strip(
2254    model: &mut Model,
2255    skin: &Skin<'_>,
2256    corners: (&Shape, &Shape, &Shape, &Shape),
2257    shared: [Option<&Shape>; 4],
2258    outward_hint: Point,
2259    hole: bool,
2260    tolerance: f64,
2261    tol: Tolerances,
2262) -> OgeomResult<SkinnedStrip> {
2263    use ogeom_geom::Surface as _;
2264    let rows = &skin.rows;
2265    // A strip whose every row lies in one plane (a straight profile edge
2266    // down a straight run, a flat face of the profile along a planar
2267    // spine) is that plane, exactly: a coplanar neighbour then melts with
2268    // it on the one surface two fits of it would never agree on.
2269    if let Some(plane) = plane_of_rows(rows, tol) {
2270        return planar_strip(
2271            model,
2272            skin,
2273            plane,
2274            corners,
2275            shared,
2276            outward_hint,
2277            hole,
2278            tolerance,
2279            tol,
2280        );
2281    }
2282    // Sections a caller placed follow their own spacing across the skin;
2283    // stations a sweep placed keep the fit's centripetal assignment, or
2284    // their index where the sweep is known between them.
2285    let fitted = skin.fit(false, tolerance, tol)?;
2286    if !fitted.met {
2287        ogeom_bail!(
2288            NotDone,
2289            "the strip reached {} against a target of {tolerance}",
2290            fitted.error
2291        );
2292    }
2293    let error = fitted.error.max(tol.confusion());
2294    let surface = fitted.curve;
2295    let (u_knots, v_knots) = (surface.u_knots().clone(), surface.v_knots().clone());
2296    let (k, l, net) = {
2297        let grid = surface.grid();
2298        let net: Vec<Point> = grid.points().iter().map(|w| (*w).point()).collect();
2299        (grid.u_count(), grid.v_count(), net)
2300    };
2301    let point_at = |i: usize, j: usize| -> Point { net[i * l + j] };
2302    let (u_dom, v_dom) = surface.domain();
2303
2304    let u_curve = |j: usize| -> OgeomResult<ogeom_geom::Curve> {
2305        let control: Vec<Point> = (0..k).map(|i| point_at(i, j)).collect();
2306        Ok(ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
2307            u_knots.clone(),
2308            control,
2309            tol,
2310        )?))
2311    };
2312    let v_curve = |i: usize| -> OgeomResult<ogeom_geom::Curve> {
2313        let control: Vec<Point> = (0..l).map(|j| point_at(i, j)).collect();
2314        Ok(ogeom_geom::Curve::BSpline(ogeom_geom::BSplineCurve::new(
2315            v_knots.clone(),
2316            control,
2317            tol,
2318        )?))
2319    };
2320    let surface_geo: SurfaceGeometry = surface.into();
2321    let surface_id = model.geometry_mut().add_surface(surface_geo.clone());
2322
2323    let (c00, c10, c01, c11) = corners;
2324    // A border the neighbouring strip already built is adopted, not
2325    // refitted (see `adopt_border`).
2326    let border = |model: &mut Model,
2327                  given: Option<&Shape>,
2328                  curve: ogeom_geom::Curve,
2329                  range: (f64, f64),
2330                  from: &Shape,
2331                  to: &Shape|
2332     -> OgeomResult<Shape> {
2333        match given {
2334            Some(edge) => {
2335                adopt_border(model, edge, &surface_geo, error + tol.confusion(), tol)?;
2336                Ok(edge.clone())
2337            }
2338            None => Ok(make_edge_between(model, curve, range, from, to, tol)?.shape),
2339        }
2340    };
2341    let bottom = border(model, shared[0], u_curve(0)?, u_dom, c00, c10)?;
2342    let top = border(model, shared[1], u_curve(l - 1)?, u_dom, c01, c11)?;
2343    let rail0 = border(model, shared[2], v_curve(0)?, v_dom, c00, c01)?;
2344    let rail1 = border(model, shared[3], v_curve(k - 1)?, v_dom, c10, c11)?;
2345
2346    let row_line = |v: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
2347        Ok(Line2d::over(
2348            ogeom_math::Axis2::new(Point2::new(0.0, v), ogeom_math::Direction2::X),
2349            u_dom.0 - 1.0,
2350            u_dom.1 + 1.0,
2351        )?
2352        .into())
2353    };
2354    let column_line = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
2355        Ok(Line2d::over(
2356            ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
2357            v_dom.0 - 1.0,
2358            v_dom.1 + 1.0,
2359        )?
2360        .into())
2361    };
2362    // A border this strip fitted runs along its own row or column, and its
2363    // image is that straight line. One adopted from the neighbour was fitted
2364    // at the neighbour's pace along the sweep, which is not this strip's:
2365    // its image here is read off this surface point by point.
2366    for (edge, given, straight, span) in [
2367        (&bottom, shared[0].is_some(), row_line(v_dom.0)?, u_dom),
2368        (&top, shared[1].is_some(), row_line(v_dom.1)?, u_dom),
2369        (&rail0, shared[2].is_some(), column_line(u_dom.0)?, v_dom),
2370        (&rail1, shared[3].is_some(), column_line(u_dom.1)?, v_dom),
2371    ] {
2372        let (image, range) = if given {
2373            let (image, range, off) = adopted_image(model, edge, &surface_geo, tol)?;
2374            // The image stands off the border by what it was measured at;
2375            // the edge and its ends carry that.
2376            if off > tol.confusion() {
2377                let held = ogeom_core::Tolerance::new(off)?;
2378                model.widen(edge, held)?;
2379                if let Some((a, b)) = ogeom_algo::edge_vertices(model, edge)? {
2380                    for v in [&a, &b] {
2381                        model.widen(v, held)?;
2382                    }
2383                }
2384            }
2385            (image, range)
2386        } else {
2387            (straight, span)
2388        };
2389        ogeom_algo::attach_pcurve(
2390            model,
2391            edge,
2392            image,
2393            surface_id,
2394            ogeom_topo::Location::identity(),
2395            range,
2396        )?;
2397    }
2398    // The rails carry the fit's honest budget: the neighbouring strip fitted
2399    // the same transported corners independently, and the weld between them
2400    // is only as tight as both fits.
2401    for edge in [&bottom, &top, &rail0, &rail1] {
2402        model.widen(edge, ogeom_core::Tolerance::new(error)?)?;
2403    }
2404
2405    let wire = ogeom_algo::make_wire(
2406        model,
2407        &[
2408            bottom.clone(),
2409            rail1.clone(),
2410            top.reversed(),
2411            rail0.reversed(),
2412        ],
2413        tol,
2414    )?
2415    .shape;
2416    let face = ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
2417    let mid_u = f64::midpoint(u_dom.0, u_dom.1);
2418    let mid_v = f64::midpoint(v_dom.0, v_dom.1);
2419    let s_mid = surface_geo.point_at(mid_u, mid_v, tol)?;
2420    let (du, dv) = surface_geo.d1_at(mid_u, mid_v, tol)?;
2421    let natural_out = du.cross(dv).dot(s_mid - outward_hint) >= 0.0;
2422    let face = if natural_out == !hole {
2423        face
2424    } else {
2425        face.reversed()
2426    };
2427    Ok(SkinnedStrip {
2428        face,
2429        bottom,
2430        top,
2431        rail0,
2432        rail1,
2433    })
2434}
2435
2436/// The plane every point of the rows lies in, if there is one.
2437fn plane_of_rows(rows: &[Vec<Point>], tol: Tolerances) -> Option<Plane> {
2438    let first = rows.first()?;
2439    let last = rows.last()?;
2440    let origin = *first.first()?;
2441    let across = *first.last()? - origin;
2442    let along = *last.first()? - origin;
2443    let normal = across.cross(along);
2444    if normal.magnitude() <= tol.confusion() * across.magnitude().max(along.magnitude()) {
2445        return None;
2446    }
2447    let normal = Direction::new(normal, tol).ok()?;
2448    let plane = Plane::through(origin, normal);
2449    rows.iter()
2450        .flatten()
2451        .all(|p| plane.distance_to(*p) <= tol.confusion())
2452        .then_some(plane)
2453}
2454
2455/// A border of a strip: the row or the column of the skin at an index.
2456#[derive(Clone, Copy)]
2457enum Side {
2458    Row(usize),
2459    Column(usize),
2460}
2461
2462/// A strip on its own exact plane: the borders fitted through the rows and
2463/// the end columns, the face on the plane.
2464#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
2465fn planar_strip(
2466    model: &mut Model,
2467    skin: &Skin<'_>,
2468    plane: Plane,
2469    corners: (&Shape, &Shape, &Shape, &Shape),
2470    shared: [Option<&Shape>; 4],
2471    outward_hint: Point,
2472    hole: bool,
2473    tolerance: f64,
2474    tol: Tolerances,
2475) -> OgeomResult<SkinnedStrip> {
2476    let rows = &skin.rows;
2477    // Splines, as every swept border is: the caps and the neighbouring
2478    // strips read them so, and a spline through collinear points is the
2479    // straight segment itself. A row or a column whose geometry the skin
2480    // knows is fitted to it, measured between its samples too.
2481    let through = |points: &[Point], side: Side| -> OgeomResult<ogeom_geom::Curve> {
2482        let fitted = match (&skin.traced, side) {
2483            (Some(traced), Side::Row(j)) => {
2484                let v = traced.sampling.vs[j];
2485                ogeom_geom::fit::fit_curve_sampled(
2486                    |u| (traced.point)(u, v),
2487                    &traced.sampling.us,
2488                    false,
2489                    3,
2490                    tolerance * 0.5,
2491                    tol,
2492                )?
2493            }
2494            (Some(traced), Side::Column(i)) if traced.sampling.between.1 => {
2495                let u = traced.sampling.us[i];
2496                ogeom_geom::fit::fit_curve_sampled(
2497                    |v| (traced.point)(u, v),
2498                    &traced.sampling.vs,
2499                    false,
2500                    3,
2501                    tolerance * 0.5,
2502                    tol,
2503                )?
2504            }
2505            _ => ogeom_geom::fit::fit_points(points, 3, tolerance * 0.5, tol)?,
2506        };
2507        if !fitted.met {
2508            ogeom_bail!(
2509                NotDone,
2510                "a planar strip's border reached {} against a target of {tolerance}",
2511                fitted.error
2512            );
2513        }
2514        Ok(ogeom_geom::Curve::BSpline(fitted.curve))
2515    };
2516    let column = |i: usize| -> Vec<Point> { rows.iter().map(|row| row[i]).collect() };
2517    let last = rows[0].len() - 1;
2518    let (c00, c10, c01, c11) = corners;
2519    let border = |model: &mut Model,
2520                  given: Option<&Shape>,
2521                  points: Vec<Point>,
2522                  side: Side,
2523                  from: &Shape,
2524                  to: &Shape|
2525     -> OgeomResult<Shape> {
2526        if let Some(edge) = given {
2527            return Ok(edge.clone());
2528        }
2529        let curve = through(&points, side)?;
2530        let domain = curve.domain();
2531        Ok(make_edge_between(model, curve, domain, from, to, tol)?.shape)
2532    };
2533    let bottom = border(model, shared[0], rows[0].clone(), Side::Row(0), c00, c10)?;
2534    let top_row = rows.len() - 1;
2535    let top = border(
2536        model,
2537        shared[1],
2538        rows[top_row].clone(),
2539        Side::Row(top_row),
2540        c01,
2541        c11,
2542    )?;
2543    let rail0 = border(model, shared[2], column(0), Side::Column(0), c00, c01)?;
2544    let rail1 = border(model, shared[3], column(last), Side::Column(last), c10, c11)?;
2545
2546    // The loop runs across, up, back and down: counter-clockwise about the
2547    // normal it turns about, read from the whole border sampled in that
2548    // order. The first row's chord and the first column's are no guide: on
2549    // a strip starting from a corner's join row they can lie along one line.
2550    let border: Vec<Point> = rows[0]
2551        .iter()
2552        .copied()
2553        .chain(rows.iter().skip(1).map(|row| row[last]))
2554        .chain(rows[top_row].iter().rev().skip(1).copied())
2555        .chain(
2556            rows.iter()
2557                .rev()
2558                .skip(1)
2559                .take(top_row.saturating_sub(1))
2560                .map(|row| row[0]),
2561        )
2562        .collect();
2563    let turn = (0..border.len())
2564        .map(|i| (border[i] - border[0]).cross(border[(i + 1) % border.len()] - border[0]))
2565        .fold(Vector::new(0.0, 0.0, 0.0), |sum, v| sum + v);
2566    let normal = if turn.dot(plane.normal().vector()) >= 0.0 {
2567        plane.normal()
2568    } else {
2569        plane.normal().reversed()
2570    };
2571    let wound = Plane::through(plane.origin(), normal);
2572    let reach = rows
2573        .iter()
2574        .flatten()
2575        .map(|p| p.distance(plane.origin()))
2576        .fold(1.0_f64, f64::max)
2577        * 2.0;
2578    let surface: SurfaceGeometry =
2579        PlaneSurface::over(wound, (-reach, reach), (-reach, reach))?.into();
2580    let face = ogeom_algo::make_face_with_pcurves(
2581        model,
2582        surface.clone(),
2583        &[vec![
2584            bottom.clone(),
2585            rail1.clone(),
2586            top.reversed(),
2587            rail0.reversed(),
2588        ]],
2589        tol,
2590    )?
2591    .shape;
2592    let mid = rows[rows.len() / 2][last / 2];
2593    let natural_out = normal.vector().dot(mid - outward_hint) >= 0.0;
2594    let face = if natural_out == !hole {
2595        face
2596    } else {
2597        face.reversed()
2598    };
2599    Ok(SkinnedStrip {
2600        face,
2601        bottom,
2602        top,
2603        rail0,
2604        rail1,
2605    })
2606}
2607
2608/// A loft through circles standing coaxial on parallel planes: the solid of
2609/// revolution of the meridian through their radii, a spline through them
2610/// in the half-plane of the first circle's start. `None` where the
2611/// sections are anything else.
2612fn coaxial_circles_loft(
2613    model: &mut Model,
2614    sections: &[Shape],
2615    tol: Tolerances,
2616) -> OgeomResult<Option<Built>> {
2617    let mut circles = Vec::with_capacity(sections.len());
2618    for wire in sections {
2619        if model.kind_of(wire)? != ShapeType::Wire {
2620            return Ok(None);
2621        }
2622        let edges = model.ordered_children_of(wire)?;
2623        let [edge] = edges.as_slice() else {
2624            return Ok(None);
2625        };
2626        let (curve, _) = spine_curve_of(model, edge)?;
2627        let ogeom_geom::Curve::Circle(c) = curve else {
2628            return Ok(None);
2629        };
2630        let placed = c
2631            .circle()
2632            .transformed(&edge.transform(model.datums())?, tol)?;
2633        circles.push(placed);
2634    }
2635    let first = circles[0].frame();
2636    let (c0, z0) = (first.origin(), first.z().vector());
2637    let last = circles[circles.len() - 1].centre();
2638    let rise = last - c0;
2639    if rise.magnitude() <= tol.confusion() {
2640        return Ok(None);
2641    }
2642    let z = rise / rise.magnitude();
2643    if z.cross(z0).magnitude() > tol.angular() {
2644        return Ok(None);
2645    }
2646    let mut heights = Vec::with_capacity(circles.len());
2647    for c in &circles {
2648        let off = c.centre() - c0;
2649        if off.cross(z).magnitude() > tol.confusion() * 10.0
2650            || c.frame().z().vector().cross(z).magnitude() > tol.angular()
2651        {
2652            return Ok(None);
2653        }
2654        heights.push(off.dot(z));
2655    }
2656    if heights.windows(2).any(|w| w[1] <= w[0] + tol.confusion()) {
2657        return Ok(None);
2658    }
2659    let x = first.x().vector();
2660    let meridian: Vec<Point> = circles
2661        .iter()
2662        .zip(&heights)
2663        .map(|(c, h)| c0 + z * *h + x * c.radius())
2664        .collect();
2665    let degree = (meridian.len() - 1).min(3);
2666    let fitted = ogeom_geom::fit::fit_points(&meridian, degree, tol.confusion() * 1e-3, tol)?;
2667    let spline: ogeom_geom::Curve = fitted.curve.into();
2668    let domain = spline.domain();
2669    let top = c0 + z * heights[heights.len() - 1];
2670    let vertex = |model: &mut Model, p: Point| ogeom_algo::make_vertex(model, p).shape;
2671    let (v_axis0, v_axis1) = (vertex(model, c0), vertex(model, top));
2672    let (v_rim0, v_rim1) = (
2673        vertex(model, meridian[0]),
2674        vertex(model, meridian[meridian.len() - 1]),
2675    );
2676    let segment =
2677        |model: &mut Model, a: (&Shape, Point), b: (&Shape, Point)| -> OgeomResult<Shape> {
2678            let line: ogeom_geom::Curve = LineCurve::segment(a.1, b.1, tol)?.into();
2679            let range = line.domain();
2680            Ok(make_edge_between(model, line, range, a.0, b.0, tol)?.shape)
2681        };
2682    let bottom = segment(model, (&v_axis0, c0), (&v_rim0, meridian[0]))?;
2683    let side = make_edge_between(model, spline, domain, &v_rim0, &v_rim1, tol)?.shape;
2684    let top_edge = segment(
2685        model,
2686        (&v_rim1, meridian[meridian.len() - 1]),
2687        (&v_axis1, top),
2688    )?;
2689    let axis_edge = segment(model, (&v_axis1, top), (&v_axis0, c0))?;
2690    let wire = ogeom_algo::make_wire(model, &[bottom, side, top_edge, axis_edge], tol)?.shape;
2691    // Framed from a point inside the profile: a plane's own origin is
2692    // where its face is read when it carries no trims.
2693    let inside = c0
2694        + z * (heights[heights.len() - 1] * 0.5)
2695        + x * (circles
2696            .iter()
2697            .map(|c| c.radius())
2698            .fold(f64::INFINITY, f64::min)
2699            * 0.5);
2700    let plane = Plane::new(Frame::new(
2701        inside,
2702        Direction::new(z.cross(x), tol)?,
2703        Direction::new(x, tol)?,
2704        tol,
2705    )?);
2706    let face = ogeom_algo::make_face(model, PlaneSurface::new(plane).into(), &[wire], tol)?.shape;
2707    let axis = ogeom_math::Axis {
2708        location: c0,
2709        direction: Direction::new(z, tol)?,
2710    };
2711    let built = ogeom_algo::make_revolution(model, &face, axis, core::f64::consts::TAU, tol)?;
2712    Ok(Some(built))
2713}
2714
2715/// A loft through sections of one edge count, every vertex a corner: one
2716/// strip per edge through all the sections, meeting its neighbours along
2717/// seams through the matched corners, each strip a plane wherever its rows
2718/// share one. `None` where the sections do not pair edge for edge. Where
2719/// `along` moves the first section onto the others, the strips are held to
2720/// that motion between the sections too.
2721fn cornered_loft(
2722    model: &mut Model,
2723    sections: &[Shape],
2724    along: Option<SectionMotion<'_>>,
2725    tolerance: f64,
2726    tol: Tolerances,
2727) -> OgeomResult<Option<Built>> {
2728    let mut rings: Vec<Vec<Shape>> = Vec::with_capacity(sections.len());
2729    for wire in sections {
2730        if model.kind_of(wire)? != ShapeType::Wire || !ogeom_algo::is_wire_closed(model, wire, tol)?
2731        {
2732            return Ok(None);
2733        }
2734        rings.push(model.ordered_children_of(wire)?);
2735    }
2736    let count = rings[0].len();
2737    if count < 2 || rings.iter().any(|r| r.len() != count) {
2738        return Ok(None);
2739    }
2740    let (Some(plane0), Some(plane1)) = (
2741        ogeom_algo::find_plane(model, &sections[0], tol)?,
2742        ogeom_algo::find_plane(model, &sections[sections.len() - 1], tol)?,
2743    ) else {
2744        return Ok(None);
2745    };
2746    const ALONG: usize = 16;
2747    // Every edge of every section, read in its ring's sense at a fraction
2748    // of its parameter range.
2749    let mut edge_curves: Vec<Vec<(ogeom_geom::Curve, (f64, f64))>> =
2750        Vec::with_capacity(rings.len());
2751    for ring in &rings {
2752        let mut per_edge = Vec::with_capacity(count);
2753        for edge in ring {
2754            let (curve, range) = spine_curve_of(model, edge)?;
2755            let curve = curve.transformed(&edge.transform(model.datums())?, tol)?;
2756            let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
2757            per_edge.push((curve, if reversed { (range.1, range.0) } else { range }));
2758        }
2759        edge_curves.push(per_edge);
2760    }
2761    let edge_at = |s: usize, e: usize, f: f64| -> OgeomResult<Point> {
2762        let (curve, (a, b)) = &edge_curves[s][e];
2763        curve.point_at(a + (b - a) * f, tol)
2764    };
2765    let samples: Vec<Vec<Vec<Point>>> = (0..rings.len())
2766        .map(|s| {
2767            (0..count)
2768                .map(|e| fractions(ALONG).iter().map(|f| edge_at(s, e, *f)).collect())
2769                .collect()
2770        })
2771        .collect::<OgeomResult<_>>()?;
2772    let corners = |model: &mut Model, s: usize| -> Vec<Shape> {
2773        (0..count)
2774            .map(|e| ogeom_algo::make_vertex(model, samples[s][e][0]).shape)
2775            .collect()
2776    };
2777    let (from, to) = (corners(model, 0), corners(model, sections.len() - 1));
2778    let middle = &samples[sections.len() / 2];
2779    let hint = {
2780        let all: Vec<Point> = middle.iter().flatten().copied().collect();
2781        #[allow(clippy::cast_precision_loss)]
2782        let n = all.len() as f64;
2783        Point::from_vector(
2784            all.iter()
2785                .fold(Vector::new(0.0, 0.0, 0.0), |acc, p| acc + p.to_vector())
2786                / n,
2787        )
2788    };
2789    let mut faces = Vec::with_capacity(count + 2);
2790    let (mut bottoms, mut tops) = (Vec::with_capacity(count), Vec::with_capacity(count));
2791    let mut first_rail: Option<Shape> = None;
2792    let mut prev_rail: Option<Shape> = None;
2793    for e in 0..count {
2794        let skin = match along {
2795            Some(motion) => Skin::swept(
2796                move |f, s| {
2797                    if s.fract() == 0.0 {
2798                        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2799                        let i = s as usize;
2800                        return edge_at(i, e, f);
2801                    }
2802                    Ok(motion(s)?.apply(edge_at(0, e, f)?))
2803                },
2804                fractions(ALONG),
2805                (0, rings.len() - 1),
2806                false,
2807            )?,
2808            None => Skin::columns(
2809                |f| (0..rings.len()).map(|s| edge_at(s, e, f)).collect(),
2810                fractions(ALONG),
2811                false,
2812                true,
2813                false,
2814            )?,
2815        };
2816        let next = (e + 1) % count;
2817        let last_rail = if e + 1 == count {
2818            first_rail.clone()
2819        } else {
2820            None
2821        };
2822        let strip = skinned_strip(
2823            model,
2824            &skin,
2825            (&from[e], &from[next], &to[e], &to[next]),
2826            [None, None, prev_rail.as_ref(), last_rail.as_ref()],
2827            hint,
2828            false,
2829            tolerance,
2830            tol,
2831        )?;
2832        if e == 0 {
2833            first_rail = Some(strip.rail0.clone());
2834        }
2835        prev_rail = Some(strip.rail1.clone());
2836        faces.push(strip.face.clone());
2837        bottoms.push(strip.bottom);
2838        tops.push(strip.top);
2839    }
2840    let towards = hint - samples[0][0][0];
2841    let n0 = plane0.normal().vector();
2842    let n0 = if n0.dot(towards) > 0.0 { -n0 } else { n0 };
2843    let away = hint - samples[sections.len() - 1][0][0];
2844    let n1 = plane1.normal().vector();
2845    let n1 = if n1.dot(away) > 0.0 { -n1 } else { n1 };
2846    faces.push(plane_cap(model, samples[0][0][0], n0, &[bottoms], tol)?);
2847    faces.push(plane_cap(
2848        model,
2849        samples[sections.len() - 1][0][0],
2850        n1,
2851        &[tops],
2852        tol,
2853    )?);
2854    let sewn = sew(model, &faces, tol)?;
2855    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
2856        ogeom_bail!(Construction, "the cornered loft did not close");
2857    }
2858    Ok(Some(make_solid(model, &sewn.shells)?))
2859}
2860
2861/// A planar cap through `at`, facing `outward`, bounded by loops of spline
2862/// or line edges lying in it; each edge's trim is its exact projection into
2863/// the plane's chart.
2864fn plane_cap(
2865    model: &mut Model,
2866    at: Point,
2867    outward: Vector,
2868    loops: &[Vec<Shape>],
2869    tol: Tolerances,
2870) -> OgeomResult<Shape> {
2871    let cap_plane = Plane::through(at, Direction::new(outward, tol)?);
2872    let mut reach = 1.0_f64;
2873    for edges in loops {
2874        for edge in edges {
2875            let (curve, range) = spine_curve_of(model, edge)?;
2876            for k in 0..8 {
2877                let p = curve.point_at(range.0 + (range.1 - range.0) * f64::from(k) / 8.0, tol)?;
2878                reach = reach.max(p.distance(at) * 2.0);
2879            }
2880        }
2881    }
2882    let surface: SurfaceGeometry =
2883        PlaneSurface::over(cap_plane, (-reach, reach), (-reach, reach))?.into();
2884    // Each loop keeps the material on its left about `outward`: the one
2885    // enclosing the most turns positively, any other negatively, and a loop
2886    // walked against that is walked back.
2887    let turns: Vec<f64> = loops
2888        .iter()
2889        .map(|edges| ring_turning(model, edges, outward, tol))
2890        .collect::<OgeomResult<_>>()?;
2891    let outer = turns
2892        .iter()
2893        .enumerate()
2894        .max_by(|a, b| a.1.abs().total_cmp(&b.1.abs()))
2895        .map_or(0, |(i, _)| i);
2896    let mut wires = Vec::with_capacity(loops.len());
2897    for (i, (edges, turn)) in loops.iter().zip(&turns).enumerate() {
2898        let ring = if (*turn > 0.0) == (i == outer) {
2899            edges.clone()
2900        } else {
2901            walked_back(edges)
2902        };
2903        wires.push(ogeom_algo::make_wire(model, &ring, tol)?.shape);
2904    }
2905    let face = ogeom_algo::make_face(model, surface, &wires, tol)?.shape;
2906    let cap_id = {
2907        let Some(ogeom_topo::NodeData::Face(data)) = model.node(&face).map(|n| n.data()) else {
2908            ogeom_bail!(Construction, "the cap holds no face data");
2909        };
2910        data.surface
2911    };
2912    let frame = cap_plane.frame();
2913    let flat = |p: Point| {
2914        let local = frame.to_local(p);
2915        Point2::new(local.x, local.y)
2916    };
2917    for edges in loops {
2918        for edge in edges {
2919            let (curve, range) = spine_curve_of(model, edge)?;
2920            let pcurve: ogeom_geom::PlanarCurve = match &curve {
2921                ogeom_geom::Curve::BSpline(bs) => {
2922                    let control2: Vec<Point2> = bs
2923                        .control_points()
2924                        .iter()
2925                        .map(|w| flat(w.point()))
2926                        .collect();
2927                    ogeom_geom::BSpline2d::new(bs.knots().clone(), control2, tol)?.into()
2928                }
2929                ogeom_geom::Curve::Line(line) => {
2930                    let axis = line.axis();
2931                    let origin = flat(axis.location);
2932                    let ahead = flat(axis.location + axis.direction.vector());
2933                    ogeom_geom::Line2d::over(
2934                        ogeom_math::Axis2::through(origin, ahead, tol)?,
2935                        range.0,
2936                        range.1,
2937                    )?
2938                    .into()
2939                }
2940                _ => ogeom_bail!(Construction, "a cap edge is neither a spline nor a line"),
2941            };
2942            ogeom_algo::attach_pcurve(
2943                model,
2944                edge,
2945                pcurve,
2946                cap_id,
2947                ogeom_topo::Location::identity(),
2948                range,
2949            )?;
2950        }
2951    }
2952    Ok(face)
2953}
2954
2955/// Loft a solid through many closed planar sections, skinned smoothly.
2956///
2957/// The sections are sampled at matched arc-length fractions from their own
2958/// traversal starts (aligning those starts is the caller's authorship),
2959/// and the skin holds every section to `tolerance`, between the samples as
2960/// well as at them: the sampling round a section is refined where the skin
2961/// misses it. The caps are the first and last sections' own planes.
2962///
2963/// # Errors
2964///
2965/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if fewer than
2966/// two sections, a section is open or not planar;
2967/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if
2968/// the skin cannot reach the tolerance.
2969pub fn make_loft_skinned(
2970    model: &mut Model,
2971    sections: &[Shape],
2972    tolerance: f64,
2973    tol: Tolerances,
2974) -> OgeomResult<Built> {
2975    loft_skinned_along(model, sections, None, tolerance, tol)
2976}
2977
2978/// The motion carrying a loft's first section to where the sections stand
2979/// at `s`, a section's index (a fraction of the way between two sections
2980/// between them): the sections of a sweep are one section moved.
2981type SectionMotion<'a> = &'a dyn Fn(f64) -> OgeomResult<Transform>;
2982
2983/// [`make_loft_skinned`], and where `along` is given the sections are the
2984/// first one moved by it and the skin is held to that motion between them
2985/// as well as to the sections.
2986fn loft_skinned_along(
2987    model: &mut Model,
2988    sections: &[Shape],
2989    along: Option<SectionMotion<'_>>,
2990    tolerance: f64,
2991    tol: Tolerances,
2992) -> OgeomResult<Built> {
2993    if sections.len() < 2 {
2994        ogeom_bail!(Construction, "a loft needs at least two sections");
2995    }
2996    let to_point = model.kind_of(&sections[sections.len() - 1])? == ShapeType::Vertex;
2997    // The smooth skin through two sections is the ruled one, and that is
2998    // built exactly: a drum or cone between circles, planes between
2999    // polygons.
3000    if sections.len() == 2
3001        && !to_point
3002        && along.is_none()
3003        && let Ok(built) = make_loft(model, &sections[0], &sections[1], tol)
3004    {
3005        return Ok(built);
3006    }
3007    if sections.len() > 2 && !to_point {
3008        if let Some(built) = coaxial_circles_loft(model, sections, tol)? {
3009            return Ok(built);
3010        }
3011        if let Some(built) = cornered_loft(model, sections, along, tolerance, tol)? {
3012            return Ok(built);
3013        }
3014    }
3015    // A trailing vertex is the apex form: the skin narrows to a point and
3016    // the solid closes there without a cap.
3017    let apex = match model.kind_of(&sections[sections.len() - 1])? {
3018        ShapeType::Vertex => {
3019            if sections.len() < 2 {
3020                ogeom_bail!(
3021                    Construction,
3022                    "a loft to a point needs a section to start from"
3023                );
3024            }
3025            let Some(data) = model
3026                .node(&sections[sections.len() - 1])
3027                .and_then(|n| n.data().as_vertex())
3028            else {
3029                ogeom_bail!(Construction, "the apex vertex holds no point");
3030            };
3031            Some(data.point)
3032        }
3033        _ => None,
3034    };
3035    let wires = &sections[..sections.len() - usize::from(apex.is_some())];
3036    let mut loops: Vec<Section> = Vec::with_capacity(sections.len() + 1);
3037    let mut cap_planes: Vec<Option<Plane>> = Vec::with_capacity(wires.len());
3038    for wire in wires {
3039        if model.kind_of(wire)? != ShapeType::Wire {
3040            ogeom_bail!(Construction, "a loft section is a wire");
3041        }
3042        if !ogeom_algo::is_wire_closed(model, wire, tol)? {
3043            ogeom_bail!(Construction, "a loft section must be closed");
3044        }
3045        // Planarity is a *cap's* requirement, not the fit's: only the
3046        // sections a cap will stand on must hold a plane. A wavy middle
3047        // section skins fine.
3048        cap_planes.push(ogeom_algo::find_plane(model, wire, tol)?);
3049        loops.push(Section::Loop(section_loop(model, wire, None, tol)?));
3050    }
3051    // A planar end is capped by its plane; one that is not (a wavy rim),
3052    // by a patch skinned from the ring to a point inside it.
3053    let outward_at = |rows: &[Vec<Point>], planes: &[Option<Plane>], end: bool| -> EndCap {
3054        let (i, j) = if end {
3055            (rows.len() - 1, rows.len() - 2)
3056        } else {
3057            (0, 1)
3058        };
3059        let Some(plane) = &planes[i] else {
3060            return EndCap::Skinned;
3061        };
3062        let towards = rows[j][0] - rows[i][0];
3063        let n = plane.normal().vector();
3064        EndCap::Plane(if n.dot(towards) > 0.0 { -n } else { n })
3065    };
3066    let mut built = if let Some(apex) = apex {
3067        if loops.len() < 2
3068            && let Some(Section::Loop(ring)) = loops.first()
3069        {
3070            // One ring to a point is exact machinery's job when it can be;
3071            // the skin still needs two rows to shape the wall, so a middle
3072            // row is interpolated halfway toward the apex.
3073            let half = Section::Halfway(ring.clone(), apex);
3074            loops.push(half);
3075        }
3076        loops.push(Section::Point(apex));
3077        let skin = Skin::sections(loops, false)?;
3078        let rows = &skin.rows;
3079        let outward0 = match outward_at(rows, &cap_planes, false) {
3080            EndCap::Plane(n) => n,
3081            EndCap::Skinned => {
3082                ogeom_bail!(
3083                    Construction,
3084                    "a loft to a point starts from a planar section; a cap stands on it"
3085                );
3086            }
3087        };
3088        skinned_solid_to_apex(model, &skin, outward0, tolerance, tol)?
3089    } else {
3090        let skin = match along {
3091            Some(motion) => {
3092                let Some(Section::Loop(first)) = loops.first().cloned() else {
3093                    ogeom_bail!(Construction, "a loft's first section is a loop");
3094                };
3095                Skin::swept(
3096                    move |f, s| {
3097                        if s.fract() == 0.0 {
3098                            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3099                            let i = s as usize;
3100                            return Ok(loops[i].at(f));
3101                        }
3102                        Ok(motion(s)?.apply(first.at(f)))
3103                    },
3104                    fractions(AROUND_SECTION),
3105                    (0, sections.len() - 1),
3106                    true,
3107                )?
3108            }
3109            None => Skin::sections(loops, false)?,
3110        };
3111        let outward0 = outward_at(&skin.rows, &cap_planes, false);
3112        let outward1 = outward_at(&skin.rows, &cap_planes, true);
3113        skinned_solid(model, &skin, (outward0, outward1), tolerance, tol)?
3114    };
3115    for section in sections {
3116        built.history.generate(section, built.shape.clone());
3117    }
3118    Ok(built)
3119}
3120
3121/// Sample a closed wire at `count` matched arc-length fractions.
3122fn sample_wire(
3123    model: &Model,
3124    wire: &Shape,
3125    count: usize,
3126    tol: Tolerances,
3127) -> OgeomResult<Vec<Point>> {
3128    sample_wire_from(model, wire, count, None, tol)
3129}
3130
3131/// As [`sample_wire`], with the arc-length origin rotated to the dense
3132/// sample nearest `start_hint`: how a caller says which point of each
3133/// section rows up with which, instead of leaning on traversal starts.
3134fn sample_wire_from(
3135    model: &Model,
3136    wire: &Shape,
3137    count: usize,
3138    start_hint: Option<Point>,
3139    tol: Tolerances,
3140) -> OgeomResult<Vec<Point>> {
3141    let arc = section_loop(model, wire, start_hint, tol)?;
3142    Ok((0..count)
3143        .map(|s| {
3144            #[allow(clippy::cast_precision_loss, reason = "a small count")]
3145            let f = s as f64 / count as f64;
3146            arc.at(f)
3147        })
3148        .collect())
3149}
3150
3151/// A closed wire read by arc length from its traversal start, or from the
3152/// dense sample nearest `start_hint` where one is given.
3153fn section_loop(
3154    model: &Model,
3155    wire: &Shape,
3156    start_hint: Option<Point>,
3157    tol: Tolerances,
3158) -> OgeomResult<ArcLoop> {
3159    let mut dense = dense_wire(model, wire, tol)?;
3160    if let Some(hint) = start_hint {
3161        let mut best = 0usize;
3162        let mut held = f64::INFINITY;
3163        for (i, p) in dense.iter().enumerate() {
3164            let d = p.distance(hint);
3165            if d < held {
3166                held = d;
3167                best = i;
3168            }
3169        }
3170        dense.rotate_left(best);
3171    }
3172    Ok(ArcLoop::new(dense))
3173}
3174
3175/// A closed wire as a dense polyline, by traversal, without the closing
3176/// point.
3177fn dense_wire(model: &Model, wire: &Shape, tol: Tolerances) -> OgeomResult<Vec<Point>> {
3178    let mut dense: Vec<Point> = Vec::new();
3179    for edge in explore(model, wire, Filter::OfType(ShapeType::Edge))? {
3180        let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
3181            ogeom_bail!(Construction, "a section edge holds no data");
3182        };
3183        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
3184            ogeom_bail!(Construction, "a section edge has no curve");
3185        };
3186        let Some(geometry) = model.geometry().curve(*curve) else {
3187            ogeom_bail!(Dangling, "curve is not in this model");
3188        };
3189        let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
3190        // Where the edge stands: a placed wire's edges carry its placement.
3191        let placement = edge.transform(model.datums())?;
3192        let at = |f: f64| -> OgeomResult<Point> {
3193            let t = if reversed {
3194                range.1 - (range.1 - range.0) * f
3195            } else {
3196                range.0 + (range.1 - range.0) * f
3197            };
3198            Ok(placement.apply(geometry.point_at(t, tol)?))
3199        };
3200        // Samples are read along the chords between these points, so the
3201        // chords keep within a micron of the curve: the sag measured over a
3202        // first pass, and the count raised by its square root (the sag
3203        // falls with the square of the count).
3204        const FIRST: u32 = 64;
3205        const MOST: u32 = 8192;
3206        let mut sag = 0.0_f64;
3207        for i in 0..FIRST {
3208            let (f0, f1) = (
3209                f64::from(i) / f64::from(FIRST),
3210                f64::from(i + 1) / f64::from(FIRST),
3211            );
3212            let (a, b) = (at(f0)?, at(f1)?);
3213            let mid = at(f64::midpoint(f0, f1))?;
3214            sag = sag.max(mid.distance(a.midpoint(b)));
3215        }
3216        let allowed = tol.confusion() * 10.0;
3217        #[allow(
3218            clippy::cast_possible_truncation,
3219            clippy::cast_sign_loss,
3220            reason = "a sample count, bounded"
3221        )]
3222        let count = if sag <= allowed {
3223            FIRST
3224        } else {
3225            ((f64::from(FIRST) * (sag / allowed).sqrt()).ceil() as u32).clamp(FIRST, MOST)
3226        };
3227        for i in 0..count {
3228            dense.push(at(f64::from(i) / f64::from(count))?);
3229        }
3230    }
3231    if dense.is_empty() {
3232        ogeom_bail!(Construction, "a section has no edges");
3233    }
3234    Ok(dense)
3235}
3236
3237/// A section's dense polyline in its own frame, started and run where its
3238/// `count` samples best match the section before (`previous`, in its
3239/// frame): a section's start and sense are accidents of how it was drawn,
3240/// and matched as given they twist the blend. Every start of the dense
3241/// polyline is a candidate both ways; the given start and sense win a tie.
3242///
3243/// A start's samples are read off one arc-length parametrisation per sense,
3244/// shifted by the start's length. The cost is a smooth function of that
3245/// shift, so it is read at about `8 * count` starts first and then at every
3246/// start next to a coarse local minimum, in the order an exhaustive scan
3247/// would visit them.
3248fn matched_loop(dense: &[Point], count: usize, previous: &[Point]) -> ArcLoop {
3249    let n = dense.len();
3250    let ways = [
3251        ArcLoop::new(dense.to_vec()),
3252        ArcLoop::new(dense.iter().rev().copied().collect()),
3253    ];
3254    #[allow(clippy::cast_precision_loss, reason = "a sample count")]
3255    fn samples(arc: &ArcLoop, start: usize, count: usize) -> impl Iterator<Item = Point> + '_ {
3256        let offset = arc.lengths[start] / arc.total;
3257        (0..count).map(move |k| {
3258            let f = offset + k as f64 / count as f64;
3259            arc.at(if f >= 1.0 { f - 1.0 } else { f })
3260        })
3261    }
3262    let cost = |arc: &ArcLoop, start: usize| -> f64 {
3263        samples(arc, start, count)
3264            .zip(previous)
3265            .map(|(p, q)| (p - *q).dot(p - *q))
3266            .sum()
3267    };
3268    let scale: f64 = previous
3269        .iter()
3270        .copied()
3271        .chain(samples(&ways[0], 0, count))
3272        .map(|p| (p - Point::ORIGIN).dot(p - Point::ORIGIN))
3273        .sum();
3274    let slack = scale * 1e-12;
3275    let coarse = 8 * count.max(1);
3276    let mut held = cost(&ways[0], 0);
3277    let mut best = (0, 0);
3278    for (w, arc) in ways.iter().enumerate() {
3279        let mut tried = vec![n <= 2 * coarse; n];
3280        if n > 2 * coarse {
3281            let picks: Vec<usize> = (0..coarse).map(|j| j * n / coarse).collect();
3282            let costs: Vec<f64> = picks.iter().map(|&i| cost(arc, i)).collect();
3283            for j in 0..coarse {
3284                let (before, after) = ((j + coarse - 1) % coarse, (j + 1) % coarse);
3285                if costs[j] <= costs[before] && costs[j] <= costs[after] {
3286                    let (from, to) = (picks[before], picks[after]);
3287                    let mut i = from;
3288                    loop {
3289                        tried[i] = true;
3290                        if i == to {
3291                            break;
3292                        }
3293                        i = (i + 1) % n;
3294                    }
3295                }
3296            }
3297        }
3298        for start in (0..n).filter(|&i| tried[i]) {
3299            let c = cost(arc, start);
3300            if c < held - slack {
3301                held = c;
3302                best = (w, start);
3303            }
3304        }
3305    }
3306    let [forward, backward] = ways;
3307    let (way, start) = best;
3308    let arc = if way == 0 { forward } else { backward };
3309    if start == 0 {
3310        return arc;
3311    }
3312    let mut turned = arc.dense;
3313    turned.rotate_left(start);
3314    ArcLoop::new(turned)
3315}
3316
3317/// Sweep a circular profile along a free-form spine, skinned.
3318///
3319/// Frames along the spine are rotation-minimizing (the double-reflection
3320/// construction), so the tube neither twists nor kinks where the spine
3321/// bends; the skin holds the tube to `tolerance`, measured between its
3322/// stations and round its circles as well as at the samples and refined
3323/// where it misses, and the caps sit perpendicular to the spine's ends.
3324///
3325/// # Errors
3326///
3327/// As [`make_pipe`], plus [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if
3328/// the skin cannot reach the
3329/// tolerance.
3330pub fn make_pipe_skinned(
3331    model: &mut Model,
3332    spine: &Shape,
3333    radius: f64,
3334    tolerance: f64,
3335    tol: Tolerances,
3336) -> OgeomResult<Built> {
3337    if !radius.is_finite() || radius <= tol.confusion() {
3338        ogeom_bail!(Construction, "a pipe of radius {radius} holds nothing");
3339    }
3340    let (curve, range) = {
3341        let Some(data) = model.node(spine).and_then(|n| n.data().as_edge()) else {
3342            ogeom_bail!(Construction, "a pipe runs along an edge");
3343        };
3344        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
3345            ogeom_bail!(Construction, "the spine has no curve");
3346        };
3347        let Some(geometry) = model.geometry().curve(*curve) else {
3348            ogeom_bail!(Dangling, "curve is not in this model");
3349        };
3350        (geometry.clone(), *range)
3351    };
3352    // The skin starts from 33 stations; the frame is carried along 64
3353    // times as many, so the frame between stations turns smoothly enough
3354    // for the fit refining between them.
3355    const STATIONS: usize = 33;
3356    const FRAMES: usize = 64 * (STATIONS - 1) + 1;
3357    const AROUND: usize = 40;
3358    let mut stations: Vec<SpineStation> = Vec::with_capacity(FRAMES);
3359    for i in 0..FRAMES {
3360        #[allow(clippy::cast_precision_loss)]
3361        let t = range.0 + (range.1 - range.0) * (i as f64) / ((FRAMES - 1) as f64);
3362        let p = curve.point_at(t, tol)?;
3363        let d = curve.d1_at(t, tol)?;
3364        let m = d.magnitude();
3365        if m <= tol.confusion() {
3366            ogeom_bail!(Construction, "the spine is degenerate at {t}");
3367        }
3368        stations.push(SpineStation {
3369            at: p,
3370            tangent: d / m,
3371            edge: 0,
3372            t,
3373        });
3374    }
3375    let normals = rmf_normals(&stations);
3376    // The tube anywhere: the circle about the spine point at `t`, in the
3377    // frame carried one rotation-minimizing step from the frame station
3378    // behind, `u` the fraction of a turn. At a station that is the
3379    // station's own frame, so the tube runs continuously through every
3380    // station.
3381    let tube = |u: f64, t: f64| -> OgeomResult<Point> {
3382        let k = stations.partition_point(|s| s.t <= t).clamp(1, FRAMES) - 1;
3383        let p = curve.point_at(t, tol)?;
3384        let d = curve.d1_at(t, tol)?;
3385        let m = d.magnitude();
3386        if m <= tol.confusion() {
3387            ogeom_bail!(Construction, "the spine is degenerate at {t}");
3388        }
3389        let tangent = d / m;
3390        let x = rmf_step(stations[k].at, stations[k].tangent, normals[k], p, tangent);
3391        let y = tangent.cross(x);
3392        let ang = core::f64::consts::TAU * u;
3393        Ok(p + (x * ang.cos() + y * ang.sin()) * radius)
3394    };
3395    let fraction = |i: usize, n: usize| -> f64 {
3396        #[allow(clippy::cast_precision_loss, reason = "a small count")]
3397        let f = i as f64 / n as f64;
3398        f
3399    };
3400    let us: Vec<f64> = (0..=AROUND).map(|a| fraction(a, AROUND)).collect();
3401    let vs: Vec<f64> = stations.iter().step_by(64).map(|s| s.t).collect();
3402    let rows = vs
3403        .iter()
3404        .map(|&t| us[..AROUND].iter().map(|&u| tube(u, t)).collect())
3405        .collect::<OgeomResult<Vec<Vec<Point>>>>()?;
3406    let skin = Skin {
3407        rows,
3408        round: true,
3409        by_spacing: false,
3410        traced: Some(Traced {
3411            // The end of the way round is its start, to the bit, so the
3412            // seam closes exactly.
3413            point: Box::new(move |u, t| tube(if u >= 1.0 { 0.0 } else { u }, t)),
3414            sampling: ogeom_geom::fit::Sampling {
3415                us,
3416                vs,
3417                between: (true, true),
3418                closed_v: false,
3419                most: 512,
3420            },
3421        }),
3422    };
3423    let mut built = skinned_solid(
3424        model,
3425        &skin,
3426        (
3427            EndCap::Plane(-stations[0].tangent),
3428            EndCap::Plane(stations[FRAMES - 1].tangent),
3429        ),
3430        tolerance,
3431        tol,
3432    )?;
3433    built.history.generate(spine, built.shape.clone());
3434    Ok(built)
3435}
3436
3437/// One sampled spine station: where the spine is and which way it runs.
3438#[derive(Clone, Copy)]
3439pub(crate) struct SpineStation {
3440    pub(crate) at: Point,
3441    /// The unit tangent, in the direction of travel.
3442    pub(crate) tangent: Vector,
3443    /// The spine edge this station stands on, by position in the spine.
3444    pub(crate) edge: usize,
3445    /// The station's parameter on that edge's curve.
3446    pub(crate) t: f64,
3447}
3448
3449/// One profile wire's closed shell round the spine: smooth wires skin as a
3450/// single closed face, faceted ones as one ring strip per facet.
3451#[allow(clippy::too_many_arguments, reason = "one frame, spelled out")]
3452fn closed_loop_shell(
3453    model: &mut Model,
3454    profile_loop: &Shape,
3455    edges: &[Shape],
3456    smooth: bool,
3457    walk: &SpineWalk<'_>,
3458    frame0: (Point, Vector),
3459    tolerance: f64,
3460    tol: Tolerances,
3461) -> OgeomResult<Shape> {
3462    const AROUND: usize = 40;
3463    let (origin, x0) = frame0;
3464    let stations = walk.stations;
3465    let t0 = stations[0].tangent;
3466    let y0 = t0.cross(x0);
3467    // The way round runs from the first station to its return home, the
3468    // last of the walk's stations.
3469    let home = stations.len() - 1;
3470    // A point of the profile, in the start frame, at `s` along the way
3471    // round: a station's own frame at a station, the home station the
3472    // first again, the frame carried between them anywhere else.
3473    let carried = |s: f64, (a, b): (f64, f64)| -> OgeomResult<Point> {
3474        if s.fract() == 0.0 {
3475            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3476            let i = (s as usize) % home;
3477            let (station, x) = (&stations[i], walk.normals[i]);
3478            return Ok(station.at + x * a + station.tangent.cross(x) * b);
3479        }
3480        walk.generator(s, (0, home), (a, b), tol)
3481    };
3482    if !smooth {
3483        // A faceted profile: one ring strip per profile edge; a fit cannot
3484        // speak a corner, so each facet gets its own v-closed skin and the
3485        // strips sew along the corner loops they share within tolerance.
3486        const ALONG_EDGE: usize = 8;
3487        // Outward for a ring strip means away from the spine's own line,
3488        // not from the loop's centroid: a ring's inner side *faces* the
3489        // centroid. The hint is the station the strip's midpoint rides.
3490        let mid_station = stations[stations.len() / 2].at;
3491        let mut faces = Vec::with_capacity(edges.len());
3492        // Each corner loop is one rail edge shared by the two strips that
3493        // meet along it, the wrap included.
3494        let mut rails: Vec<Option<Shape>> = vec![None; edges.len()];
3495        for (index, edge) in edges.iter().enumerate() {
3496            let (curve, range) = spine_curve_of(model, edge)?;
3497            let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
3498            // rows[j = station][i = across the facet]: each column one
3499            // point of the edge, at a fraction of its parameter range,
3500            // carried through the stations.
3501            let skin = Skin::swept(
3502                |f, s| {
3503                    let t = if reversed {
3504                        range.1 - (range.1 - range.0) * f
3505                    } else {
3506                        range.0 + (range.1 - range.0) * f
3507                    };
3508                    let p = curve.point_at(t, tol)?;
3509                    carried(s, ((p - origin).dot(x0), (p - origin).dot(y0)))
3510                },
3511                fractions(ALONG_EDGE),
3512                (0, home),
3513                false,
3514            )?;
3515            let next = (index + 1) % edges.len();
3516            let shared = [rails[index].clone(), rails[next].clone()];
3517            let (face, rail0, rail1) = skinned_ring_strip(
3518                model,
3519                &skin,
3520                mid_station,
3521                [shared[0].as_ref(), shared[1].as_ref()],
3522                tolerance,
3523                tol,
3524            )?;
3525            rails[index] = Some(rail0);
3526            rails[next] = Some(rail1);
3527            faces.push(face);
3528        }
3529        let sewn = sew(model, &faces, tol)?;
3530        if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
3531            if std::env::var_os("OGEOM_DEBUG_RING").is_some() {
3532                use ogeom_geom::Curve3d as _;
3533                eprintln!(
3534                    "RING: {} shells from {} strips",
3535                    sewn.shells.len(),
3536                    faces.len()
3537                );
3538                for shell in &sewn.shells {
3539                    for edge in ogeom_topo::explore_unique(model, shell, ShapeType::Edge)? {
3540                        let mut uses = 0;
3541                        for f in explore(model, shell, Filter::OfType(ShapeType::Face))? {
3542                            for w in model.children_of(&f)? {
3543                                for e in model.children_of(&w)? {
3544                                    if e.node() == edge.node() {
3545                                        uses += 1;
3546                                    }
3547                                }
3548                            }
3549                        }
3550                        if uses == 1
3551                            && let Some(d) = model.node(&edge).and_then(|n| n.data().as_edge())
3552                            && let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) =
3553                                d.curve3d()
3554                            && let Some(g) = model.geometry().curve(*curve)
3555                        {
3556                            eprintln!(
3557                                "RING open edge tol {:.2e}: {:?} -> {:?}",
3558                                d.tolerance.get(),
3559                                g.point_at(range.0, tol)?,
3560                                g.point_at(range.1, tol)?
3561                            );
3562                        }
3563                    }
3564                }
3565            }
3566            ogeom_bail!(Construction, "the faceted ring did not close");
3567        }
3568        return Ok(sewn.shells[0].clone());
3569    }
3570    // Each column one point of the profile, read by arc length round it,
3571    // carried through the stations.
3572    let around = section_loop(model, profile_loop, None, tol)?;
3573    let skin = Skin::swept(
3574        |f, s| {
3575            let p = around.at(f);
3576            carried(s, ((p - origin).dot(x0), (p - origin).dot(y0)))
3577        },
3578        fractions(AROUND),
3579        (0, home),
3580        true,
3581    )?;
3582    closed_skinned_shell(model, &skin, tolerance, tol)
3583}
3584
3585/// Rotation-minimizing normals along the stations, by double reflection:
3586/// reflect in each chord's plane, then in the plane bisecting the tangents.
3587/// Self-contained (it needs only the station list) and shared by every
3588/// sweep that must not twist where its spine bends.
3589fn rmf_normals(stations: &[SpineStation]) -> Vec<Vector> {
3590    let mut normals: Vec<Vector> = Vec::with_capacity(stations.len());
3591    let t0 = stations[0].tangent;
3592    let seed = if t0.cross(ogeom_math::Vector::Z).magnitude() > 0.5 {
3593        ogeom_math::Vector::Z
3594    } else {
3595        ogeom_math::Vector::X
3596    };
3597    let n0 = {
3598        let v = seed - t0 * seed.dot(t0);
3599        v / v.magnitude()
3600    };
3601    normals.push(n0);
3602    for i in 1..stations.len() {
3603        let (p0, t0) = (stations[i - 1].at, stations[i - 1].tangent);
3604        let (p1, t1) = (stations[i].at, stations[i].tangent);
3605        let n = normals[i - 1];
3606        let v1 = p1 - p0;
3607        let c1 = v1.dot(v1);
3608        if c1 <= 1e-20 {
3609            // A corner's twin station: no travel to reflect through. The
3610            // normal is reflected across the corner's mitre plane instead;
3611            // for a vector square to the incoming tangent that is exactly
3612            // the parallel transport about the corner's own axis, and the
3613            // mirror symmetry is what lands both legs' sheared sections on
3614            // one ring. A planar corner's normal lies in the mitre plane
3615            // already and carries straight across; a skew corner's does
3616            // not, and carried unchanged it leaves the far leg's section
3617            // off the mitre.
3618            let bisector = t0 + t1;
3619            let m = bisector.magnitude();
3620            if m <= 1e-12 {
3621                normals.push(n);
3622                continue;
3623            }
3624            let b = bisector / m;
3625            normals.push(n - b * (2.0 * n.dot(b)));
3626            continue;
3627        }
3628        normals.push(rmf_step(p0, t0, n, p1, t1));
3629    }
3630    normals
3631}
3632
3633/// One rotation-minimizing step: the normal `n0` at `(p0, t0)` carried to
3634/// `(p1, t1)` by double reflection. No travel means no change.
3635fn rmf_step(p0: Point, t0: Vector, n0: Vector, p1: Point, t1: Vector) -> Vector {
3636    let v1 = p1 - p0;
3637    let c1 = v1.dot(v1);
3638    if c1 <= 1e-20 {
3639        return n0;
3640    }
3641    let nl = n0 - v1 * (2.0 / c1 * v1.dot(n0));
3642    let tl = t0 - v1 * (2.0 / c1 * v1.dot(t0));
3643    let v2 = t1 - tl;
3644    let c2 = v2.dot(v2);
3645    let next = if c2 > 1e-20 {
3646        nl - v2 * (2.0 / c2 * v2.dot(nl))
3647    } else {
3648        nl
3649    };
3650    next / next.magnitude()
3651}
3652
3653/// A leg's generators, evaluated anywhere: the spine's own curve between
3654/// stations with the rotation-minimizing normal carried one step from the
3655/// station behind (turned to meet the next station's normal where the law
3656/// differs from that step), and a straight extension past either end in
3657/// the end frame: the surface a mitre trims against. Parameters are
3658/// station indices; a unit beyond an end is one station spacing.
3659struct SpineWalk<'a> {
3660    curves: Vec<WalkCurve>,
3661    stations: &'a [SpineStation],
3662    normals: &'a [Vector],
3663}
3664
3665/// A spine edge's curve, range and whether it is travelled reversed.
3666type WalkCurve = (ogeom_geom::Curve, (f64, f64), bool);
3667
3668/// Every spine edge as a [`WalkCurve`], in the spine's order.
3669fn walk_curves(model: &Model, spine: &Shape) -> OgeomResult<Vec<WalkCurve>> {
3670    let edges: Vec<Shape> = match model.kind_of(spine)? {
3671        ShapeType::Edge => vec![spine.clone()],
3672        _ => model.ordered_children_of(spine)?,
3673    };
3674    let mut out = Vec::with_capacity(edges.len());
3675    for edge in &edges {
3676        let (curve, range) = spine_curve_of(model, edge)?;
3677        out.push((
3678            curve,
3679            range,
3680            edge.orientation() == ogeom_topo::Orientation::Reversed,
3681        ));
3682    }
3683    Ok(out)
3684}
3685
3686/// Where two legs' generators for one profile point meet at a corner: the
3687/// point, each leg's parameter, and how far the two generators actually
3688/// miss each other (zero when the corner turns in the plane).
3689struct CornerJoin {
3690    at: Point,
3691    s1: f64,
3692    s2: f64,
3693    gap: f64,
3694}
3695
3696impl SpineWalk<'_> {
3697    /// The spine point, unit tangent and frame normal at `s` within the run
3698    /// `(rs, re)`.
3699    fn frame_at(
3700        &self,
3701        s: f64,
3702        (rs, re): (usize, usize),
3703        tol: Tolerances,
3704    ) -> OgeomResult<(Point, Vector, Vector)> {
3705        let st = self.stations;
3706        let at = |i: usize| (st[i].at, st[i].tangent, self.normals[i]);
3707        #[allow(clippy::cast_precision_loss)]
3708        let (rsf, ref_) = (rs as f64, re as f64);
3709        if s <= rsf {
3710            let (p, t, n) = at(rs);
3711            let h = st[rs].at.distance(st[(rs + 1).min(re)].at);
3712            return Ok((p + t * ((s - rsf) * h), t, n));
3713        }
3714        if s >= ref_ {
3715            let (p, t, n) = at(re);
3716            let h = st[re].at.distance(st[re.saturating_sub(1).max(rs)].at);
3717            return Ok((p + t * ((s - ref_) * h), t, n));
3718        }
3719        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3720        let j = s.floor() as usize;
3721        #[allow(clippy::cast_precision_loss)]
3722        let f = s - j as f64;
3723        if f <= 0.0 {
3724            return Ok(at(j));
3725        }
3726        let (curve, range, reversed) = &self.curves[st[j + 1].edge];
3727        let t_from = if st[j].edge == st[j + 1].edge {
3728            st[j].t
3729        } else if *reversed {
3730            range.1
3731        } else {
3732            range.0
3733        };
3734        let t = t_from + (st[j + 1].t - t_from) * f;
3735        let p = curve.point_at(t, tol)?;
3736        let d = curve.d1_at(t, tol)?;
3737        let m = d.magnitude();
3738        if m <= tol.confusion() {
3739            ogeom_bail!(Construction, "the spine is degenerate at {t}");
3740        }
3741        let tangent = if *reversed { -(d / m) } else { d / m };
3742        let n = rmf_step(st[j].at, st[j].tangent, self.normals[j], p, tangent);
3743        // A law whose normals are not carried by this step (Frenet, or a
3744        // ring's twist spread round it) turns from the carried normal by
3745        // the angle it reaches at the next station, a share of it in
3746        // proportion to the way there, so the frame meets both stations.
3747        let carried = rmf_step(
3748            st[j].at,
3749            st[j].tangent,
3750            self.normals[j],
3751            st[j + 1].at,
3752            st[j + 1].tangent,
3753        );
3754        let ahead = self.normals[j + 1];
3755        let turn = carried
3756            .cross(ahead)
3757            .dot(st[j + 1].tangent)
3758            .atan2(carried.dot(ahead));
3759        if turn == 0.0 {
3760            return Ok((p, tangent, n));
3761        }
3762        let phi = turn * f;
3763        let n = n * phi.cos() + tangent.cross(n) * phi.sin();
3764        let n = n - tangent * n.dot(tangent);
3765        Ok((p, tangent, n / n.magnitude()))
3766    }
3767
3768    /// The generator of profile point `(a, b)` at `s` within the run.
3769    fn generator(
3770        &self,
3771        s: f64,
3772        run: (usize, usize),
3773        (a, b): (f64, f64),
3774        tol: Tolerances,
3775    ) -> OgeomResult<Point> {
3776        let (p, t, x) = self.frame_at(s, run, tol)?;
3777        let y = t.cross(x);
3778        Ok(p + x * a + y * b)
3779    }
3780
3781    /// Where the generators of one profile point on the leg `before` and
3782    /// the leg `after` a corner meet: Gauss-Newton on both parameters from
3783    /// the corner itself, minimising the distance between the two.
3784    fn join(
3785        &self,
3786        before: (usize, usize),
3787        after: (usize, usize),
3788        ab: (f64, f64),
3789        tol: Tolerances,
3790    ) -> OgeomResult<CornerJoin> {
3791        const STEP: f64 = 1e-4;
3792        #[allow(clippy::cast_precision_loss)]
3793        let (mut s1, mut s2) = (before.1 as f64, after.0 as f64);
3794        for _ in 0..60 {
3795            let g1 = self.generator(s1, before, ab, tol)?;
3796            let g2 = self.generator(s2, after, ab, tol)?;
3797            let f = g1 - g2;
3798            let d1 = (self.generator(s1 + STEP, before, ab, tol)?
3799                - self.generator(s1 - STEP, before, ab, tol)?)
3800                / (2.0 * STEP);
3801            let d2 = (self.generator(s2 + STEP, after, ab, tol)?
3802                - self.generator(s2 - STEP, after, ab, tol)?)
3803                / (2.0 * STEP);
3804            let (a11, a12, a22) = (d1.dot(d1), -d1.dot(d2), d2.dot(d2));
3805            let (b1, b2) = (-f.dot(d1), f.dot(d2));
3806            let det = a11 * a22 - a12 * a12;
3807            if det.abs() <= 1e-30 {
3808                break;
3809            }
3810            let e1 = (b1 * a22 - a12 * b2) / det;
3811            let e2 = (a11 * b2 - a12 * b1) / det;
3812            s1 += e1;
3813            s2 += e2;
3814            if e1.abs().max(e2.abs()) <= 1e-12 {
3815                break;
3816            }
3817        }
3818        let g1 = self.generator(s1, before, ab, tol)?;
3819        let g2 = self.generator(s2, after, ab, tol)?;
3820        Ok(CornerJoin {
3821            at: g1.midpoint(g2),
3822            s1,
3823            s2,
3824            gap: g1.distance(g2),
3825        })
3826    }
3827}
3828
3829/// Sweep a planar profile along a helix about `axis`: a screw motion,
3830/// every point of the profile running its own helix. The thread and spring
3831/// operation, with the profile in a plane through the axis; a profile in
3832/// any other plane clear of the axis (square to it, a ramp or a stair's
3833/// tread) climbs the same way.
3834///
3835/// `pitch` is the advance per turn along `axis`, `turns` how far the
3836/// profile turns, `left_handed` turns it the other way about the axis for
3837/// the same advance, and `taper_per_turn` moves every point away from the
3838/// axis by that much per turn (a conical helix; zero for a cylindrical
3839/// one). A pitch of zero with a taper is a flat spiral, every point
3840/// turning in its plane square to the axis while moving out. The walls are
3841/// fitted through each profile edge's exact screw images; the caps are the
3842/// profile where it starts and where it ends.
3843///
3844/// # Errors
3845///
3846/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
3847/// the profile is not a planar face, meets the axis, would meet itself one
3848/// turn on (its extent along the axis is not less than the pitch, or for a
3849/// flat spiral its extent away from the axis not less than the taper), is
3850/// carried partly forward through its own plane and partly back, or tapers
3851/// onto the axis; if a taper is asked of a profile whose plane neither
3852/// holds the axis nor stands square to it; if `turns` is not positive,
3853/// `pitch` is negative, or both `pitch` and the taper are zero.
3854/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if a wall
3855/// cannot be fitted.
3856#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
3857pub fn make_helical_sweep(
3858    model: &mut Model,
3859    profile: &Shape,
3860    axis: ogeom_math::Axis,
3861    pitch: f64,
3862    turns: f64,
3863    left_handed: bool,
3864    taper_per_turn: f64,
3865    tol: Tolerances,
3866) -> OgeomResult<Built> {
3867    if !(pitch.is_finite() && pitch >= 0.0) || !(turns.is_finite() && turns > 0.0) {
3868        ogeom_bail!(
3869            Construction,
3870            "a helical sweep needs a pitch of zero or more and a positive turn count; \
3871             got {pitch} and {turns}"
3872        );
3873    }
3874    if !taper_per_turn.is_finite() {
3875        ogeom_bail!(Construction, "a taper of {taper_per_turn} is not a length");
3876    }
3877    let flat = pitch <= tol.confusion();
3878    if flat && taper_per_turn.abs() <= tol.confusion() {
3879        ogeom_bail!(
3880            Construction,
3881            "a helical sweep with no pitch and no taper turns the profile onto itself"
3882        );
3883    }
3884    if model.kind_of(profile)? != ShapeType::Face {
3885        ogeom_bail!(Construction, "a helical sweep sweeps a planar face");
3886    }
3887    let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
3888        ogeom_bail!(Construction, "a helical sweep sweeps a planar face");
3889    };
3890    let z = axis.direction.vector();
3891    let n = plane.normal().vector();
3892    let holds_axis = n.dot(z).abs() <= tol.angular()
3893        && plane.distance_to(axis.location) <= tol.confusion() * 100.0;
3894    let level = (n.dot(z).abs() - 1.0).abs() <= tol.angular();
3895    // A taper moves every point away from the axis by the same amount, which
3896    // keeps a profile in a plane only where that plane holds the axis or
3897    // stands square to it.
3898    if taper_per_turn.abs() > tol.confusion() && !holds_axis && !level {
3899        ogeom_bail!(
3900            Construction,
3901            "a tapered helical sweep carries a profile in a plane through the \
3902             axis or square to it; an oblique profile would leave its plane"
3903        );
3904    }
3905    // A profile the axis runs through has points with no helix to follow:
3906    // where the axis pierces the plane is asked of the profile's rings,
3907    // read in the plane.
3908    if !holds_axis && n.dot(z).abs() > tol.angular() {
3909        let reach = (plane.origin() - axis.location).dot(n) / z.dot(n);
3910        let pierce = axis.location + z * reach;
3911        let frame = plane.frame();
3912        let flat = |p: Point| {
3913            let l = frame.to_local(p);
3914            Point2::new(l.x, l.y)
3915        };
3916        let at = flat(pierce);
3917        let mut inside = false;
3918        let mut touches = false;
3919        for wire in explore(model, profile, Filter::OfType(ShapeType::Wire))? {
3920            let ring: Vec<Point2> = sample_wire(model, &wire, 256, tol)?
3921                .into_iter()
3922                .map(flat)
3923                .collect();
3924            for (k, a) in ring.iter().enumerate() {
3925                let b = ring[(k + 1) % ring.len()];
3926                let (ab, ap) = (b - *a, at - *a);
3927                let t = (ap.dot(ab) / ab.dot(ab).max(f64::MIN_POSITIVE)).clamp(0.0, 1.0);
3928                touches |= (*a + ab * t).distance(at) <= tol.confusion() * 100.0;
3929                if (a.y > at.y) != (b.y > at.y) && at.x < a.x + (at.y - a.y) / (b.y - a.y) * ab.x {
3930                    inside = !inside;
3931                }
3932            }
3933        }
3934        if inside || touches {
3935            ogeom_bail!(
3936                Construction,
3937                "the profile meets the axis, where a helical sweep has no helix \
3938                 to follow"
3939            );
3940        }
3941    }
3942    let total = core::f64::consts::TAU * turns;
3943    let sense = if left_handed { -1.0 } else { 1.0 };
3944    // How a point sets off at the start of the turn: up the axis by the
3945    // pitch, round it, and out by the taper, each per radian.
3946    let travel = |p: Point| -> Vector {
3947        let foot = axis.project(p);
3948        let out = p - foot;
3949        let rho = out.magnitude().max(f64::MIN_POSITIVE);
3950        let radial = out / rho;
3951        (z * pitch + radial * taper_per_turn) / core::f64::consts::TAU
3952            + z.cross(radial) * (rho * sense)
3953    };
3954    // The screw image of a point after turning through `theta`.
3955    let screw = |p: Point, theta: f64| -> OgeomResult<Point> {
3956        let foot = axis.project(p);
3957        let out = p - foot;
3958        let rho = out.magnitude();
3959        let grown = rho + taper_per_turn * theta / core::f64::consts::TAU;
3960        if rho <= tol.confusion() || grown <= tol.confusion() {
3961            ogeom_bail!(
3962                Construction,
3963                "the profile reaches the axis, where a helical sweep has no \
3964                 helix to follow"
3965            );
3966        }
3967        let radial = out / rho;
3968        let across = z.cross(radial);
3969        let (sin, cos) = (sense * theta).sin_cos();
3970        let turned = radial * cos + across * sin;
3971        Ok(foot + z * (pitch * theta / core::f64::consts::TAU) + turned * grown)
3972    };
3973
3974    // The profile's loops, their edges in ring order, each sampled.
3975    let loops = explore(model, profile, Filter::OfType(ShapeType::Wire))?;
3976    if loops.is_empty() {
3977        ogeom_bail!(Construction, "the profile has no loop to sweep");
3978    }
3979    // Every point leaves the profile's plane the same way, or the profile
3980    // sweeps back through where it has been; a plane the motion runs
3981    // along everywhere (a level profile with no pitch) sweeps nothing.
3982    {
3983        let (mut ahead, mut behind) = (0.0_f64, 0.0_f64);
3984        for wire in &loops {
3985            for p in sample_wire(model, wire, 64, tol)? {
3986                let t = travel(p);
3987                let across = t.dot(n) / t.magnitude().max(f64::MIN_POSITIVE);
3988                ahead = ahead.max(across);
3989                behind = behind.max(-across);
3990            }
3991        }
3992        if ahead > tol.angular() && behind > tol.angular() {
3993            ogeom_bail!(
3994                Construction,
3995                "the screw carries part of the profile forward through its plane \
3996                 and part back; the sweep runs into itself"
3997            );
3998        }
3999        if ahead <= tol.angular() && behind <= tol.angular() {
4000            ogeom_bail!(
4001                Construction,
4002                "the screw carries the profile along its own plane; it sweeps \
4003                 no volume"
4004            );
4005        }
4006    }
4007    // One turn on, the profile must clear itself: along the axis by the
4008    // pitch, or for a flat spiral away from it by the taper.
4009    if turns > 1.0 {
4010        let mut low = f64::INFINITY;
4011        let mut high = f64::NEG_INFINITY;
4012        for wire in &loops {
4013            for p in sample_wire(model, wire, 64, tol)? {
4014                let h = if flat {
4015                    p.distance(axis.project(p))
4016                } else {
4017                    (p - axis.location).dot(z)
4018                };
4019                low = low.min(h);
4020                high = high.max(h);
4021            }
4022        }
4023        let (clearance, across) = if flat {
4024            (taper_per_turn.abs(), "away from the axis")
4025        } else {
4026            (pitch, "along the axis")
4027        };
4028        if high - low >= clearance - tol.confusion() {
4029            ogeom_bail!(
4030                Construction,
4031                "the profile spans {} {across}, not less than the turn's advance \
4032                 {clearance}; a turn on it meets itself",
4033                high - low
4034            );
4035        }
4036    }
4037    let tolerance = tol.confusion() * 100.0;
4038    // The walls in quarter turns, each a strip of its own sharing its
4039    // borders with the next: one fit down many turns of a helix cannot
4040    // reach the tolerance, a quarter turn's can. The borders stand an
4041    // eighth of a turn off the profile's own plane and the planes square to
4042    // it: a strip's border is a copy of the profile, and a plane through the
4043    // axis at those angles (a block's face set on the axis) would otherwise
4044    // meet a strip only along its border, where no section starts.
4045    let quarter = core::f64::consts::FRAC_PI_2;
4046    let mut borders = vec![0.0];
4047    let mut next_border = quarter / 2.0;
4048    while next_border < total - quarter * 1e-3 {
4049        borders.push(next_border);
4050        next_border += quarter;
4051    }
4052    borders.push(total);
4053    let segments = borders.len() - 1;
4054    // The fit keeps fewer controls than samples, so its reach at the
4055    // samples is set by how many there are.
4056    const PER_SEGMENT: usize = 48;
4057    // The turn at `s` stations into a segment, `s` a whole station or any
4058    // fraction between.
4059    #[allow(clippy::cast_precision_loss)]
4060    let theta_at = |seg: usize, s: f64| {
4061        borders[seg] + (borders[seg + 1] - borders[seg]) * s / (PER_SEGMENT as f64)
4062    };
4063
4064    let mut faces: Vec<Shape> = Vec::new();
4065    let mut cap_loops: [Vec<Vec<Shape>>; 2] = [Vec::new(), Vec::new()];
4066    for (li, wire) in loops.iter().enumerate() {
4067        let hole = li != 0;
4068        let edges = model.ordered_children_of(wire)?;
4069        let centre = {
4070            let samples = sample_wire(model, wire, 32, tol)?;
4071            #[allow(clippy::cast_precision_loss)]
4072            let n = samples.len() as f64;
4073            let sum = samples
4074                .iter()
4075                .fold(Vector::new(0.0, 0.0, 0.0), |acc, p| acc + p.to_vector());
4076            Point::from_vector(sum / n)
4077        };
4078        // The ring's pieces: every edge in the ring's sense, a closed one
4079        // (a circle, the whole ring) cut in quarters so each strip's fit
4080        // spans a quarter turn round it at most.
4081        let mut pieces: Vec<(ogeom_geom::Curve, f64, f64)> = Vec::new();
4082        for edge in &edges {
4083            let (curve, range) = spine_curve_of(model, edge)?;
4084            let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
4085            let (t0, t1) = if reversed { (range.1, range.0) } else { range };
4086            let closed =
4087                ogeom_algo::edge_vertices(model, edge)?.is_some_and(|(a, b)| a.is_same(&b));
4088            let parts = if closed { 4 } else { 1 };
4089            for k in 0..parts {
4090                #[allow(clippy::cast_precision_loss)]
4091                let (f0, f1) = (k as f64 / parts as f64, (k + 1) as f64 / parts as f64);
4092                pieces.push((curve.clone(), t0 + (t1 - t0) * f0, t0 + (t1 - t0) * f1));
4093            }
4094        }
4095        // Each piece's samples, in the ring's sense, ends on its corners
4096        // exactly.
4097        let starts: Vec<Point> = pieces
4098            .iter()
4099            .map(|(curve, a, _)| curve.point_at(*a, tol))
4100            .collect::<OgeomResult<_>>()?;
4101        let count = pieces.len();
4102        // A piece at a fraction of its range, its ends on its corners
4103        // exactly.
4104        let piece_at = |pi: usize, f: f64| -> OgeomResult<Point> {
4105            let (curve, a, b) = &pieces[pi];
4106            if f <= 0.0 {
4107                Ok(starts[pi])
4108            } else if f >= 1.0 {
4109                Ok(starts[(pi + 1) % count])
4110            } else {
4111                curve.point_at(a + (b - a) * f, tol)
4112            }
4113        };
4114        let along_of = |pi: usize| -> usize {
4115            if matches!(pieces[pi].0, ogeom_geom::Curve::Line(_)) {
4116                8
4117            } else {
4118                24
4119            }
4120        };
4121        // A piece's strip over one segment: the piece's points screwed
4122        // through the segment, the station index (an even step of the
4123        // turn) its parameter across, known between the stations as at
4124        // them.
4125        let skin_of = |pi: usize, seg: usize| -> OgeomResult<Skin<'_>> {
4126            Skin::swept(
4127                move |f, s| screw(piece_at(pi, f)?, theta_at(seg, s)),
4128                fractions(along_of(pi)),
4129                (0, PER_SEGMENT),
4130                false,
4131            )
4132        };
4133
4134        // A vertex set at every segment boundary.
4135        let mut corners: Vec<Vec<Shape>> = Vec::with_capacity(segments + 1);
4136        for b in 0..=segments {
4137            let theta = if b == segments {
4138                total
4139            } else {
4140                theta_at(b, 0.0)
4141            };
4142            let mut set = Vec::with_capacity(count);
4143            for p in &starts {
4144                set.push(ogeom_algo::make_vertex(model, screw(*p, theta)?).shape);
4145            }
4146            corners.push(set);
4147        }
4148        let mut bottoms = Vec::with_capacity(count);
4149        let mut tops = Vec::with_capacity(count);
4150        // Per edge, the previous segment's top border, for the next to
4151        // start on.
4152        let mut held_tops: Vec<Option<Shape>> = vec![None; count];
4153        for seg in 0..segments {
4154            #[allow(clippy::cast_precision_loss)]
4155            let hint = screw(centre, theta_at(seg, (PER_SEGMENT / 2) as f64))?;
4156            let mut first_rail: Option<Shape> = None;
4157            let mut prev_rail: Option<Shape> = None;
4158            for ei in 0..count {
4159                let skin = skin_of(ei, seg)?;
4160                let next = (ei + 1) % count;
4161                let last_rail = if ei + 1 == count {
4162                    first_rail.clone()
4163                } else {
4164                    None
4165                };
4166                let (from, to) = (&corners[seg], &corners[seg + 1]);
4167                let strip = skinned_strip(
4168                    model,
4169                    &skin,
4170                    (&from[ei], &from[next], &to[ei], &to[next]),
4171                    [
4172                        held_tops[ei].as_ref(),
4173                        None,
4174                        prev_rail.as_ref(),
4175                        last_rail.as_ref(),
4176                    ],
4177                    hint,
4178                    hole,
4179                    tolerance,
4180                    tol,
4181                )?;
4182                if ei == 0 {
4183                    first_rail = Some(strip.rail0.clone());
4184                }
4185                prev_rail = Some(strip.rail1.clone());
4186                faces.push(strip.face.clone());
4187                if seg == 0 {
4188                    bottoms.push(strip.bottom.clone());
4189                }
4190                if seg + 1 == segments {
4191                    tops.push(strip.top.clone());
4192                }
4193                held_tops[ei] = Some(strip.top);
4194            }
4195        }
4196        cap_loops[0].push(bottoms);
4197        cap_loops[1].push(tops);
4198    }
4199
4200    // The caps: the profile's plane where it starts, and that plane
4201    // screwed on to where it ends.
4202    for (end, loops) in cap_loops.iter().enumerate() {
4203        let theta = if end == 0 { 0.0 } else { total };
4204        let centre = centre_of(model, profile, tol)?;
4205        let at = screw(centre, theta)?;
4206        // The profile's plane, turned with it: out of the solid, back
4207        // against the motion at the start and on with it at the end.
4208        let normal = {
4209            let (sin, cos) = (sense * theta).sin_cos();
4210            let along = z * n.dot(z);
4211            let square = n - along;
4212            let turned = along + square * cos + z.cross(square) * sin;
4213            let forward = if turned.dot(travel(at)) >= 0.0 {
4214                turned
4215            } else {
4216                -turned
4217            };
4218            if end == 0 { -forward } else { forward }
4219        };
4220        let cap_plane = Plane::through(at, Direction::new(normal, tol)?);
4221        let mut reach = 1.0_f64;
4222        for edges in loops {
4223            for edge in edges {
4224                let (curve, range) = spine_curve_of(model, edge)?;
4225                for k in 0..8 {
4226                    let p =
4227                        curve.point_at(range.0 + (range.1 - range.0) * f64::from(k) / 8.0, tol)?;
4228                    reach = reach.max(p.distance(at) * 2.0);
4229                }
4230            }
4231        }
4232        let surface: SurfaceGeometry =
4233            PlaneSurface::over(cap_plane, (-reach, reach), (-reach, reach))?.into();
4234        // The material on the left of each ring about the outward normal:
4235        // the profile's first ring is its outer one.
4236        let mut wires = Vec::with_capacity(loops.len());
4237        for (li, edges) in loops.iter().enumerate() {
4238            let ring = walked_about(model, edges, normal, li == 0, tol)?;
4239            wires.push(ogeom_algo::make_wire(model, &ring, tol)?.shape);
4240        }
4241        let face = ogeom_algo::make_face(model, surface, &wires, tol)?.shape;
4242        let cap_id = {
4243            let Some(ogeom_topo::NodeData::Face(data)) = model.node(&face).map(|n| n.data()) else {
4244                ogeom_bail!(Construction, "the cap holds no face data");
4245            };
4246            data.surface
4247        };
4248        let frame = cap_plane.frame();
4249        for edges in loops {
4250            for edge in edges {
4251                let (curve, range) = spine_curve_of(model, edge)?;
4252                let ogeom_geom::Curve::BSpline(bs) = &curve else {
4253                    ogeom_bail!(Construction, "a swept ring is not a spline");
4254                };
4255                let control2: Vec<Point2> = bs
4256                    .control_points()
4257                    .iter()
4258                    .map(|w| {
4259                        let local = frame.to_local(w.point());
4260                        Point2::new(local.x, local.y)
4261                    })
4262                    .collect();
4263                let pcurve: ogeom_geom::PlanarCurve =
4264                    ogeom_geom::BSpline2d::new(bs.knots().clone(), control2, tol)?.into();
4265                ogeom_algo::attach_pcurve(
4266                    model,
4267                    edge,
4268                    pcurve,
4269                    cap_id,
4270                    ogeom_topo::Location::identity(),
4271                    range,
4272                )?;
4273            }
4274        }
4275        faces.push(face);
4276    }
4277
4278    let sewn = sew(model, &faces, tol)?;
4279    if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
4280        ogeom_bail!(Construction, "the helical sweep did not close");
4281    }
4282    let solid = make_solid(model, &sewn.shells)?.shape;
4283    let mut history = History::new();
4284    history.generate(profile, solid.clone());
4285    Ok(Built::new(solid, history))
4286}
4287
4288/// Revolve `profile` (a planar face) about `axis`, each point turning the
4289/// right-handed way about it until its circle first meets the surface of
4290/// `limit`, a face taken as its whole surface the way
4291/// [`make_half_space`](ogeom_algo::make_half_space) takes it: the revolution
4292/// "up to face", stopping on the target at a different angle for each
4293/// point.
4294///
4295/// Built as the half turn of the profile kept on the profile's side of the
4296/// limit: exact wherever every circle meets the surface within that half
4297/// turn and does not come back to the profile's side before it ends.
4298///
4299/// # Errors
4300///
4301/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
4302/// profile is not a planar face whose plane holds the axis, a point's
4303/// circle never meets the surface, or one meets it only past half a turn or
4304/// crosses it back within the half turn; as the revolution, the half space
4305/// and the boolean otherwise.
4306pub fn make_revolution_until(
4307    model: &mut Model,
4308    profile: &Shape,
4309    axis: ogeom_math::Axis,
4310    limit: &Shape,
4311    tol: Tolerances,
4312) -> OgeomResult<Built> {
4313    use ogeom_geom::Surface as _;
4314    if model.kind_of(profile)? != ShapeType::Face {
4315        ogeom_bail!(
4316            Construction,
4317            "a revolution up to a face revolves a planar face"
4318        );
4319    }
4320    if model.kind_of(limit)? != ShapeType::Face {
4321        ogeom_bail!(Construction, "a revolution stops on a face");
4322    }
4323    let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
4324        ogeom_bail!(
4325            Construction,
4326            "a revolution up to a face revolves a planar face"
4327        );
4328    };
4329    let z = axis.direction.vector();
4330    if plane.normal().vector().dot(z).abs() > tol.angular()
4331        || plane.distance_to(axis.location) > tol.confusion() * 100.0
4332    {
4333        ogeom_bail!(
4334            Construction,
4335            "the profile's plane does not hold the axis; a revolution up to a \
4336             face turns a profile about an axis in its own plane"
4337        );
4338    }
4339    // The limit's surface, placed, and which side of it a point stands.
4340    let (surface, flip) = {
4341        let Some(data) = model.node(limit).and_then(|n| n.data().as_face()) else {
4342            ogeom_bail!(Construction, "the limit holds no face data");
4343        };
4344        let Some(surface) = model.geometry().surface(data.surface) else {
4345            ogeom_bail!(Dangling, "the limit's surface is not in this model");
4346        };
4347        (
4348            surface
4349                .clone()
4350                .transformed(&limit.transform(model.datums())?, tol)?,
4351            limit.orientation() == ogeom_topo::Orientation::Reversed,
4352        )
4353    };
4354    let side = |p: Point| -> OgeomResult<f64> {
4355        let foot = ogeom_algo::project_on_surface(&surface, p, 16, tol)?;
4356        let (u, v) = foot.parameters;
4357        let n = surface.normal_at(u, v, tol)?.vector();
4358        let s = (p - foot.point).dot(n);
4359        Ok(if flip { -s } else { s })
4360    };
4361    // Every boundary point's circle: its first meeting within half a turn,
4362    // and no return to the profile's side before the half turn ends.
4363    let turn_of = |p: Point, theta: f64| Transform::rotation(axis, theta).apply(p);
4364    let mut rings = Vec::new();
4365    for wire in explore(model, profile, Filter::OfType(ShapeType::Wire))? {
4366        rings.extend(sample_wire(model, &wire, 64, tol)?);
4367    }
4368    let start_side = side(centre_of(model, profile, tol)?)?.signum();
4369    const STEPS: u32 = 360;
4370    for p in &rings {
4371        let mut met: Option<f64> = None;
4372        for k in 1..=STEPS {
4373            let theta = core::f64::consts::TAU * f64::from(k) / f64::from(STEPS);
4374            let here = side(turn_of(*p, theta))?;
4375            match met {
4376                None if here.signum() != start_side && here.abs() > tol.confusion() => {
4377                    met = Some(theta);
4378                }
4379                Some(first)
4380                    if theta <= core::f64::consts::PI
4381                        && here.signum() == start_side
4382                        && here.abs() > tol.confusion() =>
4383                {
4384                    ogeom_bail!(
4385                        Construction,
4386                        "the circle through {p:?} meets the limit at {first} and \
4387                         crosses back within half a turn; a revolution up to it \
4388                         is not built there"
4389                    );
4390                }
4391                _ => {}
4392            }
4393        }
4394        match met {
4395            None => ogeom_bail!(
4396                Construction,
4397                "the circle through {p:?} never meets the limit's surface"
4398            ),
4399            Some(first) if first > core::f64::consts::PI => ogeom_bail!(
4400                Construction,
4401                "the circle through {p:?} meets the limit only past half a turn"
4402            ),
4403            _ => {}
4404        }
4405    }
4406    let half = ogeom_algo::make_revolution(model, profile, axis, core::f64::consts::PI, tol)?;
4407    let inside = centre_of(model, profile, tol)?;
4408    let bound = ogeom_algo::make_half_space(model, limit, inside, tol)?.shape;
4409    let mut built = ogeom_bool::common(model, &half.shape, &bound, tol)?;
4410    built.history.generate(profile, built.shape.clone());
4411    Ok(built)
4412}
4413
4414/// The centroid of a face's outer ring's samples.
4415fn centre_of(model: &Model, profile: &Shape, tol: Tolerances) -> OgeomResult<Point> {
4416    let Some(wire) = explore(model, profile, Filter::OfType(ShapeType::Wire))?
4417        .into_iter()
4418        .next()
4419    else {
4420        ogeom_bail!(Construction, "the profile has no loop");
4421    };
4422    let samples = sample_wire(model, &wire, 32, tol)?;
4423    #[allow(clippy::cast_precision_loss)]
4424    let n = samples.len() as f64;
4425    let sum = samples
4426        .iter()
4427        .fold(Vector::new(0.0, 0.0, 0.0), |acc, p| acc + p.to_vector());
4428    Ok(Point::from_vector(sum / n))
4429}
4430
4431/// The pipe along a spine of lines and circular arcs meeting tangent to
4432/// one another, built exactly: down a line the section is extruded, round
4433/// an arc it is revolved about the arc's axis (the rotation-minimizing
4434/// frame of a circle is its own rotation), and the legs are fused on the
4435/// sections they share. Every wall is then the closed form its profile
4436/// edge sweeps: a plane, drum, cone, ball or torus where the edge is a
4437/// line or circle. `None` where the spine or profile is not of that kind,
4438/// for the general construction to take.
4439fn exact_legs(
4440    model: &mut Model,
4441    profile: &Shape,
4442    spine: &Shape,
4443    frenet: bool,
4444    tol: Tolerances,
4445) -> OgeomResult<Option<Built>> {
4446    use ogeom_geom::{Curve, Curve3d as _};
4447    let edges: Vec<Shape> = match model.kind_of(spine)? {
4448        ShapeType::Edge => vec![spine.clone()],
4449        ShapeType::Wire => model.ordered_children_of(spine)?,
4450        _ => return Ok(None),
4451    };
4452    let solid = match model.kind_of(profile)? {
4453        ShapeType::Face => true,
4454        ShapeType::Wire => false,
4455        _ => return Ok(None),
4456    };
4457    if edges.is_empty() || (!solid && edges.len() > 1) {
4458        return Ok(None);
4459    }
4460    // Each leg: where it starts and ends, its heading at either end, and
4461    // the motion that carries the section down it.
4462    struct Leg {
4463        start: Point,
4464        heading: (Vector, Vector),
4465        motion: Transform,
4466        along: LegKind,
4467    }
4468    enum LegKind {
4469        Line(Vector),
4470        Arc(ogeom_math::Axis, f64),
4471    }
4472    let mut legs = Vec::with_capacity(edges.len());
4473    for edge in &edges {
4474        let (curve, range) = spine_curve_of(model, edge)?;
4475        let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
4476        let (t0, t1) = if reversed { (range.1, range.0) } else { range };
4477        let (a, b) = (curve.point_at(t0, tol)?, curve.point_at(t1, tol)?);
4478        let sense = if reversed { -1.0 } else { 1.0 };
4479        let heading = (curve.d1_at(t0, tol)? * sense, curve.d1_at(t1, tol)? * sense);
4480        let basis = match &curve {
4481            Curve::Trimmed(t) => t.basis().clone(),
4482            other => other.clone(),
4483        };
4484        let (motion, along) = match basis {
4485            Curve::Line(_) => {
4486                if frenet {
4487                    return Ok(None);
4488                }
4489                (Transform::translation(b - a), LegKind::Line(b - a))
4490            }
4491            Curve::Circle(c) => {
4492                let frame = c.circle().frame();
4493                // Turning the way the walk heads at its start.
4494                let turn = (a - frame.origin()).cross(heading.0);
4495                let direction = if turn.dot(frame.z().vector()) > 0.0 {
4496                    frame.z()
4497                } else {
4498                    -frame.z()
4499                };
4500                let axis = ogeom_math::Axis {
4501                    location: frame.origin(),
4502                    direction,
4503                };
4504                let angle = (t1 - t0).abs();
4505                (Transform::rotation(axis, angle), LegKind::Arc(axis, angle))
4506            }
4507            _ => return Ok(None),
4508        };
4509        legs.push(Leg {
4510            start: a,
4511            heading,
4512            motion,
4513            along,
4514        });
4515    }
4516    // Legs meet tangent, or all are straight and their corners are mitred
4517    // exactly below; any other corner is mitred by the general path.
4518    let cornered = legs.windows(2).any(|pair| {
4519        let (x, y) = (pair[0].heading.1, pair[1].heading.0);
4520        x.cross(y).magnitude() > tol.angular() * x.magnitude() * y.magnitude() || x.dot(y) <= 0.0
4521    });
4522    if cornered {
4523        let lines: Option<Vec<(Point, Vector)>> = legs
4524            .iter()
4525            .map(|leg| match leg.along {
4526                LegKind::Line(v) => Some((leg.start, v)),
4527                LegKind::Arc(..) => None,
4528            })
4529            .collect();
4530        return match lines {
4531            Some(lines) if solid && !frenet => mitred_lines(model, profile, spine, &lines, tol),
4532            _ => Ok(None),
4533        };
4534    }
4535    // The profile square to the spine's exact start tangent, and on it.
4536    let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
4537        return Ok(None);
4538    };
4539    let t0 = legs[0].heading.0;
4540    if plane.normal().vector().cross(t0).magnitude() > tol.angular() * t0.magnitude()
4541        || plane.distance_to(legs[0].start) > tol.confusion() * 100.0
4542    {
4543        return Ok(None);
4544    }
4545
4546    let mut joined: Vec<Shape> = Vec::new();
4547    let mut carried = Transform::IDENTITY;
4548    for leg in &legs {
4549        // Rebuilt even where it stands: the caps are this face, and it
4550        // carries its trims on its own plane.
4551        let heading = carried.apply_vector(legs[0].heading.0);
4552        let section = realized_profile_wound(model, profile, &carried, Some(heading), tol)?;
4553        let piece = match leg.along {
4554            LegKind::Line(v) => ogeom_algo::make_prism(model, &section, v, tol)?.shape,
4555            LegKind::Arc(axis, angle) => {
4556                ogeom_algo::make_revolution(model, &section, axis, angle, tol)?.shape
4557            }
4558        };
4559        carried = leg.motion * carried;
4560        joined.push(piece);
4561    }
4562    let result = fuse_in_order(model, joined, tol)?;
4563    Ok(result.map(|shape| {
4564        let mut history = History::new();
4565        history.generate(spine, shape.clone());
4566        history.generate(profile, shape.clone());
4567        Built::new(shape, history)
4568    }))
4569}
4570
4571/// A face swept down a spine of straight legs with corners, exactly: each
4572/// leg is the profile's prism, run on past its corners and trimmed by the
4573/// mitre plane that halves each corner, and the pieces are fused. The
4574/// section turns at each corner by the least rotation taking one leg's
4575/// heading to the next's, as the rotation-minimizing frame does, and every
4576/// wall is the closed form its profile edge sweeps along a line.
4577///
4578/// `None` where the spine closes on itself (a ring's section may come back
4579/// turned, which the general construction settles), a corner all but
4580/// doubles back, or a leg is too short for the corners at its ends to trim
4581/// it apart.
4582fn mitred_lines(
4583    model: &mut Model,
4584    profile: &Shape,
4585    spine: &Shape,
4586    legs: &[(Point, Vector)],
4587    tol: Tolerances,
4588) -> OgeomResult<Option<Built>> {
4589    if let (Some(first), Some(last)) = (legs.first(), legs.last())
4590        && first.0.distance(last.0 + last.1) <= tol.confusion() * 100.0
4591    {
4592        return Ok(None);
4593    }
4594    let bounds = ogeom_algo::shape_bounds(model, profile, tol)?;
4595    let (Some(low), Some(high)) = (bounds.low(), bounds.high()) else {
4596        return Ok(None);
4597    };
4598    let width = low.distance(high);
4599    let unit = |v: Vector| v / v.magnitude();
4600    // Each corner's mitre: its point and the normal of the plane halving it,
4601    // pointing on along the next leg.
4602    let mut mitres: Vec<(Point, Vector)> = Vec::with_capacity(legs.len().saturating_sub(1));
4603    for pair in legs.windows(2) {
4604        let (d0, d1) = (unit(pair[0].1), unit(pair[1].1));
4605        // A corner turning past a right angle and a half reaches too far
4606        // along its legs for the mitre to stay on them.
4607        if d0.dot(d1) < -0.7 {
4608            return Ok(None);
4609        }
4610        mitres.push((pair[1].0, unit(d0 + d1)));
4611    }
4612    // How far a section reaches along its leg from a corner's mitre.
4613    let reach = width * 2.0;
4614    for (i, (_, v)) in legs.iter().enumerate() {
4615        let corners = f64::from(u8::from(i > 0) + u8::from(i + 1 < legs.len()));
4616        if v.magnitude() <= reach * corners * 0.5 {
4617            return Ok(None);
4618        }
4619    }
4620
4621    let mut carried = Transform::IDENTITY;
4622    let mut joined: Vec<Shape> = Vec::new();
4623    for (i, &(_, v)) in legs.iter().enumerate() {
4624        let d = unit(v);
4625        let before = if i > 0 { reach } else { 0.0 };
4626        let after = if i + 1 < legs.len() { reach } else { 0.0 };
4627        let section = realized_profile_wound(
4628            model,
4629            profile,
4630            &(Transform::translation(-d * before) * carried),
4631            Some(d),
4632            tol,
4633        )?;
4634        let mut piece =
4635            ogeom_algo::make_prism(model, &section, v + d * (before + after), tol)?.shape;
4636        // Trimmed back to the mitres at either end.
4637        for (at, normal, keep_ahead) in [
4638            (i > 0).then(|| (mitres[i - 1].0, mitres[i - 1].1, true)),
4639            (i + 1 < legs.len()).then(|| (mitres[i].0, mitres[i].1, false)),
4640        ]
4641        .into_iter()
4642        .flatten()
4643        {
4644            let plane = Plane::through(at, Direction::new(normal, tol)?);
4645            let face = ogeom_algo::make_natural_face(
4646                model,
4647                SurfaceGeometry::Plane(ogeom_geom::PlaneSurface::over(
4648                    plane,
4649                    (-reach * 4.0 - width, reach * 4.0 + width),
4650                    (-reach * 4.0 - width, reach * 4.0 + width),
4651                )?),
4652            )?
4653            .shape;
4654            let side = if keep_ahead { normal } else { -normal };
4655            let half = ogeom_algo::make_half_space(model, &face, at + side * width, tol)?.shape;
4656            piece = ogeom_bool::common(model, &piece, &half, tol)?.shape;
4657        }
4658        // On to the next leg: along this one, then turned at the corner by
4659        // the least rotation between the headings.
4660        carried = Transform::translation(v) * carried;
4661        if let Some(&(corner, next)) = legs.get(i + 1) {
4662            let n = unit(next);
4663            let axis = d.cross(n);
4664            if axis.magnitude() > tol.angular() {
4665                let angle = d.dot(n).clamp(-1.0, 1.0).acos();
4666                let rotation = Transform::rotation(
4667                    ogeom_math::Axis {
4668                        location: corner,
4669                        direction: Direction::new(axis, tol)?,
4670                    },
4671                    angle,
4672                );
4673                carried = rotation * carried;
4674            }
4675        }
4676        joined.push(piece);
4677    }
4678    let result = fuse_in_order(model, joined, tol)?;
4679    Ok(result.map(|shape| {
4680        let mut history = History::new();
4681        history.generate(spine, shape.clone());
4682        history.generate(profile, shape.clone());
4683        Built::new(shape, history)
4684    }))
4685}
4686
4687/// Sweep a planar profile (a wire, or a face whose holes ride along)
4688/// down an arbitrary spine, one skinned wall per profile loop.
4689///
4690/// The spine may be a single edge or a wire of edges of any curve the
4691/// vocabulary evaluates: lines, arcs, splines, helices. Frames along it are
4692/// rotation-minimizing by default (the double-reflection construction), so
4693/// the profile neither twists nor kinks where the spine bends; `frenet`
4694/// asks for the Frenet frame instead, which turns with the spine's own
4695/// curvature, the law a thread wants. Stations are placed by each edge's
4696/// own turning, the skin holds every transported section to `tolerance`
4697/// (along the profile between its samples as well as at them, the
4698/// sampling refined where the skin misses), and the caps sit
4699/// perpendicular to the spine's ends, holes and all.
4700///
4701/// Each spine edge skins its own run of wall, and neighbouring runs share
4702/// the section where their edges meet, so a join where the curvature steps
4703/// (an arc running on into its tangent line) is followed exactly rather
4704/// than smoothed by one fit across it. A run whose sections all lie in one
4705/// plane is that plane.
4706///
4707/// A sharp corner is mitred. Between straight legs the mitre is a plane and
4708/// each wall is sheared onto it; where a leg is curved the two legs' walls
4709/// end on the crossing of their generators (each profile point's own path
4710/// down either leg, run straight on past the corner), which is exact where
4711/// the corner turns in the leg's plane. Where it turns a curved leg out of
4712/// its plane the generators miss, and the spine is swept in pieces instead:
4713/// each side runs on straight past the corner, is trimmed by the mitre
4714/// plane, and the pieces are fused, the difference between their sections
4715/// standing as a face of the mitre plane.
4716///
4717/// # Errors
4718///
4719/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
4720/// profile is not planar, leans along the spine, or does not sit at the
4721/// spine's start; if `frenet` is asked of a spine that never bends, or of a
4722/// cornered one; if a wire (not a face) is swept round a corner that turns
4723/// a curved leg out of its plane, which only solid pieces can mitre; if the
4724/// spine all but doubles back at a corner; or if a leg is shorter than its
4725/// corner's reach.
4726/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the skin
4727/// cannot reach the tolerance.
4728pub fn make_pipe_shell(
4729    model: &mut Model,
4730    profile: &Shape,
4731    spine: &Shape,
4732    frenet: bool,
4733    tolerance: f64,
4734    tol: Tolerances,
4735) -> OgeomResult<Built> {
4736    let law = if frenet {
4737        PipeLaw::Frenet
4738    } else {
4739        PipeLaw::RotationMinimizing
4740    };
4741    make_pipe_shell_with(
4742        model,
4743        profile,
4744        spine,
4745        &law,
4746        PipeCorners::Mitre,
4747        tolerance,
4748        tol,
4749    )
4750}
4751
4752/// How a pipe's section turns about its spine.
4753#[derive(Debug, Clone, Copy)]
4754pub enum PipeLaw<'a> {
4755    /// Double-reflection rotation-minimizing frames: the section neither
4756    /// twists nor kinks where the spine bends.
4757    RotationMinimizing,
4758    /// The Frenet frame, turning with the spine's own curvature.
4759    Frenet,
4760    /// The section's normal axis points at `guide`, a curve running beside
4761    /// the spine: at each station, where the guide crosses the plane square
4762    /// to the spine there.
4763    Auxiliary {
4764        /// An edge or wire beside the spine.
4765        guide: &'a Shape,
4766    },
4767    /// The section keeps this direction as its binormal, square to the
4768    /// spine's tangent.
4769    Binormal(Direction),
4770    /// The section keeps the frame it has at the spine's start all the
4771    /// way: carried by translation along the spine, never turned.
4772    Fixed,
4773}
4774
4775/// How a pipe turns a sharp corner of its spine.
4776#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
4777pub enum PipeCorners {
4778    /// The walls sheared onto the plane halving the corner.
4779    #[default]
4780    Mitre,
4781    /// Each leg runs on straight past the corner until its section clears
4782    /// the other leg's far side, and the legs are fused: the outside of the
4783    /// corner square.
4784    Extended,
4785    /// Each leg ends square at the corner and the section turns about the
4786    /// corner through its angle, joining the two ends: the outside of the
4787    /// corner rounded.
4788    Round,
4789}
4790
4791/// [`make_pipe_shell`] with a frame law: the section turns about the spine
4792/// the way `law` says.
4793///
4794/// # Errors
4795///
4796/// As [`make_pipe_shell_with`].
4797pub fn make_pipe_shell_law(
4798    model: &mut Model,
4799    profile: &Shape,
4800    spine: &Shape,
4801    law: PipeLaw<'_>,
4802    tolerance: f64,
4803    tol: Tolerances,
4804) -> OgeomResult<Built> {
4805    make_pipe_shell_with(
4806        model,
4807        profile,
4808        spine,
4809        &law,
4810        PipeCorners::Mitre,
4811        tolerance,
4812        tol,
4813    )
4814}
4815
4816/// [`make_pipe_shell`] with a frame law and a way of turning corners.
4817///
4818/// The auxiliary and binormal laws sweep an open spine with no sharp
4819/// corner. Extended and round corners are built for a face swept down an
4820/// open spine of straight legs with the rotation-minimizing frame, each
4821/// leg a prism of the section and each round corner the section revolved
4822/// about the corner; on a spine with no sharp corner every way of turning
4823/// corners is the same solid.
4824///
4825/// # Errors
4826///
4827/// As [`make_pipe_shell`], and
4828/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) where
4829/// the law or the corners are asked of a spine they do not serve, the guide
4830/// does not cross a station's plane, or the spine's tangent runs along the
4831/// binormal or toward the guide.
4832#[allow(clippy::too_many_arguments)]
4833pub fn make_pipe_shell_with(
4834    model: &mut Model,
4835    profile: &Shape,
4836    spine: &Shape,
4837    law: &PipeLaw<'_>,
4838    corners: PipeCorners,
4839    tolerance: f64,
4840    tol: Tolerances,
4841) -> OgeomResult<Built> {
4842    // A section carried by translation turns no corner: every way of
4843    // turning one is the same solid.
4844    if matches!(law, PipeLaw::Fixed) {
4845        return crate::fixed::fixed_pipe(model, profile, spine, tol);
4846    }
4847    if corners != PipeCorners::Mitre
4848        && let Some(legs) = straight_legs(model, spine, tol)?
4849        && legs.len() > 1
4850    {
4851        if !matches!(law, PipeLaw::RotationMinimizing) {
4852            ogeom_bail!(
4853                Construction,
4854                "extended and round corners turn the section with the \
4855                 rotation-minimizing frame"
4856            );
4857        }
4858        if model.kind_of(profile)? != ShapeType::Face {
4859            ogeom_bail!(Construction, "extended and round corners sweep a face");
4860        }
4861        return cornered_lines(model, profile, spine, &legs, corners, tol);
4862    }
4863    if corners != PipeCorners::Mitre && has_sharp_corner(model, spine, tol)? {
4864        ogeom_bail!(
4865            Construction,
4866            "extended and round corners are built along a spine of straight legs"
4867        );
4868    }
4869    pipe_shell_law(model, profile, spine, law, tolerance, tol)
4870}
4871
4872/// The legs of a spine made only of straight edges, in order, each as its
4873/// start and its displacement; `None` where any edge is curved.
4874fn straight_legs(
4875    model: &Model,
4876    spine: &Shape,
4877    tol: Tolerances,
4878) -> OgeomResult<Option<Vec<(Point, Vector)>>> {
4879    use ogeom_geom::Curve;
4880    let edges: Vec<Shape> = match model.kind_of(spine)? {
4881        ShapeType::Edge => vec![spine.clone()],
4882        ShapeType::Wire => model.ordered_children_of(spine)?,
4883        _ => return Ok(None),
4884    };
4885    let mut legs = Vec::with_capacity(edges.len());
4886    for edge in &edges {
4887        let (curve, range) = spine_curve_of(model, edge)?;
4888        let basis = match &curve {
4889            Curve::Trimmed(t) => t.basis().clone(),
4890            other => other.clone(),
4891        };
4892        if !matches!(basis, Curve::Line(_)) {
4893            return Ok(None);
4894        }
4895        let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
4896        let (t0, t1) = if reversed { (range.1, range.0) } else { range };
4897        let (a, b) = (curve.point_at(t0, tol)?, curve.point_at(t1, tol)?);
4898        legs.push((a, b - a));
4899    }
4900    Ok(Some(legs))
4901}
4902
4903/// Whether the spine turns sharply anywhere between its edges.
4904pub(crate) fn has_sharp_corner(model: &Model, spine: &Shape, tol: Tolerances) -> OgeomResult<bool> {
4905    let stations = shell_stations(model, spine, tol)?;
4906    Ok((0..stations.len() - 1).any(|i| {
4907        stations[i].at.distance(stations[i + 1].at) <= tol.confusion()
4908            && (stations[i]
4909                .tangent
4910                .cross(stations[i + 1].tangent)
4911                .magnitude()
4912                > tol.angular()
4913                || stations[i].tangent.dot(stations[i + 1].tangent) < 0.0)
4914    }))
4915}
4916
4917/// A face down an open spine of straight legs with extended or round
4918/// corners: each leg the prism of its section, run on past its corners
4919/// for extended ones, and each round corner the section revolved about the
4920/// corner through its turn; the pieces fused.
4921fn cornered_lines(
4922    model: &mut Model,
4923    profile: &Shape,
4924    spine: &Shape,
4925    legs: &[(Point, Vector)],
4926    corners: PipeCorners,
4927    tol: Tolerances,
4928) -> OgeomResult<Built> {
4929    if let (Some(first), Some(last)) = (legs.first(), legs.last())
4930        && first.0.distance(last.0 + last.1) <= tol.confusion() * 100.0
4931    {
4932        ogeom_bail!(
4933            Construction,
4934            "extended and round corners are built along an open spine"
4935        );
4936    }
4937    let unit = |v: Vector| v / v.magnitude();
4938    let Some(outer) = explore(model, profile, Filter::OfType(ShapeType::Wire))?
4939        .into_iter()
4940        .next()
4941    else {
4942        ogeom_bail!(Construction, "the profile has no loop");
4943    };
4944    let samples = sample_wire(model, &outer, 256, tol)?;
4945    // The motion carrying the profile to each leg's start: along the legs
4946    // before it, turned at each corner by the least rotation between the
4947    // headings.
4948    let mut carried: Vec<Transform> = Vec::with_capacity(legs.len());
4949    let mut turns: Vec<Option<(ogeom_math::Axis, f64)>> = Vec::with_capacity(legs.len());
4950    let mut motion = Transform::IDENTITY;
4951    for (i, &(_, v)) in legs.iter().enumerate() {
4952        carried.push(motion);
4953        motion = Transform::translation(v) * motion;
4954        let turn = match legs.get(i + 1) {
4955            Some(&(corner, next)) => {
4956                let (d, n) = (unit(v), unit(next));
4957                if d.dot(n) < -0.7 {
4958                    ogeom_bail!(
4959                        Construction,
4960                        "the spine all but doubles back at a corner; no corner \
4961                         of that kind turns it"
4962                    );
4963                }
4964                let axis = d.cross(n);
4965                if axis.magnitude() > tol.angular() {
4966                    let about = ogeom_math::Axis {
4967                        location: corner,
4968                        direction: Direction::new(axis, tol)?,
4969                    };
4970                    let angle = d.dot(n).clamp(-1.0, 1.0).acos();
4971                    motion = Transform::rotation(about, angle) * motion;
4972                    Some((about, angle))
4973                } else {
4974                    None
4975                }
4976            }
4977            None => None,
4978        };
4979        turns.push(turn);
4980    }
4981    // How far each leg runs on past a corner for an extended corner: until
4982    // its section passes the far side of the other leg's section there.
4983    let reach_past = |section: &Transform, corner: Point, along: Vector, turn: f64| {
4984        let far = samples
4985            .iter()
4986            .map(|p| (section.apply(*p) - corner).dot(along))
4987            .fold(0.0_f64, f64::max);
4988        far / turn.sin().max(1e-3)
4989    };
4990    let mut joined: Vec<Shape> = Vec::new();
4991    for (i, &(_, v)) in legs.iter().enumerate() {
4992        let d = unit(v);
4993        let (mut before, mut after) = (0.0, 0.0);
4994        if corners == PipeCorners::Extended {
4995            if i > 0
4996                && let Some((_, angle)) = turns[i - 1]
4997            {
4998                // The previous leg's section at the corner, reaching back.
4999                let at_corner = Transform::translation(legs[i - 1].1) * carried[i - 1];
5000                before = reach_past(&at_corner, legs[i].0, -d, angle);
5001            }
5002            if let Some((_, angle)) = turns[i] {
5003                // The next leg's section at the corner, reaching on.
5004                after = reach_past(&carried[i + 1], legs[i + 1].0, d, angle);
5005            }
5006        }
5007        let section = realized_profile_wound(
5008            model,
5009            profile,
5010            &(Transform::translation(-d * before) * carried[i]),
5011            Some(d),
5012            tol,
5013        )?;
5014        let mut piece =
5015            ogeom_algo::make_prism(model, &section, v + d * (before + after), tol)?.shape;
5016        if corners == PipeCorners::Round
5017            && let Some((axis, angle)) = turns[i]
5018        {
5019            let end = realized_profile_wound(
5020                model,
5021                profile,
5022                &(Transform::translation(v) * carried[i]),
5023                Some(d),
5024                tol,
5025            )?;
5026            // The half of the section inside the turn sweeps within the two
5027            // legs; the half outside it turns the corner's round. It
5028            // touches the axis along its cut, which the revolution takes.
5029            let next = unit(legs[i + 1].1);
5030            let inward = next - d * next.dot(d);
5031            let outer = outside_half(model, &end, axis.location, d, -inward, tol)?;
5032            let bend = ogeom_algo::make_revolution(model, &outer, axis, angle, tol)?.shape;
5033            piece = ogeom_bool::fuse(model, &piece, &bend, tol)?.shape;
5034        }
5035        joined.push(piece);
5036    }
5037    let result = fuse_in_order(model, joined, tol)?;
5038    let Some(shape) = result else {
5039        ogeom_bail!(Construction, "the spine has no leg");
5040    };
5041    let mut history = History::new();
5042    history.generate(spine, shape.clone());
5043    history.generate(profile, shape.clone());
5044    Ok(Built::new(shape, history))
5045}
5046
5047/// The part of a planar section lying on the `outward` side of the line
5048/// through `at` square to it in the section's plane (whose normal is
5049/// `normal`), as a face on that plane: the section's slab kept on that
5050/// side by a half space, and the slab's face on the section's plane.
5051fn outside_half(
5052    model: &mut Model,
5053    section: &Shape,
5054    at: Point,
5055    normal: Vector,
5056    outward: Vector,
5057    tol: Tolerances,
5058) -> OgeomResult<Shape> {
5059    let width = {
5060        let bounds = ogeom_algo::shape_bounds(model, section, tol)?;
5061        match (bounds.low(), bounds.high()) {
5062            (Some(a), Some(b)) => a.distance(b),
5063            _ => ogeom_bail!(Construction, "the section has no extent"),
5064        }
5065    };
5066    let n = normal / normal.magnitude();
5067    let slab = ogeom_algo::make_prism(model, section, n * width, tol)?.shape;
5068    let cut = Plane::through(at, Direction::new(outward, tol)?);
5069    let face = ogeom_algo::make_natural_face(
5070        model,
5071        SurfaceGeometry::Plane(ogeom_geom::PlaneSurface::over(
5072            cut,
5073            (-width * 4.0, width * 4.0),
5074            (-width * 4.0, width * 4.0),
5075        )?),
5076    )?
5077    .shape;
5078    let side = outward / outward.magnitude() * width;
5079    let half = ogeom_algo::make_half_space(model, &face, at + side, tol)?.shape;
5080    let kept = ogeom_bool::common(model, &slab, &half, tol)?.shape;
5081    for face in explore(model, &kept, Filter::OfType(ShapeType::Face))? {
5082        let Ok((point, facing)) = ogeom_algo::face_normal(model, &face, tol) else {
5083            continue;
5084        };
5085        if (point - at).dot(n).abs() <= tol.confusion() * 100.0
5086            && facing.cross(n).magnitude() <= tol.angular() * 10.0
5087        {
5088            // Facing along the section's own normal, as the section does.
5089            return Ok(if facing.dot(n) > 0.0 {
5090                face
5091            } else {
5092                face.reversed()
5093            });
5094        }
5095    }
5096    ogeom_bail!(
5097        Construction,
5098        "the section has no part outside the corner's axis to turn"
5099    )
5100}
5101
5102/// Stations added between neighbours on one edge wherever a law's frame
5103/// turns by more than a few degrees between them: a straight spine has
5104/// stations only at its ends, and a section twisting down it would be
5105/// skinned straight across.
5106fn densified(
5107    model: &Model,
5108    spine: &Shape,
5109    stations: Vec<SpineStation>,
5110    law: &PipeLaw<'_>,
5111    tolerance: f64,
5112    tol: Tolerances,
5113) -> OgeomResult<Vec<SpineStation>> {
5114    const MOST: f64 = 0.05;
5115    let edges: Vec<Shape> = match model.kind_of(spine)? {
5116        ShapeType::Edge => vec![spine.clone()],
5117        ShapeType::Wire => model.ordered_children_of(spine)?,
5118        _ => return Ok(stations),
5119    };
5120    let mut curves = Vec::with_capacity(edges.len());
5121    for edge in &edges {
5122        curves.push(spine_curve_of(model, edge)?.0);
5123    }
5124    let mut stations = stations;
5125    for _ in 0..12 {
5126        let normals = law_normals(model, &stations, law, tolerance, tol)?;
5127        let mut out: Vec<SpineStation> = Vec::with_capacity(stations.len() * 2);
5128        let mut added = false;
5129        for i in 0..stations.len() {
5130            out.push(stations[i]);
5131            let Some(next) = stations.get(i + 1) else {
5132                continue;
5133            };
5134            let here = stations[i];
5135            if here.edge != next.edge || here.at.distance(next.at) <= tol.confusion() {
5136                continue;
5137            }
5138            let turn = normals[i].dot(normals[i + 1]).clamp(-1.0, 1.0).acos();
5139            if turn <= MOST {
5140                continue;
5141            }
5142            let curve = &curves[here.edge];
5143            let t = f64::midpoint(here.t, next.t);
5144            let d = curve.d1_at(t, tol)?;
5145            let sense = if curve.d1_at(here.t, tol)?.dot(here.tangent) >= 0.0 {
5146                1.0
5147            } else {
5148                -1.0
5149            };
5150            out.push(SpineStation {
5151                at: curve.point_at(t, tol)?,
5152                tangent: d * sense / d.magnitude(),
5153                edge: here.edge,
5154                t,
5155            });
5156            added = true;
5157        }
5158        stations = out;
5159        if !added {
5160            break;
5161        }
5162        // A law whose frame jumps (a guide's nearest point switching
5163        // branch) never settles: each round doubles the stations at the
5164        // jump. Past this many the skin is refused, not grown.
5165        if stations.len() > MOST_STATIONS {
5166            ogeom_bail!(
5167                NotDone,
5168                "the frame law turns faster than {MOST_STATIONS} sections can follow; \
5169                 it jumps somewhere along the spine"
5170            );
5171        }
5172    }
5173    Ok(stations)
5174}
5175
5176/// The most sections a law pipe's skin is built through.
5177const MOST_STATIONS: usize = 2048;
5178
5179/// The frame a law gives at a station: the spine's tangent as its `z`, the
5180/// law's normal as its `x`.
5181pub(crate) fn station_frame(
5182    station: &SpineStation,
5183    normal: Vector,
5184    tol: Tolerances,
5185) -> OgeomResult<Frame> {
5186    Frame::new(
5187        station.at,
5188        Direction::new(station.tangent, tol)?,
5189        Direction::new(normal, tol)?,
5190        tol,
5191    )
5192}
5193
5194/// A copy of a closed wire moved by `motion`, its curves restated where
5195/// they land rather than placed: every edge in the wire's order and sense,
5196/// on vertices shared end to end.
5197fn moved_ring(
5198    model: &mut Model,
5199    ring: &Shape,
5200    motion: &Transform,
5201    tol: Tolerances,
5202) -> OgeomResult<Shape> {
5203    let edges = model.ordered_children_of(ring)?;
5204    let mut vertices: Vec<(Point, Shape)> = Vec::new();
5205    let mut vertex = |model: &mut Model, at: Point| -> Shape {
5206        if let Some((_, v)) = vertices
5207            .iter()
5208            .find(|(p, _)| p.distance(at) <= tol.confusion() * 10.0)
5209        {
5210            return v.clone();
5211        }
5212        let v = ogeom_algo::make_vertex(model, at).shape;
5213        vertices.push((at, v.clone()));
5214        v
5215    };
5216    let mut moved = Vec::with_capacity(edges.len());
5217    for edge in &edges {
5218        let (curve, range) = spine_curve_of(model, edge)?;
5219        let placement = edge.transform(model.datums())?;
5220        let curve = curve.transformed(&(*motion * placement), tol)?;
5221        let (a, b) = (curve.point_at(range.0, tol)?, curve.point_at(range.1, tol)?);
5222        let (va, vb) = (vertex(model, a), vertex(model, b));
5223        let built = ogeom_algo::make_edge_between(model, curve, range, &va, &vb, tol)?.shape;
5224        moved.push(if edge.orientation() == ogeom_topo::Orientation::Reversed {
5225            built.reversed()
5226        } else {
5227            built
5228        });
5229    }
5230    Ok(ogeom_algo::make_wire(model, &moved, tol)?.shape)
5231}
5232
5233/// A profile swept under a law that turns it about the spine, as the loft
5234/// through its outer ring placed at every station by the law's frame: the
5235/// section keeps the place it has in the frame at the spine's start.
5236#[allow(clippy::too_many_arguments)]
5237fn law_loft(
5238    model: &mut Model,
5239    profile: &Shape,
5240    spine: &Shape,
5241    stations: &[SpineStation],
5242    law: &PipeLaw<'_>,
5243    tolerance: f64,
5244    tol: Tolerances,
5245) -> OgeomResult<Built> {
5246    let rings: Vec<Shape> = match model.kind_of(profile)? {
5247        ShapeType::Face => model.ordered_children_of(profile)?,
5248        ShapeType::Wire => vec![profile.clone()],
5249        _ => ogeom_bail!(Construction, "a pipe sweeps a planar face or wire"),
5250    };
5251    if rings.len() != 1 {
5252        ogeom_bail!(
5253            Construction,
5254            "an auxiliary or binormal law sweeps a profile with no hole"
5255        );
5256    }
5257    let normals = law_normals(model, stations, law, tolerance, tol)?;
5258    let start = station_frame(&stations[0], normals[0], tol)?;
5259    let mut kept: Vec<SpineStation> = Vec::with_capacity(stations.len());
5260    let mut kept_normals: Vec<Vector> = Vec::with_capacity(stations.len());
5261    let mut sections: Vec<Shape> = Vec::with_capacity(stations.len());
5262    for (station, normal) in stations.iter().zip(&normals) {
5263        if kept
5264            .last()
5265            .is_some_and(|p| p.at.distance(station.at) <= tol.confusion())
5266        {
5267            continue;
5268        }
5269        kept.push(*station);
5270        kept_normals.push(*normal);
5271        let frame = station_frame(station, *normal, tol)?;
5272        let motion = Transform::from_frame(&frame) * Transform::to_frame(&start);
5273        sections.push(moved_ring(model, &rings[0], &motion, tol)?);
5274    }
5275    // Between two stations the spine is read off its curve and the law
5276    // asked for its normal there, as at a station.
5277    let walk = SpineWalk {
5278        curves: walk_curves(model, spine)?,
5279        stations: &kept,
5280        normals: &kept_normals,
5281    };
5282    let held: std::cell::RefCell<ogeom_core::FastMap<u64, Transform>> =
5283        std::cell::RefCell::default();
5284    let guide = guide_curves(model, law)?;
5285    let motion = |s: f64| -> OgeomResult<Transform> {
5286        if let Some(m) = held.borrow().get(&s.to_bits()) {
5287            return Ok(*m);
5288        }
5289        let (at, tangent, _) = walk.frame_at(s, (0, kept.len() - 1), tol)?;
5290        let here = SpineStation {
5291            at,
5292            tangent,
5293            ..kept[0]
5294        };
5295        let normal = law_normals_on(&guide, &[here], law, tolerance, tol)?[0];
5296        let frame = station_frame(&here, normal, tol)?;
5297        let m = Transform::from_frame(&frame) * Transform::to_frame(&start);
5298        held.borrow_mut().insert(s.to_bits(), m);
5299        Ok(m)
5300    };
5301    let mut built = loft_skinned_along(model, &sections, Some(&motion), tolerance, tol)?;
5302    built.history.generate(spine, built.shape.clone());
5303    built.history.generate(profile, built.shape.clone());
5304    Ok(built)
5305}
5306
5307/// Sweep several planar sections down one spine, the section changing
5308/// shape along the path: a multisection pipe.
5309///
5310/// Each section stands where the spine crosses its plane, and is read in
5311/// the spine's moving frame there (rotation-minimizing, or Frenet where
5312/// `frenet` asks). Between two sections the section is blended in that
5313/// frame by the length run along the spine, matched point to point from
5314/// each section's own start, so the result follows the spine rather than
5315/// the chord between the sections. The sections are closed wires, or
5316/// faces without holes; the ends are capped. The first or last section may
5317/// be a vertex where the spine starts or ends: the pipe closes to that
5318/// point, uncapped there.
5319///
5320/// # Errors
5321///
5322/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
5323/// there are fewer than two sections, a section is not planar or has a
5324/// hole, the spine does not cross a section's plane, or the sections stand
5325/// out of order along the spine;
5326/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the skin
5327/// cannot reach `tolerance`.
5328pub fn make_pipe_sections(
5329    model: &mut Model,
5330    sections: &[Shape],
5331    spine: &Shape,
5332    frenet: bool,
5333    tolerance: f64,
5334    tol: Tolerances,
5335) -> OgeomResult<Built> {
5336    const AROUND: usize = 64;
5337    if sections.len() < 2 {
5338        ogeom_bail!(
5339            Construction,
5340            "a multisection pipe needs at least two sections"
5341        );
5342    }
5343    let law = if frenet {
5344        PipeLaw::Frenet
5345    } else {
5346        PipeLaw::RotationMinimizing
5347    };
5348    let stations = shell_stations(model, spine, tol)?;
5349    let stations = evenly(model, spine, stations, 24, false, tol)?;
5350    let normals = law_normals(model, &stations, &law, tolerance, tol)?;
5351    // Length run along the spine at each station.
5352    let mut run = vec![0.0_f64];
5353    for pair in stations.windows(2) {
5354        let held = run[run.len() - 1];
5355        run.push(held + pair[0].at.distance(pair[1].at));
5356    }
5357    // Each section: where along the spine it stands, and its samples in
5358    // the frame there.
5359    // Each section where it stands along the spine, read by arc length in
5360    // its own frame, and its samples there for the next to match.
5361    let mut placed: Vec<(f64, Section, Vec<Point>)> = Vec::with_capacity(sections.len());
5362    // A point section: where it stands, and whether at the spine's end.
5363    let mut apex: Option<(Point, bool)> = None;
5364    // The last section's ring read, for the straight pipe to a point.
5365    let mut last_ring: Option<Shape> = None;
5366    for (index, section) in sections.iter().enumerate() {
5367        if model.kind_of(section)? == ShapeType::Vertex {
5368            let Some(data) = model.node(section).and_then(|n| n.data().as_vertex()) else {
5369                ogeom_bail!(Dangling, "vertex is not in this model");
5370            };
5371            let point = section.transform(model.datums())?.apply(data.point);
5372            let reach = tolerance.max(tol.confusion() * 1e3);
5373            let (Some(head), Some(tail)) = (stations.first(), stations.last()) else {
5374                ogeom_bail!(Construction, "the spine has no stations");
5375            };
5376            let at_end = if index + 1 == sections.len() && point.distance(tail.at) <= reach {
5377                true
5378            } else if index == 0 && point.distance(head.at) <= reach {
5379                false
5380            } else {
5381                ogeom_bail!(
5382                    Construction,
5383                    "a point section stands first or last, where the spine \
5384                     starts or ends"
5385                );
5386            };
5387            apex = Some((point, at_end));
5388            // Every sample at the frame's origin: the section shrinks onto
5389            // the spine there.
5390            let along = if at_end { run[run.len() - 1] } else { 0.0 };
5391            placed.push((
5392                along,
5393                Section::Point(Point::ORIGIN),
5394                vec![Point::ORIGIN; AROUND],
5395            ));
5396            continue;
5397        }
5398        let ring = match model.kind_of(section)? {
5399            ShapeType::Face => {
5400                let rings = model.ordered_children_of(section)?;
5401                if rings.len() != 1 {
5402                    ogeom_bail!(Construction, "a multisection pipe's section has no hole");
5403                }
5404                rings[0].clone()
5405            }
5406            ShapeType::Wire => section.clone(),
5407            _ => ogeom_bail!(
5408                Construction,
5409                "a section is a planar face or wire, or a point at an end"
5410            ),
5411        };
5412        let Some(plane) = ogeom_algo::find_plane(model, &ring, tol)? else {
5413            ogeom_bail!(Construction, "a section is not planar");
5414        };
5415        last_ring = Some(ring.clone());
5416        let side = |p: Point| (p - plane.origin()).dot(plane.normal().vector());
5417        let mut found: Option<(f64, usize, f64)> = None;
5418        for i in 0..stations.len() {
5419            let here = side(stations[i].at);
5420            let crossing = if here.abs() <= tol.confusion() {
5421                Some((run[i], i, 0.0))
5422            } else if let Some(next) = stations.get(i + 1) {
5423                let there = side(next.at);
5424                (here.signum() != there.signum() && there.abs() > tol.confusion()).then(|| {
5425                    let f = here / (here - there);
5426                    (run[i] + (run[i + 1] - run[i]) * f, i, f)
5427                })
5428            } else {
5429                None
5430            };
5431            if crossing.is_some() {
5432                found = crossing;
5433                break;
5434            }
5435        }
5436        let Some((along, i, f)) = found else {
5437            ogeom_bail!(Construction, "the spine does not cross a section's plane");
5438        };
5439        // The frame there, between the two stations it falls between.
5440        let j = (i + 1).min(stations.len() - 1);
5441        let at = stations[i].at + (stations[j].at - stations[i].at) * f;
5442        let tangent = stations[i].tangent * (1.0 - f) + stations[j].tangent * f;
5443        let normal = normals[i] * (1.0 - f) + normals[j] * f;
5444        let normal = normal - tangent * normal.dot(tangent) / tangent.dot(tangent);
5445        let frame = Frame::new(
5446            at,
5447            Direction::new(tangent, tol)?,
5448            Direction::new(normal, tol)?,
5449            tol,
5450        )?;
5451        let into = Transform::to_frame(&frame);
5452        let dense: Vec<Point> = dense_wire(model, &ring, tol)?
5453            .iter()
5454            .map(|p| into.apply(*p))
5455            .collect();
5456        let around = match placed.last() {
5457            Some((_, _, previous)) => matched_loop(&dense, AROUND, previous),
5458            None => ArcLoop::new(dense),
5459        };
5460        let samples = fractions(AROUND)[..AROUND]
5461            .iter()
5462            .map(|f| around.at(*f))
5463            .collect();
5464        placed.push((along, Section::Loop(around), samples));
5465    }
5466    for pair in placed.windows(2) {
5467        if pair[1].0 <= pair[0].0 + tol.confusion() {
5468            ogeom_bail!(
5469                Construction,
5470                "the sections stand out of order along the spine"
5471            );
5472        }
5473    }
5474    // One section closing to a point down a straight spine: every sample
5475    // runs straight to the point, which is the exact pyramid or cone over
5476    // the section.
5477    if let (Some((point, _)), Some(ring), 2) = (apex, &last_ring, sections.len())
5478        && straight_legs(model, spine, tol)?.is_some_and(|legs| legs.len() == 1)
5479    {
5480        let tip = model.add_vertex(VertexData::new(point));
5481        let mut built = make_loft(model, ring, &tip, tol)?;
5482        built.history.generate(spine, built.shape.clone());
5483        for section in sections {
5484            built.history.generate(section, built.shape.clone());
5485        }
5486        return Ok(built);
5487    }
5488    // A ring at every station from the first section's to the last's, the
5489    // two sections either side blended by the length between them.
5490    let (first, last) = (placed[0].0, placed[placed.len() - 1].0);
5491    let mut rings: Vec<Shape> = Vec::new();
5492    let mut ring_at = |model: &mut Model, along: f64, frame: &Frame| -> OgeomResult<()> {
5493        let k = placed
5494            .windows(2)
5495            .position(|w| along <= w[1].0 + tol.confusion())
5496            .unwrap_or(placed.len() - 2);
5497        let (a, b) = (&placed[k], &placed[k + 1]);
5498        let f = ((along - a.0) / (b.0 - a.0)).clamp(0.0, 1.0);
5499        let out = Transform::from_frame(frame);
5500        // The blend round at a fraction of each section's length, held to
5501        // its share of the tolerance between the samples as well as at
5502        // them.
5503        let target = tolerance * 0.1;
5504        let fitted = ogeom_geom::fit::fit_curve_sampled(
5505            |g| {
5506                let (p, q) = (a.1.at(g), b.1.at(g));
5507                Ok(out.apply(p + (q - p) * f))
5508            },
5509            &fractions(AROUND),
5510            true,
5511            3,
5512            target,
5513            tol,
5514        )?;
5515        if !fitted.met {
5516            ogeom_bail!(
5517                NotDone,
5518                "a blended section reached {} against a target of {target}",
5519                fitted.error
5520            );
5521        }
5522        let curve: ogeom_geom::Curve = fitted.curve.into();
5523        let range = curve.domain();
5524        let edge = ogeom_algo::make_edge(model, curve, range, tol)?.shape;
5525        rings.push(ogeom_algo::make_wire(model, &[edge], tol)?.shape);
5526        Ok(())
5527    };
5528    let frame_at = |i: usize| station_frame(&stations[i], normals[i], tol);
5529    // The first section's own station, the stations strictly between, and
5530    // the last section's.
5531    let at_along = |along: f64| -> OgeomResult<Frame> {
5532        let i = run
5533            .windows(2)
5534            .position(|w| along <= w[1] + tol.confusion())
5535            .unwrap_or(run.len() - 2);
5536        let f = ((along - run[i]) / (run[i + 1] - run[i]).max(f64::MIN_POSITIVE)).clamp(0.0, 1.0);
5537        let at = stations[i].at + (stations[i + 1].at - stations[i].at) * f;
5538        let tangent = stations[i].tangent * (1.0 - f) + stations[i + 1].tangent * f;
5539        let normal = normals[i] * (1.0 - f) + normals[i + 1] * f;
5540        let normal = normal - tangent * normal.dot(tangent) / tangent.dot(tangent);
5541        Frame::new(
5542            at,
5543            Direction::new(tangent, tol)?,
5544            Direction::new(normal, tol)?,
5545            tol,
5546        )
5547    };
5548    // The end a point section stands at has no ring: the skin closes on the
5549    // point itself, which the loft takes last.
5550    if apex.is_none_or(|(_, at_end)| at_end) {
5551        ring_at(model, first, &at_along(first)?)?;
5552    }
5553    for (i, &along) in run.iter().enumerate() {
5554        if along > first + tol.confusion() && along < last - tol.confusion() {
5555            ring_at(model, along, &frame_at(i)?)?;
5556        }
5557    }
5558    if apex.is_none_or(|(_, at_end)| !at_end) {
5559        ring_at(model, last, &at_along(last)?)?;
5560    }
5561    if let Some((point, at_end)) = apex {
5562        if !at_end {
5563            rings.reverse();
5564        }
5565        rings.push(model.add_vertex(VertexData::new(point)));
5566    }
5567    let mut built = make_loft_skinned(model, &rings, tolerance, tol)?;
5568    built.history.generate(spine, built.shape.clone());
5569    for section in sections {
5570        built.history.generate(section, built.shape.clone());
5571    }
5572    Ok(built)
5573}
5574
5575/// Stations added so every edge of the spine holds at least `count`
5576/// stations evenly along its parameter, besides the ones it has.
5577fn evenly(
5578    model: &Model,
5579    spine: &Shape,
5580    stations: Vec<SpineStation>,
5581    count: u32,
5582    keep: bool,
5583    tol: Tolerances,
5584) -> OgeomResult<Vec<SpineStation>> {
5585    evenly_by(model, spine, stations, &|_| count, keep, tol)
5586}
5587
5588/// [`evenly`], with each edge's own count: `count(e)` for edge `e`.
5589fn evenly_by(
5590    model: &Model,
5591    spine: &Shape,
5592    stations: Vec<SpineStation>,
5593    count: &dyn Fn(usize) -> u32,
5594    keep: bool,
5595    tol: Tolerances,
5596) -> OgeomResult<Vec<SpineStation>> {
5597    let edges: Vec<Shape> = match model.kind_of(spine)? {
5598        ShapeType::Edge => vec![spine.clone()],
5599        ShapeType::Wire => model.ordered_children_of(spine)?,
5600        _ => return Ok(stations),
5601    };
5602    let mut out: Vec<SpineStation> = Vec::with_capacity(stations.len() + edges.len() * 32);
5603    for (e, edge) in edges.iter().enumerate() {
5604        let on: Vec<SpineStation> = stations.iter().copied().filter(|s| s.edge == e).collect();
5605        let (Some(first), Some(last)) = (on.first().copied(), on.last().copied()) else {
5606            continue;
5607        };
5608        let (curve, _) = spine_curve_of(model, edge)?;
5609        let sense = if curve.d1_at(first.t, tol)?.dot(first.tangent) >= 0.0 {
5610            1.0
5611        } else {
5612            -1.0
5613        };
5614        let mut ts: Vec<f64> = if keep {
5615            on.iter().map(|s| s.t).collect()
5616        } else {
5617            vec![first.t, last.t]
5618        };
5619        let count = count(e);
5620        for k in 1..count {
5621            ts.push(first.t + (last.t - first.t) * f64::from(k) / f64::from(count));
5622        }
5623        ts.sort_by(|a, b| {
5624            if first.t <= last.t {
5625                a.total_cmp(b)
5626            } else {
5627                b.total_cmp(a)
5628            }
5629        });
5630        ts.dedup_by(|a, b| (*a - *b).abs() <= tol.parametric());
5631        for t in ts {
5632            let d = curve.d1_at(t, tol)?;
5633            out.push(SpineStation {
5634                at: curve.point_at(t, tol)?,
5635                tangent: d * sense / d.magnitude(),
5636                edge: e,
5637                t,
5638            });
5639        }
5640    }
5641    Ok(out)
5642}
5643
5644/// The frame normals a law gives at each station.
5645///
5646/// An auxiliary guide ending within `reach` of a station's plane (a
5647/// sketch's end in single precision) is taken to cross it, carried on
5648/// along its end tangent.
5649pub(crate) fn law_normals(
5650    model: &Model,
5651    stations: &[SpineStation],
5652    law: &PipeLaw<'_>,
5653    reach: f64,
5654    tol: Tolerances,
5655) -> OgeomResult<Vec<Vector>> {
5656    law_normals_on(&guide_curves(model, law)?, stations, law, reach, tol)
5657}
5658
5659/// An auxiliary law's guide, each edge's curve and range in the guide's
5660/// order; nothing for any other law.
5661fn guide_curves(
5662    model: &Model,
5663    law: &PipeLaw<'_>,
5664) -> OgeomResult<Vec<(ogeom_geom::Curve, (f64, f64))>> {
5665    let PipeLaw::Auxiliary { guide } = law else {
5666        return Ok(Vec::new());
5667    };
5668    let edges: Vec<Shape> = match model.kind_of(guide)? {
5669        ShapeType::Edge => vec![(*guide).clone()],
5670        ShapeType::Wire => model.ordered_children_of(guide)?,
5671        _ => ogeom_bail!(Construction, "an auxiliary spine is an edge or a wire"),
5672    };
5673    let mut curves = Vec::with_capacity(edges.len());
5674    for edge in &edges {
5675        curves.push(spine_curve_of(model, edge)?);
5676    }
5677    Ok(curves)
5678}
5679
5680/// [`law_normals`], an auxiliary law's guide read already
5681/// ([`guide_curves`]).
5682fn law_normals_on(
5683    curves: &[(ogeom_geom::Curve, (f64, f64))],
5684    stations: &[SpineStation],
5685    law: &PipeLaw<'_>,
5686    reach: f64,
5687    tol: Tolerances,
5688) -> OgeomResult<Vec<Vector>> {
5689    match law {
5690        // Carried by translation, the section has no frame turning with the
5691        // spine; the fixed pipe is built without stations.
5692        PipeLaw::Fixed => ogeom_bail!(
5693            Construction,
5694            "a fixed section keeps its own frame; it has no frame normals"
5695        ),
5696        PipeLaw::RotationMinimizing => Ok(rmf_normals(stations)),
5697        PipeLaw::Frenet => frenet_normals(stations, tol),
5698        PipeLaw::Binormal(b) => stations
5699            .iter()
5700            .map(|s| {
5701                let t = s.tangent;
5702                let b = b.vector() - t * b.vector().dot(t);
5703                if b.magnitude() <= tol.angular() {
5704                    ogeom_bail!(
5705                        Construction,
5706                        "the spine runs along the binormal at {:?}; no frame keeps it",
5707                        s.at
5708                    );
5709                }
5710                let n = (b / b.magnitude()).cross(t);
5711                Ok(n / n.magnitude())
5712            })
5713            .collect(),
5714        PipeLaw::Auxiliary { .. } => {
5715            let mut out = Vec::with_capacity(stations.len());
5716            let mut last: Option<Point> = None;
5717            for s in stations {
5718                let (p, t) = (s.at, s.tangent);
5719                // Where the guide crosses this station's plane: a sign
5720                // change of the height along each guide edge, refined; the
5721                // crossing nearest the last one found.
5722                let mut best: Option<Point> = None;
5723                for (curve, range) in curves {
5724                    const STEPS: u32 = 256;
5725                    let height = |u: f64| -> OgeomResult<(f64, Point)> {
5726                        let q = curve.point_at(u, tol)?;
5727                        Ok(((q - p).dot(t), q))
5728                    };
5729                    let at =
5730                        |k: u32| range.0 + (range.1 - range.0) * f64::from(k) / f64::from(STEPS);
5731                    let mut prev = height(at(0))?;
5732                    for k in 1..=STEPS {
5733                        let here = height(at(k))?;
5734                        if prev.0 == 0.0 || prev.0.signum() != here.0.signum() {
5735                            // A sample on the plane is the crossing itself.
5736                            let q = if prev.0 == 0.0 {
5737                                prev.1
5738                            } else {
5739                                let (mut lo, mut hi) = (at(k - 1), at(k));
5740                                let mut f_lo = prev.0;
5741                                for _ in 0..60 {
5742                                    let mid = f64::midpoint(lo, hi);
5743                                    let (f_mid, _) = height(mid)?;
5744                                    if f_mid.signum() == f_lo.signum() {
5745                                        lo = mid;
5746                                        f_lo = f_mid;
5747                                    } else {
5748                                        hi = mid;
5749                                    }
5750                                }
5751                                height(f64::midpoint(lo, hi))?.1
5752                            };
5753                            let near = last.unwrap_or(p);
5754                            if best.is_none_or(|b| q.distance(near) < b.distance(near)) {
5755                                best = Some(q);
5756                            }
5757                        }
5758                        prev = here;
5759                    }
5760                }
5761                if best.is_none() {
5762                    let near = last.unwrap_or(p);
5763                    for (curve, range) in curves {
5764                        for u in [range.0, range.1] {
5765                            let q = curve.point_at(u, tol)?;
5766                            let h = (q - p).dot(t);
5767                            if h.abs() > reach {
5768                                continue;
5769                            }
5770                            let d = curve.d1_at(u, tol)?;
5771                            let along = d.dot(t);
5772                            let onto = if along.abs() > tol.angular() * d.magnitude() {
5773                                q - d * (h / along)
5774                            } else {
5775                                q
5776                            };
5777                            // A tangent nearly in the plane carries the end
5778                            // far; the end itself is then as good.
5779                            let q = if onto.distance(q) <= 2.0 * reach {
5780                                onto
5781                            } else {
5782                                q
5783                            };
5784                            if best.is_none_or(|b| q.distance(near) < b.distance(near)) {
5785                                best = Some(q);
5786                            }
5787                        }
5788                    }
5789                }
5790                let Some(q) = best else {
5791                    ogeom_bail!(
5792                        Construction,
5793                        "the auxiliary spine does not cross the plane square to the \
5794                         spine at {p:?}"
5795                    );
5796                };
5797                last = Some(q);
5798                let toward = (q - p) - t * (q - p).dot(t);
5799                if toward.magnitude() <= tol.confusion() {
5800                    ogeom_bail!(
5801                        Construction,
5802                        "the auxiliary spine meets the spine at {p:?}; no direction \
5803                         points at it"
5804                    );
5805                }
5806                out.push(toward / toward.magnitude());
5807            }
5808            Ok(out)
5809        }
5810    }
5811}
5812
5813/// The pipe shell under a frame law, mitred at corners.
5814#[allow(clippy::too_many_lines)]
5815fn pipe_shell_law(
5816    model: &mut Model,
5817    profile: &Shape,
5818    spine: &Shape,
5819    law: &PipeLaw<'_>,
5820    tolerance: f64,
5821    tol: Tolerances,
5822) -> OgeomResult<Built> {
5823    const AROUND: usize = 40;
5824    let frenet = matches!(law, PipeLaw::Frenet);
5825    let classic = matches!(law, PipeLaw::RotationMinimizing | PipeLaw::Frenet);
5826
5827    if classic && let Some(exact) = exact_legs(model, profile, spine, frenet, tol)? {
5828        return Ok(exact);
5829    }
5830    let stations = shell_stations(model, spine, tol)?;
5831    let stations = if classic {
5832        stations
5833    } else {
5834        // As many stations as the law's turning asks, spread evenly: the
5835        // skin takes its sections at even steps of its own parameter.
5836        let wanted = densified(model, spine, stations.clone(), law, tolerance, tol)?.len();
5837        // Shared out along the spine by length, so the spacing is the same
5838        // on every edge: the skin steps evenly along its whole run.
5839        let mut lengths: Vec<f64> = Vec::new();
5840        for pair in stations.windows(2) {
5841            let e = pair[1].edge;
5842            if lengths.len() <= e {
5843                lengths.resize(e + 1, 0.0);
5844            }
5845            if pair[0].edge == e {
5846                lengths[e] += pair[0].at.distance(pair[1].at);
5847            }
5848        }
5849        let total: f64 = lengths.iter().sum();
5850        #[allow(
5851            clippy::cast_precision_loss,
5852            clippy::cast_possible_truncation,
5853            clippy::cast_sign_loss
5854        )]
5855        let share = |e: usize| -> u32 {
5856            let length = lengths.get(e).copied().unwrap_or(0.0);
5857            if total <= 0.0 {
5858                return 2;
5859            }
5860            ((wanted as f64 * length / total).ceil() as u32).max(2)
5861        };
5862        evenly_by(model, spine, stations, &share, false, tol)?
5863    };
5864    // Corners: twin stations standing on one point with different headings.
5865    let corners: Vec<usize> = (0..stations.len() - 1)
5866        .filter(|&i| {
5867            stations[i].at.distance(stations[i + 1].at) <= tol.confusion()
5868                && (stations[i]
5869                    .tangent
5870                    .cross(stations[i + 1].tangent)
5871                    .magnitude()
5872                    > tol.angular()
5873                    || stations[i].tangent.dot(stations[i + 1].tangent) < 0.0)
5874        })
5875        .collect();
5876    // Every twin, corners and smooth junctions alike: each ends one run of
5877    // skin and starts the next, the two runs sharing the section there. A
5878    // smooth junction's mitre plane is its own section, so nothing shears.
5879    let kinks: Vec<usize> = (0..stations.len() - 1)
5880        .filter(|&i| stations[i].at.distance(stations[i + 1].at) <= tol.confusion())
5881        .collect();
5882    let ring = stations[0].at.distance(stations[stations.len() - 1].at) <= tol.confusion() * 10.0;
5883    if !classic && (ring || !corners.is_empty()) {
5884        ogeom_bail!(
5885            Construction,
5886            "an auxiliary or binormal law sweeps an open spine with no sharp corner"
5887        );
5888    }
5889    if !classic {
5890        return law_loft(model, profile, spine, &stations, law, tolerance, tol);
5891    }
5892    if ring && kinks.is_empty() {
5893        return closed_pipe_shell(model, profile, spine, stations, frenet, tolerance, tol);
5894    }
5895    // The cornered ring closes at its own wrap. Seamed on a corner, the
5896    // wrap is one more mitre; seamed mid-leg, the wrap's "mitre" plane is
5897    // the leg's own cross-section (both twin tangents are the leg's), and
5898    // the shear onto it moves nothing, so the two halves of that leg butt
5899    // together on the seam's own ring, coplanar walls meeting on it. The
5900    // solid is exact either way; the mid-leg seam merely leaves its leg in
5901    // two pieces.
5902    if frenet && !corners.is_empty() {
5903        ogeom_bail!(
5904            Construction,
5905            "a Frenet frame has no direction at a corner; sweep a cornered \
5906             spine with the rotation-minimizing frame"
5907        );
5908    }
5909    let normals = law_normals(model, &stations, law, tolerance, tol)?;
5910    // A ring's frame must come home: carry once more across the wrap
5911    // corner, read the twist between departure and return, and spread it
5912    // along the arc: the smooth loop's own reconciliation, ending at a
5913    // mitre instead of a tangent join.
5914    let normals = if ring {
5915        let mut extended = stations.clone();
5916        extended.push(stations[0]);
5917        let carried = rmf_normals(&extended);
5918        let (n0, n_home) = (carried[0], carried[carried.len() - 1]);
5919        let t0 = stations[0].tangent;
5920        let twist = (n0.cross(n_home).dot(t0)).atan2(n0.dot(n_home));
5921        let mut lengths = vec![0.0_f64];
5922        for pair in extended.windows(2) {
5923            let held = lengths[lengths.len() - 1];
5924            lengths.push(held + pair[0].at.distance(pair[1].at));
5925        }
5926        let total = lengths[lengths.len() - 1];
5927        carried
5928            .iter()
5929            .take(stations.len())
5930            .enumerate()
5931            .map(|(i, n)| {
5932                let phi = -twist * lengths[i] / total;
5933                let t = extended[i].tangent;
5934                let v = *n * phi.cos() + t.cross(*n) * phi.sin();
5935                let v = v - t * v.dot(t);
5936                v / v.magnitude()
5937            })
5938            .collect()
5939    } else {
5940        normals
5941    };
5942    // At each corner both twin sections are thrown onto the bisector plane
5943    // along their own tangents; the mirror symmetry of the rotation-
5944    // minimizing frame lands them on one ring, the mitre both runs share.
5945    let mitre: Vec<Option<(Point, Vector)>> = {
5946        let mut out: Vec<Option<(Point, Vector)>> = vec![None; stations.len()];
5947        for &k in &kinks {
5948            let n = stations[k].tangent + stations[k + 1].tangent;
5949            if n.magnitude() <= tol.angular() {
5950                ogeom_bail!(
5951                    Construction,
5952                    "the spine doubles straight back on itself; no mitre \
5953                     plane divides that corner"
5954                );
5955            }
5956            out[k] = Some((stations[k].at, n));
5957            out[k + 1] = Some((stations[k + 1].at, n));
5958        }
5959        if ring {
5960            let wrap = stations.len() - 1;
5961            let n = stations[wrap].tangent + stations[0].tangent;
5962            if n.magnitude() <= tol.angular() {
5963                ogeom_bail!(
5964                    Construction,
5965                    "the spine doubles straight back on itself; no mitre \
5966                     plane divides that corner"
5967                );
5968            }
5969            out[wrap] = Some((stations[wrap].at, n));
5970            out[0] = Some((stations[0].at, n));
5971        }
5972        out
5973    };
5974    let runs: Vec<(usize, usize)> = {
5975        let mut out = Vec::with_capacity(kinks.len() + 1);
5976        let mut start = 0;
5977        for &k in &kinks {
5978            out.push((start, k));
5979            start = k + 1;
5980        }
5981        out.push((start, stations.len() - 1));
5982        out
5983    };
5984    // A mitred end between straight legs is a *shear*: the honest wall is
5985    // the run's own surface trimmed by the mitre plane, which for a
5986    // straight leg is exactly the ruled skin between its two end rings.
5987    // Under a law that turns the section along a straight leg, the leg's
5988    // wall is no ruled skin between its end rings.
5989    let straight = |rs: usize, re: usize| -> bool {
5990        if !classic {
5991            return false;
5992        }
5993        let t0 = stations[rs].tangent;
5994        (rs..=re).all(|i| stations[i].tangent.cross(t0).magnitude() <= tol.angular())
5995    };
5996    // Every corner as the pair of runs it stands between, the wrap
5997    // included, and whether either leg is curved. A curved leg's trim is
5998    // not a loft of its rows: the two legs' generators for one profile
5999    // point are followed (the leg's own curve, run straight on past the
6000    // corner) to where they meet, and each wall ends on that crossing.
6001    // Where the corner turns in the plane the crossing is exact; a skew
6002    // corner's generators miss each other, and that miss is refused.
6003    struct CornerPair {
6004        before: (usize, usize),
6005        after: (usize, usize),
6006        curved: bool,
6007    }
6008    let corner_pairs: Vec<CornerPair> = {
6009        let mut out = Vec::new();
6010        // A smooth junction joins its runs on their shared section; only a
6011        // corner that turns asks the generators where the walls meet.
6012        for pair in runs.windows(2) {
6013            out.push(CornerPair {
6014                before: pair[0],
6015                after: pair[1],
6016                curved: corners.contains(&pair[0].1)
6017                    && (!straight(pair[0].0, pair[0].1) || !straight(pair[1].0, pair[1].1)),
6018            });
6019        }
6020        if ring && runs.len() > 1 {
6021            let (before, after) = (runs[runs.len() - 1], runs[0]);
6022            let turns = stations[before.1]
6023                .tangent
6024                .cross(stations[after.0].tangent)
6025                .magnitude()
6026                > tol.angular()
6027                || stations[before.1].tangent.dot(stations[after.0].tangent) < 0.0;
6028            out.push(CornerPair {
6029                before,
6030                after,
6031                curved: turns && (!straight(before.0, before.1) || !straight(after.0, after.1)),
6032            });
6033        }
6034        out
6035    };
6036    let walk = SpineWalk {
6037        curves: walk_curves(model, spine)?,
6038        stations: &stations,
6039        normals: &normals,
6040    };
6041    let join_reach = tolerance.max(tol.confusion() * 100.0);
6042    let curved_join = |pair: &CornerPair, ab: (f64, f64)| -> OgeomResult<CornerJoin> {
6043        let join = walk.join(pair.before, pair.after, ab, tol)?;
6044        if join.gap > join_reach {
6045            ogeom_bail!(
6046                Construction,
6047                "a skew corner against a curved leg is still owed its frame \
6048                 law: the legs' generators miss by {}; see docs/PARITY.md, \
6049                 offset.sweeps",
6050                join.gap
6051            );
6052        }
6053        Ok(join)
6054    };
6055    // Across a curved corner the two legs' skins share the join row as one
6056    // edge: a run adopts the previous run's end row at its start, and the
6057    // last run of a cornered ring adopts the first run's start row at its
6058    // end.
6059    let shares_start = |ri: usize| -> bool {
6060        ri > 0
6061            && corner_pairs
6062                .iter()
6063                .any(|pair| pair.curved && pair.after == runs[ri])
6064    };
6065    let shares_end = |ri: usize| -> bool {
6066        ring && ri + 1 == runs.len()
6067            && corner_pairs
6068                .iter()
6069                .any(|pair| pair.curved && pair.before == runs[ri] && pair.after == runs[0])
6070    };
6071    // The curved corner a station is a twin of, if any.
6072    let curved_at = |i: usize| -> Option<&CornerPair> {
6073        corner_pairs
6074            .iter()
6075            .find(|pair| pair.curved && (pair.before.1 == i || pair.after.0 == i))
6076    };
6077
6078    // The profile's loops: a face contributes every wire, holes included;
6079    // a bare wire is one loop.
6080    let loops: Vec<Shape> = match model.kind_of(profile)? {
6081        ShapeType::Face => explore(model, profile, Filter::OfType(ShapeType::Wire))?,
6082        ShapeType::Wire => vec![profile.clone()],
6083        other => ogeom_bail!(
6084            Construction,
6085            "a pipe shell sweeps a planar wire or face, not a {other:?}"
6086        ),
6087    };
6088    if loops.is_empty() {
6089        ogeom_bail!(Construction, "the profile has no loop to sweep");
6090    }
6091    let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
6092        ogeom_bail!(Construction, "a pipe shell sweeps a planar profile");
6093    };
6094    let t0 = stations[0].tangent;
6095    // Square to the spine's own start tangent, read exactly off its first
6096    // edge, to an angle's tolerance.
6097    let exact_t0 = {
6098        let first = match model.kind_of(spine)? {
6099            ShapeType::Edge => spine.clone(),
6100            _ => model.ordered_children_of(spine)?[0].clone(),
6101        };
6102        let (curve, range) = spine_curve_of(model, &first)?;
6103        let reversed = first.orientation() == ogeom_topo::Orientation::Reversed;
6104        let d = curve.d1_at(if reversed { range.1 } else { range.0 }, tol)?;
6105        let d = if reversed { -d } else { d };
6106        d / d.magnitude()
6107    };
6108    if plane.normal().vector().cross(exact_t0).magnitude() > tol.angular() {
6109        ogeom_bail!(
6110            Construction,
6111            "the profile leans along its spine; a pipe shell runs square to \
6112             the start"
6113        );
6114    }
6115    if plane.distance_to(stations[0].at) > tol.confusion() * 100.0 {
6116        ogeom_bail!(
6117            Construction,
6118            "the profile does not sit at the spine's start"
6119        );
6120    }
6121
6122    // Transport: each loop expressed in the start frame's own 2D
6123    // coordinates, then re-expressed in every station's frame. A loop of one
6124    // smooth closed edge skins as one wall; a faceted loop skins one strip
6125    // per edge, cornered at shared vertices, because no single fit can
6126    // speak a corner.
6127    let x0 = normals[0];
6128    let y0 = t0.cross(x0);
6129    let origin = stations[0].at;
6130    let flat = |p: Point| -> (f64, f64) { ((p - origin).dot(x0), (p - origin).dot(y0)) };
6131    // A skew corner against a curved leg: the legs' generators miss, so
6132    // no join row closes the walls. Such a corner is a mitre instead: the
6133    // spine is split there, each side swept on straight past the corner
6134    // and trimmed by the mitre plane, and the pieces fused.
6135    let skew: Vec<(usize, usize)> = {
6136        let mut probes: Vec<(f64, f64)> = Vec::new();
6137        for wire in &loops {
6138            probes.extend(sample_wire(model, wire, AROUND, tol)?.into_iter().map(flat));
6139        }
6140        let mut out = Vec::new();
6141        for pair in corner_pairs.iter().filter(|pair| pair.curved) {
6142            let mut worst = 0.0_f64;
6143            for ab in &probes {
6144                worst = worst.max(walk.join(pair.before, pair.after, *ab, tol)?.gap);
6145            }
6146            if worst > join_reach {
6147                out.push((pair.before.1, pair.after.0));
6148            }
6149        }
6150        out
6151    };
6152    if !skew.is_empty() {
6153        let probes: Vec<(f64, f64)> = {
6154            let mut out = Vec::new();
6155            for wire in &loops {
6156                out.extend(sample_wire(model, wire, AROUND, tol)?.into_iter().map(flat));
6157            }
6158            out
6159        };
6160        return mitred_pieces(
6161            model, profile, spine, &stations, &skew, ring, &probes, tolerance, tol,
6162        );
6163    }
6164    let place = |i: usize, (a, b): (f64, f64)| -> OgeomResult<Point> {
6165        if let Some(pair) = curved_at(i) {
6166            return Ok(curved_join(pair, (a, b))?.at);
6167        }
6168        let x = normals[i];
6169        let y = stations[i].tangent.cross(x);
6170        let p = stations[i].at + x * a + y * b;
6171        Ok(match mitre[i] {
6172            Some((corner, n)) => {
6173                let t = stations[i].tangent;
6174                p + t * ((corner - p).dot(n) / t.dot(n))
6175            }
6176            None => p,
6177        })
6178    };
6179    // One profile point's generator along a re-rowed run: its parameter
6180    // against the length run from the column's start, and the whole length.
6181    struct RerowColumn {
6182        along: Vec<(f64, f64)>,
6183        length: f64,
6184    }
6185    // Where one profile point's generator on `run` meets the next leg's (at
6186    // its end) or the previous leg's (at its start), at a curved corner.
6187    let crossing =
6188        |run: (usize, usize), ab: (f64, f64), at_start: bool| -> OgeomResult<Option<CornerJoin>> {
6189            let pair = corner_pairs.iter().find(|pair| {
6190                pair.curved
6191                    && if at_start {
6192                        pair.after == run
6193                    } else {
6194                        pair.before == run
6195                    }
6196            });
6197            pair.map(|pair| curved_join(pair, ab)).transpose()
6198        };
6199    // Each end of one profile point's column on `run`: the crossing where
6200    // the run meets a curved corner there, else the end station. A crossing
6201    // past the middle of the run means the leg is shorter than the corner's
6202    // reach.
6203    let column_ends = |(rs, re): (usize, usize),
6204                       ab: (f64, f64)|
6205     -> OgeomResult<(Option<CornerJoin>, Option<CornerJoin>)> {
6206        let run = (rs, re);
6207        let (start, end) = (crossing(run, ab, true)?, crossing(run, ab, false)?);
6208        #[allow(clippy::cast_precision_loss)]
6209        let (rsf, ref_) = (rs as f64, re as f64);
6210        if start.as_ref().is_some_and(|j| j.s2 >= ref_ - 0.5)
6211            || end.as_ref().is_some_and(|j| j.s1 <= rsf + 0.5)
6212        {
6213            ogeom_bail!(
6214                Construction,
6215                "a leg is shorter than its corner's reach; the mitre would \
6216                 run off its far end"
6217            );
6218        }
6219        Ok((start, end))
6220    };
6221    // A straight run's column is its two ends, ruled: the trimmed prism
6222    // itself, whether an end is sheared onto a mitre plane or stands on a
6223    // curved corner's crossing.
6224    let straight_column = |(rs, re): (usize, usize), ab: (f64, f64)| -> OgeomResult<Vec<Point>> {
6225        let (start, end) = column_ends((rs, re), ab)?;
6226        Ok(vec![
6227            match start {
6228                Some(j) => j.at,
6229                None => place(rs, ab)?,
6230            },
6231            match end {
6232                Some(j) => j.at,
6233                None => place(re, ab)?,
6234            },
6235        ])
6236    };
6237    // A curved run with a crossing at either end is re-rowed whole: every
6238    // column runs its own generator from its start to its end, read at
6239    // fractions of its own arc length, so the skin's parameter across is
6240    // one for all columns. A column's pace differs between the leg's curve
6241    // and its straight extension, and a stretch skewed only near the corner
6242    // would pace each column differently. The skin is known everywhere
6243    // along its columns, so it is checked between its rows as well.
6244    let rerow_column = |(rs, re): (usize, usize), ab: (f64, f64)| -> OgeomResult<RerowColumn> {
6245        let run = (rs, re);
6246        let (start, end) = column_ends(run, ab)?;
6247        #[allow(clippy::cast_precision_loss)]
6248        let lo = start.map_or(rs as f64, |j| j.s2);
6249        #[allow(clippy::cast_precision_loss)]
6250        let hi = end.map_or(re as f64, |j| j.s1);
6251        // The length is read in eighths of a station on the stations' own
6252        // grid, so the run's end station, where the leg's curve gives way to
6253        // its straight extension and a generator off the spine changes its
6254        // pace, is a step's end. Read across it, one step would mix the two
6255        // paces and kink the column's parameter, which a cubic fit cannot
6256        // follow.
6257        let mut nodes: Vec<f64> = vec![lo];
6258        #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
6259        {
6260            let mut m = (lo * 8.0).floor() as i64 + 1;
6261            while (m as f64) / 8.0 < hi {
6262                let sp = (m as f64) / 8.0;
6263                if sp - lo > 1e-9 && hi - sp > 1e-9 {
6264                    nodes.push(sp);
6265                }
6266                m += 1;
6267            }
6268        }
6269        nodes.push(hi);
6270        let mut along: Vec<(f64, f64)> = Vec::with_capacity(nodes.len());
6271        let mut prev: Option<Point> = None;
6272        let mut length = 0.0;
6273        for sp in nodes {
6274            let p = walk.generator(sp, run, ab, tol)?;
6275            if let Some(q) = prev {
6276                length += q.distance(p);
6277            }
6278            along.push((length, sp));
6279            prev = Some(p);
6280        }
6281        Ok(RerowColumn { along, length })
6282    };
6283    // The point a fraction `f` of the way along a re-rowed column.
6284    let rerowed_at =
6285        |run: (usize, usize), column: &RerowColumn, ab: (f64, f64), f: f64| -> OgeomResult<Point> {
6286            let along = &column.along;
6287            let target = column.length * f;
6288            let at = along
6289                .partition_point(|(l, _)| *l < target)
6290                .clamp(1, along.len() - 1);
6291            let ((l0, s0), (l1, s1)) = (along[at - 1], along[at]);
6292            let g = if l1 > l0 {
6293                ((target - l0) / (l1 - l0)).clamp(0.0, 1.0)
6294            } else {
6295                0.0
6296            };
6297            walk.generator(s0 + (s1 - s0) * g, run, ab, tol)
6298        };
6299    // A re-rowed run's skin known everywhere: the point `s` stations' worth
6300    // of the way along the column of profile point `ab` at `u`, each column
6301    // built once into `held`.
6302    #[allow(clippy::type_complexity)]
6303    let rerowed_point = |(rs, re): (usize, usize),
6304                         held: &std::cell::RefCell<ogeom_core::FastMap<u64, RerowColumn>>,
6305                         u: f64,
6306                         ab: (f64, f64),
6307                         s: f64|
6308     -> OgeomResult<Point> {
6309        if !held.borrow().contains_key(&u.to_bits()) {
6310            let column = rerow_column((rs, re), ab)?;
6311            held.borrow_mut().insert(u.to_bits(), column);
6312        }
6313        let held = held.borrow();
6314        #[allow(clippy::cast_precision_loss)]
6315        let f = (s - rs as f64) / (re - rs) as f64;
6316        rerowed_at((rs, re), &held[&u.to_bits()], ab, f)
6317    };
6318    // A curved run with no crossing at either end has its stations for
6319    // rows, and the frame carried between them places the profile anywhere
6320    // along it: its skin is checked between the stations too, its
6321    // parameter across even in the station index (an affine image of the
6322    // spine's own parameter along the run).
6323    let plain = |run: (usize, usize)| -> bool {
6324        !straight(run.0, run.1)
6325            && !corner_pairs
6326                .iter()
6327                .any(|pair| pair.curved && (pair.after == run || pair.before == run))
6328    };
6329    let along_run = |run: (usize, usize), s: f64, ab: (f64, f64)| -> OgeomResult<Point> {
6330        if s.fract() == 0.0 {
6331            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
6332            let i = s as usize;
6333            place(i, ab)
6334        } else {
6335            walk.generator(s, run, ab, tol)
6336        }
6337    };
6338    let last = stations.len() - 1;
6339
6340    enum LoopWall {
6341        Ring {
6342            ring0: Shape,
6343            ring1: Shape,
6344        },
6345        Chain {
6346            bottoms: Vec<Shape>,
6347            tops: Vec<Shape>,
6348        },
6349    }
6350    let mut faces: Vec<Shape> = Vec::new();
6351    let mut ends: Vec<LoopWall> = Vec::with_capacity(loops.len());
6352    for (li, wire) in loops.iter().enumerate() {
6353        let hole = li != 0;
6354        let edges = model.ordered_children_of(wire)?;
6355        let single_smooth = edges.len() == 1 && {
6356            let (curve, _) = spine_curve_of(model, &edges[0])?;
6357            !matches!(curve, ogeom_geom::Curve::Line(_))
6358                && ogeom_algo::edge_vertices(model, &edges[0])?.is_some_and(|(a, b)| a.is_same(&b))
6359        };
6360        if single_smooth {
6361            let profile_loop = section_loop(model, wire, None, tol)?;
6362            // One wall per smooth run: a fit across a corner speaks nothing,
6363            // and the twin stations put both runs' boundary rows on the one
6364            // mitred ring, where the sew joins them.
6365            let mut ring0: Option<Shape> = None;
6366            let mut ring1: Option<Shape> = None;
6367            for (ri, &(rs, re)) in runs.iter().enumerate() {
6368                // Each column the run's rows of one point of the profile,
6369                // read by arc length round it.
6370                let held = std::cell::RefCell::default();
6371                let skin = if plain((rs, re)) {
6372                    Skin::swept(
6373                        |f, s| along_run((rs, re), s, flat(profile_loop.at(f))),
6374                        fractions(AROUND),
6375                        (rs, re),
6376                        true,
6377                    )?
6378                } else if straight(rs, re) {
6379                    Skin::columns(
6380                        |f| straight_column((rs, re), flat(profile_loop.at(f))),
6381                        fractions(AROUND),
6382                        true,
6383                        false,
6384                        false,
6385                    )?
6386                } else {
6387                    Skin::swept(
6388                        |f, s| rerowed_point((rs, re), &held, f, flat(profile_loop.at(f)), s),
6389                        fractions(AROUND),
6390                        (rs, re),
6391                        true,
6392                    )?
6393                };
6394                let shared_start = shares_start(ri).then_some(()).and(ring1.as_ref());
6395                let shared_end = shares_end(ri).then_some(()).and(ring0.as_ref());
6396                let wall = skinned_wall(model, &skin, (shared_start, shared_end), tolerance, tol)?;
6397                faces.push(if hole {
6398                    wall.face.reversed()
6399                } else {
6400                    wall.face.clone()
6401                });
6402                if ring0.is_none() {
6403                    ring0 = Some(wall.ring0);
6404                }
6405                ring1 = Some(wall.ring1);
6406            }
6407            let (Some(ring0), Some(ring1)) = (ring0, ring1) else {
6408                ogeom_bail!(Construction, "the sweep produced no wall");
6409            };
6410            ends.push(LoopWall::Ring { ring0, ring1 });
6411        } else {
6412            // Shared corner vertices at both ends of every edge junction.
6413            let count = edges.len();
6414            let mut corner_flat: Vec<(f64, f64)> = Vec::with_capacity(count);
6415            for edge in &edges {
6416                // Already in the ring's own sense: a reversed edge's
6417                // vertices come back end first.
6418                let Some((start, _)) = ogeom_algo::edge_vertices(model, edge)? else {
6419                    ogeom_bail!(Construction, "a profile edge has no vertices");
6420                };
6421                let Some(data) = model.node(&start).and_then(|n| n.data().as_vertex()) else {
6422                    ogeom_bail!(Construction, "a profile vertex holds no data");
6423                };
6424                corner_flat.push(flat(data.point));
6425            }
6426            let make_corners = |model: &mut Model, station: usize| -> OgeomResult<Vec<Shape>> {
6427                let mut out = Vec::with_capacity(corner_flat.len());
6428                for ab in &corner_flat {
6429                    out.push(ogeom_algo::make_vertex(model, place(station, *ab)?).shape);
6430                }
6431                Ok(out)
6432            };
6433            // Corner vertex sets at every run boundary; a kink's twin
6434            // stations land on the same mitred points, so both runs take
6435            // the same vertex objects.
6436            let mut corners_at: Vec<Option<Vec<Shape>>> = vec![None; stations.len()];
6437            if ring {
6438                // The wrap is one corner: both runs take the same vertex
6439                // objects. Every corner's two sheared sections must land on
6440                // one ring for the loop to close; a planar ring's do
6441                // exactly, and a skew ring's (whose parallel-carried frame
6442                // leaves the far tangent's plane) do not, so the residue
6443                // is measured and the skew ring refused by name rather
6444                // than sewn hoping.
6445                let mut worst = 0.0_f64;
6446                for ab in &corner_flat {
6447                    worst = worst.max(place(last, *ab)?.distance(place(0, *ab)?));
6448                    for &k in &kinks {
6449                        worst = worst.max(place(k, *ab)?.distance(place(k + 1, *ab)?));
6450                    }
6451                }
6452                if worst > tolerance.max(tol.confusion() * 100.0) {
6453                    ogeom_bail!(
6454                        Construction,
6455                        "a skew-cornered ring's sections do not meet on \
6456                         their mitres; the out-of-plane corner's frame law \
6457                         is still owed; see docs/PARITY.md, offset.sweeps"
6458                    );
6459                }
6460                let set = make_corners(model, 0)?;
6461                if worst > tol.confusion() {
6462                    for v in &set {
6463                        model.widen(v, ogeom_core::Tolerance::new(worst * 2.0)?)?;
6464                    }
6465                }
6466                corners_at[0] = Some(set.clone());
6467                corners_at[last] = Some(set);
6468            } else {
6469                corners_at[0] = Some(make_corners(model, 0)?);
6470                corners_at[last] = Some(make_corners(model, last)?);
6471            }
6472            for &k in &kinks {
6473                let set = make_corners(model, k)?;
6474                corners_at[k] = Some(set.clone());
6475                corners_at[k + 1] = Some(set);
6476            }
6477
6478            // The loop's own centroid line, for orienting each strip.
6479            let hint_flat = {
6480                let mut a = 0.0;
6481                let mut b = 0.0;
6482                for (fa, fb) in &corner_flat {
6483                    a += fa;
6484                    b += fb;
6485                }
6486                #[allow(clippy::cast_precision_loss)]
6487                let n = count as f64;
6488                (a / n, b / n)
6489            };
6490
6491            let mut bottoms = Vec::with_capacity(count);
6492            let mut tops = Vec::with_capacity(count);
6493            // Per run: the first strip's start rail, for the last strip to
6494            // close the loop on, and the previous strip's end rail, for
6495            // the next to start from: one edge for both, never two fits.
6496            let mut run_rails: Vec<(Option<Shape>, Option<Shape>)> = vec![(None, None); runs.len()];
6497            for (ei, edge) in edges.iter().enumerate() {
6498                let (curve, range) = spine_curve_of(model, edge)?;
6499                let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
6500                const ALONG_EDGE: usize = 8;
6501                let edge_flat = |f: f64| -> OgeomResult<(f64, f64)> {
6502                    let t = if reversed {
6503                        range.1 - (range.1 - range.0) * f
6504                    } else {
6505                        range.0 + (range.1 - range.0) * f
6506                    };
6507                    Ok(flat(curve.point_at(t, tol)?))
6508                };
6509                let next = (ei + 1) % count;
6510                let mut bottom: Option<Shape> = None;
6511                let mut top: Option<Shape> = None;
6512                for (ri, &(rs, re)) in runs.iter().enumerate() {
6513                    // Each column the run's rows of one point of the edge,
6514                    // at a fraction of its parameter range.
6515                    let held = std::cell::RefCell::default();
6516                    let skin = if plain((rs, re)) {
6517                        Skin::swept(
6518                            |f, s| along_run((rs, re), s, edge_flat(f)?),
6519                            fractions(ALONG_EDGE),
6520                            (rs, re),
6521                            false,
6522                        )?
6523                    } else if straight(rs, re) {
6524                        Skin::columns(
6525                            |f| straight_column((rs, re), edge_flat(f)?),
6526                            fractions(ALONG_EDGE),
6527                            false,
6528                            false,
6529                            false,
6530                        )?
6531                    } else {
6532                        Skin::swept(
6533                            |f, s| rerowed_point((rs, re), &held, f, edge_flat(f)?, s),
6534                            fractions(ALONG_EDGE),
6535                            (rs, re),
6536                            false,
6537                        )?
6538                    };
6539                    let shared_start = shares_start(ri).then_some(()).and(top.as_ref());
6540                    let shared_end = shares_end(ri).then_some(()).and(bottom.as_ref());
6541                    let mid_i = usize::midpoint(rs, re);
6542                    let hint = {
6543                        let x = normals[mid_i];
6544                        let y = stations[mid_i].tangent.cross(x);
6545                        stations[mid_i].at + x * hint_flat.0 + y * hint_flat.1
6546                    };
6547                    let (Some(from), Some(to)) = (&corners_at[rs], &corners_at[re]) else {
6548                        ogeom_bail!(Construction, "a run boundary has no corners");
6549                    };
6550                    let (first_rail0, prev_rail1) = run_rails[ri].clone();
6551                    let shared_rail0 = if ei > 0 { prev_rail1 } else { None };
6552                    let shared_rail1 = if ei + 1 == count && count > 1 {
6553                        first_rail0.clone()
6554                    } else {
6555                        None
6556                    };
6557                    let strip = skinned_strip(
6558                        model,
6559                        &skin,
6560                        (&from[ei], &from[next], &to[ei], &to[next]),
6561                        [
6562                            shared_start,
6563                            shared_end,
6564                            shared_rail0.as_ref(),
6565                            shared_rail1.as_ref(),
6566                        ],
6567                        hint,
6568                        hole,
6569                        tolerance,
6570                        tol,
6571                    )?;
6572                    run_rails[ri] = (
6573                        if ei == 0 {
6574                            Some(strip.rail0.clone())
6575                        } else {
6576                            first_rail0
6577                        },
6578                        Some(strip.rail1.clone()),
6579                    );
6580                    faces.push(strip.face.clone());
6581                    if bottom.is_none() {
6582                        bottom = Some(strip.bottom);
6583                    }
6584                    top = Some(strip.top);
6585                }
6586                let (Some(bottom), Some(top)) = (bottom, top) else {
6587                    ogeom_bail!(Construction, "the sweep produced no strip");
6588                };
6589                bottoms.push(bottom);
6590                tops.push(top);
6591            }
6592            ends.push(LoopWall::Chain { bottoms, tops });
6593        }
6594    }
6595
6596    // A cap per end: one plane, one wire per loop, each edge's pcurve the
6597    // exact projection of its control net into the plane's chart. A ring
6598    // has no ends: its two boundary rings stand on one mitre plane and the
6599    // sew joins them.
6600    for end in 0..if ring { 0 } else { 2 } {
6601        let (at, outward) = if end == 0 {
6602            (stations[0].at, -stations[0].tangent)
6603        } else {
6604            (stations[last].at, stations[last].tangent)
6605        };
6606        let cap_plane = Plane::through(at, Direction::new(outward, tol)?);
6607        let mut loop_edges: Vec<Vec<Shape>> = Vec::with_capacity(ends.len());
6608        for wall in &ends {
6609            loop_edges.push(match wall {
6610                LoopWall::Ring { ring0, ring1 } => {
6611                    vec![if end == 0 {
6612                        ring0.clone()
6613                    } else {
6614                        ring1.clone()
6615                    }]
6616                }
6617                LoopWall::Chain { bottoms, tops } => {
6618                    if end == 0 {
6619                        bottoms.clone()
6620                    } else {
6621                        tops.clone()
6622                    }
6623                }
6624            });
6625        }
6626        let mut reach = 1.0_f64;
6627        for edges in &loop_edges {
6628            for edge in edges {
6629                let (curve, range) = spine_curve_of(model, edge)?;
6630                for t in 0..8 {
6631                    let p =
6632                        curve.point_at(range.0 + (range.1 - range.0) * f64::from(t) / 8.0, tol)?;
6633                    reach = reach.max(p.distance(at) * 2.0);
6634                }
6635            }
6636        }
6637        let cap_surface: SurfaceGeometry =
6638            PlaneSurface::over(cap_plane, (-reach, reach), (-reach, reach))?.into();
6639        // The material on the left of each ring about the outward normal:
6640        // the profile's first loop is its outer one.
6641        let mut wires: Vec<Shape> = Vec::with_capacity(loop_edges.len());
6642        for (li, edges) in loop_edges.iter().enumerate() {
6643            let ring = walked_about(model, edges, outward, li == 0, tol)?;
6644            wires.push(ogeom_algo::make_wire(model, &ring, tol)?.shape);
6645        }
6646        let face = ogeom_algo::make_face(model, cap_surface.clone(), &wires, tol)?.shape;
6647        let cap_id = {
6648            let Some(node) = model.node(&face) else {
6649                ogeom_bail!(Dangling, "the cap just built is not in this model");
6650            };
6651            let ogeom_topo::NodeData::Face(data) = node.data() else {
6652                ogeom_bail!(Construction, "the cap holds no face data");
6653            };
6654            data.surface
6655        };
6656        let frame = cap_plane.frame();
6657        for edges in &loop_edges {
6658            for edge in edges {
6659                let (curve, range) = spine_curve_of(model, edge)?;
6660                let ogeom_geom::Curve::BSpline(bs) = &curve else {
6661                    ogeom_bail!(Construction, "a swept ring is not a spline");
6662                };
6663                // A planar polynomial spline's chart image is the same-degree
6664                // spline of the projected control points: affine, so exact.
6665                let control2: Vec<Point2> = bs
6666                    .control_points()
6667                    .iter()
6668                    .map(|w| {
6669                        let local = frame.to_local(w.point());
6670                        Point2::new(local.x, local.y)
6671                    })
6672                    .collect();
6673                let pcurve: ogeom_geom::PlanarCurve =
6674                    ogeom_geom::BSpline2d::new(bs.knots().clone(), control2, tol)?.into();
6675                ogeom_algo::attach_pcurve(
6676                    model,
6677                    edge,
6678                    pcurve,
6679                    cap_id,
6680                    ogeom_topo::Location::identity(),
6681                    range,
6682                )?;
6683            }
6684        }
6685        faces.push(face);
6686    }
6687
6688    let sewn = sew(model, &faces, tol)?;
6689    let mut built = if ring {
6690        // A holed ring sews into one shell per profile loop: the outer
6691        // bounds the material, each hole a void tunnel. Largest bound
6692        // first, the voids' faces already turned at build.
6693        if sewn.shells.is_empty() {
6694            ogeom_bail!(Construction, "the pipe shell did not close");
6695        }
6696        for shell in &sewn.shells {
6697            if !ogeom_algo::is_shell_closed(model, shell)? {
6698                ogeom_bail!(Construction, "the pipe shell did not close");
6699            }
6700        }
6701        let mut ordered = sewn.shells.clone();
6702        let mut sized: Vec<(f64, Shape)> = Vec::with_capacity(ordered.len());
6703        for shell in ordered.drain(..) {
6704            let bound = ogeom_algo::shape_bounds(model, &shell, tol)?;
6705            sized.push((bound.diagonal(), shell));
6706        }
6707        sized.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(core::cmp::Ordering::Equal));
6708        let shells: Vec<Shape> = sized.into_iter().map(|(_, s)| s).collect();
6709        make_solid(model, &shells)?
6710    } else {
6711        if sewn.shells.len() != 1 || !ogeom_algo::is_shell_closed(model, &sewn.shells[0])? {
6712            if std::env::var_os("OGEOM_DEBUG_SWEEP").is_some() {
6713                eprintln!(
6714                    "SWEEP: {} shells, {} free edges from {} faces",
6715                    sewn.shells.len(),
6716                    sewn.free_edges.len(),
6717                    faces.len()
6718                );
6719                for edge in &sewn.free_edges {
6720                    let (curve, range) = spine_curve_of(model, edge)?;
6721                    let a = curve.point_at(range.0, tol)?;
6722                    let b = curve.point_at(range.1, tol)?;
6723                    let m = curve.point_at(f64::midpoint(range.0, range.1), tol)?;
6724                    let t = model.tolerance_of(edge)?.map_or(0.0, |t| t.get());
6725                    eprintln!(
6726                        "  free ({:.3},{:.3},{:.3}) -> ({:.3},{:.3},{:.3}) via ({:.3},{:.3},{:.3}) tol {t:.2e}",
6727                        a.x, a.y, a.z, b.x, b.y, b.z, m.x, m.y, m.z
6728                    );
6729                }
6730            }
6731            ogeom_bail!(Construction, "the pipe shell did not close");
6732        }
6733        make_solid(model, std::slice::from_ref(&sewn.shells[0]))?
6734    };
6735    built.history.generate(profile, built.shape.clone());
6736    built.history.generate(spine, built.shape.clone());
6737    Ok(built)
6738}
6739
6740/// An edge's 3D curve and range, cloned out of the model.
6741/// A pipe shell whose spine turns a skew corner against a curved leg,
6742/// built as pieces between such corners and fused.
6743///
6744/// With the frame reflected across the mitre plane, a straight leg's walls
6745/// and a curved leg's cut that plane in sections that differ on the inside
6746/// of the turn: no single join row closes both. Each piece is swept on
6747/// straight past its corners (the frame carries unchanged along a straight
6748/// run), trimmed by each corner's mitre plane, and the pieces fused: where
6749/// their sections on the plane coincide the caps melt, and where they
6750/// differ the difference stands as a face of the mitre plane. Exact, and
6751/// the plain mitre wherever the two sections agree.
6752#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
6753fn mitred_pieces(
6754    model: &mut Model,
6755    profile: &Shape,
6756    spine: &Shape,
6757    stations: &[SpineStation],
6758    skew: &[(usize, usize)],
6759    ring: bool,
6760    probes: &[(f64, f64)],
6761    tolerance: f64,
6762    tol: Tolerances,
6763) -> OgeomResult<Built> {
6764    if model.kind_of(profile)? == ShapeType::Wire {
6765        // A closed planar wire sweeps the walls of the face it bounds: the
6766        // solid pieces are mitred and fused as for that face, and its end
6767        // caps (on the planes square to the spine's ends) are taken off.
6768        if !ogeom_algo::is_wire_closed(model, profile, tol)? {
6769            ogeom_bail!(
6770                Construction,
6771                "a skew corner against a curved leg is mitred by fusing solid \
6772                 pieces; an open wire bounds no face to sweep round it"
6773            );
6774        }
6775        let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
6776            ogeom_bail!(Construction, "a pipe shell sweeps a planar profile");
6777        };
6778        let reach = 1e4_f64;
6779        let surface: SurfaceGeometry =
6780            PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
6781        let face = ogeom_algo::make_face(model, surface, std::slice::from_ref(profile), tol)?.shape;
6782        let face = realized_profile(model, &face, &Transform::IDENTITY, tol)?;
6783        let solid = mitred_pieces(
6784            model, &face, spine, stations, skew, ring, probes, tolerance, tol,
6785        )?
6786        .shape;
6787        let ends: Vec<(Point, Vector)> = if ring {
6788            Vec::new()
6789        } else {
6790            vec![
6791                (stations[0].at, stations[0].tangent),
6792                (
6793                    stations[stations.len() - 1].at,
6794                    stations[stations.len() - 1].tangent,
6795                ),
6796            ]
6797        };
6798        let mut walls = Vec::new();
6799        for f in explore(model, &solid, Filter::OfType(ShapeType::Face))? {
6800            let Some(ogeom_topo::NodeData::Face(data)) = model.node(&f).map(|n| n.data()) else {
6801                continue;
6802            };
6803            let cap = match model.geometry().surface(data.surface) {
6804                Some(SurfaceGeometry::Plane(p)) => {
6805                    let placed = p.plane();
6806                    ends.iter().any(|(at, n)| {
6807                        placed.normal().vector().cross(*n).magnitude() <= tol.angular()
6808                            && placed.distance_to(*at) <= tol.confusion() * 100.0
6809                    })
6810                }
6811                _ => false,
6812            };
6813            if !cap {
6814                walls.push(f);
6815            }
6816        }
6817        let sewn = sew(model, &walls, tol)?;
6818        let shape = match sewn.shells.as_slice() {
6819            [shell] => shell.clone(),
6820            _ => ogeom_algo::make_compound(model, &sewn.shells)?.shape,
6821        };
6822        let mut history = History::new();
6823        history.generate(profile, shape.clone());
6824        history.generate(spine, shape.clone());
6825        return Ok(Built::new(shape, history));
6826    }
6827    if model.kind_of(profile)? != ShapeType::Face {
6828        ogeom_bail!(Construction, "a pipe shell sweeps a planar wire or face");
6829    }
6830    let edges: Vec<Shape> = match model.kind_of(spine)? {
6831        ShapeType::Edge => vec![spine.clone()],
6832        _ => model.ordered_children_of(spine)?,
6833    };
6834    let normals = rmf_normals(stations);
6835    let reach_out = probes
6836        .iter()
6837        .map(|(a, b)| a.hypot(*b))
6838        .fold(0.0_f64, f64::max);
6839
6840    // Each split corner: the junction's edges, its point, the tangents
6841    // either side, the frame the far side starts in, and how far each side
6842    // runs on past it to cover the mitre plane across the whole profile.
6843    struct Split {
6844        edge_before: usize,
6845        at: Point,
6846        before: Vector,
6847        after: Vector,
6848        frame_after: Vector,
6849        run_on: f64,
6850    }
6851    let mut splits: Vec<Split> = Vec::with_capacity(skew.len());
6852    for &(k, next) in skew {
6853        let (before, after) = (stations[k].tangent, stations[next].tangent);
6854        let turn = before.dot(after).clamp(-1.0, 1.0).acos();
6855        let half = (turn * 0.5).cos();
6856        if half < 0.05 {
6857            ogeom_bail!(
6858                Construction,
6859                "the spine all but doubles back at a corner; a mitre there \
6860                 runs off to infinity"
6861            );
6862        }
6863        splits.push(Split {
6864            edge_before: stations[k].edge,
6865            at: stations[k].at,
6866            before,
6867            after,
6868            frame_after: normals[next],
6869            // The mitre plane stands at most `R·tan(φ/2)` past the corner
6870            // along either leg for a profile reaching `R` from the spine;
6871            // half as far again clears it with room.
6872            run_on: reach_out * ((turn * 0.5).tan() * 1.5 + 0.1),
6873        });
6874    }
6875    splits.sort_by_key(|s| s.edge_before);
6876
6877    // Pieces as runs of spine edges, each between split corners (or the
6878    // open spine's own ends).
6879    let count = edges.len();
6880    let mut pieces: Vec<(Vec<usize>, Option<usize>, Option<usize>)> = Vec::new();
6881    if ring {
6882        for (i, split) in splits.iter().enumerate() {
6883            let next = &splits[(i + 1) % splits.len()];
6884            let mut run = Vec::new();
6885            let mut e = (split.edge_before + 1) % count;
6886            loop {
6887                run.push(e);
6888                if e == next.edge_before {
6889                    break;
6890                }
6891                e = (e + 1) % count;
6892            }
6893            pieces.push((run, Some(i), Some((i + 1) % splits.len())));
6894        }
6895    } else {
6896        let mut first = 0;
6897        for (i, split) in splits.iter().enumerate() {
6898            pieces.push((
6899                (first..=split.edge_before).collect(),
6900                i.checked_sub(1),
6901                Some(i),
6902            ));
6903            first = split.edge_before + 1;
6904        }
6905        pieces.push(((first..count).collect(), splits.len().checked_sub(1), None));
6906    }
6907
6908    let x0 = normals[0];
6909    let start_frame = Frame::new(
6910        stations[0].at,
6911        Direction::new(stations[0].tangent, tol)?,
6912        Direction::new(x0, tol)?,
6913        tol,
6914    )?;
6915    // One block per split, standing on the mitre plane on the far side of
6916    // the corner: the piece before the corner is cut by it and the piece
6917    // after keeps what it shares with it, so both sides' caps are pieces
6918    // of the block's one face, on one surface and one chart, which is what
6919    // lets the fuse melt them.
6920    let mut blocks: Vec<Shape> = Vec::with_capacity(splits.len());
6921    for split in &splits {
6922        let n = (split.before + split.after) / (split.before + split.after).magnitude();
6923        let normal = Direction::new(n, tol)?;
6924        let plane = Plane::through(split.at, normal);
6925        let reach = (split.run_on + reach_out) * 4.0;
6926        let frame = plane.frame();
6927        let (u, v) = (frame.x().vector(), frame.y().vector());
6928        let corners: Vec<Point> = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
6929            .iter()
6930            .map(|(a, b)| split.at + u * (a * reach) + v * (b * reach))
6931            .collect();
6932        let wire = ogeom_algo::make_polygon(model, &corners, true, tol)?.shape;
6933        let edges = explore(model, &wire, Filter::OfType(ShapeType::Edge))?;
6934        let surface: SurfaceGeometry = PlaneSurface::over(
6935            plane,
6936            (-reach * 2.0, reach * 2.0),
6937            (-reach * 2.0, reach * 2.0),
6938        )?
6939        .into();
6940        let base = ogeom_algo::make_face_with_pcurves(model, surface, &[edges], tol)?.shape;
6941        let block = ogeom_algo::make_prism(model, &base, n * (reach * 2.0), tol)?.shape;
6942        blocks.push(block);
6943    }
6944
6945    let mut joined: Vec<Shape> = Vec::new();
6946    for (run, start, end) in pieces {
6947        let mut wire_edges: Vec<Shape> = Vec::new();
6948        let traversal = |model: &Model, e: usize, at_start: bool| -> OgeomResult<Shape> {
6949            // In the spine's own sense: a reversed edge's vertices come
6950            // back end first.
6951            let Some((a, b)) = ogeom_algo::edge_vertices(model, &edges[e])? else {
6952                ogeom_bail!(Construction, "a spine edge has no vertices");
6953            };
6954            Ok(if at_start { a } else { b })
6955        };
6956        if let Some(i) = start {
6957            let split = &splits[i];
6958            let far = ogeom_algo::make_vertex(model, split.at - split.after * split.run_on).shape;
6959            let near = traversal(model, run[0], true)?;
6960            let line: ogeom_geom::Curve =
6961                LineCurve::segment(split.at - split.after * split.run_on, split.at, tol)?.into();
6962            let domain = line.domain();
6963            wire_edges
6964                .push(ogeom_algo::make_edge_between(model, line, domain, &far, &near, tol)?.shape);
6965        }
6966        wire_edges.extend(run.iter().map(|&e| edges[e].clone()));
6967        if let Some(i) = end {
6968            let split = &splits[i];
6969            let near = traversal(model, run[run.len() - 1], false)?;
6970            let far = ogeom_algo::make_vertex(model, split.at + split.before * split.run_on).shape;
6971            let line: ogeom_geom::Curve =
6972                LineCurve::segment(split.at, split.at + split.before * split.run_on, tol)?.into();
6973            let domain = line.domain();
6974            wire_edges
6975                .push(ogeom_algo::make_edge_between(model, line, domain, &near, &far, tol)?.shape);
6976        }
6977        let sub_spine = ogeom_algo::make_wire(model, &wire_edges, tol)?.shape;
6978        // The profile where this piece starts: the spine's own start keeps
6979        // the caller's; a piece starting past a corner takes the profile
6980        // moved into the frame the corner's far side starts in, set back
6981        // along its run-on.
6982        let placed = match start {
6983            None => profile.clone(),
6984            Some(i) => {
6985                let split = &splits[i];
6986                let target = Frame::new(
6987                    split.at - split.after * split.run_on,
6988                    Direction::new(split.after, tol)?,
6989                    Direction::new(split.frame_after, tol)?,
6990                    tol,
6991                )?;
6992                let motion = Transform::from_frame(&target) * Transform::to_frame(&start_frame);
6993                realized_profile(model, profile, &motion, tol)?
6994            }
6995        };
6996        let mut piece = make_pipe_shell(model, &placed, &sub_spine, false, tolerance, tol)?.shape;
6997        if model.kind_of(&piece)? != ShapeType::Solid {
6998            ogeom_bail!(Construction, "a mitred piece did not sweep into a solid");
6999        }
7000        if let Some(i) = start {
7001            piece = ogeom_bool::common(model, &piece, &blocks[i], tol)?.shape;
7002        }
7003        if let Some(i) = end {
7004            piece = ogeom_bool::cut(model, &piece, &blocks[i], tol)?.shape;
7005        }
7006        joined.push(piece);
7007    }
7008    let result = fuse_in_order(model, joined, tol)?;
7009    let Some(shape) = result else {
7010        ogeom_bail!(Construction, "the spine produced no piece to sweep");
7011    };
7012    let mut history = History::new();
7013    history.generate(profile, shape.clone());
7014    for edge in &edges {
7015        history.generate(edge, shape.clone());
7016    }
7017    Ok(Built::new(shape, history))
7018}
7019
7020/// A planar profile face rebuilt under a rigid motion: every edge's curve
7021/// moved and re-bounded, vertices shared, the face on the moved plane.
7022fn realized_profile(
7023    model: &mut Model,
7024    profile: &Shape,
7025    motion: &Transform,
7026    tol: Tolerances,
7027) -> OgeomResult<Shape> {
7028    realized_profile_wound(model, profile, motion, None, tol)
7029}
7030
7031/// As [`realized_profile`], each ring wound about `about` where given: the
7032/// outer ring turning positively, every hole the other way.
7033fn realized_profile_wound(
7034    model: &mut Model,
7035    profile: &Shape,
7036    motion: &Transform,
7037    about: Option<Vector>,
7038    tol: Tolerances,
7039) -> OgeomResult<Shape> {
7040    use ogeom_geom::Transformable as _;
7041    let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
7042        ogeom_bail!(Construction, "a pipe shell sweeps a planar profile");
7043    };
7044    let moved_plane = Plane::through(
7045        motion.apply(plane.origin()),
7046        Direction::new(motion.apply_vector(plane.normal().vector()), tol)?,
7047    );
7048    let mut vertices: ogeom_core::FastMap<ogeom_topo::TShapeId, Shape> =
7049        ogeom_core::FastMap::default();
7050    let mut edge_copies: ogeom_core::FastMap<ogeom_topo::TShapeId, Shape> =
7051        ogeom_core::FastMap::default();
7052    let mut wires: Vec<Vec<Shape>> = Vec::new();
7053    for wire in explore(model, profile, Filter::OfType(ShapeType::Wire))? {
7054        let mut ring = Vec::new();
7055        for edge in model.ordered_children_of(&wire)? {
7056            let copy = match edge_copies.get(&edge.node()) {
7057                Some(done) => done.clone(),
7058                None => {
7059                    let (curve, range) = spine_curve_of(model, &edge)?;
7060                    let placed = curve.transformed(&edge.transform(model.datums())?, tol)?;
7061                    let moved = placed.transformed(motion, tol)?;
7062                    // The copy is of the edge itself, in its own sense; the
7063                    // ring's use of it is reapplied below.
7064                    let own = if edge.orientation() == ogeom_topo::Orientation::Reversed {
7065                        edge.reversed()
7066                    } else {
7067                        edge.clone()
7068                    };
7069                    let Some((a, b)) = ogeom_algo::edge_vertices(model, &own)? else {
7070                        ogeom_bail!(Construction, "a profile edge has no vertices");
7071                    };
7072                    let mut ends = Vec::with_capacity(2);
7073                    for v in [a, b] {
7074                        let key = v.node();
7075                        let held = match vertices.get(&key) {
7076                            Some(done) => done.clone(),
7077                            None => {
7078                                let Some(data) = model.node(&v).and_then(|n| n.data().as_vertex())
7079                                else {
7080                                    ogeom_bail!(Construction, "a profile vertex holds no data");
7081                                };
7082                                let at = v.transform(model.datums())?.apply(data.point);
7083                                let fresh = ogeom_algo::make_vertex(model, motion.apply(at)).shape;
7084                                vertices.insert(key, fresh.clone());
7085                                fresh
7086                            }
7087                        };
7088                        ends.push(held);
7089                    }
7090                    let fresh = ogeom_algo::make_edge_between(
7091                        model, moved, range, &ends[0], &ends[1], tol,
7092                    )?
7093                    .shape;
7094                    edge_copies.insert(edge.node(), fresh.clone());
7095                    fresh
7096                }
7097            };
7098            ring.push(if edge.orientation() == ogeom_topo::Orientation::Reversed {
7099                copy.reversed()
7100            } else {
7101                copy
7102            });
7103        }
7104        if let Some(axis) = about {
7105            let turning = ring_turning(model, &ring, axis, tol)?;
7106            let outer = wires.is_empty();
7107            if (turning > 0.0) != outer {
7108                ring = ring.iter().rev().map(Shape::reversed).collect();
7109            }
7110        }
7111        wires.push(ring);
7112    }
7113    let reach = 1e4_f64;
7114    let surface: SurfaceGeometry =
7115        PlaneSurface::over(moved_plane, (-reach, reach), (-reach, reach))?.into();
7116    Ok(ogeom_algo::make_face_with_pcurves(model, surface, &wires, tol)?.shape)
7117}
7118
7119/// `ring` walked so it turns about `axis` positively where `outer`,
7120/// negatively otherwise: a face whose normal is `axis` keeps its material
7121/// on the left of an outer ring walked so, and of a hole walked the other
7122/// way.
7123fn walked_about(
7124    model: &Model,
7125    ring: &[Shape],
7126    axis: Vector,
7127    outer: bool,
7128    tol: Tolerances,
7129) -> OgeomResult<Vec<Shape>> {
7130    let turn = ring_turning(model, ring, axis, tol)?;
7131    Ok(if (turn > 0.0) == outer {
7132        ring.to_vec()
7133    } else {
7134        walked_back(ring)
7135    })
7136}
7137
7138/// `ring` walked the other way round: its edges in reverse order, each
7139/// reversed.
7140fn walked_back(ring: &[Shape]) -> Vec<Shape> {
7141    ring.iter().rev().map(Shape::reversed).collect()
7142}
7143
7144/// Twice the signed area a ring of edges encloses about `axis`, from its
7145/// edges sampled in the ring's own sense.
7146fn ring_turning(model: &Model, ring: &[Shape], axis: Vector, tol: Tolerances) -> OgeomResult<f64> {
7147    let mut points: Vec<Point> = Vec::new();
7148    for edge in ring {
7149        let (curve, range) = spine_curve_of(model, edge)?;
7150        let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
7151        for i in 0..32 {
7152            let f = f64::from(i) / 32.0;
7153            let t = if reversed {
7154                range.1 - (range.1 - range.0) * f
7155            } else {
7156                range.0 + (range.1 - range.0) * f
7157            };
7158            points.push(curve.point_at(t, tol)?);
7159        }
7160    }
7161    let Some(&origin) = points.first() else {
7162        return Ok(0.0);
7163    };
7164    let n = points.len();
7165    Ok((0..n)
7166        .map(|i| {
7167            (points[i] - origin)
7168                .cross(points[(i + 1) % n] - origin)
7169                .dot(axis)
7170        })
7171        .sum())
7172}
7173
7174pub(crate) fn spine_curve_of(
7175    model: &Model,
7176    edge: &Shape,
7177) -> OgeomResult<(ogeom_geom::Curve, (f64, f64))> {
7178    let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
7179        ogeom_bail!(Construction, "an edge holds no data");
7180    };
7181    let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
7182        ogeom_bail!(Construction, "an edge has no curve");
7183    };
7184    let Some(geometry) = model.geometry().curve(*curve) else {
7185        ogeom_bail!(Dangling, "curve is not in this model");
7186    };
7187    Ok((geometry.clone(), *range))
7188}
7189
7190/// The pipe shell around a spine that loops back on itself: one wall,
7191/// closed both ways round, no caps at all.
7192///
7193/// The frames are rotation-minimizing with the loop's holonomy paid off:
7194/// transported round a closed spine, the frame comes home twisted by some
7195/// angle, and that twist is spread back along the arc so the last station's
7196/// frame *is* the first's; without it the closed fit fights a helical
7197/// grid. The profile may be smooth or faceted, and each hole sweeps a void
7198/// tunnel of its own shell; the Frenet law rides the loop as well.
7199fn closed_pipe_shell(
7200    model: &mut Model,
7201    profile: &Shape,
7202    spine: &Shape,
7203    mut stations: Vec<SpineStation>,
7204    frenet: bool,
7205    tolerance: f64,
7206    tol: Tolerances,
7207) -> OgeomResult<Built> {
7208    // The walk visits the join twice; the loop owns it once.
7209    let Some(home) = stations.pop() else {
7210        ogeom_bail!(Construction, "a closed spine needs room to turn");
7211    };
7212    if stations.len() < 3 {
7213        ogeom_bail!(Construction, "a closed spine needs room to turn");
7214    }
7215    // A sharp corner is a kink, and a kinked ring is mitred by the caller;
7216    // one arriving here has a heading that jumps between two stations.
7217    for i in 0..stations.len() {
7218        let next = &stations[(i + 1) % stations.len()];
7219        if stations[i].tangent.dot(next.tangent) < 0.9 {
7220            ogeom_bail!(
7221                Construction,
7222                "a closed spine turns too sharply between two of its stations to skin"
7223            );
7224        }
7225    }
7226
7227    let loops: Vec<Shape> = match model.kind_of(profile)? {
7228        ShapeType::Face => explore(model, profile, Filter::OfType(ShapeType::Wire))?,
7229        ShapeType::Wire => vec![profile.clone()],
7230        other => ogeom_bail!(
7231            Construction,
7232            "a pipe shell sweeps a planar wire or face, not a {other:?}"
7233        ),
7234    };
7235    // Every wire sweeps its own closed shell: the outer boundary first,
7236    // each hole a void tunnel inside it.
7237    let profile_loop = &loops[0];
7238    let edges = model.ordered_children_of(profile_loop)?;
7239    let smooth = edges.len() == 1
7240        && ogeom_algo::edge_vertices(model, &edges[0])?.is_some_and(|(a, b)| a.is_same(&b));
7241    let Some(plane) = ogeom_algo::find_plane(model, profile, tol)? else {
7242        ogeom_bail!(Construction, "a pipe shell sweeps a planar profile");
7243    };
7244    let t0 = stations[0].tangent;
7245    // Square to the spine's own start tangent, read exactly off its first
7246    // edge, to an angle's tolerance.
7247    let exact_t0 = {
7248        let first = match model.kind_of(spine)? {
7249            ShapeType::Edge => spine.clone(),
7250            _ => model.ordered_children_of(spine)?[0].clone(),
7251        };
7252        let (curve, range) = spine_curve_of(model, &first)?;
7253        let reversed = first.orientation() == ogeom_topo::Orientation::Reversed;
7254        let d = curve.d1_at(if reversed { range.1 } else { range.0 }, tol)?;
7255        let d = if reversed { -d } else { d };
7256        d / d.magnitude()
7257    };
7258    if plane.normal().vector().cross(exact_t0).magnitude() > tol.angular() {
7259        ogeom_bail!(
7260            Construction,
7261            "the profile leans along its spine; a pipe shell runs square to \
7262             the start"
7263        );
7264    }
7265    if plane.distance_to(stations[0].at) > tol.confusion() * 100.0 {
7266        ogeom_bail!(
7267            Construction,
7268            "the profile does not sit at the spine's start"
7269        );
7270    }
7271
7272    // Frames with the loop's mismatch paid off: carry once more back to the
7273    // start, read the twist between departure and return, and spread it
7274    // along the arc. Rotation-minimizing frames owe this for their
7275    // holonomy; the Frenet law owes it too, because straight stretches
7276    // carry the frame through by continuation and the continuation is
7277    // path-dependent. One reconciliation serves both.
7278    let mut normals: Vec<Vector> = if frenet {
7279        // The Frenet frame is the spine's own, single-valued round a loop:
7280        // read with wrapped neighbours it closes on itself and owes no
7281        // reconciliation. Read from a walk that visits the join twice it
7282        // does not: the one-sided differences at the walk's two ends
7283        // disagree with the interior, and the strips built on them miss
7284        // each other at the join by that kink.
7285        frenet_normals_closed(&stations, tol)?
7286    } else {
7287        let mut extended = stations.clone();
7288        extended.push(stations[0]);
7289        let carried = rmf_normals(&extended);
7290        let (n0, n_home) = (carried[0], carried[carried.len() - 1]);
7291        let twist = (n0.cross(n_home).dot(t0)).atan2(n0.dot(n_home));
7292        let mut lengths = vec![0.0_f64];
7293        for pair in extended.windows(2) {
7294            let last = lengths[lengths.len() - 1];
7295            lengths.push(last + pair[0].at.distance(pair[1].at));
7296        }
7297        let total = lengths[lengths.len() - 1];
7298        carried
7299            .iter()
7300            .take(stations.len())
7301            .enumerate()
7302            .map(|(i, n)| {
7303                let phi = -twist * lengths[i] / total;
7304                let t = extended[i].tangent;
7305                *n * phi.cos() + t.cross(*n) * phi.sin()
7306            })
7307            .collect()
7308    };
7309    for (n, station) in normals.iter_mut().zip(&stations) {
7310        // Re-square each corrected normal against its own tangent.
7311        let v = *n - station.tangent * n.dot(station.tangent);
7312        *n = v / v.magnitude();
7313    }
7314
7315    let x0 = normals[0];
7316    let origin = stations[0].at;
7317    // The way round once more to its start, at the end of the spine's
7318    // range, so the frame is carried between the last station and home.
7319    let (round, round_normals) = {
7320        let mut round = stations.clone();
7321        round.push(SpineStation {
7322            tangent: stations[0].tangent,
7323            ..home
7324        });
7325        let mut normals = normals.clone();
7326        normals.push(normals[0]);
7327        (round, normals)
7328    };
7329    let walk = SpineWalk {
7330        curves: walk_curves(model, spine)?,
7331        stations: &round,
7332        normals: &round_normals,
7333    };
7334    let mut shells: Vec<Shape> = Vec::with_capacity(loops.len());
7335    for (li, wire) in loops.iter().enumerate() {
7336        let wire_edges = model.ordered_children_of(wire)?;
7337        let wire_smooth = wire_edges.len() == 1
7338            && ogeom_algo::edge_vertices(model, &wire_edges[0])?
7339                .is_some_and(|(a, b)| a.is_same(&b));
7340        let shell = closed_loop_shell(
7341            model,
7342            wire,
7343            &wire_edges,
7344            wire_smooth,
7345            &walk,
7346            (origin, x0),
7347            tolerance,
7348            tol,
7349        )?;
7350        // The material side follows each loop's own winding, so it is
7351        // read off the shell itself: the outer shell faces out of what it
7352        // encloses, a void's faces toward its tunnel.
7353        let enclosed = shell_signed_volume(model, &shell, tol)?;
7354        let outward = enclosed > 0.0;
7355        shells.push(if outward == (li == 0) {
7356            shell
7357        } else {
7358            shell.reversed()
7359        });
7360    }
7361    let mut built = make_solid(model, &shells)?;
7362    built.history.generate(profile, built.shape.clone());
7363    built.history.generate(spine, built.shape.clone());
7364    let _ = (smooth, profile_loop, edges);
7365    Ok(built)
7366}
7367
7368/// The volume a closed shell encloses as it faces, from its mesh: negative
7369/// where its faces point into what it bounds.
7370fn shell_signed_volume(model: &Model, shell: &Shape, tol: Tolerances) -> OgeomResult<f64> {
7371    Ok(ogeom_mesh::triangulate(model, shell, ogeom_mesh::Deflection::default(), tol)?.volume())
7372}
7373
7374/// Sample a spine (one edge or a wire of them) into stations, each edge
7375/// given a station count by its own turning.
7376fn shell_stations(model: &Model, spine: &Shape, tol: Tolerances) -> OgeomResult<Vec<SpineStation>> {
7377    let edges: Vec<Shape> = match model.kind_of(spine)? {
7378        ShapeType::Edge => vec![spine.clone()],
7379        ShapeType::Wire => model.ordered_children_of(spine)?,
7380        other => ogeom_bail!(
7381            Construction,
7382            "a pipe shell runs along an edge or a wire, not a {other:?}"
7383        ),
7384    };
7385    if edges.is_empty() {
7386        ogeom_bail!(Construction, "the spine has no edge to run along");
7387    }
7388    let mut stations: Vec<SpineStation> = Vec::new();
7389    for (ei, edge) in edges.iter().enumerate() {
7390        let (curve, range) = {
7391            let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
7392                ogeom_bail!(Construction, "a spine edge holds no data");
7393            };
7394            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
7395                ogeom_bail!(Construction, "a spine edge has no curve");
7396            };
7397            let Some(geometry) = model.geometry().curve(*curve) else {
7398                ogeom_bail!(Dangling, "curve is not in this model");
7399            };
7400            (geometry.clone(), *range)
7401        };
7402        let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
7403        // Stations by turning: sample tangents coarsely, sum the angles, and
7404        // give each edge enough stations that no step turns more than a few
7405        // degrees. A straight edge keeps a healthy minimum for the fit.
7406        let turning = {
7407            let mut sum = 0.0_f64;
7408            let mut last: Option<Vector> = None;
7409            for i in 0..=16 {
7410                let t = range.0 + (range.1 - range.0) * f64::from(i) / 16.0;
7411                let d = curve.d1_at(t, tol)?;
7412                let m = d.magnitude();
7413                if m <= tol.confusion() {
7414                    continue;
7415                }
7416                let u = d / m;
7417                if let Some(prev) = last {
7418                    sum += prev.dot(u).clamp(-1.0, 1.0).acos() * 16.0 / 16.0;
7419                }
7420                last = Some(u);
7421            }
7422            sum
7423        };
7424        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
7425        let count = (turning / (core::f64::consts::TAU / 64.0)).ceil().max(8.0) as usize;
7426        for i in 0..=count {
7427            #[allow(clippy::cast_precision_loss)]
7428            let f = (i as f64) / (count as f64);
7429            let t = if reversed {
7430                range.1 - (range.1 - range.0) * f
7431            } else {
7432                range.0 + (range.1 - range.0) * f
7433            };
7434            let p = curve.point_at(t, tol)?;
7435            let d = curve.d1_at(t, tol)?;
7436            let m = d.magnitude();
7437            if m <= tol.confusion() {
7438                ogeom_bail!(Construction, "the spine is degenerate at {t}");
7439            }
7440            let tangent = if reversed { -(d / m) } else { d / m };
7441            if let Some(prev) = stations.last()
7442                && prev.edge == ei
7443                && prev.at.distance(p) <= tol.confusion()
7444                && prev.tangent.cross(tangent).magnitude() <= tol.angular()
7445                && prev.tangent.dot(tangent) > 0.0
7446            {
7447                continue;
7448            }
7449            // A station coincident with the last but on the next edge is a
7450            // twin: heading elsewhere it is a *corner* the sweep mitres, and
7451            // heading on it is a smooth junction where the next edge's own
7452            // run of skin begins, since one fit across two curves' joins
7453            // cannot follow the step in their curvature.
7454            stations.push(SpineStation {
7455                at: p,
7456                tangent,
7457                edge: ei,
7458                t,
7459            });
7460        }
7461    }
7462    if stations.len() < 2 {
7463        ogeom_bail!(Construction, "the spine collapses to a point");
7464    }
7465    Ok(stations)
7466}
7467
7468/// Frenet normals: each station's frame turns with the spine's own
7469/// curvature, read from the tangents' finite differences. Straight runs
7470/// carry the last bending station's normal forward; a spine that never
7471/// bends has no Frenet frame at all and is refused by name.
7472fn frenet_normals(stations: &[SpineStation], tol: Tolerances) -> OgeomResult<Vec<Vector>> {
7473    let mut normals: Vec<Option<Vector>> = Vec::with_capacity(stations.len());
7474    for i in 0..stations.len() {
7475        let (before, after) = (
7476            &stations[i.saturating_sub(1)],
7477            &stations[(i + 1).min(stations.len() - 1)],
7478        );
7479        let dt = after.tangent - before.tangent;
7480        let t = stations[i].tangent;
7481        let bend = dt - t * dt.dot(t);
7482        let m = bend.magnitude();
7483        normals.push(if m > tol.angular().max(1e-9) {
7484            Some(bend / m)
7485        } else {
7486            None
7487        });
7488    }
7489    // Carry forward, then backward, so straight lead-ins take the first
7490    // bend's frame rather than none.
7491    let mut carried: Vec<Vector> = Vec::with_capacity(stations.len());
7492    let mut last: Option<Vector> = None;
7493    for n in &normals {
7494        if let Some(n) = n {
7495            last = Some(*n);
7496        }
7497        carried.push(last.unwrap_or(Vector::new(0.0, 0.0, 0.0)));
7498    }
7499    let mut ahead: Option<Vector> = None;
7500    for i in (0..stations.len()).rev() {
7501        if let Some(n) = normals[i] {
7502            ahead = Some(n);
7503        } else if carried[i].magnitude() < 0.5
7504            && let Some(n) = ahead
7505        {
7506            carried[i] = n;
7507        }
7508    }
7509    if carried.iter().any(|n| n.magnitude() < 0.5) {
7510        ogeom_bail!(
7511            Construction,
7512            "a straight spine has no Frenet frame; use the \
7513             rotation-minimizing default"
7514        );
7515    }
7516    Ok(carried)
7517}
7518
7519/// Frenet normals round a closed loop: each station's bend read from its
7520/// neighbours across the join as well, so the field is periodic. A loop
7521/// with a straight stretch carries the last bend's normal through it, as
7522/// the open form does; a loop that never bends has no Frenet frame.
7523fn frenet_normals_closed(stations: &[SpineStation], tol: Tolerances) -> OgeomResult<Vec<Vector>> {
7524    let n = stations.len();
7525    let mut normals: Vec<Option<Vector>> = Vec::with_capacity(n);
7526    for i in 0..n {
7527        let (before, after) = (&stations[(i + n - 1) % n], &stations[(i + 1) % n]);
7528        let dt = after.tangent - before.tangent;
7529        let t = stations[i].tangent;
7530        let bend = dt - t * dt.dot(t);
7531        let m = bend.magnitude();
7532        normals.push(if m > tol.angular().max(1e-9) {
7533            Some(bend / m)
7534        } else {
7535            None
7536        });
7537    }
7538    let Some(first_bend) = normals.iter().position(Option::is_some) else {
7539        ogeom_bail!(
7540            Construction,
7541            "a straight spine has no Frenet frame; use the \
7542             rotation-minimizing default"
7543        );
7544    };
7545    // Carry forward round the loop from the first bend, so a straight
7546    // stretch anywhere takes the bend behind it.
7547    let mut carried: Vec<Vector> = vec![Vector::new(0.0, 0.0, 0.0); n];
7548    let mut last = normals[first_bend].unwrap_or_else(|| unreachable!());
7549    for k in 0..n {
7550        let i = (first_bend + k) % n;
7551        if let Some(bend) = normals[i] {
7552            last = bend;
7553        }
7554        carried[i] = last;
7555    }
7556    Ok(carried)
7557}
7558
7559// --- the evolved shape -------------------------------------------------------
7560
7561/// One station of the spine: where it is, which way it runs, and how it gets
7562/// there.
7563struct Station {
7564    /// Where the traversal enters and leaves this edge.
7565    from: Point,
7566    to: Point,
7567    /// The unit tangent at each end, in the direction of travel.
7568    tangent_in: Vector,
7569    tangent_out: Vector,
7570    /// A straight run, or a turn about an axis through an angle.
7571    turn: Option<(ogeom_math::Axis, f64)>,
7572}
7573
7574/// Sweep a profile along a spine, the way a moulding runs round a frame.
7575///
7576/// The spine is a **planar** wire, or a planar face whose outer wire is taken.
7577/// The profile is a wire standing in a plane that contains the spine's own
7578/// normal, positioned where the spine starts. What comes back is what the
7579/// profile sweeps out as it travels the spine, always square to it:
7580///
7581/// - a straight spine edge extrudes the profile (a prism);
7582/// - a circular one turns it about that arc's own axis (a revolution);
7583/// - and each corner between them turns it about the corner, through exactly
7584///   the angle the spine turns there, which is the join the 2D offset makes
7585///   for the same reason.
7586///
7587/// Every piece is exact: the surfaces are the ones a prism and a revolution
7588/// give for the profile's own curves, and nothing is fitted. The pieces are
7589/// then unioned, which is the assembly's real name: consecutive pieces meet
7590/// on the *same* placed profile, and a coincident face is what the boolean
7591/// identifies rather than probes across.
7592///
7593/// # Volume or shell
7594///
7595/// The result is always a volume, and which spine is given is what says
7596/// whether there is one to have. A **closed** profile bounds its own section
7597/// and sweeps a solid along either kind of spine. An **open** one does not,
7598/// and there is exactly one honest way to close it: against the plane a
7599/// **face** spine was drawn in, whose own plane the profile's two ends must
7600/// reach. An open profile along a wire spine is refused, and the refusal says
7601/// which spine would close it.
7602///
7603/// # Errors
7604///
7605/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the spine is
7606/// not a planar wire or face, if it carries an edge that is neither straight
7607/// nor circular, if the profile is not planar, if the profile's plane does not
7608/// contain the spine's normal or does not cut across it (a profile that leans
7609/// or lies along is not square to the spine), or if an open profile has no
7610/// spine plane to close against.
7611/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) where a corner's turn
7612/// would sweep the profile across the corner itself, which no revolution can
7613/// express.
7614pub fn make_evolved(
7615    model: &mut Model,
7616    spine: &Shape,
7617    profile: &Shape,
7618    tol: Tolerances,
7619) -> OgeomResult<Built> {
7620    use ogeom_algo::{make_prism, make_revolution, transformed};
7621
7622    let (wire, capped_by_spine_plane) = match model.kind_of(spine)? {
7623        ShapeType::Face => {
7624            let wires = explore(model, spine, Filter::OfType(ShapeType::Wire))?;
7625            let Some(outer) = wires.first().cloned() else {
7626                ogeom_bail!(Construction, "a face with no wire has no spine to run");
7627            };
7628            (outer, true)
7629        }
7630        ShapeType::Wire => (spine.clone(), false),
7631        other => ogeom_bail!(
7632            Construction,
7633            "a {other:?} is not a spine; sweep along a wire or a planar face"
7634        ),
7635    };
7636
7637    let stations = spine_stations(model, &wire, tol)?;
7638    if stations.is_empty() {
7639        ogeom_bail!(Construction, "a spine with no edges goes nowhere");
7640    }
7641    let normal = spine_normal(&stations, tol)?;
7642    let (profile_origin, profile_normal) = profile_plane(model, profile, tol)?;
7643    if profile_normal.dot(normal.vector()).abs() > tol.angular() {
7644        ogeom_bail!(
7645            Construction,
7646            "the profile's plane must contain the spine's normal, or the \
7647             profile is not square to the spine it travels"
7648        );
7649    }
7650    let start = &stations[0];
7651    if profile_normal.cross(start.tangent_in).magnitude() > tol.angular() {
7652        ogeom_bail!(
7653            Construction,
7654            "the profile's plane must cut the spine across, not run along it: \
7655             the profile is not square to the spine it travels"
7656        );
7657    }
7658    let _ = profile_origin;
7659    let reference = (start.from, start.tangent_in);
7660
7661    // The profile as a face, which is what makes each swept piece a *solid*
7662    // and the assembly a union rather than a hopeful sew. An open profile is
7663    // closed against the spine's own plane, which is exactly what a face
7664    // spine offers and a wire spine does not.
7665    let section = profile_face(
7666        model,
7667        profile,
7668        profile_normal,
7669        capped_by_spine_plane.then(|| Plane::through(start.from, normal)),
7670        tol,
7671    )?;
7672
7673    let mut pieces: Vec<Shape> = Vec::new();
7674    for (index, station) in stations.iter().enumerate() {
7675        // The corner *before* this station, so the pieces come out in the
7676        // order the spine runs them.
7677        if index > 0 {
7678            let previous = &stations[index - 1];
7679            if let Some(piece) = corner_piece(
7680                model,
7681                &section,
7682                reference,
7683                previous.to,
7684                previous.tangent_out,
7685                station.tangent_in,
7686                normal,
7687                tol,
7688            )? {
7689                pieces.push(piece);
7690            }
7691        }
7692        let placed = transformed(
7693            model,
7694            &section,
7695            station_transform(reference, station.from, station.tangent_in, normal, tol)?,
7696        )?
7697        .shape;
7698        pieces.push(match station.turn {
7699            None => make_prism(model, &placed, station.to - station.from, tol)?.shape,
7700            Some((axis, angle)) => make_revolution(model, &placed, axis, angle, tol)?.shape,
7701        });
7702    }
7703    // A closed spine turns at the join between its last edge and its first
7704    // just as it does anywhere else.
7705    let last = &stations[stations.len() - 1];
7706    if last.to.distance(start.from) <= tol.confusion()
7707        && let Some(piece) = corner_piece(
7708            model,
7709            &section,
7710            reference,
7711            last.to,
7712            last.tangent_out,
7713            start.tangent_in,
7714            normal,
7715            tol,
7716        )?
7717    {
7718        pieces.push(piece);
7719    }
7720
7721    // The union, in the order the spine runs: consecutive pieces meet on the
7722    // *same* placed profile, which is the coincident-face case the boolean
7723    // resolves by identifying it rather than by probing across it.
7724    let mut history = History::new();
7725    let Some(shape) = fuse_in_order(model, pieces, tol)? else {
7726        ogeom_bail!(Construction, "the spine produced no piece to sweep");
7727    };
7728    history.generate(spine, shape.clone());
7729    history.generate(profile, shape.clone());
7730    Ok(Built::new(shape, history))
7731}
7732
7733/// The profile as a face.
7734///
7735/// A closed profile bounds its own area. An open one does not, and there is
7736/// exactly one honest way to close it: against the plane the spine was given
7737/// as a face *in*, which is what a face spine says to do and a wire spine has
7738/// no answer for. The closing segment runs between the profile's two ends, and
7739/// both have to be on that plane or the profile does not reach it.
7740fn profile_face(
7741    model: &mut Model,
7742    profile: &Shape,
7743    profile_normal: Vector,
7744    against: Option<Plane>,
7745    tol: Tolerances,
7746) -> OgeomResult<Shape> {
7747    if model.kind_of(profile)? == ShapeType::Face {
7748        return Ok(profile.clone());
7749    }
7750    if model.kind_of(profile)? != ShapeType::Wire {
7751        ogeom_bail!(Construction, "a profile is a wire or a face");
7752    }
7753    let mut edges = ogeom_topo::explore(model, profile, Filter::OfType(ShapeType::Edge))?;
7754    let closed = ogeom_algo::is_wire_closed(model, profile, tol)?;
7755    if !closed {
7756        let Some(plane) = against else {
7757            ogeom_bail!(
7758                Construction,
7759                "an open profile sweeps a shell, not a volume; give the spine \
7760                 as a planar face for its plane to close the profile against, \
7761                 or close the profile itself"
7762            );
7763        };
7764        let [(from, v0), (to, v1)] = wire_ends(model, profile, tol)?;
7765        for end in [from, to] {
7766            if plane.signed_distance_to(end).abs() > tol.confusion() * 1e2 {
7767                ogeom_bail!(
7768                    Construction,
7769                    "an open profile is closed against the spine face's own \
7770                     plane, and this one does not reach it"
7771                );
7772            }
7773        }
7774        // Built on the profile's *own* end vertices, so the closed ring is a
7775        // wire rather than edges that merely touch.
7776        let line = LineCurve::new(ogeom_math::Axis {
7777            location: from,
7778            direction: Direction::new(to - from, tol)?,
7779        });
7780        edges.push(
7781            make_edge_between(model, line.into(), (0.0, from.distance(to)), &v0, &v1, tol)?.shape,
7782        );
7783    }
7784    let ordered = ogeom_algo::order_edges(model, &edges, tol)?;
7785    let mut bound = ogeom_math::Aabb::EMPTY;
7786    for edge in &ordered {
7787        bound = bound.union(&ogeom_algo::shape_bounds(model, edge, tol)?);
7788    }
7789    let Some(centre) = bound.centre() else {
7790        ogeom_bail!(Construction, "a profile with no extent sweeps nothing");
7791    };
7792    let reach = bound.diagonal().mul_add(2.0, 1.0);
7793    let plane = Plane::through(centre, Direction::new(profile_normal, tol)?);
7794    let surface = PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?;
7795    Ok(make_face_with_pcurves(model, surface.into(), &[ordered], tol)?.shape)
7796}
7797
7798/// Where an open wire begins and ends: the point, and the vertex there.
7799fn wire_ends(model: &Model, wire: &Shape, tol: Tolerances) -> OgeomResult<[(Point, Shape); 2]> {
7800    let mut counts: Vec<(Point, Shape, usize)> = Vec::new();
7801    for edge in explore(model, wire, Filter::OfType(ShapeType::Edge))? {
7802        for v in explore(model, &edge, Filter::OfType(ShapeType::Vertex))? {
7803            let Some(data) = model.node(&v).and_then(|n| n.data().as_vertex()) else {
7804                continue;
7805            };
7806            let at = v.transform(model.datums())?.apply(data.point);
7807            match counts
7808                .iter_mut()
7809                .find(|(p, _, _)| p.distance(at) <= tol.confusion() * 10.0)
7810            {
7811                Some((_, _, n)) => *n += 1,
7812                None => counts.push((at, v.clone(), 1)),
7813            }
7814        }
7815    }
7816    let free: Vec<(Point, Shape)> = counts
7817        .into_iter()
7818        .filter(|(_, _, n)| *n == 1)
7819        .map(|(p, v, _)| (p, v))
7820        .collect();
7821    if free.len() != 2 {
7822        ogeom_bail!(
7823            Construction,
7824            "an open profile has exactly two ends; this one has {}",
7825            free.len()
7826        );
7827    }
7828    let mut ends = free.into_iter();
7829    let (Some(a), Some(b)) = (ends.next(), ends.next()) else {
7830        ogeom_bail!(Construction, "the profile lost an end between checks");
7831    };
7832    Ok([a, b])
7833}
7834
7835/// The spine, edge by edge, in the order the wire runs it.
7836fn spine_stations(model: &Model, wire: &Shape, tol: Tolerances) -> OgeomResult<Vec<Station>> {
7837    let mut out = Vec::new();
7838    for edge in explore(model, wire, Filter::OfType(ShapeType::Edge))? {
7839        let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
7840            ogeom_bail!(Construction, "a spine edge is not in this model");
7841        };
7842        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
7843            ogeom_bail!(Construction, "a spine edge with no curve runs nowhere");
7844        };
7845        let Some(geometry) = model.geometry().curve(*curve) else {
7846            ogeom_bail!(Dangling, "curve is not in this model");
7847        };
7848        let placed = geometry.transformed(&edge.transform(model.datums())?, tol)?;
7849        let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
7850        let (t0, t1) = if reversed {
7851            (range.1, range.0)
7852        } else {
7853            (range.0, range.1)
7854        };
7855        let sign = if reversed { -1.0 } else { 1.0 };
7856        let unit = |t: f64| -> OgeomResult<Vector> {
7857            let d = placed.d1_at(t, tol)? * sign;
7858            if d.magnitude() <= tol.confusion() {
7859                ogeom_bail!(Construction, "a spine edge has no direction at {t}");
7860            }
7861            Ok(d / d.magnitude())
7862        };
7863        let station = match &placed {
7864            Curve::Line(_) => Station {
7865                from: placed.point_at(t0, tol)?,
7866                to: placed.point_at(t1, tol)?,
7867                tangent_in: unit(t0)?,
7868                tangent_out: unit(t1)?,
7869                turn: None,
7870            },
7871            Curve::Circle(c) => {
7872                let circle = c.circle();
7873                let swept = (range.1 - range.0).abs();
7874                let axis = ogeom_math::Axis {
7875                    location: circle.centre(),
7876                    direction: if reversed {
7877                        -circle.frame().z()
7878                    } else {
7879                        circle.frame().z()
7880                    },
7881                };
7882                Station {
7883                    from: placed.point_at(t0, tol)?,
7884                    to: placed.point_at(t1, tol)?,
7885                    tangent_in: unit(t0)?,
7886                    tangent_out: unit(t1)?,
7887                    turn: Some((axis, swept)),
7888                }
7889            }
7890            other => ogeom_bail!(
7891                Construction,
7892                "a spine runs on straight and circular edges; a {:?} sweeps a \
7893                 surface this construction does not have",
7894                other.kind()
7895            ),
7896        };
7897        out.push(station);
7898    }
7899    Ok(out)
7900}
7901
7902/// The spine's own normal, and the check that it has one.
7903///
7904/// Taken from the first turn the spine makes (a corner or an arc) because
7905/// that is exact, and then measured against every station: a spine that
7906/// leaves its own plane has no square profile to carry, and says so here
7907/// rather than by producing a shape nobody asked for.
7908fn spine_normal(stations: &[Station], tol: Tolerances) -> OgeomResult<Direction> {
7909    let mut best: Option<(f64, Vector)> = None;
7910    let mut consider = |a: Vector, b: Vector| {
7911        let cross = a.cross(b);
7912        let magnitude = cross.magnitude();
7913        if magnitude > best.map_or(tol.angular(), |(m, _)| m) {
7914            best = Some((magnitude, cross / magnitude));
7915        }
7916    };
7917    for (index, station) in stations.iter().enumerate() {
7918        consider(station.tangent_in, station.tangent_out);
7919        if index + 1 < stations.len() {
7920            consider(station.tangent_out, stations[index + 1].tangent_in);
7921        }
7922    }
7923    if stations.len() > 1 {
7924        consider(
7925            stations[stations.len() - 1].tangent_out,
7926            stations[0].tangent_in,
7927        );
7928    }
7929    let Some((_, normal)) = best else {
7930        ogeom_bail!(
7931            Construction,
7932            "a spine that never turns has no plane of its own; give the \
7933             profile's own orientation a spine with at least one corner or arc"
7934        );
7935    };
7936    for station in stations {
7937        for tangent in [station.tangent_in, station.tangent_out] {
7938            if tangent.dot(normal).abs() > tol.angular() {
7939                ogeom_bail!(
7940                    Construction,
7941                    "the spine leaves its own plane; an evolved sweep runs a \
7942                     planar spine"
7943                );
7944            }
7945        }
7946        if let Some((axis, _)) = &station.turn
7947            && axis.direction.vector().cross(normal).magnitude() > tol.angular()
7948        {
7949            ogeom_bail!(
7950                Construction,
7951                "a spine arc turns about an axis off the spine's own normal"
7952            );
7953        }
7954    }
7955    Direction::new(normal, tol)
7956}
7957
7958/// The profile's plane: a point on it and its normal.
7959fn profile_plane(model: &Model, profile: &Shape, tol: Tolerances) -> OgeomResult<(Point, Vector)> {
7960    let mut points: Vec<Point> = Vec::new();
7961    for edge in explore(model, profile, Filter::OfType(ShapeType::Edge))? {
7962        let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
7963            continue;
7964        };
7965        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
7966            continue;
7967        };
7968        let Some(geometry) = model.geometry().curve(*curve) else {
7969            ogeom_bail!(Dangling, "curve is not in this model");
7970        };
7971        let placed = geometry.transformed(&edge.transform(model.datums())?, tol)?;
7972        for i in 0..=8 {
7973            let t = range.0 + (range.1 - range.0) * f64::from(i) / 8.0;
7974            points.push(placed.point_at(t, tol)?);
7975        }
7976    }
7977    if points.len() < 3 {
7978        ogeom_bail!(Construction, "a profile needs an extent to sweep");
7979    }
7980    let origin = points[0];
7981    // The widest cross product among the sampled offsets: the plane's normal,
7982    // taken where it is best conditioned rather than from the first three
7983    // points that happen to be there.
7984    let mut best: Option<(f64, Vector)> = None;
7985    for (i, a) in points.iter().enumerate() {
7986        for b in points.iter().skip(i + 1) {
7987            let cross = (*a - origin).cross(*b - origin);
7988            let magnitude = cross.magnitude();
7989            if magnitude > best.map_or(tol.confusion(), |(m, _)| m) {
7990                best = Some((magnitude, cross / magnitude));
7991            }
7992        }
7993    }
7994    let Some((_, normal)) = best else {
7995        ogeom_bail!(Construction, "a profile with no area has no plane");
7996    };
7997    for p in &points {
7998        if (*p - origin).dot(normal).abs() > tol.confusion() * 1e2 {
7999            ogeom_bail!(Construction, "the profile is not planar");
8000        }
8001    }
8002    Ok((origin, normal))
8003}
8004
8005/// The rigid motion that carries the profile from the spine's start to a
8006/// station: a turn about the spine's normal, then a translation.
8007fn station_transform(
8008    reference: (Point, Vector),
8009    at: Point,
8010    tangent: Vector,
8011    normal: Direction,
8012    tol: Tolerances,
8013) -> OgeomResult<Transform> {
8014    let (origin, from) = reference;
8015    let n = normal.vector();
8016    let angle = from.cross(tangent).dot(n).atan2(from.dot(tangent));
8017    let turn = if angle.abs() <= tol.angular() {
8018        Transform::IDENTITY
8019    } else {
8020        Transform::rotation(
8021            ogeom_math::Axis {
8022                location: origin,
8023                direction: normal,
8024            },
8025            angle,
8026        )
8027    };
8028    Ok(Transform::translation(at - origin) * turn)
8029}
8030
8031/// The wedge a corner adds: the profile turned about the corner, through
8032/// exactly the angle the spine turns there.
8033///
8034/// `None` where the spine does not turn; two edges meeting smoothly leave no
8035/// wedge to fill.
8036#[allow(clippy::too_many_arguments)]
8037fn corner_piece(
8038    model: &mut Model,
8039    profile: &Shape,
8040    reference: (Point, Vector),
8041    corner: Point,
8042    incoming: Vector,
8043    outgoing: Vector,
8044    normal: Direction,
8045    tol: Tolerances,
8046) -> OgeomResult<Option<Shape>> {
8047    let n = normal.vector();
8048    let angle = incoming
8049        .cross(outgoing)
8050        .dot(n)
8051        .atan2(incoming.dot(outgoing));
8052    if angle.abs() <= tol.angular() {
8053        return Ok(None);
8054    }
8055    let placed = ogeom_algo::transformed(
8056        model,
8057        profile,
8058        station_transform(reference, corner, incoming, normal, tol)?,
8059    )?
8060    .shape;
8061    let axis = ogeom_math::Axis {
8062        location: corner,
8063        direction: if angle > 0.0 { normal } else { -normal },
8064    };
8065    let turned = ogeom_algo::make_revolution(model, &placed, axis, angle.abs(), tol);
8066    match turned {
8067        Ok(built) => Ok(Some(built.shape)),
8068        Err(_) => ogeom_bail!(
8069            NotDone,
8070            "the profile straddles the spine at a corner, so turning it about \
8071             that corner sweeps it through itself; there is no revolution for \
8072             that wedge"
8073        ),
8074    }
8075}
8076
8077/// Pieces laid end to end, fused: pairs of neighbours first, then pairs of
8078/// those, so each fuse joins two runs of about equal size where one at a
8079/// time fused every piece onto everything before it, redoing the whole run
8080/// so far each time. Neighbours stay neighbours, so every fuse still meets
8081/// its partner across the shared section. `None` for no pieces.
8082fn fuse_in_order(
8083    model: &mut Model,
8084    pieces: Vec<Shape>,
8085    tol: Tolerances,
8086) -> OgeomResult<Option<Shape>> {
8087    let mut runs = pieces;
8088    while runs.len() > 1 {
8089        let mut next = Vec::with_capacity(runs.len().div_ceil(2));
8090        let mut pending = runs.into_iter();
8091        while let Some(first) = pending.next() {
8092            match pending.next() {
8093                Some(second) => next.push(ogeom_bool::fuse(model, &first, &second, tol)?.shape),
8094                None => next.push(first),
8095            }
8096        }
8097        runs = next;
8098    }
8099    Ok(runs.pop())
8100}