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