Skip to main content

ogeom_algo/
build.rs

1//! Building topology from geometry.
2//!
3//! One level above [`Model`]'s raw builders: these take geometry, derive the
4//! topology it implies, check the invariants that need geometry to check, and
5//! report history.
6//!
7//! # What "checked" means here
8//!
9//! [`Model::add_wire`] verifies that its children are edges. That is all it can
10//! do, because it has no geometry. [`make_wire`] additionally verifies that
11//! consecutive edges actually *meet*, which is the property that makes a wire
12//! a connected path rather than a bag of edges, and which every algorithm
13//! downstream assumes without checking. A wire whose edges do not join produces
14//! a face with a gap in its boundary, and the first thing to notice is usually
15//! a boolean, several operations later.
16
17use ogeom_core::FastMap;
18
19use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
20use ogeom_geom::{Curve, Curve3d, SurfaceGeometry};
21use ogeom_math::{Point, Point2};
22use ogeom_topo::{EdgeData, EdgeRepr, FaceData, Location, Model, Shape, ShapeType, VertexData};
23
24use crate::history::{Built, History};
25
26/// Roles a builder assigns, so a rebuild can match entities up.
27pub mod roles {
28    use ogeom_core::Role;
29
30    /// The vertex an edge starts at.
31    pub const EDGE_START: Role = Role::op_defined(0);
32    /// The vertex an edge ends at.
33    pub const EDGE_END: Role = Role::op_defined(1);
34}
35
36/// Add a vertex at `point`.
37pub fn make_vertex(model: &mut Model, point: Point) -> Built {
38    Built::from_nothing(model.add_vertex(VertexData::new(point)))
39}
40
41/// Build an edge on `curve`, bounded by its own endpoints.
42///
43/// Creates the two bounding vertices from the curve's ends, so the edge's
44/// topology and its geometry cannot disagree about where it starts and stops. A
45/// closed curve gets one vertex named twice, which is what keeps "walk to the
46/// end" meaningful for a full circle.
47///
48/// # Errors
49///
50/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if `range` leaves the curve's
51/// domain, and [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if it
52/// is empty.
53pub fn make_edge(
54    model: &mut Model,
55    curve: Curve,
56    range: (f64, f64),
57    tol: Tolerances,
58) -> OgeomResult<Built> {
59    let (lo, hi) = range;
60    if !lo.is_finite() || !hi.is_finite() || hi <= lo + tol.parametric() {
61        ogeom_bail!(Construction, "edge range [{lo}, {hi}] is empty");
62    }
63    let start = curve.point_at(lo, tol)?;
64    let end = curve.point_at(hi, tol)?;
65
66    let start_vertex = model.add_vertex(VertexData::new(start));
67    // A closed edge names one vertex twice rather than two coincident ones:
68    // two would leave the wire looking open at the join.
69    let end_vertex = if start.is_equal(end, tol) {
70        start_vertex.clone()
71    } else {
72        model.add_vertex(VertexData::new(end))
73    };
74
75    let id = model.geometry_mut().add_curve(curve);
76    let data = EdgeData::on_curve(id, Location::identity(), range);
77    let edge = model.add_edge(data, &[start_vertex.clone(), end_vertex.clone()])?;
78
79    model.set_derived(
80        &start_vertex,
81        std::slice::from_ref(&edge),
82        roles::EDGE_START,
83    )?;
84    if !end_vertex.is_same(&start_vertex) {
85        model.set_derived(&end_vertex, std::slice::from_ref(&edge), roles::EDGE_END)?;
86    }
87
88    let mut history = History::new();
89    history.generate(&edge, start_vertex);
90    if !end_vertex.is_same(&edge) {
91        history.generate(&edge, end_vertex);
92    }
93    Ok(Built::new(edge, history))
94}
95
96/// Build an edge between two existing vertices, on `curve`.
97///
98/// # Errors
99///
100/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if either shape is
101/// not a vertex, or the curve's ends do not reach them within tolerance.
102pub fn make_edge_between(
103    model: &mut Model,
104    curve: Curve,
105    range: (f64, f64),
106    from: &Shape,
107    to: &Shape,
108    tol: Tolerances,
109) -> OgeomResult<Built> {
110    for v in [from, to] {
111        if model.kind_of(v)? != ShapeType::Vertex {
112            ogeom_bail!(Construction, "an edge is bounded by vertices");
113        }
114    }
115    // The geometry has to actually reach the vertices it claims to join. An
116    // edge whose curve stops short leaves a gap that only shows up later, in
117    // whatever first tries to walk the boundary.
118    let ends = [(range.0, from), (range.1, to)];
119    for (parameter, vertex) in ends {
120        let Some(node) = model.node(vertex) else {
121            ogeom_bail!(Dangling, "vertex is not in this model");
122        };
123        let Some(data) = node.data().as_vertex() else {
124            ogeom_bail!(Construction, "vertex node holds no point");
125        };
126        // Through the vertex's own placement, not against its stored point. A
127        // vertex is a triple like any other shape, and the same node appears at
128        // different places: the two ends of a prism are one vertex twice.
129        // Comparing against the stored point would put both ends at the origin
130        // of the placement and reject every edge that joins them.
131        let placed = vertex.transform(model.datums())?.apply(data.point);
132        let on_curve = curve.point_at(parameter, tol)?;
133        let reach = data.tolerance.get().max(tol.confusion());
134        if !on_curve.is_within(placed, reach) {
135            if std::env::var_os("OGEOM_DEBUG_EDGE").is_some() {
136                eprintln!(
137                    "EDGE MISS range {range:?} at {parameter}: curve {on_curve:?} vertex {placed:?} kind {}",
138                    match &curve {
139                        Curve::Line(_) => "line",
140                        Curve::Circle(_) => "circle",
141                        Curve::BSpline(b) =>
142                            if b.degree() == 1 {
143                                "bspline-1"
144                            } else {
145                                "bspline"
146                            },
147                        _ => "other",
148                    }
149                );
150            }
151            ogeom_bail!(
152                Construction,
153                "curve at {parameter} is {} from the vertex it should meet, \
154                 outside its tolerance of {reach}",
155                on_curve.distance(placed)
156            );
157        }
158    }
159
160    let id = model.geometry_mut().add_curve(curve);
161    let data = EdgeData::on_curve(id, Location::identity(), range);
162    let edge = model.add_edge(data, &[from.clone(), to.clone()])?;
163    Ok(Built::from_nothing(edge))
164}
165
166/// The vertices an edge runs between, in the direction it is traversed.
167///
168/// A reversed edge runs the other way, so its start is its stored second
169/// bound. Every caller that walks a boundary needs this, and getting it from
170/// the raw children instead is how a wire ends up appearing disconnected.
171///
172/// # Errors
173///
174/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `edge` is not
175/// an edge.
176pub fn edge_vertices(model: &Model, edge: &Shape) -> OgeomResult<Option<(Shape, Shape)>> {
177    if model.kind_of(edge)? != ShapeType::Edge {
178        ogeom_bail!(Construction, "expected an edge");
179    }
180    let bounds = model.children_of(edge)?;
181    let (first, last) = match bounds.len() {
182        0 => return Ok(None),
183        1 => (bounds[0].clone(), bounds[0].clone()),
184        _ => (bounds[0].clone(), bounds[bounds.len() - 1].clone()),
185    };
186    Ok(Some(
187        if edge.orientation() == ogeom_topo::Orientation::Reversed {
188            (last, first)
189        } else {
190            (first, last)
191        },
192    ))
193}
194
195/// An edge occurrence's curve where the occurrence stands, and the range of
196/// it the edge covers; `None` for an edge with no curve in space.
197///
198/// The curve is moved by the curve's own location and then by the
199/// occurrence's, so a reader building geometry from it builds it where the
200/// edge is. A placement with a scale rescales a length-parameterized
201/// curve's domain, and the range is carried with it. An unplaced edge's
202/// curve comes back as stored.
203///
204/// # Errors
205///
206/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if the edge,
207/// its curve or a datum is not in `model`; as
208/// [`Transformable::transformed`](ogeom_geom::Transformable::transformed).
209pub fn placed_edge_curve(
210    model: &Model,
211    edge: &Shape,
212    tol: Tolerances,
213) -> OgeomResult<Option<(Curve, (f64, f64))>> {
214    use ogeom_geom::Transformable as _;
215    let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
216        ogeom_bail!(Dangling, "edge is not in this model");
217    };
218    let Some(EdgeRepr::Curve3d {
219        curve,
220        location,
221        range,
222    }) = data.curve3d()
223    else {
224        return Ok(None);
225    };
226    let Some(stored) = model.geometry().curve(*curve) else {
227        ogeom_bail!(Dangling, "curve is not in this model");
228    };
229    if location.is_identity() && edge.location().is_identity() {
230        return Ok(Some((stored.clone(), *range)));
231    }
232    let placement = edge.transform(model.datums())? * location.composed(model.datums())?;
233    let placed = stored.transformed(&placement, tol)?;
234    let (from, to) = (stored.domain(), placed.domain());
235    let range = (
236        ogeom_geom::carried(range.0, from, to),
237        ogeom_geom::carried(range.1, from, to),
238    );
239    Ok(Some((placed, range)))
240}
241
242/// An edge's curve where its occurrence places it in the face it bounds,
243/// for a pcurve on that face's surface: `edge` is the occurrence as the
244/// face's wire holds it. The pcurve runs on the edge's own parameter, so an
245/// occurrence placed with a scale, which carries the curve to another
246/// parameter, is refused.
247///
248/// # Errors
249///
250/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
251/// edge has no curve in space or its placement rescales its parameter; as
252/// [`placed_edge_curve`].
253pub(crate) fn charted_edge_curve(
254    model: &Model,
255    edge: &Shape,
256    tol: Tolerances,
257) -> OgeomResult<(Curve, (f64, f64))> {
258    let Some((curve, range)) = placed_edge_curve(model, edge, tol)? else {
259        ogeom_bail!(Construction, "a face edge has no 3D curve");
260    };
261    let stored = model
262        .node(edge)
263        .and_then(|n| n.data().as_edge())
264        .and_then(EdgeData::curve3d)
265        .and_then(EdgeRepr::range);
266    if stored != Some(range) {
267        ogeom_bail!(
268            Construction,
269            "an edge placed with a scale runs on another parameter than its \
270             curve; bake its placement before charting it on a face"
271        );
272    }
273    Ok((curve, range))
274}
275
276/// Build a wire of straight segments through a sequence of points.
277///
278/// `closed` adds a final segment back to the first point, and does it by
279/// naming the *first vertex again* rather than making a coincident second one,
280/// which is what keeps the wire closed under [`is_wire_closed`] rather than
281/// merely looking closed.
282///
283/// # Errors
284///
285/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if there are fewer
286/// than two points, fewer than three for a closed polygon, if consecutive
287/// points coincide within tolerance (a zero-length edge is not an edge), or if
288/// a closed polygon's ends do not meet.
289pub fn make_polygon(
290    model: &mut Model,
291    points: &[Point],
292    closed: bool,
293    tol: Tolerances,
294) -> OgeomResult<Built> {
295    let least = if closed { 3 } else { 2 };
296    if points.len() < least {
297        ogeom_bail!(
298            Construction,
299            "a {} polygon needs at least {least} points, got {}",
300            if closed { "closed" } else { "open" },
301            points.len()
302        );
303    }
304    for i in 1..points.len() {
305        if points[i].is_equal(points[i - 1], tol) {
306            ogeom_bail!(
307                Construction,
308                "points {} and {i} coincide, so the edge between them has no \
309                 length",
310                i - 1
311            );
312        }
313    }
314    // Whether or not `closed` was asked for. A caller that repeated the first
315    // point at the end wants a loop, and building it as written would give the
316    // wire two vertices in the same place, which every later boundary walk
317    // treats as a gap that happens to be zero wide. `closed` produces the loop
318    // by naming the first vertex again, which is the thing that actually
319    // closes.
320    if points[0].is_equal(points[points.len() - 1], tol) {
321        ogeom_bail!(
322            Construction,
323            "the first and last points coincide; pass `closed` and drop the \
324             repeat, or the wire gets two vertices in one place rather than a \
325             closed loop"
326        );
327    }
328
329    let mut vertices: Vec<Shape> = Vec::with_capacity(points.len());
330    for point in points {
331        vertices.push(model.add_vertex(VertexData::new(*point)));
332    }
333
334    let mut edges = Vec::with_capacity(points.len());
335    let segments = if closed {
336        points.len()
337    } else {
338        points.len() - 1
339    };
340    for i in 0..segments {
341        let (from, to) = (points[i], points[(i + 1) % points.len()]);
342        let curve: Curve = ogeom_geom::LineCurve::segment(from, to, tol)?.into();
343        edges.push(
344            make_edge_between(
345                model,
346                curve,
347                (0.0, from.distance(to)),
348                &vertices[i],
349                // The closing segment names the first vertex again rather than
350                // a second one at the same place.
351                &vertices[(i + 1) % points.len()],
352                tol,
353            )?
354            .shape,
355        );
356    }
357    make_wire(model, &edges, tol)
358}
359
360/// The plane a shape lies in, if it lies in one.
361///
362/// Fitted to the shape's geometry and then *checked*: the fit always produces a
363/// plane, and the answer is only useful if every sample is within tolerance of
364/// it. `None` means the shape is not planar, which is a fact about the shape
365/// rather than a failure.
366///
367/// Curved edges are sampled along their length, not only at their ends. A
368/// circular arc's endpoints lie in a great many planes that the arc does not.
369///
370/// # Errors
371///
372/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if a handle fails to
373/// resolve.
374pub fn find_plane(
375    model: &Model,
376    shape: &Shape,
377    tol: Tolerances,
378) -> OgeomResult<Option<ogeom_math::Plane>> {
379    let points = sample_shape(model, shape, tol)?;
380    if points.len() < 3 {
381        return Ok(None);
382    }
383    let Some((centroid, normal)) = crate::measure::least_squares_plane(&points, tol) else {
384        return Ok(None);
385    };
386    // Fitting always yields a plane. Whether the shape is *in* it is the
387    // question, and it is answered by measuring, not by having fitted.
388    let reach = tol.confusion().max(
389        points
390            .iter()
391            .map(|p| normal.dot_vector(*p - centroid).abs())
392            .fold(0.0_f64, f64::max),
393    );
394    if reach > tol.confusion() {
395        return Ok(None);
396    }
397    Ok(Some(ogeom_math::Plane::new(ogeom_math::Frame::about(
398        centroid, normal,
399    ))))
400}
401
402/// Points along a shape's geometry: every vertex, and every curved edge sampled
403/// along its length.
404fn sample_shape(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Vec<Point>> {
405    /// Enough to catch a curve leaving a candidate plane, without the cost of a
406    /// real discretization; this is a yes-or-no question, not a mesh.
407    const ALONG_EDGE: usize = 8;
408
409    let mut points = Vec::new();
410    for vertex in ogeom_topo::explore_unique(model, shape, ShapeType::Vertex)? {
411        if let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) {
412            points.push(vertex.transform(model.datums())?.apply(data.point));
413        }
414    }
415    for edge in ogeom_topo::explore_unique(model, shape, ShapeType::Edge)? {
416        let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
417            continue;
418        };
419        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
420            continue;
421        };
422        let Some(geometry) = model.geometry().curve(*curve) else {
423            continue;
424        };
425        if geometry.kind() == ogeom_geom::CurveKind::Line {
426            continue;
427        }
428        let placement = edge.transform(model.datums())?;
429        for i in 1..ALONG_EDGE {
430            #[allow(clippy::cast_precision_loss)]
431            let t = i as f64 / ALONG_EDGE as f64;
432            let at = range.0 + (range.1 - range.0) * t;
433            points.push(placement.apply(geometry.point_at(at, tol)?));
434        }
435    }
436    Ok(points)
437}
438
439/// Build a wire from edges that meet end to end.
440///
441/// # Errors
442///
443/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a child is not
444/// an edge, the list is empty, or consecutive edges do not share a vertex.
445pub fn make_wire(model: &mut Model, edges: &[Shape], tol: Tolerances) -> OgeomResult<Built> {
446    if edges.is_empty() {
447        ogeom_bail!(Construction, "a wire needs at least one edge");
448    }
449    check_connected(model, edges, tol)?;
450
451    let wire = model.add_wire(edges)?;
452    let mut history = History::new();
453    for edge in edges {
454        // The edges are not consumed: they are shared, and an edge between two
455        // faces belongs to both wires. Reporting them modified into the wire
456        // would claim the edge ceased to exist.
457        history.generate(edge, wire.clone());
458    }
459    Ok(Built::new(wire, history))
460}
461
462/// Verify that consecutive edges share a vertex.
463fn check_connected(model: &Model, edges: &[Shape], tol: Tolerances) -> OgeomResult<()> {
464    let mut ends: Vec<Option<(Shape, Shape)>> = Vec::with_capacity(edges.len());
465    for edge in edges {
466        ends.push(edge_vertices(model, edge)?);
467    }
468    for i in 0..edges.len().saturating_sub(1) {
469        let (Some((_, end)), Some((next_start, _))) = (&ends[i], &ends[i + 1]) else {
470            // An unbounded edge cannot be shown to join anything, and pretending
471            // otherwise is worse than saying so.
472            ogeom_bail!(
473                Construction,
474                "edge {i} or {} has no bounding vertices, so the wire cannot be \
475                 shown to connect",
476                i + 1
477            );
478        };
479        if !end.is_same(next_start)
480            && !model.same_position(end, next_start, tol)?
481            && !one_point(model, end, next_start, tol)?
482        {
483            if std::env::var("OGEOM_DEBUG_WIRE").is_ok()
484                && let (Some(a), Some(b)) = (
485                    model.node(end).and_then(|n| n.data().as_vertex().cloned()),
486                    model
487                        .node(next_start)
488                        .and_then(|n| n.data().as_vertex().cloned()),
489                )
490            {
491                eprintln!(
492                    "WIRE GAP: {:?} (tol {:.2e}) vs {:?} (tol {:.2e}), gap {:.3e}",
493                    a.point,
494                    a.tolerance.get(),
495                    b.point,
496                    b.tolerance.get(),
497                    a.point.distance(b.point)
498                );
499            }
500            ogeom_bail!(
501                Construction,
502                "edge {i} ends where edge {} does not begin; a wire whose edges \
503                 do not meet leaves a gap in every face built on it",
504                i + 1
505            );
506        }
507    }
508    Ok(())
509}
510
511/// Whether a wire's last edge returns to its first edge's start.
512///
513/// # Errors
514///
515/// As [`edge_vertices`].
516pub fn is_wire_closed(model: &Model, wire: &Shape, tol: Tolerances) -> OgeomResult<bool> {
517    let edges = model.children_of(wire)?;
518    let (Some(first), Some(last)) = (edges.first(), edges.last()) else {
519        return Ok(false);
520    };
521    let (Some((start, _)), Some((_, end))) =
522        (edge_vertices(model, first)?, edge_vertices(model, last)?)
523    else {
524        return Ok(false);
525    };
526    Ok(start.is_same(&end)
527        || model.same_position(&start, &end, tol)?
528        || one_point(model, &start, &end, tol)?)
529}
530
531/// Whether two occurrences of one vertex node land on one point: a vertex
532/// on a revolution's axis, turned, is itself under another placement.
533pub(crate) fn one_point(model: &Model, a: &Shape, b: &Shape, tol: Tolerances) -> OgeomResult<bool> {
534    if a.node() != b.node() {
535        return Ok(false);
536    }
537    let Some(data) = model.node(a).and_then(|n| n.data().as_vertex()) else {
538        return Ok(false);
539    };
540    let pa = a.transform(model.datums())?.apply(data.point);
541    let pb = b.transform(model.datums())?.apply(data.point);
542    Ok(pa.distance(pb) <= data.tolerance.get().max(tol.confusion()))
543}
544
545/// A fresh wire walking `wire`'s edges the other way round.
546pub(crate) fn walked_back(model: &mut Model, wire: &Shape, tol: Tolerances) -> OgeomResult<Shape> {
547    let edges = model.ordered_children_of(&wire.reversed())?;
548    Ok(make_wire(model, &edges, tol)?.shape)
549}
550
551/// How a wire turns about `axis`: twice the area it encloses projected
552/// square to the axis, signed by the right-hand rule, from points sampled
553/// along its edges in traversal order.
554pub(crate) fn wire_turn(
555    model: &Model,
556    wire: &Shape,
557    axis: ogeom_math::Vector,
558    tol: Tolerances,
559) -> OgeomResult<f64> {
560    edges_turn(model, &model.ordered_children_of(wire)?, axis, tol)
561}
562
563/// How a closed walk of edge occurrences turns about `axis`: twice the
564/// area it encloses projected square to the axis, signed by the right-hand
565/// rule, from points sampled along the edges in traversal order. Positive
566/// for a ring walked counter-clockwise about `axis`.
567///
568/// # Errors
569///
570/// As the model's placements and the curves' evaluation.
571pub fn edges_turn(
572    model: &Model,
573    edges: &[Shape],
574    axis: ogeom_math::Vector,
575    tol: Tolerances,
576) -> OgeomResult<f64> {
577    let mut points: Vec<ogeom_math::Point> = Vec::new();
578    for edge in edges {
579        let Some(EdgeRepr::Curve3d { curve, range, .. }) = model
580            .node(edge)
581            .and_then(|n| n.data().as_edge())
582            .and_then(|d| d.curve3d())
583        else {
584            continue;
585        };
586        let Some(geometry) = model.geometry().curve(*curve) else {
587            continue;
588        };
589        let placement = edge.transform(model.datums())?;
590        const SAMPLES: u32 = 16;
591        for k in 0..SAMPLES {
592            let f = f64::from(k) / f64::from(SAMPLES);
593            let t = if edge.orientation() == ogeom_topo::Orientation::Reversed {
594                range.1 + (range.0 - range.1) * f
595            } else {
596                range.0 + (range.1 - range.0) * f
597            };
598            points.push(placement.apply(geometry.point_at(t, tol)?));
599        }
600    }
601    // About the walk's own first point: the sum is the same about any
602    // point, and about one far off it is a difference of huge products.
603    let Some(&anchor) = points.first() else {
604        return Ok(0.0);
605    };
606    let mut newell = ogeom_math::Vector::ZERO;
607    for i in 0..points.len() {
608        let (a, b) = (points[i], points[(i + 1) % points.len()]);
609        newell += (a - anchor).cross(b - anchor);
610    }
611    Ok(newell.dot(axis))
612}
613
614/// Build a face on `surface`, bounded by `wires`.
615///
616/// The first wire is the outer boundary; any others are holes. Every wire must
617/// be closed, since an open boundary encloses nothing. On a plane the face
618/// faces the plane's normal and walks its outer boundary counter-clockwise
619/// about it and its holes clockwise, whichever way the wires were walked.
620///
621/// # Errors
622///
623/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a wire is open
624/// or is not a wire.
625pub fn make_face(
626    model: &mut Model,
627    surface: SurfaceGeometry,
628    wires: &[Shape],
629    tol: Tolerances,
630) -> OgeomResult<Built> {
631    for (i, wire) in wires.iter().enumerate() {
632        if model.kind_of(wire)? != ShapeType::Wire {
633            ogeom_bail!(Construction, "a face is bounded by wires");
634        }
635        if !is_wire_closed(model, wire, tol)? {
636            ogeom_bail!(
637                Construction,
638                "wire {i} is open; an open boundary encloses no area"
639            );
640        }
641    }
642
643    let wound = wound_to_plane(model, &surface, wires, tol)?;
644    let id = model.geometry_mut().add_surface(surface);
645    let mut built = make_face_on(model, id, &wound, tol)?;
646    for (given, used) in wires.iter().zip(&wound) {
647        if !given.is_same(used) {
648            built.history.generate(given, built.shape.clone());
649        }
650    }
651    Ok(built)
652}
653
654/// `wires` as a face on `surface` walks them: on a plane, the outer ring
655/// counter-clockwise about the plane's normal and each hole clockwise, so
656/// the material lies on the left of every ring seen from the side the
657/// face faces. A ring wound the other way is replaced by a fresh wire
658/// walking its edges back; the plane, and so the face's normal, is kept.
659/// Any other surface, and a ring enclosing no area, is left as given.
660fn wound_to_plane(
661    model: &mut Model,
662    surface: &SurfaceGeometry,
663    wires: &[Shape],
664    tol: Tolerances,
665) -> OgeomResult<Vec<Shape>> {
666    use ogeom_geom::Surface as _;
667    let SurfaceGeometry::Plane(plane) = surface else {
668        return Ok(wires.to_vec());
669    };
670    let normal = plane.normal_at(0.0, 0.0, tol)?.vector();
671    let mut wound = Vec::with_capacity(wires.len());
672    for (i, wire) in wires.iter().enumerate() {
673        let turn = wire_turn(model, wire, normal, tol)?;
674        let want = if i == 0 { 1.0 } else { -1.0 };
675        // Twice the enclosed area: a ring closing on itself within the
676        // confusion distance encloses nothing to wind about.
677        let against = turn * want < -tol.confusion() * tol.confusion();
678        wound.push(if against {
679            walked_back(model, wire, tol)?
680        } else {
681            wire.clone()
682        });
683    }
684    Ok(wound)
685}
686
687/// Build a face on a surface the model already holds.
688///
689/// The distinction from [`make_face`] matters more than it looks: a pcurve
690/// names the surface it is drawn on by id, so an edge's pcurve and the face
691/// bounded by that edge have to name the *same* id. Registering the surface
692/// twice gives two ids for one surface, and every lookup of "the pcurve on this
693/// face" comes back empty even though the pcurve is right there.
694///
695/// # Errors
696///
697/// As [`make_face`].
698pub fn make_face_on(
699    model: &mut Model,
700    surface: ogeom_topo::SurfaceId,
701    wires: &[Shape],
702    tol: Tolerances,
703) -> OgeomResult<Built> {
704    for (i, wire) in wires.iter().enumerate() {
705        if model.kind_of(wire)? != ShapeType::Wire {
706            ogeom_bail!(Construction, "a face is bounded by wires");
707        }
708        if !is_wire_closed(model, wire, tol)? {
709            ogeom_bail!(
710                Construction,
711                "wire {i} is open; an open boundary encloses no area"
712            );
713        }
714    }
715
716    let data = FaceData::new(surface, Location::identity());
717    let face = model.add_face(data, wires)?;
718
719    let mut history = History::new();
720    for wire in wires {
721        history.generate(wire, face.clone());
722    }
723    Ok(Built::new(face, history))
724}
725
726/// Build a face on `surface` from per-wire edge lists, attaching an exact
727/// same-parameter pcurve to every edge.
728///
729/// The construction path for faces whose curves were *chosen* to have
730/// closed-form charts: blend wedges, offset rebuilds. Every edge's curve
731/// must lie on the surface in a configuration
732/// [`ogeom_intersect::exact_pcurve_of`] recognises; a fitted pcurve here would
733/// manufacture disagreement where none exists, so an edge with no closed
734/// form is refused instead.
735///
736/// Each ring is walked as its edges are listed, on a plane too: a rebuilt
737/// face whose ring winds against its surface was driven through itself,
738/// and the face keeps that for the checks downstream to find.
739///
740/// # Errors
741///
742/// As [`make_wire`] and [`make_face`], and
743/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if an edge has
744/// no 3D curve or no closed-form pcurve on `surface`.
745pub fn make_face_with_pcurves(
746    model: &mut Model,
747    surface: SurfaceGeometry,
748    wires: &[Vec<Shape>],
749    tol: Tolerances,
750) -> OgeomResult<Built> {
751    let mut rings: Vec<Shape> = Vec::with_capacity(wires.len());
752    for edges in wires {
753        rings.push(make_wire(model, edges, tol)?.shape);
754    }
755    let id = model.geometry_mut().add_surface(surface.clone());
756    let built = make_face_on(model, id, &rings, tol)?;
757    let surface_id = {
758        let Some(node) = model.node(&built.shape) else {
759            ogeom_bail!(Dangling, "the face just built is not in this model");
760        };
761        let ogeom_topo::NodeData::Face(data) = node.data() else {
762            ogeom_bail!(Construction, "the face holds no face data");
763        };
764        data.surface
765    };
766    // Walked in each wire's own traversal order, so that on a periodic
767    // chart every pcurve lands on the *same branch* as its neighbour: an
768    // exact inversion answers with whatever phase its construction likes,
769    // and two images a full period apart describe the same points while
770    // tearing the boundary the arrangement walks. Each pcurve after the
771    // first is shifted by whole periods until its start meets the previous
772    // traversal's end.
773    use ogeom_geom::Curve2d as _;
774    use ogeom_geom::Surface as _;
775    let ((ua, ub), (va, vb)) = surface.domain();
776    let u_period = surface.is_periodic_u().then_some(ub - ua);
777    let v_period = surface.is_periodic_v().then_some(vb - va);
778    let mut done: Vec<Shape> = Vec::new();
779    for edges in wires {
780        let mut prev_end: Option<ogeom_math::Point2> = None;
781        for edge in edges {
782            let (curve, prange) = charted_edge_curve(model, edge, tol)?;
783            let mut pcurve = match ogeom_intersect::exact_pcurve_over(&curve, prange, &surface, tol)
784            {
785                Some(exact) => exact,
786                // No closed form: a fitted surface, mostly. The edge lies on
787                // the surface by construction here (a rebuilt boundary, a
788                // recovered intersection), so the projected fit speaks it: the
789                // same machinery the exchange readers trust, at the same cap.
790                // The edge is widened to the offset its samples sat at and to
791                // how far the fitted trim, lifted, stands from the curve
792                // anywhere along it.
793                None => {
794                    let (fitted, _, _, worst_off, _) =
795                        crate::pcurve_fit::fit_projected_pcurve(&curve, prange, &surface, tol)?;
796                    let gap = crate::pcurve_fit::lifted_gap(
797                        (&curve, prange),
798                        (&fitted, prange),
799                        &surface,
800                        false,
801                        tol,
802                    )?;
803                    let worst_off = worst_off.max(gap);
804                    if worst_off > tol.confusion() {
805                        // The edge owns the offset, and so must the vertices
806                        // that bound it: the containment rule.
807                        let widened = ogeom_core::Tolerance::new(worst_off + tol.confusion())?;
808                        model.widen(edge, widened)?;
809                        if let Some((a, b)) = edge_vertices(model, edge)? {
810                            model.widen(&a, widened)?;
811                            model.widen(&b, widened)?;
812                        }
813                    }
814                    fitted
815                }
816            };
817            let (t_start, t_end) = if edge.orientation() == ogeom_topo::Orientation::Reversed {
818                (prange.1, prange.0)
819            } else {
820                (prange.0, prange.1)
821            };
822            if u_period.is_some() || v_period.is_some() {
823                let start = pcurve.point_at(t_start, tol)?;
824                if let Some(prev) = prev_end {
825                    let shift = ogeom_math::Vector2::new(
826                        u_period.map_or(0.0, |p| ((prev.x - start.x) / p).round() * p),
827                        v_period.map_or(0.0, |p| ((prev.y - start.y) / p).round() * p),
828                    );
829                    if shift.x != 0.0 || shift.y != 0.0 {
830                        pcurve =
831                            pcurve.transformed(&ogeom_math::Transform2::translation(shift), tol)?;
832                    }
833                }
834                prev_end = Some(pcurve.point_at(t_end, tol)?);
835            }
836            if done.iter().any(|e| e.is_same(edge)) {
837                continue;
838            }
839            done.push(edge.clone());
840            attach_pcurve(
841                model,
842                edge,
843                pcurve,
844                surface_id,
845                edge.location().clone(),
846                prange,
847            )?;
848        }
849    }
850    Ok(built)
851}
852
853/// Put a face's wires on one branch of a periodic chart.
854///
855/// An exchange file's pcurves, or a fit of them, answer with whatever
856/// phase the inversion likes: a hole loop that straddles a drum's seam
857/// comes back half on each branch (a loop that never closes in the chart,
858/// which no mesher can cut), and a slitted wall's outer wire hops branches
859/// at every slit. Walked in each wire's own order, an image whose start
860/// misses the previous traversal's end by close to a whole period is
861/// shifted by that period; a miss of half a period is a pole, where two
862/// meridians meet at one point with no edge between them, and is left
863/// alone. A seam representation carries both branches by design: the
864/// walk passes through it on the side the occurrence uses and moves
865/// nothing. Every wire after the first is then carried whole onto the
866/// first wire's branch, the middle of the extent its images run over
867/// brought within half a period of the first's, so rims, slits, holes and
868/// rings closed by one edge all read in one chart.
869/// The images are rewritten in place (`GeometryStore::pcurve_mut`), once
870/// each; a slit uses one image twice.
871///
872/// # Errors
873///
874/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if a wire or
875/// edge is not in this model.
876pub fn chain_wire_branches(
877    model: &mut Model,
878    surface: ogeom_topo::SurfaceId,
879    wires: &[Shape],
880    tol: Tolerances,
881) -> OgeomResult<()> {
882    use ogeom_geom::Curve2d as _;
883    use ogeom_geom::Surface as _;
884    let Some(geometry) = model.geometry().surface(surface) else {
885        ogeom_bail!(Dangling, "surface is not in this model");
886    };
887    let ((ua, ub), (va, vb)) = geometry.domain();
888    let u_period = geometry.is_periodic_u().then_some(ub - ua);
889    let v_period = geometry.is_periodic_v().then_some(vb - va);
890    if u_period.is_none() && v_period.is_none() {
891        return Ok(());
892    }
893    // A whole number of periods, when the gap is within a quarter period
894    // of one; nothing for a pole's half-period hop.
895    let whole = |gap: f64, period: Option<f64>| -> f64 {
896        period.map_or(0.0, |p| {
897            let k = (gap / p).round();
898            if k != 0.0 && (gap - k * p).abs() <= p * 0.25 {
899                k * p
900            } else {
901                0.0
902            }
903        })
904    };
905    // A wire's images in traversal order: the pcurve to move (none for a
906    // seam), its start and end in the edge's own direction, and points
907    // along it, which place a loop closed by one edge whose ends say
908    // nothing of where it runs.
909    type Image = (
910        Option<ogeom_topo::PCurveId>,
911        ogeom_math::Point2,
912        ogeom_math::Point2,
913        Vec<ogeom_math::Point2>,
914    );
915    /// How many steps each image is sampled at for the wire's extent.
916    const STEPS: u32 = 8;
917    let images = |model: &Model, wire: &Shape| -> OgeomResult<Vec<Image>> {
918        let mut out = Vec::new();
919        for edge in model.ordered_children_of(wire)? {
920            let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
921                ogeom_bail!(Dangling, "edge is not in this model");
922            };
923            let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
924            let (id, movable, range) = match data.pcurve_for(surface, edge.location()) {
925                Some(EdgeRepr::PCurve { curve, range, .. }) => (*curve, true, *range),
926                Some(EdgeRepr::Seam {
927                    forward,
928                    reversed: back,
929                    range,
930                    ..
931                }) => (if reversed { *back } else { *forward }, false, *range),
932                _ => continue,
933            };
934            let Some(planar) = model.geometry().pcurve(id) else {
935                ogeom_bail!(Dangling, "pcurve is not in this model");
936            };
937            let (t_start, t_end) = if reversed {
938                (range.1, range.0)
939            } else {
940                (range.0, range.1)
941            };
942            let mut along = Vec::with_capacity(STEPS as usize - 1);
943            for k in 1..STEPS {
944                let t = range.0 + (range.1 - range.0) * f64::from(k) / f64::from(STEPS);
945                along.push(planar.point_at(t, tol)?);
946            }
947            out.push((
948                movable.then_some(id),
949                planar.point_at(t_start, tol)?,
950                planar.point_at(t_end, tol)?,
951                along,
952            ));
953        }
954        Ok(out)
955    };
956    let mut first_centre: Option<ogeom_math::Point2> = None;
957    for wire in wires {
958        let wire_images = images(model, wire)?;
959        if wire_images.is_empty() {
960            continue;
961        }
962        // The chain, dry: each image's shift so its start meets the
963        // previous end, an image already placed taking its earlier shift.
964        let mut shifts: Vec<(ogeom_topo::PCurveId, ogeom_math::Vector2)> = Vec::new();
965        let mut prev_end: Option<ogeom_math::Point2> = None;
966        let mut lo = ogeom_math::Point2::new(f64::INFINITY, f64::INFINITY);
967        let mut hi = ogeom_math::Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
968        for (id, start, end, along) in &wire_images {
969            let shift = match id {
970                None => ogeom_math::Vector2::new(0.0, 0.0),
971                Some(id) => match shifts.iter().find(|(seen, _)| seen == id) {
972                    Some((_, s)) => *s,
973                    None => {
974                        let s = prev_end.map_or(ogeom_math::Vector2::new(0.0, 0.0), |prev| {
975                            ogeom_math::Vector2::new(
976                                whole(prev.x - start.x, u_period),
977                                whole(prev.y - start.y, v_period),
978                            )
979                        });
980                        shifts.push((*id, s));
981                        s
982                    }
983                },
984            };
985            let (s, e) = (*start + shift, *end + shift);
986            for p in along.iter().map(|p| *p + shift).chain([s, e]) {
987                lo = ogeom_math::Point2::new(lo.x.min(p.x), lo.y.min(p.y));
988                hi = ogeom_math::Point2::new(hi.x.max(p.x), hi.y.max(p.y));
989            }
990            prev_end = Some(e);
991        }
992        // Then the whole wire onto the first wire's branch.
993        let centre = ogeom_math::Point2::new(f64::midpoint(lo.x, hi.x), f64::midpoint(lo.y, hi.y));
994        let carry = match first_centre {
995            None => {
996                first_centre = Some(centre);
997                ogeom_math::Vector2::new(0.0, 0.0)
998            }
999            Some(first) => ogeom_math::Vector2::new(
1000                u_period.map_or(0.0, |p| ((first.x - centre.x) / p).round() * p),
1001                v_period.map_or(0.0, |p| ((first.y - centre.y) / p).round() * p),
1002            ),
1003        };
1004        for (id, shift) in shifts {
1005            let total = shift + carry;
1006            if total.x == 0.0 && total.y == 0.0 {
1007                continue;
1008            }
1009            let Some(planar) = model.geometry().pcurve(id) else {
1010                ogeom_bail!(Dangling, "pcurve is not in this model");
1011            };
1012            let shifted = planar.transformed(&ogeom_math::Transform2::translation(total), tol)?;
1013            if let Some(slot) = model.geometry_mut().pcurve_mut(id) {
1014                *slot = shifted;
1015            }
1016        }
1017    }
1018    Ok(())
1019}
1020
1021/// A wire's edge uses with the chart closed where the surface collapses.
1022///
1023/// A face whose boundary passes through a pole or an apex meets it in space
1024/// at one vertex, but in the chart the use arriving there and the use
1025/// leaving it sit apart along the row (or column) that collapses to that
1026/// point: a sphere bounded by its seam walked both ways arrives at the north
1027/// pole on one side of the chart and leaves it on the other. An exchange
1028/// file has no edge for that stretch, and without one the boundary does not
1029/// close in the chart. Between consecutive uses that share a vertex and
1030/// whose images sit apart on a stretch the surface maps to that one point, a
1031/// degenerate edge is put in, its pcurve the straight run across. A wire
1032/// whose images break anywhere else (an image a period off its neighbours,
1033/// which the mesher folds back) has no run to trust, and comes back as it
1034/// is, as does one with a use that has no image on `surface`.
1035///
1036/// # Errors
1037///
1038/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if the surface,
1039/// an edge or a pcurve is not in this model.
1040pub fn closed_at_poles(
1041    model: &mut Model,
1042    surface: ogeom_topo::SurfaceId,
1043    edges: &[Shape],
1044    tol: Tolerances,
1045) -> OgeomResult<Vec<Shape>> {
1046    use ogeom_geom::Curve2d as _;
1047    use ogeom_geom::Surface as _;
1048    let Some(geometry) = model.geometry().surface(surface).cloned() else {
1049        ogeom_bail!(Dangling, "surface is not in this model");
1050    };
1051    let ((ua, ub), (va, vb)) = geometry.domain();
1052    let span = (ub - ua).abs().max((vb - va).abs());
1053    // Each use's chart start and end, in the direction it is walked.
1054    let mut ends = Vec::with_capacity(edges.len());
1055    for edge in edges {
1056        let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1057            ogeom_bail!(Dangling, "edge is not in this model");
1058        };
1059        let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
1060        let (id, range) = match data.pcurve_for(surface, edge.location()) {
1061            Some(EdgeRepr::PCurve { curve, range, .. }) => (*curve, *range),
1062            Some(EdgeRepr::Seam {
1063                forward,
1064                reversed: back,
1065                range,
1066                ..
1067            }) => (if reversed { *back } else { *forward }, *range),
1068            _ => return Ok(edges.to_vec()),
1069        };
1070        let Some(planar) = model.geometry().pcurve(id) else {
1071            ogeom_bail!(Dangling, "pcurve is not in this model");
1072        };
1073        let (t_start, t_end) = if reversed {
1074            (range.1, range.0)
1075        } else {
1076            (range.0, range.1)
1077        };
1078        ends.push((planar.point_at(t_start, tol)?, planar.point_at(t_end, tol)?));
1079    }
1080    let collapses = |from: Point2, to: Point2| -> bool {
1081        let Ok(at) = geometry.point_at(from.x, from.y, tol) else {
1082            return false;
1083        };
1084        (1..=4).all(|k| {
1085            let s = f64::from(k) / 4.0;
1086            let p = Point2::new(from.x + (to.x - from.x) * s, from.y + (to.y - from.y) * s);
1087            geometry
1088                .point_at(p.x, p.y, tol)
1089                .is_ok_and(|q| q.distance(at) <= tol.confusion())
1090        })
1091    };
1092    // Each junction: whether the images meet there, or which pole run
1093    // closes it.
1094    let mut runs: Vec<Option<(Shape, Point2, Point2)>> = Vec::with_capacity(edges.len());
1095    for (i, edge) in edges.iter().enumerate() {
1096        let j = (i + 1) % edges.len();
1097        // A miss that small is fitting slop between images that meet, and
1098        // collapses to a point on any surface. So is one too short in the
1099        // chart to be an edge of its own whose ends stand at one point in
1100        // space: on a chart spanning a fortieth of a unit, a millionth of
1101        // the span is shorter than the confusion distance.
1102        let (from, to) = (ends[i].1, ends[j].0);
1103        let gap = from.distance(to);
1104        let meets_in_space = || match (
1105            geometry.point_at(from.x, from.y, tol),
1106            geometry.point_at(to.x, to.y, tol),
1107        ) {
1108            (Ok(p), Ok(q)) => p.distance(q) <= tol.confusion(),
1109            _ => false,
1110        };
1111        if gap <= span * 1e-6 || (gap <= tol.confusion() && meets_in_space()) {
1112            runs.push(None);
1113            continue;
1114        }
1115        let (Some((_, arriving)), Some((leaving, _))) = (
1116            edge_vertices(model, edge)?,
1117            edge_vertices(model, &edges[j])?,
1118        ) else {
1119            return Ok(edges.to_vec());
1120        };
1121        if arriving.node() != leaving.node() || !collapses(from, to) {
1122            return Ok(edges.to_vec());
1123        }
1124        runs.push(Some((arriving, from, to)));
1125    }
1126    let mut out = Vec::with_capacity(edges.len() + 2);
1127    for (edge, run) in edges.iter().zip(runs) {
1128        out.push(edge.clone());
1129        let Some((at, from, to)) = run else {
1130            continue;
1131        };
1132        let mut data = EdgeData::new();
1133        data.degenerate = true;
1134        let pole = model.add_edge(data, &[at.clone(), at])?;
1135        attach_pcurve(
1136            model,
1137            &pole,
1138            ogeom_geom::Line2d::segment(from, to, tol)?.into(),
1139            surface,
1140            Location::identity(),
1141            (0.0, from.distance(to)),
1142        )?;
1143        out.push(pole);
1144    }
1145    Ok(out)
1146}
1147
1148/// The one wire a face on a B-spline closed round in `u` takes when its
1149/// file bounds it by rings alone and writes no seam: each ring runs the
1150/// whole period along the chart, from one end column to the other, and
1151/// without a seam no ring meets another, so the boundary encloses nothing
1152/// in the chart. The seam is the surface's own column at the period's
1153/// join, exact (the iso-curve the control columns trace), from where one
1154/// ring's walk ends to where the next begins; walked up one end column and
1155/// back down the other, it closes the two rings into one loop. A single
1156/// ring opposite a row the surface collapses to a point (a dome's pole)
1157/// takes the seam to that point and back, and the pole's run across the
1158/// chart is laid by [`closed_at_poles`].
1159///
1160/// `None` where the face is not that shape: another surface, one closed
1161/// the other way or periodic, a wire of more than one edge, or a ring
1162/// whose image does not run end column to end column along one row.
1163///
1164/// # Errors
1165///
1166/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if the
1167/// surface, an edge or a pcurve is not in this model.
1168pub fn belted_wire(
1169    model: &mut Model,
1170    surface: ogeom_topo::SurfaceId,
1171    wires: &[Shape],
1172    tol: Tolerances,
1173) -> OgeomResult<Option<Shape>> {
1174    use ogeom_geom::Curve2d as _;
1175    use ogeom_geom::Surface as _;
1176    let Some(SurfaceGeometry::BSpline(spline)) = model.geometry().surface(surface).cloned() else {
1177        return Ok(None);
1178    };
1179    if !spline.is_closed_u(tol) || spline.is_periodic_u() || spline.is_closed_v(tol) {
1180        return Ok(None);
1181    }
1182    if wires.is_empty() || wires.len() > 2 {
1183        return Ok(None);
1184    }
1185    let ((u0, u1), (v0, v1)) = spline.domain();
1186    let slack = (u1 - u0).abs().max((v1 - v0).abs()) * 1e-6;
1187    // Each ring: its use, the vertex it starts and ends at, and its walk's
1188    // row, start column and end column.
1189    struct Belt {
1190        edge: Shape,
1191        vertex: Shape,
1192        row: f64,
1193        from: f64,
1194        to: f64,
1195    }
1196    let mut belts = Vec::with_capacity(2);
1197    for wire in wires {
1198        let edges = model.ordered_children_of(wire)?;
1199        let [edge] = edges.as_slice() else {
1200            return Ok(None);
1201        };
1202        let Some((a, b)) = edge_vertices(model, edge)? else {
1203            return Ok(None);
1204        };
1205        if !a.is_same(&b) {
1206            return Ok(None);
1207        }
1208        let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
1209            ogeom_bail!(Dangling, "edge is not in this model");
1210        };
1211        if data.degenerate {
1212            return Ok(None);
1213        }
1214        let Some(EdgeRepr::PCurve { curve, range, .. }) = data.pcurve_for(surface, edge.location())
1215        else {
1216            return Ok(None);
1217        };
1218        let Some(planar) = model.geometry().pcurve(*curve) else {
1219            ogeom_bail!(Dangling, "pcurve is not in this model");
1220        };
1221        let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
1222        let (s, e) = (
1223            planar.point_at(range.0, tol)?,
1224            planar.point_at(range.1, tol)?,
1225        );
1226        let (s, e) = if reversed { (e, s) } else { (s, e) };
1227        let column = |x: f64| -> Option<f64> {
1228            if (x - u0).abs() <= slack {
1229                Some(u0)
1230            } else if (x - u1).abs() <= slack {
1231                Some(u1)
1232            } else {
1233                None
1234            }
1235        };
1236        let (Some(from), Some(to)) = (column(s.x), column(e.x)) else {
1237            return Ok(None);
1238        };
1239        if from == to || (s.y - e.y).abs() > slack {
1240            return Ok(None);
1241        }
1242        belts.push(Belt {
1243            edge: edge.clone(),
1244            vertex: a,
1245            row: f64::midpoint(s.y, e.y),
1246            from,
1247            to,
1248        });
1249    }
1250    // The far end of the seam: the second ring's vertex, or the point the
1251    // row opposite a lone ring collapses to.
1252    let (end_vertex, end_row) = if let [_, second] = belts.as_slice() {
1253        if second.from != belts[0].to {
1254            return Ok(None);
1255        }
1256        (second.vertex.clone(), second.row)
1257    } else {
1258        let row = if (belts[0].row - v0).abs() <= (belts[0].row - v1).abs() {
1259            v1
1260        } else {
1261            v0
1262        };
1263        let across = spline.iso_v_curve(row, tol)?;
1264        let points: Vec<Point> = (0..=8)
1265            .map(|k| {
1266                let t = u0 + (u1 - u0) * f64::from(k) / 8.0;
1267                ogeom_geom::Curve3d::point_at(&across, t, tol)
1268            })
1269            .collect::<OgeomResult<_>>()?;
1270        if points
1271            .iter()
1272            .any(|p| p.distance(points[0]) > tol.confusion())
1273        {
1274            return Ok(None);
1275        }
1276        (model.add_vertex(VertexData::new(points[0])), row)
1277    };
1278    let first = &belts[0];
1279    let up = first.row < end_row;
1280    let (lo, hi) = if up {
1281        (first.vertex.clone(), end_vertex)
1282    } else {
1283        (end_vertex, first.vertex.clone())
1284    };
1285    let column_curve: Curve = spline.iso_u_curve(first.to, tol)?.into();
1286    let range = (first.row.min(end_row), first.row.max(end_row));
1287    let seam = make_edge_between(model, column_curve, range, &lo, &hi, tol)?.shape;
1288    let column = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1289        Ok(ogeom_geom::Line2d::over(
1290            ogeom_math::Axis2::new(
1291                Point2::new(u, 0.0),
1292                ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, 1.0), tol)?,
1293            ),
1294            range.0,
1295            range.1,
1296        )?
1297        .into())
1298    };
1299    // The seam's Forward use climbs; walked up the first ring's end column
1300    // where the walk climbs, and down its start column where it falls.
1301    let (forward_col, reversed_col) = if up {
1302        (first.to, first.from)
1303    } else {
1304        (first.from, first.to)
1305    };
1306    attach_seam(
1307        model,
1308        &seam,
1309        column(forward_col)?,
1310        column(reversed_col)?,
1311        surface,
1312        Location::identity(),
1313        range,
1314    )?;
1315    let climb = if up { seam.clone() } else { seam.reversed() };
1316    let mut walk = vec![first.edge.clone(), climb.clone()];
1317    if let [_, second] = belts.as_slice() {
1318        walk.push(second.edge.clone());
1319    }
1320    walk.push(climb.reversed());
1321    Ok(Some(make_wire(model, &walk, tol)?.shape))
1322}
1323
1324/// Build a face covering the whole of `surface`, with no trimming.
1325///
1326/// # Errors
1327///
1328/// Never fails for a well-formed surface; the signature matches [`make_face`]
1329/// so the two are interchangeable at a call site.
1330pub fn make_natural_face(model: &mut Model, surface: SurfaceGeometry) -> OgeomResult<Built> {
1331    let id = model.geometry_mut().add_surface(surface);
1332    let face = model.add_face(FaceData::natural(id, Location::identity()), &[])?;
1333    Ok(Built::from_nothing(face))
1334}
1335
1336/// Build a shell from faces.
1337///
1338/// # Errors
1339///
1340/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a child is not
1341/// a face, or the list is empty.
1342/// Build a compsolid: solids gluing along shared faces.
1343///
1344/// A compsolid is more than a bag of solids: its members must actually
1345/// glue. Every pair connected through the whole is connected through shared
1346/// *face nodes*: the same face entity bounding two solids, once from each
1347/// side, which is the sharing a boolean or a sew produces. A set of solids
1348/// that merely touch, faces coincident but distinct, is a compound, not a
1349/// compsolid, and is refused as one.
1350///
1351/// # Errors
1352///
1353/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
1354/// fewer than two solids are given, a member is not a solid, or the members
1355/// do not glue into one connected whole through shared faces.
1356pub fn make_compsolid(model: &mut Model, solids: &[Shape]) -> OgeomResult<Built> {
1357    if solids.len() < 2 {
1358        ogeom_bail!(
1359            Construction,
1360            "a compsolid glues at least two solids; one solid is just a solid"
1361        );
1362    }
1363    // Which face nodes bound each solid.
1364    let mut faces_of: Vec<ogeom_core::FastSet<ogeom_topo::TShapeId>> = Vec::new();
1365    for solid in solids {
1366        if model.kind_of(solid)? != ShapeType::Solid {
1367            ogeom_bail!(Construction, "a compsolid's members are solids");
1368        }
1369        let set = ogeom_topo::explore(model, solid, ogeom_topo::Filter::OfType(ShapeType::Face))?
1370            .iter()
1371            .map(Shape::node)
1372            .collect();
1373        faces_of.push(set);
1374    }
1375    // Connectivity over shared face nodes: a flood fill from the first.
1376    let mut joined = vec![false; solids.len()];
1377    joined[0] = true;
1378    let mut frontier = vec![0_usize];
1379    while let Some(current) = frontier.pop() {
1380        for (other, other_faces) in faces_of.iter().enumerate() {
1381            if joined[other] {
1382                continue;
1383            }
1384            if !faces_of[current].is_disjoint(other_faces) {
1385                joined[other] = true;
1386                frontier.push(other);
1387            }
1388        }
1389    }
1390    if joined.iter().any(|j| !j) {
1391        ogeom_bail!(
1392            Construction,
1393            "the solids do not glue into one connected whole: at least one              shares no face with the rest. Coincident-but-distinct faces are              a compound's arrangement, not a compsolid's; sew or fuse first."
1394        );
1395    }
1396
1397    let compsolid = model.add_compsolid(solids)?;
1398    let mut history = History::new();
1399    for solid in solids {
1400        history.generate(solid, compsolid.clone());
1401    }
1402    Ok(Built::new(compsolid, history))
1403}
1404
1405/// Build a compound from any shapes, with history.
1406///
1407/// The grouping container: a compound holds anything, orders nothing, and
1408/// claims nothing about closure. What this adds over the raw model call is
1409/// the same thing every builder adds: a history that says what went in.
1410///
1411/// # Errors
1412///
1413/// As [`Model::add_compound`].
1414pub fn make_compound(model: &mut Model, shapes: &[Shape]) -> OgeomResult<Built> {
1415    let compound = model.add_compound(shapes)?;
1416    let mut history = History::new();
1417    for shape in shapes {
1418        history.generate(shape, compound.clone());
1419    }
1420    Ok(Built::new(compound, history))
1421}
1422
1423/// Build a shell from faces.
1424///
1425/// # Errors
1426///
1427/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a child is
1428/// not a face, or the list is empty.
1429pub fn make_shell(model: &mut Model, faces: &[Shape]) -> OgeomResult<Built> {
1430    let shell = model.add_shell(faces)?;
1431    let mut history = History::new();
1432    for face in faces {
1433        history.generate(face, shell.clone());
1434    }
1435    Ok(Built::new(shell, history))
1436}
1437
1438/// Build a solid from shells.
1439///
1440/// The first shell is the outer boundary; any others are voids inside it.
1441///
1442/// Every face of a solid faces away from its material: the outer shell
1443/// out of the volume it bounds, a void's shell into its cavity. A closed
1444/// shell whose faces agree with each other (every edge walked once each
1445/// way) but face the other way is used turned over: the outer shell where
1446/// its meshed volume comes out negative, a void's where it comes out
1447/// positive. A shell that is open, whose faces disagree, or whose mesh
1448/// does not close is used as given; so is anything put together with
1449/// [`Model::add_solid`], for a builder whose shell faces in on purpose
1450/// (the material outside a closed face).
1451///
1452/// # Errors
1453///
1454/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a child is not
1455/// a shell, or the list is empty.
1456pub fn make_solid(model: &mut Model, shells: &[Shape]) -> OgeomResult<Built> {
1457    let mut used = Vec::with_capacity(shells.len());
1458    for (i, shell) in shells.iter().enumerate() {
1459        let encloses = encloses_material(model, shell)?;
1460        let inside_out = encloses.is_some_and(|out| if i == 0 { !out } else { out });
1461        used.push(if inside_out {
1462            shell.reversed()
1463        } else {
1464            shell.clone()
1465        });
1466    }
1467    let solid = model.add_solid(&used)?;
1468    let mut history = History::new();
1469    for shell in shells {
1470        history.generate(shell, solid.clone());
1471    }
1472    Ok(Built::new(solid, history))
1473}
1474
1475/// The signed volume a closed shell's mesh encloses, positive where its
1476/// faces face out; `None` for anything else: an open shell, one with an
1477/// edge both its faces walk the same way (no whole turn makes it right),
1478/// or one whose mesh does not close.
1479fn encloses_material(model: &Model, shell: &Shape) -> OgeomResult<Option<bool>> {
1480    /// How many of the shell's faces are probed before it is meshed.
1481    const PROBED: usize = 4;
1482    if model.node(shell).map(ogeom_topo::TShape::kind) != Some(ShapeType::Shell)
1483        || !is_shell_closed(model, shell)?
1484        || !crate::check::walked_one_way(model, shell)?.is_empty()
1485    {
1486        return Ok(None);
1487    }
1488    // Its faces agree with each other, so any one of them says which way
1489    // the whole shell faces: one probed off its two sides against the
1490    // region the shell encloses faces out of it or into it. Probing a face
1491    // costs that face's mesh and a few rays, where the shell's volume costs
1492    // every face's mesh. But a shell that passes through itself reads out
1493    // at some places and in at others, so no one place decides: the probes
1494    // settle it only where several spread over the faces agree, and where
1495    // they disagree or too few read, the volume the whole shell encloses
1496    // does.
1497    let tol = Tolerances::default();
1498    let mut read = crate::check::Readings::default();
1499    if let Ok(boundary) = crate::check::probe_boundary(model, shell, tol) {
1500        for face in ogeom_topo::explore_unique(model, shell, ShapeType::Face)?
1501            .iter()
1502            .take(PROBED)
1503        {
1504            let wanted = crate::check::AGREEING - read.count();
1505            if let Ok(more) = crate::check::face_readings(model, face, &boundary, wanted, tol) {
1506                read.out += more.out;
1507                read.inward += more.inward;
1508            }
1509            if read.count() >= crate::check::AGREEING {
1510                break;
1511            }
1512        }
1513    }
1514    if read.count() >= crate::check::AGREEING
1515        && let Some(inward) = read.verdict()
1516    {
1517        return Ok(Some(!inward));
1518    }
1519    Ok(enclosed_volume(model, shell)
1520        .map(|v| v > 0.0)
1521        .or_else(|| read.verdict().map(|inward| !inward)))
1522}
1523
1524/// The volume a closed shell whose faces agree encloses, from its mesh:
1525/// positive where they face out of it.
1526fn enclosed_volume(model: &Model, shell: &Shape) -> Option<f64> {
1527    ogeom_mesh::triangulate(
1528        model,
1529        shell,
1530        ogeom_mesh::Deflection::default(),
1531        Tolerances::default(),
1532    )
1533    .ok()
1534    .filter(ogeom_topo::Triangulation::is_closed)
1535    .map(|mesh| mesh.volume())
1536}
1537
1538/// Whether every edge in a shell is shared by exactly two faces.
1539///
1540/// The defining property of a closed shell, and the one that decides whether it
1541/// can bound a solid. An edge used once is a free boundary: the shell has a
1542/// hole. An edge used three or more times is non-manifold, which is legitimate
1543/// topology but not something that encloses a volume.
1544///
1545/// # Errors
1546///
1547/// As [`ogeom_topo::explore`].
1548pub fn is_shell_closed(model: &Model, shell: &Shape) -> OgeomResult<bool> {
1549    // Counted by *use*, not by how many distinct faces an edge belongs to. A
1550    // seam edge bounds one face twice (up one side of its parameter rectangle
1551    // and down the other), so counting faces would call every cylinder, sphere
1552    // and torus open, which is precisely backwards.
1553    let mut uses: FastMap<ogeom_topo::TShapeId, usize> = FastMap::default();
1554    // Read from the nodes as they are stored: what is counted is which edge
1555    // node each use names, which no placement changes.
1556    let children = |shape: &Shape| -> OgeomResult<&[Shape]> {
1557        match model.node(shape) {
1558            Some(node) => Ok(node.children()),
1559            None => ogeom_bail!(Dangling, "shape refers to a node not in this model"),
1560        }
1561    };
1562    for face in ogeom_topo::explore(model, shell, ogeom_topo::Filter::OfType(ShapeType::Face))? {
1563        for wire in children(&face)? {
1564            for edge in children(wire)? {
1565                *uses.entry(edge.node()).or_default() += 1;
1566            }
1567        }
1568    }
1569    // A degenerate edge (a sphere's pole, a cone's apex) has no length, so
1570    // there is no gap along it for a second face to close. Counting it would
1571    // call every sphere and every true cone open, and the thing that is
1572    // actually open, isn't. Only an edge used an odd number of times is
1573    // asked.
1574    let degenerate = |id: ogeom_topo::TShapeId| {
1575        model
1576            .node_by_id(id)
1577            .and_then(|n| n.data().as_edge())
1578            .is_some_and(|d| d.degenerate)
1579    };
1580    let counted = uses.keys().any(|id| !degenerate(*id));
1581    Ok(counted && uses.iter().all(|(id, n)| n % 2 == 0 || degenerate(*id)))
1582}
1583
1584/// Whether a face covers the whole of its surface: it has a wire, and
1585/// every edge its wires walk has no length or is a seam of that surface
1586/// walked once each way.
1587///
1588/// Such a face has no boundary on the surface: a seam's two walks run
1589/// along one line in opposite senses and cancel, and an edge of no length
1590/// bounds nothing. On a connected closed surface (a sphere, a torus) a
1591/// face without boundary is the whole surface. Any other edge, or a seam
1592/// walked otherwise, and the answer is `false`.
1593///
1594/// # Errors
1595///
1596/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if the face,
1597/// a wire or an edge is not in this model.
1598pub fn covers_whole_surface(model: &Model, face: &Shape) -> OgeomResult<bool> {
1599    use ogeom_topo::Orientation;
1600    let Some(data) = model.node(face).and_then(|n| n.data().as_face()) else {
1601        ogeom_bail!(Dangling, "face is not in this model");
1602    };
1603    let wires = model.children_of(face)?;
1604    if wires.is_empty() {
1605        return Ok(false);
1606    }
1607    // Each seam's walks, forward and reversed.
1608    let mut walks: FastMap<ogeom_topo::TShapeId, (u32, u32)> = FastMap::default();
1609    for wire in &wires {
1610        for edge in model.children_of(wire)? {
1611            let Some(edge_data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1612                ogeom_bail!(Dangling, "edge is not in this model");
1613            };
1614            if edge_data.degenerate {
1615                continue;
1616            }
1617            if !matches!(
1618                edge_data.pcurve_for(data.surface, edge.location()),
1619                Some(EdgeRepr::Seam { .. })
1620            ) {
1621                return Ok(false);
1622            }
1623            let walked = walks.entry(edge.node()).or_default();
1624            match edge.orientation() {
1625                Orientation::Forward => walked.0 += 1,
1626                Orientation::Reversed => walked.1 += 1,
1627                Orientation::Internal | Orientation::External => return Ok(false),
1628            }
1629        }
1630    }
1631    Ok(walks.values().all(|&w| w == (1, 1)))
1632}
1633
1634/// Give every edge of `face` that has no trim on the face's surface its
1635/// exact one, where a closed form exists: a profile built from bare
1636/// curves becomes a face a sweep can keep as an end.
1637///
1638/// # Errors
1639///
1640/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if the face
1641/// or an edge is not in this model.
1642pub fn trimmed_where_bare(model: &mut Model, face: &Shape, tol: Tolerances) -> OgeomResult<()> {
1643    trim_bare_edges(model, face, false, tol)
1644}
1645
1646/// As [`trimmed_where_bare`], with every edge left without a closed form
1647/// given a pcurve fitted by projection at the curve's own parameters: a
1648/// face kept whole in a solid (a sweep's end) is only valid with a trim on
1649/// every edge.
1650///
1651/// The fit's miss is measured, the lifted pcurve against the curve at the
1652/// same parameter, and the edge and its vertices widened to it. An edge
1653/// sitting off the surface by more than its own tolerance is not the
1654/// face's boundary, and refuses.
1655///
1656/// # Errors
1657///
1658/// As [`trimmed_where_bare`], and
1659/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if an
1660/// edge with no closed-form trim sits off the face's surface by more than
1661/// its tolerance, or cannot be projected onto it.
1662pub fn trimmed_or_fitted_where_bare(
1663    model: &mut Model,
1664    face: &Shape,
1665    tol: Tolerances,
1666) -> OgeomResult<()> {
1667    trim_bare_edges(model, face, true, tol)
1668}
1669
1670fn trim_bare_edges(model: &mut Model, face: &Shape, fit: bool, tol: Tolerances) -> OgeomResult<()> {
1671    let Some(data) = model.node(face).and_then(|n| n.data().as_face().cloned()) else {
1672        ogeom_bail!(Dangling, "face is not in this model");
1673    };
1674    let Some(surface) = model.geometry().surface(data.surface).cloned() else {
1675        ogeom_bail!(Dangling, "face refers to a surface not in this model");
1676    };
1677    // The edges are reached through the face, carrying its placement; the
1678    // surface is stated unplaced, so each curve is taken into the surface's
1679    // own frame before it is read in the chart.
1680    let unplace = face.transform(model.datums())?.inverse()?;
1681    for edge in ogeom_topo::explore_unique(model, face, ShapeType::Edge)? {
1682        let Some(edge_data) = model.node(&edge).and_then(|n| n.data().as_edge().cloned()) else {
1683            continue;
1684        };
1685        if edge_data
1686            .pcurve_for(data.surface, edge.location())
1687            .is_some()
1688        {
1689            continue;
1690        }
1691        let Some(EdgeRepr::Curve3d { curve, range, .. }) = edge_data.curve3d() else {
1692            continue;
1693        };
1694        let Some(geometry) = model.geometry().curve(*curve).cloned() else {
1695            continue;
1696        };
1697        use ogeom_geom::Transformable as _;
1698        let placed = geometry.transformed(&(unplace * edge.transform(model.datums())?), tol)?;
1699        // A plane's closed forms project the curve rather than test that it
1700        // lies there, so where a fit could take over, the projection stands
1701        // only if its lift meets the curve within the edge's tolerance at
1702        // the same parameter, the measure `check` holds it to.
1703        let exact = ogeom_intersect::exact_pcurve_of(&placed, &surface, tol).filter(|exact| {
1704            !fit || crate::pcurve_fit::lifted_gap(
1705                (&placed, *range),
1706                (exact, *range),
1707                &surface,
1708                true,
1709                tol,
1710            )
1711            .is_ok_and(|gap| gap <= edge_data.tolerance.get().max(tol.confusion()))
1712        });
1713        let pcurve = match exact {
1714            Some(exact) => exact,
1715            None if fit => {
1716                fitted_where_bare(model, &edge, &edge_data, (&placed, *range), &surface, tol)?
1717            }
1718            None => continue,
1719        };
1720        attach_pcurve(
1721            model,
1722            &edge,
1723            pcurve,
1724            data.surface,
1725            edge.location().clone(),
1726            *range,
1727        )?;
1728    }
1729    Ok(())
1730}
1731
1732/// The pcurve of a bare edge with no closed form, fitted at the curve's
1733/// own parameters, the edge and its vertices widened to the fit's
1734/// measured miss.
1735fn fitted_where_bare(
1736    model: &mut Model,
1737    edge: &Shape,
1738    edge_data: &EdgeData,
1739    (curve, range): (&Curve, (f64, f64)),
1740    surface: &SurfaceGeometry,
1741    tol: Tolerances,
1742) -> OgeomResult<ogeom_geom::PlanarCurve> {
1743    let reach = edge_data.tolerance.get().max(tol.confusion());
1744    let fitted =
1745        match crate::pcurve_fit::fit_projected_pcurve_within(curve, range, surface, reach, tol)? {
1746            crate::pcurve_fit::CappedFit::Fitted((fitted, ..)) => fitted,
1747            crate::pcurve_fit::CappedFit::TooFar(off) => ogeom_bail!(
1748                Construction,
1749                "an edge of the face sits {off:.2e} off its surface, outside the edge's \
1750                 tolerance of {reach:.2e}, so it has no trim there"
1751            ),
1752        };
1753    // Held to the same parameter, the stricter of the two measures `check`
1754    // takes, so the width holds whether or not the edge claims it.
1755    let gap = crate::pcurve_fit::lifted_gap((curve, range), (&fitted, range), surface, true, tol)?;
1756    if gap > tol.confusion() {
1757        // The edge owns the miss, and so must the vertices that bound it:
1758        // the containment rule.
1759        let widened = ogeom_core::Tolerance::new(gap + tol.confusion())?;
1760        model.widen(edge, widened)?;
1761        if let Some((a, b)) = edge_vertices(model, edge)? {
1762            model.widen(&a, widened)?;
1763            model.widen(&b, widened)?;
1764        }
1765    }
1766    Ok(fitted)
1767}
1768
1769/// Attach a pcurve to an edge, describing it in a surface's parameter space.
1770///
1771/// # Errors
1772///
1773/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `edge` is not
1774/// an edge; [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if it does not
1775/// resolve.
1776pub fn attach_pcurve(
1777    model: &mut Model,
1778    edge: &Shape,
1779    pcurve: ogeom_geom::PlanarCurve,
1780    surface: ogeom_topo::SurfaceId,
1781    location: Location,
1782    range: (f64, f64),
1783) -> OgeomResult<()> {
1784    if model.kind_of(edge)? != ShapeType::Edge {
1785        ogeom_bail!(Construction, "pcurves attach to edges");
1786    }
1787    let id = model.geometry_mut().add_pcurve(pcurve);
1788    let Some(node) = model.node_mut(edge) else {
1789        ogeom_bail!(Dangling, "edge is not in this model");
1790    };
1791    let ogeom_topo::NodeData::Edge(data) = node.data_mut() else {
1792        ogeom_bail!(Construction, "edge node holds no edge data");
1793    };
1794    data.add(EdgeRepr::PCurve {
1795        curve: id,
1796        surface,
1797        location,
1798        range,
1799    });
1800    Ok(())
1801}
1802
1803/// Attach a seam representation to an edge: one pcurve per side of a closed
1804/// surface's join.
1805///
1806/// The counterpart of [`attach_pcurve`] for the edge that bounds one face
1807/// twice: up one side of the parameter rectangle and down the other. Which
1808/// pcurve applies to an occurrence is decided by that occurrence's
1809/// orientation, which is the only thing distinguishing the two.
1810///
1811/// # Errors
1812///
1813/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `edge` is not
1814/// an edge; [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if it is not in
1815/// this model.
1816pub fn attach_seam(
1817    model: &mut Model,
1818    edge: &Shape,
1819    forward: ogeom_geom::PlanarCurve,
1820    reversed: ogeom_geom::PlanarCurve,
1821    surface: ogeom_topo::SurfaceId,
1822    location: Location,
1823    range: (f64, f64),
1824) -> OgeomResult<()> {
1825    if model.kind_of(edge)? != ShapeType::Edge {
1826        ogeom_bail!(Construction, "seams attach to edges");
1827    }
1828    let forward = model.geometry_mut().add_pcurve(forward);
1829    let reversed = model.geometry_mut().add_pcurve(reversed);
1830    let Some(node) = model.node_mut(edge) else {
1831        ogeom_bail!(Dangling, "edge is not in this model");
1832    };
1833    let ogeom_topo::NodeData::Edge(data) = node.data_mut() else {
1834        ogeom_bail!(Construction, "edge node holds no edge data");
1835    };
1836    data.add(EdgeRepr::Seam {
1837        forward,
1838        reversed,
1839        surface,
1840        location,
1841        range,
1842    });
1843    Ok(())
1844}
1845
1846/// Whether a circle is a *parallel* of a revolved surface (its axis the
1847/// revolution axis, its centre on it) rather than a circle that merely
1848/// lies on the surface.
1849fn circle_is_parallel_of(
1850    circle: ogeom_math::Circle,
1851    surface: &ogeom_geom::SurfaceGeometry,
1852    axis_z: ogeom_math::Vector,
1853    tol: Tolerances,
1854) -> bool {
1855    if circle.frame().z().vector().cross(axis_z).magnitude() > tol.angular() {
1856        return false;
1857    }
1858    match surface_axis_origin(surface) {
1859        Some(origin) => {
1860            let off = circle.centre() - origin;
1861            let radial = off - axis_z * off.dot(axis_z);
1862            radial.magnitude() <= tol.confusion() * 10.0
1863        }
1864        None => true,
1865    }
1866}
1867
1868/// Whether two closed ring edges are parallels of `surface`, so that
1869/// [`make_revolution_band`] can build a band between them. A reader asks
1870/// this first: a periodic face bounded by two closed circles that are *not*
1871/// parallels (a button head's rims, square to the screw on a sphere whose
1872/// chart runs along z) is a legitimate face on its own bounds, not a band
1873/// missing its seam, and deserves no warning.
1874///
1875/// # Errors
1876///
1877/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if an edge is
1878/// not in the model.
1879pub fn rings_are_parallels(
1880    model: &Model,
1881    surface: &ogeom_geom::SurfaceGeometry,
1882    rings: &[&Shape],
1883    tol: Tolerances,
1884) -> OgeomResult<bool> {
1885    let Some(axis_z) = surface_iso_axis(surface) else {
1886        return Ok(false);
1887    };
1888    for ring in rings {
1889        let Some(node) = model.node(ring) else {
1890            ogeom_bail!(Dangling, "edge is not in this model");
1891        };
1892        let Some(data) = node.data().as_edge() else {
1893            return Ok(false);
1894        };
1895        if data.degenerate {
1896            continue;
1897        }
1898        let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d() else {
1899            return Ok(false);
1900        };
1901        let Some(ogeom_geom::Curve::Circle(c)) = model.geometry().curve(*curve) else {
1902            return Ok(false);
1903        };
1904        if !circle_is_parallel_of(c.circle(), surface, axis_z, tol) {
1905            return Ok(false);
1906        }
1907    }
1908    Ok(true)
1909}
1910
1911/// A point on the revolution axis of an analytic surface, where one is
1912/// stated: the frame origin of a cylinder, cone or torus, a sphere's centre.
1913fn surface_axis_origin(surface: &ogeom_geom::SurfaceGeometry) -> Option<Point> {
1914    use ogeom_geom::SurfaceGeometry as S;
1915    match surface {
1916        S::Cylinder(c) => Some(c.cylinder().frame().origin()),
1917        S::Cone(c) => Some(c.cone().frame().origin()),
1918        S::Sphere(s) => Some(s.sphere().centre()),
1919        S::Torus(t) => Some(t.torus().frame().origin()),
1920        _ => None,
1921    }
1922}
1923
1924/// Build the face of a revolution band: two closed rings joined by a
1925/// synthesised seam, pcurves attached window-coherently.
1926///
1927/// The one construction of a band's seam, for every caller. The chart walk
1928/// decides everything: the bottom ring is
1929/// traversed forward, and where its chart line *ends* is where the first
1930/// seam column stands; the top ring's occurrence direction is chosen so its
1931/// walk starts there (rings winding the same way traverse opposite, rings
1932/// winding opposite traverse alike), and the seam's two pcurves are assigned
1933/// to match which occurrence the triangulator will hand them to. The face is
1934/// built on a fresh copy of the surface, so no annotation left by an earlier
1935/// use of the same rings can apply.
1936///
1937/// One ring may be *degenerate* (an edge with no curve, both ends the same
1938/// vertex, flagged as such), standing for an apex or a pole: a rim of no
1939/// length that still bounds the face in parameter space. It takes the row
1940/// the collapsed point sits on and traverses the chart opposite the real
1941/// ring, exactly as native cones and spheres bound their tips.
1942///
1943/// # Errors
1944///
1945/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a ring is
1946/// neither a circle nor degenerate, both rings are degenerate, the rings are
1947/// not rings of this surface, their vertices stand on different columns of
1948/// it, or the surface's iso-curve has no closed form. A refused band leaves
1949/// the rings' edges as they were.
1950pub fn make_revolution_band(
1951    model: &mut Model,
1952    surface: &ogeom_geom::SurfaceGeometry,
1953    ring_lo: &Shape,
1954    ring_hi: &Shape,
1955    tol: Tolerances,
1956) -> OgeomResult<Shape> {
1957    revolution_band(model, surface, ring_lo, ring_hi, None, tol)
1958}
1959
1960/// [`make_revolution_band`] on a curve revolved a whole turn about an
1961/// axis ([`RevolutionSurface`](ogeom_geom::RevolutionSurface)), its rings
1962/// the parallels through the profile's two ends.
1963///
1964/// The seam is the profile turned to the column of the first ring's
1965/// vertex, on the profile's own parameter, and each ring's row is the
1966/// profile's end it is the parallel of. The rings must be the parallels
1967/// through the profile's ends, each a circle about the axis.
1968///
1969/// # Errors
1970///
1971/// As [`make_revolution_band`], and
1972/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
1973/// surface is not a whole turn of a revolution, or a ring is not the
1974/// parallel through an end of its profile.
1975pub fn make_profile_revolution_band(
1976    model: &mut Model,
1977    surface: &ogeom_geom::SurfaceGeometry,
1978    ring_lo: &Shape,
1979    ring_hi: &Shape,
1980    tol: Tolerances,
1981) -> OgeomResult<Shape> {
1982    use ogeom_geom::Surface as _;
1983    let SurfaceGeometry::Revolution(revolved) = surface else {
1984        ogeom_bail!(
1985            Construction,
1986            "a profile's band asked of a surface no profile sweeps"
1987        );
1988    };
1989    let ((ua, ub), _) = surface.domain();
1990    if !surface.is_periodic_u() || ((ub - ua) - core::f64::consts::TAU).abs() > tol.angular() {
1991        ogeom_bail!(
1992            Construction,
1993            "a profile's band asked of less than a whole turn"
1994        );
1995    }
1996    revolution_band(model, surface, ring_lo, ring_hi, Some(revolved), tol)
1997}
1998
1999/// Where a point on a whole turn of a revolved profile stands in its
2000/// chart: its angle about the axis from the profile's own half-plane, and
2001/// the profile's parameter at the end of it the point turns from. `None`
2002/// for a point on the parallel of neither end.
2003fn profile_chart_of(
2004    revolved: &ogeom_geom::RevolutionSurface,
2005    p: Point,
2006    tol: Tolerances,
2007) -> Option<ogeom_math::Point2> {
2008    use ogeom_geom::Curve3d as _;
2009    let axis = revolved.axis();
2010    let curve = revolved.curve();
2011    let (lo, hi) = curve.domain();
2012    // A point's height along the axis and its radius off it.
2013    let place = |q: Point| {
2014        let off = q - axis.location;
2015        let height = off.dot(axis.direction.vector());
2016        let radial = off - axis.direction.vector() * height;
2017        (height, radial)
2018    };
2019    let (height, radial) = place(p);
2020    let mut found = None;
2021    for end in [lo, hi] {
2022        let q = curve.point_at(end, tol).ok()?;
2023        let (h, r) = place(q);
2024        if (h - height).abs() <= tol.confusion()
2025            && (r.magnitude() - radial.magnitude()).abs() <= tol.confusion()
2026        {
2027            let z = axis.direction.vector();
2028            let x = r / r.magnitude();
2029            let y = z.cross(x);
2030            let angle = radial.dot(y).atan2(radial.dot(x));
2031            found = Some(ogeom_math::Point2::new(
2032                angle.rem_euclid(core::f64::consts::TAU),
2033                end,
2034            ));
2035            break;
2036        }
2037    }
2038    found
2039}
2040
2041/// [`make_revolution_band`], or [`make_profile_revolution_band`] where
2042/// `profile` is the surface's own revolution.
2043fn revolution_band(
2044    model: &mut Model,
2045    surface: &ogeom_geom::SurfaceGeometry,
2046    ring_lo: &Shape,
2047    ring_hi: &Shape,
2048    profile: Option<&ogeom_geom::RevolutionSurface>,
2049    tol: Tolerances,
2050) -> OgeomResult<Shape> {
2051    use ogeom_geom::Surface as _;
2052
2053    let ((ua_dom, ub_dom), _) = surface.domain();
2054    let span = ub_dom - ua_dom;
2055    let axis_z = surface_iso_axis(surface)
2056        .or_else(|| profile.map(|r| r.axis().direction.vector()))
2057        .ok_or_else(|| {
2058            ogeom_core::ogeom_err!(Construction, "the surface has no revolution axis")
2059        })?;
2060    // The chart's closed-form inversion: the analytic kinds', or the
2061    // profile's at its ends.
2062    let chart_of = |p: Point| -> Option<ogeom_math::Point2> {
2063        match profile {
2064            Some(revolved) => profile_chart_of(revolved, p, tol),
2065            None => analytic_chart_of(surface, p),
2066        }
2067    };
2068
2069    // Each ring's curve, range, winding, vertex, and chart row.
2070    struct Ring {
2071        edge: Shape,
2072        vertex: Shape,
2073        crange: (f64, f64),
2074        winding: f64,
2075        row: f64,
2076        degenerate: bool,
2077    }
2078    let mut rings = Vec::new();
2079    for used in [ring_lo, ring_hi] {
2080        // The caller's edges may carry orientation from their old wire uses;
2081        // the band is built on the curves' own directions, and the walk
2082        // chooses each occurrence's orientation itself.
2083        let edge = &if used.orientation() == ogeom_topo::Orientation::Reversed {
2084            used.reversed()
2085        } else {
2086            used.clone()
2087        };
2088        let Some((vertex, other)) = edge_vertices(model, edge)? else {
2089            ogeom_bail!(Construction, "a band ring has no vertex");
2090        };
2091        if !vertex.is_same(&other) {
2092            ogeom_bail!(Construction, "a band ring is not closed");
2093        }
2094        let at = {
2095            let Some(node) = model.node(&vertex) else {
2096                ogeom_bail!(Dangling, "vertex is not in this model");
2097            };
2098            let Some(data) = node.data().as_vertex() else {
2099                ogeom_bail!(Construction, "vertex node holds no vertex data");
2100            };
2101            data.point
2102        };
2103        let degenerate = {
2104            let Some(node) = model.node(edge) else {
2105                ogeom_bail!(Dangling, "edge is not in this model");
2106            };
2107            let Some(data) = node.data().as_edge() else {
2108                ogeom_bail!(Construction, "edge node holds no edge data");
2109            };
2110            data.degenerate
2111        };
2112        if degenerate {
2113            // An apex or a pole: no curve to read, no winding of its own.
2114            // Its row comes from the collapsed point, which sits on the
2115            // axis, where iterative projection has no nearest angle, so only
2116            // the closed-form inversion can place it.
2117            let Some(uv) = chart_of(at) else {
2118                ogeom_bail!(
2119                    Construction,
2120                    "a degenerate ring's row cannot be found on this surface"
2121                );
2122            };
2123            rings.push(Ring {
2124                edge: edge.clone(),
2125                vertex,
2126                crange: (0.0, span),
2127                winding: 0.0,
2128                row: uv.y,
2129                degenerate: true,
2130            });
2131            continue;
2132        }
2133        let (curve, crange) = {
2134            let Some(node) = model.node(edge) else {
2135                ogeom_bail!(Dangling, "edge is not in this model");
2136            };
2137            let Some(data) = node.data().as_edge() else {
2138                ogeom_bail!(Construction, "edge node holds no edge data");
2139            };
2140            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
2141                ogeom_bail!(Construction, "a band ring has no curve");
2142            };
2143            let Some(geometry) = model.geometry().curve(*curve) else {
2144                ogeom_bail!(Dangling, "curve is not in this model");
2145            };
2146            (geometry.clone(), *range)
2147        };
2148        let ogeom_geom::Curve::Circle(c) = &curve else {
2149            ogeom_bail!(Construction, "a band ring is not a circle");
2150        };
2151        // A ring of *this* surface is a parallel: its axis the revolution
2152        // axis, its centre on it. A circle merely lying on the surface
2153        // (a button head's rim, cut square to the screw while the sphere's
2154        // chart runs along z) is a closed loop in the chart, not a row of
2155        // it, and building a band on it would hand every rim a latitude
2156        // line it never follows. Refused here, so a reader falls through to
2157        // the face's own bounds, which triangulate as the nested loops they
2158        // are.
2159        let off_axis = profile.is_some_and(|r| {
2160            let off = c.circle().centre() - r.axis().location;
2161            (off - axis_z * off.dot(axis_z)).magnitude() > tol.confusion() * 10.0
2162        });
2163        if off_axis || !circle_is_parallel_of(c.circle(), surface, axis_z, tol) {
2164            ogeom_bail!(
2165                Construction,
2166                "a band ring is a circle on the surface but not a parallel \
2167                 of it; its axis or centre is off the revolution axis"
2168            );
2169        }
2170        // A circle turns about its frame's spin, and back about it where
2171        // the curve is reversed.
2172        let turned = if c.is_reversed() { -1.0 } else { 1.0 };
2173        let winding = turned * spin(&c.circle().frame()).dot(axis_z).signum();
2174        let row = match chart_of(at) {
2175            Some(uv) => uv.y,
2176            None => {
2177                crate::measure::project_on_surface(surface, at, 32, tol)?
2178                    .parameters
2179                    .1
2180            }
2181        };
2182        rings.push(Ring {
2183            edge: edge.clone(),
2184            vertex,
2185            crange,
2186            winding,
2187            row,
2188            degenerate: false,
2189        });
2190    }
2191    // A degenerate ring bounds nothing by itself, and the chart anchor must
2192    // come from a rim that has an angle: the real ring goes first, and the
2193    // degenerate one traverses opposite it.
2194    if rings[0].degenerate && rings[1].degenerate {
2195        ogeom_bail!(Construction, "both band rings are degenerate");
2196    }
2197    if rings[0].degenerate {
2198        rings.swap(0, 1);
2199    }
2200    if rings[1].degenerate {
2201        rings[1].winding = -rings[0].winding;
2202    }
2203    // A row on the seam of a chart periodic in `v` (a torus's outer
2204    // equator) reads at either end of it, and which one rounding picked
2205    // decides whether the band runs a quarter of the tube or the other
2206    // three. Such a row is taken at the end nearer the other ring's row.
2207    if surface.is_periodic_v() {
2208        let (_, (va, vb)) = surface.domain();
2209        let on_seam =
2210            |row: f64| (row - va).abs() <= tol.angular() || (vb - row).abs() <= tol.angular();
2211        for (this, other) in [(0, 1), (1, 0)] {
2212            if on_seam(rings[this].row) && !on_seam(rings[other].row) {
2213                let toward = rings[other].row;
2214                rings[this].row = if (toward - va).abs() <= (vb - toward).abs() {
2215                    va
2216                } else {
2217                    vb
2218                };
2219            }
2220        }
2221    }
2222    let anchor = {
2223        let Some(node) = model.node(&rings[0].vertex) else {
2224            ogeom_bail!(Dangling, "vertex is not in this model");
2225        };
2226        let Some(data) = node.data().as_vertex() else {
2227            ogeom_bail!(Construction, "vertex node holds no vertex data");
2228        };
2229        data.point
2230    };
2231    let ua = match chart_of(anchor) {
2232        Some(uv) => uv.x,
2233        None => {
2234            crate::measure::project_on_surface(surface, anchor, 32, tol)?
2235                .parameters
2236                .0
2237        }
2238    };
2239    if !rings[1].degenerate && circle_crossing_of(model, &rings[1].edge, anchor, tol)?.is_some() {
2240        ogeom_bail!(
2241            Construction,
2242            "the band rings' vertices stand on different columns, which no \
2243             seam joins; split_ring_on_column gives one rim a vertex on the \
2244             other's column"
2245        );
2246    }
2247
2248    // The seam runs along the surface's own iso-curve at the anchor angle,
2249    // parameterized by `v`, built along increasing `v`.
2250    let (va, vb) = (rings[0].row, rings[1].row);
2251    let turned = match profile {
2252        Some(revolved) => {
2253            use ogeom_geom::Transformable as _;
2254            let turn = ogeom_math::Transform::rotation(revolved.axis(), ua);
2255            Some(revolved.curve().clone().transformed(&turn, tol)?)
2256        }
2257        None => seam_iso_u_curve(surface, ua, tol),
2258    };
2259    let Some(seam_curve) = turned else {
2260        ogeom_bail!(
2261            Construction,
2262            "the surface's iso-curve has no closed form; no seam can be built"
2263        );
2264    };
2265    let (range, from, to, downward) = if va <= vb {
2266        (
2267            (va, vb),
2268            rings[0].vertex.clone(),
2269            rings[1].vertex.clone(),
2270            false,
2271        )
2272    } else {
2273        (
2274            (vb, va),
2275            rings[1].vertex.clone(),
2276            rings[0].vertex.clone(),
2277            true,
2278        )
2279    };
2280    // The edge lives on the curve's own parameterization; the chart rows map
2281    // onto it linearly, which is exactly the rescale a pcurve range states.
2282    let curve_range = (
2283        iso_curve_parameter_at(surface, range.0),
2284        iso_curve_parameter_at(surface, range.1),
2285    );
2286    let seam = make_edge_between(model, seam_curve, curve_range, &from, &to, tol)?.shape;
2287
2288    // Nothing above touches the rings, so a refusal (ring vertices on
2289    // different columns, which no seam joins) leaves them as they came,
2290    // with no pcurve on a surface no face uses.
2291    let surface_id = model.geometry_mut().add_surface(surface.clone());
2292
2293    // Window-coherent ring pcurves: u(t) spans [ua, ua + span] whichever way
2294    // each ring winds.
2295    for ring in &rings {
2296        let u_start = if ring.winding > 0.0 { ua } else { ua + span };
2297        let origin =
2298            ogeom_math::Point2::new(ring.winding.mul_add(-ring.crange.0, u_start), ring.row);
2299        let pcurve: ogeom_geom::PlanarCurve = ogeom_geom::Line2d::over(
2300            ogeom_math::Axis2::new(
2301                origin,
2302                ogeom_math::Direction2::new(ogeom_math::Vector2::new(ring.winding, 0.0), tol)?,
2303            ),
2304            ring.crange.0,
2305            ring.crange.1,
2306        )?
2307        .into();
2308        attach_pcurve(
2309            model,
2310            &ring.edge,
2311            pcurve,
2312            surface_id,
2313            Location::identity(),
2314            ring.crange,
2315        )?;
2316    }
2317
2318    // The walk closes only if the top ring's traversal starts where the
2319    // bottom's ends, and the seam sides sit at the columns the walk visits.
2320    let bottom_end = if rings[0].winding > 0.0 {
2321        ua + span
2322    } else {
2323        ua
2324    };
2325    let other_col = if rings[0].winding > 0.0 {
2326        ua
2327    } else {
2328        ua + span
2329    };
2330    let hi_reversed = (rings[1].winding - rings[0].winding).abs() < 0.5;
2331    let column = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
2332        Ok(ogeom_geom::Line2d::over(
2333            ogeom_math::Axis2::new(
2334                ogeom_math::Point2::new(u, 0.0),
2335                ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, 1.0), tol)?,
2336            ),
2337            range.0 - 1.0,
2338            range.1 + 1.0,
2339        )?
2340        .into())
2341    };
2342    // The walk runs counter-clockwise in the chart where the first ring
2343    // turns with `u` along the lower row, or against it along the upper; the
2344    // other way round, the ring is walked back, so the face keeps its
2345    // material on the left about the surface's normal.
2346    let counter_clockwise = (rings[0].winding > 0.0) == (va < vb);
2347    // The first seam occurrence in the wire is `up`; the triangulator hands
2348    // a Forward occurrence the `forward` pcurve. `up` is Forward exactly
2349    // when the seam was built upward, and it stands where the first ring's
2350    // walk ends, or where it starts when the ring is walked back.
2351    let (forward_col, reversed_col) = if downward == counter_clockwise {
2352        (other_col, bottom_end)
2353    } else {
2354        (bottom_end, other_col)
2355    };
2356    attach_seam(
2357        model,
2358        &seam,
2359        column(forward_col)?,
2360        column(reversed_col)?,
2361        surface_id,
2362        Location::identity(),
2363        range,
2364    )?;
2365
2366    let up = if downward {
2367        seam.reversed()
2368    } else {
2369        seam.clone()
2370    };
2371    let top = if hi_reversed {
2372        rings[1].edge.reversed()
2373    } else {
2374        rings[1].edge.clone()
2375    };
2376    let ring = if counter_clockwise {
2377        vec![rings[0].edge.clone(), up.clone(), top, up.reversed()]
2378    } else {
2379        vec![
2380            rings[0].edge.reversed(),
2381            up.clone(),
2382            top.reversed(),
2383            up.reversed(),
2384        ]
2385    };
2386    let wire = make_wire(model, &ring, tol)?.shape;
2387    Ok(make_face_on(model, surface_id, &[wire], tol)?.shape)
2388}
2389
2390/// A circle edge's curve and range, the parameter where it crosses a
2391/// column, and the point there.
2392type RingCrossing = (Curve, (f64, f64), f64, Point);
2393
2394/// Where a closed circle edge crosses the column of `anchor`: the circle's
2395/// parameter there, inside the edge's range, and the point. `None` where
2396/// the edge's own vertex already stands on that column.
2397fn circle_crossing_of(
2398    model: &Model,
2399    ring: &Shape,
2400    anchor: Point,
2401    tol: Tolerances,
2402) -> OgeomResult<Option<RingCrossing>> {
2403    let Some(data) = model.node(ring).and_then(|n| n.data().as_edge()) else {
2404        ogeom_bail!(Dangling, "edge is not in this model");
2405    };
2406    let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
2407        ogeom_bail!(Construction, "a band ring has no curve");
2408    };
2409    let range = *range;
2410    let Some(geometry) = model.geometry().curve(*curve) else {
2411        ogeom_bail!(Dangling, "curve is not in this model");
2412    };
2413    let Curve::Circle(c) = geometry else {
2414        ogeom_bail!(Construction, "a band ring is not a circle");
2415    };
2416    let circle = c.circle();
2417    let local = circle.frame().to_local(anchor);
2418    if local.x.hypot(local.y) <= tol.confusion() {
2419        ogeom_bail!(
2420            Construction,
2421            "the anchor stands on the ring's axis, where every column meets"
2422        );
2423    }
2424    let angle = local.y.atan2(local.x);
2425    let along = if c.is_reversed() { -angle } else { angle };
2426    let period = core::f64::consts::TAU;
2427    let t = range.0 + (along - range.0).rem_euclid(period);
2428    let point = geometry.point_at(t, tol)?;
2429    let start = geometry.point_at(range.0, tol)?;
2430    let end = geometry.point_at(range.1, tol)?;
2431    if point.distance(start) <= tol.confusion() || point.distance(end) <= tol.confusion() {
2432        return Ok(None);
2433    }
2434    Ok(Some((geometry.clone(), range, t, point)))
2435}
2436
2437/// Split a closed circle rim of a revolution band where it crosses the
2438/// column of `anchor`, the vertex of the band's other rim, so both rims
2439/// have a vertex on one column and a seam can join them.
2440///
2441/// The rim must be a parallel of `surface` (its axis the revolution axis,
2442/// its centre on it), as [`make_revolution_band`] requires. It becomes two
2443/// edges on its own curve: the first from its vertex to a new vertex on the
2444/// anchor's column, the second back. The result is a closed wire of the two
2445/// in the curve's direction starting on the anchor's column, the second
2446/// piece first, ready to be a ring of [`make_band_of_rings`]. The history
2447/// records the rim as modified into both pieces.
2448///
2449/// The pieces carry no pcurves, and every face holding the rim must take
2450/// both pieces in its place, or the shell opens along the rim: a caller
2451/// splitting a rim that bounds a face already built rebuilds that face.
2452///
2453/// Returns `None` when the rim's vertex already stands on the anchor's
2454/// column.
2455///
2456/// # Errors
2457///
2458/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
2459/// rim is not a closed circle that is a parallel of `surface`, or the
2460/// anchor stands on its axis.
2461pub fn split_ring_on_column(
2462    model: &mut Model,
2463    surface: &SurfaceGeometry,
2464    ring: &Shape,
2465    anchor: &Shape,
2466    tol: Tolerances,
2467) -> OgeomResult<Option<Built>> {
2468    let rim = ring.oriented(ogeom_topo::Orientation::Forward);
2469    let Some((vertex, other)) = edge_vertices(model, &rim)? else {
2470        ogeom_bail!(Construction, "a band ring has no vertex");
2471    };
2472    if !vertex.is_same(&other) {
2473        ogeom_bail!(Construction, "a band ring is not closed");
2474    }
2475    if !rings_are_parallels(model, surface, &[&rim], tol)? {
2476        ogeom_bail!(
2477            Construction,
2478            "a band ring is not a parallel of the surface it bounds"
2479        );
2480    }
2481    let anchor_point = {
2482        let Some(data) = model.node(anchor).and_then(|n| n.data().as_vertex()) else {
2483            ogeom_bail!(Construction, "the anchor is not a vertex of this model");
2484        };
2485        anchor.transform(model.datums())?.apply(data.point)
2486    };
2487    let Some((curve, range, t, point)) = circle_crossing_of(model, &rim, anchor_point, tol)? else {
2488        return Ok(None);
2489    };
2490    let stated = model
2491        .node(&rim)
2492        .and_then(|n| n.data().as_edge())
2493        .map(|d| d.tolerance);
2494    let column = make_vertex(model, point).shape;
2495    let head = make_edge_between(model, curve.clone(), (range.0, t), &vertex, &column, tol)?.shape;
2496    let tail = make_edge_between(model, curve, (t, range.1), &column, &vertex, tol)?.shape;
2497    if let Some(stated) = stated {
2498        model.widen(&head, stated)?;
2499        model.widen(&tail, stated)?;
2500    }
2501    let wire = make_wire(model, &[tail.clone(), head.clone()], tol)?.shape;
2502    let mut history = History::new();
2503    history.modify(&rim, head);
2504    history.modify(&rim, tail);
2505    Ok(Some(Built::new(wire, history)))
2506}
2507
2508/// Build the face of a band between two closed rings of edges on a periodic
2509/// analytic surface, joined by a synthesised seam.
2510///
2511/// [`make_revolution_band`] for rings of any number of edges, each edge a
2512/// curve with a closed-form pcurve on the surface: a notched rim of arcs and
2513/// rulings, not only a whole circle. Each ring is the edge occurrences of a
2514/// closed wire in order, the first starting on the seam's column; a ring of
2515/// one degenerate edge stands for a pole or an apex. The first ring is
2516/// walked as given and fixes the seam's column and which way round the band
2517/// runs; the second is walked back the other way, reversed if it was given
2518/// winding the same way, and the seam joins the two start vertices along the
2519/// surface's iso-curve. The face is built on a fresh copy of the surface.
2520///
2521/// # Errors
2522///
2523/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
2524/// surface is not a periodic analytic surface, a ring does not go once round
2525/// it, the rings' start vertices do not share a column, an edge has no
2526/// closed-form pcurve, or the iso-curve has no closed form.
2527pub fn make_band_of_rings(
2528    model: &mut Model,
2529    surface: &SurfaceGeometry,
2530    ring_a: &[Shape],
2531    ring_b: &[Shape],
2532    tol: Tolerances,
2533) -> OgeomResult<Shape> {
2534    use ogeom_geom::Curve2d as _;
2535    use ogeom_geom::Surface as _;
2536    use ogeom_math::{Axis2, Direction2, Point2, Transform2, Vector2};
2537
2538    let ((ua_dom, ub_dom), _) = surface.domain();
2539    let span = ub_dom - ua_dom;
2540    if !surface.is_periodic_u() || span <= 0.0 || ring_a.is_empty() || ring_b.is_empty() {
2541        ogeom_bail!(
2542            Construction,
2543            "a band needs two rings on a surface closing in u"
2544        );
2545    }
2546    let point_of = |model: &Model, vertex: &Shape| -> OgeomResult<Point> {
2547        let Some(data) = model.node(vertex).and_then(|n| n.data().as_vertex()) else {
2548            ogeom_bail!(Dangling, "vertex is not in this model");
2549        };
2550        Ok(vertex.transform(model.datums())?.apply(data.point))
2551    };
2552    let start_of = |model: &Model, ring: &[Shape]| -> OgeomResult<Shape> {
2553        let Some((start, _)) = edge_vertices(model, &ring[0])? else {
2554            ogeom_bail!(Construction, "a band ring's edge has no vertex");
2555        };
2556        Ok(start)
2557    };
2558    let (start_a, start_b) = (start_of(model, ring_a)?, start_of(model, ring_b)?);
2559    // The face's copy of the surface is turned about its axis so the seam
2560    // stands at the start of its window: the band then lies in the
2561    // surface's own period, as a primitive's does.
2562    let surface = {
2563        let Some(at) = analytic_chart_of(surface, point_of(model, &start_a)?) else {
2564            ogeom_bail!(Construction, "the band's surface has no closed-form chart");
2565        };
2566        let (Some(origin), Some(axis)) = (surface_axis_origin(surface), surface_iso_axis(surface))
2567        else {
2568            ogeom_bail!(Construction, "the band's surface has no axis");
2569        };
2570        let turn = ogeom_math::Transform::rotation(
2571            ogeom_math::Axis::new(origin, ogeom_math::Direction::new(axis, tol)?),
2572            at.x - ua_dom,
2573        );
2574        ogeom_geom::Transformable::transformed(surface, &turn, tol)?
2575    };
2576    let surface = &surface;
2577    let (Some(chart_a), Some(chart_b)) = (
2578        analytic_chart_of(surface, point_of(model, &start_a)?),
2579        analytic_chart_of(surface, point_of(model, &start_b)?),
2580    ) else {
2581        ogeom_bail!(Construction, "the band's surface has no closed-form chart");
2582    };
2583    // At the window's start, give or take a rounding either side of it.
2584    let column = if (chart_a.x - ub_dom).abs() <= 1e-9 * span {
2585        ua_dom
2586    } else {
2587        chart_a.x
2588    };
2589    let degenerate_b = ring_b.len() == 1
2590        && model
2591            .node(&ring_b[0])
2592            .and_then(|n| n.data().as_edge())
2593            .is_some_and(|d| d.degenerate);
2594    // A pole's column is any column; a rim's start must stand on the seam.
2595    let apart = (chart_b.x - column).rem_euclid(span);
2596    if !degenerate_b && apart.min(span - apart) > 1e-6 * span {
2597        ogeom_bail!(
2598            Construction,
2599            "the band rings' start vertices are not on one column"
2600        );
2601    }
2602
2603    // A ring's pcurves, each moved by whole periods to continue the walk
2604    // from `from`, and where the walk ends.
2605    type Walk = (Vec<(Shape, ogeom_geom::PlanarCurve, (f64, f64))>, Point2);
2606    let walk = |model: &Model, ring: &[Shape], from: Point2| -> OgeomResult<Walk> {
2607        let mut out = Vec::with_capacity(ring.len());
2608        let mut at = from;
2609        for occurrence in ring {
2610            let Some(data) = model.node(occurrence).and_then(|n| n.data().as_edge()) else {
2611                ogeom_bail!(Dangling, "edge is not in this model");
2612            };
2613            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
2614                ogeom_bail!(Construction, "a band ring's edge has no curve");
2615            };
2616            let Some(geometry) = model.geometry().curve(*curve) else {
2617                ogeom_bail!(Dangling, "curve is not in this model");
2618            };
2619            let geometry = ogeom_geom::Transformable::transformed(
2620                geometry,
2621                &occurrence.transform(model.datums())?,
2622                tol,
2623            )?;
2624            let Some(pcurve) = ogeom_intersect::exact_pcurve_over(&geometry, *range, surface, tol)
2625            else {
2626                ogeom_bail!(Construction, "a band ring's edge has no closed-form pcurve");
2627            };
2628            // A line pcurve's stated domain must cover the edge's range,
2629            // which for a circle anchored anywhere runs past one period.
2630            let pcurve = match &pcurve {
2631                ogeom_geom::PlanarCurve::Line(l) => {
2632                    let (lo, hi) = (l.domain().0.min(range.0), l.domain().1.max(range.1));
2633                    ogeom_geom::Line2d::over(l.axis(), lo, hi)?.into()
2634                }
2635                _ => pcurve,
2636            };
2637            let reversed = occurrence.orientation() == ogeom_topo::Orientation::Reversed;
2638            let (t0, t1) = if reversed {
2639                (range.1, range.0)
2640            } else {
2641                (range.0, range.1)
2642            };
2643            let start = pcurve.point_at(t0, tol)?;
2644            let turns = ((at.x - start.x) / span).round();
2645            let pcurve = pcurve.transformed(
2646                &Transform2::translation(Vector2::new(turns * span, 0.0)),
2647                tol,
2648            )?;
2649            at = pcurve.point_at(t1, tol)?;
2650            out.push((occurrence.clone(), pcurve, *range));
2651        }
2652        Ok((out, at))
2653    };
2654    let once_round = |from: f64, to: f64| ((to - from).abs() - span).abs() <= 1e-6 * span;
2655
2656    // The band stands in the period from the column up, the surface's own
2657    // window's way round: a ring winding backward starts a period on.
2658    let (mut walked_a, mut end_a) = walk(model, ring_a, Point2::new(column, chart_a.y))?;
2659    let column = if end_a.x < column {
2660        (walked_a, end_a) = walk(model, ring_a, Point2::new(column + span, chart_a.y))?;
2661        column + span
2662    } else {
2663        column
2664    };
2665    if !once_round(column, end_a.x) {
2666        ogeom_bail!(Construction, "the first band ring does not go once round");
2667    }
2668    let end_column = end_a.x;
2669
2670    // The second ring runs back from where the first ended.
2671    let mut ring_b: Vec<Shape> = ring_b.to_vec();
2672    let walked_b = if degenerate_b {
2673        let toward = (column - end_column).signum();
2674        let pole: ogeom_geom::PlanarCurve = ogeom_geom::Line2d::over(
2675            Axis2::new(
2676                Point2::new(end_column, chart_b.y),
2677                Direction2::new(Vector2::new(toward, 0.0), tol)?,
2678            ),
2679            0.0,
2680            span,
2681        )?
2682        .into();
2683        vec![(ring_b[0].clone(), pole, (0.0, span))]
2684    } else {
2685        let mut walked = walk(model, &ring_b, Point2::new(end_column, chart_b.y))?;
2686        if !once_round(end_column, walked.1.x) {
2687            ogeom_bail!(Construction, "the second band ring does not go once round");
2688        }
2689        if (walked.1.x - column).abs() > 1e-6 * span {
2690            ring_b = ring_b.iter().rev().map(Shape::reversed).collect();
2691            walked = walk(model, &ring_b, Point2::new(end_column, chart_b.y))?;
2692        }
2693        walked.0
2694    };
2695
2696    let surface_id = model.geometry_mut().add_surface(surface.clone());
2697    for (edge, pcurve, range) in walked_a.into_iter().chain(walked_b) {
2698        attach_pcurve(
2699            model,
2700            &edge,
2701            pcurve,
2702            surface_id,
2703            Location::identity(),
2704            range,
2705        )?;
2706    }
2707
2708    // The seam along the iso-curve at the column, built upward in `v`; the
2709    // occurrence climbing from the first ring to the second stands on the
2710    // column the first ring's walk ended at.
2711    let (va, vb) = (chart_a.y, chart_b.y);
2712    let Some(seam_curve) = seam_iso_u_curve(surface, column, tol) else {
2713        ogeom_bail!(
2714            Construction,
2715            "the surface's iso-curve has no closed form; no seam can be built"
2716        );
2717    };
2718    let (rows, from, to, downward) = if va <= vb {
2719        ((va, vb), start_a.clone(), start_b.clone(), false)
2720    } else {
2721        ((vb, va), start_b.clone(), start_a.clone(), true)
2722    };
2723    let curve_range = (
2724        iso_curve_parameter_at(surface, rows.0),
2725        iso_curve_parameter_at(surface, rows.1),
2726    );
2727    let seam = make_edge_between(model, seam_curve, curve_range, &from, &to, tol)?.shape;
2728    let side = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
2729        Ok(ogeom_geom::Line2d::over(
2730            Axis2::new(
2731                Point2::new(u, 0.0),
2732                Direction2::new(Vector2::new(0.0, 1.0), tol)?,
2733            ),
2734            rows.0 - 1.0,
2735            rows.1 + 1.0,
2736        )?
2737        .into())
2738    };
2739    // The rings run as given, so the face's neighbours still meet them
2740    // the other way; which way the loop then goes round the chart says
2741    // which side the face faces. Clockwise, it faces against the surface,
2742    // and stores the loop walked back so it keeps the face on its left in
2743    // the chart: the climbing occurrence then stands where the walk
2744    // starts, on the first ring's column.
2745    let clockwise = (end_column - column) * (vb - va) < 0.0;
2746    let (forward_side, reversed_side) = if downward != clockwise {
2747        (column, end_column)
2748    } else {
2749        (end_column, column)
2750    };
2751    attach_seam(
2752        model,
2753        &seam,
2754        side(forward_side)?,
2755        side(reversed_side)?,
2756        surface_id,
2757        Location::identity(),
2758        rows,
2759    )?;
2760    let up = if downward {
2761        seam.reversed()
2762    } else {
2763        seam.clone()
2764    };
2765    let mut edges: Vec<Shape> = ring_a.to_vec();
2766    edges.push(up.clone());
2767    edges.extend(ring_b);
2768    edges.push(up.reversed());
2769    if clockwise {
2770        edges = edges.iter().rev().map(Shape::reversed).collect();
2771    }
2772    let wire = make_wire(model, &edges, tol)?.shape;
2773    let face = make_face_on(model, surface_id, &[wire], tol)?.shape;
2774    Ok(if clockwise { face.reversed() } else { face })
2775}
2776
2777/// Build the face of a band between two closed rings that need not be
2778/// circles, each with a caller-supplied chart image.
2779///
2780/// [`make_revolution_band`]'s general sibling: where the band derives each
2781/// circle's row and pcurve itself, this takes the rings as they come (a
2782/// fitted tangency curve, a wavy trim), with the pcurve the caller already
2783/// knows for each, same-parameter over the edge's own range. The seam stays
2784/// an exact iso-column of the surface, which is what routes around the
2785/// closed-form refusal in [`make_face_with_pcurves`].
2786///
2787/// The caller's contract: both rings are closed, both chart images run the
2788/// full period *forward* in `u`, and both start on the same column; the
2789/// seam runs there, between the two start vertices. One ring may be a pole:
2790/// a degenerate edge whose image is the pole's row over `(0, period)`. The
2791/// face walks the lower ring forward and the upper one back, whichever
2792/// order they come in, so it keeps itself on the left of its walk.
2793///
2794/// # Errors
2795///
2796/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a
2797/// ring is not closed, the images disagree about the start column or do not
2798/// run the period forward, or the surface's iso-curve has no closed form.
2799pub fn make_band_between(
2800    model: &mut Model,
2801    surface: &SurfaceGeometry,
2802    rings: [(&Shape, ogeom_geom::PlanarCurve); 2],
2803    tol: Tolerances,
2804) -> OgeomResult<Shape> {
2805    use ogeom_geom::Curve2d as _;
2806    use ogeom_geom::Surface as _;
2807
2808    let surface_id = model.geometry_mut().add_surface(surface.clone());
2809    let ((ua_dom, ub_dom), _) = surface.domain();
2810    let span = ub_dom - ua_dom;
2811
2812    struct Ring {
2813        edge: Shape,
2814        vertex: Shape,
2815        crange: (f64, f64),
2816        pcurve: ogeom_geom::PlanarCurve,
2817        start: ogeom_math::Point2,
2818    }
2819    let mut prepared: Vec<Ring> = Vec::with_capacity(2);
2820    for (used, pcurve) in rings {
2821        let edge = if used.orientation() == ogeom_topo::Orientation::Reversed {
2822            used.reversed()
2823        } else {
2824            used.clone()
2825        };
2826        let Some((vertex, other)) = edge_vertices(model, &edge)? else {
2827            ogeom_bail!(Construction, "a band ring has no vertex");
2828        };
2829        if !vertex.is_same(&other) {
2830            ogeom_bail!(Construction, "a band ring is not closed");
2831        }
2832        let crange = {
2833            let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
2834                ogeom_bail!(Construction, "a band ring holds no edge data");
2835            };
2836            // A pole: no curve, and its chart image the pole's row run over
2837            // one period.
2838            if data.degenerate {
2839                (0.0, span)
2840            } else {
2841                let Some(EdgeRepr::Curve3d { range, .. }) = data.curve3d() else {
2842                    ogeom_bail!(Construction, "a band ring has no curve");
2843                };
2844                *range
2845            }
2846        };
2847        let start = pcurve.point_at(crange.0, tol)?;
2848        let end = pcurve.point_at(crange.1, tol)?;
2849        if (end.x - start.x - span).abs() > 1e-3 {
2850            ogeom_bail!(
2851                Construction,
2852                "a band ring's chart image must run the period forward in u"
2853            );
2854        }
2855        prepared.push(Ring {
2856            edge,
2857            vertex,
2858            crange,
2859            pcurve,
2860            start,
2861        });
2862    }
2863    // The band keeps itself on the left of its walk in the chart, which
2864    // runs the lower ring forward and the upper one back: the rings do not
2865    // cross, so their start rows say which is lower.
2866    if prepared[1].start.y < prepared[0].start.y {
2867        prepared.swap(0, 1);
2868    }
2869    // The connector between the rings' starts. On one column it is the
2870    // surface's own iso; on different columns it is the straight chart
2871    // segment: a ruling, a parallel arc, or a helix, all exact on a
2872    // cylinder, and refused by name elsewhere.
2873    // The rings close round the period, so a start a whole period from the
2874    // other's is the same column: the second ring's image is moved into the
2875    // first's period, or the connector would wind a full turn round to it.
2876    let turns = ((prepared[1].start.x - prepared[0].start.x) / span).round();
2877    if turns != 0.0 {
2878        let shift =
2879            ogeom_math::Transform2::translation(ogeom_math::Vector2::new(-turns * span, 0.0));
2880        prepared[1].pcurve = prepared[1].pcurve.transformed(&shift, tol)?;
2881        prepared[1].start = shift.apply(prepared[1].start);
2882    }
2883    let (start0, start1) = (prepared[0].start, prepared[1].start);
2884    let dcol = start1.x - start0.x;
2885    // The iso is for rings sharing a column to within the confusion
2886    // distance: the offset times the chart's stretch there, since an
2887    // off-column iso misses a start vertex by exactly that. Starts further
2888    // apart take the chart segment, which meets both exactly; a segment
2889    // spanning less than confusion would be an edge with no length.
2890    let stretch = {
2891        let (du, _) = surface.d1_at(start0.x, start0.y, tol)?;
2892        du.magnitude().max(1.0)
2893    };
2894    let (seam, up_is_forward, chart_from, chart_to) = if dcol.abs() * stretch <= tol.confusion() {
2895        let column = start0.x;
2896        let (va, vb) = (start0.y, start1.y);
2897        let Some(seam_curve) = seam_iso_u_curve(surface, column, tol) else {
2898            ogeom_bail!(
2899                Construction,
2900                "the surface's iso-curve has no closed form; no seam can be built"
2901            );
2902        };
2903        let (range, from, to, downward) = if va <= vb {
2904            (
2905                (va, vb),
2906                prepared[0].vertex.clone(),
2907                prepared[1].vertex.clone(),
2908                false,
2909            )
2910        } else {
2911            (
2912                (vb, va),
2913                prepared[1].vertex.clone(),
2914                prepared[0].vertex.clone(),
2915                true,
2916            )
2917        };
2918        let curve_range = (
2919            iso_curve_parameter_at(surface, range.0),
2920            iso_curve_parameter_at(surface, range.1),
2921        );
2922        // A ring fitted through its stations starts a fit error off the
2923        // exact column; its start vertex owns that slop.
2924        for (vertex, at) in [(&from, curve_range.0), (&to, curve_range.1)] {
2925            let Some(data) = model.node(vertex).and_then(|n| n.data().as_vertex()) else {
2926                ogeom_bail!(Construction, "a band ring's start vertex holds no point");
2927            };
2928            let placed = vertex.transform(model.datums())?.apply(data.point);
2929            let miss = seam_curve.point_at(at, tol)?.distance(placed);
2930            if miss > tol.confusion() {
2931                model.widen(vertex, ogeom_core::Tolerance::new(miss * 2.0)?)?;
2932            }
2933        }
2934        let seam = make_edge_between(model, seam_curve, curve_range, &from, &to, tol)?.shape;
2935        let (a, b) = (
2936            ogeom_math::Point2::new(column, range.0),
2937            ogeom_math::Point2::new(column, range.1),
2938        );
2939        (seam, !downward, a, b)
2940    } else if !matches!(surface, SurfaceGeometry::Cylinder(_)) {
2941        // On any other surface the chart segment lifts to a curve with no
2942        // closed form (a loxodrome on a sphere, a skew run on a torus)
2943        // and is fitted through it, at the chart's own arc length so the
2944        // curve and its images share one parameter. The fit's error is the
2945        // vertices' to carry.
2946        let (a, b, from, to, forward) = if dcol > 0.0 {
2947            (
2948                start0,
2949                start1,
2950                prepared[0].vertex.clone(),
2951                prepared[1].vertex.clone(),
2952                true,
2953            )
2954        } else {
2955            (
2956                start1,
2957                start0,
2958                prepared[1].vertex.clone(),
2959                prepared[0].vertex.clone(),
2960                false,
2961            )
2962        };
2963        let length = (b.x - a.x).hypot(b.y - a.y);
2964        const SAMPLES: usize = 64;
2965        // Held to the target between the samples as well as at them; the
2966        // error is the worst measured, and the edge states it below.
2967        let target = tol.confusion() * 10.0;
2968        let stations = crate::traced::stations_between((0.0, length), &[], 3, SAMPLES);
2969        let fitted = crate::traced::fit_traced(
2970            |t| {
2971                let f = t / length;
2972                surface.point_at(a.x + (b.x - a.x) * f, a.y + (b.y - a.y) * f, tol)
2973            },
2974            &stations,
2975            3,
2976            target,
2977            tol,
2978        )?;
2979        if fitted.error > tol.confusion() * 1e3 {
2980            ogeom_bail!(
2981                NotDone,
2982                "a band's connector reached {} against a target of {target}",
2983                fitted.error
2984            );
2985        }
2986        let fit_error = fitted.error;
2987        let connector: ogeom_geom::Curve = ogeom_geom::Curve::BSpline(fitted.curve);
2988        // Against the vertices' own points, not the lifted chart points: a
2989        // ring's start vertex stands off the host by the ring's own slop
2990        // (a fitted seam a few microns off the surface it trims), and the
2991        // connector, which lies on the host, misses it by that much.
2992        for (vertex, at) in [(&from, 0.0), (&to, length)] {
2993            let Some(data) = model.node(vertex).and_then(|n| n.data().as_vertex()) else {
2994                ogeom_bail!(Construction, "a band ring's start vertex holds no point");
2995            };
2996            let placed = vertex.transform(model.datums())?.apply(data.point);
2997            let miss = connector.point_at(at, tol)?.distance(placed);
2998            if miss > tol.confusion() {
2999                model.widen(vertex, ogeom_core::Tolerance::new(miss * 2.0)?)?;
3000            }
3001        }
3002        let seam = make_edge_between(model, connector, (0.0, length), &from, &to, tol)?.shape;
3003        if fit_error > tol.confusion() {
3004            model.widen(
3005                &seam,
3006                ogeom_core::Tolerance::new(fit_error + tol.confusion())?,
3007            )?;
3008        }
3009        (seam, forward, a, b)
3010    } else {
3011        let SurfaceGeometry::Cylinder(c) = surface else {
3012            ogeom_bail!(
3013                Construction,
3014                "a band whose rings start on different columns needs a chart \
3015                 connector; the surface above was not a cylinder"
3016            );
3017        };
3018        let cylinder = c.cylinder();
3019        let frame = cylinder.frame();
3020        // The chart segment, parameterized by the angle so the pcurve's
3021        // linear map onto it is exact.
3022        let (a, b, from, to, forward) = if dcol > 0.0 {
3023            (
3024                start0,
3025                start1,
3026                prepared[0].vertex.clone(),
3027                prepared[1].vertex.clone(),
3028                true,
3029            )
3030        } else {
3031            (
3032                start1,
3033                start0,
3034                prepared[1].vertex.clone(),
3035                prepared[0].vertex.clone(),
3036                false,
3037            )
3038        };
3039        let drow = b.y - a.y;
3040        let connector: ogeom_geom::Curve = if drow.abs() <= 1e-9 {
3041            // A parallel: an arc at one height.
3042            let lifted = ogeom_math::Frame::new(
3043                frame.origin() + frame.z().vector() * a.y,
3044                frame.z(),
3045                frame.x(),
3046                tol,
3047            )?;
3048            ogeom_geom::CircleCurve::new(ogeom_math::Circle::new(lifted, cylinder.radius(), tol)?)
3049                .into()
3050        } else {
3051            // A helix through both chart points: the pitch is the slope, and
3052            // the frame rides down so height zero lands where it must.
3053            let pitch = core::f64::consts::TAU * drow / (b.x - a.x);
3054            let dropped = ogeom_math::Frame::new(
3055                frame.origin()
3056                    + frame.z().vector() * (pitch / core::f64::consts::TAU).mul_add(-a.x, a.y),
3057                frame.z(),
3058                frame.x(),
3059                tol,
3060            )?;
3061            ogeom_geom::HelixCurve::over(dropped, cylinder.radius(), pitch, a.x, b.x)?.into()
3062        };
3063        let seam = make_edge_between(model, connector, (a.x, b.x), &from, &to, tol)?.shape;
3064        (seam, forward, a, b)
3065    };
3066
3067    // Both rings run forward, so the walk closes as [lo, up, hi rev, down]:
3068    // the bottom ends at the far column, and the seam's two chart images sit
3069    // one period apart, assigned to whichever occurrence the walk meets
3070    // first.
3071    let segment_line = |shift: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
3072        let a = ogeom_math::Point2::new(chart_from.x + shift, chart_from.y);
3073        let b = ogeom_math::Point2::new(chart_to.x + shift, chart_to.y);
3074        let d = ogeom_math::Vector2::new(b.x - a.x, b.y - a.y);
3075        let m = d.x.hypot(d.y);
3076        if m <= 1e-12 {
3077            ogeom_bail!(Construction, "the seam's chart image has no length");
3078        }
3079        Ok(ogeom_geom::Line2d::over(
3080            ogeom_math::Axis2::new(a, ogeom_math::Direction2::new(d, tol)?),
3081            0.0,
3082            m,
3083        )?
3084        .into())
3085    };
3086    let (forward_shift, reversed_shift) = if up_is_forward {
3087        (span, 0.0)
3088    } else {
3089        (0.0, span)
3090    };
3091    // The pcurves run over their own arc length, start-for-start with the
3092    // seam's curve range, so that is the window the attachment states.
3093    let seam_length = (chart_to.x - chart_from.x).hypot(chart_to.y - chart_from.y);
3094    attach_seam(
3095        model,
3096        &seam,
3097        segment_line(forward_shift)?,
3098        segment_line(reversed_shift)?,
3099        surface_id,
3100        Location::identity(),
3101        (0.0, seam_length),
3102    )?;
3103    crate::state_pcurve_gaps_of(model, core::slice::from_ref(&seam), tol)?;
3104    for ring in &prepared {
3105        attach_pcurve(
3106            model,
3107            &ring.edge,
3108            ring.pcurve.clone(),
3109            surface_id,
3110            Location::identity(),
3111            ring.crange,
3112        )?;
3113    }
3114
3115    let up = if up_is_forward {
3116        seam.clone()
3117    } else {
3118        seam.reversed()
3119    };
3120    let walk = vec![
3121        prepared[0].edge.clone(),
3122        up.clone(),
3123        prepared[1].edge.reversed(),
3124        up.reversed(),
3125    ];
3126    let wire = make_wire(model, &walk, tol)?.shape;
3127    Ok(make_face_on(model, surface_id, &[wire], tol)?.shape)
3128}
3129
3130/// Build the face of a revolution cap: one closed ring belting a cone, with
3131/// the apex the file never wrote synthesised as the degenerate ring the band
3132/// needs.
3133///
3134/// A cone face bounded by a single circle has exactly one other boundary the
3135/// geometry permits, the apex, because the region away from the apex is
3136/// unbounded. The apex becomes a vertex and a degenerate edge, and the rest
3137/// is [`make_revolution_band`], one authority for the seam either way.
3138///
3139/// # Errors
3140///
3141/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the surface
3142/// is not a cone, or the ring resists the band construction.
3143pub fn make_apex_band(
3144    model: &mut Model,
3145    surface: &SurfaceGeometry,
3146    ring: &Shape,
3147    tol: Tolerances,
3148) -> OgeomResult<Shape> {
3149    let SurfaceGeometry::Cone(c) = surface else {
3150        ogeom_bail!(
3151            Construction,
3152            "only a cone has the single apex a one-ring cap implies"
3153        );
3154    };
3155    let apex = model.add_vertex(VertexData::new(c.cone().apex()));
3156    let mut data = EdgeData::new();
3157    data.degenerate = true;
3158    let edge = model.add_edge(data, &[apex.clone(), apex])?;
3159    make_revolution_band(model, surface, ring, &edge, tol)
3160}
3161
3162/// The chart coordinates of a point on a periodic analytic surface, by
3163/// closed-form inversion, folded into the surface's own stated window.
3164///
3165/// The band synthesis needs the *row* each ring stands on and the *column*
3166/// the seam anchors to; iterative projection over an imported surface's
3167/// enormous stated extents can converge to a clamped boundary and place a
3168/// seam whole units from the vertex it must meet, so the analytic kinds
3169/// invert exactly instead.
3170fn analytic_chart_of(
3171    surface: &SurfaceGeometry,
3172    p: ogeom_math::Point,
3173) -> Option<ogeom_math::Point2> {
3174    use ogeom_geom::Surface as _;
3175    let tau = core::f64::consts::TAU;
3176    let raw = match surface {
3177        SurfaceGeometry::Cylinder(s) => {
3178            let l = s.cylinder().frame().to_local(p);
3179            ogeom_math::Point2::new(l.y.atan2(l.x), l.z)
3180        }
3181        SurfaceGeometry::Cone(s) => {
3182            let l = s.cone().frame().to_local(p);
3183            ogeom_math::Point2::new(l.y.atan2(l.x), l.z)
3184        }
3185        SurfaceGeometry::Sphere(s) => {
3186            let sphere = s.sphere();
3187            let l = sphere.frame().to_local(p);
3188            let lat = (l.z / sphere.radius()).clamp(-1.0, 1.0).asin();
3189            ogeom_math::Point2::new(l.y.atan2(l.x), lat)
3190        }
3191        SurfaceGeometry::Torus(s) => {
3192            let torus = s.torus();
3193            let l = torus.frame().to_local(p);
3194            let radial = l.x.hypot(l.y) - torus.major_radius();
3195            ogeom_math::Point2::new(l.y.atan2(l.x), l.z.atan2(radial))
3196        }
3197        _ => return None,
3198    };
3199    let ((ua, _), (va, vb)) = surface.domain();
3200    let u = ua + (raw.x - ua).rem_euclid(tau);
3201    let v = if surface.is_periodic_v() {
3202        va + (raw.y - va).rem_euclid(vb - va)
3203    } else {
3204        raw.y
3205    };
3206    Some(ogeom_math::Point2::new(u, v))
3207}
3208
3209/// The revolution axis direction of a periodic analytic surface, the way
3210/// its angle `u` turns about: [`spin`] of its frame.
3211fn surface_iso_axis(surface: &ogeom_geom::SurfaceGeometry) -> Option<ogeom_math::Vector> {
3212    match surface {
3213        ogeom_geom::SurfaceGeometry::Cylinder(c) => Some(spin(&c.cylinder().frame())),
3214        ogeom_geom::SurfaceGeometry::Cone(c) => Some(spin(&c.cone().frame())),
3215        ogeom_geom::SurfaceGeometry::Torus(t) => Some(spin(&t.torus().frame())),
3216        ogeom_geom::SurfaceGeometry::Sphere(s) => Some(spin(&s.sphere().frame())),
3217        _ => None,
3218    }
3219}
3220
3221/// The axis a frame's angle turns about: `x × y`, which is `z` on a
3222/// right-handed frame and `-z` on a left-handed one.
3223fn spin(frame: &ogeom_math::Frame) -> ogeom_math::Vector {
3224    frame.z().vector() * frame.handedness().sign()
3225}
3226
3227/// The surface's `u = at` iso-curve, parameterized by `v` exactly.
3228///
3229/// A ruling on a cylinder, a tube circle on a torus: the curve a seam runs
3230/// along. `None` where no closed form exists. Shared by the STEP reader's
3231/// seam synthesis and the healer's ring re-anchoring, so the two cannot
3232/// disagree about what a seam is.
3233pub fn surface_iso_u_curve(
3234    surface: &SurfaceGeometry,
3235    at: f64,
3236    tol: Tolerances,
3237) -> Option<ogeom_geom::Curve> {
3238    match surface {
3239        ogeom_geom::SurfaceGeometry::Cylinder(c) => {
3240            let cylinder = c.cylinder();
3241            let frame = cylinder.frame();
3242            let radial = frame.x().vector() * at.cos() + frame.y().vector() * at.sin();
3243            let location = frame.origin() + radial * cylinder.radius();
3244            let axis = ogeom_math::Axis {
3245                location,
3246                direction: frame.z(),
3247            };
3248            Some(ogeom_geom::LineCurve::new(axis).into())
3249        }
3250        ogeom_geom::SurfaceGeometry::Torus(t) => {
3251            let torus = t.torus();
3252            let frame = torus.frame();
3253            let radial = frame.x().vector() * at.cos() + frame.y().vector() * at.sin();
3254            let centre = frame.origin() + radial * torus.major_radius();
3255            // The tube circle framed so its own angle *is* the surface's v:
3256            // x toward the outer equator, y along the axis, which makes
3257            // z = x cross y the tangential direction.
3258            let circle_frame = ogeom_math::Frame::new(
3259                centre,
3260                ogeom_math::Direction::new(radial.cross(frame.z().vector()), tol).ok()?,
3261                ogeom_math::Direction::new(radial, tol).ok()?,
3262                tol,
3263            )
3264            .ok()?;
3265            let circle = ogeom_math::Circle::new(circle_frame, torus.minor_radius(), tol).ok()?;
3266            Some(ogeom_geom::CircleCurve::new(circle).into())
3267        }
3268        ogeom_geom::SurfaceGeometry::Cone(c) => {
3269            let cone = c.cone();
3270            let frame = cone.frame();
3271            let radial = frame.x().vector() * at.cos() + frame.y().vector() * at.sin();
3272            // The ruling through v = 0, arc-length parameterized: the chart's
3273            // v maps onto it linearly, by t = v / cos(half angle), which the
3274            // seam construction recovers through `iso_curve_parameter_at`.
3275            let location = frame.origin() + radial * cone.radius_at(0.0);
3276            let direction = ogeom_math::Direction::new(
3277                frame.z().vector() + radial * cone.half_angle().tan(),
3278                tol,
3279            )
3280            .ok()?;
3281            Some(
3282                ogeom_geom::LineCurve::new(ogeom_math::Axis {
3283                    location,
3284                    direction,
3285                })
3286                .into(),
3287            )
3288        }
3289        ogeom_geom::SurfaceGeometry::BSpline(b) => {
3290            Some(ogeom_geom::Curve::BSpline(b.iso_u_curve(at, tol).ok()?))
3291        }
3292        ogeom_geom::SurfaceGeometry::Sphere(sp) => {
3293            let sphere = sp.sphere();
3294            let frame = sphere.frame();
3295            let radial = frame.x().vector() * at.cos() + frame.y().vector() * at.sin();
3296            // The meridian framed so its own angle is the latitude exactly:
3297            // x one radius out along the parallel, y toward the north pole.
3298            let circle_frame = ogeom_math::Frame::new(
3299                frame.origin(),
3300                ogeom_math::Direction::new(radial.cross(frame.z().vector()), tol).ok()?,
3301                ogeom_math::Direction::new(radial, tol).ok()?,
3302                tol,
3303            )
3304            .ok()?;
3305            let circle = ogeom_math::Circle::new(circle_frame, sphere.radius(), tol).ok()?;
3306            Some(ogeom_geom::CircleCurve::new(circle).into())
3307        }
3308        _ => None,
3309    }
3310}
3311
3312/// The iso-curve a band's seam runs along at column `at`: the closed forms
3313/// [`surface_iso_u_curve`] names, and an extrusion's generator, the line
3314/// through its profile's point at `at` along the sweep, on which the
3315/// chart's `v` is the distance swept.
3316fn seam_iso_u_curve(
3317    surface: &SurfaceGeometry,
3318    at: f64,
3319    tol: Tolerances,
3320) -> Option<ogeom_geom::Curve> {
3321    surface_iso_u_curve(surface, at, tol).or_else(|| match surface {
3322        ogeom_geom::SurfaceGeometry::Extrusion(swept) => {
3323            use ogeom_geom::Curve3d as _;
3324            let location = swept.curve().point_at(at, tol).ok()?;
3325            Some(
3326                ogeom_geom::LineCurve::new(ogeom_math::Axis {
3327                    location,
3328                    direction: swept.direction(),
3329                })
3330                .into(),
3331            )
3332        }
3333        _ => None,
3334    })
3335}
3336
3337/// The parameter on a surface's iso-curve that lands at chart row `v`.
3338///
3339/// Identity for the kinds whose iso-curve is parameterized by `v` itself
3340/// (a cylinder ruling, a torus tube circle, a sphere meridian, an
3341/// extrusion's generator) and the slant
3342/// rescale for a cone, whose ruling is arc-length parameterized while the
3343/// chart's `v` is the height.
3344fn iso_curve_parameter_at(surface: &SurfaceGeometry, v: f64) -> f64 {
3345    match surface {
3346        ogeom_geom::SurfaceGeometry::Cone(c) => v / c.cone().half_angle().cos(),
3347        _ => v,
3348    }
3349}
3350
3351#[cfg(test)]
3352#[allow(clippy::unwrap_used, clippy::expect_used)]
3353mod tests {
3354    use super::*;
3355
3356    /// A ring's turn is twice its area wherever it stands: a square 0.125 on
3357    /// a side turns by 2 × 0.125² about its normal at the origin and 1e8 away
3358    /// alike. The side is small against the offset, so a sum whose rounding
3359    /// grows with the distance from the origin would swamp the area.
3360    #[test]
3361    fn a_small_ring_far_from_the_origin_turns_by_its_area() {
3362        let tol = ogeom_core::Tolerances::millimetres();
3363        let side = 0.125;
3364        for offset in [0.0, 1.0e6, 1.0e7, 1.0e8] {
3365            let mut model = Model::new();
3366            let corners = [(0.0, 0.0), (side, 0.0), (side, side), (0.0, side)]
3367                .map(|(x, y)| Point::new(offset + x, -offset + y, 0.0));
3368            let ring = make_polygon(&mut model, &corners, true, tol).unwrap().shape;
3369            let z = ogeom_math::Vector::new(0.0, 0.0, 1.0);
3370            // Corners 1e8 out are exact (0.125 is a power of two), but the
3371            // points sampled between them are rounded to 1.5e-8 there, a
3372            // relative 1e-7 of the side.
3373            approx::assert_relative_eq!(
3374                wire_turn(&model, &ring, z, tol).unwrap(),
3375                2.0 * side * side,
3376                max_relative = 1e-6
3377            );
3378        }
3379    }
3380
3381    /// A plane keeps its normal and the face walks its rings about it:
3382    /// an outline wound clockwise and a hole wound counter-clockwise are
3383    /// both walked back.
3384    #[test]
3385    fn a_planar_face_walks_its_rings_about_its_plane() {
3386        let tol = ogeom_core::Tolerances::millimetres();
3387        let mut model = Model::new();
3388        let ring = |model: &mut Model, corners: &[(f64, f64)]| {
3389            let points: Vec<Point> = corners
3390                .iter()
3391                .map(|&(x, y)| Point::new(x, y, 0.0))
3392                .collect();
3393            make_polygon(model, &points, true, tol).unwrap().shape
3394        };
3395        let outline = ring(
3396            &mut model,
3397            &[(0.0, 0.0), (0.0, 4.0), (4.0, 4.0), (4.0, 0.0)],
3398        );
3399        let hole = ring(
3400            &mut model,
3401            &[(1.0, 1.0), (3.0, 1.0), (3.0, 3.0), (1.0, 3.0)],
3402        );
3403        let plane = ogeom_math::Plane::new(ogeom_math::Frame::WORLD);
3404        let face = make_face(
3405            &mut model,
3406            ogeom_geom::PlaneSurface::new(plane).into(),
3407            &[outline.clone(), hole.clone()],
3408            tol,
3409        )
3410        .unwrap();
3411        let z = ogeom_math::Vector::new(0.0, 0.0, 1.0);
3412        let wires = model.children_of(&face.shape).unwrap();
3413        assert!(wire_turn(&model, &wires[0], z, tol).unwrap() > 0.0);
3414        assert!(wire_turn(&model, &wires[1], z, tol).unwrap() < 0.0);
3415        assert_eq!(
3416            face.history.generated(&outline),
3417            std::slice::from_ref(&face.shape)
3418        );
3419        assert_eq!(
3420            face.history.generated(&hole),
3421            std::slice::from_ref(&face.shape)
3422        );
3423        use ogeom_geom::Surface as _;
3424        let data = model.node(&face.shape).unwrap().data().as_face().unwrap();
3425        let normal = model
3426            .geometry()
3427            .surface(data.surface)
3428            .unwrap()
3429            .normal_at(0.0, 0.0, tol)
3430            .unwrap();
3431        assert!(normal.vector().dot(z) > 0.0);
3432    }
3433
3434    /// A face on a fitted surface builds with fitted trims: the closed-form
3435    /// refusal falls back to the projected fit, which is what lets a
3436    /// defeaturing rebuild stand a face on a spline neighbour.
3437    #[test]
3438    fn a_face_on_a_fitted_surface_gains_projected_pcurves() {
3439        let tol = ogeom_core::Tolerances::millimetres();
3440        let mut model = Model::new();
3441        // A gently wavy fitted patch, nothing analytic recognises.
3442        let rows: Vec<Vec<ogeom_math::Point>> = (0..5)
3443            .map(|j| {
3444                (0..5)
3445                    .map(|i| {
3446                        let (x, y) = (f64::from(i) * 2.5, f64::from(j) * 2.5);
3447                        ogeom_math::Point::new(x, y, 0.4 * (x * 0.7).sin() * (y * 0.5).cos())
3448                    })
3449                    .collect()
3450            })
3451            .collect();
3452        let surface: ogeom_geom::SurfaceGeometry =
3453            ogeom_geom::fit::fit_surface_grid(&rows, 3, 1e-6, tol)
3454                .unwrap()
3455                .curve
3456                .into();
3457        // The border iso-curves, straight off the fitted chart.
3458        use ogeom_geom::Surface as _;
3459        let ((ua, ub), (va, vb)) = surface.domain();
3460        let iso = |fixed_u: Option<f64>, fixed_v: Option<f64>| -> Curve {
3461            const N: usize = 33;
3462            let pts: Vec<ogeom_math::Point> = (0..N)
3463                .map(|k| {
3464                    let t = f64::from(u32::try_from(k).unwrap())
3465                        / f64::from(u32::try_from(N - 1).unwrap());
3466                    let (u, v) = match (fixed_u, fixed_v) {
3467                        (Some(u), None) => (u, va + (vb - va) * t),
3468                        (None, Some(v)) => (ua + (ub - ua) * t, v),
3469                        _ => unreachable!(),
3470                    };
3471                    surface.point_at(u, v, tol).unwrap()
3472                })
3473                .collect();
3474            Curve::BSpline(
3475                ogeom_geom::fit::fit_points(&pts, 3, 1e-9, tol)
3476                    .unwrap()
3477                    .curve,
3478            )
3479        };
3480        // Shared corner vertices, widened to absorb the border fits' own
3481        // slack, so the ring connects by node rather than by luck.
3482        let corners: Vec<Shape> = [(ua, va), (ub, va), (ub, vb), (ua, vb)]
3483            .into_iter()
3484            .map(|(u, v)| {
3485                let p = surface.point_at(u, v, tol).unwrap();
3486                let vertex = model.add_vertex(ogeom_topo::VertexData::new(p));
3487                if let Some(node) = model.node_mut(&vertex)
3488                    && let ogeom_topo::NodeData::Vertex(data) = node.data_mut()
3489                {
3490                    data.tolerance = data.tolerance.widen_to(1e-4);
3491                }
3492                vertex
3493            })
3494            .collect();
3495        let ring = [
3496            (iso(None, Some(va)), 0usize, 1usize),
3497            (iso(Some(ub), None), 1, 2),
3498            (iso(None, Some(vb)), 3, 2),
3499            (iso(Some(ua), None), 0, 3),
3500        ];
3501        let edges: Vec<Shape> = ring
3502            .into_iter()
3503            .enumerate()
3504            .map(|(k, (c, from, to))| {
3505                use ogeom_geom::Curve3d as _;
3506                let domain = c.domain();
3507                let edge =
3508                    make_edge_between(&mut model, c, domain, &corners[from], &corners[to], tol)
3509                        .unwrap()
3510                        .shape;
3511                if k >= 2 { edge.reversed() } else { edge }
3512            })
3513            .collect();
3514        let built =
3515            make_face_with_pcurves(&mut model, surface, std::slice::from_ref(&edges), tol).unwrap();
3516        // Every edge carries a pcurve for the face's surface.
3517        let data = model
3518            .node(&built.shape)
3519            .unwrap()
3520            .data()
3521            .as_face()
3522            .unwrap()
3523            .clone();
3524        for edge in &edges {
3525            let e = model.node(edge).unwrap().data().as_edge().unwrap();
3526            assert!(
3527                e.pcurve_for(data.surface, edge.location()).is_some(),
3528                "a fitted trim was attached"
3529            );
3530        }
3531    }
3532
3533    #[test]
3534    fn a_band_whose_rings_start_apart_gets_a_helical_connector() {
3535        // Two circles at different heights whose parameter origins differ:
3536        // the seam has no single column to run down, so it runs the straight
3537        // chart segment: a helix on the cylinder, exactly.
3538        let mut model = Model::new();
3539        let radius = 2.0;
3540        let cylinder = ogeom_geom::CylinderSurface::new(
3541            ogeom_math::Cylinder::new(Frame::WORLD, radius, T).unwrap(),
3542            (-1.0, 4.0),
3543        )
3544        .unwrap();
3545        let surface: SurfaceGeometry = cylinder.into();
3546
3547        let ring_at = |model: &mut Model, height: f64, phase: f64| {
3548            let x = ogeom_math::Direction::new(
3549                ogeom_math::Vector::new(phase.cos(), phase.sin(), 0.0),
3550                T,
3551            )
3552            .unwrap();
3553            let frame =
3554                Frame::new(Point::new(0.0, 0.0, height), ogeom_math::Direction::Z, x, T).unwrap();
3555            let curve: Curve = CircleCurve::new(Circle::new(frame, radius, T).unwrap()).into();
3556            let domain = curve.domain();
3557            let edge = make_edge(model, curve, domain, T).unwrap().shape;
3558            let pcurve: ogeom_geom::PlanarCurve = ogeom_geom::Line2d::over(
3559                ogeom_math::Axis2::new(
3560                    ogeom_math::Point2::new(phase, height),
3561                    ogeom_math::Direction2::new(ogeom_math::Vector2::new(1.0, 0.0), T).unwrap(),
3562                ),
3563                0.0,
3564                core::f64::consts::TAU,
3565            )
3566            .unwrap()
3567            .into();
3568            (edge, pcurve)
3569        };
3570        let (lo, lo_pcurve) = ring_at(&mut model, 0.0, 0.0);
3571        let (hi, hi_pcurve) = ring_at(&mut model, 2.0, 0.4);
3572
3573        let band = make_band_between(
3574            &mut model,
3575            &surface,
3576            [(&lo, lo_pcurve), (&hi, hi_pcurve)],
3577            T,
3578        )
3579        .unwrap();
3580
3581        assert_eq!(
3582            explore_unique(&model, &band, ShapeType::Edge)
3583                .unwrap()
3584                .len(),
3585            3
3586        );
3587        // The connector's curve is a genuine helix through both starts.
3588        let helix = explore_unique(&model, &band, ShapeType::Edge)
3589            .unwrap()
3590            .into_iter()
3591            .find_map(|e| {
3592                let data = model.node(&e)?.data().as_edge()?;
3593                let ogeom_topo::EdgeRepr::Curve3d { curve, .. } = data.curve3d()? else {
3594                    return None;
3595                };
3596                match model.geometry().curve(*curve)? {
3597                    Curve::Helix(h) => Some(*h),
3598                    _ => None,
3599                }
3600            })
3601            .expect("the connector is a helix");
3602        use ogeom_geom::Curve3d as _;
3603        let at_start = Curve::Helix(helix).point_at(0.0, T).unwrap();
3604        let at_end = Curve::Helix(helix).point_at(0.4, T).unwrap();
3605        assert!(at_start.distance(Point::new(2.0, 0.0, 0.0)) < 1e-9);
3606        assert!(at_end.distance(Point::new(2.0 * 0.4_f64.cos(), 2.0 * 0.4_f64.sin(), 2.0)) < 1e-9);
3607    }
3608
3609    /// A boundary with more detail than the projected fit's samples, on a
3610    /// patch no closed form speaks: each fitted trim, lifted, stands within
3611    /// what its edge states.
3612    #[test]
3613    fn a_fitted_trim_states_its_gap_along_the_whole_edge() {
3614        let mut model = Model::new();
3615        let patch = ogeom_geom::BSplineSurface::new(
3616            ogeom_math::KnotVector::clamped_uniform(1, 2).unwrap(),
3617            ogeom_math::KnotVector::clamped_uniform(1, 2).unwrap(),
3618            &ogeom_math::ControlGrid::new(
3619                vec![
3620                    Point::new(-1.0, -1.0, 0.0),
3621                    Point::new(-1.0, 11.0, 0.0),
3622                    Point::new(11.0, -1.0, 0.0),
3623                    Point::new(11.0, 11.0, 0.0),
3624                ],
3625                2,
3626                2,
3627            )
3628            .unwrap(),
3629            T,
3630        )
3631        .unwrap();
3632        const SPANS: usize = 300;
3633        let control: Vec<Point> = (0..SPANS + 3)
3634            .map(|i| {
3635                #[allow(clippy::cast_precision_loss)]
3636                let x = 10.0 * i as f64 / (SPANS + 2) as f64;
3637                let y = if i == 0 || i == SPANS + 2 {
3638                    0.0
3639                } else if i.is_multiple_of(2) {
3640                    0.03
3641                } else {
3642                    -0.03
3643                };
3644                Point::new(x, y, 0.0)
3645            })
3646            .collect();
3647        let wiggle: Curve = ogeom_geom::BSplineCurve::new(
3648            ogeom_math::KnotVector::clamped_uniform(3, control.len()).unwrap(),
3649            control,
3650            T,
3651        )
3652        .unwrap()
3653        .into();
3654        let corners = [
3655            Point::new(0.0, 0.0, 0.0),
3656            Point::new(10.0, 0.0, 0.0),
3657            Point::new(10.0, 10.0, 0.0),
3658            Point::new(0.0, 10.0, 0.0),
3659        ];
3660        let v: Vec<Shape> = corners
3661            .iter()
3662            .map(|p| make_vertex(&mut model, *p).shape)
3663            .collect();
3664        let mut edges = vec![
3665            make_edge_between(&mut model, wiggle, (0.0, 1.0), &v[0], &v[1], T)
3666                .unwrap()
3667                .shape,
3668        ];
3669        for i in 1..4 {
3670            let (a, b) = (corners[i], corners[(i + 1) % 4]);
3671            let line: Curve = LineCurve::segment(a, b, T).unwrap().into();
3672            edges.push(
3673                make_edge_between(
3674                    &mut model,
3675                    line,
3676                    (0.0, a.distance(b)),
3677                    &v[i],
3678                    &v[(i + 1) % 4],
3679                    T,
3680                )
3681                .unwrap()
3682                .shape,
3683            );
3684        }
3685        let face = make_face_with_pcurves(&mut model, SurfaceGeometry::BSpline(patch), &[edges], T)
3686            .unwrap()
3687            .shape;
3688        for edge in explore_unique(&model, &face, ShapeType::Edge).unwrap() {
3689            let stated = model
3690                .node(&edge)
3691                .unwrap()
3692                .data()
3693                .as_edge()
3694                .unwrap()
3695                .tolerance
3696                .get();
3697            let gap = crate::edge_pcurve_gap(&model, &edge, T).unwrap().unwrap();
3698            assert!(gap <= stated, "a trim {gap} off an edge stating {stated}");
3699        }
3700        let diagnosis = crate::check(&model, &face, T).unwrap();
3701        assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
3702    }
3703
3704    /// On a sphere the chart segment between two starts lifts to a curve
3705    /// with no closed form, and the connector is fitted through it: its
3706    /// charts stand within what the edge states, everywhere along it.
3707    #[test]
3708    fn a_fitted_connector_states_its_charts_gap() {
3709        let mut model = Model::new();
3710        let radius = 5.0;
3711        let surface: SurfaceGeometry = ogeom_geom::SphereSurface::new(
3712            ogeom_math::Sphere::new(Frame::WORLD, radius, T).unwrap(),
3713        )
3714        .into();
3715        let ring_at = |model: &mut Model, lat: f64, phase: f64| {
3716            let x = ogeom_math::Direction::new(
3717                ogeom_math::Vector::new(phase.cos(), phase.sin(), 0.0),
3718                T,
3719            )
3720            .unwrap();
3721            let frame = Frame::new(
3722                Point::new(0.0, 0.0, radius * lat.sin()),
3723                ogeom_math::Direction::Z,
3724                x,
3725                T,
3726            )
3727            .unwrap();
3728            let curve: Curve =
3729                CircleCurve::new(Circle::new(frame, radius * lat.cos(), T).unwrap()).into();
3730            let domain = curve.domain();
3731            let edge = make_edge(model, curve, domain, T).unwrap().shape;
3732            let pcurve: ogeom_geom::PlanarCurve = ogeom_geom::Line2d::over(
3733                ogeom_math::Axis2::new(
3734                    ogeom_math::Point2::new(phase, lat),
3735                    ogeom_math::Direction2::new(ogeom_math::Vector2::new(1.0, 0.0), T).unwrap(),
3736                ),
3737                0.0,
3738                core::f64::consts::TAU,
3739            )
3740            .unwrap()
3741            .into();
3742            (edge, pcurve)
3743        };
3744        let (lo, lo_pcurve) = ring_at(&mut model, -1.2, 0.0);
3745        let (hi, hi_pcurve) = ring_at(&mut model, 1.2, 3.0);
3746        let band = make_band_between(
3747            &mut model,
3748            &surface,
3749            [(&lo, lo_pcurve), (&hi, hi_pcurve)],
3750            T,
3751        )
3752        .unwrap();
3753        for edge in explore_unique(&model, &band, ShapeType::Edge).unwrap() {
3754            let stated = model
3755                .node(&edge)
3756                .unwrap()
3757                .data()
3758                .as_edge()
3759                .unwrap()
3760                .tolerance
3761                .get();
3762            let gap = crate::edge_pcurve_gap(&model, &edge, T).unwrap().unwrap();
3763            assert!(gap <= stated, "a chart {gap} off an edge stating {stated}");
3764        }
3765        let diagnosis = crate::check(&model, &band, T).unwrap();
3766        assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
3767    }
3768
3769    #[test]
3770    fn a_band_between_a_circle_and_a_fitted_wavy_ring_closes_with_its_seam() {
3771        use ogeom_geom::Curve2d as _;
3772        use ogeom_geom::Curve3d as _;
3773        use ogeom_geom::Surface as _;
3774
3775        let mut model = Model::new();
3776        let radius = 2.0;
3777        let cylinder = ogeom_geom::CylinderSurface::new(
3778            ogeom_math::Cylinder::new(Frame::WORLD, radius, T).unwrap(),
3779            (-1.0, 4.0),
3780        )
3781        .unwrap();
3782        let surface: SurfaceGeometry = cylinder.into();
3783
3784        // The lower ring: the exact circle at z = 0, its chart image the row.
3785        let lo_curve: Curve =
3786            CircleCurve::new(Circle::new(Frame::WORLD, radius, T).unwrap()).into();
3787        let lo_domain = lo_curve.domain();
3788        let lo = make_edge(&mut model, lo_curve, lo_domain, T).unwrap().shape;
3789        let lo_pcurve: ogeom_geom::PlanarCurve = ogeom_geom::Line2d::over(
3790            ogeom_math::Axis2::new(
3791                ogeom_math::Point2::new(0.0, 0.0),
3792                ogeom_math::Direction2::new(ogeom_math::Vector2::new(1.0, 0.0), T).unwrap(),
3793            ),
3794            lo_domain.0,
3795            lo_domain.1,
3796        )
3797        .unwrap()
3798        .into();
3799
3800        // The upper ring: a wavy loop on the cylinder, fitted jointly with
3801        // its own chart image so curve and pcurve stay same-parameter.
3802        let tau = core::f64::consts::TAU;
3803        let samples = 96;
3804        let mut points = Vec::with_capacity(samples + 1);
3805        let mut chart = Vec::with_capacity(samples + 1);
3806        for i in 0..=samples {
3807            #[allow(clippy::cast_precision_loss)]
3808            let u = tau * (i as f64) / (samples as f64);
3809            let v = (3.0 * u).sin().mul_add(0.2, 1.5);
3810            points.push(Point::new(radius * u.cos(), radius * u.sin(), v));
3811            chart.push(ogeom_math::Point2::new(u, v));
3812        }
3813        let (fitted, hi_chart, _) =
3814            ogeom_geom::fit::fit_points_joint_closed(&points, &chart, &chart, 3, 1e-4, T).unwrap();
3815        assert!(fitted.met, "the wavy ring fit should meet its tolerance");
3816        let hi_curve: Curve = fitted.curve.into();
3817        let hi_domain = hi_curve.domain();
3818        let hi = make_edge(&mut model, hi_curve.clone(), hi_domain, T)
3819            .unwrap()
3820            .shape;
3821
3822        let band = make_band_between(
3823            &mut model,
3824            &surface,
3825            [(&lo, lo_pcurve), (&hi, hi_chart.into())],
3826            T,
3827        )
3828        .unwrap();
3829
3830        // Four traversals, three distinct edges: two rings and the seam,
3831        // used once per side.
3832        let occurrences =
3833            ogeom_topo::explore(&model, &band, ogeom_topo::Filter::OfType(ShapeType::Edge))
3834                .unwrap();
3835        assert_eq!(occurrences.len(), 4);
3836        assert_eq!(
3837            explore_unique(&model, &band, ShapeType::Edge)
3838                .unwrap()
3839                .len(),
3840            3
3841        );
3842
3843        // The wavy ring's chart image lands on the curve itself: the fit is
3844        // same-parameter, and the surface evaluates the image back onto it.
3845        let hi_pcurve = {
3846            let data = model.node(&hi).unwrap().data().as_edge().unwrap();
3847            let repr = data
3848                .representations
3849                .iter()
3850                .find_map(|r| match r {
3851                    ogeom_topo::EdgeRepr::PCurve { curve, .. } => Some(*curve),
3852                    _ => None,
3853                })
3854                .expect("the ring carries its pcurve");
3855            model.geometry().pcurve(repr).unwrap().clone()
3856        };
3857        for i in 0..8 {
3858            #[allow(clippy::cast_precision_loss)]
3859            let t = hi_domain.0 + (hi_domain.1 - hi_domain.0) * (i as f64) / 8.0;
3860            let uv = hi_pcurve.point_at(t, T).unwrap();
3861            let through_chart = surface.point_at(uv.x, uv.y, T).unwrap();
3862            let direct = hi_curve.point_at(t, T).unwrap();
3863            assert!(
3864                through_chart.distance(direct) < 1e-4,
3865                "same-parameter drift at t = {t}"
3866            );
3867        }
3868    }
3869
3870    use ogeom_geom::{CircleCurve, LineCurve, PlaneSurface};
3871    use ogeom_math::{Circle, Frame, Plane};
3872    use ogeom_topo::explore_unique;
3873
3874    const T: Tolerances = Tolerances::millimetres();
3875
3876    fn segment(a: Point, b: Point) -> Curve {
3877        LineCurve::segment(a, b, T).unwrap().into()
3878    }
3879
3880    /// Four edges forming a closed square in the xy plane.
3881    fn square_edges(model: &mut Model) -> Vec<Shape> {
3882        let corners = [
3883            Point::new(0.0, 0.0, 0.0),
3884            Point::new(1.0, 0.0, 0.0),
3885            Point::new(1.0, 1.0, 0.0),
3886            Point::new(0.0, 1.0, 0.0),
3887        ];
3888        let mut vertices: Vec<Shape> = corners
3889            .iter()
3890            .map(|p| model.add_vertex(VertexData::new(*p)))
3891            .collect();
3892        vertices.push(vertices[0].clone());
3893
3894        (0..4)
3895            .map(|i| {
3896                let curve = segment(corners[i], corners[(i + 1) % 4]);
3897                let length = corners[i].distance(corners[(i + 1) % 4]);
3898                make_edge_between(
3899                    model,
3900                    curve,
3901                    (0.0, length),
3902                    &vertices[i],
3903                    &vertices[i + 1],
3904                    T,
3905                )
3906                .unwrap()
3907                .shape
3908            })
3909            .collect()
3910    }
3911
3912    /// A face on a patch whose chart spans a fortieth of a unit, its
3913    /// pcurves meeting at one corner 6e-8 apart in the chart and less than
3914    /// the confusion distance apart in space: the miss is fitting slop, as
3915    /// a miss of a millionth of the chart's span is. It is too short in the
3916    /// chart to be an edge of its own, so no pole is laid there, and the
3917    /// wire comes back as it was.
3918    #[test]
3919    fn a_small_chart_miss_whose_ends_meet_in_space_lays_no_pole() {
3920        use ogeom_geom::{BSplineSurface, Line2d, SurfaceGeometry};
3921        use ogeom_math::{ControlGrid, KnotVector, Point2};
3922        let side = 0.025;
3923        let mut model = Model::new();
3924        // The patch p(u, v) = (u, v, 0) over [0, side] each way.
3925        let knots = || KnotVector::new(vec![0.0, 0.0, side, side], 1).unwrap();
3926        let grid = ControlGrid::new(
3927            vec![
3928                Point::new(0.0, 0.0, 0.0),
3929                Point::new(0.0, side, 0.0),
3930                Point::new(side, 0.0, 0.0),
3931                Point::new(side, side, 0.0),
3932            ],
3933            2,
3934            2,
3935        )
3936        .unwrap();
3937        let surface: SurfaceGeometry = BSplineSurface::new(knots(), knots(), &grid, T)
3938            .unwrap()
3939            .into();
3940        let surface_id = model.geometry_mut().add_surface(surface);
3941        let corners = [(0.0, 0.0), (side, 0.0), (side, side), (0.0, side)];
3942        let vertices: Vec<Shape> = corners
3943            .iter()
3944            .map(|&(x, y)| model.add_vertex(VertexData::new(Point::new(x, y, 0.0))))
3945            .collect();
3946        let miss = 6e-8;
3947        let edges: Vec<Shape> = (0..4)
3948            .map(|i| {
3949                let (a, b) = (corners[i], corners[(i + 1) % 4]);
3950                let (pa, pb) = (Point::new(a.0, a.1, 0.0), Point::new(b.0, b.1, 0.0));
3951                let edge = make_edge_between(
3952                    &mut model,
3953                    segment(pa, pb),
3954                    (0.0, side),
3955                    &vertices[i],
3956                    &vertices[(i + 1) % 4],
3957                    T,
3958                )
3959                .unwrap()
3960                .shape;
3961                // The second edge's pcurve starts a hair up the chart from
3962                // where the first one's ends.
3963                let start = if i == 1 {
3964                    Point2::new(a.0, a.1 + miss)
3965                } else {
3966                    Point2::new(a.0, a.1)
3967                };
3968                let end = Point2::new(b.0, b.1);
3969                attach_pcurve(
3970                    &mut model,
3971                    &edge,
3972                    Line2d::segment(start, end, T).unwrap().into(),
3973                    surface_id,
3974                    Location::identity(),
3975                    (0.0, start.distance(end)),
3976                )
3977                .unwrap();
3978                edge
3979            })
3980            .collect();
3981        let closed = closed_at_poles(&mut model, surface_id, &edges, T).unwrap();
3982        assert_eq!(closed.len(), 4, "no pole edge is laid in the miss");
3983        for (got, given) in closed.iter().zip(&edges) {
3984            assert!(got.is_same(given));
3985        }
3986    }
3987
3988    #[test]
3989    fn an_edge_takes_its_vertices_from_its_own_geometry() {
3990        // Deriving them means the topology and the geometry cannot disagree
3991        // about where the edge starts and stops.
3992        let mut model = Model::new();
3993        let built = make_edge(
3994            &mut model,
3995            segment(Point::ORIGIN, Point::new(3.0, 4.0, 0.0)),
3996            (0.0, 5.0),
3997            T,
3998        )
3999        .unwrap();
4000
4001        let (start, end) = edge_vertices(&model, &built.shape).unwrap().unwrap();
4002        let point_of = |v: &Shape| model.node(v).unwrap().data().as_vertex().unwrap().point;
4003        assert!(point_of(&start).is_equal(Point::ORIGIN, T));
4004        assert!(point_of(&end).is_equal(Point::new(3.0, 4.0, 0.0), T));
4005        assert_eq!(built.history.generated(&built.shape).len(), 2);
4006    }
4007
4008    #[test]
4009    fn a_closed_edge_names_one_vertex_twice() {
4010        // Two coincident vertices would leave the wire looking open at the join,
4011        // and the gap only surfaces when something tries to walk the boundary.
4012        let mut model = Model::new();
4013        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
4014        let built = make_edge(&mut model, circle, (0.0, core::f64::consts::TAU), T).unwrap();
4015
4016        let (start, end) = edge_vertices(&model, &built.shape).unwrap().unwrap();
4017        assert!(start.is_same(&end), "a closed edge starts where it ends");
4018        assert_eq!(
4019            explore_unique(&model, &built.shape, ShapeType::Vertex)
4020                .unwrap()
4021                .len(),
4022            1
4023        );
4024    }
4025
4026    #[test]
4027    fn an_edge_whose_curve_does_not_reach_its_vertices_is_refused() {
4028        // The gap would show up much later, in whatever first walks the
4029        // boundary, with nothing left to say where it came from.
4030        let mut model = Model::new();
4031        let a = model.add_vertex(VertexData::new(Point::ORIGIN));
4032        let b = model.add_vertex(VertexData::new(Point::new(10.0, 0.0, 0.0)));
4033        let curve = segment(Point::ORIGIN, Point::new(5.0, 0.0, 0.0));
4034
4035        assert!(
4036            make_edge_between(&mut model, curve.clone(), (0.0, 5.0), &a, &b, T).is_err(),
4037            "the curve stops half way"
4038        );
4039        let c = model.add_vertex(VertexData::new(Point::new(5.0, 0.0, 0.0)));
4040        assert!(make_edge_between(&mut model, curve, (0.0, 5.0), &a, &c, T).is_ok());
4041    }
4042
4043    #[test]
4044    fn a_vertex_with_a_loose_tolerance_admits_a_curve_that_stops_short() {
4045        // The reach is the vertex's own tolerance, not a fixed epsilon: a
4046        // vertex that has been widened by an earlier repair genuinely does
4047        // occupy that much space.
4048        let mut model = Model::new();
4049        let a = model.add_vertex(VertexData::new(Point::ORIGIN));
4050        let b = model
4051            .add_vertex(VertexData::with_tolerance(Point::new(5.001, 0.0, 0.0), 1e-2).unwrap());
4052        let curve = segment(Point::ORIGIN, Point::new(5.0, 0.0, 0.0));
4053        assert!(make_edge_between(&mut model, curve, (0.0, 5.0), &a, &b, T).is_ok());
4054    }
4055
4056    #[test]
4057    fn edge_vertices_follow_the_direction_of_travel() {
4058        let mut model = Model::new();
4059        let built = make_edge(
4060            &mut model,
4061            segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0)),
4062            (0.0, 1.0),
4063            T,
4064        )
4065        .unwrap();
4066
4067        let (forward_start, forward_end) = edge_vertices(&model, &built.shape).unwrap().unwrap();
4068        let (back_start, back_end) = edge_vertices(&model, &built.shape.reversed())
4069            .unwrap()
4070            .unwrap();
4071
4072        assert!(back_start.is_same(&forward_end));
4073        assert!(back_end.is_same(&forward_start));
4074    }
4075
4076    #[test]
4077    fn a_wire_whose_edges_do_not_meet_is_refused() {
4078        // Model::add_wire cannot catch this: it has no geometry. This is the
4079        // check that keeps a face from being built on a boundary with a gap.
4080        let mut model = Model::new();
4081        let joined = make_edge(
4082            &mut model,
4083            segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0)),
4084            (0.0, 1.0),
4085            T,
4086        )
4087        .unwrap()
4088        .shape;
4089        let apart = make_edge(
4090            &mut model,
4091            segment(Point::new(5.0, 0.0, 0.0), Point::new(6.0, 0.0, 0.0)),
4092            (0.0, 1.0),
4093            T,
4094        )
4095        .unwrap()
4096        .shape;
4097
4098        let err = make_wire(&mut model, &[joined.clone(), apart], T).unwrap_err();
4099        assert!(err.to_string().contains("gap"), "unexpected message: {err}");
4100        assert!(make_wire(&mut model, &[joined], T).is_ok());
4101    }
4102
4103    #[test]
4104    fn a_wire_of_edges_that_meet_is_accepted_and_reported_closed() {
4105        let mut model = Model::new();
4106        let edges = square_edges(&mut model);
4107        let wire = make_wire(&mut model, &edges, T).unwrap();
4108        assert!(is_wire_closed(&model, &wire.shape, T).unwrap());
4109
4110        // The edges are generated into the wire, not consumed by it: an edge
4111        // between two faces belongs to both wires.
4112        for edge in &edges {
4113            assert!(!wire.history.is_deleted(edge));
4114            assert_eq!(wire.history.generated(edge).len(), 1);
4115        }
4116    }
4117
4118    #[test]
4119    fn an_open_wire_is_reported_open() {
4120        let mut model = Model::new();
4121        let edges = square_edges(&mut model);
4122        let open = make_wire(&mut model, &edges[..3], T).unwrap();
4123        assert!(!is_wire_closed(&model, &open.shape, T).unwrap());
4124    }
4125
4126    #[test]
4127    fn a_face_refuses_an_open_boundary() {
4128        let mut model = Model::new();
4129        let edges = square_edges(&mut model);
4130        let open = make_wire(&mut model, &edges[..3], T).unwrap().shape;
4131        let plane: SurfaceGeometry = PlaneSurface::new(Plane::new(Frame::WORLD)).into();
4132
4133        let err = make_face(&mut model, plane.clone(), &[open], T).unwrap_err();
4134        assert!(
4135            err.to_string().contains("open"),
4136            "unexpected message: {err}"
4137        );
4138
4139        let closed = make_wire(&mut model, &edges, T).unwrap().shape;
4140        assert!(make_face(&mut model, plane, &[closed], T).is_ok());
4141    }
4142
4143    #[test]
4144    fn a_natural_face_needs_no_wires_at_all() {
4145        let mut model = Model::new();
4146        let built = make_natural_face(
4147            &mut model,
4148            PlaneSurface::new(Plane::new(Frame::WORLD)).into(),
4149        )
4150        .unwrap();
4151        let node = model.node(&built.shape).unwrap();
4152        assert!(node.data().as_face().unwrap().natural_restriction);
4153        assert!(built.history.is_empty());
4154    }
4155
4156    #[test]
4157    fn a_shell_of_one_face_is_not_closed() {
4158        // A single face has free edges all round, so it bounds nothing.
4159        let mut model = Model::new();
4160        let edges = square_edges(&mut model);
4161        let wire = make_wire(&mut model, &edges, T).unwrap().shape;
4162        let face = make_face(
4163            &mut model,
4164            PlaneSurface::new(Plane::new(Frame::WORLD)).into(),
4165            &[wire],
4166            T,
4167        )
4168        .unwrap()
4169        .shape;
4170        let shell = make_shell(&mut model, &[face]).unwrap();
4171        assert!(!is_shell_closed(&model, &shell.shape).unwrap());
4172    }
4173
4174    #[test]
4175    fn two_faces_sharing_every_edge_form_a_closed_shell() {
4176        // The degenerate closed shell: the same square from both sides. Every
4177        // edge is used exactly twice, which is what closure means.
4178        let mut model = Model::new();
4179        let edges = square_edges(&mut model);
4180        let plane: SurfaceGeometry = PlaneSurface::new(Plane::new(Frame::WORLD)).into();
4181
4182        let front_wire = make_wire(&mut model, &edges, T).unwrap().shape;
4183        let reversed: Vec<Shape> = edges.iter().rev().map(Shape::reversed).collect();
4184        let back_wire = make_wire(&mut model, &reversed, T).unwrap().shape;
4185
4186        let front = make_face(&mut model, plane.clone(), &[front_wire], T)
4187            .unwrap()
4188            .shape;
4189        let back = make_face(&mut model, plane, &[back_wire], T).unwrap().shape;
4190        let shell = make_shell(&mut model, &[front, back]).unwrap().shape;
4191
4192        assert!(is_shell_closed(&model, &shell).unwrap());
4193        assert_eq!(
4194            explore_unique(&model, &shell, ShapeType::Edge)
4195                .unwrap()
4196                .len(),
4197            4
4198        );
4199        assert!(make_solid(&mut model, &[shell]).is_ok());
4200    }
4201
4202    #[test]
4203    fn a_pcurve_can_be_attached_after_the_edge_exists() {
4204        // An edge learns about a face's parameter space when it joins that
4205        // face, not when it is created; it may join several.
4206        let mut model = Model::new();
4207        let edge = make_edge(
4208            &mut model,
4209            segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0)),
4210            (0.0, 1.0),
4211            T,
4212        )
4213        .unwrap()
4214        .shape;
4215
4216        let surface = model
4217            .geometry_mut()
4218            .add_surface(PlaneSurface::new(Plane::new(Frame::WORLD)).into());
4219        let pcurve = ogeom_geom::Line2d::segment(
4220            ogeom_math::Point2::ORIGIN,
4221            ogeom_math::Point2::new(1.0, 0.0),
4222            T,
4223        )
4224        .unwrap()
4225        .into();
4226        attach_pcurve(
4227            &mut model,
4228            &edge,
4229            pcurve,
4230            surface,
4231            Location::identity(),
4232            (0.0, 1.0),
4233        )
4234        .unwrap();
4235
4236        let data = model.node(&edge).unwrap().data().as_edge().unwrap();
4237        assert_eq!(data.representations.len(), 2, "the curve and now a pcurve");
4238        assert!(data.pcurve_on(surface).is_some());
4239        assert!(
4240            !data.same_parameter(),
4241            "the new representation has not been shown to agree with the curve"
4242        );
4243    }
4244
4245    #[test]
4246    fn degenerate_edge_ranges_are_refused() {
4247        let mut model = Model::new();
4248        let curve = segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0));
4249        assert!(make_edge(&mut model, curve.clone(), (1.0, 0.0), T).is_err());
4250        assert!(make_edge(&mut model, curve.clone(), (0.5, 0.5), T).is_err());
4251        assert!(make_edge(&mut model, curve.clone(), (0.0, f64::NAN), T).is_err());
4252        assert!(make_edge(&mut model, curve, (0.0, 1.0), T).is_ok());
4253    }
4254
4255    #[test]
4256    fn builders_reject_children_of_the_wrong_type() {
4257        let mut model = Model::new();
4258        let vertex = model.add_vertex(VertexData::new(Point::ORIGIN));
4259        assert!(make_wire(&mut model, std::slice::from_ref(&vertex), T).is_err());
4260        assert!(make_shell(&mut model, std::slice::from_ref(&vertex)).is_err());
4261        assert!(make_solid(&mut model, &[vertex]).is_err());
4262        assert!(make_wire(&mut model, &[], T).is_err());
4263    }
4264
4265    #[test]
4266    fn an_edges_vertices_record_where_they_came_from() {
4267        // Provenance is what a rebuild matches against; a vertex that does not
4268        // say which edge produced it cannot be found again.
4269        let mut model = Model::new();
4270        model.begin_operation();
4271        let built = make_edge(
4272            &mut model,
4273            segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0)),
4274            (0.0, 1.0),
4275            T,
4276        )
4277        .unwrap();
4278
4279        let (start, end) = edge_vertices(&model, &built.shape).unwrap().unwrap();
4280        for (vertex, role) in [(start, roles::EDGE_START), (end, roles::EDGE_END)] {
4281            let provenance = model.provenance_of(&vertex).unwrap();
4282            assert!(
4283                matches!(provenance, ogeom_core::Provenance::Derived { role: r, .. } if *r == role),
4284                "expected a derived vertex with role {role:?}, got {provenance:?}"
4285            );
4286        }
4287    }
4288}
4289
4290#[cfg(test)]
4291#[allow(clippy::unwrap_used, clippy::expect_used)]
4292mod polygon_tests {
4293    use super::*;
4294    use ogeom_geom::{CircleCurve, PlaneSurface};
4295    use ogeom_math::{Circle, Frame, Plane};
4296    use ogeom_topo::explore_unique;
4297
4298    const T: Tolerances = Tolerances::millimetres();
4299
4300    fn square() -> Vec<Point> {
4301        vec![
4302            Point::new(0.0, 0.0, 0.0),
4303            Point::new(2.0, 0.0, 0.0),
4304            Point::new(2.0, 2.0, 0.0),
4305            Point::new(0.0, 2.0, 0.0),
4306        ]
4307    }
4308
4309    #[test]
4310    fn a_closed_polygon_names_its_first_vertex_again_rather_than_a_second_one() {
4311        // Two coincident vertices would leave the wire looking open at the
4312        // join, and the gap only surfaces when something walks the boundary.
4313        let mut model = Model::new();
4314        let built = make_polygon(&mut model, &square(), true, T).unwrap();
4315
4316        assert_eq!(
4317            explore_unique(&model, &built.shape, ShapeType::Edge)
4318                .unwrap()
4319                .len(),
4320            4
4321        );
4322        assert_eq!(
4323            explore_unique(&model, &built.shape, ShapeType::Vertex)
4324                .unwrap()
4325                .len(),
4326            4,
4327            "four corners, not five"
4328        );
4329        assert!(is_wire_closed(&model, &built.shape, T).unwrap());
4330    }
4331
4332    #[test]
4333    fn an_open_polygon_is_one_edge_short_and_says_it_is_open() {
4334        let mut model = Model::new();
4335        let built = make_polygon(&mut model, &square(), false, T).unwrap();
4336        assert_eq!(
4337            explore_unique(&model, &built.shape, ShapeType::Edge)
4338                .unwrap()
4339                .len(),
4340            3
4341        );
4342        assert!(!is_wire_closed(&model, &built.shape, T).unwrap());
4343    }
4344
4345    #[test]
4346    fn a_polygon_that_describes_nothing_is_refused() {
4347        let mut model = Model::new();
4348        assert!(make_polygon(&mut model, &[], false, T).is_err());
4349        assert!(make_polygon(&mut model, &square()[..1], false, T).is_err());
4350        assert!(
4351            make_polygon(&mut model, &square()[..2], true, T).is_err(),
4352            "two points do not enclose anything"
4353        );
4354
4355        // A repeated point is a zero-length edge, which is not an edge.
4356        let mut doubled = square();
4357        doubled.insert(2, doubled[1]);
4358        assert!(make_polygon(&mut model, &doubled, true, T).is_err());
4359
4360        // Repeating the first point at the end is refused either way. Closed,
4361        // it would add a zero-length segment; open, it would leave the wire
4362        // with two vertices in one place, which every later boundary walk
4363        // reads as a gap that happens to be zero wide.
4364        let mut wrapped = square();
4365        wrapped.push(wrapped[0]);
4366        for closed in [true, false] {
4367            let err = make_polygon(&mut model, &wrapped, closed, T).unwrap_err();
4368            assert!(
4369                err.to_string().contains("first and last points coincide"),
4370                "unexpected message: {err}"
4371            );
4372        }
4373    }
4374
4375    #[test]
4376    fn a_planar_shape_reports_the_plane_it_lies_in() {
4377        let mut model = Model::new();
4378        let wire = make_polygon(&mut model, &square(), true, T).unwrap().shape;
4379        let plane = find_plane(&model, &wire, T)
4380            .unwrap()
4381            .expect("a flat square");
4382        assert!(
4383            plane.normal().is_parallel(ogeom_math::Direction::Z, T),
4384            "got {:?}",
4385            plane.normal()
4386        );
4387        for p in square() {
4388            assert!(plane.distance_to(p) < 1e-9);
4389        }
4390    }
4391
4392    #[test]
4393    fn a_shape_that_is_not_planar_says_so_rather_than_fitting_one_anyway() {
4394        // Every set of three or more points has a best-fit plane, including a
4395        // set nowhere near one. Returning it would be a confident wrong answer.
4396        let mut model = Model::new();
4397        let mut skew = square();
4398        skew[2] = Point::new(2.0, 2.0, 1.0);
4399        let wire = make_polygon(&mut model, &skew, true, T).unwrap().shape;
4400        assert!(find_plane(&model, &wire, T).unwrap().is_none());
4401    }
4402
4403    #[test]
4404    fn a_curved_edge_is_sampled_along_its_length_not_only_at_its_ends() {
4405        // An arc's endpoints lie in a great many planes the arc itself does
4406        // not. Checking only the vertices would call this shape planar.
4407        let mut model = Model::new();
4408        let circle = Circle::new(
4409            Frame::new(
4410                Point::ORIGIN,
4411                ogeom_math::Direction::Z,
4412                ogeom_math::Direction::X,
4413                T,
4414            )
4415            .unwrap(),
4416            2.0,
4417            T,
4418        )
4419        .unwrap();
4420        let arc = make_edge(
4421            &mut model,
4422            CircleCurve::new(circle).into(),
4423            (0.0, std::f64::consts::PI),
4424            T,
4425        )
4426        .unwrap()
4427        .shape;
4428        // In its own plane, it is planar.
4429        assert!(find_plane(&model, &arc, T).unwrap().is_some());
4430
4431        // The two endpoints alone would admit the plane through them and the
4432        // z axis; the arc does not lie in it, and sampling catches that.
4433        let plane = find_plane(&model, &arc, T).unwrap().unwrap();
4434        assert!(plane.normal().is_parallel(ogeom_math::Direction::Z, T));
4435    }
4436
4437    #[test]
4438    fn a_face_is_planar_when_its_surface_is() {
4439        let mut model = Model::new();
4440        let wire = make_polygon(&mut model, &square(), true, T).unwrap().shape;
4441        let face = make_face(
4442            &mut model,
4443            PlaneSurface::new(Plane::new(Frame::WORLD)).into(),
4444            std::slice::from_ref(&wire),
4445            T,
4446        )
4447        .unwrap()
4448        .shape;
4449        assert!(find_plane(&model, &face, T).unwrap().is_some());
4450
4451        let solid = crate::make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
4452            .unwrap()
4453            .shape;
4454        assert!(
4455            find_plane(&model, &solid, T).unwrap().is_none(),
4456            "a box is not planar"
4457        );
4458    }
4459}
4460#[cfg(test)]
4461#[allow(clippy::unwrap_used, clippy::expect_used)]
4462mod compsolid_tests {
4463    use super::*;
4464    use ogeom_geom::PlaneSurface;
4465    use ogeom_math::{Frame, Plane, Point};
4466
4467    const T: Tolerances = Tolerances::millimetres();
4468
4469    /// Two unit cubes glued along one shared face node at `x = 1`.
4470    fn glued_cubes(model: &mut Model) -> (Shape, Shape) {
4471        let a = crate::make_box(model, Frame::WORLD, (1.0, 1.0, 1.0), T)
4472            .unwrap()
4473            .shape;
4474        // The face of `a` at x = 1: the one whose every vertex has x = 1.
4475        let shared = ogeom_topo::explore(model, &a, ogeom_topo::Filter::OfType(ShapeType::Face))
4476            .unwrap()
4477            .into_iter()
4478            .find(|f| {
4479                ogeom_topo::explore(model, f, ogeom_topo::Filter::OfType(ShapeType::Vertex))
4480                    .unwrap()
4481                    .iter()
4482                    .all(|v| {
4483                        let p = model.node(v).unwrap().data().as_vertex().unwrap().point;
4484                        let world = v.transform(model.datums()).unwrap().apply(p);
4485                        (world.x - 1.0).abs() < 1e-9
4486                    })
4487            })
4488            .expect("the box has a face at x = 1");
4489
4490        // The second cube: the shared face seen from the other side, plus
4491        // five new faces over the shared boundary vertices.
4492        let corner = |x: f64, y: f64, z: f64| Point::new(x, y, z);
4493        let quad = |model: &mut Model, points: [Point; 4]| -> Shape {
4494            let wire = crate::make_polygon(model, &points, true, T).unwrap().shape;
4495            let plane = crate::find_plane(model, &wire, T).unwrap().unwrap();
4496            make_face(
4497                model,
4498                PlaneSurface::over(Plane::new(plane.frame()), (-4.0, 4.0), (-4.0, 4.0))
4499                    .unwrap()
4500                    .into(),
4501                std::slice::from_ref(&wire),
4502                T,
4503            )
4504            .unwrap()
4505            .shape
4506        };
4507        let faces = [
4508            quad(
4509                model,
4510                [
4511                    corner(2.0, 0.0, 0.0),
4512                    corner(2.0, 1.0, 0.0),
4513                    corner(2.0, 1.0, 1.0),
4514                    corner(2.0, 0.0, 1.0),
4515                ],
4516            ),
4517            quad(
4518                model,
4519                [
4520                    corner(1.0, 0.0, 0.0),
4521                    corner(2.0, 0.0, 0.0),
4522                    corner(2.0, 0.0, 1.0),
4523                    corner(1.0, 0.0, 1.0),
4524                ],
4525            ),
4526            quad(
4527                model,
4528                [
4529                    corner(1.0, 1.0, 0.0),
4530                    corner(1.0, 1.0, 1.0),
4531                    corner(2.0, 1.0, 1.0),
4532                    corner(2.0, 1.0, 0.0),
4533                ],
4534            ),
4535            quad(
4536                model,
4537                [
4538                    corner(1.0, 0.0, 0.0),
4539                    corner(1.0, 1.0, 0.0),
4540                    corner(2.0, 1.0, 0.0),
4541                    corner(2.0, 0.0, 0.0),
4542                ],
4543            ),
4544            quad(
4545                model,
4546                [
4547                    corner(1.0, 0.0, 1.0),
4548                    corner(2.0, 0.0, 1.0),
4549                    corner(2.0, 1.0, 1.0),
4550                    corner(1.0, 1.0, 1.0),
4551                ],
4552            ),
4553        ];
4554        let mut shell_faces = vec![shared.reversed()];
4555        shell_faces.extend(faces);
4556        let shell = make_shell(model, &shell_faces).unwrap().shape;
4557        let b = make_solid(model, std::slice::from_ref(&shell))
4558            .unwrap()
4559            .shape;
4560        (a, b)
4561    }
4562
4563    #[test]
4564    fn glued_solids_build_a_compsolid_and_loose_ones_are_refused() {
4565        let mut model = Model::new();
4566        let (a, b) = glued_cubes(&mut model);
4567        let built = make_compsolid(&mut model, &[a.clone(), b.clone()]).unwrap();
4568        assert_eq!(model.kind_of(&built.shape).unwrap(), ShapeType::CompSolid);
4569        assert_eq!(
4570            built.history.generated(&a),
4571            std::slice::from_ref(&built.shape)
4572        );
4573
4574        // Two boxes merely sitting apart share nothing and are refused.
4575        let mut model = Model::new();
4576        let a = crate::make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
4577            .unwrap()
4578            .shape;
4579        let far = Frame::new(
4580            Point::new(5.0, 0.0, 0.0),
4581            ogeom_math::Direction::Z,
4582            ogeom_math::Direction::X,
4583            T,
4584        )
4585        .unwrap();
4586        let b = crate::make_box(&mut model, far, (1.0, 1.0, 1.0), T)
4587            .unwrap()
4588            .shape;
4589        assert!(make_compsolid(&mut model, &[a.clone(), b]).is_err());
4590        assert!(make_compsolid(&mut model, std::slice::from_ref(&a)).is_err());
4591    }
4592}
4593
4594#[cfg(test)]
4595#[allow(clippy::unwrap_used, clippy::expect_used)]
4596mod band_tests {
4597    use super::*;
4598    use approx::assert_relative_eq;
4599    use ogeom_core::Tolerances;
4600    use ogeom_geom::CircleCurve;
4601    use ogeom_math::{Circle, Cone, Frame, Sphere};
4602
4603    const T: Tolerances = Tolerances::millimetres();
4604    const TAU: f64 = core::f64::consts::TAU;
4605
4606    fn mesh_area(model: &Model, face: &Shape) -> f64 {
4607        let fine = ogeom_mesh::Deflection {
4608            chord: 1e-3,
4609            ..ogeom_mesh::Deflection::default()
4610        };
4611        let mesh = ogeom_mesh::triangulate(model, face, fine, T).unwrap();
4612        mesh.triangles
4613            .iter()
4614            .map(|t| {
4615                let [a, b, c] = t.map(|i| mesh.positions[i as usize]);
4616                (b - a).cross(c - a).magnitude() / 2.0
4617            })
4618            .sum()
4619    }
4620
4621    /// A full circle edge at `frame`'s origin, radius `r`, single vertex.
4622    fn ring(model: &mut Model, frame: Frame, r: f64) -> Shape {
4623        let circle = Circle::new(frame, r, T).unwrap();
4624        let vertex = make_vertex(model, frame.origin() + frame.x() * r).shape;
4625        make_edge_between(
4626            model,
4627            CircleCurve::new(circle).into(),
4628            (0.0, TAU),
4629            &vertex,
4630            &vertex,
4631            T,
4632        )
4633        .unwrap()
4634        .shape
4635    }
4636
4637    /// The area a face's stored rings enclose in its surface's chart,
4638    /// walked as stored: positive where they keep the face on their left.
4639    fn chart_area(model: &Model, face: &Shape) -> f64 {
4640        use ogeom_geom::Curve2d as _;
4641        let surface = model.node(face).unwrap().data().as_face().unwrap().surface;
4642        let mut points = Vec::new();
4643        for wire in model
4644            .ordered_children_of(&face.oriented(ogeom_topo::Orientation::Forward))
4645            .unwrap()
4646        {
4647            for edge in model.ordered_children_of(&wire).unwrap() {
4648                let data = model.node(&edge).unwrap().data().as_edge().unwrap();
4649                let forward = edge.orientation() == ogeom_topo::Orientation::Forward;
4650                let (curve, range) = match data.pcurve_for(surface, edge.location()).unwrap() {
4651                    EdgeRepr::PCurve { curve, range, .. } => (*curve, *range),
4652                    EdgeRepr::Seam {
4653                        forward: f,
4654                        reversed: r,
4655                        range,
4656                        ..
4657                    } => (if forward { *f } else { *r }, *range),
4658                    _ => unreachable!("a pcurve was asked for"),
4659                };
4660                let pcurve = model.geometry().pcurve(curve).unwrap();
4661                for k in 0..32 {
4662                    let f = f64::from(k) / 32.0;
4663                    let t = if forward {
4664                        range.0 + (range.1 - range.0) * f
4665                    } else {
4666                        range.1 + (range.0 - range.1) * f
4667                    };
4668                    points.push(pcurve.point_at(t, T).unwrap());
4669                }
4670            }
4671        }
4672        (0..points.len())
4673            .map(|i| {
4674                let (a, b) = (points[i], points[(i + 1) % points.len()]);
4675                a.x * b.y - b.x * a.y
4676            })
4677            .sum::<f64>()
4678            / 2.0
4679    }
4680
4681    /// A band keeps its face on the left of its ring in the surface's
4682    /// chart, whichever ring comes first and whichever way each circle
4683    /// turns: a drum's wall between two rims, a quarter turn of chart
4684    /// height two wide.
4685    #[test]
4686    fn a_band_keeps_its_face_on_the_left_whichever_way_its_rings_come() {
4687        let mut model = Model::new();
4688        let cylinder = ogeom_math::Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
4689        let surface: SurfaceGeometry = ogeom_geom::CylinderSurface::new(cylinder, (-1.0, 3.0))
4690            .unwrap()
4691            .into();
4692        let at = |z: f64, up: bool| {
4693            let axis = if up {
4694                ogeom_math::Direction::Z
4695            } else {
4696                -ogeom_math::Direction::Z
4697            };
4698            Frame::new(Point::new(0.0, 0.0, z), axis, ogeom_math::Direction::X, T).unwrap()
4699        };
4700        for (first_up, second_up) in [(true, true), (false, true), (true, false)] {
4701            let low = ring(&mut model, at(0.0, first_up), 2.0);
4702            let high = ring(&mut model, at(2.0, second_up), 2.0);
4703            for (a, b) in [(&low, &high), (&high, &low)] {
4704                let face = make_revolution_band(&mut model, &surface, a, b, T).unwrap();
4705                let area = chart_area(&model, &face);
4706                assert_relative_eq!(area, TAU * 2.0, max_relative = 1e-9);
4707            }
4708        }
4709    }
4710
4711    /// A band on a cylinder of either handedness, between rims spelled on
4712    /// frames of either handedness and either axis direction, each circle
4713    /// forward or reversed: each rim's pcurve lands on its circle at every
4714    /// parameter, and the face keeps its chart area `2π · 2` on its left.
4715    /// On a left-handed frame the angle turns about `-z`, and a reversed
4716    /// circle turns back, so a winding read from `z` alone runs such a
4717    /// rim's pcurve the wrong way round.
4718    #[test]
4719    fn a_band_follows_its_rims_on_frames_of_either_handedness() {
4720        use ogeom_geom::{Curve2d as _, Curve3d as _, Surface as _};
4721        let spellings = |f: Frame| {
4722            let (o, x, y, z) = (f.origin(), f.x(), f.y(), f.z());
4723            [
4724                f,
4725                f.mirrored(),
4726                Frame::from_axes(o, x, y, -z, T).unwrap(),
4727                Frame::from_axes(o, x, -y, -z, T).unwrap(),
4728            ]
4729        };
4730        for wall in spellings(Frame::WORLD) {
4731            let cylinder = ogeom_math::Cylinder::new(wall, 2.0, T).unwrap();
4732            let heights = if wall.z().vector().z > 0.0 {
4733                (-1.0, 3.0)
4734            } else {
4735                (-3.0, 1.0)
4736            };
4737            let surface: SurfaceGeometry = ogeom_geom::CylinderSurface::new(cylinder, heights)
4738                .unwrap()
4739                .into();
4740            let lows = spellings(Frame::WORLD);
4741            let highs = spellings(Frame::WORLD.with_origin(Point::new(0.0, 0.0, 2.0)));
4742            let pairs = lows.into_iter().zip(highs.into_iter().rev());
4743            for ((low, high), reversed) in pairs.flat_map(|p| [(p, false), (p, true)]) {
4744                let mut model = Model::new();
4745                let mut rim = |frame: Frame| {
4746                    use ogeom_geom::Reversible as _;
4747                    let circle: ogeom_geom::Curve =
4748                        CircleCurve::new(Circle::new(frame, 2.0, T).unwrap()).into();
4749                    let circle = if reversed { circle.reversed() } else { circle };
4750                    let vertex = make_vertex(&mut model, frame.origin() + frame.x() * 2.0).shape;
4751                    make_edge_between(&mut model, circle, (0.0, TAU), &vertex, &vertex, T)
4752                        .unwrap()
4753                        .shape
4754                };
4755                let (low, high) = (rim(low), rim(high));
4756                let face = make_revolution_band(&mut model, &surface, &low, &high, T).unwrap();
4757                let id = model.node(&face).unwrap().data().as_face().unwrap().surface;
4758                for rim in [&low, &high] {
4759                    let data = model.node(rim).unwrap().data().as_edge().unwrap();
4760                    let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d() else {
4761                        unreachable!("a rim has a curve");
4762                    };
4763                    let curve = model.geometry().curve(*curve).unwrap();
4764                    let Some(EdgeRepr::PCurve { curve: p, .. }) =
4765                        data.pcurve_for(id, &Location::identity())
4766                    else {
4767                        unreachable!("a rim has a pcurve on the band");
4768                    };
4769                    let pcurve = model.geometry().pcurve(*p).unwrap();
4770                    for k in 0..=16 {
4771                        let t = TAU * f64::from(k) / 16.0;
4772                        let uv = pcurve.point_at(t, T).unwrap();
4773                        let lifted = surface.point_at(uv.x, uv.y, T).unwrap();
4774                        let on = curve.point_at(t, T).unwrap();
4775                        assert!(
4776                            lifted.distance(on) < 1e-9,
4777                            "wall {wall:?}: a rim's pcurve misses its circle by {}",
4778                            lifted.distance(on)
4779                        );
4780                    }
4781                }
4782                assert_relative_eq!(chart_area(&model, &face), TAU * 2.0, max_relative = 1e-9);
4783            }
4784        }
4785    }
4786
4787    /// Two rims of a drum whose vertices stand half a turn apart: no seam
4788    /// along one column joins them, so the band is refused, and the rims
4789    /// come back with no pcurve on the surface. A pcurve left behind would
4790    /// start at the wrong vertex column, half a turn off its circle at
4791    /// every matched parameter, on a surface no face uses.
4792    #[test]
4793    fn a_refused_band_leaves_its_rings_without_pcurves() {
4794        let mut model = Model::new();
4795        let cylinder = ogeom_math::Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
4796        let surface: SurfaceGeometry = ogeom_geom::CylinderSurface::new(cylinder, (-1.0, 3.0))
4797            .unwrap()
4798            .into();
4799        let at = |z: f64, x: ogeom_math::Direction| {
4800            Frame::new(Point::new(0.0, 0.0, z), ogeom_math::Direction::Z, x, T).unwrap()
4801        };
4802        let low = ring(&mut model, at(0.0, ogeom_math::Direction::X), 2.0);
4803        let high = ring(&mut model, at(2.0, -ogeom_math::Direction::X), 2.0);
4804        let refused = make_revolution_band(&mut model, &surface, &low, &high, T).unwrap_err();
4805        assert!(
4806            refused.to_string().contains("different columns"),
4807            "the refusal names why: {refused}"
4808        );
4809        for rim in [&low, &high] {
4810            let data = model.node(rim).unwrap().data().as_edge().unwrap();
4811            assert!(
4812                data.representations
4813                    .iter()
4814                    .all(|r| !matches!(r, EdgeRepr::PCurve { .. } | EdgeRepr::Seam { .. })),
4815                "a refused band left a pcurve on a rim"
4816            );
4817        }
4818    }
4819
4820    /// A band of rings on a cylinder of either handedness, its rims'
4821    /// vertices a quarter turn round from the frame's `x`: the face's copy
4822    /// of the surface is turned so the seam stands at the start of its
4823    /// window, and every rim pcurve lies in the window and lands on its
4824    /// circle. On a left-handed frame the angle turns about `-z`, so a turn
4825    /// about `z` puts the seam half a turn off the window's start.
4826    #[test]
4827    fn a_band_of_rings_seams_at_its_window_start_on_either_handedness() {
4828        use ogeom_geom::{Curve2d as _, Curve3d as _, Surface as _};
4829        for wall in [Frame::WORLD, Frame::WORLD.mirrored()] {
4830            let mut model = Model::new();
4831            let cylinder = ogeom_math::Cylinder::new(wall, 2.0, T).unwrap();
4832            let surface: SurfaceGeometry = ogeom_geom::CylinderSurface::new(cylinder, (-1.0, 3.0))
4833                .unwrap()
4834                .into();
4835            let at = |z: f64| {
4836                Frame::new(
4837                    Point::new(0.0, 0.0, z),
4838                    ogeom_math::Direction::Z,
4839                    ogeom_math::Direction::Y,
4840                    T,
4841                )
4842                .unwrap()
4843            };
4844            let low = ring(&mut model, at(0.0), 2.0);
4845            let high = ring(&mut model, at(2.0), 2.0);
4846            let face = make_band_of_rings(
4847                &mut model,
4848                &surface,
4849                core::slice::from_ref(&low),
4850                core::slice::from_ref(&high),
4851                T,
4852            )
4853            .unwrap();
4854            let id = model.node(&face).unwrap().data().as_face().unwrap().surface;
4855            let band = model.geometry().surface(id).unwrap().clone();
4856            let ((ua, ub), _) = band.domain();
4857            for rim in [&low, &high] {
4858                let data = model.node(rim).unwrap().data().as_edge().unwrap();
4859                let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d() else {
4860                    unreachable!("a rim has a curve");
4861                };
4862                let curve = model.geometry().curve(*curve).unwrap();
4863                let Some(EdgeRepr::PCurve { curve: p, .. }) =
4864                    data.pcurve_for(id, &Location::identity())
4865                else {
4866                    unreachable!("a rim has a pcurve on the band");
4867                };
4868                let pcurve = model.geometry().pcurve(*p).unwrap();
4869                for k in 0..=16 {
4870                    let t = TAU * f64::from(k) / 16.0;
4871                    let uv = pcurve.point_at(t, T).unwrap();
4872                    assert!(
4873                        uv.x >= ua - 1e-9 && uv.x <= ub + 1e-9,
4874                        "wall {wall:?}: u {} off the window",
4875                        uv.x
4876                    );
4877                    let lifted = band.point_at(uv.x, uv.y, T).unwrap();
4878                    assert!(lifted.distance(curve.point_at(t, T).unwrap()) < 1e-9);
4879                }
4880            }
4881            assert_relative_eq!(chart_area(&model, &face), TAU * 2.0, max_relative = 1e-9);
4882        }
4883    }
4884
4885    /// A drum whose rims' vertices stand half a turn apart: the top rim is
4886    /// split on the bottom vertex's column, the band seams there, and the
4887    /// top cap takes both pieces, so the solid closes, checks valid and
4888    /// holds pi r^2 h. The history traces the top rim to its two pieces.
4889    #[test]
4890    fn a_rim_split_on_the_other_rims_column_closes_the_drum() {
4891        let mut model = Model::new();
4892        let (r, h) = (2.0, 3.0);
4893        let cylinder = ogeom_math::Cylinder::new(Frame::WORLD, r, T).unwrap();
4894        let surface: SurfaceGeometry = ogeom_geom::CylinderSurface::new(cylinder, (-1.0, 4.0))
4895            .unwrap()
4896            .into();
4897        let at = |z: f64, x: ogeom_math::Direction| {
4898            Frame::new(Point::new(0.0, 0.0, z), ogeom_math::Direction::Z, x, T).unwrap()
4899        };
4900        let low = ring(&mut model, at(0.0, ogeom_math::Direction::X), r);
4901        let high = ring(&mut model, at(h, -ogeom_math::Direction::X), r);
4902        let (anchor, _) = edge_vertices(&model, &low).unwrap().unwrap();
4903
4904        let split = split_ring_on_column(&mut model, &surface, &high, &anchor, T)
4905            .unwrap()
4906            .expect("the rims stand on different columns");
4907        let pieces = model.ordered_children_of(&split.shape).unwrap();
4908        assert_eq!(pieces.len(), 2);
4909        assert_eq!(split.history.modified(&high).len(), 2);
4910        // The wire starts on the anchor's column: x = +r.
4911        let (start, _) = edge_vertices(&model, &pieces[0]).unwrap().unwrap();
4912        let start = model
4913            .node(&start)
4914            .unwrap()
4915            .data()
4916            .as_vertex()
4917            .unwrap()
4918            .point;
4919        assert!(start.distance(Point::new(r, 0.0, h)) <= T.confusion());
4920        // Aligned now: a second split has nothing to do.
4921        assert!(
4922            split_ring_on_column(&mut model, &surface, &low, &anchor, T)
4923                .unwrap()
4924                .is_none()
4925        );
4926
4927        let band = make_band_of_rings(
4928            &mut model,
4929            &surface,
4930            core::slice::from_ref(&low),
4931            &pieces,
4932            T,
4933        )
4934        .unwrap();
4935        let down = Frame::new(
4936            Point::ORIGIN,
4937            -ogeom_math::Direction::Z,
4938            ogeom_math::Direction::X,
4939            T,
4940        )
4941        .unwrap();
4942        let bottom = make_face_with_pcurves(
4943            &mut model,
4944            ogeom_geom::PlaneSurface::new(ogeom_math::Plane::new(down)).into(),
4945            &[vec![low.reversed()]],
4946            T,
4947        )
4948        .unwrap()
4949        .shape;
4950        let top = make_face_with_pcurves(
4951            &mut model,
4952            ogeom_geom::PlaneSurface::new(ogeom_math::Plane::new(at(h, ogeom_math::Direction::X)))
4953                .into(),
4954            core::slice::from_ref(&pieces),
4955            T,
4956        )
4957        .unwrap()
4958        .shape;
4959        let shell = make_shell(&mut model, &[bottom, band, top]).unwrap().shape;
4960        assert!(is_shell_closed(&model, &shell).unwrap());
4961        let solid = make_solid(&mut model, &[shell]).unwrap().shape;
4962        let diagnosis = crate::check(&model, &solid, T).unwrap();
4963        assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
4964        let fine = ogeom_mesh::Deflection {
4965            chord: 1e-3,
4966            ..ogeom_mesh::Deflection::default()
4967        };
4968        let volume = crate::volume_properties(&model, &solid, fine, T)
4969            .unwrap()
4970            .mass;
4971        assert_relative_eq!(
4972            volume,
4973            core::f64::consts::PI * r * r * h,
4974            max_relative = 1e-3
4975        );
4976    }
4977
4978    #[test]
4979    fn a_cone_cap_takes_its_apex_as_a_degenerate_ring() {
4980        // Half angle 45 degrees, reference radius 2: apex at z = -2, slant
4981        // length 2*sqrt(2), lateral area pi * r * slant.
4982        let mut model = Model::new();
4983        let cone = Cone::new(Frame::WORLD, 2.0, core::f64::consts::FRAC_PI_4, T).unwrap();
4984        let surface: SurfaceGeometry = ogeom_geom::ConeSurface::new(cone, (-3.0, 3.0))
4985            .unwrap()
4986            .into();
4987        let rim = ring(&mut model, Frame::WORLD, 2.0);
4988        let face = crate::make_apex_band(&mut model, &surface, &rim, T).unwrap();
4989        assert_relative_eq!(
4990            mesh_area(&model, &face),
4991            core::f64::consts::PI * 2.0 * 2.0 * core::f64::consts::SQRT_2,
4992            max_relative = 1e-2
4993        );
4994    }
4995
4996    #[test]
4997    fn a_ring_square_to_the_axis_is_not_a_band_ring() {
4998        // A button head: a sphere whose chart runs along z, bounded by two
4999        // circles cut square to the screw along x. Each lies on the sphere
5000        // and is closed, but neither is a parallel; building a band on
5001        // them hands every rim a latitude line it never follows. Refused,
5002        // and the reader's question answers the same.
5003        let mut model = Model::new();
5004        let sphere = ogeom_math::Sphere::centred(Point::ORIGIN, 5.0, T).unwrap();
5005        let surface: SurfaceGeometry = ogeom_geom::SphereSurface::new(sphere).into();
5006        // Rims in planes x = 3 and x = 4: radii 4 and 3, axis along x.
5007        let rim_at = |model: &mut Model, x: f64| {
5008            let frame = Frame::new(
5009                Point::new(x, 0.0, 0.0),
5010                ogeom_math::Direction::X,
5011                ogeom_math::Direction::Z,
5012                T,
5013            )
5014            .unwrap();
5015            ring(model, frame, (25.0 - x * x).sqrt())
5016        };
5017        let lo = rim_at(&mut model, 3.0);
5018        let hi = rim_at(&mut model, 4.0);
5019        assert!(!crate::rings_are_parallels(&model, &surface, &[&lo, &hi], T).unwrap());
5020        assert!(make_revolution_band(&mut model, &surface, &lo, &hi, T).is_err());
5021        // The genuine article still passes: parallels of the same sphere.
5022        let p_lo = ring(
5023            &mut model,
5024            Frame::new(
5025                Point::new(0.0, 0.0, 3.0),
5026                ogeom_math::Direction::Z,
5027                ogeom_math::Direction::X,
5028                T,
5029            )
5030            .unwrap(),
5031            4.0,
5032        );
5033        let p_hi = ring(
5034            &mut model,
5035            Frame::new(
5036                Point::new(0.0, 0.0, 4.0),
5037                ogeom_math::Direction::Z,
5038                ogeom_math::Direction::X,
5039                T,
5040            )
5041            .unwrap(),
5042            3.0,
5043        );
5044        assert!(crate::rings_are_parallels(&model, &surface, &[&p_lo, &p_hi], T).unwrap());
5045    }
5046
5047    #[test]
5048    fn a_degenerate_ring_is_accepted_whichever_side_it_is_passed_on() {
5049        let mut model = Model::new();
5050        let cone = Cone::new(Frame::WORLD, 2.0, core::f64::consts::FRAC_PI_4, T).unwrap();
5051        let surface: SurfaceGeometry = ogeom_geom::ConeSurface::new(cone, (-3.0, 3.0))
5052            .unwrap()
5053            .into();
5054        let rim = ring(&mut model, Frame::WORLD, 2.0);
5055        let apex = make_vertex(&mut model, cone.apex()).shape;
5056        let mut data = EdgeData::new();
5057        data.degenerate = true;
5058        let degenerate = model.add_edge(data, &[apex.clone(), apex]).unwrap();
5059        // Degenerate first: the band swaps it into place rather than asking
5060        // the caller to know which ring is real.
5061        let face = make_revolution_band(&mut model, &surface, &degenerate, &rim, T).unwrap();
5062        assert_relative_eq!(
5063            mesh_area(&model, &face),
5064            core::f64::consts::PI * 2.0 * 2.0 * core::f64::consts::SQRT_2,
5065            max_relative = 1e-2
5066        );
5067    }
5068
5069    #[test]
5070    fn a_sphere_cap_closes_against_its_pole() {
5071        // The equator ring and the north pole: a hemisphere, area 2 pi r^2.
5072        let mut model = Model::new();
5073        let sphere = Sphere::new(Frame::WORLD, 3.0, T).unwrap();
5074        let surface: SurfaceGeometry = ogeom_geom::SphereSurface::new(sphere).into();
5075        let rim = ring(&mut model, Frame::WORLD, 3.0);
5076        let pole = make_vertex(&mut model, Point::new(0.0, 0.0, 3.0)).shape;
5077        let mut data = EdgeData::new();
5078        data.degenerate = true;
5079        let degenerate = model.add_edge(data, &[pole.clone(), pole]).unwrap();
5080        let face = make_revolution_band(&mut model, &surface, &rim, &degenerate, T).unwrap();
5081        assert_relative_eq!(
5082            mesh_area(&model, &face),
5083            2.0 * core::f64::consts::PI * 9.0,
5084            max_relative = 1e-2
5085        );
5086    }
5087
5088    #[test]
5089    fn two_degenerate_rings_bound_nothing_and_are_refused() {
5090        let mut model = Model::new();
5091        let cone = Cone::new(Frame::WORLD, 2.0, core::f64::consts::FRAC_PI_4, T).unwrap();
5092        let surface: SurfaceGeometry = ogeom_geom::ConeSurface::new(cone, (-3.0, 3.0))
5093            .unwrap()
5094            .into();
5095        let apex = make_vertex(&mut model, cone.apex()).shape;
5096        let mut data = EdgeData::new();
5097        data.degenerate = true;
5098        let a = model
5099            .add_edge(data.clone(), &[apex.clone(), apex.clone()])
5100            .unwrap();
5101        let b = model.add_edge(data, &[apex.clone(), apex]).unwrap();
5102        assert!(make_revolution_band(&mut model, &surface, &a, &b, T).is_err());
5103    }
5104
5105    #[test]
5106    fn an_apex_band_needs_a_cone() {
5107        let mut model = Model::new();
5108        let sphere = Sphere::new(Frame::WORLD, 3.0, T).unwrap();
5109        let surface: SurfaceGeometry = ogeom_geom::SphereSurface::new(sphere).into();
5110        let rim = ring(&mut model, Frame::WORLD, 3.0);
5111        assert!(crate::make_apex_band(&mut model, &surface, &rim, T).is_err());
5112    }
5113}
5114
5115#[cfg(test)]
5116#[allow(clippy::unwrap_used, clippy::expect_used)]
5117mod solid_tests {
5118    use super::*;
5119    use ogeom_math::Frame;
5120
5121    const T: Tolerances = Tolerances::millimetres();
5122
5123    /// The one shell of a box `size` on a side with its corner at `corner`.
5124    fn box_shell(model: &mut Model, corner: f64, size: f64) -> Shape {
5125        let frame = Frame::new(
5126            Point::new(corner, corner, corner),
5127            ogeom_math::Direction::Z,
5128            ogeom_math::Direction::X,
5129            T,
5130        )
5131        .unwrap();
5132        let solid = crate::make_box(model, frame, (size, size, size), T)
5133            .unwrap()
5134            .shape;
5135        model.children_of(&solid).unwrap()[0].clone()
5136    }
5137
5138    /// A 10 mm cube with a 2 mm cubic void, the void given facing into
5139    /// its cavity or out of it and the outer shell facing out or in: the
5140    /// solid comes back with its outer shell facing out and its void
5141    /// facing in, valid, 992 mm3.
5142    #[test]
5143    fn a_solid_faces_out_and_its_void_faces_in_however_given() {
5144        for (outer_in, void_out) in [(false, false), (false, true), (true, false), (true, true)] {
5145            let mut model = Model::new();
5146            let outer = box_shell(&mut model, 0.0, 10.0);
5147            let void = box_shell(&mut model, 4.0, 2.0);
5148            let outer = if outer_in { outer.reversed() } else { outer };
5149            let void = if void_out { void } else { void.reversed() };
5150            let solid = make_solid(&mut model, &[outer, void]).unwrap().shape;
5151            let d = crate::check(&model, &solid, T).unwrap();
5152            assert!(d.is_valid(), "{outer_in} {void_out}: {d}");
5153            let shells = model.children_of(&solid).unwrap();
5154            assert!(enclosed_volume(&model, &shells[0]).unwrap() > 0.0);
5155            assert!(enclosed_volume(&model, &shells[1]).unwrap() < 0.0);
5156            let v = crate::volume_properties(&model, &solid, ogeom_mesh::Deflection::default(), T)
5157                .unwrap()
5158                .mass;
5159            assert!((v - 992.0).abs() < 1e-9, "{outer_in} {void_out}: {v}");
5160        }
5161    }
5162}