Skip to main content

ogeom_offset/
thick.rs

1//! Offsetting and thickening sheets: a face or an open shell moved along
2//! its own normals, and the solid between a sheet and its offset.
3//!
4//! Every face moves on its own chart: the moved surface answers, at the old
5//! surface's `(u, v)`, the old point moved the distance along the normal
6//! there. An analytic surface has such a parallel in its own family (a
7//! plane translated, a cylinder, cone, sphere or torus with its radius
8//! changed), and it is used once it is measured to agree with the moved
9//! points. Any other surface (a B-spline, a revolution, an extrusion) is
10//! fitted to the moved points at their own parameters, refined until the
11//! fit, measured *between* the fitted points against the exact offset, is
12//! within the approximation tolerance. Where the finest fit still misses
13//! it (the offset of a fitted surface is only as smooth as that surface's
14//! normal), the closest fit is kept if it is within the fit's bound: the
15//! larger of the face's own tolerance and a ten-thousandth of the
16//! distance. The measured deviation widens the moved face's tolerance,
17//! which is where it is reported. Because the chart
18//! is kept, every pcurve of the sheet carries over unchanged, so a trimmed
19//! face, holes and all, is trimmed along the same chart curves as its original.
20//!
21//! Edges and vertices move the same way: each point along the normal of
22//! the faces it bounds. A line or a circle whose move is a translation or
23//! a similarity stays a line or a circle (checked at samples, not assumed);
24//! any other edge is fitted at its own parameters the same way, its bound
25//! the largest of its faces'.
26//! Where two faces meet, both must move the shared boundary to the same
27//! place: faces meeting tangentially do, faces meeting at a crease do not,
28//! and an offset sheet refuses a crease by name rather than tearing or
29//! patching it.
30//!
31//! Thickening builds the sheet moved to both of its sides (or the sheet
32//! itself and one side) and closes the gap along every free edge with the
33//! ruled face between the edge and its offset: a plane along a straight
34//! edge of a constant normal, a cylinder along a circle whose normal runs
35//! along its axis, and otherwise a B-spline fitted to the rulings on the
36//! edge's own parameter, its deviation recorded the same way. The pieces
37//! share their edges by construction, so the shell closes without sewing.
38//!
39//! A thickened sheet joins its faces across a crease with a mitre, as a
40//! solid's walls meet when it is hollowed: in each layer the two faces'
41//! moved surfaces are cut back or run on to where they cross, found beside
42//! each point of the crease in the plane square to it there. A crease is
43//! joined where it runs from one border of the sheet to another, each end
44//! a vertex where it meets one free edge of each of its two faces, and
45//! where those borders and their offsets lie square to it (as the walls of
46//! a profile swept square to its plane do), so the side along each such
47//! border is flat and reaches the mitre. The mitred edges are lines and
48//! circles on analytic faces, each charted exactly on its moved face.
49//!
50//! Refused by name: a distance beyond a face's smallest radius of curvature
51//! on the side it moves to (checked over the face's chart window), a
52//! free-form face whose normal is undefined somewhere the offset needs it,
53//! a crease in an offset sheet and a crease a thickened sheet cannot mitre,
54//! an edge shared by more than two faces, and a thickened sheet whose
55//! layers run into each other.
56
57use ogeom_algo::{
58    Built, History, attach_pcurve, attach_seam, edge_vertices, make_edge_between, make_face_on,
59    make_face_with_pcurves, make_shell, make_solid, make_vertex, make_wire,
60};
61use ogeom_core::FastMap;
62use ogeom_core::{OgeomResult, Tolerance, Tolerances, ogeom_bail};
63use ogeom_geom::{
64    Curve, Curve2d as _, Curve3d as _, CylinderSurface, Line2d, LineCurve, OffsetSurface,
65    PlanarCurve, PlaneSurface, Surface as _, SurfaceGeometry, Transformable as _,
66};
67use ogeom_math::{
68    Axis2, Cylinder, Direction, Direction2, Frame, Point, Point2, Transform, Transform2, Vector,
69    Vector2,
70};
71use ogeom_topo::{
72    EdgeData, EdgeRepr, FaceData, Filter, Location, Model, NodeData, Orientation, Shape, ShapeType,
73    SurfaceId, TShapeId, explore,
74};
75
76/// Offset a sheet (a face or a shell, open or closed) by `distance` along
77/// its normals: each face's surface moves on its own chart, its trim and
78/// its joins to its neighbours carried over one for one, so the history
79/// maps every face, edge and vertex to the one it became.
80///
81/// A face with no parallel in its own family is fitted within the
82/// approximation tolerance where refining the fit reaches it, and
83/// otherwise within the larger of the face's own tolerance and a
84/// ten-thousandth of `distance`; its tolerance (and so its edges' and
85/// vertices') covers the deviation the fit measured.
86///
87/// # Errors
88///
89/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
90/// the shape is not a face or a shell, `distance` moves nothing, the
91/// distance reaches past a face's smallest radius of curvature on the side
92/// it moves to, the faces meet at a crease (the offsets of the two sides
93/// part or cross there), an edge is shared by more than two faces, or a
94/// free-form face has no normal where the offset needs one;
95/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the finest
96/// fit misses that bound.
97pub fn offset_sheet(
98    model: &mut Model,
99    sheet: &Shape,
100    distance: f64,
101    tol: Tolerances,
102) -> OgeomResult<Built> {
103    if !distance.is_finite() || distance.abs() <= tol.confusion() {
104        ogeom_bail!(Construction, "an offset of {distance} moves nothing");
105    }
106    let read = read_sheet(model, sheet, tol)?;
107    let layer = moved_layer(model, &read, distance, None, tol)?;
108    let shape = if read.root_is_face {
109        layer.faces[0].clone()
110    } else {
111        make_shell(model, &layer.faces)?.shape
112    };
113    // A moved face on a fitted surface carries fitted pcurves, which stand
114    // off its edges between their samples; every edge the offset made
115    // states what its pcurves measure along it.
116    state_new_gaps(model, sheet, &shape, tol)?;
117    let mut history = layer.history;
118    if !read.root_is_face {
119        history.modify(&read.root, shape.clone());
120    }
121    if layer.faces.len() > 1 && !ogeom_algo::check_self_intersection(model, &shape, tol)?.is_empty()
122    {
123        ogeom_bail!(
124            Construction,
125            "the offset sheet runs into itself; the distance is larger than \
126             the room between its faces"
127        );
128    }
129    Ok(Built::new(shape, with_prefix(read.prefix, history)))
130}
131
132/// Thicken a sheet (a face or an open shell) into a solid: the sheet
133/// moved `thickness` along its normals (a negative thickness against
134/// them), or by half of it to each side when `both_sides`, the two layers
135/// joined by side faces along the sheet's free edges.
136///
137/// The layers are built as [`offset_sheet`] builds them, so a free-form
138/// face is fitted to the same bound and reports its deviation in its
139/// tolerance. Where two faces meet at a crease, their offsets meet on the
140/// mitre: each layer's faces are cut back or run on to where their moved
141/// surfaces cross, and the borders ending at the crease with them. Each
142/// side face is the ruled face between a free edge's two images: a plane
143/// or a cylinder where the rulings make one on the edge's own parameter,
144/// and otherwise a B-spline fitted to the rulings to the same bound (the
145/// thickness standing for the distance), its deviation recorded the same
146/// way; along a border ending at a crease it is the flat face between the
147/// images.
148///
149/// # Errors
150///
151/// As [`offset_sheet`] but for creases, and if the sheet is closed or has
152/// no free edge (a closed shell bounds a solid already; hollow it with
153/// [`make_thick_solid`](crate::make_thick_solid)), a free edge runs into a
154/// point where its face has no normal, or the layers run into each other.
155/// A crease is refused where it closes on itself, ends where more than
156/// one border of each of its faces meets it, is walked the same way by
157/// both faces, folds its faces back onto each other, or where a border
158/// ending at it is not flat with its offset or does not reach the mitre,
159/// or its mitred edges have no exact chart image on the moved faces.
160pub fn make_thick_sheet(
161    model: &mut Model,
162    sheet: &Shape,
163    thickness: f64,
164    both_sides: bool,
165    tol: Tolerances,
166) -> OgeomResult<Built> {
167    if !thickness.is_finite() || thickness.abs() <= tol.confusion() {
168        ogeom_bail!(Construction, "a thickness of {thickness} holds nothing");
169    }
170    let read = read_sheet(model, sheet, tol)?;
171    if read.faces.iter().any(|f| f.natural) {
172        ogeom_bail!(
173            Construction,
174            "a face with no boundary edges has nothing to close the sides \
175             along; bound it with edges first"
176        );
177    }
178    let free: Vec<usize> = (0..read.edges.len())
179        .filter(|&i| read.edges[i].uses.len() == 1 && !read.edges[i].data.degenerate)
180        .collect();
181    if free.is_empty() {
182        ogeom_bail!(
183            Construction,
184            "the sheet has no free edge; a closed shell is hollowed with \
185             make_thick_solid, not thickened"
186        );
187    }
188    let (lo, hi) = if both_sides {
189        (-thickness.abs() / 2.0, thickness.abs() / 2.0)
190    } else if thickness > 0.0 {
191        (0.0, thickness)
192    } else {
193        (thickness, 0.0)
194    };
195    let creases = creases(&read, lo.abs().max(hi.abs()), tol)?;
196    let lower = moved_layer(model, &read, lo, Some(&creases), tol)?;
197    let upper = moved_layer(model, &read, hi, Some(&creases), tol)?;
198
199    let mut history = History::new();
200    let mut faces: Vec<Shape> = Vec::new();
201    for (fi, face) in read.faces.iter().enumerate() {
202        // The upper layer looks the way the sheet does; the lower one looks
203        // back into the material.
204        faces.push(upper.faces[fi].clone());
205        faces.push(lower.faces[fi].reversed());
206        history.generate(&face.occurrence, upper.faces[fi].clone());
207        history.generate(&face.occurrence, lower.faces[fi].reversed());
208    }
209    let mut risers: FastMap<TShapeId, Shape> = FastMap::default();
210    for ei in free {
211        let side = if creases.touches(&read.edges[ei]) {
212            flat_side_face(model, &read, ei, (&lower, &upper), &mut risers, tol)?
213        } else {
214            side_face(
215                model,
216                &read,
217                ei,
218                (lo, hi),
219                (&lower, &upper),
220                &mut risers,
221                tol,
222            )?
223        };
224        history.generate(&Shape::of(read.edges[ei].node), side.clone());
225        faces.push(side);
226    }
227    let shell = make_shell(model, &faces)?.shape;
228    if !ogeom_algo::is_shell_closed(model, &shell)? {
229        ogeom_bail!(
230            Construction,
231            "the thickened sheet does not close: an edge bounds the sheet \
232             more than once in a way the side faces cannot follow"
233        );
234    }
235    let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
236    // Crossing layers are asked about first: where they cross, material
237    // lies on both sides of a face, so a solid that runs into itself also
238    // shows faces turned in, and the crossing is the cause.
239    if !ogeom_algo::check_self_intersection(model, &solid, tol)?.is_empty() {
240        ogeom_bail!(
241            Construction,
242            "the thickened sheet runs into itself; the thickness is larger \
243             than the room between its faces"
244        );
245    }
246    if !ogeom_algo::inside_out_faces(model, &solid, tol)?.is_empty() {
247        ogeom_bail!(
248            Construction,
249            "the thickened sheet folds through itself and turns faces inside out"
250        );
251    }
252    if !read.root_is_face {
253        history.generate(&read.root, solid.clone());
254    }
255    // The walls between the layers are ruled between their rims, fitted
256    // where no closed form joins them (an annulus between two circles),
257    // and their pcurves lift to the fit, not to the rim: every edge the
258    // thickening made states what its pcurves measure along it.
259    state_new_gaps(model, sheet, &solid, tol)?;
260    Ok(Built::new(solid, with_prefix(read.prefix, history)))
261}
262
263fn with_prefix(prefix: Option<History>, history: History) -> History {
264    match prefix {
265        Some(prefix) => prefix.then(&history),
266        None => history,
267    }
268}
269
270/// One face of the sheet, as the sheet holds it.
271struct SheetFace {
272    occurrence: Shape,
273    surface_id: SurfaceId,
274    surface: SurfaceGeometry,
275    /// `+1` where the sheet's normal is the surface's own, `-1` against it.
276    sign: f64,
277    natural: bool,
278    /// The face's own tolerance.
279    tolerance: f64,
280    /// The stored wires: each wire's sense and its edges' nodes and senses.
281    wires: Vec<(Orientation, Vec<(TShapeId, Orientation)>)>,
282    /// The region of the chart the face covers.
283    window: ((f64, f64), (f64, f64)),
284}
285
286/// One use of an edge by a face of the sheet.
287struct EdgeUse {
288    face: usize,
289    /// The edge's sense in the bare face.
290    sense: Orientation,
291    /// The pcurve on the face's surface and the range it runs over, if the
292    /// edge carries one.
293    pcurve: Option<(PlanarCurve, (f64, f64))>,
294}
295
296/// One edge of the sheet.
297struct SheetEdge {
298    node: TShapeId,
299    data: EdgeData,
300    /// The curve in space and the edge's range on it; none for a pole.
301    curve: Option<(Curve, (f64, f64))>,
302    /// The start and end vertex nodes.
303    ends: (TShapeId, TShapeId),
304    uses: Vec<EdgeUse>,
305}
306
307/// A sheet read into the pieces the offset walks.
308struct Sheet {
309    root: Shape,
310    root_is_face: bool,
311    faces: Vec<SheetFace>,
312    edges: Vec<SheetEdge>,
313    /// Each vertex node, its point and where it sits in each face's chart.
314    vertices: Vec<SheetVertex>,
315    prefix: Option<History>,
316}
317
318/// A vertex node, its point, and where it sits in the chart of each face
319/// it bounds.
320type SheetVertex = (TShapeId, Point, Seats);
321
322/// Places in face charts: the face's index and the parameters there.
323type Seats = Vec<(usize, Point2)>;
324
325/// The sheet as faces, edges and vertices, every placement baked in.
326fn read_sheet(model: &mut Model, sheet: &Shape, tol: Tolerances) -> OgeomResult<Sheet> {
327    let kind = model.kind_of(sheet)?;
328    match kind {
329        ShapeType::Face | ShapeType::Shell => {}
330        ShapeType::Solid | ShapeType::CompSolid => ogeom_bail!(
331            Construction,
332            "a solid is offset with offset_shape and hollowed with \
333             make_thick_solid; a sheet is a face or a shell"
334        ),
335        other => ogeom_bail!(
336            Construction,
337            "a sheet is a face or a shell, not a {other:?}"
338        ),
339    }
340    let (root, prefix) = if placed(model, sheet)? {
341        baked_sheet(model, sheet, kind, tol)?
342    } else {
343        (sheet.clone(), None)
344    };
345
346    let mut faces: Vec<SheetFace> = Vec::new();
347    let mut seen: Vec<TShapeId> = Vec::new();
348    for occurrence in explore(model, &root, Filter::OfType(ShapeType::Face))? {
349        if seen.contains(&occurrence.node()) {
350            ogeom_bail!(Construction, "the sheet holds one face twice");
351        }
352        seen.push(occurrence.node());
353        let Some(node) = model.node(&occurrence) else {
354            ogeom_bail!(Dangling, "face is not in this model");
355        };
356        let NodeData::Face(data) = node.data() else {
357            ogeom_bail!(Construction, "face node holds no face data");
358        };
359        let Some(surface) = model.geometry().surface(data.surface).cloned() else {
360            ogeom_bail!(Dangling, "face refers to a surface not in this model");
361        };
362        let mut wires = Vec::new();
363        for wire in node.children() {
364            let Some(wire_node) = model.node(wire) else {
365                ogeom_bail!(Dangling, "wire is not in this model");
366            };
367            let edges = wire_node
368                .children()
369                .iter()
370                .map(|e| (e.node(), e.orientation()))
371                .collect();
372            wires.push((wire.orientation(), edges));
373        }
374        faces.push(SheetFace {
375            occurrence: occurrence.clone(),
376            surface_id: data.surface,
377            sign: if occurrence.orientation() == Orientation::Reversed {
378                -1.0
379            } else {
380                1.0
381            },
382            natural: data.natural_restriction || wires.is_empty(),
383            tolerance: data.tolerance.get(),
384            surface,
385            wires,
386            window: ((0.0, 0.0), (0.0, 0.0)),
387        });
388    }
389    if faces.is_empty() {
390        ogeom_bail!(Construction, "the sheet has no faces");
391    }
392
393    let mut edges: Vec<SheetEdge> = Vec::new();
394    let mut edge_index: FastMap<TShapeId, usize> = FastMap::default();
395    for (fi, face) in faces.iter().enumerate() {
396        // Where the face's first ring lies in a periodic chart: the branch
397        // a ring charted here from its curves alone is carried onto.
398        let mut first_ring: Option<(Point2, Point2)> = None;
399        for (wire_sense, list) in &face.wires {
400            let mut ring: Vec<RingUse> = Vec::with_capacity(list.len());
401            for (edge_node, edge_sense) in list {
402                let sense = wire_sense.compose(*edge_sense);
403                let index = if let Some(i) = edge_index.get(edge_node) {
404                    *i
405                } else {
406                    let bare = Shape::of(*edge_node);
407                    let Some(data) = model.node(&bare).and_then(|n| n.data().as_edge()).cloned()
408                    else {
409                        ogeom_bail!(Construction, "edge node holds no edge data");
410                    };
411                    let curve = match data.curve3d() {
412                        Some(EdgeRepr::Curve3d { curve, range, .. }) => {
413                            let Some(geometry) = model.geometry().curve(*curve) else {
414                                ogeom_bail!(Dangling, "curve is not in this model");
415                            };
416                            Some((geometry.clone(), *range))
417                        }
418                        _ if data.degenerate => None,
419                        _ => ogeom_bail!(Construction, "a sheet edge has no curve in space"),
420                    };
421                    let Some((start, end)) = edge_vertices(model, &bare)? else {
422                        ogeom_bail!(Construction, "a sheet edge has no vertices");
423                    };
424                    edges.push(SheetEdge {
425                        node: *edge_node,
426                        data,
427                        curve,
428                        ends: (start.node(), end.node()),
429                        uses: Vec::new(),
430                    });
431                    edge_index.insert(*edge_node, edges.len() - 1);
432                    edges.len() - 1
433                };
434                let edge = &edges[index];
435                let pcurve = match edge.data.pcurve_on(face.surface_id) {
436                    Some(EdgeRepr::PCurve { curve, range, .. }) => model
437                        .geometry()
438                        .pcurve(*curve)
439                        .cloned()
440                        .map(|p| (p, *range)),
441                    Some(EdgeRepr::Seam {
442                        forward,
443                        reversed,
444                        range,
445                        ..
446                    }) => {
447                        let id = if sense == Orientation::Reversed {
448                            *reversed
449                        } else {
450                            *forward
451                        };
452                        model.geometry().pcurve(id).cloned().map(|p| (p, *range))
453                    }
454                    _ => None,
455                };
456                if pcurve.is_none() && edge.data.degenerate {
457                    ogeom_bail!(Construction, "a pole edge has no pcurve to place it");
458                }
459                ring.push(RingUse {
460                    edge: index,
461                    walked: *edge_sense,
462                    sense,
463                    pcurve,
464                    charted: false,
465                });
466            }
467            chart_bare_uses(&edges, face, &mut ring, &mut first_ring, tol)?;
468            for used in ring {
469                edges[used.edge].uses.push(EdgeUse {
470                    face: fi,
471                    sense: used.sense,
472                    pcurve: used.pcurve,
473                });
474            }
475        }
476    }
477    for edge in &edges {
478        let faces_met: ogeom_core::FastSet<usize> = edge.uses.iter().map(|u| u.face).collect();
479        if edge.uses.len() > 2 {
480            ogeom_bail!(
481                Construction,
482                "an edge is shared by more than two faces; the sheet is not a \
483                 surface the offset can move"
484            );
485        }
486        if faces_met.len() == 1 && edge.uses.len() == 2 && edge.uses[0].sense == edge.uses[1].sense
487        {
488            ogeom_bail!(Construction, "a seam is walked the same way twice");
489        }
490    }
491
492    // Each face's window, and each vertex's seat in every chart it is on.
493    let mut vertices: Vec<SheetVertex> = Vec::new();
494    let mut vertex_index: FastMap<TShapeId, usize> = FastMap::default();
495    let mut seat = |model: &Model,
496                    vertex: TShapeId,
497                    face: usize,
498                    uv: Option<Point2>,
499                    surface: &SurfaceGeometry|
500     -> OgeomResult<()> {
501        let index = if let Some(i) = vertex_index.get(&vertex) {
502            *i
503        } else {
504            let Some(data) = model
505                .node(&Shape::of(vertex))
506                .and_then(|n| n.data().as_vertex())
507            else {
508                ogeom_bail!(Construction, "vertex node holds no point");
509            };
510            vertices.push((vertex, data.point, Vec::new()));
511            vertex_index.insert(vertex, vertices.len() - 1);
512            vertices.len() - 1
513        };
514        let uv = match uv {
515            Some(uv) => uv,
516            None => {
517                let found = ogeom_algo::project_on_surface(surface, vertices[index].1, 32, tol)?;
518                Point2::new(found.parameters.0, found.parameters.1)
519            }
520        };
521        vertices[index].2.push((face, uv));
522        Ok(())
523    };
524    let mut boxes: Vec<Option<(Point2, Point2)>> = vec![None; faces.len()];
525    let grow = |boxes: &mut Vec<Option<(Point2, Point2)>>, face: usize, uv: Point2| {
526        boxes[face] = Some(match boxes[face] {
527            None => (uv, uv),
528            Some((lo, hi)) => (
529                Point2::new(lo.x.min(uv.x), lo.y.min(uv.y)),
530                Point2::new(hi.x.max(uv.x), hi.y.max(uv.y)),
531            ),
532        });
533    };
534    for edge in &edges {
535        for used in &edge.uses {
536            let surface = &faces[used.face].surface;
537            let (start_uv, end_uv) = if let Some((p, range)) = &used.pcurve {
538                for k in 0..=24 {
539                    let t = range.0 + (range.1 - range.0) * f64::from(k) / 24.0;
540                    grow(&mut boxes, used.face, p.point_at(t, tol)?);
541                }
542                (
543                    Some(p.point_at(range.0, tol)?),
544                    Some(p.point_at(range.1, tol)?),
545                )
546            } else if let Some((curve, range)) = &edge.curve {
547                for k in 0..=24 {
548                    let t = range.0 + (range.1 - range.0) * f64::from(k) / 24.0;
549                    let found =
550                        ogeom_algo::project_on_surface(surface, curve.point_at(t, tol)?, 32, tol)?;
551                    grow(
552                        &mut boxes,
553                        used.face,
554                        Point2::new(found.parameters.0, found.parameters.1),
555                    );
556                }
557                (None, None)
558            } else {
559                (None, None)
560            };
561            seat(model, edge.ends.0, used.face, start_uv, surface)?;
562            seat(model, edge.ends.1, used.face, end_uv, surface)?;
563        }
564    }
565    for (fi, face) in faces.iter_mut().enumerate() {
566        let ((ua, ub), (va, vb)) = face.surface.domain();
567        face.window = match boxes[fi] {
568            Some((lo, hi)) if !face.natural => {
569                // A pcurve bulges between its samples by a little; the
570                // margin holds it, the surface's own domain caps it where
571                // the surface is not periodic.
572                let mu = (hi.x - lo.x).max(tol.parametric()) * 0.02;
573                let mv = (hi.y - lo.y).max(tol.parametric()) * 0.02;
574                // Not past a pole, a side of the box that is one point: a
575                // chart carried on through the axis of a revolution turns
576                // its normal over there, and no offset follows it.
577                let pole =
578                    |u: Option<f64>, v: Option<f64>| collapsed(&face.surface, u, v, (lo, hi), tol);
579                let margin = |m: f64, side: bool| if side { 0.0 } else { m };
580                let (mut u0, mut u1, mut v0, mut v1) = (
581                    lo.x - margin(mu, pole(Some(lo.x), None)?),
582                    hi.x + margin(mu, pole(Some(hi.x), None)?),
583                    lo.y - margin(mv, pole(None, Some(lo.y))?),
584                    hi.y + margin(mv, pole(None, Some(hi.y))?),
585                );
586                if !face.surface.is_periodic_u() {
587                    u0 = u0.max(ua);
588                    u1 = u1.min(ub);
589                }
590                if !face.surface.is_periodic_v() {
591                    v0 = v0.max(va);
592                    v1 = v1.min(vb);
593                }
594                ((u0, u1), (v0, v1))
595            }
596            _ => ((ua, ub), (va, vb)),
597        };
598        let ((u0, u1), (v0, v1)) = face.window;
599        if ![u0, u1, v0, v1].iter().all(|x| x.is_finite()) || u1 <= u0 || v1 <= v0 {
600            ogeom_bail!(
601                Construction,
602                "a face covers no finite region of its surface's chart"
603            );
604        }
605    }
606    Ok(Sheet {
607        root_is_face: kind == ShapeType::Face,
608        root,
609        faces,
610        edges,
611        vertices,
612        prefix,
613    })
614}
615
616/// One use of an edge by a ring of a face, in the ring's stored order.
617struct RingUse {
618    edge: usize,
619    /// The edge's sense in the ring as stored, which chains the ring.
620    walked: Orientation,
621    /// The edge's sense in the bare face.
622    sense: Orientation,
623    pcurve: Option<(PlanarCurve, (f64, f64))>,
624    /// Whether the pcurve was charted here from the edge's curve.
625    charted: bool,
626}
627
628/// Chart every use of `ring` whose edge carries no pcurve on `face` (a
629/// face built bare, as `make_face` builds it) from the edge's curve: the
630/// closed-form image where the surface has one, and otherwise the
631/// projected fit, whose error the moved edge states once its pcurves are
632/// measured. On a periodic chart each image is carried by whole periods
633/// onto the branch where the ring's walk reaches it, from the previous
634/// use's end; a ring charted entirely here is then carried whole so the
635/// middle of the region it spans lies within half a period of the face's
636/// first ring's, as holes and outer boundary share one branch.
637fn chart_bare_uses(
638    edges: &[SheetEdge],
639    face: &SheetFace,
640    ring: &mut [RingUse],
641    first_ring: &mut Option<(Point2, Point2)>,
642    tol: Tolerances,
643) -> OgeomResult<()> {
644    let surface = &face.surface;
645    let ends = |used: &RingUse| -> OgeomResult<Option<(Point2, Point2)>> {
646        let Some((p, range)) = &used.pcurve else {
647            return Ok(None);
648        };
649        let (a, b) = (p.point_at(range.0, tol)?, p.point_at(range.1, tol)?);
650        Ok(Some(if used.walked == Orientation::Reversed {
651            (b, a)
652        } else {
653            (a, b)
654        }))
655    };
656    let mut previous = match ring.last() {
657        Some(last) => ends(last)?.map(|(_, end)| end),
658        None => None,
659    };
660    let mut all_charted = true;
661    for used in ring.iter_mut() {
662        if used.pcurve.is_none()
663            && let Some((curve, range)) = &edges[used.edge].curve
664        {
665            let image = match ogeom_intersect::exact_pcurve_over(curve, *range, surface, tol) {
666                Some(exact) => exact,
667                None => {
668                    ogeom_algo::pcurve_fit::fit_projected_pcurve(curve, *range, surface, tol)?.0
669                }
670            };
671            let start = if used.walked == Orientation::Reversed {
672                range.1
673            } else {
674                range.0
675            };
676            let image = match previous {
677                Some(near) => on_branch(image, start, near, surface, tol)?,
678                None => image,
679            };
680            used.pcurve = Some((image, *range));
681            used.charted = true;
682        } else {
683            all_charted = false;
684        }
685        if let Some((_, end)) = ends(used)? {
686            previous = Some(end);
687        }
688    }
689    let periodic = surface.is_periodic_u() || surface.is_periodic_v();
690    let mut span: Option<(Point2, Point2)> = None;
691    for used in ring.iter() {
692        if let Some((p, range)) = &used.pcurve {
693            for k in 0..=8 {
694                let t = range.0 + (range.1 - range.0) * f64::from(k) / 8.0;
695                let uv = p.point_at(t, tol)?;
696                span = Some(match span {
697                    None => (uv, uv),
698                    Some((lo, hi)) => (
699                        Point2::new(lo.x.min(uv.x), lo.y.min(uv.y)),
700                        Point2::new(hi.x.max(uv.x), hi.y.max(uv.y)),
701                    ),
702                });
703            }
704        }
705    }
706    let Some(span) = span else {
707        return Ok(());
708    };
709    match *first_ring {
710        None => *first_ring = Some(span),
711        Some((lo, hi)) if periodic && all_charted => {
712            let middle =
713                |(a, b): (Point2, Point2)| Point2::new((a.x + b.x) / 2.0, (a.y + b.y) / 2.0);
714            let (want, have) = (middle((lo, hi)), middle(span));
715            for used in ring.iter_mut().filter(|u| u.charted) {
716                if let Some((p, range)) = used.pcurve.take() {
717                    let at = p.point_at(range.0, tol)?;
718                    let near = Point2::new(at.x + want.x - have.x, at.y + want.y - have.y);
719                    used.pcurve = Some((on_branch(p, range.0, near, surface, tol)?, range));
720                }
721            }
722        }
723        Some(_) => {}
724    }
725    Ok(())
726}
727
728/// Whether anything in the sheet stands away from its node: a placed
729/// occurrence, or geometry stored under a placement.
730fn placed(model: &Model, sheet: &Shape) -> OgeomResult<bool> {
731    for kind in [ShapeType::Vertex, ShapeType::Edge, ShapeType::Face] {
732        for occurrence in explore(model, sheet, Filter::OfType(kind))? {
733            if occurrence.transform(model.datums())?.kind() != ogeom_math::TransformKind::Identity {
734                return Ok(true);
735            }
736            let Some(node) = model.node(&occurrence) else {
737                ogeom_bail!(Dangling, "shape is not in this model");
738            };
739            match node.data() {
740                NodeData::Face(data) if !data.location.is_identity() => return Ok(true),
741                NodeData::Edge(data)
742                    if data
743                        .representations
744                        .iter()
745                        .any(|r| r.location().is_some_and(|l| !l.is_identity())) =>
746                {
747                    return Ok(true);
748                }
749                _ => {}
750            }
751        }
752    }
753    Ok(false)
754}
755
756/// The sheet restated in world coordinates, with the history from the
757/// placed sheet to the restated one.
758fn baked_sheet(
759    model: &mut Model,
760    sheet: &Shape,
761    kind: ShapeType,
762    tol: Tolerances,
763) -> OgeomResult<(Shape, Option<History>)> {
764    let shell = if kind == ShapeType::Face {
765        model.add_shell(std::slice::from_ref(sheet))?
766    } else {
767        sheet.clone()
768    };
769    let holder = model.add_solid(std::slice::from_ref(&shell))?;
770    let baked = ogeom_algo::baked_shape(model, &holder, tol)?;
771    let root = if kind == ShapeType::Face {
772        let faces = explore(model, &baked.shape, Filter::OfType(ShapeType::Face))?;
773        let [face] = faces.as_slice() else {
774            ogeom_bail!(Construction, "a placed face did not restate as one face");
775        };
776        face.clone()
777    } else {
778        let shells = explore(model, &baked.shape, Filter::OfType(ShapeType::Shell))?;
779        let [one] = shells.as_slice() else {
780            ogeom_bail!(Construction, "a placed shell did not restate as one shell");
781        };
782        one.clone()
783    };
784    let mut history = baked.history;
785    if history.modified(sheet).is_empty() {
786        history.modify(sheet, root.clone());
787    }
788    Ok((root, Some(history)))
789}
790
791/// A face's surface moved by the offset.
792struct MovedSurface {
793    id: SurfaceId,
794    geometry: SurfaceGeometry,
795    /// Whether the geometry is the exact parallel (rather than a fit).
796    exact: bool,
797    /// The fit's measured deviation from the exact offset; zero when exact.
798    deviation: f64,
799}
800
801/// The sheet moved by one distance.
802struct Layer {
803    /// Faces as the sheet holds them, index for index.
804    faces: Vec<Shape>,
805    /// Each sheet edge's image, bare and forward.
806    edges: FastMap<TShapeId, Shape>,
807    /// Each sheet vertex's image.
808    vertices: FastMap<TShapeId, (Shape, Point)>,
809    history: History,
810}
811
812/// The sheet moved `distance` along its normals; at zero, a copy on the
813/// same geometry.
814///
815/// With `creases`, the faces either side of each crease meet on the
816/// mitre: the crease's image is where their moved surfaces cross, beside
817/// each point of the crease in the plane square to it there, and the
818/// borders ending at the crease are cut back or carried on to that line.
819/// Without, a crease is refused.
820fn moved_layer(
821    model: &mut Model,
822    sheet: &Sheet,
823    distance: f64,
824    creases: Option<&Creases>,
825    tol: Tolerances,
826) -> OgeomResult<Layer> {
827    let creases = creases.filter(|c| distance != 0.0 && !c.edges.is_empty());
828    let target = tol.approximation();
829    let mut history = History::new();
830
831    let mut surfaces: Vec<MovedSurface> = Vec::with_capacity(sheet.faces.len());
832    for face in &sheet.faces {
833        let (geometry, exact, deviation) = if distance == 0.0 {
834            (face.surface.clone(), true, 0.0)
835        } else {
836            let bound = fit_bound(face.tolerance, distance, tol);
837            moved_surface(face, distance, (target, bound), tol)?
838        };
839        let id = model.geometry_mut().add_surface(geometry.clone());
840        surfaces.push(MovedSurface {
841            id,
842            geometry,
843            exact,
844            deviation,
845        });
846    }
847
848    // Vertices: every face a vertex bounds must move it to one place.
849    let mut vertices: FastMap<TShapeId, (Shape, Point)> = FastMap::default();
850    let mut vertex_slop: FastMap<TShapeId, f64> = FastMap::default();
851    for (node, point, seats) in &sheet.vertices {
852        if let Some(&(ci, at_end)) = creases.and_then(|c| c.ends.get(node)) {
853            let edge = &sheet.edges[ci];
854            let Some((_, range)) = &edge.curve else {
855                ogeom_bail!(Construction, "a crease has no curve in space");
856            };
857            let t = if at_end { range.1 } else { range.0 };
858            let moved = mitre_at(sheet, &surfaces, edge, t, tol)?;
859            let shape = make_vertex(model, moved).shape;
860            history.modify(&Shape::of(*node), shape.clone());
861            vertex_slop.insert(*node, 0.0);
862            vertices.insert(*node, (shape, moved));
863            continue;
864        }
865        let mut candidates = Vec::with_capacity(seats.len());
866        for (fi, uv) in seats {
867            candidates.push(moved_point(
868                sheet, &surfaces, *fi, *uv, *point, distance, tol,
869            )?);
870        }
871        let (moved, spread) = agreed(&candidates, target, "a vertex")?;
872        let shape = make_vertex(model, moved).shape;
873        history.modify(&Shape::of(*node), shape.clone());
874        vertex_slop.insert(*node, spread);
875        vertices.insert(*node, (shape, moved));
876    }
877
878    // Edges.
879    let mut edges: FastMap<TShapeId, Shape> = FastMap::default();
880    for (ei, edge) in sheet.edges.iter().enumerate() {
881        let is_crease = creases.is_some_and(|c| c.edges.contains(&ei));
882        let cut = creases.map_or([false, false], |c| {
883            [
884                c.ends.contains_key(&edge.ends.0),
885                c.ends.contains_key(&edge.ends.1),
886            ]
887        });
888        let mut recharted: Option<(Curve, (f64, f64))> = None;
889        let (Some((from, from_at)), Some((to, to_at))) = (
890            vertices.get(&edge.ends.0).cloned(),
891            vertices.get(&edge.ends.1).cloned(),
892        ) else {
893            ogeom_bail!(Construction, "an edge end has no moved vertex");
894        };
895        let built = if let Some((curve, range)) = &edge.curve {
896            let off = |t: f64| -> OgeomResult<Point> {
897                let at = curve.point_at(t, tol)?;
898                let mut candidates = Vec::with_capacity(edge.uses.len());
899                for used in &edge.uses {
900                    let uv = seat_on(sheet, edge, used, (at, t, *range), tol)?;
901                    candidates.push(moved_point(
902                        sheet, &surfaces, used.face, uv, at, distance, tol,
903                    )?);
904                }
905                Ok(agreed(&candidates, target, "an edge")?.0)
906            };
907            let mitre = |t: f64| mitre_at(sheet, &surfaces, edge, t, tol);
908            let off: &dyn Fn(f64) -> OgeomResult<Point> = if is_crease { &mitre } else { &off };
909            let (moved, slop) = if distance == 0.0 {
910                (curve.clone(), 0.0)
911            } else if let Some(exact) = exact_moved_curve(curve, *range, off, tol)? {
912                (exact, 0.0)
913            } else {
914                let bound = edge
915                    .uses
916                    .iter()
917                    .map(|u| fit_bound(sheet.faces[u.face].tolerance, distance, tol))
918                    .fold(target, f64::max);
919                fitted_moved_curve(*range, off, (target, bound), tol)?
920            };
921            // A border ending at a crease runs to the crease's image.
922            let (moved, range) = if !is_crease && cut.contains(&true) {
923                let ends = [cut[0].then_some(from_at), cut[1].then_some(to_at)];
924                retrimmed(&moved, *range, ends, tol)?
925            } else {
926                (moved, *range)
927            };
928            if is_crease || cut.contains(&true) {
929                recharted = Some((moved.clone(), range));
930            }
931            // The curve's ends must reach the vertices: within the slop of
932            // the fit and the spread the vertex's faces agreed within.
933            for (t, (vertex, at)) in [(range.0, (&from, from_at)), (range.1, (&to, to_at))] {
934                let gap = moved.point_at(t, tol)?.distance(at);
935                if gap > tol.confusion() {
936                    model.widen(vertex, Tolerance::new(gap + tol.confusion())?)?;
937                }
938            }
939            let shape = make_edge_between(model, moved, range, &from, &to, tol)?.shape;
940            if slop > 0.0 {
941                model.widen(&shape, Tolerance::new(slop + tol.confusion())?)?;
942            }
943            shape
944        } else {
945            let mut data = EdgeData::new();
946            data.degenerate = true;
947            model.add_edge(data, &[from.clone(), to.clone()])?
948        };
949        for end in [edge.ends.0, edge.ends.1] {
950            let slop = vertex_slop.get(&end).copied().unwrap_or(0.0);
951            if slop > tol.confusion() {
952                model.widen(&vertices[&end].0, Tolerance::new(slop + tol.confusion())?)?;
953            }
954        }
955        // An edge the mitre moved is charted afresh on each moved surface.
956        if let Some((curve, range)) = &recharted {
957            for used in &edge.uses {
958                let surface = &surfaces[used.face].geometry;
959                let Some(image) = ogeom_intersect::exact_pcurve_over(curve, *range, surface, tol)
960                else {
961                    ogeom_bail!(
962                        Construction,
963                        "the sheet's faces meet at a crease whose mitre has no exact \
964                         image on a moved face; a crease is joined between planes, \
965                         cylinders, cones, spheres and tori bounded by lines and circles"
966                    );
967                };
968                let near = sheet
969                    .vertices
970                    .iter()
971                    .find(|(node, _, _)| *node == edge.ends.0)
972                    .and_then(|(_, _, seats)| seats.iter().find(|(f, _)| *f == used.face))
973                    .map(|(_, uv)| *uv);
974                let image = match near {
975                    Some(near) => on_branch(image, range.0, near, surface, tol)?,
976                    None => image,
977                };
978                let sid = surfaces[used.face].id;
979                attach_pcurve(model, &built, image, sid, Location::identity(), *range)?;
980            }
981            model.widen(&built, edge.data.tolerance)?;
982            same_parameter(model, &built, tol)?;
983            history.modify(&Shape::of(edge.node), built.clone());
984            edges.insert(edge.node, built);
985            continue;
986        }
987        // The pcurves carry over: the moved surface keeps the chart.
988        let mut done: Vec<SurfaceId> = Vec::new();
989        for used in &edge.uses {
990            let sid = surfaces[used.face].id;
991            if done.contains(&sid) {
992                continue;
993            }
994            done.push(sid);
995            let old = sheet.faces[used.face].surface_id;
996            match edge.data.pcurve_on(old) {
997                Some(EdgeRepr::PCurve { curve, range, .. }) => {
998                    let Some(p) = model.geometry().pcurve(*curve).cloned() else {
999                        ogeom_bail!(Dangling, "pcurve is not in this model");
1000                    };
1001                    attach_pcurve(model, &built, p, sid, Location::identity(), *range)?;
1002                }
1003                Some(EdgeRepr::Seam {
1004                    forward,
1005                    reversed,
1006                    range,
1007                    ..
1008                }) => {
1009                    let (Some(f), Some(r)) = (
1010                        model.geometry().pcurve(*forward).cloned(),
1011                        model.geometry().pcurve(*reversed).cloned(),
1012                    ) else {
1013                        ogeom_bail!(Dangling, "pcurve is not in this model");
1014                    };
1015                    attach_seam(model, &built, f, r, sid, Location::identity(), *range)?;
1016                }
1017                // Charted from the edge's curve as the sheet was read: one
1018                // image, or a seam's two, one for each sense it is walked.
1019                _ => {
1020                    let here: Vec<&EdgeUse> = edge
1021                        .uses
1022                        .iter()
1023                        .filter(|u| surfaces[u.face].id == sid)
1024                        .collect();
1025                    let image = |sense: Orientation| {
1026                        here.iter()
1027                            .find(|u| u.sense == sense)
1028                            .and_then(|u| u.pcurve.clone())
1029                    };
1030                    let seam = here.len() == 2 && here[0].face == here[1].face;
1031                    match (
1032                        seam,
1033                        image(Orientation::Forward),
1034                        image(Orientation::Reversed),
1035                    ) {
1036                        (true, Some((f, range)), Some((r, _))) => {
1037                            attach_seam(model, &built, f, r, sid, Location::identity(), range)?;
1038                        }
1039                        (_, Some((p, range)), _) | (_, None, Some((p, range))) => {
1040                            attach_pcurve(model, &built, p, sid, Location::identity(), range)?;
1041                        }
1042                        _ => {}
1043                    }
1044                }
1045            }
1046        }
1047        // The sheet's own slop moves with it.
1048        model.widen(&built, edge.data.tolerance)?;
1049        same_parameter(model, &built, tol)?;
1050        history.modify(&Shape::of(edge.node), built.clone());
1051        edges.insert(edge.node, built);
1052    }
1053
1054    // Faces, mirroring the sheet's own structure.
1055    let mut faces: Vec<Shape> = Vec::with_capacity(sheet.faces.len());
1056    for (fi, face) in sheet.faces.iter().enumerate() {
1057        let mut wires = Vec::with_capacity(face.wires.len());
1058        for (wire_sense, list) in &face.wires {
1059            let ring: Vec<Shape> = list
1060                .iter()
1061                .map(|(node, sense)| edges[node].oriented(*sense))
1062                .collect();
1063            wires.push(model.add_wire(&ring)?.oriented(*wire_sense));
1064        }
1065        let data = if face.natural {
1066            FaceData::natural(surfaces[fi].id, Location::identity())
1067        } else {
1068            FaceData::new(surfaces[fi].id, Location::identity())
1069        };
1070        let bare = model.add_face(data, &wires)?;
1071        if surfaces[fi].deviation > 0.0 {
1072            model.widen(
1073                &bare,
1074                Tolerance::new(surfaces[fi].deviation + tol.confusion())?,
1075            )?;
1076        }
1077        let shape = bare.oriented(face.occurrence.orientation());
1078        history.modify(&face.occurrence, shape.clone());
1079        faces.push(shape);
1080    }
1081    Ok(Layer {
1082        faces,
1083        edges,
1084        vertices,
1085        history,
1086    })
1087}
1088
1089/// Where the point `at`, at `t` on the edge's curve over `range`, sits
1090/// in a face's chart: read off the pcurve, its own range matched to the
1091/// curve's end for end, where that lands on the point, and projected
1092/// where it does not (a pcurve parameterized apart from its curve).
1093fn seat_on(
1094    sheet: &Sheet,
1095    edge: &SheetEdge,
1096    used: &EdgeUse,
1097    (at, t, range): (Point, f64, (f64, f64)),
1098    tol: Tolerances,
1099) -> OgeomResult<Point2> {
1100    let surface = &sheet.faces[used.face].surface;
1101    if let Some((p, own)) = &used.pcurve {
1102        let s = if range == *own {
1103            t
1104        } else {
1105            own.0 + (own.1 - own.0) * (t - range.0) / (range.1 - range.0)
1106        };
1107        let uv = p.point_at(s, tol)?;
1108        let reach = edge.data.tolerance.get().max(tol.confusion()) * 10.0;
1109        if surface.point_at(uv.x, uv.y, tol)?.distance(at) <= reach {
1110            return Ok(uv);
1111        }
1112    }
1113    let found = ogeom_algo::project_on_surface(surface, at, 32, tol)?;
1114    Ok(Point2::new(found.parameters.0, found.parameters.1))
1115}
1116
1117/// A point of a face, at `uv` in its chart, moved `distance` along the
1118/// sheet's normal there. Where the normal is undefined (a pole, an apex)
1119/// the exact moved surface still answers at the same parameters; a fitted
1120/// one has nothing to answer with.
1121fn moved_point(
1122    sheet: &Sheet,
1123    surfaces: &[MovedSurface],
1124    face: usize,
1125    uv: Point2,
1126    at: Point,
1127    distance: f64,
1128    tol: Tolerances,
1129) -> OgeomResult<Point> {
1130    if distance == 0.0 {
1131        return Ok(at);
1132    }
1133    if let Some(n) = sheet_normal(&sheet.faces[face], uv, tol) {
1134        let moved = at + n * distance;
1135        // On a parallel collapsed to a point the chart's normal is any
1136        // direction, and may point the moved point away from the exact
1137        // moved surface by as much as twice the distance; that surface
1138        // answers there instead.
1139        if surfaces[face].exact {
1140            let exact = surfaces[face].geometry.point_at(uv.x, uv.y, tol)?;
1141            if exact.distance(moved) > distance.abs() {
1142                return Ok(exact);
1143            }
1144        }
1145        return Ok(moved);
1146    }
1147    if surfaces[face].exact {
1148        return surfaces[face].geometry.point_at(uv.x, uv.y, tol);
1149    }
1150    ogeom_bail!(
1151        Construction,
1152        "a free-form face has no normal at ({}, {}); its offset there is \
1153         not determined",
1154        uv.x,
1155        uv.y
1156    )
1157}
1158
1159/// The sheet's unit normal on a face, or `None` where the chart is
1160/// degenerate and determines none.
1161fn sheet_normal(face: &SheetFace, uv: Point2, tol: Tolerances) -> Option<Vector> {
1162    face.surface
1163        .normal_at(uv.x, uv.y, tol)
1164        .ok()
1165        .map(|n| n.vector() * face.sign)
1166}
1167
1168/// The one place several faces move a shared point to, and how far the
1169/// farthest of them stands from it; refused as a crease when they part by
1170/// more than `target`.
1171fn agreed(candidates: &[Point], target: f64, what: &str) -> OgeomResult<(Point, f64)> {
1172    let Some(first) = candidates.first() else {
1173        ogeom_bail!(Construction, "{what} bounds no face of the sheet");
1174    };
1175    #[allow(clippy::cast_precision_loss, reason = "a handful of faces")]
1176    let count = candidates.len() as f64;
1177    let sum = candidates
1178        .iter()
1179        .fold(Vector::ZERO, |acc, p| acc + (*p - *first));
1180    let mean = *first + sum / count;
1181    let spread = candidates
1182        .iter()
1183        .fold(0.0_f64, |acc, p| acc.max(p.distance(mean)));
1184    if spread > target {
1185        ogeom_bail!(
1186            Construction,
1187            "the sheet's faces meet at a crease: {what} they share moves \
1188             {} apart on the two sides, which the offset cannot join",
1189            2.0 * spread
1190        );
1191    }
1192    Ok((mean, spread))
1193}
1194
1195/// Whether the line of `surface`'s chart at `u` (or at `v`) across the box
1196/// from `lo` to `hi` is one point within the confusion distance: a pole,
1197/// or a parallel so near the axis of a revolution that it is one.
1198fn collapsed(
1199    surface: &SurfaceGeometry,
1200    u: Option<f64>,
1201    v: Option<f64>,
1202    (lo, hi): (Point2, Point2),
1203    tol: Tolerances,
1204) -> OgeomResult<bool> {
1205    let mut points = Vec::with_capacity(9);
1206    for k in 0..=8 {
1207        let t = f64::from(k) / 8.0;
1208        points.push(surface.point_at(
1209            u.unwrap_or(lo.x + (hi.x - lo.x) * t),
1210            v.unwrap_or(lo.y + (hi.y - lo.y) * t),
1211            tol,
1212        )?);
1213    }
1214    let first = points[0];
1215    let sum = points
1216        .iter()
1217        .fold(Vector::ZERO, |acc, p| acc + (*p - first));
1218    let centre = first + sum / 9.0;
1219    Ok(points.iter().all(|p| p.distance(centre) <= tol.confusion()))
1220}
1221
1222/// The sample count along a window or a range when measuring.
1223const PROBES: i32 = 8;
1224
1225/// The share of the offset distance a fit that cannot reach the
1226/// approximation tolerance may miss by.
1227const FIT_SHARE: f64 = 1e-4;
1228
1229/// The most a fitted offset of a face may miss the exact offset by when
1230/// refining cannot bring it within the approximation tolerance: the face's
1231/// own tolerance or a share of the distance, whichever is larger.
1232pub(crate) fn fit_bound(face_tolerance: f64, distance: f64, tol: Tolerances) -> f64 {
1233    tol.approximation()
1234        .max(face_tolerance)
1235        .max(FIT_SHARE * distance.abs())
1236}
1237
1238/// A face's surface moved `distance` along the sheet's normal: the exact
1239/// parallel where one is measured to keep the chart, else a fit refined
1240/// toward `target` and kept within `bound`. Returns the geometry, whether
1241/// it is exact, and the fit's measured deviation.
1242fn moved_surface(
1243    face: &SheetFace,
1244    distance: f64,
1245    (target, bound): (f64, f64),
1246    tol: Tolerances,
1247) -> OgeomResult<(SurfaceGeometry, bool, f64)> {
1248    let along = face.sign * distance;
1249    // An offset surface moves on as its basis offset by the sum.
1250    let (base, carried) = match &face.surface {
1251        SurfaceGeometry::Offset(o) => (o.basis(), o.distance()),
1252        other => (other, 0.0),
1253    };
1254    let total = carried + along;
1255    let ((u0, u1), (v0, v1)) = face.window;
1256    let probe = |i: i32, j: i32| {
1257        (
1258            u0 + (u1 - u0) * f64::from(i) / f64::from(PROBES),
1259            v0 + (v1 - v0) * f64::from(j) / f64::from(PROBES),
1260        )
1261    };
1262
1263    folds_within(base, total, distance, face.window, tol)?;
1264
1265    let moved_at = |u: f64, v: f64| -> OgeomResult<Option<Point>> {
1266        let p = face.surface.point_at(u, v, tol)?;
1267        Ok(face
1268            .surface
1269            .normal_at(u, v, tol)
1270            .ok()
1271            .map(|n| p + n.vector() * along))
1272    };
1273
1274    // The exact parallel, measured against the moved points before use.
1275    if let Some(parallel) = OffsetSurface::new(base.clone(), total)?.analytic(tol)? {
1276        // On a pole the basis normal is any direction the chart happens to
1277        // give, and says nothing of the offset: the window's rows and
1278        // columns that are one point are not measured.
1279        let window = (Point2::new(u0, v0), Point2::new(u1, v1));
1280        let mut pole_columns = Vec::new();
1281        let mut pole_rows = Vec::new();
1282        for k in 0..=PROBES {
1283            let (u, v) = probe(k, k);
1284            pole_columns.push(collapsed(base, Some(u), None, window, tol)?);
1285            pole_rows.push(collapsed(base, None, Some(v), window, tol)?);
1286        }
1287        let mut worst = 0.0_f64;
1288        for i in 0..=PROBES {
1289            for j in 0..=PROBES {
1290                let (u, v) = probe(i, j);
1291                if pole_columns[i as usize] || pole_rows[j as usize] {
1292                    continue;
1293                }
1294                if let Some(want) = moved_at(u, v)? {
1295                    worst = worst.max(parallel.point_at(u, v, tol)?.distance(want));
1296                }
1297            }
1298        }
1299        if worst <= tol.confusion() * 10.0 {
1300            return Ok((parallel, true, 0.0));
1301        }
1302    }
1303
1304    fitted_parallel(
1305        &face.surface,
1306        base,
1307        &moved_at,
1308        face.window,
1309        distance,
1310        (target, bound),
1311        tol,
1312    )
1313}
1314
1315/// Refuse a parallel at signed distance `total` from `base` that folds
1316/// over itself somewhere in `window`: moved toward the side a surface
1317/// curves to by its radius of curvature or more.
1318pub(crate) fn folds_within(
1319    base: &SurfaceGeometry,
1320    total: f64,
1321    distance: f64,
1322    ((u0, u1), (v0, v1)): ((f64, f64), (f64, f64)),
1323    tol: Tolerances,
1324) -> OgeomResult<()> {
1325    let probe = |i: i32, j: i32| {
1326        (
1327            u0 + (u1 - u0) * f64::from(i) / f64::from(PROBES),
1328            v0 + (v1 - v0) * f64::from(j) / f64::from(PROBES),
1329        )
1330    };
1331    for i in 0..=PROBES {
1332        for j in 0..=PROBES {
1333            let (u, v) = probe(i, j);
1334            let Some(curvatures) = principal_curvatures(base, u, v, tol)? else {
1335                continue;
1336            };
1337            for k in curvatures {
1338                if total * k >= 1.0 - 1e-9 {
1339                    ogeom_bail!(
1340                        Construction,
1341                        "an offset of {distance} reaches past the face's \
1342                         radius of curvature of {} at ({u}, {v}); the moved \
1343                         face would fold over itself",
1344                        1.0 / k.abs()
1345                    );
1346                }
1347            }
1348        }
1349    }
1350    Ok(())
1351}
1352
1353/// The parallel of `own` over `window` of its chart, fitted at the
1354/// chart's own parameters to the points `moved_at` gives, refined until the
1355/// points it was not fitted to (the cell centres and edge middles) agree
1356/// within `target`, or the finest net is reached and the closest fit
1357/// stands against `bound`. `base` is the surface whose knots the fit may
1358/// turn at. Returns the fit and its measured deviation.
1359pub(crate) fn fitted_parallel(
1360    own: &SurfaceGeometry,
1361    base: &SurfaceGeometry,
1362    moved_at: &dyn Fn(f64, f64) -> OgeomResult<Option<Point>>,
1363    window: ((f64, f64), (f64, f64)),
1364    distance: f64,
1365    (target, bound): (f64, f64),
1366    tol: Tolerances,
1367) -> OgeomResult<(SurfaceGeometry, bool, f64)> {
1368    let ((u0, u1), (v0, v1)) = window;
1369    let corners = chart_corners(base, window);
1370
1371    // A fit at the chart's own parameters, refined until the points it was
1372    // not fitted to (the cell centres and edge middles) agree too, or the
1373    // finest net is reached and the closest fit stands against the bound.
1374    let mut spans: u32 = 8;
1375    let mut best: Option<(SurfaceGeometry, f64)> = None;
1376    loop {
1377        let n = spans;
1378        let at = |k: u32, lo: f64, hi: f64| lo + (hi - lo) * f64::from(k) / f64::from(2 * n);
1379        let mut fine: Vec<Vec<Point>> = Vec::with_capacity((2 * n + 1) as usize);
1380        for j in 0..=2 * n {
1381            let v = at(j, v0, v1);
1382            let mut row = Vec::with_capacity((2 * n + 1) as usize);
1383            for i in 0..=2 * n {
1384                let u = at(i, u0, u1);
1385                let Some(p) = moved_at(u, v)? else {
1386                    ogeom_bail!(
1387                        Construction,
1388                        "a free-form face has no normal at ({u}, {v}); its \
1389                         offset there is not determined"
1390                    );
1391                };
1392                row.push(p);
1393            }
1394            fine.push(row);
1395        }
1396        let us: Vec<f64> = (0..=n).map(|i| at(2 * i, u0, u1)).collect();
1397        let vs: Vec<f64> = (0..=n).map(|j| at(2 * j, v0, v1)).collect();
1398        let rows: Vec<Vec<Point>> = (0..=n as usize)
1399            .map(|j| (0..=n as usize).map(|i| fine[2 * j][2 * i]).collect())
1400            .collect();
1401        // The chart's corner lines stand in the fit as knots it may turn
1402        // at, once the net is fine enough to hold them.
1403        let held = |kept: &[(f64, usize)]| 4 + 3 * kept.len() <= n as usize + 1;
1404        let kept_u: &[(f64, usize)] = if held(&corners.0) { &corners.0 } else { &[] };
1405        let kept_v: &[(f64, usize)] = if held(&corners.1) { &corners.1 } else { &[] };
1406        let fitted = ogeom_geom::fit::fit_surface_grid_at_with_knots(
1407            &us,
1408            &vs,
1409            &rows,
1410            3,
1411            target * 0.5,
1412            (kept_u, kept_v),
1413            tol,
1414        )?;
1415        let mut surface: SurfaceGeometry = fitted.curve.into();
1416        let mut worst = fitted.error;
1417        for j in 0..=2 * n {
1418            for i in 0..=2 * n {
1419                let want = fine[j as usize][i as usize];
1420                let (u, v) = (at(i, u0, u1), at(j, v0, v1));
1421                worst = worst.max(surface.point_at(u, v, tol)?.distance(want));
1422            }
1423        }
1424        if best.as_ref().is_none_or(|(_, b)| worst < *b) {
1425            best = Some((surface.clone(), worst));
1426        }
1427        let finest = spans >= 128;
1428        if worst > target && finest {
1429            let Some((closest, reached)) = best.take() else {
1430                ogeom_bail!(NotDone, "the offset of a free-form face was not fitted");
1431            };
1432            if reached > bound {
1433                ogeom_bail!(
1434                    NotDone,
1435                    "the offset of a free-form face did not fit within {bound}; \
1436                     the closest fit was {reached} off"
1437                );
1438            }
1439            surface = closest;
1440            worst = reached;
1441        }
1442        if worst <= target || finest {
1443            // The fit must face the way the exact offset does everywhere it
1444            // was measured; a fold between the probes shows up here.
1445            for j in (0..=2 * n).step_by(2) {
1446                for i in (0..=2 * n).step_by(2) {
1447                    let (u, v) = (at(i, u0, u1), at(j, v0, v1));
1448                    let (Ok(a), Ok(b)) = (own.normal_at(u, v, tol), surface.normal_at(u, v, tol))
1449                    else {
1450                        continue;
1451                    };
1452                    if a.vector().dot(b.vector()) <= 0.0 {
1453                        ogeom_bail!(
1454                            Construction,
1455                            "an offset of {distance} turns the face over at \
1456                             ({u}, {v}); it reaches past the face's radius of \
1457                             curvature there"
1458                        );
1459                    }
1460                }
1461            }
1462            return Ok((surface, false, worst));
1463        }
1464        spans *= 2;
1465    }
1466}
1467
1468/// Interior knots, `(parameter, multiplicity)`.
1469type Knots = Vec<(f64, usize)>;
1470
1471/// Where a B-spline chart turns: the interior knots, in `u` and in `v`,
1472/// across which the moved points are only C0, each as a cubic fit's knot
1473/// of full multiplicity. A point of the surface is as smooth as its basis
1474/// is at a knot and its normal one order less, so the moved point is C0
1475/// wherever the surface is C1 or less.
1476fn chart_corners(
1477    surface: &SurfaceGeometry,
1478    ((u0, u1), (v0, v1)): ((f64, f64), (f64, f64)),
1479) -> (Knots, Knots) {
1480    let SurfaceGeometry::BSpline(b) = surface else {
1481        return (Vec::new(), Vec::new());
1482    };
1483    let corners = |knots: &ogeom_math::KnotVector, (lo, hi): (f64, f64)| {
1484        let degree = knots.degree();
1485        knots
1486            .distinct()
1487            .into_iter()
1488            .filter(|&(at, multiplicity)| at > lo && at < hi && multiplicity + 1 >= degree)
1489            .map(|(at, _)| (at, 3))
1490            .collect()
1491    };
1492    (
1493        corners(b.u_knots(), (u0, u1)),
1494        corners(b.v_knots(), (v0, v1)),
1495    )
1496}
1497
1498/// The two principal curvatures at `(u, v)`, signed positive where the
1499/// surface curves toward its own normal; `None` at a point where the chart
1500/// determines no normal. From the two fundamental forms: the curvatures
1501/// are the roots of `k^2 - 2Hk + K`, so no principal direction is needed,
1502/// which at an umbilic (everywhere on a sphere) is not determined.
1503fn principal_curvatures(
1504    surface: &SurfaceGeometry,
1505    u: f64,
1506    v: f64,
1507    tol: Tolerances,
1508) -> OgeomResult<Option<[f64; 2]>> {
1509    if surface.is_degenerate_at(u, v, tol)? {
1510        return Ok(None);
1511    }
1512    let jet = surface.jet_at(u, v, tol)?;
1513    let c = jet.du.cross(jet.dv);
1514    let scale = jet.du.magnitude().max(jet.dv.magnitude());
1515    if c.magnitude() <= tol.angular() * scale * scale {
1516        return Ok(None);
1517    }
1518    let n = c / c.magnitude();
1519    let (e, f, g) = (jet.du.dot(jet.du), jet.du.dot(jet.dv), jet.dv.dot(jet.dv));
1520    let (l, m, nn) = (jet.d2u.dot(n), jet.duv.dot(n), jet.d2v.dot(n));
1521    let det = e.mul_add(g, -f * f);
1522    let gaussian = l.mul_add(nn, -m * m) / det;
1523    let mean = (e * nn - 2.0 * f * m + g * l) / (2.0 * det);
1524    let spread = mean.mul_add(mean, -gaussian).max(0.0).sqrt();
1525    Ok(Some([mean + spread, mean - spread]))
1526}
1527
1528/// A curve moved by `off` that is still a line or a circle: the move a
1529/// translation, or for a circle a similarity about its centre, measured
1530/// against `off` before it is trusted.
1531fn exact_moved_curve(
1532    curve: &Curve,
1533    range: (f64, f64),
1534    off: &dyn Fn(f64) -> OgeomResult<Point>,
1535    tol: Tolerances,
1536) -> OgeomResult<Option<Curve>> {
1537    let start = curve.point_at(range.0, tol)?;
1538    let shift = off(range.0)? - start;
1539    let mut candidates = vec![Transform::translation(shift)];
1540    if let Curve::Circle(c) = curve {
1541        let circle = c.circle();
1542        let radius = circle.radius();
1543        let radial = (start - circle.centre()) / radius;
1544        let axis = circle.frame().z().vector();
1545        let grown = radius + shift.dot(radial);
1546        if grown > tol.confusion() {
1547            candidates.push(
1548                Transform::translation(axis * shift.dot(axis))
1549                    * Transform::scaling(circle.centre(), grown / radius, tol)?,
1550            );
1551        }
1552    }
1553    for candidate in candidates {
1554        let moved = curve.transformed(&candidate, tol)?;
1555        let mut worst = 0.0_f64;
1556        for k in 0..=4 * PROBES {
1557            let t = range.0 + (range.1 - range.0) * f64::from(k) / f64::from(4 * PROBES);
1558            worst = worst.max(moved.point_at(t, tol)?.distance(off(t)?));
1559        }
1560        if worst <= tol.confusion() * 10.0 {
1561            return Ok(Some(moved));
1562        }
1563    }
1564    Ok(None)
1565}
1566
1567/// A curve fitted to `off` at its own parameters, refined until the
1568/// midpoints between the fitted samples agree within `target`, or the
1569/// closest fit kept within `bound` once the finest is reached; the curve
1570/// and the deviation measured.
1571fn fitted_moved_curve(
1572    range: (f64, f64),
1573    off: &dyn Fn(f64) -> OgeomResult<Point>,
1574    (target, bound): (f64, f64),
1575    tol: Tolerances,
1576) -> OgeomResult<(Curve, f64)> {
1577    let mut spans: u32 = 16;
1578    let mut best: Option<(Curve, f64)> = None;
1579    loop {
1580        let at = |k: u32| range.0 + (range.1 - range.0) * f64::from(k) / f64::from(2 * spans);
1581        let mut fine = Vec::with_capacity((2 * spans + 1) as usize);
1582        for k in 0..=2 * spans {
1583            fine.push(off(at(k))?);
1584        }
1585        let params: Vec<f64> = (0..=spans).map(|k| at(2 * k)).collect();
1586        let points: Vec<Point> = (0..=spans as usize).map(|k| fine[2 * k]).collect();
1587        let fitted = ogeom_geom::fit::fit_points_at(&params, &points, 3, target * 0.5, tol)?;
1588        let curve: Curve = fitted.curve.into();
1589        let mut worst = fitted.error;
1590        for k in 0..=2 * spans {
1591            let want = fine[k as usize];
1592            worst = worst.max(curve.point_at(at(k), tol)?.distance(want));
1593        }
1594        if worst <= target {
1595            return Ok((curve, worst));
1596        }
1597        if best.as_ref().is_none_or(|(_, b)| worst < *b) {
1598            best = Some((curve, worst));
1599        }
1600        if spans >= 1024 {
1601            return match best {
1602                Some((closest, reached)) if reached <= bound => Ok((closest, reached)),
1603                Some((_, reached)) => ogeom_bail!(
1604                    NotDone,
1605                    "the offset of an edge did not fit within {bound}; the \
1606                     closest fit was {reached} off"
1607                ),
1608                None => ogeom_bail!(NotDone, "the offset of an edge was not fitted"),
1609            };
1610        }
1611        spans *= 2;
1612    }
1613}
1614
1615/// Measure how far each pcurve of `edge`, read on its surface, strays from
1616/// the edge's curve, widen the edge to hold it, and record the agreement.
1617fn same_parameter(model: &mut Model, edge: &Shape, tol: Tolerances) -> OgeomResult<()> {
1618    let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()).cloned() else {
1619        ogeom_bail!(Construction, "edge node holds no edge data");
1620    };
1621    let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1622        return Ok(());
1623    };
1624    let Some(curve) = model.geometry().curve(*curve).cloned() else {
1625        ogeom_bail!(Dangling, "curve is not in this model");
1626    };
1627    let mut worst = 0.0_f64;
1628    for repr in &data.representations {
1629        let (ids, surface, own) = match repr {
1630            EdgeRepr::PCurve {
1631                curve,
1632                surface,
1633                range,
1634                ..
1635            } => (vec![*curve], *surface, *range),
1636            EdgeRepr::Seam {
1637                forward,
1638                reversed,
1639                surface,
1640                range,
1641                ..
1642            } => (vec![*forward, *reversed], *surface, *range),
1643            _ => continue,
1644        };
1645        // A pcurve over its own range carries the sheet's own parameter
1646        // disagreement over as it was; there is no claim to measure.
1647        if own != *range {
1648            return Ok(());
1649        }
1650        let Some(surface) = model.geometry().surface(surface).cloned() else {
1651            ogeom_bail!(Dangling, "surface is not in this model");
1652        };
1653        for id in ids {
1654            let Some(p) = model.geometry().pcurve(id).cloned() else {
1655                ogeom_bail!(Dangling, "pcurve is not in this model");
1656            };
1657            for k in 0..=4 * PROBES {
1658                let t = range.0 + (range.1 - range.0) * f64::from(k) / f64::from(4 * PROBES);
1659                let uv = p.point_at(t, tol)?;
1660                let on = surface.point_at(uv.x, uv.y, tol)?;
1661                worst = worst.max(on.distance(curve.point_at(t, tol)?));
1662            }
1663        }
1664    }
1665    if worst > tol.confusion() {
1666        model.widen(edge, Tolerance::new(worst + tol.confusion())?)?;
1667    }
1668    if let Some(node) = model.node_mut(edge)
1669        && let NodeData::Edge(data) = node.data_mut()
1670    {
1671        data.assert_same_parameter(true);
1672    }
1673    Ok(())
1674}
1675
1676/// Where the sheet's faces meet at an angle, so that their offsets part
1677/// on one side and cross on the other.
1678#[derive(Default)]
1679struct Creases {
1680    /// The creased edges, by index into the sheet's edges.
1681    edges: Vec<usize>,
1682    /// Each vertex a crease ends at: the crease, and whether the vertex is
1683    /// its end rather than its start.
1684    ends: FastMap<TShapeId, (usize, bool)>,
1685}
1686
1687impl Creases {
1688    /// Whether an edge ends at a crease.
1689    fn touches(&self, edge: &SheetEdge) -> bool {
1690        self.ends.contains_key(&edge.ends.0) || self.ends.contains_key(&edge.ends.1)
1691    }
1692}
1693
1694/// The sheet's creases at an offset reaching `reach`: the edges two faces
1695/// share whose normals part there by more than the faces could agree on.
1696/// Each crease must run between two of the sheet's borders: its ends are
1697/// vertices where it meets one free edge of each of its two faces and
1698/// nothing else, and its faces do not fold back onto each other.
1699fn creases(sheet: &Sheet, reach: f64, tol: Tolerances) -> OgeomResult<Creases> {
1700    let target = tol.approximation();
1701    let mut found = Creases::default();
1702    for (ei, edge) in sheet.edges.iter().enumerate() {
1703        let [a, b] = edge.uses.as_slice() else {
1704            continue;
1705        };
1706        let Some((curve, range)) = &edge.curve else {
1707            continue;
1708        };
1709        if a.face == b.face {
1710            continue;
1711        }
1712        let mut parting = 0.0_f64;
1713        for k in 0..=PROBES {
1714            let t = range.0 + (range.1 - range.0) * f64::from(k) / f64::from(PROBES);
1715            let at = curve.point_at(t, tol)?;
1716            let na = sheet_normal(
1717                &sheet.faces[a.face],
1718                seat_on(sheet, edge, a, (at, t, *range), tol)?,
1719                tol,
1720            );
1721            let nb = sheet_normal(
1722                &sheet.faces[b.face],
1723                seat_on(sheet, edge, b, (at, t, *range), tol)?,
1724                tol,
1725            );
1726            if let (Some(na), Some(nb)) = (na, nb) {
1727                parting = parting.max((na - nb).magnitude());
1728            }
1729        }
1730        if parting * reach / 2.0 > target {
1731            found.edges.push(ei);
1732        }
1733    }
1734    for &ei in &found.edges {
1735        let edge = &sheet.edges[ei];
1736        if edge.ends.0 == edge.ends.1 {
1737            ogeom_bail!(
1738                Construction,
1739                "the sheet's faces meet at a crease that closes on itself; a crease \
1740                 is joined where it runs from one border of the sheet to another"
1741            );
1742        }
1743        let walked = |u: &EdgeUse| {
1744            sheet.faces[u.face]
1745                .occurrence
1746                .orientation()
1747                .compose(u.sense)
1748        };
1749        if walked(&edge.uses[0]) == walked(&edge.uses[1]) {
1750            ogeom_bail!(
1751                Construction,
1752                "the sheet's faces walk a crease the same way, so they disagree on \
1753                 which side of the sheet they face; orient the sheet first"
1754            );
1755        }
1756        let mut sides: Vec<usize> = edge.uses.iter().map(|u| u.face).collect();
1757        sides.sort_unstable();
1758        for (vertex, at_end) in [(edge.ends.0, false), (edge.ends.1, true)] {
1759            let meeting: Vec<&SheetEdge> = sheet
1760                .edges
1761                .iter()
1762                .enumerate()
1763                .filter(|(i, e)| *i != ei && (e.ends.0 == vertex || e.ends.1 == vertex))
1764                .map(|(_, e)| e)
1765                .collect();
1766            let mut faces: Vec<usize> = meeting
1767                .iter()
1768                .filter(|e| e.uses.len() == 1)
1769                .map(|e| e.uses[0].face)
1770                .collect();
1771            faces.sort_unstable();
1772            if meeting.len() != 2 || faces != sides {
1773                ogeom_bail!(
1774                    Construction,
1775                    "the sheet's faces meet at a crease that ends where more than \
1776                     its two faces' borders meet; a crease is joined where it runs \
1777                     from one border of the sheet to another"
1778                );
1779            }
1780            found.ends.insert(vertex, (ei, at_end));
1781        }
1782    }
1783    Ok(found)
1784}
1785
1786/// The mitre beside the point at `t` on a crease: where the moved
1787/// surfaces of the crease's two faces cross in the plane square to the
1788/// crease there.
1789fn mitre_at(
1790    sheet: &Sheet,
1791    surfaces: &[MovedSurface],
1792    edge: &SheetEdge,
1793    t: f64,
1794    tol: Tolerances,
1795) -> OgeomResult<Point> {
1796    let Some((curve, range)) = &edge.curve else {
1797        ogeom_bail!(Construction, "a crease has no curve in space");
1798    };
1799    let [a, b] = edge.uses.as_slice() else {
1800        ogeom_bail!(Construction, "a crease is shared by two faces");
1801    };
1802    let at = curve.point_at(t, tol)?;
1803    let seat_a = seat_on(sheet, edge, a, (at, t, *range), tol)?;
1804    let seat_b = seat_on(sheet, edge, b, (at, t, *range), tol)?;
1805    let (Some(na), Some(nb)) = (
1806        sheet_normal(&sheet.faces[a.face], seat_a, tol),
1807        sheet_normal(&sheet.faces[b.face], seat_b, tol),
1808    ) else {
1809        ogeom_bail!(
1810            Construction,
1811            "a crease runs into a point where a face has no normal; the mitre \
1812             there is not determined"
1813        );
1814    };
1815    if 1.0 + na.dot(nb) <= 1e-6 {
1816        ogeom_bail!(
1817            Construction,
1818            "the sheet folds back onto itself at a crease; the offsets of its \
1819             two faces never meet"
1820        );
1821    }
1822    let along = curve.d1_at(t, tol)?;
1823    let speed = along.magnitude();
1824    if speed <= f64::MIN_POSITIVE {
1825        ogeom_bail!(Construction, "a crease stands still at {at:?}");
1826    }
1827    mitre_point(
1828        (&surfaces[a.face].geometry, &surfaces[b.face].geometry),
1829        (seat_a, seat_b),
1830        at,
1831        along / speed,
1832        tol,
1833    )
1834}
1835
1836/// The point on both surfaces in the plane through `at` square to
1837/// `tangent`, by Newton's method in the two charts from `seats`.
1838fn mitre_point(
1839    (first, second): (&SurfaceGeometry, &SurfaceGeometry),
1840    (seat_a, seat_b): (Point2, Point2),
1841    at: Point,
1842    tangent: Vector,
1843    tol: Tolerances,
1844) -> OgeomResult<Point> {
1845    let mut x = [seat_a.x, seat_a.y, seat_b.x, seat_b.y];
1846    for _ in 0..32 {
1847        let (pa, au, av) = first.point_d1_at(x[0], x[1], tol)?;
1848        let (pb, bu, bv) = second.point_d1_at(x[2], x[3], tol)?;
1849        let gap = pa - pb;
1850        let across = (pa - at).dot(tangent);
1851        if gap.magnitude() <= tol.confusion() * 1e-3 && across.abs() <= tol.confusion() * 1e-3 {
1852            return Ok(pa + (pb - pa) * 0.5);
1853        }
1854        let mut rows = [
1855            [au.x, av.x, -bu.x, -bv.x, -gap.x],
1856            [au.y, av.y, -bu.y, -bv.y, -gap.y],
1857            [au.z, av.z, -bu.z, -bv.z, -gap.z],
1858            [au.dot(tangent), av.dot(tangent), 0.0, 0.0, -across],
1859        ];
1860        let Some(step) = solved(&mut rows) else {
1861            break;
1862        };
1863        for (xi, si) in x.iter_mut().zip(step) {
1864            *xi += si;
1865        }
1866    }
1867    ogeom_bail!(
1868        NotDone,
1869        "the offsets of the faces at a crease were not found to meet beside {at:?}"
1870    )
1871}
1872
1873/// The solution of four linear equations, each row its coefficients and
1874/// right-hand side, by elimination with partial pivoting; `None` where
1875/// they are singular.
1876fn solved(rows: &mut [[f64; 5]; 4]) -> Option<[f64; 4]> {
1877    let scale = rows
1878        .iter()
1879        .flat_map(|r| r[..4].iter())
1880        .fold(0.0_f64, |m, v| m.max(v.abs()));
1881    if scale <= f64::MIN_POSITIVE {
1882        return None;
1883    }
1884    for col in 0..4 {
1885        let pivot = (col..4).max_by(|&i, &j| rows[i][col].abs().total_cmp(&rows[j][col].abs()))?;
1886        if rows[pivot][col].abs() <= 1e-14 * scale {
1887            return None;
1888        }
1889        rows.swap(col, pivot);
1890        let lead = rows[col];
1891        for row in rows.iter_mut().skip(col + 1) {
1892            let f = row[col] / lead[col];
1893            for (v, l) in row.iter_mut().zip(lead).skip(col) {
1894                *v -= f * l;
1895            }
1896        }
1897    }
1898    let mut out = [0.0; 4];
1899    for r in (0..4).rev() {
1900        let mut v = rows[r][4];
1901        for (c, known) in out.iter().enumerate().skip(r + 1) {
1902            v -= rows[r][c] * known;
1903        }
1904        out[r] = v / rows[r][r];
1905    }
1906    Some(out)
1907}
1908
1909/// A border's moved curve run to new ends: each end given a point is moved
1910/// to the curve's foot of that point, a line or a circle carried on past
1911/// its old end where the point lies beyond it.
1912fn retrimmed(
1913    curve: &Curve,
1914    range: (f64, f64),
1915    ends: [Option<Point>; 2],
1916    tol: Tolerances,
1917) -> OgeomResult<(Curve, (f64, f64))> {
1918    let mut new = [range.0, range.1];
1919    for (k, end) in ends.iter().enumerate() {
1920        let Some(p) = end else {
1921            continue;
1922        };
1923        let t = match curve {
1924            Curve::Line(line) => {
1925                let axis = line.axis();
1926                (*p - axis.location).dot(axis.direction.vector())
1927            }
1928            _ => {
1929                let mut t = new[k];
1930                for _ in 0..64 {
1931                    let c = curve.point_at(t, tol)?;
1932                    let d = curve.d1_at(t, tol)?;
1933                    let speed = d.dot(d);
1934                    if speed <= f64::MIN_POSITIVE {
1935                        break;
1936                    }
1937                    let step = (*p - c).dot(d) / speed;
1938                    t += step;
1939                    if step.abs() <= 1e-15 * (1.0 + t.abs()) {
1940                        break;
1941                    }
1942                }
1943                t
1944            }
1945        };
1946        let reached = match curve {
1947            Curve::Line(line) => line.axis().point_at(t),
1948            _ => curve.point_at(t, tol)?,
1949        };
1950        if reached.distance(*p) > tol.confusion() * 10.0 {
1951            ogeom_bail!(
1952                Construction,
1953                "a border ending at a crease does not reach the crease's mitre \
1954                 ({:.3e} away); a crease is joined where the borders at its ends \
1955                 lie square to it",
1956                reached.distance(*p)
1957            );
1958        }
1959        new[k] = t;
1960    }
1961    if new[1] - new[0] <= tol.parametric() {
1962        ogeom_bail!(
1963            Construction,
1964            "the thickness reaches past a border's length where it meets a \
1965             crease; the faces' offsets cross beyond it"
1966        );
1967    }
1968    Ok(match curve {
1969        Curve::Line(line) => {
1970            let (d0, d1) = curve.domain();
1971            let carried = LineCurve::over(line.axis(), new[0].min(d0), new[1].max(d1))?;
1972            (carried.into(), (new[0], new[1]))
1973        }
1974        // Restarted at the new start, so the range runs from zero within
1975        // one turn, where every chart image of the circle is defined.
1976        Curve::Circle(c) => {
1977            let circle = c.circle();
1978            let start = curve.point_at(new[0], tol)?;
1979            let frame = Frame::new(
1980                circle.centre(),
1981                circle.frame().z(),
1982                Direction::new(start - circle.centre(), tol)?,
1983                tol,
1984            )?;
1985            let restarted = ogeom_math::Circle::new(frame, circle.radius(), tol)?;
1986            (
1987                ogeom_geom::CircleCurve::new(restarted).into(),
1988                (0.0, new[1] - new[0]),
1989            )
1990        }
1991        _ => (curve.clone(), (new[0], new[1])),
1992    })
1993}
1994
1995/// A pcurve shifted by whole periods of `surface`'s chart so that its
1996/// point at `t` lies closest to `near`.
1997fn on_branch(
1998    image: PlanarCurve,
1999    t: f64,
2000    near: Point2,
2001    surface: &SurfaceGeometry,
2002    tol: Tolerances,
2003) -> OgeomResult<PlanarCurve> {
2004    let ((ua, ub), (va, vb)) = surface.domain();
2005    let at = image.point_at(t, tol)?;
2006    let whole = |periodic: bool, period: f64, gap: f64| {
2007        if periodic && period > 0.0 {
2008            (gap / period).round() * period
2009        } else {
2010            0.0
2011        }
2012    };
2013    let shift = Vector2::new(
2014        whole(surface.is_periodic_u(), ub - ua, near.x - at.x),
2015        whole(surface.is_periodic_v(), vb - va, near.y - at.y),
2016    );
2017    if shift.x == 0.0 && shift.y == 0.0 {
2018        return Ok(image);
2019    }
2020    image.transformed(&Transform2::translation(shift), tol)
2021}
2022
2023/// The straight riser from a free vertex's image in the lower layer to its
2024/// image in the upper one, built once and shared through `risers`.
2025fn riser(
2026    model: &mut Model,
2027    vertex: TShapeId,
2028    (lower, upper): (&Layer, &Layer),
2029    risers: &mut FastMap<TShapeId, Shape>,
2030    tol: Tolerances,
2031) -> OgeomResult<Shape> {
2032    if let Some(found) = risers.get(&vertex) {
2033        return Ok(found.clone());
2034    }
2035    let (from, a) = lower.vertices[&vertex].clone();
2036    let (to, b) = upper.vertices[&vertex].clone();
2037    let segment = LineCurve::segment(a, b, tol)?;
2038    let span = a.distance(b);
2039    let shape = make_edge_between(model, segment.into(), (0.0, span), &from, &to, tol)?.shape;
2040    risers.insert(vertex, shape.clone());
2041    Ok(shape)
2042}
2043
2044/// The direction out of the material across a free edge, in the sheet:
2045/// the face lies to the left of the edge as the bare face walks it.
2046fn outward_of(sheet: &Sheet, ei: usize, tol: Tolerances) -> OgeomResult<Vector> {
2047    let edge = &sheet.edges[ei];
2048    let used = &edge.uses[0];
2049    let face = &sheet.faces[used.face];
2050    let Some((curve, range)) = &edge.curve else {
2051        ogeom_bail!(Construction, "a free edge has no curve in space");
2052    };
2053    let mid = f64::midpoint(range.0, range.1);
2054    let uv = seat_on(
2055        sheet,
2056        edge,
2057        used,
2058        (curve.point_at(mid, tol)?, mid, *range),
2059        tol,
2060    )?;
2061    let natural = face.surface.normal_at(uv.x, uv.y, tol)?.vector();
2062    let walked = if used.sense == Orientation::Reversed {
2063        -curve.d1_at(mid, tol)?
2064    } else {
2065        curve.d1_at(mid, tol)?
2066    };
2067    Ok(walked.cross(natural))
2068}
2069
2070/// Points along an edge's curve over its range, start to end.
2071fn edge_samples(
2072    model: &Model,
2073    edge: &Shape,
2074    count: i32,
2075    tol: Tolerances,
2076) -> OgeomResult<Vec<Point>> {
2077    let Some(EdgeRepr::Curve3d { curve, range, .. }) = model
2078        .node(edge)
2079        .and_then(|n| n.data().as_edge())
2080        .and_then(EdgeData::curve3d)
2081    else {
2082        ogeom_bail!(Construction, "a layer edge has no curve");
2083    };
2084    let Some(curve) = model.geometry().curve(*curve) else {
2085        ogeom_bail!(Dangling, "curve is not in this model");
2086    };
2087    (0..=count)
2088        .map(|k| {
2089            curve.point_at(
2090                range.0 + (range.1 - range.0) * f64::from(k) / f64::from(count),
2091                tol,
2092            )
2093        })
2094        .collect()
2095}
2096
2097/// The side face along a free edge that ends at a crease: the flat face
2098/// bounded by the edge's images in the two layers and the risers at its
2099/// ends, oriented out of the material.
2100fn flat_side_face(
2101    model: &mut Model,
2102    sheet: &Sheet,
2103    ei: usize,
2104    (lower, upper): (&Layer, &Layer),
2105    risers: &mut FastMap<TShapeId, Shape>,
2106    tol: Tolerances,
2107) -> OgeomResult<Shape> {
2108    let edge = &sheet.edges[ei];
2109    let rise_start = riser(model, edge.ends.0, (lower, upper), risers, tol)?;
2110    let rise_end = riser(model, edge.ends.1, (lower, upper), risers, tol)?;
2111    let low = lower.edges[&edge.node].clone();
2112    let high = upper.edges[&edge.node].clone();
2113    // The walk: along the lower image, up the end riser, back along the
2114    // upper image and down the start riser; its plane is the one the
2115    // walk turns counter-clockwise about.
2116    let mut walk = edge_samples(model, &low, 4 * PROBES, tol)?;
2117    let mut back = edge_samples(model, &high, 4 * PROBES, tol)?;
2118    back.reverse();
2119    walk.extend(back);
2120    let mut turning = Vector::ZERO;
2121    for (k, p) in walk.iter().enumerate() {
2122        let q = walk[(k + 1) % walk.len()];
2123        turning += Vector::new(
2124            (p.y - q.y) * (p.z + q.z),
2125            (p.z - q.z) * (p.x + q.x),
2126            (p.x - q.x) * (p.y + q.y),
2127        );
2128    }
2129    #[allow(clippy::cast_precision_loss, reason = "a few dozen samples")]
2130    let count = walk.len() as f64;
2131    let centre = walk
2132        .iter()
2133        .fold(Point::ORIGIN, |acc, p| acc + (*p - Point::ORIGIN) / count);
2134    let normal = Direction::new(turning, tol)?;
2135    let flat = walk
2136        .iter()
2137        .map(|p| (*p - centre).dot(normal.vector()).abs())
2138        .fold(0.0_f64, f64::max);
2139    if flat > tol.confusion() * 10.0 {
2140        ogeom_bail!(
2141            Construction,
2142            "the side along a border ending at a crease is not flat ({flat:.3e} \
2143             out of its plane); a crease is joined where the borders at its ends \
2144             and their offsets lie in one plane"
2145        );
2146    }
2147    let plane =
2148        ogeom_math::Plane::new(Frame::new(centre, normal, normal.any_perpendicular(), tol)?);
2149    let bare = make_face_with_pcurves(
2150        model,
2151        PlaneSurface::new(plane).into(),
2152        &[vec![
2153            low.clone(),
2154            rise_end.clone(),
2155            high.reversed(),
2156            rise_start.reversed(),
2157        ]],
2158        tol,
2159    )?
2160    .shape;
2161    for e in [&low, &high, &rise_start, &rise_end] {
2162        same_parameter(model, e, tol)?;
2163    }
2164    Ok(if normal.vector().dot(outward_of(sheet, ei, tol)?) >= 0.0 {
2165        bare
2166    } else {
2167        bare.reversed()
2168    })
2169}
2170
2171/// The ruled face between a free edge's images in the two layers, oriented
2172/// out of the material, with the risers at its ends shared with the
2173/// neighbouring side faces through `risers`.
2174#[allow(
2175    clippy::too_many_lines,
2176    reason = "one construction: chart, edges, pcurves, orientation"
2177)]
2178fn side_face(
2179    model: &mut Model,
2180    sheet: &Sheet,
2181    ei: usize,
2182    (lo, hi): (f64, f64),
2183    (lower, upper): (&Layer, &Layer),
2184    risers: &mut FastMap<TShapeId, Shape>,
2185    tol: Tolerances,
2186) -> OgeomResult<Shape> {
2187    let target = tol.approximation();
2188    let edge = &sheet.edges[ei];
2189    let used = &edge.uses[0];
2190    let face = &sheet.faces[used.face];
2191    let bound = fit_bound(face.tolerance, hi - lo, tol);
2192    let Some((curve, range)) = edge.curve.clone() else {
2193        ogeom_bail!(Construction, "a free edge has no curve in space");
2194    };
2195    let (t0, t1) = range;
2196    let height = hi - lo;
2197    // The sheet's normal along the edge, from the face it bounds.
2198    let normal_at = |t: f64| -> OgeomResult<(Point, Vector)> {
2199        let at = curve.point_at(t, tol)?;
2200        let uv = seat_on(sheet, edge, used, (at, t, range), tol)?;
2201        let Some(n) = sheet_normal(face, uv, tol) else {
2202            ogeom_bail!(
2203                Construction,
2204                "a free edge runs into a point where its face has no \
2205                 normal; the side there is not determined"
2206            );
2207        };
2208        Ok((at, n))
2209    };
2210    let ruled = |t: f64, s: f64| -> OgeomResult<Point> {
2211        let (at, n) = normal_at(t)?;
2212        Ok(at + n * (lo + s))
2213    };
2214
2215    // The chart: u is the edge's own parameter, v = v0 + sense * s for the
2216    // height s above the lower layer.
2217    let measure = |surface: &SurfaceGeometry, v0: f64, sense: f64| -> OgeomResult<f64> {
2218        let mut worst = 0.0_f64;
2219        for i in 0..=4 * PROBES {
2220            let t = t0 + (t1 - t0) * f64::from(i) / f64::from(4 * PROBES);
2221            for s in [0.0, 0.5 * height, height] {
2222                let p = surface.point_at(t, sense.mul_add(s, v0), tol)?;
2223                worst = worst.max(p.distance(ruled(t, s)?));
2224            }
2225        }
2226        Ok(worst)
2227    };
2228    let mut chart: Option<(SurfaceGeometry, f64, f64, f64)> = None;
2229    let (_, n0) = normal_at(t0)?;
2230    match &curve {
2231        Curve::Line(line) => {
2232            let axis = line.axis();
2233            let across = Direction::new(axis.direction.vector().cross(n0), tol)?;
2234            let frame = Frame::new(axis.location + n0 * lo, across, axis.direction, tol)?;
2235            let pad = (t1 - t0).abs() + height;
2236            let plane: SurfaceGeometry = PlaneSurface::over(
2237                ogeom_math::Plane::new(frame),
2238                (t0 - pad, t1 + pad),
2239                (-pad, height + pad),
2240            )?
2241            .into();
2242            if measure(&plane, 0.0, 1.0)? <= tol.confusion() * 10.0 {
2243                chart = Some((plane, 0.0, 1.0, 0.0));
2244            }
2245        }
2246        Curve::Circle(c) => {
2247            let circle = c.circle();
2248            let axis = circle.frame().z().vector();
2249            let sense = if n0.dot(axis) >= 0.0 { 1.0 } else { -1.0 };
2250            let (a, b) = (sense * lo, sense * hi);
2251            let cylinder: SurfaceGeometry = CylinderSurface::new(
2252                Cylinder::new(circle.frame(), circle.radius(), tol)?,
2253                (a.min(b) - height, a.max(b) + height),
2254            )?
2255            .into();
2256            if measure(&cylinder, sense * lo, sense)? <= tol.confusion() * 10.0 {
2257                chart = Some((cylinder, sense * lo, sense, 0.0));
2258            }
2259        }
2260        _ => {}
2261    }
2262    let (surface, v0, sense, deviation) = match chart {
2263        Some(found) => found,
2264        None => {
2265            let mut spans: u32 = 16;
2266            let mut best: Option<(SurfaceGeometry, f64)> = None;
2267            loop {
2268                let at = |k: u32| t0 + (t1 - t0) * f64::from(k) / f64::from(2 * spans);
2269                let us: Vec<f64> = (0..=spans).map(|k| at(2 * k)).collect();
2270                let rows: Vec<Vec<Point>> = [0.0, height]
2271                    .iter()
2272                    .map(|s| us.iter().map(|t| ruled(*t, *s)).collect())
2273                    .collect::<OgeomResult<_>>()?;
2274                let fitted = ogeom_geom::fit::fit_surface_grid_at(
2275                    &us,
2276                    &[0.0, height],
2277                    &rows,
2278                    3,
2279                    target * 0.5,
2280                    tol,
2281                )?;
2282                let fit: SurfaceGeometry = fitted.curve.into();
2283                let worst = fitted.error.max(measure(&fit, 0.0, 1.0)?);
2284                if worst <= target {
2285                    break (fit, 0.0, 1.0, worst);
2286                }
2287                if best.as_ref().is_none_or(|(_, b)| worst < *b) {
2288                    best = Some((fit, worst));
2289                }
2290                if spans >= 1024 {
2291                    match best {
2292                        Some((closest, reached)) if reached <= bound => {
2293                            break (closest, 0.0, 1.0, reached);
2294                        }
2295                        Some((_, reached)) => ogeom_bail!(
2296                            NotDone,
2297                            "the side face along a free edge did not fit within \
2298                             {bound}; the closest fit was {reached} off"
2299                        ),
2300                        None => ogeom_bail!(NotDone, "the side face was not fitted"),
2301                    }
2302                }
2303                spans *= 2;
2304            }
2305        }
2306    };
2307    let side_id = model.geometry_mut().add_surface(surface.clone());
2308
2309    // The risers: one straight edge per free vertex, lower to upper.
2310    let rise_start = riser(model, edge.ends.0, (lower, upper), risers, tol)?;
2311    let rise_end = riser(model, edge.ends.1, (lower, upper), risers, tol)?;
2312    let low = lower.edges[&edge.node].clone();
2313    let high = upper.edges[&edge.node].clone();
2314
2315    // The rectangle (t0, 0) .. (t1, height) walked counter-clockwise in
2316    // (t, s): along the lower edge, up the end riser, back along the upper
2317    // edge, down the start riser. Where v runs against s the chart sees it
2318    // clockwise, and the walk turns round.
2319    let forward = Orientation::Forward;
2320    let backward = Orientation::Reversed;
2321    let walk: Vec<(Shape, Orientation)> = if sense > 0.0 {
2322        vec![
2323            (low.clone(), forward),
2324            (rise_end.clone(), forward),
2325            (high.clone(), backward),
2326            (rise_start.clone(), backward),
2327        ]
2328    } else {
2329        vec![
2330            (rise_start.clone(), forward),
2331            (high.clone(), forward),
2332            (rise_end.clone(), backward),
2333            (low.clone(), backward),
2334        ]
2335    };
2336    let ring: Vec<Shape> = walk.iter().map(|(e, o)| e.oriented(*o)).collect();
2337    let wire = make_wire(model, &ring, tol)?.shape;
2338    let bare = make_face_on(model, side_id, std::slice::from_ref(&wire), tol)?.shape;
2339
2340    let row = |v: f64| -> OgeomResult<PlanarCurve> {
2341        Ok(Line2d::over(Axis2::new(Point2::new(0.0, v), Direction2::X), t0, t1)?.into())
2342    };
2343    let up = Direction2::new(ogeom_math::Vector2::new(0.0, sense), tol)?;
2344    let column = |t: f64, length: f64| -> OgeomResult<PlanarCurve> {
2345        Ok(Line2d::over(Axis2::new(Point2::new(t, v0), up), 0.0, length)?.into())
2346    };
2347    let identity = Location::identity();
2348    attach_pcurve(model, &low, row(v0)?, side_id, identity.clone(), range)?;
2349    attach_pcurve(
2350        model,
2351        &high,
2352        row(sense.mul_add(height, v0))?,
2353        side_id,
2354        identity.clone(),
2355        range,
2356    )?;
2357    let length_of = |model: &Model, e: &Shape| -> OgeomResult<f64> {
2358        match model
2359            .node(e)
2360            .and_then(|n| n.data().as_edge())
2361            .and_then(EdgeData::curve3d)
2362        {
2363            Some(EdgeRepr::Curve3d { range, .. }) => Ok(range.1),
2364            _ => ogeom_bail!(Construction, "a riser has no curve"),
2365        }
2366    };
2367    if rise_start.node() == rise_end.node() {
2368        // A closed free edge: one riser bounds the side twice, a seam. Its
2369        // forward use is whichever end the walk climbs.
2370        let length = length_of(model, &rise_start)?;
2371        let (climbed, descended) = if sense > 0.0 { (t1, t0) } else { (t0, t1) };
2372        attach_seam(
2373            model,
2374            &rise_start,
2375            column(climbed, length)?,
2376            column(descended, length)?,
2377            side_id,
2378            identity,
2379            (0.0, length),
2380        )?;
2381    } else {
2382        let length = length_of(model, &rise_start)?;
2383        attach_pcurve(
2384            model,
2385            &rise_start,
2386            column(t0, length)?,
2387            side_id,
2388            identity.clone(),
2389            (0.0, length),
2390        )?;
2391        let length = length_of(model, &rise_end)?;
2392        attach_pcurve(
2393            model,
2394            &rise_end,
2395            column(t1, length)?,
2396            side_id,
2397            identity,
2398            (0.0, length),
2399        )?;
2400    }
2401    for e in [&low, &high, &rise_start, &rise_end] {
2402        same_parameter(model, e, tol)?;
2403    }
2404    if deviation > 0.0 {
2405        model.widen(&bare, Tolerance::new(deviation + tol.confusion())?)?;
2406    }
2407
2408    let mid = f64::midpoint(t0, t1);
2409    let outward = outward_of(sheet, ei, tol)?;
2410    let side_normal = surface
2411        .normal_at(mid, sense.mul_add(0.5 * height, v0), tol)?
2412        .vector();
2413    Ok(if side_normal.dot(outward) >= 0.0 {
2414        bare
2415    } else {
2416        bare.reversed()
2417    })
2418}
2419
2420/// Every edge of `built` that `given` does not hold widened to what its
2421/// pcurves measure along it. The given sheet's own edges are the caller's,
2422/// and are left as they were.
2423fn state_new_gaps(
2424    model: &mut Model,
2425    given: &Shape,
2426    built: &Shape,
2427    tol: Tolerances,
2428) -> OgeomResult<()> {
2429    let held: ogeom_core::FastSet<_> = ogeom_topo::explore_unique(model, given, ShapeType::Edge)?
2430        .iter()
2431        .map(Shape::node)
2432        .collect();
2433    let made: Vec<Shape> = ogeom_topo::explore_unique(model, built, ShapeType::Edge)?
2434        .into_iter()
2435        .filter(|e| !held.contains(&e.node()))
2436        .collect();
2437    ogeom_algo::state_pcurve_gaps_of(model, &made, tol)
2438}