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 std::collections::HashMap;
18
19use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
20use ogeom_geom::{Curve, Curve3d, SurfaceGeometry};
21use ogeom_math::Point;
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/// Build a wire of straight segments through a sequence of points.
196///
197/// `closed` adds a final segment back to the first point, and does it by
198/// naming the *first vertex again* rather than making a coincident second one,
199/// which is what keeps the wire closed under [`is_wire_closed`] rather than
200/// merely looking closed.
201///
202/// # Errors
203///
204/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if there are fewer
205/// than two points, fewer than three for a closed polygon, if consecutive
206/// points coincide within tolerance (a zero-length edge is not an edge), or if
207/// a closed polygon's ends do not meet.
208pub fn make_polygon(
209    model: &mut Model,
210    points: &[Point],
211    closed: bool,
212    tol: Tolerances,
213) -> OgeomResult<Built> {
214    let least = if closed { 3 } else { 2 };
215    if points.len() < least {
216        ogeom_bail!(
217            Construction,
218            "a {} polygon needs at least {least} points, got {}",
219            if closed { "closed" } else { "open" },
220            points.len()
221        );
222    }
223    for i in 1..points.len() {
224        if points[i].is_equal(points[i - 1], tol) {
225            ogeom_bail!(
226                Construction,
227                "points {} and {i} coincide, so the edge between them has no \
228                 length",
229                i - 1
230            );
231        }
232    }
233    // Whether or not `closed` was asked for. A caller that repeated the first
234    // point at the end wants a loop, and building it as written would give the
235    // wire two vertices in the same place, which every later boundary walk
236    // treats as a gap that happens to be zero wide. `closed` produces the loop
237    // by naming the first vertex again, which is the thing that actually
238    // closes.
239    if points[0].is_equal(points[points.len() - 1], tol) {
240        ogeom_bail!(
241            Construction,
242            "the first and last points coincide; pass `closed` and drop the \
243             repeat, or the wire gets two vertices in one place rather than a \
244             closed loop"
245        );
246    }
247
248    let mut vertices: Vec<Shape> = Vec::with_capacity(points.len());
249    for point in points {
250        vertices.push(model.add_vertex(VertexData::new(*point)));
251    }
252
253    let mut edges = Vec::with_capacity(points.len());
254    let segments = if closed {
255        points.len()
256    } else {
257        points.len() - 1
258    };
259    for i in 0..segments {
260        let (from, to) = (points[i], points[(i + 1) % points.len()]);
261        let curve: Curve = ogeom_geom::LineCurve::segment(from, to, tol)?.into();
262        edges.push(
263            make_edge_between(
264                model,
265                curve,
266                (0.0, from.distance(to)),
267                &vertices[i],
268                // The closing segment names the first vertex again rather than
269                // a second one at the same place.
270                &vertices[(i + 1) % points.len()],
271                tol,
272            )?
273            .shape,
274        );
275    }
276    make_wire(model, &edges, tol)
277}
278
279/// The plane a shape lies in, if it lies in one.
280///
281/// Fitted to the shape's geometry and then *checked*: the fit always produces a
282/// plane, and the answer is only useful if every sample is within tolerance of
283/// it. `None` means the shape is not planar, which is a fact about the shape
284/// rather than a failure.
285///
286/// Curved edges are sampled along their length, not only at their ends. A
287/// circular arc's endpoints lie in a great many planes that the arc does not.
288///
289/// # Errors
290///
291/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if a handle fails to
292/// resolve.
293pub fn find_plane(
294    model: &Model,
295    shape: &Shape,
296    tol: Tolerances,
297) -> OgeomResult<Option<ogeom_math::Plane>> {
298    let points = sample_shape(model, shape, tol)?;
299    if points.len() < 3 {
300        return Ok(None);
301    }
302    let Some((centroid, normal)) = crate::measure::least_squares_plane(&points, tol) else {
303        return Ok(None);
304    };
305    // Fitting always yields a plane. Whether the shape is *in* it is the
306    // question, and it is answered by measuring, not by having fitted.
307    let reach = tol.confusion().max(
308        points
309            .iter()
310            .map(|p| normal.dot_vector(*p - centroid).abs())
311            .fold(0.0_f64, f64::max),
312    );
313    if reach > tol.confusion() {
314        return Ok(None);
315    }
316    Ok(Some(ogeom_math::Plane::new(ogeom_math::Frame::about(
317        centroid, normal,
318    ))))
319}
320
321/// Points along a shape's geometry: every vertex, and every curved edge sampled
322/// along its length.
323fn sample_shape(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Vec<Point>> {
324    /// Enough to catch a curve leaving a candidate plane, without the cost of a
325    /// real discretization; this is a yes-or-no question, not a mesh.
326    const ALONG_EDGE: usize = 8;
327
328    let mut points = Vec::new();
329    for vertex in ogeom_topo::explore_unique(model, shape, ShapeType::Vertex)? {
330        if let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) {
331            points.push(vertex.transform(model.datums())?.apply(data.point));
332        }
333    }
334    for edge in ogeom_topo::explore_unique(model, shape, ShapeType::Edge)? {
335        let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
336            continue;
337        };
338        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
339            continue;
340        };
341        let Some(geometry) = model.geometry().curve(*curve) else {
342            continue;
343        };
344        if geometry.kind() == ogeom_geom::CurveKind::Line {
345            continue;
346        }
347        let placement = edge.transform(model.datums())?;
348        for i in 1..ALONG_EDGE {
349            #[allow(clippy::cast_precision_loss)]
350            let t = i as f64 / ALONG_EDGE as f64;
351            let at = range.0 + (range.1 - range.0) * t;
352            points.push(placement.apply(geometry.point_at(at, tol)?));
353        }
354    }
355    Ok(points)
356}
357
358/// Build a wire from edges that meet end to end.
359///
360/// # Errors
361///
362/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a child is not
363/// an edge, the list is empty, or consecutive edges do not share a vertex.
364pub fn make_wire(model: &mut Model, edges: &[Shape], tol: Tolerances) -> OgeomResult<Built> {
365    if edges.is_empty() {
366        ogeom_bail!(Construction, "a wire needs at least one edge");
367    }
368    check_connected(model, edges, tol)?;
369
370    let wire = model.add_wire(edges)?;
371    let mut history = History::new();
372    for edge in edges {
373        // The edges are not consumed: they are shared, and an edge between two
374        // faces belongs to both wires. Reporting them modified into the wire
375        // would claim the edge ceased to exist.
376        history.generate(edge, wire.clone());
377    }
378    Ok(Built::new(wire, history))
379}
380
381/// Verify that consecutive edges share a vertex.
382fn check_connected(model: &Model, edges: &[Shape], tol: Tolerances) -> OgeomResult<()> {
383    let mut ends: Vec<Option<(Shape, Shape)>> = Vec::with_capacity(edges.len());
384    for edge in edges {
385        ends.push(edge_vertices(model, edge)?);
386    }
387    for i in 0..edges.len().saturating_sub(1) {
388        let (Some((_, end)), Some((next_start, _))) = (&ends[i], &ends[i + 1]) else {
389            // An unbounded edge cannot be shown to join anything, and pretending
390            // otherwise is worse than saying so.
391            ogeom_bail!(
392                Construction,
393                "edge {i} or {} has no bounding vertices, so the wire cannot be \
394                 shown to connect",
395                i + 1
396            );
397        };
398        if !end.is_same(next_start)
399            && !model.same_position(end, next_start, tol)?
400            && !one_point(model, end, next_start, tol)?
401        {
402            if std::env::var("OGEOM_DEBUG_WIRE").is_ok()
403                && let (Some(a), Some(b)) = (
404                    model.node(end).and_then(|n| n.data().as_vertex().cloned()),
405                    model
406                        .node(next_start)
407                        .and_then(|n| n.data().as_vertex().cloned()),
408                )
409            {
410                eprintln!(
411                    "WIRE GAP: {:?} (tol {:.2e}) vs {:?} (tol {:.2e}), gap {:.3e}",
412                    a.point,
413                    a.tolerance.get(),
414                    b.point,
415                    b.tolerance.get(),
416                    a.point.distance(b.point)
417                );
418            }
419            ogeom_bail!(
420                Construction,
421                "edge {i} ends where edge {} does not begin; a wire whose edges \
422                 do not meet leaves a gap in every face built on it",
423                i + 1
424            );
425        }
426    }
427    Ok(())
428}
429
430/// Whether a wire's last edge returns to its first edge's start.
431///
432/// # Errors
433///
434/// As [`edge_vertices`].
435pub fn is_wire_closed(model: &Model, wire: &Shape, tol: Tolerances) -> OgeomResult<bool> {
436    let edges = model.children_of(wire)?;
437    let (Some(first), Some(last)) = (edges.first(), edges.last()) else {
438        return Ok(false);
439    };
440    let (Some((start, _)), Some((_, end))) =
441        (edge_vertices(model, first)?, edge_vertices(model, last)?)
442    else {
443        return Ok(false);
444    };
445    Ok(start.is_same(&end)
446        || model.same_position(&start, &end, tol)?
447        || one_point(model, &start, &end, tol)?)
448}
449
450/// Whether two occurrences of one vertex node land on one point: a vertex
451/// on a revolution's axis, turned, is itself under another placement.
452pub(crate) fn one_point(model: &Model, a: &Shape, b: &Shape, tol: Tolerances) -> OgeomResult<bool> {
453    if a.node() != b.node() {
454        return Ok(false);
455    }
456    let Some(data) = model.node(a).and_then(|n| n.data().as_vertex()) else {
457        return Ok(false);
458    };
459    let pa = a.transform(model.datums())?.apply(data.point);
460    let pb = b.transform(model.datums())?.apply(data.point);
461    Ok(pa.distance(pb) <= data.tolerance.get().max(tol.confusion()))
462}
463
464/// Build a face on `surface`, bounded by `wires`.
465///
466/// The first wire is the outer boundary; any others are holes. Every wire must
467/// be closed, since an open boundary encloses nothing.
468///
469/// # Errors
470///
471/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a wire is open
472/// or is not a wire.
473pub fn make_face(
474    model: &mut Model,
475    surface: SurfaceGeometry,
476    wires: &[Shape],
477    tol: Tolerances,
478) -> OgeomResult<Built> {
479    for (i, wire) in wires.iter().enumerate() {
480        if model.kind_of(wire)? != ShapeType::Wire {
481            ogeom_bail!(Construction, "a face is bounded by wires");
482        }
483        if !is_wire_closed(model, wire, tol)? {
484            ogeom_bail!(
485                Construction,
486                "wire {i} is open; an open boundary encloses no area"
487            );
488        }
489    }
490
491    let id = model.geometry_mut().add_surface(surface);
492    make_face_on(model, id, wires, tol)
493}
494
495/// Build a face on a surface the model already holds.
496///
497/// The distinction from [`make_face`] matters more than it looks: a pcurve
498/// names the surface it is drawn on by id, so an edge's pcurve and the face
499/// bounded by that edge have to name the *same* id. Registering the surface
500/// twice gives two ids for one surface, and every lookup of "the pcurve on this
501/// face" comes back empty even though the pcurve is right there.
502///
503/// # Errors
504///
505/// As [`make_face`].
506pub fn make_face_on(
507    model: &mut Model,
508    surface: ogeom_topo::SurfaceId,
509    wires: &[Shape],
510    tol: Tolerances,
511) -> OgeomResult<Built> {
512    for (i, wire) in wires.iter().enumerate() {
513        if model.kind_of(wire)? != ShapeType::Wire {
514            ogeom_bail!(Construction, "a face is bounded by wires");
515        }
516        if !is_wire_closed(model, wire, tol)? {
517            ogeom_bail!(
518                Construction,
519                "wire {i} is open; an open boundary encloses no area"
520            );
521        }
522    }
523
524    let data = FaceData::new(surface, Location::identity());
525    let face = model.add_face(data, wires)?;
526
527    let mut history = History::new();
528    for wire in wires {
529        history.generate(wire, face.clone());
530    }
531    Ok(Built::new(face, history))
532}
533
534/// Build a face on `surface` from per-wire edge lists, attaching an exact
535/// same-parameter pcurve to every edge.
536///
537/// The construction path for faces whose curves were *chosen* to have
538/// closed-form charts: blend wedges, offset rebuilds. Every edge's curve
539/// must lie on the surface in a configuration
540/// [`ogeom_intersect::exact_pcurve_of`] recognises; a fitted pcurve here would
541/// manufacture disagreement where none exists, so an edge with no closed
542/// form is refused instead.
543///
544/// # Errors
545///
546/// As [`make_wire`] and [`make_face`], and
547/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if an edge has
548/// no 3D curve or no closed-form pcurve on `surface`.
549pub fn make_face_with_pcurves(
550    model: &mut Model,
551    surface: SurfaceGeometry,
552    wires: &[Vec<Shape>],
553    tol: Tolerances,
554) -> OgeomResult<Built> {
555    let mut rings: Vec<Shape> = Vec::with_capacity(wires.len());
556    for edges in wires {
557        rings.push(make_wire(model, edges, tol)?.shape);
558    }
559    let built = make_face(model, surface.clone(), &rings, tol)?;
560    let surface_id = {
561        let Some(node) = model.node(&built.shape) else {
562            ogeom_bail!(Dangling, "the face just built is not in this model");
563        };
564        let ogeom_topo::NodeData::Face(data) = node.data() else {
565            ogeom_bail!(Construction, "the face holds no face data");
566        };
567        data.surface
568    };
569    // Walked in each wire's own traversal order, so that on a periodic
570    // chart every pcurve lands on the *same branch* as its neighbour: an
571    // exact inversion answers with whatever phase its construction likes,
572    // and two images a full period apart describe the same points while
573    // tearing the boundary the arrangement walks. Each pcurve after the
574    // first is shifted by whole periods until its start meets the previous
575    // traversal's end.
576    use ogeom_geom::Curve2d as _;
577    use ogeom_geom::Surface as _;
578    let ((ua, ub), (va, vb)) = surface.domain();
579    let u_period = surface.is_periodic_u().then_some(ub - ua);
580    let v_period = surface.is_periodic_v().then_some(vb - va);
581    let mut done: Vec<Shape> = Vec::new();
582    for edges in wires {
583        let mut prev_end: Option<ogeom_math::Point2> = None;
584        for edge in edges {
585            let (curve, prange) = {
586                let Some(node) = model.node(edge) else {
587                    ogeom_bail!(Dangling, "edge is not in this model");
588                };
589                let Some(data) = node.data().as_edge() else {
590                    ogeom_bail!(Construction, "edge node holds no edge data");
591                };
592                let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
593                    ogeom_bail!(Construction, "a face edge has no 3D curve");
594                };
595                let Some(geometry) = model.geometry().curve(*curve) else {
596                    ogeom_bail!(Dangling, "curve is not in this model");
597                };
598                (geometry.clone(), *range)
599            };
600            let mut pcurve = match ogeom_intersect::exact_pcurve_of(&curve, &surface, tol) {
601                Some(exact) => exact,
602                // No closed form: a fitted surface, mostly. The edge lies on
603                // the surface by construction here (a rebuilt boundary, a
604                // recovered intersection), so the projected fit speaks it: the
605                // same machinery the exchange readers trust, at the same cap,
606                // and the measured offset widens the edge honestly.
607                None => {
608                    let (fitted, _, _, worst_off, _) =
609                        crate::pcurve_fit::fit_projected_pcurve(&curve, prange, &surface, tol)?;
610                    if worst_off > tol.confusion() {
611                        // The edge owns the offset, and so must the vertices
612                        // that bound it: the containment rule.
613                        let widened = ogeom_core::Tolerance::new(worst_off + tol.confusion())?;
614                        model.widen(edge, widened)?;
615                        if let Some((a, b)) = edge_vertices(model, edge)? {
616                            model.widen(&a, widened)?;
617                            model.widen(&b, widened)?;
618                        }
619                    }
620                    fitted
621                }
622            };
623            let (t_start, t_end) = if edge.orientation() == ogeom_topo::Orientation::Reversed {
624                (prange.1, prange.0)
625            } else {
626                (prange.0, prange.1)
627            };
628            if u_period.is_some() || v_period.is_some() {
629                let start = pcurve.point_at(t_start, tol)?;
630                if let Some(prev) = prev_end {
631                    let shift = ogeom_math::Vector2::new(
632                        u_period.map_or(0.0, |p| ((prev.x - start.x) / p).round() * p),
633                        v_period.map_or(0.0, |p| ((prev.y - start.y) / p).round() * p),
634                    );
635                    if shift.x != 0.0 || shift.y != 0.0 {
636                        pcurve =
637                            pcurve.transformed(&ogeom_math::Transform2::translation(shift), tol)?;
638                    }
639                }
640                prev_end = Some(pcurve.point_at(t_end, tol)?);
641            }
642            if done.iter().any(|e| e.is_same(edge)) {
643                continue;
644            }
645            done.push(edge.clone());
646            attach_pcurve(
647                model,
648                edge,
649                pcurve,
650                surface_id,
651                Location::identity(),
652                prange,
653            )?;
654        }
655    }
656    Ok(built)
657}
658
659/// Put a face's wires on one branch of a periodic chart.
660///
661/// An exchange file's pcurves, or a fit of them, answer with whatever
662/// phase the inversion likes: a hole loop that straddles a drum's seam
663/// comes back half on each branch (a loop that never closes in the chart,
664/// which no mesher can cut), and a slitted wall's outer wire hops branches
665/// at every slit. Walked in each wire's own order, an image whose start
666/// misses the previous traversal's end by close to a whole period is
667/// shifted by that period; a miss of half a period is a pole, where two
668/// meridians meet at one point with no edge between them, and is left
669/// alone. A seam representation carries both branches by design: the
670/// walk passes through it on the side the occurrence uses and moves
671/// nothing. Every wire after the first is then carried whole onto the
672/// first wire's branch, so rims, slits and holes all read in one chart.
673/// The images are rewritten in place (`GeometryStore::pcurve_mut`), once
674/// each; a slit uses one image twice.
675///
676/// # Errors
677///
678/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if a wire or
679/// edge is not in this model.
680pub fn chain_wire_branches(
681    model: &mut Model,
682    surface: ogeom_topo::SurfaceId,
683    wires: &[Shape],
684    tol: Tolerances,
685) -> OgeomResult<()> {
686    use ogeom_geom::Curve2d as _;
687    use ogeom_geom::Surface as _;
688    let Some(geometry) = model.geometry().surface(surface) else {
689        ogeom_bail!(Dangling, "surface is not in this model");
690    };
691    let ((ua, ub), (va, vb)) = geometry.domain();
692    let u_period = geometry.is_periodic_u().then_some(ub - ua);
693    let v_period = geometry.is_periodic_v().then_some(vb - va);
694    if u_period.is_none() && v_period.is_none() {
695        return Ok(());
696    }
697    // A whole number of periods, when the gap is within a quarter period
698    // of one; nothing for a pole's half-period hop.
699    let whole = |gap: f64, period: Option<f64>| -> f64 {
700        period.map_or(0.0, |p| {
701            let k = (gap / p).round();
702            if k != 0.0 && (gap - k * p).abs() <= p * 0.25 {
703                k * p
704            } else {
705                0.0
706            }
707        })
708    };
709    // A wire's images in traversal order: the pcurve to move (none for a
710    // seam), its start and end in the edge's own direction.
711    type Image = (
712        Option<ogeom_topo::PCurveId>,
713        ogeom_math::Point2,
714        ogeom_math::Point2,
715    );
716    let images = |model: &Model, wire: &Shape| -> OgeomResult<Vec<Image>> {
717        let mut out = Vec::new();
718        for edge in model.ordered_children_of(wire)? {
719            let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
720                ogeom_bail!(Dangling, "edge is not in this model");
721            };
722            let reversed = edge.orientation() == ogeom_topo::Orientation::Reversed;
723            let (id, movable, range) = match data.pcurve_for(surface, edge.location()) {
724                Some(EdgeRepr::PCurve { curve, range, .. }) => (*curve, true, *range),
725                Some(EdgeRepr::Seam {
726                    forward,
727                    reversed: back,
728                    range,
729                    ..
730                }) => (if reversed { *back } else { *forward }, false, *range),
731                _ => continue,
732            };
733            let Some(planar) = model.geometry().pcurve(id) else {
734                ogeom_bail!(Dangling, "pcurve is not in this model");
735            };
736            let (t_start, t_end) = if reversed {
737                (range.1, range.0)
738            } else {
739                (range.0, range.1)
740            };
741            out.push((
742                movable.then_some(id),
743                planar.point_at(t_start, tol)?,
744                planar.point_at(t_end, tol)?,
745            ));
746        }
747        Ok(out)
748    };
749    let mut first_centre: Option<ogeom_math::Point2> = None;
750    for wire in wires {
751        let wire_images = images(model, wire)?;
752        if wire_images.is_empty() {
753            continue;
754        }
755        // The chain, dry: each image's shift so its start meets the
756        // previous end, an image already placed taking its earlier shift.
757        let mut shifts: Vec<(ogeom_topo::PCurveId, ogeom_math::Vector2)> = Vec::new();
758        let mut prev_end: Option<ogeom_math::Point2> = None;
759        let mut lo = ogeom_math::Point2::new(f64::INFINITY, f64::INFINITY);
760        let mut hi = ogeom_math::Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
761        for (id, start, end) in &wire_images {
762            let shift = match id {
763                None => ogeom_math::Vector2::new(0.0, 0.0),
764                Some(id) => match shifts.iter().find(|(seen, _)| seen == id) {
765                    Some((_, s)) => *s,
766                    None => {
767                        let s = prev_end.map_or(ogeom_math::Vector2::new(0.0, 0.0), |prev| {
768                            ogeom_math::Vector2::new(
769                                whole(prev.x - start.x, u_period),
770                                whole(prev.y - start.y, v_period),
771                            )
772                        });
773                        shifts.push((*id, s));
774                        s
775                    }
776                },
777            };
778            let (s, e) = (*start + shift, *end + shift);
779            for p in [s, e] {
780                lo = ogeom_math::Point2::new(lo.x.min(p.x), lo.y.min(p.y));
781                hi = ogeom_math::Point2::new(hi.x.max(p.x), hi.y.max(p.y));
782            }
783            prev_end = Some(e);
784        }
785        // Then the whole wire onto the first wire's branch.
786        let centre = ogeom_math::Point2::new(f64::midpoint(lo.x, hi.x), f64::midpoint(lo.y, hi.y));
787        let carry = match first_centre {
788            None => {
789                first_centre = Some(centre);
790                ogeom_math::Vector2::new(0.0, 0.0)
791            }
792            Some(first) => ogeom_math::Vector2::new(
793                u_period.map_or(0.0, |p| ((first.x - centre.x) / p).round() * p),
794                v_period.map_or(0.0, |p| ((first.y - centre.y) / p).round() * p),
795            ),
796        };
797        for (id, shift) in shifts {
798            let total = shift + carry;
799            if total.x == 0.0 && total.y == 0.0 {
800                continue;
801            }
802            let Some(planar) = model.geometry().pcurve(id) else {
803                ogeom_bail!(Dangling, "pcurve is not in this model");
804            };
805            let shifted = planar.transformed(&ogeom_math::Transform2::translation(total), tol)?;
806            if let Some(slot) = model.geometry_mut().pcurve_mut(id) {
807                *slot = shifted;
808            }
809        }
810    }
811    Ok(())
812}
813
814/// Build a face covering the whole of `surface`, with no trimming.
815///
816/// # Errors
817///
818/// Never fails for a well-formed surface; the signature matches [`make_face`]
819/// so the two are interchangeable at a call site.
820pub fn make_natural_face(model: &mut Model, surface: SurfaceGeometry) -> OgeomResult<Built> {
821    let id = model.geometry_mut().add_surface(surface);
822    let face = model.add_face(FaceData::natural(id, Location::identity()), &[])?;
823    Ok(Built::from_nothing(face))
824}
825
826/// Build a shell from faces.
827///
828/// # Errors
829///
830/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a child is not
831/// a face, or the list is empty.
832/// Build a compsolid: solids gluing along shared faces.
833///
834/// A compsolid is more than a bag of solids: its members must actually
835/// glue. Every pair connected through the whole is connected through shared
836/// *face nodes*: the same face entity bounding two solids, once from each
837/// side, which is the sharing a boolean or a sew produces. A set of solids
838/// that merely touch, faces coincident but distinct, is a compound, not a
839/// compsolid, and is refused as one.
840///
841/// # Errors
842///
843/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
844/// fewer than two solids are given, a member is not a solid, or the members
845/// do not glue into one connected whole through shared faces.
846pub fn make_compsolid(model: &mut Model, solids: &[Shape]) -> OgeomResult<Built> {
847    if solids.len() < 2 {
848        ogeom_bail!(
849            Construction,
850            "a compsolid glues at least two solids; one solid is just a solid"
851        );
852    }
853    // Which face nodes bound each solid.
854    let mut faces_of: Vec<std::collections::HashSet<ogeom_topo::TShapeId>> = Vec::new();
855    for solid in solids {
856        if model.kind_of(solid)? != ShapeType::Solid {
857            ogeom_bail!(Construction, "a compsolid's members are solids");
858        }
859        let set = ogeom_topo::explore(model, solid, ogeom_topo::Filter::OfType(ShapeType::Face))?
860            .iter()
861            .map(Shape::node)
862            .collect();
863        faces_of.push(set);
864    }
865    // Connectivity over shared face nodes: a flood fill from the first.
866    let mut joined = vec![false; solids.len()];
867    joined[0] = true;
868    let mut frontier = vec![0_usize];
869    while let Some(current) = frontier.pop() {
870        for (other, other_faces) in faces_of.iter().enumerate() {
871            if joined[other] {
872                continue;
873            }
874            if !faces_of[current].is_disjoint(other_faces) {
875                joined[other] = true;
876                frontier.push(other);
877            }
878        }
879    }
880    if joined.iter().any(|j| !j) {
881        ogeom_bail!(
882            Construction,
883            "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."
884        );
885    }
886
887    let compsolid = model.add_compsolid(solids)?;
888    let mut history = History::new();
889    for solid in solids {
890        history.generate(solid, compsolid.clone());
891    }
892    Ok(Built::new(compsolid, history))
893}
894
895/// Build a compound from any shapes, with history.
896///
897/// The grouping container: a compound holds anything, orders nothing, and
898/// claims nothing about closure. What this adds over the raw model call is
899/// the same thing every builder adds: a history that says what went in.
900///
901/// # Errors
902///
903/// As [`Model::add_compound`].
904pub fn make_compound(model: &mut Model, shapes: &[Shape]) -> OgeomResult<Built> {
905    let compound = model.add_compound(shapes)?;
906    let mut history = History::new();
907    for shape in shapes {
908        history.generate(shape, compound.clone());
909    }
910    Ok(Built::new(compound, history))
911}
912
913/// Build a shell from faces.
914///
915/// # Errors
916///
917/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a child is
918/// not a face, or the list is empty.
919pub fn make_shell(model: &mut Model, faces: &[Shape]) -> OgeomResult<Built> {
920    let shell = model.add_shell(faces)?;
921    let mut history = History::new();
922    for face in faces {
923        history.generate(face, shell.clone());
924    }
925    Ok(Built::new(shell, history))
926}
927
928/// Build a solid from shells.
929///
930/// The first shell is the outer boundary; any others are voids inside it.
931///
932/// # Errors
933///
934/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a child is not
935/// a shell, or the list is empty.
936pub fn make_solid(model: &mut Model, shells: &[Shape]) -> OgeomResult<Built> {
937    let solid = model.add_solid(shells)?;
938    let mut history = History::new();
939    for shell in shells {
940        history.generate(shell, solid.clone());
941    }
942    Ok(Built::new(solid, history))
943}
944
945/// Whether every edge in a shell is shared by exactly two faces.
946///
947/// The defining property of a closed shell, and the one that decides whether it
948/// can bound a solid. An edge used once is a free boundary: the shell has a
949/// hole. An edge used three or more times is non-manifold, which is legitimate
950/// topology but not something that encloses a volume.
951///
952/// # Errors
953///
954/// As [`ogeom_topo::explore`].
955pub fn is_shell_closed(model: &Model, shell: &Shape) -> OgeomResult<bool> {
956    // Counted by *use*, not by how many distinct faces an edge belongs to. A
957    // seam edge bounds one face twice (up one side of its parameter rectangle
958    // and down the other), so counting faces would call every cylinder, sphere
959    // and torus open, which is precisely backwards.
960    let mut uses: HashMap<ogeom_topo::TShapeId, usize> = HashMap::new();
961    for face in ogeom_topo::explore(model, shell, ogeom_topo::Filter::OfType(ShapeType::Face))? {
962        for wire in model.children_of(&face)? {
963            for edge in model.children_of(&wire)? {
964                // A degenerate edge (a sphere's pole, a cone's apex) has no
965                // length, so there is no gap along it for a second face to
966                // close. Counting it would call every sphere and every true
967                // cone open, and the thing that is actually open, isn't.
968                if model
969                    .node(&edge)
970                    .and_then(|n| n.data().as_edge())
971                    .is_some_and(|d| d.degenerate)
972                {
973                    continue;
974                }
975                *uses.entry(edge.node()).or_default() += 1;
976            }
977        }
978    }
979    Ok(!uses.is_empty() && uses.values().all(|n| n % 2 == 0))
980}
981
982/// Give every edge of `face` that has no trim on the face's surface its
983/// exact one, where a closed form exists: a profile built from bare
984/// curves becomes a face a sweep can keep as an end.
985///
986/// # Errors
987///
988/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if the face
989/// or an edge is not in this model.
990pub fn trimmed_where_bare(model: &mut Model, face: &Shape, tol: Tolerances) -> OgeomResult<()> {
991    let Some(data) = model.node(face).and_then(|n| n.data().as_face().cloned()) else {
992        ogeom_bail!(Dangling, "face is not in this model");
993    };
994    let Some(surface) = model.geometry().surface(data.surface).cloned() else {
995        ogeom_bail!(Dangling, "face refers to a surface not in this model");
996    };
997    for edge in ogeom_topo::explore_unique(model, face, ShapeType::Edge)? {
998        let Some(edge_data) = model.node(&edge).and_then(|n| n.data().as_edge().cloned()) else {
999            continue;
1000        };
1001        if edge_data
1002            .pcurve_for(data.surface, edge.location())
1003            .is_some()
1004        {
1005            continue;
1006        }
1007        let Some(EdgeRepr::Curve3d { curve, range, .. }) = edge_data.curve3d() else {
1008            continue;
1009        };
1010        let Some(geometry) = model.geometry().curve(*curve).cloned() else {
1011            continue;
1012        };
1013        use ogeom_geom::Transformable as _;
1014        let placed = geometry.transformed(&edge.transform(model.datums())?, tol)?;
1015        if let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&placed, &surface, tol) {
1016            attach_pcurve(
1017                model,
1018                &edge,
1019                pcurve,
1020                data.surface,
1021                edge.location().clone(),
1022                *range,
1023            )?;
1024        }
1025    }
1026    Ok(())
1027}
1028
1029/// Attach a pcurve to an edge, describing it in a surface's parameter space.
1030///
1031/// # Errors
1032///
1033/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `edge` is not
1034/// an edge; [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if it does not
1035/// resolve.
1036pub fn attach_pcurve(
1037    model: &mut Model,
1038    edge: &Shape,
1039    pcurve: ogeom_geom::PlanarCurve,
1040    surface: ogeom_topo::SurfaceId,
1041    location: Location,
1042    range: (f64, f64),
1043) -> OgeomResult<()> {
1044    if model.kind_of(edge)? != ShapeType::Edge {
1045        ogeom_bail!(Construction, "pcurves attach to edges");
1046    }
1047    let id = model.geometry_mut().add_pcurve(pcurve);
1048    let Some(node) = model.node_mut(edge) else {
1049        ogeom_bail!(Dangling, "edge is not in this model");
1050    };
1051    let ogeom_topo::NodeData::Edge(data) = node.data_mut() else {
1052        ogeom_bail!(Construction, "edge node holds no edge data");
1053    };
1054    data.add(EdgeRepr::PCurve {
1055        curve: id,
1056        surface,
1057        location,
1058        range,
1059    });
1060    Ok(())
1061}
1062
1063/// Attach a seam representation to an edge: one pcurve per side of a closed
1064/// surface's join.
1065///
1066/// The counterpart of [`attach_pcurve`] for the edge that bounds one face
1067/// twice: up one side of the parameter rectangle and down the other. Which
1068/// pcurve applies to an occurrence is decided by that occurrence's
1069/// orientation, which is the only thing distinguishing the two.
1070///
1071/// # Errors
1072///
1073/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `edge` is not
1074/// an edge; [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if it is not in
1075/// this model.
1076pub fn attach_seam(
1077    model: &mut Model,
1078    edge: &Shape,
1079    forward: ogeom_geom::PlanarCurve,
1080    reversed: ogeom_geom::PlanarCurve,
1081    surface: ogeom_topo::SurfaceId,
1082    location: Location,
1083    range: (f64, f64),
1084) -> OgeomResult<()> {
1085    if model.kind_of(edge)? != ShapeType::Edge {
1086        ogeom_bail!(Construction, "seams attach to edges");
1087    }
1088    let forward = model.geometry_mut().add_pcurve(forward);
1089    let reversed = model.geometry_mut().add_pcurve(reversed);
1090    let Some(node) = model.node_mut(edge) else {
1091        ogeom_bail!(Dangling, "edge is not in this model");
1092    };
1093    let ogeom_topo::NodeData::Edge(data) = node.data_mut() else {
1094        ogeom_bail!(Construction, "edge node holds no edge data");
1095    };
1096    data.add(EdgeRepr::Seam {
1097        forward,
1098        reversed,
1099        surface,
1100        location,
1101        range,
1102    });
1103    Ok(())
1104}
1105
1106/// Whether a circle is a *parallel* of a revolved surface (its axis the
1107/// revolution axis, its centre on it) rather than a circle that merely
1108/// lies on the surface.
1109fn circle_is_parallel_of(
1110    circle: ogeom_math::Circle,
1111    surface: &ogeom_geom::SurfaceGeometry,
1112    axis_z: ogeom_math::Vector,
1113    tol: Tolerances,
1114) -> bool {
1115    if circle.frame().z().vector().cross(axis_z).magnitude() > tol.angular() {
1116        return false;
1117    }
1118    match surface_axis_origin(surface) {
1119        Some(origin) => {
1120            let off = circle.centre() - origin;
1121            let radial = off - axis_z * off.dot(axis_z);
1122            radial.magnitude() <= tol.confusion() * 10.0
1123        }
1124        None => true,
1125    }
1126}
1127
1128/// Whether two closed ring edges are parallels of `surface`, so that
1129/// [`make_revolution_band`] can build a band between them. A reader asks
1130/// this first: a periodic face bounded by two closed circles that are *not*
1131/// parallels (a button head's rims, square to the screw on a sphere whose
1132/// chart runs along z) is a legitimate face on its own bounds, not a band
1133/// missing its seam, and deserves no warning.
1134///
1135/// # Errors
1136///
1137/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if an edge is
1138/// not in the model.
1139pub fn rings_are_parallels(
1140    model: &Model,
1141    surface: &ogeom_geom::SurfaceGeometry,
1142    rings: &[&Shape],
1143    tol: Tolerances,
1144) -> OgeomResult<bool> {
1145    let Some(axis_z) = surface_iso_axis(surface) else {
1146        return Ok(false);
1147    };
1148    for ring in rings {
1149        let Some(node) = model.node(ring) else {
1150            ogeom_bail!(Dangling, "edge is not in this model");
1151        };
1152        let Some(data) = node.data().as_edge() else {
1153            return Ok(false);
1154        };
1155        if data.degenerate {
1156            continue;
1157        }
1158        let Some(EdgeRepr::Curve3d { curve, .. }) = data.curve3d() else {
1159            return Ok(false);
1160        };
1161        let Some(ogeom_geom::Curve::Circle(c)) = model.geometry().curve(*curve) else {
1162            return Ok(false);
1163        };
1164        if !circle_is_parallel_of(c.circle(), surface, axis_z, tol) {
1165            return Ok(false);
1166        }
1167    }
1168    Ok(true)
1169}
1170
1171/// A point on the revolution axis of an analytic surface, where one is
1172/// stated: the frame origin of a cylinder, cone or torus, a sphere's centre.
1173fn surface_axis_origin(surface: &ogeom_geom::SurfaceGeometry) -> Option<Point> {
1174    use ogeom_geom::SurfaceGeometry as S;
1175    match surface {
1176        S::Cylinder(c) => Some(c.cylinder().frame().origin()),
1177        S::Cone(c) => Some(c.cone().frame().origin()),
1178        S::Sphere(s) => Some(s.sphere().centre()),
1179        S::Torus(t) => Some(t.torus().frame().origin()),
1180        _ => None,
1181    }
1182}
1183
1184/// Build the face of a revolution band: two closed rings joined by a
1185/// synthesised seam, pcurves attached window-coherently.
1186///
1187/// The one authority for a job three call sites got subtly wrong three
1188/// different ways. The chart walk decides everything: the bottom ring is
1189/// traversed forward, and where its chart line *ends* is where the first
1190/// seam column stands; the top ring's occurrence direction is chosen so its
1191/// walk starts there (rings winding the same way traverse opposite, rings
1192/// winding opposite traverse alike), and the seam's two pcurves are assigned
1193/// to match which occurrence the triangulator will hand them to. The face is
1194/// built on a fresh copy of the surface, so no stale annotation from another
1195/// phase of the same rings can apply.
1196///
1197/// One ring may be *degenerate* (an edge with no curve, both ends the same
1198/// vertex, flagged as such), standing for an apex or a pole: a rim of no
1199/// length that still bounds the face in parameter space. It takes the row
1200/// the collapsed point sits on and traverses the chart opposite the real
1201/// ring, exactly as native cones and spheres bound their tips.
1202///
1203/// # Errors
1204///
1205/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a ring is
1206/// neither a circle nor degenerate, both rings are degenerate, the rings are
1207/// not rings of this surface, or the surface's iso-curve has no closed form.
1208pub fn make_revolution_band(
1209    model: &mut Model,
1210    surface: &ogeom_geom::SurfaceGeometry,
1211    ring_lo: &Shape,
1212    ring_hi: &Shape,
1213    tol: Tolerances,
1214) -> OgeomResult<Shape> {
1215    use ogeom_geom::Surface as _;
1216
1217    let surface_id = model.geometry_mut().add_surface(surface.clone());
1218    let ((ua_dom, ub_dom), _) = surface.domain();
1219    let span = ub_dom - ua_dom;
1220    let axis_z = surface_iso_axis(surface).ok_or_else(|| {
1221        ogeom_core::ogeom_err!(Construction, "the surface has no revolution axis")
1222    })?;
1223
1224    // Each ring's curve, range, winding, vertex, and chart row.
1225    struct Ring {
1226        edge: Shape,
1227        vertex: Shape,
1228        crange: (f64, f64),
1229        winding: f64,
1230        row: f64,
1231        degenerate: bool,
1232    }
1233    let mut rings = Vec::new();
1234    for used in [ring_lo, ring_hi] {
1235        // The caller's edges may carry orientation from their old wire uses;
1236        // the band is built on the curves' own directions, and the walk
1237        // chooses each occurrence's orientation itself.
1238        let edge = &if used.orientation() == ogeom_topo::Orientation::Reversed {
1239            used.reversed()
1240        } else {
1241            used.clone()
1242        };
1243        let Some((vertex, other)) = edge_vertices(model, edge)? else {
1244            ogeom_bail!(Construction, "a band ring has no vertex");
1245        };
1246        if !vertex.is_same(&other) {
1247            ogeom_bail!(Construction, "a band ring is not closed");
1248        }
1249        let at = {
1250            let Some(node) = model.node(&vertex) else {
1251                ogeom_bail!(Dangling, "vertex is not in this model");
1252            };
1253            let Some(data) = node.data().as_vertex() else {
1254                ogeom_bail!(Construction, "vertex node holds no vertex data");
1255            };
1256            data.point
1257        };
1258        let degenerate = {
1259            let Some(node) = model.node(edge) else {
1260                ogeom_bail!(Dangling, "edge is not in this model");
1261            };
1262            let Some(data) = node.data().as_edge() else {
1263                ogeom_bail!(Construction, "edge node holds no edge data");
1264            };
1265            data.degenerate
1266        };
1267        if degenerate {
1268            // An apex or a pole: no curve to read, no winding of its own.
1269            // Its row comes from the collapsed point, which sits on the
1270            // axis, where iterative projection has no nearest angle, so only
1271            // the closed-form inversion can place it.
1272            let Some(uv) = analytic_chart_of(surface, at) else {
1273                ogeom_bail!(
1274                    Construction,
1275                    "a degenerate ring's row cannot be found on this surface"
1276                );
1277            };
1278            rings.push(Ring {
1279                edge: edge.clone(),
1280                vertex,
1281                crange: (0.0, span),
1282                winding: 0.0,
1283                row: uv.y,
1284                degenerate: true,
1285            });
1286            continue;
1287        }
1288        let (curve, crange) = {
1289            let Some(node) = model.node(edge) else {
1290                ogeom_bail!(Dangling, "edge is not in this model");
1291            };
1292            let Some(data) = node.data().as_edge() else {
1293                ogeom_bail!(Construction, "edge node holds no edge data");
1294            };
1295            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1296                ogeom_bail!(Construction, "a band ring has no curve");
1297            };
1298            let Some(geometry) = model.geometry().curve(*curve) else {
1299                ogeom_bail!(Dangling, "curve is not in this model");
1300            };
1301            (geometry.clone(), *range)
1302        };
1303        let ogeom_geom::Curve::Circle(c) = &curve else {
1304            ogeom_bail!(Construction, "a band ring is not a circle");
1305        };
1306        // A ring of *this* surface is a parallel: its axis the revolution
1307        // axis, its centre on it. A circle merely lying on the surface
1308        // (a button head's rim, cut square to the screw while the sphere's
1309        // chart runs along z) is a closed loop in the chart, not a row of
1310        // it, and building a band on it would hand every rim a latitude
1311        // line it never follows. Refused here, so a reader falls through to
1312        // the face's own bounds, which triangulate as the nested loops they
1313        // are.
1314        if !circle_is_parallel_of(c.circle(), surface, axis_z, tol) {
1315            ogeom_bail!(
1316                Construction,
1317                "a band ring is a circle on the surface but not a parallel \
1318                 of it; its axis or centre is off the revolution axis"
1319            );
1320        }
1321        let winding = c.circle().frame().z().vector().dot(axis_z).signum();
1322        let row = match analytic_chart_of(surface, at) {
1323            Some(uv) => uv.y,
1324            None => {
1325                crate::measure::project_on_surface(surface, at, 32, tol)?
1326                    .parameters
1327                    .1
1328            }
1329        };
1330        rings.push(Ring {
1331            edge: edge.clone(),
1332            vertex,
1333            crange,
1334            winding,
1335            row,
1336            degenerate: false,
1337        });
1338    }
1339    // A degenerate ring bounds nothing by itself, and the chart anchor must
1340    // come from a rim that has an angle: the real ring goes first, and the
1341    // degenerate one traverses opposite it.
1342    if rings[0].degenerate && rings[1].degenerate {
1343        ogeom_bail!(Construction, "both band rings are degenerate");
1344    }
1345    if rings[0].degenerate {
1346        rings.swap(0, 1);
1347    }
1348    if rings[1].degenerate {
1349        rings[1].winding = -rings[0].winding;
1350    }
1351    let anchor = {
1352        let Some(node) = model.node(&rings[0].vertex) else {
1353            ogeom_bail!(Dangling, "vertex is not in this model");
1354        };
1355        let Some(data) = node.data().as_vertex() else {
1356            ogeom_bail!(Construction, "vertex node holds no vertex data");
1357        };
1358        data.point
1359    };
1360    let ua = match analytic_chart_of(surface, anchor) {
1361        Some(uv) => uv.x,
1362        None => {
1363            crate::measure::project_on_surface(surface, anchor, 32, tol)?
1364                .parameters
1365                .0
1366        }
1367    };
1368
1369    // Window-coherent ring pcurves: u(t) spans [ua, ua + span] whichever way
1370    // each ring winds.
1371    for ring in &rings {
1372        let u_start = if ring.winding > 0.0 { ua } else { ua + span };
1373        let origin =
1374            ogeom_math::Point2::new(ring.winding.mul_add(-ring.crange.0, u_start), ring.row);
1375        let pcurve: ogeom_geom::PlanarCurve = ogeom_geom::Line2d::over(
1376            ogeom_math::Axis2::new(
1377                origin,
1378                ogeom_math::Direction2::new(ogeom_math::Vector2::new(ring.winding, 0.0), tol)?,
1379            ),
1380            ring.crange.0,
1381            ring.crange.1,
1382        )?
1383        .into();
1384        attach_pcurve(
1385            model,
1386            &ring.edge,
1387            pcurve,
1388            surface_id,
1389            Location::identity(),
1390            ring.crange,
1391        )?;
1392    }
1393
1394    // The seam runs along the surface's own iso-curve at the anchor angle,
1395    // parameterized by `v`, built along increasing `v`.
1396    let (va, vb) = (rings[0].row, rings[1].row);
1397    let Some(seam_curve) = surface_iso_u_curve(surface, ua, tol) else {
1398        ogeom_bail!(
1399            Construction,
1400            "the surface's iso-curve has no closed form; no seam can be built"
1401        );
1402    };
1403    let (range, from, to, downward) = if va <= vb {
1404        (
1405            (va, vb),
1406            rings[0].vertex.clone(),
1407            rings[1].vertex.clone(),
1408            false,
1409        )
1410    } else {
1411        (
1412            (vb, va),
1413            rings[1].vertex.clone(),
1414            rings[0].vertex.clone(),
1415            true,
1416        )
1417    };
1418    // The edge lives on the curve's own parameterization; the chart rows map
1419    // onto it linearly, which is exactly the rescale a pcurve range states.
1420    let curve_range = (
1421        iso_curve_parameter_at(surface, range.0),
1422        iso_curve_parameter_at(surface, range.1),
1423    );
1424    let seam = make_edge_between(model, seam_curve, curve_range, &from, &to, tol)?.shape;
1425
1426    // The walk closes only if the top ring's traversal starts where the
1427    // bottom's ends, and the seam sides sit at the columns the walk visits.
1428    let bottom_end = if rings[0].winding > 0.0 {
1429        ua + span
1430    } else {
1431        ua
1432    };
1433    let other_col = if rings[0].winding > 0.0 {
1434        ua
1435    } else {
1436        ua + span
1437    };
1438    let hi_reversed = (rings[1].winding - rings[0].winding).abs() < 0.5;
1439    let column = |u: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1440        Ok(ogeom_geom::Line2d::over(
1441            ogeom_math::Axis2::new(
1442                ogeom_math::Point2::new(u, 0.0),
1443                ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, 1.0), tol)?,
1444            ),
1445            range.0 - 1.0,
1446            range.1 + 1.0,
1447        )?
1448        .into())
1449    };
1450    // The first seam occurrence in the wire is `up`; the triangulator hands
1451    // a Forward occurrence the `forward` pcurve. `up` is Forward exactly
1452    // when the seam was built upward.
1453    let (forward_col, reversed_col) = if downward {
1454        (other_col, bottom_end)
1455    } else {
1456        (bottom_end, other_col)
1457    };
1458    attach_seam(
1459        model,
1460        &seam,
1461        column(forward_col)?,
1462        column(reversed_col)?,
1463        surface_id,
1464        Location::identity(),
1465        range,
1466    )?;
1467
1468    let up = if downward {
1469        seam.reversed()
1470    } else {
1471        seam.clone()
1472    };
1473    let top = if hi_reversed {
1474        rings[1].edge.reversed()
1475    } else {
1476        rings[1].edge.clone()
1477    };
1478    let ring = vec![rings[0].edge.clone(), up.clone(), top, up.reversed()];
1479    let wire = make_wire(model, &ring, tol)?.shape;
1480    Ok(make_face_on(model, surface_id, &[wire], tol)?.shape)
1481}
1482
1483/// Build the face of a band between two closed rings that need not be
1484/// circles, each with a caller-supplied chart image.
1485///
1486/// [`make_revolution_band`]'s general sibling: where the band derives each
1487/// circle's row and pcurve itself, this takes the rings as they come (a
1488/// fitted tangency curve, a wavy trim), with the pcurve the caller already
1489/// knows for each, same-parameter over the edge's own range. The seam stays
1490/// an exact iso-column of the surface, which is what routes around the
1491/// closed-form refusal in [`make_face_with_pcurves`].
1492///
1493/// The caller's contract: both rings are closed, both chart images run the
1494/// full period *forward* in `u`, and both start on the same column; the
1495/// seam runs there, between the two start vertices. One ring may be a pole:
1496/// a degenerate edge whose image is the pole's row over `(0, period)`.
1497///
1498/// # Errors
1499///
1500/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a
1501/// ring is not closed, the images disagree about the start column or do not
1502/// run the period forward, or the surface's iso-curve has no closed form.
1503pub fn make_band_between(
1504    model: &mut Model,
1505    surface: &SurfaceGeometry,
1506    rings: [(&Shape, ogeom_geom::PlanarCurve); 2],
1507    tol: Tolerances,
1508) -> OgeomResult<Shape> {
1509    use ogeom_geom::Curve2d as _;
1510    use ogeom_geom::Surface as _;
1511
1512    let surface_id = model.geometry_mut().add_surface(surface.clone());
1513    let ((ua_dom, ub_dom), _) = surface.domain();
1514    let span = ub_dom - ua_dom;
1515
1516    struct Ring {
1517        edge: Shape,
1518        vertex: Shape,
1519        crange: (f64, f64),
1520        pcurve: ogeom_geom::PlanarCurve,
1521        start: ogeom_math::Point2,
1522    }
1523    let mut prepared: Vec<Ring> = Vec::with_capacity(2);
1524    for (used, pcurve) in rings {
1525        let edge = if used.orientation() == ogeom_topo::Orientation::Reversed {
1526            used.reversed()
1527        } else {
1528            used.clone()
1529        };
1530        let Some((vertex, other)) = edge_vertices(model, &edge)? else {
1531            ogeom_bail!(Construction, "a band ring has no vertex");
1532        };
1533        if !vertex.is_same(&other) {
1534            ogeom_bail!(Construction, "a band ring is not closed");
1535        }
1536        let crange = {
1537            let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1538                ogeom_bail!(Construction, "a band ring holds no edge data");
1539            };
1540            // A pole: no curve, and its chart image the pole's row run over
1541            // one period.
1542            if data.degenerate {
1543                (0.0, span)
1544            } else {
1545                let Some(EdgeRepr::Curve3d { range, .. }) = data.curve3d() else {
1546                    ogeom_bail!(Construction, "a band ring has no curve");
1547                };
1548                *range
1549            }
1550        };
1551        let start = pcurve.point_at(crange.0, tol)?;
1552        let end = pcurve.point_at(crange.1, tol)?;
1553        if (end.x - start.x - span).abs() > 1e-3 {
1554            ogeom_bail!(
1555                Construction,
1556                "a band ring's chart image must run the period forward in u"
1557            );
1558        }
1559        prepared.push(Ring {
1560            edge,
1561            vertex,
1562            crange,
1563            pcurve,
1564            start,
1565        });
1566    }
1567    // The connector between the rings' starts. On one column it is the
1568    // surface's own iso; on different columns it is the straight chart
1569    // segment: a ruling, a parallel arc, or a helix, all exact on a
1570    // cylinder, and refused by name elsewhere.
1571    let (start0, start1) = (prepared[0].start, prepared[1].start);
1572    let dcol = start1.x - start0.x;
1573    // The iso is for rings sharing a column to within the confusion
1574    // distance: the offset times the chart's stretch there, since an
1575    // off-column iso misses a start vertex by exactly that. Starts further
1576    // apart take the chart segment, which meets both exactly; a segment
1577    // spanning less than confusion would be an edge with no length.
1578    let stretch = {
1579        let (du, _) = surface.d1_at(start0.x, start0.y, tol)?;
1580        du.magnitude().max(1.0)
1581    };
1582    let (seam, up_is_forward, chart_from, chart_to) = if dcol.abs() * stretch <= tol.confusion() {
1583        let column = start0.x;
1584        let (va, vb) = (start0.y, start1.y);
1585        let Some(seam_curve) = surface_iso_u_curve(surface, column, tol) else {
1586            ogeom_bail!(
1587                Construction,
1588                "the surface's iso-curve has no closed form; no seam can be built"
1589            );
1590        };
1591        let (range, from, to, downward) = if va <= vb {
1592            (
1593                (va, vb),
1594                prepared[0].vertex.clone(),
1595                prepared[1].vertex.clone(),
1596                false,
1597            )
1598        } else {
1599            (
1600                (vb, va),
1601                prepared[1].vertex.clone(),
1602                prepared[0].vertex.clone(),
1603                true,
1604            )
1605        };
1606        let curve_range = (
1607            iso_curve_parameter_at(surface, range.0),
1608            iso_curve_parameter_at(surface, range.1),
1609        );
1610        // A ring fitted through its stations starts a fit error off the
1611        // exact column; its start vertex owns that slop.
1612        for (vertex, at) in [(&from, curve_range.0), (&to, curve_range.1)] {
1613            let Some(data) = model.node(vertex).and_then(|n| n.data().as_vertex()) else {
1614                ogeom_bail!(Construction, "a band ring's start vertex holds no point");
1615            };
1616            let placed = vertex.transform(model.datums())?.apply(data.point);
1617            let miss = seam_curve.point_at(at, tol)?.distance(placed);
1618            if miss > tol.confusion() {
1619                model.widen(vertex, ogeom_core::Tolerance::new(miss * 2.0)?)?;
1620            }
1621        }
1622        let seam = make_edge_between(model, seam_curve, curve_range, &from, &to, tol)?.shape;
1623        let (a, b) = (
1624            ogeom_math::Point2::new(column, range.0),
1625            ogeom_math::Point2::new(column, range.1),
1626        );
1627        (seam, !downward, a, b)
1628    } else if !matches!(surface, SurfaceGeometry::Cylinder(_)) {
1629        // On any other surface the chart segment lifts to a curve with no
1630        // closed form (a loxodrome on a sphere, a skew run on a torus)
1631        // and is fitted through it, at the chart's own arc length so the
1632        // curve and its images share one parameter. The fit's error is the
1633        // vertices' to carry.
1634        let (a, b, from, to, forward) = if dcol > 0.0 {
1635            (
1636                start0,
1637                start1,
1638                prepared[0].vertex.clone(),
1639                prepared[1].vertex.clone(),
1640                true,
1641            )
1642        } else {
1643            (
1644                start1,
1645                start0,
1646                prepared[1].vertex.clone(),
1647                prepared[0].vertex.clone(),
1648                false,
1649            )
1650        };
1651        let length = (b.x - a.x).hypot(b.y - a.y);
1652        const SAMPLES: usize = 64;
1653        let mut params: Vec<f64> = Vec::with_capacity(SAMPLES + 1);
1654        let mut lifted: Vec<Point> = Vec::with_capacity(SAMPLES + 1);
1655        for i in 0..=SAMPLES {
1656            #[allow(clippy::cast_precision_loss)]
1657            let f = i as f64 / SAMPLES as f64;
1658            params.push(length * f);
1659            lifted.push(surface.point_at(a.x + (b.x - a.x) * f, a.y + (b.y - a.y) * f, tol)?);
1660        }
1661        let target = tol.confusion() * 1e3;
1662        let fitted = ogeom_geom::fit::fit_points_at(&params, &lifted, 3, target, tol)?;
1663        if !fitted.met {
1664            ogeom_bail!(
1665                NotDone,
1666                "a band's connector reached {} against a target of {target}",
1667                fitted.error
1668            );
1669        }
1670        let connector: ogeom_geom::Curve = ogeom_geom::Curve::BSpline(fitted.curve);
1671        // Against the vertices' own points, not the lifted chart points: a
1672        // ring's start vertex stands off the host by the ring's own slop
1673        // (a fitted seam a few microns off the surface it trims), and the
1674        // connector, which lies on the host, misses it by that much.
1675        for (vertex, at) in [(&from, 0.0), (&to, length)] {
1676            let Some(data) = model.node(vertex).and_then(|n| n.data().as_vertex()) else {
1677                ogeom_bail!(Construction, "a band ring's start vertex holds no point");
1678            };
1679            let placed = vertex.transform(model.datums())?.apply(data.point);
1680            let miss = connector.point_at(at, tol)?.distance(placed);
1681            if miss > tol.confusion() {
1682                model.widen(vertex, ogeom_core::Tolerance::new(miss * 2.0)?)?;
1683            }
1684        }
1685        let seam = make_edge_between(model, connector, (0.0, length), &from, &to, tol)?.shape;
1686        (seam, forward, a, b)
1687    } else {
1688        let SurfaceGeometry::Cylinder(c) = surface else {
1689            ogeom_bail!(
1690                Construction,
1691                "a band whose rings start on different columns needs a chart \
1692                 connector; the surface above was not a cylinder"
1693            );
1694        };
1695        let cylinder = c.cylinder();
1696        let frame = cylinder.frame();
1697        // The chart segment, parameterized by the angle so the pcurve's
1698        // linear map onto it is exact.
1699        let (a, b, from, to, forward) = if dcol > 0.0 {
1700            (
1701                start0,
1702                start1,
1703                prepared[0].vertex.clone(),
1704                prepared[1].vertex.clone(),
1705                true,
1706            )
1707        } else {
1708            (
1709                start1,
1710                start0,
1711                prepared[1].vertex.clone(),
1712                prepared[0].vertex.clone(),
1713                false,
1714            )
1715        };
1716        let drow = b.y - a.y;
1717        let connector: ogeom_geom::Curve = if drow.abs() <= 1e-9 {
1718            // A parallel: an arc at one height.
1719            let lifted = ogeom_math::Frame::new(
1720                frame.origin() + frame.z().vector() * a.y,
1721                frame.z(),
1722                frame.x(),
1723                tol,
1724            )?;
1725            ogeom_geom::CircleCurve::new(ogeom_math::Circle::new(lifted, cylinder.radius(), tol)?)
1726                .into()
1727        } else {
1728            // A helix through both chart points: the pitch is the slope, and
1729            // the frame rides down so height zero lands where it must.
1730            let pitch = core::f64::consts::TAU * drow / (b.x - a.x);
1731            let dropped = ogeom_math::Frame::new(
1732                frame.origin()
1733                    + frame.z().vector() * (pitch / core::f64::consts::TAU).mul_add(-a.x, a.y),
1734                frame.z(),
1735                frame.x(),
1736                tol,
1737            )?;
1738            ogeom_geom::HelixCurve::over(dropped, cylinder.radius(), pitch, a.x, b.x)?.into()
1739        };
1740        let seam = make_edge_between(model, connector, (a.x, b.x), &from, &to, tol)?.shape;
1741        (seam, forward, a, b)
1742    };
1743
1744    // Both rings run forward, so the walk closes as [lo, up, hi rev, down]:
1745    // the bottom ends at the far column, and the seam's two chart images sit
1746    // one period apart, assigned to whichever occurrence the walk meets
1747    // first.
1748    let segment_line = |shift: f64| -> OgeomResult<ogeom_geom::PlanarCurve> {
1749        let a = ogeom_math::Point2::new(chart_from.x + shift, chart_from.y);
1750        let b = ogeom_math::Point2::new(chart_to.x + shift, chart_to.y);
1751        let d = ogeom_math::Vector2::new(b.x - a.x, b.y - a.y);
1752        let m = d.x.hypot(d.y);
1753        if m <= 1e-12 {
1754            ogeom_bail!(Construction, "the seam's chart image has no length");
1755        }
1756        Ok(ogeom_geom::Line2d::over(
1757            ogeom_math::Axis2::new(a, ogeom_math::Direction2::new(d, tol)?),
1758            0.0,
1759            m,
1760        )?
1761        .into())
1762    };
1763    let (forward_shift, reversed_shift) = if up_is_forward {
1764        (span, 0.0)
1765    } else {
1766        (0.0, span)
1767    };
1768    // The pcurves run over their own arc length, start-for-start with the
1769    // seam's curve range, so that is the window the attachment states.
1770    let seam_length = (chart_to.x - chart_from.x).hypot(chart_to.y - chart_from.y);
1771    attach_seam(
1772        model,
1773        &seam,
1774        segment_line(forward_shift)?,
1775        segment_line(reversed_shift)?,
1776        surface_id,
1777        Location::identity(),
1778        (0.0, seam_length),
1779    )?;
1780    for ring in &prepared {
1781        attach_pcurve(
1782            model,
1783            &ring.edge,
1784            ring.pcurve.clone(),
1785            surface_id,
1786            Location::identity(),
1787            ring.crange,
1788        )?;
1789    }
1790
1791    let up = if up_is_forward {
1792        seam.clone()
1793    } else {
1794        seam.reversed()
1795    };
1796    let walk = vec![
1797        prepared[0].edge.clone(),
1798        up.clone(),
1799        prepared[1].edge.reversed(),
1800        up.reversed(),
1801    ];
1802    let wire = make_wire(model, &walk, tol)?.shape;
1803    Ok(make_face_on(model, surface_id, &[wire], tol)?.shape)
1804}
1805
1806/// Build the face of a revolution cap: one closed ring belting a cone, with
1807/// the apex the file never wrote synthesised as the degenerate ring the band
1808/// needs.
1809///
1810/// A cone face bounded by a single circle has exactly one other boundary the
1811/// geometry permits, the apex, because the region away from the apex is
1812/// unbounded. The apex becomes a vertex and a degenerate edge, and the rest
1813/// is [`make_revolution_band`], one authority for the seam either way.
1814///
1815/// # Errors
1816///
1817/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the surface
1818/// is not a cone, or the ring resists the band construction.
1819pub fn make_apex_band(
1820    model: &mut Model,
1821    surface: &SurfaceGeometry,
1822    ring: &Shape,
1823    tol: Tolerances,
1824) -> OgeomResult<Shape> {
1825    let SurfaceGeometry::Cone(c) = surface else {
1826        ogeom_bail!(
1827            Construction,
1828            "only a cone has the single apex a one-ring cap implies"
1829        );
1830    };
1831    let apex = model.add_vertex(VertexData::new(c.cone().apex()));
1832    let mut data = EdgeData::new();
1833    data.degenerate = true;
1834    let edge = model.add_edge(data, &[apex.clone(), apex])?;
1835    make_revolution_band(model, surface, ring, &edge, tol)
1836}
1837
1838/// The chart coordinates of a point on a periodic analytic surface, by
1839/// closed-form inversion, folded into the surface's own stated window.
1840///
1841/// The band synthesis needs the *row* each ring stands on and the *column*
1842/// the seam anchors to; iterative projection over an imported surface's
1843/// enormous stated extents can converge to a clamped boundary and place a
1844/// seam whole units from the vertex it must meet, so the analytic kinds
1845/// invert exactly instead.
1846fn analytic_chart_of(
1847    surface: &SurfaceGeometry,
1848    p: ogeom_math::Point,
1849) -> Option<ogeom_math::Point2> {
1850    use ogeom_geom::Surface as _;
1851    let tau = core::f64::consts::TAU;
1852    let raw = match surface {
1853        SurfaceGeometry::Cylinder(s) => {
1854            let l = s.cylinder().frame().to_local(p);
1855            ogeom_math::Point2::new(l.y.atan2(l.x), l.z)
1856        }
1857        SurfaceGeometry::Cone(s) => {
1858            let l = s.cone().frame().to_local(p);
1859            ogeom_math::Point2::new(l.y.atan2(l.x), l.z)
1860        }
1861        SurfaceGeometry::Sphere(s) => {
1862            let sphere = s.sphere();
1863            let l = sphere.frame().to_local(p);
1864            let lat = (l.z / sphere.radius()).clamp(-1.0, 1.0).asin();
1865            ogeom_math::Point2::new(l.y.atan2(l.x), lat)
1866        }
1867        SurfaceGeometry::Torus(s) => {
1868            let torus = s.torus();
1869            let l = torus.frame().to_local(p);
1870            let radial = l.x.hypot(l.y) - torus.major_radius();
1871            ogeom_math::Point2::new(l.y.atan2(l.x), l.z.atan2(radial))
1872        }
1873        _ => return None,
1874    };
1875    let ((ua, _), (va, vb)) = surface.domain();
1876    let u = ua + (raw.x - ua).rem_euclid(tau);
1877    let v = if surface.is_periodic_v() {
1878        va + (raw.y - va).rem_euclid(vb - va)
1879    } else {
1880        raw.y
1881    };
1882    Some(ogeom_math::Point2::new(u, v))
1883}
1884
1885/// The revolution axis direction of a periodic analytic surface.
1886fn surface_iso_axis(surface: &ogeom_geom::SurfaceGeometry) -> Option<ogeom_math::Vector> {
1887    match surface {
1888        ogeom_geom::SurfaceGeometry::Cylinder(c) => Some(c.cylinder().frame().z().vector()),
1889        ogeom_geom::SurfaceGeometry::Cone(c) => Some(c.cone().frame().z().vector()),
1890        ogeom_geom::SurfaceGeometry::Torus(t) => Some(t.torus().frame().z().vector()),
1891        ogeom_geom::SurfaceGeometry::Sphere(s) => Some(s.sphere().frame().z().vector()),
1892        _ => None,
1893    }
1894}
1895
1896/// The surface's `u = at` iso-curve, parameterized by `v` exactly.
1897///
1898/// A ruling on a cylinder, a tube circle on a torus: the curve a seam runs
1899/// along. `None` where no closed form exists. Shared by the STEP reader's
1900/// seam synthesis and the healer's ring re-anchoring, so the two cannot
1901/// disagree about what a seam is.
1902pub fn surface_iso_u_curve(
1903    surface: &SurfaceGeometry,
1904    at: f64,
1905    tol: Tolerances,
1906) -> Option<ogeom_geom::Curve> {
1907    match surface {
1908        ogeom_geom::SurfaceGeometry::Cylinder(c) => {
1909            let cylinder = c.cylinder();
1910            let frame = cylinder.frame();
1911            let radial = frame.x().vector() * at.cos() + frame.y().vector() * at.sin();
1912            let location = frame.origin() + radial * cylinder.radius();
1913            let axis = ogeom_math::Axis {
1914                location,
1915                direction: frame.z(),
1916            };
1917            Some(ogeom_geom::LineCurve::new(axis).into())
1918        }
1919        ogeom_geom::SurfaceGeometry::Torus(t) => {
1920            let torus = t.torus();
1921            let frame = torus.frame();
1922            let radial = frame.x().vector() * at.cos() + frame.y().vector() * at.sin();
1923            let centre = frame.origin() + radial * torus.major_radius();
1924            // The tube circle framed so its own angle *is* the surface's v:
1925            // x toward the outer equator, y along the axis, which makes
1926            // z = x cross y the tangential direction.
1927            let circle_frame = ogeom_math::Frame::new(
1928                centre,
1929                ogeom_math::Direction::new(radial.cross(frame.z().vector()), tol).ok()?,
1930                ogeom_math::Direction::new(radial, tol).ok()?,
1931                tol,
1932            )
1933            .ok()?;
1934            let circle = ogeom_math::Circle::new(circle_frame, torus.minor_radius(), tol).ok()?;
1935            Some(ogeom_geom::CircleCurve::new(circle).into())
1936        }
1937        ogeom_geom::SurfaceGeometry::Cone(c) => {
1938            let cone = c.cone();
1939            let frame = cone.frame();
1940            let radial = frame.x().vector() * at.cos() + frame.y().vector() * at.sin();
1941            // The ruling through v = 0, arc-length parameterized: the chart's
1942            // v maps onto it linearly, by t = v / cos(half angle), which the
1943            // seam construction recovers through `iso_curve_parameter_at`.
1944            let location = frame.origin() + radial * cone.radius_at(0.0);
1945            let direction = ogeom_math::Direction::new(
1946                frame.z().vector() + radial * cone.half_angle().tan(),
1947                tol,
1948            )
1949            .ok()?;
1950            Some(
1951                ogeom_geom::LineCurve::new(ogeom_math::Axis {
1952                    location,
1953                    direction,
1954                })
1955                .into(),
1956            )
1957        }
1958        ogeom_geom::SurfaceGeometry::BSpline(b) => {
1959            Some(ogeom_geom::Curve::BSpline(b.iso_u_curve(at, tol).ok()?))
1960        }
1961        ogeom_geom::SurfaceGeometry::Sphere(sp) => {
1962            let sphere = sp.sphere();
1963            let frame = sphere.frame();
1964            let radial = frame.x().vector() * at.cos() + frame.y().vector() * at.sin();
1965            // The meridian framed so its own angle is the latitude exactly:
1966            // x one radius out along the parallel, y toward the north pole.
1967            let circle_frame = ogeom_math::Frame::new(
1968                frame.origin(),
1969                ogeom_math::Direction::new(radial.cross(frame.z().vector()), tol).ok()?,
1970                ogeom_math::Direction::new(radial, tol).ok()?,
1971                tol,
1972            )
1973            .ok()?;
1974            let circle = ogeom_math::Circle::new(circle_frame, sphere.radius(), tol).ok()?;
1975            Some(ogeom_geom::CircleCurve::new(circle).into())
1976        }
1977        _ => None,
1978    }
1979}
1980
1981/// The parameter on a surface's iso-curve that lands at chart row `v`.
1982///
1983/// Identity for the kinds whose iso-curve is parameterized by `v` itself
1984/// (a cylinder ruling, a torus tube circle, a sphere meridian) and the slant
1985/// rescale for a cone, whose ruling is arc-length parameterized while the
1986/// chart's `v` is the height.
1987fn iso_curve_parameter_at(surface: &SurfaceGeometry, v: f64) -> f64 {
1988    match surface {
1989        ogeom_geom::SurfaceGeometry::Cone(c) => v / c.cone().half_angle().cos(),
1990        _ => v,
1991    }
1992}
1993
1994#[cfg(test)]
1995#[allow(clippy::unwrap_used, clippy::expect_used)]
1996mod tests {
1997    use super::*;
1998
1999    /// A face on a fitted surface builds with fitted trims: the closed-form
2000    /// refusal falls back to the projected fit, which is what lets a
2001    /// defeaturing rebuild stand a face on a spline neighbour.
2002    #[test]
2003    fn a_face_on_a_fitted_surface_gains_projected_pcurves() {
2004        let tol = ogeom_core::Tolerances::millimetres();
2005        let mut model = Model::new();
2006        // A gently wavy fitted patch, nothing analytic recognises.
2007        let rows: Vec<Vec<ogeom_math::Point>> = (0..5)
2008            .map(|j| {
2009                (0..5)
2010                    .map(|i| {
2011                        let (x, y) = (f64::from(i) * 2.5, f64::from(j) * 2.5);
2012                        ogeom_math::Point::new(x, y, 0.4 * (x * 0.7).sin() * (y * 0.5).cos())
2013                    })
2014                    .collect()
2015            })
2016            .collect();
2017        let surface: ogeom_geom::SurfaceGeometry =
2018            ogeom_geom::fit::fit_surface_grid(&rows, 3, 1e-6, tol)
2019                .unwrap()
2020                .curve
2021                .into();
2022        // The border iso-curves, straight off the fitted chart.
2023        use ogeom_geom::Surface as _;
2024        let ((ua, ub), (va, vb)) = surface.domain();
2025        let iso = |fixed_u: Option<f64>, fixed_v: Option<f64>| -> Curve {
2026            const N: usize = 33;
2027            let pts: Vec<ogeom_math::Point> = (0..N)
2028                .map(|k| {
2029                    let t = f64::from(u32::try_from(k).unwrap())
2030                        / f64::from(u32::try_from(N - 1).unwrap());
2031                    let (u, v) = match (fixed_u, fixed_v) {
2032                        (Some(u), None) => (u, va + (vb - va) * t),
2033                        (None, Some(v)) => (ua + (ub - ua) * t, v),
2034                        _ => unreachable!(),
2035                    };
2036                    surface.point_at(u, v, tol).unwrap()
2037                })
2038                .collect();
2039            Curve::BSpline(
2040                ogeom_geom::fit::fit_points(&pts, 3, 1e-9, tol)
2041                    .unwrap()
2042                    .curve,
2043            )
2044        };
2045        // Shared corner vertices, widened to absorb the border fits' own
2046        // slack, so the ring connects by node rather than by luck.
2047        let corners: Vec<Shape> = [(ua, va), (ub, va), (ub, vb), (ua, vb)]
2048            .into_iter()
2049            .map(|(u, v)| {
2050                let p = surface.point_at(u, v, tol).unwrap();
2051                let vertex = model.add_vertex(ogeom_topo::VertexData::new(p));
2052                if let Some(node) = model.node_mut(&vertex)
2053                    && let ogeom_topo::NodeData::Vertex(data) = node.data_mut()
2054                {
2055                    data.tolerance = data.tolerance.widen_to(1e-4);
2056                }
2057                vertex
2058            })
2059            .collect();
2060        let ring = [
2061            (iso(None, Some(va)), 0usize, 1usize),
2062            (iso(Some(ub), None), 1, 2),
2063            (iso(None, Some(vb)), 3, 2),
2064            (iso(Some(ua), None), 0, 3),
2065        ];
2066        let edges: Vec<Shape> = ring
2067            .into_iter()
2068            .enumerate()
2069            .map(|(k, (c, from, to))| {
2070                use ogeom_geom::Curve3d as _;
2071                let domain = c.domain();
2072                let edge =
2073                    make_edge_between(&mut model, c, domain, &corners[from], &corners[to], tol)
2074                        .unwrap()
2075                        .shape;
2076                if k >= 2 { edge.reversed() } else { edge }
2077            })
2078            .collect();
2079        let built =
2080            make_face_with_pcurves(&mut model, surface, std::slice::from_ref(&edges), tol).unwrap();
2081        // Every edge carries a pcurve for the face's surface now.
2082        let data = model
2083            .node(&built.shape)
2084            .unwrap()
2085            .data()
2086            .as_face()
2087            .unwrap()
2088            .clone();
2089        for edge in &edges {
2090            let e = model.node(edge).unwrap().data().as_edge().unwrap();
2091            assert!(
2092                e.pcurve_for(data.surface, edge.location()).is_some(),
2093                "a fitted trim was attached"
2094            );
2095        }
2096    }
2097
2098    #[test]
2099    fn a_band_whose_rings_start_apart_gets_a_helical_connector() {
2100        // Two circles at different heights whose parameter origins differ:
2101        // the seam has no single column to run down, so it runs the straight
2102        // chart segment: a helix on the cylinder, exactly.
2103        let mut model = Model::new();
2104        let radius = 2.0;
2105        let cylinder = ogeom_geom::CylinderSurface::new(
2106            ogeom_math::Cylinder::new(Frame::WORLD, radius, T).unwrap(),
2107            (-1.0, 4.0),
2108        )
2109        .unwrap();
2110        let surface: SurfaceGeometry = cylinder.into();
2111
2112        let ring_at = |model: &mut Model, height: f64, phase: f64| {
2113            let x = ogeom_math::Direction::new(
2114                ogeom_math::Vector::new(phase.cos(), phase.sin(), 0.0),
2115                T,
2116            )
2117            .unwrap();
2118            let frame =
2119                Frame::new(Point::new(0.0, 0.0, height), ogeom_math::Direction::Z, x, T).unwrap();
2120            let curve: Curve = CircleCurve::new(Circle::new(frame, radius, T).unwrap()).into();
2121            let domain = curve.domain();
2122            let edge = make_edge(model, curve, domain, T).unwrap().shape;
2123            let pcurve: ogeom_geom::PlanarCurve = ogeom_geom::Line2d::over(
2124                ogeom_math::Axis2::new(
2125                    ogeom_math::Point2::new(phase, height),
2126                    ogeom_math::Direction2::new(ogeom_math::Vector2::new(1.0, 0.0), T).unwrap(),
2127                ),
2128                0.0,
2129                core::f64::consts::TAU,
2130            )
2131            .unwrap()
2132            .into();
2133            (edge, pcurve)
2134        };
2135        let (lo, lo_pcurve) = ring_at(&mut model, 0.0, 0.0);
2136        let (hi, hi_pcurve) = ring_at(&mut model, 2.0, 0.4);
2137
2138        let band = make_band_between(
2139            &mut model,
2140            &surface,
2141            [(&lo, lo_pcurve), (&hi, hi_pcurve)],
2142            T,
2143        )
2144        .unwrap();
2145
2146        assert_eq!(
2147            explore_unique(&model, &band, ShapeType::Edge)
2148                .unwrap()
2149                .len(),
2150            3
2151        );
2152        // The connector's curve is a genuine helix through both starts.
2153        let helix = explore_unique(&model, &band, ShapeType::Edge)
2154            .unwrap()
2155            .into_iter()
2156            .find_map(|e| {
2157                let data = model.node(&e)?.data().as_edge()?;
2158                let ogeom_topo::EdgeRepr::Curve3d { curve, .. } = data.curve3d()? else {
2159                    return None;
2160                };
2161                match model.geometry().curve(*curve)? {
2162                    Curve::Helix(h) => Some(*h),
2163                    _ => None,
2164                }
2165            })
2166            .expect("the connector is a helix");
2167        use ogeom_geom::Curve3d as _;
2168        let at_start = Curve::Helix(helix).point_at(0.0, T).unwrap();
2169        let at_end = Curve::Helix(helix).point_at(0.4, T).unwrap();
2170        assert!(at_start.distance(Point::new(2.0, 0.0, 0.0)) < 1e-9);
2171        assert!(at_end.distance(Point::new(2.0 * 0.4_f64.cos(), 2.0 * 0.4_f64.sin(), 2.0)) < 1e-9);
2172    }
2173
2174    #[test]
2175    fn a_band_between_a_circle_and_a_fitted_wavy_ring_closes_with_its_seam() {
2176        use ogeom_geom::Curve2d as _;
2177        use ogeom_geom::Curve3d as _;
2178        use ogeom_geom::Surface as _;
2179
2180        let mut model = Model::new();
2181        let radius = 2.0;
2182        let cylinder = ogeom_geom::CylinderSurface::new(
2183            ogeom_math::Cylinder::new(Frame::WORLD, radius, T).unwrap(),
2184            (-1.0, 4.0),
2185        )
2186        .unwrap();
2187        let surface: SurfaceGeometry = cylinder.into();
2188
2189        // The lower ring: the exact circle at z = 0, its chart image the row.
2190        let lo_curve: Curve =
2191            CircleCurve::new(Circle::new(Frame::WORLD, radius, T).unwrap()).into();
2192        let lo_domain = lo_curve.domain();
2193        let lo = make_edge(&mut model, lo_curve, lo_domain, T).unwrap().shape;
2194        let lo_pcurve: ogeom_geom::PlanarCurve = ogeom_geom::Line2d::over(
2195            ogeom_math::Axis2::new(
2196                ogeom_math::Point2::new(0.0, 0.0),
2197                ogeom_math::Direction2::new(ogeom_math::Vector2::new(1.0, 0.0), T).unwrap(),
2198            ),
2199            lo_domain.0,
2200            lo_domain.1,
2201        )
2202        .unwrap()
2203        .into();
2204
2205        // The upper ring: a wavy loop on the cylinder, fitted jointly with
2206        // its own chart image so curve and pcurve stay same-parameter.
2207        let tau = core::f64::consts::TAU;
2208        let samples = 96;
2209        let mut points = Vec::with_capacity(samples + 1);
2210        let mut chart = Vec::with_capacity(samples + 1);
2211        for i in 0..=samples {
2212            #[allow(clippy::cast_precision_loss)]
2213            let u = tau * (i as f64) / (samples as f64);
2214            let v = (3.0 * u).sin().mul_add(0.2, 1.5);
2215            points.push(Point::new(radius * u.cos(), radius * u.sin(), v));
2216            chart.push(ogeom_math::Point2::new(u, v));
2217        }
2218        let (fitted, hi_chart, _) =
2219            ogeom_geom::fit::fit_points_joint_closed(&points, &chart, &chart, 3, 1e-4, T).unwrap();
2220        assert!(fitted.met, "the wavy ring fit should meet its tolerance");
2221        let hi_curve: Curve = fitted.curve.into();
2222        let hi_domain = hi_curve.domain();
2223        let hi = make_edge(&mut model, hi_curve.clone(), hi_domain, T)
2224            .unwrap()
2225            .shape;
2226
2227        let band = make_band_between(
2228            &mut model,
2229            &surface,
2230            [(&lo, lo_pcurve), (&hi, hi_chart.into())],
2231            T,
2232        )
2233        .unwrap();
2234
2235        // Four traversals, three distinct edges: two rings and the seam,
2236        // used once per side.
2237        let occurrences =
2238            ogeom_topo::explore(&model, &band, ogeom_topo::Filter::OfType(ShapeType::Edge))
2239                .unwrap();
2240        assert_eq!(occurrences.len(), 4);
2241        assert_eq!(
2242            explore_unique(&model, &band, ShapeType::Edge)
2243                .unwrap()
2244                .len(),
2245            3
2246        );
2247
2248        // The wavy ring's chart image lands on the curve itself: the fit is
2249        // same-parameter, and the surface evaluates the image back onto it.
2250        let hi_pcurve = {
2251            let data = model.node(&hi).unwrap().data().as_edge().unwrap();
2252            let repr = data
2253                .representations
2254                .iter()
2255                .find_map(|r| match r {
2256                    ogeom_topo::EdgeRepr::PCurve { curve, .. } => Some(*curve),
2257                    _ => None,
2258                })
2259                .expect("the ring carries its pcurve");
2260            model.geometry().pcurve(repr).unwrap().clone()
2261        };
2262        for i in 0..8 {
2263            #[allow(clippy::cast_precision_loss)]
2264            let t = hi_domain.0 + (hi_domain.1 - hi_domain.0) * (i as f64) / 8.0;
2265            let uv = hi_pcurve.point_at(t, T).unwrap();
2266            let through_chart = surface.point_at(uv.x, uv.y, T).unwrap();
2267            let direct = hi_curve.point_at(t, T).unwrap();
2268            assert!(
2269                through_chart.distance(direct) < 1e-4,
2270                "same-parameter drift at t = {t}"
2271            );
2272        }
2273    }
2274
2275    use ogeom_geom::{CircleCurve, LineCurve, PlaneSurface};
2276    use ogeom_math::{Circle, Frame, Plane};
2277    use ogeom_topo::explore_unique;
2278
2279    const T: Tolerances = Tolerances::millimetres();
2280
2281    fn segment(a: Point, b: Point) -> Curve {
2282        LineCurve::segment(a, b, T).unwrap().into()
2283    }
2284
2285    /// Four edges forming a closed square in the xy plane.
2286    fn square_edges(model: &mut Model) -> Vec<Shape> {
2287        let corners = [
2288            Point::new(0.0, 0.0, 0.0),
2289            Point::new(1.0, 0.0, 0.0),
2290            Point::new(1.0, 1.0, 0.0),
2291            Point::new(0.0, 1.0, 0.0),
2292        ];
2293        let mut vertices: Vec<Shape> = corners
2294            .iter()
2295            .map(|p| model.add_vertex(VertexData::new(*p)))
2296            .collect();
2297        vertices.push(vertices[0].clone());
2298
2299        (0..4)
2300            .map(|i| {
2301                let curve = segment(corners[i], corners[(i + 1) % 4]);
2302                let length = corners[i].distance(corners[(i + 1) % 4]);
2303                make_edge_between(
2304                    model,
2305                    curve,
2306                    (0.0, length),
2307                    &vertices[i],
2308                    &vertices[i + 1],
2309                    T,
2310                )
2311                .unwrap()
2312                .shape
2313            })
2314            .collect()
2315    }
2316
2317    #[test]
2318    fn an_edge_takes_its_vertices_from_its_own_geometry() {
2319        // Deriving them means the topology and the geometry cannot disagree
2320        // about where the edge starts and stops.
2321        let mut model = Model::new();
2322        let built = make_edge(
2323            &mut model,
2324            segment(Point::ORIGIN, Point::new(3.0, 4.0, 0.0)),
2325            (0.0, 5.0),
2326            T,
2327        )
2328        .unwrap();
2329
2330        let (start, end) = edge_vertices(&model, &built.shape).unwrap().unwrap();
2331        let point_of = |v: &Shape| model.node(v).unwrap().data().as_vertex().unwrap().point;
2332        assert!(point_of(&start).is_equal(Point::ORIGIN, T));
2333        assert!(point_of(&end).is_equal(Point::new(3.0, 4.0, 0.0), T));
2334        assert_eq!(built.history.generated(&built.shape).len(), 2);
2335    }
2336
2337    #[test]
2338    fn a_closed_edge_names_one_vertex_twice() {
2339        // Two coincident vertices would leave the wire looking open at the join,
2340        // and the gap only surfaces when something tries to walk the boundary.
2341        let mut model = Model::new();
2342        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
2343        let built = make_edge(&mut model, circle, (0.0, core::f64::consts::TAU), T).unwrap();
2344
2345        let (start, end) = edge_vertices(&model, &built.shape).unwrap().unwrap();
2346        assert!(start.is_same(&end), "a closed edge starts where it ends");
2347        assert_eq!(
2348            explore_unique(&model, &built.shape, ShapeType::Vertex)
2349                .unwrap()
2350                .len(),
2351            1
2352        );
2353    }
2354
2355    #[test]
2356    fn an_edge_whose_curve_does_not_reach_its_vertices_is_refused() {
2357        // The gap would show up much later, in whatever first walks the
2358        // boundary, with nothing left to say where it came from.
2359        let mut model = Model::new();
2360        let a = model.add_vertex(VertexData::new(Point::ORIGIN));
2361        let b = model.add_vertex(VertexData::new(Point::new(10.0, 0.0, 0.0)));
2362        let curve = segment(Point::ORIGIN, Point::new(5.0, 0.0, 0.0));
2363
2364        assert!(
2365            make_edge_between(&mut model, curve.clone(), (0.0, 5.0), &a, &b, T).is_err(),
2366            "the curve stops half way"
2367        );
2368        let c = model.add_vertex(VertexData::new(Point::new(5.0, 0.0, 0.0)));
2369        assert!(make_edge_between(&mut model, curve, (0.0, 5.0), &a, &c, T).is_ok());
2370    }
2371
2372    #[test]
2373    fn a_vertex_with_a_loose_tolerance_admits_a_curve_that_stops_short() {
2374        // The reach is the vertex's own tolerance, not a fixed epsilon: a
2375        // vertex that has been widened by an earlier repair genuinely does
2376        // occupy that much space.
2377        let mut model = Model::new();
2378        let a = model.add_vertex(VertexData::new(Point::ORIGIN));
2379        let b = model
2380            .add_vertex(VertexData::with_tolerance(Point::new(5.001, 0.0, 0.0), 1e-2).unwrap());
2381        let curve = segment(Point::ORIGIN, Point::new(5.0, 0.0, 0.0));
2382        assert!(make_edge_between(&mut model, curve, (0.0, 5.0), &a, &b, T).is_ok());
2383    }
2384
2385    #[test]
2386    fn edge_vertices_follow_the_direction_of_travel() {
2387        let mut model = Model::new();
2388        let built = make_edge(
2389            &mut model,
2390            segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0)),
2391            (0.0, 1.0),
2392            T,
2393        )
2394        .unwrap();
2395
2396        let (forward_start, forward_end) = edge_vertices(&model, &built.shape).unwrap().unwrap();
2397        let (back_start, back_end) = edge_vertices(&model, &built.shape.reversed())
2398            .unwrap()
2399            .unwrap();
2400
2401        assert!(back_start.is_same(&forward_end));
2402        assert!(back_end.is_same(&forward_start));
2403    }
2404
2405    #[test]
2406    fn a_wire_whose_edges_do_not_meet_is_refused() {
2407        // Model::add_wire cannot catch this: it has no geometry. This is the
2408        // check that keeps a face from being built on a boundary with a gap.
2409        let mut model = Model::new();
2410        let joined = make_edge(
2411            &mut model,
2412            segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0)),
2413            (0.0, 1.0),
2414            T,
2415        )
2416        .unwrap()
2417        .shape;
2418        let apart = make_edge(
2419            &mut model,
2420            segment(Point::new(5.0, 0.0, 0.0), Point::new(6.0, 0.0, 0.0)),
2421            (0.0, 1.0),
2422            T,
2423        )
2424        .unwrap()
2425        .shape;
2426
2427        let err = make_wire(&mut model, &[joined.clone(), apart], T).unwrap_err();
2428        assert!(err.to_string().contains("gap"), "unexpected message: {err}");
2429        assert!(make_wire(&mut model, &[joined], T).is_ok());
2430    }
2431
2432    #[test]
2433    fn a_wire_of_edges_that_meet_is_accepted_and_reported_closed() {
2434        let mut model = Model::new();
2435        let edges = square_edges(&mut model);
2436        let wire = make_wire(&mut model, &edges, T).unwrap();
2437        assert!(is_wire_closed(&model, &wire.shape, T).unwrap());
2438
2439        // The edges are generated into the wire, not consumed by it: an edge
2440        // between two faces belongs to both wires.
2441        for edge in &edges {
2442            assert!(!wire.history.is_deleted(edge));
2443            assert_eq!(wire.history.generated(edge).len(), 1);
2444        }
2445    }
2446
2447    #[test]
2448    fn an_open_wire_is_reported_open() {
2449        let mut model = Model::new();
2450        let edges = square_edges(&mut model);
2451        let open = make_wire(&mut model, &edges[..3], T).unwrap();
2452        assert!(!is_wire_closed(&model, &open.shape, T).unwrap());
2453    }
2454
2455    #[test]
2456    fn a_face_refuses_an_open_boundary() {
2457        let mut model = Model::new();
2458        let edges = square_edges(&mut model);
2459        let open = make_wire(&mut model, &edges[..3], T).unwrap().shape;
2460        let plane: SurfaceGeometry = PlaneSurface::new(Plane::new(Frame::WORLD)).into();
2461
2462        let err = make_face(&mut model, plane.clone(), &[open], T).unwrap_err();
2463        assert!(
2464            err.to_string().contains("open"),
2465            "unexpected message: {err}"
2466        );
2467
2468        let closed = make_wire(&mut model, &edges, T).unwrap().shape;
2469        assert!(make_face(&mut model, plane, &[closed], T).is_ok());
2470    }
2471
2472    #[test]
2473    fn a_natural_face_needs_no_wires_at_all() {
2474        let mut model = Model::new();
2475        let built = make_natural_face(
2476            &mut model,
2477            PlaneSurface::new(Plane::new(Frame::WORLD)).into(),
2478        )
2479        .unwrap();
2480        let node = model.node(&built.shape).unwrap();
2481        assert!(node.data().as_face().unwrap().natural_restriction);
2482        assert!(built.history.is_empty());
2483    }
2484
2485    #[test]
2486    fn a_shell_of_one_face_is_not_closed() {
2487        // A single face has free edges all round, so it bounds nothing.
2488        let mut model = Model::new();
2489        let edges = square_edges(&mut model);
2490        let wire = make_wire(&mut model, &edges, T).unwrap().shape;
2491        let face = make_face(
2492            &mut model,
2493            PlaneSurface::new(Plane::new(Frame::WORLD)).into(),
2494            &[wire],
2495            T,
2496        )
2497        .unwrap()
2498        .shape;
2499        let shell = make_shell(&mut model, &[face]).unwrap();
2500        assert!(!is_shell_closed(&model, &shell.shape).unwrap());
2501    }
2502
2503    #[test]
2504    fn two_faces_sharing_every_edge_form_a_closed_shell() {
2505        // The degenerate closed shell: the same square from both sides. Every
2506        // edge is used exactly twice, which is what closure means.
2507        let mut model = Model::new();
2508        let edges = square_edges(&mut model);
2509        let plane: SurfaceGeometry = PlaneSurface::new(Plane::new(Frame::WORLD)).into();
2510
2511        let front_wire = make_wire(&mut model, &edges, T).unwrap().shape;
2512        let reversed: Vec<Shape> = edges.iter().rev().map(Shape::reversed).collect();
2513        let back_wire = make_wire(&mut model, &reversed, T).unwrap().shape;
2514
2515        let front = make_face(&mut model, plane.clone(), &[front_wire], T)
2516            .unwrap()
2517            .shape;
2518        let back = make_face(&mut model, plane, &[back_wire], T).unwrap().shape;
2519        let shell = make_shell(&mut model, &[front, back]).unwrap().shape;
2520
2521        assert!(is_shell_closed(&model, &shell).unwrap());
2522        assert_eq!(
2523            explore_unique(&model, &shell, ShapeType::Edge)
2524                .unwrap()
2525                .len(),
2526            4
2527        );
2528        assert!(make_solid(&mut model, &[shell]).is_ok());
2529    }
2530
2531    #[test]
2532    fn a_pcurve_can_be_attached_after_the_edge_exists() {
2533        // An edge learns about a face's parameter space when it joins that
2534        // face, not when it is created; it may join several.
2535        let mut model = Model::new();
2536        let edge = make_edge(
2537            &mut model,
2538            segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0)),
2539            (0.0, 1.0),
2540            T,
2541        )
2542        .unwrap()
2543        .shape;
2544
2545        let surface = model
2546            .geometry_mut()
2547            .add_surface(PlaneSurface::new(Plane::new(Frame::WORLD)).into());
2548        let pcurve = ogeom_geom::Line2d::segment(
2549            ogeom_math::Point2::ORIGIN,
2550            ogeom_math::Point2::new(1.0, 0.0),
2551            T,
2552        )
2553        .unwrap()
2554        .into();
2555        attach_pcurve(
2556            &mut model,
2557            &edge,
2558            pcurve,
2559            surface,
2560            Location::identity(),
2561            (0.0, 1.0),
2562        )
2563        .unwrap();
2564
2565        let data = model.node(&edge).unwrap().data().as_edge().unwrap();
2566        assert_eq!(data.representations.len(), 2, "the curve and now a pcurve");
2567        assert!(data.pcurve_on(surface).is_some());
2568        assert!(
2569            !data.same_parameter(),
2570            "the new representation has not been shown to agree with the curve"
2571        );
2572    }
2573
2574    #[test]
2575    fn degenerate_edge_ranges_are_refused() {
2576        let mut model = Model::new();
2577        let curve = segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0));
2578        assert!(make_edge(&mut model, curve.clone(), (1.0, 0.0), T).is_err());
2579        assert!(make_edge(&mut model, curve.clone(), (0.5, 0.5), T).is_err());
2580        assert!(make_edge(&mut model, curve.clone(), (0.0, f64::NAN), T).is_err());
2581        assert!(make_edge(&mut model, curve, (0.0, 1.0), T).is_ok());
2582    }
2583
2584    #[test]
2585    fn builders_reject_children_of_the_wrong_type() {
2586        let mut model = Model::new();
2587        let vertex = model.add_vertex(VertexData::new(Point::ORIGIN));
2588        assert!(make_wire(&mut model, std::slice::from_ref(&vertex), T).is_err());
2589        assert!(make_shell(&mut model, std::slice::from_ref(&vertex)).is_err());
2590        assert!(make_solid(&mut model, &[vertex]).is_err());
2591        assert!(make_wire(&mut model, &[], T).is_err());
2592    }
2593
2594    #[test]
2595    fn an_edges_vertices_record_where_they_came_from() {
2596        // Provenance is what a rebuild matches against; a vertex that does not
2597        // say which edge produced it cannot be found again.
2598        let mut model = Model::new();
2599        model.begin_operation();
2600        let built = make_edge(
2601            &mut model,
2602            segment(Point::ORIGIN, Point::new(1.0, 0.0, 0.0)),
2603            (0.0, 1.0),
2604            T,
2605        )
2606        .unwrap();
2607
2608        let (start, end) = edge_vertices(&model, &built.shape).unwrap().unwrap();
2609        for (vertex, role) in [(start, roles::EDGE_START), (end, roles::EDGE_END)] {
2610            let provenance = model.provenance_of(&vertex).unwrap();
2611            assert!(
2612                matches!(provenance, ogeom_core::Provenance::Derived { role: r, .. } if *r == role),
2613                "expected a derived vertex with role {role:?}, got {provenance:?}"
2614            );
2615        }
2616    }
2617}
2618
2619#[cfg(test)]
2620#[allow(clippy::unwrap_used, clippy::expect_used)]
2621mod polygon_tests {
2622    use super::*;
2623    use ogeom_geom::{CircleCurve, PlaneSurface};
2624    use ogeom_math::{Circle, Frame, Plane};
2625    use ogeom_topo::explore_unique;
2626
2627    const T: Tolerances = Tolerances::millimetres();
2628
2629    fn square() -> Vec<Point> {
2630        vec![
2631            Point::new(0.0, 0.0, 0.0),
2632            Point::new(2.0, 0.0, 0.0),
2633            Point::new(2.0, 2.0, 0.0),
2634            Point::new(0.0, 2.0, 0.0),
2635        ]
2636    }
2637
2638    #[test]
2639    fn a_closed_polygon_names_its_first_vertex_again_rather_than_a_second_one() {
2640        // Two coincident vertices would leave the wire looking open at the
2641        // join, and the gap only surfaces when something walks the boundary.
2642        let mut model = Model::new();
2643        let built = make_polygon(&mut model, &square(), true, T).unwrap();
2644
2645        assert_eq!(
2646            explore_unique(&model, &built.shape, ShapeType::Edge)
2647                .unwrap()
2648                .len(),
2649            4
2650        );
2651        assert_eq!(
2652            explore_unique(&model, &built.shape, ShapeType::Vertex)
2653                .unwrap()
2654                .len(),
2655            4,
2656            "four corners, not five"
2657        );
2658        assert!(is_wire_closed(&model, &built.shape, T).unwrap());
2659    }
2660
2661    #[test]
2662    fn an_open_polygon_is_one_edge_short_and_says_it_is_open() {
2663        let mut model = Model::new();
2664        let built = make_polygon(&mut model, &square(), false, T).unwrap();
2665        assert_eq!(
2666            explore_unique(&model, &built.shape, ShapeType::Edge)
2667                .unwrap()
2668                .len(),
2669            3
2670        );
2671        assert!(!is_wire_closed(&model, &built.shape, T).unwrap());
2672    }
2673
2674    #[test]
2675    fn a_polygon_that_describes_nothing_is_refused() {
2676        let mut model = Model::new();
2677        assert!(make_polygon(&mut model, &[], false, T).is_err());
2678        assert!(make_polygon(&mut model, &square()[..1], false, T).is_err());
2679        assert!(
2680            make_polygon(&mut model, &square()[..2], true, T).is_err(),
2681            "two points do not enclose anything"
2682        );
2683
2684        // A repeated point is a zero-length edge, which is not an edge.
2685        let mut doubled = square();
2686        doubled.insert(2, doubled[1]);
2687        assert!(make_polygon(&mut model, &doubled, true, T).is_err());
2688
2689        // Repeating the first point at the end is refused either way. Closed,
2690        // it would add a zero-length segment; open, it would leave the wire
2691        // with two vertices in one place, which every later boundary walk
2692        // reads as a gap that happens to be zero wide.
2693        let mut wrapped = square();
2694        wrapped.push(wrapped[0]);
2695        for closed in [true, false] {
2696            let err = make_polygon(&mut model, &wrapped, closed, T).unwrap_err();
2697            assert!(
2698                err.to_string().contains("first and last points coincide"),
2699                "unexpected message: {err}"
2700            );
2701        }
2702    }
2703
2704    #[test]
2705    fn a_planar_shape_reports_the_plane_it_lies_in() {
2706        let mut model = Model::new();
2707        let wire = make_polygon(&mut model, &square(), true, T).unwrap().shape;
2708        let plane = find_plane(&model, &wire, T)
2709            .unwrap()
2710            .expect("a flat square");
2711        assert!(
2712            plane.normal().is_parallel(ogeom_math::Direction::Z, T),
2713            "got {:?}",
2714            plane.normal()
2715        );
2716        for p in square() {
2717            assert!(plane.distance_to(p) < 1e-9);
2718        }
2719    }
2720
2721    #[test]
2722    fn a_shape_that_is_not_planar_says_so_rather_than_fitting_one_anyway() {
2723        // Every set of three or more points has a best-fit plane, including a
2724        // set nowhere near one. Returning it would be a confident wrong answer.
2725        let mut model = Model::new();
2726        let mut skew = square();
2727        skew[2] = Point::new(2.0, 2.0, 1.0);
2728        let wire = make_polygon(&mut model, &skew, true, T).unwrap().shape;
2729        assert!(find_plane(&model, &wire, T).unwrap().is_none());
2730    }
2731
2732    #[test]
2733    fn a_curved_edge_is_sampled_along_its_length_not_only_at_its_ends() {
2734        // An arc's endpoints lie in a great many planes the arc itself does
2735        // not. Checking only the vertices would call this shape planar.
2736        let mut model = Model::new();
2737        let circle = Circle::new(
2738            Frame::new(
2739                Point::ORIGIN,
2740                ogeom_math::Direction::Z,
2741                ogeom_math::Direction::X,
2742                T,
2743            )
2744            .unwrap(),
2745            2.0,
2746            T,
2747        )
2748        .unwrap();
2749        let arc = make_edge(
2750            &mut model,
2751            CircleCurve::new(circle).into(),
2752            (0.0, std::f64::consts::PI),
2753            T,
2754        )
2755        .unwrap()
2756        .shape;
2757        // In its own plane, it is planar.
2758        assert!(find_plane(&model, &arc, T).unwrap().is_some());
2759
2760        // The two endpoints alone would admit the plane through them and the
2761        // z axis; the arc does not lie in it, and sampling catches that.
2762        let plane = find_plane(&model, &arc, T).unwrap().unwrap();
2763        assert!(plane.normal().is_parallel(ogeom_math::Direction::Z, T));
2764    }
2765
2766    #[test]
2767    fn a_face_is_planar_when_its_surface_is() {
2768        let mut model = Model::new();
2769        let wire = make_polygon(&mut model, &square(), true, T).unwrap().shape;
2770        let face = make_face(
2771            &mut model,
2772            PlaneSurface::new(Plane::new(Frame::WORLD)).into(),
2773            std::slice::from_ref(&wire),
2774            T,
2775        )
2776        .unwrap()
2777        .shape;
2778        assert!(find_plane(&model, &face, T).unwrap().is_some());
2779
2780        let solid = crate::make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2781            .unwrap()
2782            .shape;
2783        assert!(
2784            find_plane(&model, &solid, T).unwrap().is_none(),
2785            "a box is not planar"
2786        );
2787    }
2788}
2789#[cfg(test)]
2790#[allow(clippy::unwrap_used, clippy::expect_used)]
2791mod compsolid_tests {
2792    use super::*;
2793    use ogeom_geom::PlaneSurface;
2794    use ogeom_math::{Frame, Plane, Point};
2795
2796    const T: Tolerances = Tolerances::millimetres();
2797
2798    /// Two unit cubes glued along one shared face node at `x = 1`.
2799    fn glued_cubes(model: &mut Model) -> (Shape, Shape) {
2800        let a = crate::make_box(model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2801            .unwrap()
2802            .shape;
2803        // The face of `a` at x = 1: the one whose every vertex has x = 1.
2804        let shared = ogeom_topo::explore(model, &a, ogeom_topo::Filter::OfType(ShapeType::Face))
2805            .unwrap()
2806            .into_iter()
2807            .find(|f| {
2808                ogeom_topo::explore(model, f, ogeom_topo::Filter::OfType(ShapeType::Vertex))
2809                    .unwrap()
2810                    .iter()
2811                    .all(|v| {
2812                        let p = model.node(v).unwrap().data().as_vertex().unwrap().point;
2813                        let world = v.transform(model.datums()).unwrap().apply(p);
2814                        (world.x - 1.0).abs() < 1e-9
2815                    })
2816            })
2817            .expect("the box has a face at x = 1");
2818
2819        // The second cube: the shared face seen from the other side, plus
2820        // five new faces over the shared boundary vertices.
2821        let corner = |x: f64, y: f64, z: f64| Point::new(x, y, z);
2822        let quad = |model: &mut Model, points: [Point; 4]| -> Shape {
2823            let wire = crate::make_polygon(model, &points, true, T).unwrap().shape;
2824            let plane = crate::find_plane(model, &wire, T).unwrap().unwrap();
2825            make_face(
2826                model,
2827                PlaneSurface::over(Plane::new(plane.frame()), (-4.0, 4.0), (-4.0, 4.0))
2828                    .unwrap()
2829                    .into(),
2830                std::slice::from_ref(&wire),
2831                T,
2832            )
2833            .unwrap()
2834            .shape
2835        };
2836        let faces = [
2837            quad(
2838                model,
2839                [
2840                    corner(2.0, 0.0, 0.0),
2841                    corner(2.0, 1.0, 0.0),
2842                    corner(2.0, 1.0, 1.0),
2843                    corner(2.0, 0.0, 1.0),
2844                ],
2845            ),
2846            quad(
2847                model,
2848                [
2849                    corner(1.0, 0.0, 0.0),
2850                    corner(2.0, 0.0, 0.0),
2851                    corner(2.0, 0.0, 1.0),
2852                    corner(1.0, 0.0, 1.0),
2853                ],
2854            ),
2855            quad(
2856                model,
2857                [
2858                    corner(1.0, 1.0, 0.0),
2859                    corner(1.0, 1.0, 1.0),
2860                    corner(2.0, 1.0, 1.0),
2861                    corner(2.0, 1.0, 0.0),
2862                ],
2863            ),
2864            quad(
2865                model,
2866                [
2867                    corner(1.0, 0.0, 0.0),
2868                    corner(1.0, 1.0, 0.0),
2869                    corner(2.0, 1.0, 0.0),
2870                    corner(2.0, 0.0, 0.0),
2871                ],
2872            ),
2873            quad(
2874                model,
2875                [
2876                    corner(1.0, 0.0, 1.0),
2877                    corner(2.0, 0.0, 1.0),
2878                    corner(2.0, 1.0, 1.0),
2879                    corner(1.0, 1.0, 1.0),
2880                ],
2881            ),
2882        ];
2883        let mut shell_faces = vec![shared.reversed()];
2884        shell_faces.extend(faces);
2885        let shell = make_shell(model, &shell_faces).unwrap().shape;
2886        let b = make_solid(model, std::slice::from_ref(&shell))
2887            .unwrap()
2888            .shape;
2889        (a, b)
2890    }
2891
2892    #[test]
2893    fn glued_solids_build_a_compsolid_and_loose_ones_are_refused() {
2894        let mut model = Model::new();
2895        let (a, b) = glued_cubes(&mut model);
2896        let built = make_compsolid(&mut model, &[a.clone(), b.clone()]).unwrap();
2897        assert_eq!(model.kind_of(&built.shape).unwrap(), ShapeType::CompSolid);
2898        assert_eq!(
2899            built.history.generated(&a),
2900            std::slice::from_ref(&built.shape)
2901        );
2902
2903        // Two boxes merely sitting apart share nothing and are refused.
2904        let mut model = Model::new();
2905        let a = crate::make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2906            .unwrap()
2907            .shape;
2908        let far = Frame::new(
2909            Point::new(5.0, 0.0, 0.0),
2910            ogeom_math::Direction::Z,
2911            ogeom_math::Direction::X,
2912            T,
2913        )
2914        .unwrap();
2915        let b = crate::make_box(&mut model, far, (1.0, 1.0, 1.0), T)
2916            .unwrap()
2917            .shape;
2918        assert!(make_compsolid(&mut model, &[a.clone(), b]).is_err());
2919        assert!(make_compsolid(&mut model, std::slice::from_ref(&a)).is_err());
2920    }
2921}
2922
2923#[cfg(test)]
2924#[allow(clippy::unwrap_used, clippy::expect_used)]
2925mod band_tests {
2926    use super::*;
2927    use approx::assert_relative_eq;
2928    use ogeom_core::Tolerances;
2929    use ogeom_geom::CircleCurve;
2930    use ogeom_math::{Circle, Cone, Frame, Sphere};
2931
2932    const T: Tolerances = Tolerances::millimetres();
2933    const TAU: f64 = core::f64::consts::TAU;
2934
2935    fn mesh_area(model: &Model, face: &Shape) -> f64 {
2936        let fine = ogeom_mesh::Deflection {
2937            chord: 1e-3,
2938            ..ogeom_mesh::Deflection::default()
2939        };
2940        let mesh = ogeom_mesh::triangulate(model, face, fine, T).unwrap();
2941        mesh.triangles
2942            .iter()
2943            .map(|t| {
2944                let [a, b, c] = t.map(|i| mesh.positions[i as usize]);
2945                (b - a).cross(c - a).magnitude() / 2.0
2946            })
2947            .sum()
2948    }
2949
2950    /// A full circle edge at `frame`'s origin, radius `r`, single vertex.
2951    fn ring(model: &mut Model, frame: Frame, r: f64) -> Shape {
2952        let circle = Circle::new(frame, r, T).unwrap();
2953        let vertex = make_vertex(model, frame.origin() + frame.x() * r).shape;
2954        make_edge_between(
2955            model,
2956            CircleCurve::new(circle).into(),
2957            (0.0, TAU),
2958            &vertex,
2959            &vertex,
2960            T,
2961        )
2962        .unwrap()
2963        .shape
2964    }
2965
2966    #[test]
2967    fn a_cone_cap_takes_its_apex_as_a_degenerate_ring() {
2968        // Half angle 45 degrees, reference radius 2: apex at z = -2, slant
2969        // length 2*sqrt(2), lateral area pi * r * slant.
2970        let mut model = Model::new();
2971        let cone = Cone::new(Frame::WORLD, 2.0, core::f64::consts::FRAC_PI_4, T).unwrap();
2972        let surface: SurfaceGeometry = ogeom_geom::ConeSurface::new(cone, (-3.0, 3.0))
2973            .unwrap()
2974            .into();
2975        let rim = ring(&mut model, Frame::WORLD, 2.0);
2976        let face = crate::make_apex_band(&mut model, &surface, &rim, T).unwrap();
2977        assert_relative_eq!(
2978            mesh_area(&model, &face),
2979            core::f64::consts::PI * 2.0 * 2.0 * core::f64::consts::SQRT_2,
2980            max_relative = 1e-2
2981        );
2982    }
2983
2984    #[test]
2985    fn a_ring_square_to_the_axis_is_not_a_band_ring() {
2986        // A button head: a sphere whose chart runs along z, bounded by two
2987        // circles cut square to the screw along x. Each lies on the sphere
2988        // and is closed, but neither is a parallel; building a band on
2989        // them hands every rim a latitude line it never follows. Refused,
2990        // and the reader's question answers the same.
2991        let mut model = Model::new();
2992        let sphere = ogeom_math::Sphere::centred(Point::ORIGIN, 5.0, T).unwrap();
2993        let surface: SurfaceGeometry = ogeom_geom::SphereSurface::new(sphere).into();
2994        // Rims in planes x = 3 and x = 4: radii 4 and 3, axis along x.
2995        let rim_at = |model: &mut Model, x: f64| {
2996            let frame = Frame::new(
2997                Point::new(x, 0.0, 0.0),
2998                ogeom_math::Direction::X,
2999                ogeom_math::Direction::Z,
3000                T,
3001            )
3002            .unwrap();
3003            ring(model, frame, (25.0 - x * x).sqrt())
3004        };
3005        let lo = rim_at(&mut model, 3.0);
3006        let hi = rim_at(&mut model, 4.0);
3007        assert!(!crate::rings_are_parallels(&model, &surface, &[&lo, &hi], T).unwrap());
3008        assert!(make_revolution_band(&mut model, &surface, &lo, &hi, T).is_err());
3009        // The genuine article still passes: parallels of the same sphere.
3010        let p_lo = ring(
3011            &mut model,
3012            Frame::new(
3013                Point::new(0.0, 0.0, 3.0),
3014                ogeom_math::Direction::Z,
3015                ogeom_math::Direction::X,
3016                T,
3017            )
3018            .unwrap(),
3019            4.0,
3020        );
3021        let p_hi = ring(
3022            &mut model,
3023            Frame::new(
3024                Point::new(0.0, 0.0, 4.0),
3025                ogeom_math::Direction::Z,
3026                ogeom_math::Direction::X,
3027                T,
3028            )
3029            .unwrap(),
3030            3.0,
3031        );
3032        assert!(crate::rings_are_parallels(&model, &surface, &[&p_lo, &p_hi], T).unwrap());
3033    }
3034
3035    #[test]
3036    fn a_degenerate_ring_is_accepted_whichever_side_it_is_passed_on() {
3037        let mut model = Model::new();
3038        let cone = Cone::new(Frame::WORLD, 2.0, core::f64::consts::FRAC_PI_4, T).unwrap();
3039        let surface: SurfaceGeometry = ogeom_geom::ConeSurface::new(cone, (-3.0, 3.0))
3040            .unwrap()
3041            .into();
3042        let rim = ring(&mut model, Frame::WORLD, 2.0);
3043        let apex = make_vertex(&mut model, cone.apex()).shape;
3044        let mut data = EdgeData::new();
3045        data.degenerate = true;
3046        let degenerate = model.add_edge(data, &[apex.clone(), apex]).unwrap();
3047        // Degenerate first: the band swaps it into place rather than asking
3048        // the caller to know which ring is real.
3049        let face = make_revolution_band(&mut model, &surface, &degenerate, &rim, T).unwrap();
3050        assert_relative_eq!(
3051            mesh_area(&model, &face),
3052            core::f64::consts::PI * 2.0 * 2.0 * core::f64::consts::SQRT_2,
3053            max_relative = 1e-2
3054        );
3055    }
3056
3057    #[test]
3058    fn a_sphere_cap_closes_against_its_pole() {
3059        // The equator ring and the north pole: a hemisphere, area 2 pi r^2.
3060        let mut model = Model::new();
3061        let sphere = Sphere::new(Frame::WORLD, 3.0, T).unwrap();
3062        let surface: SurfaceGeometry = ogeom_geom::SphereSurface::new(sphere).into();
3063        let rim = ring(&mut model, Frame::WORLD, 3.0);
3064        let pole = make_vertex(&mut model, Point::new(0.0, 0.0, 3.0)).shape;
3065        let mut data = EdgeData::new();
3066        data.degenerate = true;
3067        let degenerate = model.add_edge(data, &[pole.clone(), pole]).unwrap();
3068        let face = make_revolution_band(&mut model, &surface, &rim, &degenerate, T).unwrap();
3069        assert_relative_eq!(
3070            mesh_area(&model, &face),
3071            2.0 * core::f64::consts::PI * 9.0,
3072            max_relative = 1e-2
3073        );
3074    }
3075
3076    #[test]
3077    fn two_degenerate_rings_bound_nothing_and_are_refused() {
3078        let mut model = Model::new();
3079        let cone = Cone::new(Frame::WORLD, 2.0, core::f64::consts::FRAC_PI_4, T).unwrap();
3080        let surface: SurfaceGeometry = ogeom_geom::ConeSurface::new(cone, (-3.0, 3.0))
3081            .unwrap()
3082            .into();
3083        let apex = make_vertex(&mut model, cone.apex()).shape;
3084        let mut data = EdgeData::new();
3085        data.degenerate = true;
3086        let a = model
3087            .add_edge(data.clone(), &[apex.clone(), apex.clone()])
3088            .unwrap();
3089        let b = model.add_edge(data, &[apex.clone(), apex]).unwrap();
3090        assert!(make_revolution_band(&mut model, &surface, &a, &b, T).is_err());
3091    }
3092
3093    #[test]
3094    fn an_apex_band_needs_a_cone() {
3095        let mut model = Model::new();
3096        let sphere = Sphere::new(Frame::WORLD, 3.0, T).unwrap();
3097        let surface: SurfaceGeometry = ogeom_geom::SphereSurface::new(sphere).into();
3098        let rim = ring(&mut model, Frame::WORLD, 3.0);
3099        assert!(crate::make_apex_band(&mut model, &surface, &rim, T).is_err());
3100    }
3101}