Skip to main content

ogeom_offset/
sheet.rs

1//! Sheets: ruled surfaces, lofts and sweeps that bound no volume.
2//!
3//! Sections, profiles and rails are edges or wires, open or closed, planar
4//! or not. Each section edge is restated as its exact B-spline (a conic as
5//! its rational form), the edges the sections pair up are made compatible
6//! (one degree, one knot vector), and the skin interpolates their control
7//! points in homogeneous coordinates. Every section therefore lies on the
8//! skin exactly, not to a fit's tolerance. A ruled skin is degree one
9//! across, so every ruling is a straight line. A loft with guide curves is
10//! a Gordon surface over the sections and the guides (see `guided`).
11//!
12//! A sweep places copies of its profile along the path (rigid copies under
13//! a frame law, similar copies between two rails) and skins them the same
14//! way. The copies lie on the skin exactly; between them the skin departs
15//! from the swept profile, and that departure is measured against copies
16//! placed halfway, by projection, and held to ten confusions, the copies
17//! doubled until it is.
18//!
19//! A sheet has one face per section edge (per span between neighbouring
20//! sections for a ruled loft, per spine edge for a sweep), the faces meeting on shared edges. Sections
21//! with different edge counts are matched by arc length: each section's
22//! edges are split where the others' breaks fall, as fractions of its
23//! length, the pieces keeping their exact form. A section edge that bounds
24//! the sheet, belongs to the model and was not split is the caller's own
25//! edge, so the sheet sews to what it was built from. Each face's normal
26//! is its chart's: `u` runs along the sections, `v` across them.
27
28use ogeom_algo::{
29    Built, History, attach_pcurve, attach_seam, edge_vertices, make_edge_between, make_face_on,
30    make_face_with_pcurves, make_shell, make_vertex, make_wire,
31};
32use ogeom_core::{OgeomResult, Tolerances, ogeom_bail, ogeom_err};
33use ogeom_geom::Curve2d as _;
34use ogeom_geom::Curve3d as _;
35use ogeom_geom::Surface as _;
36use ogeom_geom::Transformable as _;
37use ogeom_geom::{
38    BSpline2d, BSplineCurve, BSplineSurface, Curve, Line2d, LineCurve, PlanarCurve, PlaneSurface,
39    SurfaceGeometry,
40};
41use ogeom_math::Blend as _;
42use ogeom_math::{
43    Axis2, ControlGrid, Direction, Direction2, Frame, KnotVector, Plane, Point, Point2, Transform,
44    TransformKind, Vector, Weighted,
45};
46use ogeom_topo::{Location, Model, Orientation, Shape, ShapeType};
47
48use crate::sweep::{PipeLaw, SpineStation, law_normals, spine_curve_of, station_frame};
49
50mod guided;
51
52/// Knots closer than this, on the unit domain every section is restated
53/// over, are one knot.
54const KNOT_SAME: f64 = 1e-12;
55
56/// The most sections a sweep's skin is built through.
57const MOST_SWEEP_SECTIONS: usize = 1025;
58
59/// The ruled surface between two curves: each point of `a` joined by a
60/// straight line to the point of `b` at the same fraction of its parameter.
61///
62/// `a` and `b` are edges or wires, open or closed, taken in their own
63/// traversal sense; wires pair edge for edge, one face per pair, and wires
64/// of different edge counts are first split to match by arc length (see
65/// [`make_loft_surface`]). Between two straight segments the face is the
66/// plane when the four corners share one and the bilinear patch otherwise;
67/// between curves it is the exact rational B-spline of degree one across. The long edges are `a`'s and
68/// `b`'s own edges where they were not split, and the rulings at the ends
69/// are straight segments.
70///
71/// # Errors
72///
73/// As [`make_loft_surface`] with two sections and `ruled`.
74pub fn make_ruled(model: &mut Model, a: &Shape, b: &Shape, tol: Tolerances) -> OgeomResult<Built> {
75    make_loft_surface(model, &[a.clone(), b.clone()], false, &[], true, tol)
76}
77
78/// A sheet lofted through sections, in order.
79///
80/// The sections are edges or wires, open or closed, planar or not, each
81/// taken in its own traversal sense from its own start (aligning those is
82/// the caller's authorship). Wires pair edge for edge, and the sheet has
83/// one face per edge (one per edge and span between neighbouring sections
84/// when `ruled`). Sections with different edge counts are matched by arc
85/// length first: every section is split at the fractions of its length
86/// where any section has a break (breaks closer than ten confusions along
87/// the longest section are one), each piece the exact restriction of the
88/// edge it came from, so the sections pair edge for edge. The sheet
89/// passes through every section exactly. A smooth loft is the B-spline
90/// interpolating the sections across, cubic from four sections up,
91/// parameterized by the mean distance between their control points; a
92/// ruled loft is degree one between each pair of neighbours.
93///
94/// With `closed` the sheet runs from the last section back to the first:
95/// smoothly, with no seam in its slope, or ruled, with a last span. A
96/// closed smooth loft spaces its sections evenly in its parameter.
97///
98/// The first and last sections (every section, when ruled) bound the
99/// sheet, and where they are edges of the model, not split to match, they
100/// are those edges.
101///
102/// With `guides` the sheet also follows each guide, an edge or a wire
103/// crossing every section once, in the same order along every section and
104/// in the sections' order along itself. The guided sheet is built as a
105/// Gordon surface: each section is paced so every guide crosses it at one
106/// parameter, each guide so it crosses every section at one parameter, and
107/// the skin across the sections is corrected along each guide by the
108/// guide's departure from it. Through sections of one edge it is one face,
109/// and through closed sections its seam runs along the first guide.
110/// Sections of several edges have one edge count, and a guide runs
111/// through every corner where two edges meet, at the same corner of every
112/// section: the sheet has one face per edge, the Gordon surface following
113/// the guides through its two corners and those crossing the edge inside,
114/// and neighbouring faces meet along the guide through their corner. The
115/// sheet passes through every section exactly and through every guide to
116/// within the distance by which the guide misses the sections; both are
117/// measured on the built skin and held to ten confusions.
118///
119/// # Errors
120///
121/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction)
122/// with fewer than two sections, or three for a closed loft; if a section
123/// is not an edge or a wire, or has a curve with no exact B-spline form (a
124/// helix, an offset); if the sections differ in being closed; if they
125/// differ in edge count and one has no length or their breaks fall too
126/// close together to match; if two neighbouring sections coincide; or if
127/// neighbouring edges of the sections carry weights at their shared corner
128/// that would part their faces. With guides, also if the loft is ruled or closed, the
129/// sections differ in edge count, a guide misses a section by more than ten
130/// confusions, crosses the sections out of their order, or crosses some
131/// sections at an end and others inside, or two guides cross the sections
132/// in different orders; through sections of several edges, also if a
133/// guide crosses the sections at different corners or edges, a corner
134/// where two edges meet has no guide, or two guides run through one
135/// corner.
136/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the guided
137/// skin strays more than ten confusions from a section or a guide.
138pub fn make_loft_surface(
139    model: &mut Model,
140    sections: &[Shape],
141    closed: bool,
142    guides: &[Shape],
143    ruled: bool,
144    tol: Tolerances,
145) -> OgeomResult<Built> {
146    if !guides.is_empty() {
147        return guided::guided_loft(model, sections, closed, guides, ruled, tol);
148    }
149    let least = if closed { 3 } else { 2 };
150    if sections.len() < least {
151        ogeom_bail!(
152            Construction,
153            "a {}loft surface needs at least {least} sections, given {}",
154            if closed { "closed " } else { "" },
155            sections.len()
156        );
157    }
158    let mut read: Vec<Section> = Vec::with_capacity(sections.len());
159    for shape in sections {
160        read.push(read_section(model, shape, "loft section", tol)?);
161    }
162    let closed_u = read[0].closed;
163    for (k, s) in read.iter().enumerate() {
164        if s.closed != closed_u {
165            ogeom_bail!(
166                Construction,
167                "loft sections are all closed or all open; section {k} is {} and section 0 \
168                 is not",
169                if s.closed { "closed" } else { "open" }
170            );
171        }
172    }
173    if read.iter().any(|s| s.edges.len() != read[0].edges.len()) {
174        matched_by_length(&mut read, tol)?;
175    }
176    let count = read[0].edges.len();
177    // One degree and one knot vector per edge the sections pair up.
178    for e in 0..count {
179        let curves: Vec<BSplineCurve> = read.iter().map(|s| s.edges[e].curve.clone()).collect();
180        let mut matched = made_compatible(&curves, tol)?;
181        if !ruled {
182            matched = matched.iter().map(smooth_joint).collect();
183        }
184        for (s, c) in read.iter_mut().zip(matched) {
185            s.edges[e].curve = c;
186        }
187    }
188    let n = read.len();
189    let mut chords = Vec::with_capacity(n);
190    for k in 0..n - 1 {
191        chords.push(section_chord(&read[k], &read[k + 1], k, tol)?);
192    }
193    if closed {
194        chords.push(section_chord(&read[n - 1], &read[0], n - 1, tol)?);
195    }
196
197    let (spans, surfaces, seam_v) = if ruled {
198        let mut spans: Vec<(usize, usize)> = (0..n - 1).map(|k| (k, k + 1)).collect();
199        if closed {
200            spans.push((n - 1, 0));
201        }
202        let mut surfaces = Vec::with_capacity(count);
203        for e in 0..count {
204            let mut per_span = Vec::with_capacity(spans.len());
205            for &(lo, hi) in &spans {
206                let rows = vec![
207                    read[lo].edges[e].curve.control_points().to_vec(),
208                    read[hi].edges[e].curve.control_points().to_vec(),
209                ];
210                let v_knots = KnotVector::clamped_uniform(1, 2)?;
211                per_span.push(surface_of(
212                    read[lo].edges[e].curve.knots(),
213                    v_knots,
214                    &rows,
215                    tol,
216                )?);
217            }
218            surfaces.push(per_span);
219        }
220        (spans, surfaces, false)
221    } else if closed {
222        let mut surfaces = Vec::with_capacity(count);
223        for e in 0..count {
224            let rows: Vec<Vec<Weighted<Point>>> = read
225                .iter()
226                .map(|s| s.edges[e].curve.control_points().to_vec())
227                .collect();
228            let (v_knots, net) = interpolated_closed(&rows, tol)?;
229            surfaces.push(vec![surface_of(
230                read[0].edges[e].curve.knots(),
231                v_knots,
232                &net,
233                tol,
234            )?]);
235        }
236        // The seam is the first section's curve, built fresh: one edge
237        // bounding the face on both sides of its chart.
238        for edge in &mut read[0].edges {
239            edge.edge = None;
240        }
241        (vec![(0, 0)], surfaces, true)
242    } else {
243        let params = unit_params(&chords);
244        let mut surfaces = Vec::with_capacity(count);
245        for e in 0..count {
246            let rows: Vec<Vec<Weighted<Point>>> = read
247                .iter()
248                .map(|s| s.edges[e].curve.control_points().to_vec())
249                .collect();
250            let (v_knots, net) = interpolated(&rows, &params, tol)?;
251            surfaces.push(vec![surface_of(
252                read[0].edges[e].curve.knots(),
253                v_knots,
254                &net,
255                tol,
256            )?]);
257        }
258        (vec![(0, n - 1)], surfaces, false)
259    };
260    let shape = sheet(model, &read, &surfaces, &spans, seam_v, ruled, tol)?;
261    let mut history = History::new();
262    for section in sections {
263        history.generate(section, shape.clone());
264    }
265    Ok(Built { shape, history })
266}
267
268/// Sweep a profile along a spine into a sheet.
269///
270/// The profile is an edge or a wire, open or closed, planar or not, swept
271/// as it stands: it is the sheet's first section, and `law` turns it about
272/// the spine the way it turns a pipe's section ([`PipeLaw::Fixed`] carries
273/// it by translation). The sheet has one face per profile edge and spine
274/// edge, each spine edge's faces skinned through their own copies and
275/// meeting the next edge's on the copy at the edges' shared vertex, and no
276/// caps; its far end is the profile where the law carries it to the
277/// spine's end. The skin passes through rigid copies of the profile placed
278/// along the spine, exactly, and keeps within ten confusions of the swept
279/// profile between them, measured halfway between each pair of copies.
280///
281/// # Errors
282///
283/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
284/// the profile is not an edge or a wire or has a curve with no exact
285/// B-spline form; if the spine is closed (a sweep surface along a closed
286/// spine is not built), turns a sharp corner, or stands still; and as the
287/// law refuses its spine (a Frenet frame along a spine that never bends,
288/// a guide the stations' planes do not cross).
289/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the skin
290/// cannot reach the target with the most sections it is built through.
291pub fn make_sweep_surface(
292    model: &mut Model,
293    profile: &Shape,
294    spine: &Shape,
295    law: &PipeLaw<'_>,
296    tol: Tolerances,
297) -> OgeomResult<Built> {
298    let section = read_section(model, profile, "sweep profile", tol)?;
299    let target = tol.confusion() * 10.0;
300    let motions = |model: &Model, density: usize| -> OgeomResult<Placements> {
301        let stations = sweep_stations(model, spine, density, tol)?;
302        let breaks = stations
303            .windows(2)
304            .enumerate()
305            .filter(|(_, pair)| pair[0].edge != pair[1].edge)
306            .map(|(k, _)| k)
307            .collect();
308        if matches!(law, PipeLaw::Fixed) {
309            let start = stations[0].at;
310            return Ok(Placements {
311                motions: stations
312                    .iter()
313                    .map(|s| Transform::translation(s.at - start))
314                    .collect(),
315                breaks,
316            });
317        }
318        let normals = law_normals(model, &stations, law, target, tol)?;
319        let start = station_frame(&stations[0], normals[0], tol)?;
320        let mut out = Vec::with_capacity(stations.len());
321        for (s, n) in stations.iter().zip(&normals) {
322            let frame = station_frame(s, *n, tol)?;
323            out.push(Transform::from_frame(&frame) * Transform::to_frame(&start));
324        }
325        Ok(Placements {
326            motions: out,
327            breaks,
328        })
329    };
330    let shape = swept_sheet(model, &section, motions, target, tol)?;
331    let mut history = History::new();
332    history.generate(profile, shape.clone());
333    history.generate(spine, shape.clone());
334    if let PipeLaw::Auxiliary { guide } = law {
335        history.generate(guide, shape.clone());
336    }
337    Ok(Built { shape, history })
338}
339
340/// Sweep a profile between two rails into a sheet.
341///
342/// The profile is an edge or a wire, open or closed, planar or not, whose
343/// start sits on the start of one rail and whose end on the start of the
344/// other. At each fraction of the rails' lengths the profile is placed by
345/// the similarity carrying its ends onto the rails' points there: scaled by
346/// the distance between those points, turned so the chord between its ends
347/// follows the chord between them, and turned about that chord with the
348/// rails' mean direction. The profile is the sheet's first section; the
349/// sheet has one face per profile edge, passes through the placed copies
350/// exactly and keeps within ten confusions of the swept profile and the
351/// rails between them, measured halfway between each pair of copies. The
352/// long edges are the skin's own, built on the rails' line.
353///
354/// # Errors
355///
356/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
357/// the profile or a rail is not an edge or a wire, a rail is closed, the
358/// profile's ends do not sit on the rails' starts, the rails meet, or the
359/// rails' mean direction runs along the chord between them somewhere.
360/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the skin
361/// cannot reach the target with the most sections it is built through.
362pub fn make_sweep_two_rails(
363    model: &mut Model,
364    profile: &Shape,
365    rail_a: &Shape,
366    rail_b: &Shape,
367    tol: Tolerances,
368) -> OgeomResult<Built> {
369    let section = read_section(model, profile, "sweep profile", tol)?;
370    if section.closed {
371        ogeom_bail!(
372            Construction,
373            "a two-rail profile runs from one rail to the other; a closed profile has one end"
374        );
375    }
376    let start = section.edges[0].curve.point_at(0.0, tol)?;
377    let end = section.edges[section.edges.len() - 1]
378        .curve
379        .point_at(1.0, tol)?;
380    let mut first = Rail::read(model, rail_a, tol)?;
381    let mut second = Rail::read(model, rail_b, tol)?;
382    let (a0, b0) = (first.at(0.0, tol)?.0, second.at(0.0, tol)?.0);
383    let near = tol.confusion() * 10.0;
384    if start.distance(b0) <= near && end.distance(a0) <= near {
385        core::mem::swap(&mut first, &mut second);
386    }
387    let (a0, b0) = (first.at(0.0, tol)?.0, second.at(0.0, tol)?.0);
388    if start.distance(a0) > near || end.distance(b0) > near {
389        ogeom_bail!(
390            Construction,
391            "the profile's ends must sit on the rails' starts; they are {:.3e} and {:.3e} away",
392            start.distance(a0),
393            end.distance(b0)
394        );
395    }
396    let frame_at = |f: f64| -> OgeomResult<(Frame, f64)> {
397        let (a, ta) = first.at(f, tol)?;
398        let (b, tb) = second.at(f, tol)?;
399        let chord = b - a;
400        let width = chord.magnitude();
401        if width <= tol.confusion() {
402            ogeom_bail!(
403                Construction,
404                "the rails meet at {a:?}; a profile between them has no width there"
405            );
406        }
407        let x = chord / width;
408        let mean = ta + tb;
409        let along = mean - x * mean.dot(x);
410        if along.magnitude() <= 1e-6 * mean.magnitude().max(tol.confusion()) {
411            ogeom_bail!(
412                Construction,
413                "the rails' mean direction runs along the chord between them at {a:?}"
414            );
415        }
416        Ok((
417            Frame::new(a, Direction::new(along, tol)?, Direction::new(x, tol)?, tol)?,
418            width,
419        ))
420    };
421    let (start_frame, start_width) = frame_at(0.0)?;
422    let target = tol.confusion() * 10.0;
423    let motions = |_: &Model, density: usize| -> OgeomResult<Placements> {
424        let count = 32 * density;
425        let mut out = Vec::with_capacity(count + 1);
426        for i in 0..=count {
427            #[allow(clippy::cast_precision_loss)]
428            let f = i as f64 / count as f64;
429            let (frame, width) = frame_at(f)?;
430            let scale = Transform::scaling(Point::ORIGIN, width / start_width, tol)?;
431            out.push(Transform::from_frame(&frame) * scale * Transform::to_frame(&start_frame));
432        }
433        Ok(Placements {
434            motions: out,
435            breaks: Vec::new(),
436        })
437    };
438    let shape = swept_sheet(model, &section, motions, target, tol)?;
439    let mut history = History::new();
440    for input in [profile, rail_a, rail_b] {
441        history.generate(input, shape.clone());
442    }
443    Ok(Built { shape, history })
444}
445
446/// One edge of a section: the caller's edge where the sheet may bound
447/// itself with it, and its exact B-spline.
448#[derive(Clone)]
449struct SectionEdge {
450    /// The model edge, oriented the way the section runs.
451    edge: Option<Shape>,
452    /// The edge's curve in the section's sense, over `[0, 1]`, its first
453    /// weight one unless it is a piece cut from an edge.
454    curve: BSplineCurve,
455    /// Whether `curve`'s parameter is the edge's own, mapped affinely.
456    paced: bool,
457}
458
459/// A section as the skin reads it: its edges in traversal order.
460#[derive(Clone)]
461struct Section {
462    edges: Vec<SectionEdge>,
463    closed: bool,
464}
465
466/// Read an edge or a wire as a section. Its edges are kept for the sheet to
467/// bound itself with when they stand unplaced and chain vertex to vertex.
468fn read_section(model: &Model, shape: &Shape, what: &str, tol: Tolerances) -> OgeomResult<Section> {
469    let edges = match model.kind_of(shape)? {
470        ShapeType::Edge => vec![shape.clone()],
471        ShapeType::Wire => model.ordered_children_of(shape)?,
472        other => ogeom_bail!(
473            Construction,
474            "a {what} is an edge or a wire, not a {other:?}"
475        ),
476    };
477    if edges.is_empty() {
478        ogeom_bail!(Construction, "the {what} has no edges");
479    }
480    let mut adoptable = true;
481    let mut out = Vec::with_capacity(edges.len());
482    for edge in &edges {
483        let (curve, range) = spine_curve_of(model, edge)?;
484        let placement = edge.transform(model.datums())?;
485        if placement.kind() != TransformKind::Identity {
486            adoptable = false;
487        }
488        let exact = curve.to_bspline_over(range, tol)?;
489        let exact = moved(&exact, &placement, tol)?;
490        let exact = if edge.orientation() == Orientation::Reversed {
491            let (knots, control) =
492                ogeom_math::bspline::reverse(exact.knots(), exact.control_points());
493            BSplineCurve::rational(knots, control)?
494        } else {
495            exact
496        };
497        out.push(SectionEdge {
498            edge: Some(edge.clone()),
499            curve: standard(&exact)?,
500            paced: true,
501        });
502    }
503    let head = out[0].curve.point_at(0.0, tol)?;
504    let tail = out[out.len() - 1].curve.point_at(1.0, tol)?;
505    let closed = head.distance(tail) <= tol.confusion();
506    if adoptable {
507        let mut ends = Vec::with_capacity(edges.len());
508        for edge in &edges {
509            match edge_vertices(model, edge)? {
510                Some(pair) => ends.push(pair),
511                None => adoptable = false,
512            }
513        }
514        if adoptable {
515            adoptable = ends.windows(2).all(|w| w[0].1.is_partner(&w[1].0))
516                && (!closed || ends[ends.len() - 1].1.is_partner(&ends[0].0));
517        }
518    }
519    if !adoptable {
520        for e in &mut out {
521            e.edge = None;
522        }
523    }
524    Ok(Section { edges: out, closed })
525}
526
527/// A B-spline moved by a similarity, exactly: its control points carried,
528/// its weights kept.
529fn moved(curve: &BSplineCurve, motion: &Transform, tol: Tolerances) -> OgeomResult<BSplineCurve> {
530    if motion.kind() == TransformKind::Identity {
531        return Ok(curve.clone());
532    }
533    let control = curve
534        .control_points()
535        .iter()
536        .map(|w| Weighted::new(motion.apply(w.point()), w.weight, tol))
537        .collect::<OgeomResult<Vec<_>>>()?;
538    BSplineCurve::rational(curve.knots().clone(), control)
539}
540
541/// The same curve over `[0, 1]` with its first weight one: scaling every
542/// weight alike leaves a rational curve where it is.
543fn standard(curve: &BSplineCurve) -> OgeomResult<BSplineCurve> {
544    let knots = curve.knots().reparameterized(0.0, 1.0)?;
545    let first = curve.control_points()[0].weight;
546    let control = curve
547        .control_points()
548        .iter()
549        .map(|w| w.scale(1.0 / first))
550        .collect();
551    BSplineCurve::rational(knots, control)
552}
553
554/// Sections split to pair edge for edge by arc length: each section is cut
555/// at the fractions of its length where any section has a break, so every
556/// section ends with the same breaks, at the same fractions. Breaks closer
557/// than ten confusions along the longest section are one, and a section
558/// whose own break is among them keeps it. A piece is the exact
559/// restriction of the edge it is cut from. A section that is cut bounds the
560/// sheet with fresh edges rather than its own.
561fn matched_by_length(sections: &mut [Section], tol: Tolerances) -> OgeomResult<()> {
562    let mut lengths: Vec<Vec<f64>> = Vec::with_capacity(sections.len());
563    for (k, s) in sections.iter().enumerate() {
564        let each = s
565            .edges
566            .iter()
567            .map(|e| ogeom_algo::curve_length(&Curve::BSpline(e.curve.clone()), (0.0, 1.0), tol))
568            .collect::<OgeomResult<Vec<f64>>>()?;
569        if each.iter().sum::<f64>() <= tol.confusion() {
570            ogeom_bail!(
571                Construction,
572                "section {k} has no length to match the other sections' edges along"
573            );
574        }
575        lengths.push(each);
576    }
577    let longest = lengths
578        .iter()
579        .map(|l| l.iter().sum::<f64>())
580        .fold(0.0_f64, f64::max);
581    let same = tol.confusion() * 10.0 / longest;
582    // Each section's own breaks, as fractions of its length.
583    let own: Vec<Vec<f64>> = lengths
584        .iter()
585        .map(|l| {
586            let total: f64 = l.iter().sum();
587            let mut run = 0.0;
588            l[..l.len() - 1]
589                .iter()
590                .map(|x| {
591                    run += x;
592                    run / total
593                })
594                .collect()
595        })
596        .collect();
597    let mut union: Vec<f64> = own.iter().flatten().copied().collect();
598    union.sort_by(f64::total_cmp);
599    let mut breaks: Vec<f64> = Vec::with_capacity(union.len());
600    for f in union {
601        if f <= same || f >= 1.0 - same {
602            continue;
603        }
604        if breaks.last().is_none_or(|b| f - b > same) {
605            breaks.push(f);
606        }
607    }
608    for ((section, l), mine) in sections.iter_mut().zip(&lengths).zip(&own) {
609        let total: f64 = l.iter().sum();
610        let mut pieces: Vec<SectionEdge> = Vec::with_capacity(breaks.len() + 1);
611        let mut cut = false;
612        let mut start = 0.0;
613        for (edge, length) in section.edges.iter().zip(l) {
614            let (lo, hi) = (start / total, (start + length) / total);
615            // The fractions inside this edge none of its own breaks stands
616            // for, as lengths from its start.
617            let inside: Vec<f64> = breaks
618                .iter()
619                .filter(|f| **f > lo + same && **f < hi - same)
620                .filter(|f| mine.iter().all(|m| (*m - **f).abs() > same))
621                .map(|f| f * total - start)
622                .collect();
623            start += length;
624            if inside.is_empty() {
625                pieces.push(edge.clone());
626                continue;
627            }
628            cut = true;
629            let whole = Curve::BSpline(edge.curve.clone());
630            let mut at = Vec::with_capacity(inside.len());
631            for along in inside {
632                at.push(ogeom_algo::parameter_at_length(
633                    &whole,
634                    (0.0, 1.0),
635                    along,
636                    tol,
637                )?);
638            }
639            let mut rest = (
640                edge.curve.knots().clone(),
641                edge.curve.control_points().to_vec(),
642            );
643            // The rest keeps the edge's parameter, so each cut is made at
644            // the parameter found on the whole edge. The pieces keep their
645            // weights as cut, so where two meet the weights agree and the
646            // corner keeps the ratio a section with a vertex there has.
647            let piece = |(knots, control): (KnotVector, Vec<Weighted<Point>>)| {
648                Ok::<_, ogeom_core::OgeomError>(SectionEdge {
649                    edge: None,
650                    curve: BSplineCurve::rational(knots.reparameterized(0.0, 1.0)?, control)?,
651                    paced: false,
652                })
653            };
654            for t in at {
655                let (before, after) = ogeom_math::bspline::split(&rest.0, &rest.1, t, tol)?;
656                pieces.push(piece(before)?);
657                rest = after;
658            }
659            pieces.push(piece(rest)?);
660        }
661        if cut {
662            for piece in &mut pieces {
663                piece.edge = None;
664            }
665        }
666        section.edges = pieces;
667    }
668    let count = sections[0].edges.len();
669    if let Some(k) = sections.iter().position(|s| s.edges.len() != count) {
670        ogeom_bail!(
671            Construction,
672            "the sections' breaks fall too close together to match by length: section 0 \
673             splits into {count} edges and section {k} into {}",
674            sections[k].edges.len()
675        );
676    }
677    Ok(())
678}
679
680/// Curves raised to one degree and refined to one knot vector, each
681/// unchanged as a curve.
682pub(crate) fn made_compatible(
683    curves: &[BSplineCurve],
684    tol: Tolerances,
685) -> OgeomResult<Vec<BSplineCurve>> {
686    let degree = curves.iter().map(BSplineCurve::degree).max().unwrap_or(1);
687    let mut raised = Vec::with_capacity(curves.len());
688    for c in curves {
689        let mut c = c.clone();
690        while c.degree() < degree {
691            c = c.elevated(tol)?;
692        }
693        raised.push(c);
694    }
695    let interior = |c: &BSplineCurve| -> Vec<(f64, usize)> {
696        c.knots()
697            .distinct()
698            .into_iter()
699            .filter(|(v, _)| *v > KNOT_SAME && *v < 1.0 - KNOT_SAME)
700            .collect()
701    };
702    let mut union: Vec<(f64, usize)> = Vec::new();
703    for c in &raised {
704        for (value, mult) in interior(c) {
705            match union
706                .iter_mut()
707                .find(|(v, _)| (*v - value).abs() <= KNOT_SAME)
708            {
709                Some(entry) => entry.1 = entry.1.max(mult),
710                None => union.push((value, mult)),
711            }
712        }
713    }
714    for c in &mut raised {
715        for &(value, mult) in &union {
716            let have = interior(c)
717                .iter()
718                .find(|(v, _)| (*v - value).abs() <= KNOT_SAME)
719                .map_or(0, |entry| entry.1);
720            if have < mult {
721                *c = c.with_knot_inserted(value, mult - have, tol)?;
722            }
723        }
724    }
725    let knots = raised[0].knots().clone();
726    for c in &raised {
727        let same = c.knots().knots().len() == knots.knots().len()
728            && c.knots()
729                .knots()
730                .iter()
731                .zip(knots.knots())
732                .all(|(a, b)| (a - b).abs() <= KNOT_SAME * 10.0);
733        if !same {
734            ogeom_bail!(Construction, "the sections' knots could not be matched");
735        }
736    }
737    raised
738        .iter()
739        .map(|c| BSplineCurve::rational(knots.clone(), c.control_points().to_vec()))
740        .collect()
741}
742
743/// A section of two rational pieces that meet tangent at their joint knot,
744/// reweighted so its weighted control points run smoothly through the
745/// joint: the joint's weighted point is the knot spans' blend of its two
746/// neighbours'. Each piece is reparameterized by the rational change of
747/// parameter that keeps its ends, so the curve is the same curve.
748///
749/// A loft blends its sections' weighted control points, and the blend
750/// keeps any linear relation all of them share. Sections whose joints are
751/// all such blends with the same spans make a surface tangent across the
752/// joint line between them; a section tangent at its joint with its
753/// weights left as they come (a circular arc at its middle knot) is not
754/// such a blend, and the skin between two arcs of different sweeps creases
755/// along the joint. Any other section is returned as it is.
756fn smooth_joint(curve: &BSplineCurve) -> BSplineCurve {
757    let degree = curve.degree();
758    let knots = curve.knots();
759    let interior: Vec<(f64, usize)> = knots
760        .distinct()
761        .into_iter()
762        .filter(|(v, _)| *v > KNOT_SAME && *v < 1.0 - KNOT_SAME)
763        .collect();
764    let control = curve.control_points();
765    let ([(at, multiplicity)], true) = (interior.as_slice(), control.len() == 2 * degree + 1)
766    else {
767        return curve.clone();
768    };
769    if *multiplicity != degree || degree == 0 {
770        return curve.clone();
771    }
772    let (start, end) = (knots.knots()[0], knots.knots()[knots.knots().len() - 1]);
773    let (left, right) = (at - start, end - at);
774    let joint = degree;
775    let (before, here, after) = (
776        control[joint - 1].point(),
777        control[joint].point(),
778        control[joint + 1].point(),
779    );
780    // Where the joint stands along the chord of its two neighbours, and
781    // how far off it.
782    let chord = after - before;
783    let length = chord.magnitude();
784    if length <= 0.0 || left <= 0.0 || right <= 0.0 {
785        return curve.clone();
786    }
787    let s = (here - before).dot(chord) / (length * length);
788    let off = (here - before - chord * s).magnitude();
789    if !(s > 0.0 && s < 1.0) || off > 1e-9 * length {
790        return curve.clone();
791    }
792    // The joint's weighted point as right/(left+right) of the one before
793    // and left/(left+right) of the one after.
794    let (lambda, mu) = (right / (left + right), left / (left + right));
795    let w = control[joint].weight;
796    let c_before = w * (1.0 - s) / lambda / control[joint - 1].weight;
797    let c_after = w * s / mu / control[joint + 1].weight;
798    let mut out = control.to_vec();
799    for i in 0..=degree {
800        let k = i32::try_from(i).unwrap_or(i32::MAX);
801        out[joint - i] = control[joint - i].scale(c_before.powi(k));
802        out[joint + i] = control[joint + i].scale(c_after.powi(k));
803    }
804    BSplineCurve::rational(knots.clone(), out).unwrap_or_else(|_| curve.clone())
805}
806
807/// The mean distance between two compatible sections' control points.
808fn section_chord(a: &Section, b: &Section, index: usize, tol: Tolerances) -> OgeomResult<f64> {
809    let mut sum = 0.0;
810    let mut count = 0.0;
811    for (ea, eb) in a.edges.iter().zip(&b.edges) {
812        for (p, q) in ea
813            .curve
814            .control_points()
815            .iter()
816            .zip(eb.curve.control_points())
817        {
818            sum += p.point().distance(q.point());
819            count += 1.0;
820        }
821    }
822    let chord = sum / count;
823    if chord <= tol.confusion() {
824        ogeom_bail!(
825            Construction,
826            "sections {index} and {} coincide; a skin between them has no extent",
827            index + 1
828        );
829    }
830    Ok(chord)
831}
832
833/// Cumulative chords over `[0, 1]`.
834fn unit_params(chords: &[f64]) -> Vec<f64> {
835    let total: f64 = chords.iter().sum();
836    let mut params = Vec::with_capacity(chords.len() + 1);
837    let mut run = 0.0;
838    params.push(0.0);
839    for c in chords {
840        run += c;
841        params.push(run / total);
842    }
843    let last = params.len() - 1;
844    params[last] = 1.0;
845    params
846}
847
848/// The rows of control points (one per section, homogeneous) interpolated
849/// across at `params`: cubic from four rows up, over the averaged knots.
850/// Returns the knots across and the interpolating rows.
851fn interpolated(
852    rows: &[Vec<Weighted<Point>>],
853    params: &[f64],
854    tol: Tolerances,
855) -> OgeomResult<(KnotVector, Vec<Vec<Weighted<Point>>>)> {
856    let n = rows.len();
857    let degree = (n - 1).min(3);
858    let knots = KnotVector::averaged(degree, params)?;
859    let mut matrix = vec![vec![0.0; n]; n];
860    for (k, v) in params.iter().enumerate() {
861        let span = knots.span(*v, tol)?;
862        for (b, j) in knots.basis(span, *v).iter().zip(span - degree..=span) {
863            matrix[k][j] = *b;
864        }
865    }
866    let inverse = inverted(matrix)?;
867    Ok((knots, combined(&inverse, rows)))
868}
869
870/// The rows interpolated round a loop: a periodic B-spline through every
871/// row and back to the first, the rows evenly spaced in its parameter,
872/// cubic from four rows up and quadratic for three. Cut open at the first
873/// row and clamped there, so its first and last rows are that row.
874fn interpolated_closed(
875    rows: &[Vec<Weighted<Point>>],
876    tol: Tolerances,
877) -> OgeomResult<(KnotVector, Vec<Vec<Weighted<Point>>>)> {
878    let n = rows.len();
879    let degree = (n - 1).min(3);
880    // An even degree is interpolated mid-span, where its basis is
881    // dominated by its own control; an odd one at the knots.
882    let shift = if degree.is_multiple_of(2) { 0.5 } else { 0.0 };
883    // The ring's controls Q[0..n) wrapped one before and degree + 1 after,
884    // over uniform knots: the periodic curve over a domain wide enough to
885    // cut a whole turn from inside it.
886    let count = n + degree + 2;
887    #[allow(clippy::cast_precision_loss)]
888    let knots = KnotVector::new((0..=count + degree).map(|i| i as f64).collect(), degree)?;
889    let ring = |j: usize| (j + n - 1) % n;
890    #[allow(clippy::cast_precision_loss)]
891    let at = |k: usize| (degree + 1 + k) as f64 + shift;
892    let mut matrix = vec![vec![0.0; n]; n];
893    for (k, row) in matrix.iter_mut().enumerate() {
894        let v = at(k);
895        let span = knots.span(v, tol)?;
896        for (b, j) in knots.basis(span, v).iter().zip(span - degree..=span) {
897            row[ring(j)] += *b;
898        }
899    }
900    let inverse = inverted(matrix)?;
901    let solved = combined(&inverse, rows);
902    let wrapped: Vec<Vec<Weighted<Point>>> = (0..count).map(|j| solved[ring(j)].clone()).collect();
903    #[allow(clippy::cast_precision_loss)]
904    let (from, to) = (at(0), at(n));
905    let width = rows[0].len();
906    let mut columns: Vec<Vec<Weighted<Point>>> = Vec::with_capacity(width);
907    let mut cut_knots: Option<KnotVector> = None;
908    for i in 0..width {
909        let column: Vec<Weighted<Point>> = wrapped.iter().map(|r| r[i]).collect();
910        let (_, (k1, c1)) = ogeom_math::bspline::split(&knots, &column, from, tol)?;
911        let ((k2, c2), _) = ogeom_math::bspline::split(&k1, &c1, to, tol)?;
912        cut_knots = Some(k2);
913        columns.push(c2);
914    }
915    let Some(cut_knots) = cut_knots else {
916        ogeom_bail!(Construction, "a section has no control points");
917    };
918    let l = columns[0].len();
919    let mut net: Vec<Vec<Weighted<Point>>> = (0..l)
920        .map(|j| columns.iter().map(|c| c[j]).collect())
921        .collect();
922    // The two ends are the first row, to rounding; one row, exactly.
923    net[l - 1] = net[0].clone();
924    Ok((cut_knots.reparameterized(0.0, 1.0)?, net))
925}
926
927/// `inverse` applied to the rows, row by row in homogeneous coordinates.
928fn combined(inverse: &[Vec<f64>], rows: &[Vec<Weighted<Point>>]) -> Vec<Vec<Weighted<Point>>> {
929    let width = rows[0].len();
930    inverse
931        .iter()
932        .map(|line| {
933            (0..width)
934                .map(|i| {
935                    line.iter()
936                        .zip(rows)
937                        .fold(Weighted::<Point>::zero(), |acc, (a, row)| {
938                            acc.add(row[i].scale(*a))
939                        })
940                })
941                .collect()
942        })
943        .collect()
944}
945
946/// The inverse of a small dense matrix, by Gauss-Jordan elimination with
947/// partial pivoting.
948fn inverted(mut a: Vec<Vec<f64>>) -> OgeomResult<Vec<Vec<f64>>> {
949    let n = a.len();
950    let mut inv: Vec<Vec<f64>> = (0..n)
951        .map(|i| (0..n).map(|j| if i == j { 1.0 } else { 0.0 }).collect())
952        .collect();
953    for col in 0..n {
954        let pivot = (col..n)
955            .max_by(|&x, &y| a[x][col].abs().total_cmp(&a[y][col].abs()))
956            .unwrap_or(col);
957        if a[pivot][col].abs() <= 1e-12 {
958            ogeom_bail!(Numeric, "the skin's interpolation system is singular");
959        }
960        a.swap(col, pivot);
961        inv.swap(col, pivot);
962        let d = a[col][col];
963        for j in 0..n {
964            a[col][j] /= d;
965            inv[col][j] /= d;
966        }
967        for r in 0..n {
968            if r == col {
969                continue;
970            }
971            let f = a[r][col];
972            if f == 0.0 {
973                continue;
974            }
975            for j in 0..n {
976                a[r][j] -= f * a[col][j];
977                inv[r][j] -= f * inv[col][j];
978            }
979        }
980    }
981    Ok(inv)
982}
983
984/// The patch whose `v`-rows are `rows`, over `u_knots` along them.
985fn surface_of(
986    u_knots: &KnotVector,
987    v_knots: KnotVector,
988    rows: &[Vec<Weighted<Point>>],
989    tol: Tolerances,
990) -> OgeomResult<BSplineSurface> {
991    let (k, l) = (rows[0].len(), rows.len());
992    let mut points = Vec::with_capacity(k * l);
993    for i in 0..k {
994        for row in rows {
995            let w = row[i];
996            if !w.weight.is_finite() || w.weight <= tol.confusion() {
997                ogeom_bail!(
998                    Construction,
999                    "the skin's weights fall to {} between the sections; sections this unlike \
1000                     cannot be skinned exactly",
1001                    w.weight
1002                );
1003            }
1004            points.push(w);
1005        }
1006    }
1007    BSplineSurface::rational(u_knots.clone(), v_knots, ControlGrid::new(points, k, l)?)
1008}
1009
1010/// One boundary side of a sheet face: the edge as the face runs it (bottom
1011/// along `u`, rails along `v`), its image in the chart at its own
1012/// parameter, and that parameter's range.
1013struct Side {
1014    edge: Shape,
1015    image: PlanarCurve,
1016    range: (f64, f64),
1017}
1018
1019/// The faces of a sheet over its surfaces, `surfaces[e][s]` the skin of
1020/// section edge `e` over span `s`, bounded by section edges and rails, the
1021/// faces chained into a shell where there are several.
1022///
1023/// `seam_v` marks a skin closed across, whose single span's first section
1024/// bounds the face on both sides of its chart.
1025#[allow(clippy::too_many_lines, reason = "one assembly, spelled out")]
1026fn sheet(
1027    model: &mut Model,
1028    sections: &[Section],
1029    surfaces: &[Vec<BSplineSurface>],
1030    spans: &[(usize, usize)],
1031    seam_v: bool,
1032    ruled: bool,
1033    tol: Tolerances,
1034) -> OgeomResult<Shape> {
1035    let count = sections[0].edges.len();
1036    let closed_u = sections[0].closed;
1037    let joints = if closed_u { count } else { count + 1 };
1038    let end_joint = |e: usize| if closed_u { (e + 1) % count } else { e + 1 };
1039
1040    // Neighbouring edges' skins share the rail at their corner only where
1041    // the corner's weights keep one ratio through every section.
1042    for j in 0..joints {
1043        let (before, after) = if closed_u {
1044            ((j + count - 1) % count, j)
1045        } else if j == 0 || j == count {
1046            continue;
1047        } else {
1048            (j - 1, j)
1049        };
1050        let ratio = |s: &Section| {
1051            let end = s.edges[before].curve.control_points();
1052            let start = s.edges[after].curve.control_points();
1053            end[end.len() - 1].weight / start[0].weight
1054        };
1055        let first = ratio(&sections[0]);
1056        if sections
1057            .iter()
1058            .any(|s| (ratio(s) - first).abs() > 1e-9 * first.abs())
1059        {
1060            ogeom_bail!(
1061                Construction,
1062                "neighbouring section edges carry weights at their shared corner {j} that \
1063                 change from section to section; their skins would part"
1064            );
1065        }
1066    }
1067
1068    // The sections that bound faces: their joint vertices and edges.
1069    let mut bounding: Vec<usize> = spans.iter().flat_map(|&(a, b)| [a, b]).collect();
1070    bounding.sort_unstable();
1071    bounding.dedup();
1072    let mut vertices: Vec<Option<Vec<Shape>>> = vec![None; sections.len()];
1073    let mut borders: Vec<Option<Vec<(Shape, bool)>>> = vec![None; sections.len()];
1074    for &k in &bounding {
1075        let section = &sections[k];
1076        let adopted = section.edges.iter().all(|e| e.edge.is_some());
1077        let mut joint_vertices = Vec::with_capacity(joints);
1078        let mut edges = Vec::with_capacity(count);
1079        if adopted {
1080            for e in &section.edges {
1081                let Some(edge) = &e.edge else {
1082                    ogeom_bail!(Construction, "an adopted section lost an edge");
1083                };
1084                let Some((start, _)) = edge_vertices(model, edge)? else {
1085                    ogeom_bail!(Construction, "a section edge has no vertices");
1086                };
1087                joint_vertices.push(start);
1088                edges.push((edge.clone(), true));
1089            }
1090            if !closed_u {
1091                let Some(last) = &section.edges[count - 1].edge else {
1092                    ogeom_bail!(Construction, "an adopted section lost an edge");
1093                };
1094                let Some((_, end)) = edge_vertices(model, last)? else {
1095                    ogeom_bail!(Construction, "a section edge has no vertices");
1096                };
1097                joint_vertices.push(end);
1098            }
1099        } else {
1100            for e in &section.edges {
1101                let at = e.curve.point_at(0.0, tol)?;
1102                joint_vertices.push(make_vertex(model, at).shape);
1103            }
1104            if !closed_u {
1105                let at = section.edges[count - 1].curve.point_at(1.0, tol)?;
1106                joint_vertices.push(make_vertex(model, at).shape);
1107            }
1108            for (e, piece) in section.edges.iter().enumerate() {
1109                let edge = make_edge_between(
1110                    model,
1111                    Curve::BSpline(piece.curve.clone()),
1112                    (0.0, 1.0),
1113                    &joint_vertices[e],
1114                    &joint_vertices[end_joint(e)],
1115                    tol,
1116                )?
1117                .shape;
1118                edges.push((edge, false));
1119            }
1120        }
1121        vertices[k] = Some(joint_vertices);
1122        borders[k] = Some(edges);
1123    }
1124    let vertex = |k: usize, j: usize| -> OgeomResult<Shape> {
1125        vertices[k]
1126            .as_ref()
1127            .map(|v| v[j].clone())
1128            .ok_or_else(|| ogeom_err!(Construction, "section {k} bounds no face"))
1129    };
1130
1131    // Rails: one per span and joint, shared by the faces either side.
1132    let mut rails: Vec<Vec<(Shape, bool)>> = Vec::with_capacity(spans.len());
1133    for (s, &(lo, hi)) in spans.iter().enumerate() {
1134        let mut per_joint = Vec::with_capacity(joints);
1135        for j in 0..joints {
1136            let (e, u) = if j < count {
1137                (j, 0.0)
1138            } else {
1139                (count - 1, 1.0)
1140            };
1141            let surface = &surfaces[e][s];
1142            let control = sections[lo].edges[e].curve.control_points();
1143            let index = if u == 0.0 { 0 } else { control.len() - 1 };
1144            let w_lo = control[index].weight;
1145            let w_hi = sections[hi].edges[e].curve.control_points()[index].weight;
1146            let (from, to) = (vertex(lo, j)?, vertex(hi, j)?);
1147            // A ruled rail between equal weights runs at an even pace: the
1148            // straight segment, its chart image the matching column.
1149            if ruled && (w_lo - w_hi).abs() <= 1e-12 * w_lo {
1150                let a = surface.point_at(u, 0.0, tol)?;
1151                let b = surface.point_at(u, 1.0, tol)?;
1152                let line: Curve = LineCurve::segment(a, b, tol)?.into();
1153                let range = line.domain();
1154                let edge = make_edge_between(model, line, range, &from, &to, tol)?.shape;
1155                per_joint.push((edge, true));
1156            } else {
1157                let curve = Curve::BSpline(surface.iso_u_curve(u, tol)?);
1158                let edge = make_edge_between(model, curve, (0.0, 1.0), &from, &to, tol)?.shape;
1159                per_joint.push((edge, false));
1160            }
1161        }
1162        rails.push(per_joint);
1163    }
1164
1165    let column = |u: f64, straight: bool, range: (f64, f64)| -> OgeomResult<PlanarCurve> {
1166        if straight {
1167            let knots = KnotVector::new(vec![range.0, range.0, range.1, range.1], 1)?;
1168            Ok(BSpline2d::new(knots, vec![Point2::new(u, 0.0), Point2::new(u, 1.0)], tol)?.into())
1169        } else {
1170            Ok(Line2d::over(Axis2::new(Point2::new(u, 0.0), Direction2::Y), -1.0, 2.0)?.into())
1171        }
1172    };
1173    let rail_range = |model: &Model, edge: &Shape| -> OgeomResult<(f64, f64)> {
1174        Ok(spine_curve_of(model, edge)?.1)
1175    };
1176
1177    let mut faces = Vec::with_capacity(count * spans.len());
1178    for e in 0..count {
1179        for (s, &(lo, hi)) in spans.iter().enumerate() {
1180            let surface = &surfaces[e][s];
1181            let geometry: SurfaceGeometry = surface.clone().into();
1182            let border = |k: usize| -> OgeomResult<(Shape, bool)> {
1183                borders[k]
1184                    .as_ref()
1185                    .map(|b| b[e].clone())
1186                    .ok_or_else(|| ogeom_err!(Construction, "section {k} bounds no face"))
1187            };
1188            let (bottom, bottom_adopted) = border(lo)?;
1189            let (top, top_adopted) = border(hi)?;
1190            let (rail0, straight0) = rails[s][e].clone();
1191            let (rail1, straight1) = rails[s][end_joint(e)].clone();
1192
1193            if ruled
1194                && let Some(plane) =
1195                    ruled_plane(&sections[lo].edges[e], &sections[hi].edges[e], tol)?
1196            {
1197                let reach = [0.0, 1.0]
1198                    .iter()
1199                    .flat_map(|u| [(*u, 0.0), (*u, 1.0)])
1200                    .map(|(u, v)| {
1201                        surface
1202                            .point_at(u, v, tol)
1203                            .map(|p| p.distance(plane.origin()))
1204                    })
1205                    .collect::<OgeomResult<Vec<f64>>>()?
1206                    .into_iter()
1207                    .fold(1.0_f64, f64::max)
1208                    * 2.0;
1209                let flat: SurfaceGeometry =
1210                    PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
1211                let face = make_face_with_pcurves(
1212                    model,
1213                    flat,
1214                    &[vec![bottom, rail1, top.reversed(), rail0.reversed()]],
1215                    tol,
1216                )?
1217                .shape;
1218                faces.push(face);
1219                continue;
1220            }
1221
1222            let row = |model: &mut Model,
1223                       edge: &Shape,
1224                       adopted: bool,
1225                       v: f64|
1226             -> OgeomResult<Side> {
1227                let (image, range) = if adopted {
1228                    let section = &sections[if v == 0.0 { lo } else { hi }].edges[e];
1229                    adopted_row(
1230                        model,
1231                        edge,
1232                        &section.curve,
1233                        section.paced,
1234                        v,
1235                        &geometry,
1236                        tol,
1237                    )?
1238                } else {
1239                    (
1240                        Line2d::over(Axis2::new(Point2::new(0.0, v), Direction2::X), -1.0, 2.0)?
1241                            .into(),
1242                        (0.0, 1.0),
1243                    )
1244                };
1245                Ok(Side {
1246                    edge: edge.clone(),
1247                    image,
1248                    range,
1249                })
1250            };
1251            let bottom_side = row(model, &bottom, bottom_adopted, 0.0)?;
1252            let top_side = row(model, &top, top_adopted, 1.0)?;
1253            let range0 = rail_range(model, &rail0)?;
1254            let range1 = rail_range(model, &rail1)?;
1255            let rail0_side = Side {
1256                image: column(0.0, straight0, range0)?,
1257                edge: rail0,
1258                range: range0,
1259            };
1260            let rail1_side = Side {
1261                image: column(1.0, straight1, range1)?,
1262                edge: rail1,
1263                range: range1,
1264            };
1265            debug_assert!(!seam_v || bottom_side.edge.is_partner(&top_side.edge));
1266            faces.push(sheet_face(
1267                model,
1268                geometry,
1269                bottom_side,
1270                rail1_side,
1271                top_side,
1272                rail0_side,
1273                tol,
1274            )?);
1275        }
1276    }
1277    if faces.len() == 1 {
1278        return Ok(faces.swap_remove(0));
1279    }
1280    Ok(make_shell(model, &faces)?.shape)
1281}
1282
1283/// The plane a ruled span between two straight segments lies in, framed so
1284/// its normal is the chart's (along the segments, then across), if their
1285/// four ends share one.
1286fn ruled_plane(a: &SectionEdge, b: &SectionEdge, tol: Tolerances) -> OgeomResult<Option<Plane>> {
1287    let straight = |c: &BSplineCurve| c.degree() == 1 && c.control_points().len() == 2;
1288    if !straight(&a.curve) || !straight(&b.curve) {
1289        return Ok(None);
1290    }
1291    let (a0, a1) = (a.curve.point_at(0.0, tol)?, a.curve.point_at(1.0, tol)?);
1292    let (b0, b1) = (b.curve.point_at(0.0, tol)?, b.curve.point_at(1.0, tol)?);
1293    let normal = [
1294        (a1 - a0).cross(b0 - a0),
1295        (a1 - a0).cross(b1 - a1),
1296        (b1 - b0).cross(b0 - a0),
1297    ]
1298    .into_iter()
1299    .max_by(|x, y| x.magnitude().total_cmp(&y.magnitude()))
1300    .unwrap_or(Vector::new(0.0, 0.0, 0.0));
1301    if normal.magnitude() <= tol.confusion() * (a1 - a0).magnitude().max(1.0) {
1302        return Ok(None);
1303    }
1304    let plane = Plane::through(a0, Direction::new(normal, tol)?);
1305    Ok([a1, b0, b1]
1306        .iter()
1307        .all(|p| plane.distance_to(*p) <= tol.confusion())
1308        .then_some(plane))
1309}
1310
1311/// A face on `surface` bounded by its four sides, each side's image
1312/// attached; a side that bounds the face twice is a seam.
1313fn sheet_face(
1314    model: &mut Model,
1315    surface: SurfaceGeometry,
1316    bottom: Side,
1317    rail1: Side,
1318    top: Side,
1319    rail0: Side,
1320    tol: Tolerances,
1321) -> OgeomResult<Shape> {
1322    let id = model.geometry_mut().add_surface(surface);
1323    let here = Location::identity;
1324    if bottom.edge.is_partner(&top.edge) {
1325        attach_seam(
1326            model,
1327            &bottom.edge,
1328            bottom.image,
1329            top.image,
1330            id,
1331            here(),
1332            bottom.range,
1333        )?;
1334    } else {
1335        attach_pcurve(model, &bottom.edge, bottom.image, id, here(), bottom.range)?;
1336        attach_pcurve(model, &top.edge, top.image, id, here(), top.range)?;
1337    }
1338    if rail0.edge.is_partner(&rail1.edge) {
1339        attach_seam(
1340            model,
1341            &rail1.edge,
1342            rail1.image,
1343            rail0.image,
1344            id,
1345            here(),
1346            rail1.range,
1347        )?;
1348    } else {
1349        attach_pcurve(model, &rail1.edge, rail1.image, id, here(), rail1.range)?;
1350        attach_pcurve(model, &rail0.edge, rail0.image, id, here(), rail0.range)?;
1351    }
1352    let wire = make_wire(
1353        model,
1354        &[
1355            bottom.edge,
1356            rail1.edge,
1357            top.edge.reversed(),
1358            rail0.edge.reversed(),
1359        ],
1360        tol,
1361    )?
1362    .shape;
1363    Ok(make_face_on(model, id, &[wire], tol)?.shape)
1364}
1365
1366/// The image of a caller's section edge on the row `v` of a skin, at the
1367/// edge's own parameter. A segment's or a spline's parameter maps onto the
1368/// row evenly, exactly, where `paced` says the section keeps it; a conic's
1369/// does not (its rational form runs at another pace), nor does a section
1370/// paced anew, and its image is fitted through the row at the edge's
1371/// parameters, the fit measured on the surface against the edge and the
1372/// edge widened to what it reached.
1373fn adopted_row(
1374    model: &mut Model,
1375    edge: &Shape,
1376    section: &BSplineCurve,
1377    paced: bool,
1378    v: f64,
1379    surface: &SurfaceGeometry,
1380    tol: Tolerances,
1381) -> OgeomResult<(PlanarCurve, (f64, f64))> {
1382    let (curve, range) = spine_curve_of(model, edge)?;
1383    let reversed = edge.orientation() == Orientation::Reversed;
1384    let even = paced
1385        && match &curve {
1386            Curve::Line(_) => true,
1387            Curve::BSpline(b) => b.knots().is_clamped(),
1388            _ => false,
1389        };
1390    if even {
1391        let (a, b) = if reversed { (1.0, 0.0) } else { (0.0, 1.0) };
1392        let knots = KnotVector::new(vec![range.0, range.0, range.1, range.1], 1)?;
1393        let image = BSpline2d::new(knots, vec![Point2::new(a, v), Point2::new(b, v)], tol)?;
1394        return Ok((image.into(), range));
1395    }
1396    // The section's rational spans meet with a jump in how fast the
1397    // angle turns into `u`: each span is fitted on its own and the pieces
1398    // joined at the spans' ends, where both agree exactly.
1399    let u_at = |t: f64, guess: f64| -> OgeomResult<f64> {
1400        foot_on(section, curve.point_at(t, tol)?, guess, tol)
1401    };
1402    // `u` along the edge's own parameter: rising, or falling where the
1403    // edge runs against the section.
1404    let ends = if reversed { (1.0, 0.0) } else { (0.0, 1.0) };
1405    let mut breaks: Vec<(f64, f64)> = vec![(range.0, ends.0)];
1406    let mut inner: Vec<f64> = section
1407        .knots()
1408        .distinct()
1409        .into_iter()
1410        .map(|(k, _)| k)
1411        .filter(|k| *k > KNOT_SAME && *k < 1.0 - KNOT_SAME)
1412        .collect();
1413    if reversed {
1414        inner.reverse();
1415    }
1416    for knot in inner {
1417        let (mut lo, mut hi) = (breaks[breaks.len() - 1].0, range.1);
1418        for _ in 0..80 {
1419            let mid = f64::midpoint(lo, hi);
1420            let below = u_at(mid, knot)? < knot;
1421            if below != reversed {
1422                lo = mid;
1423            } else {
1424                hi = mid;
1425            }
1426        }
1427        breaks.push((f64::midpoint(lo, hi), knot));
1428    }
1429    breaks.push((range.1, ends.1));
1430    // The chart's `u` runs the section once: a step in it moves the point
1431    // at most the control polygon's length.
1432    let reach: f64 = section
1433        .control_points()
1434        .windows(2)
1435        .map(|w| w[0].point().distance(w[1].point()))
1436        .sum();
1437    let target = tol.confusion() * 0.01 / reach.max(tol.confusion());
1438    const SAMPLES: u32 = 64;
1439    let mut joined: Option<(KnotVector, Vec<Weighted<Point2>>)> = None;
1440    for w in breaks.windows(2) {
1441        let ((ta, ua), (tb, ub)) = (w[0], w[1]);
1442        let mut params = Vec::with_capacity(SAMPLES as usize + 1);
1443        let mut image = Vec::with_capacity(SAMPLES as usize + 1);
1444        for i in 0..=SAMPLES {
1445            let f = f64::from(i) / f64::from(SAMPLES);
1446            let t = ta + (tb - ta) * f;
1447            let u = if i == 0 {
1448                ua
1449            } else if i == SAMPLES {
1450                ub
1451            } else {
1452                u_at(t, ua + (ub - ua) * f)?
1453            };
1454            params.push(t);
1455            image.push(Point2::new(u, v));
1456        }
1457        let fitted = ogeom_geom::fit::fit_points_2d_at(&params, &image, 3, target, tol)?;
1458        let mut control = fitted.curve.control_points().to_vec();
1459        // Pinned to the span's ends, which are exact.
1460        let last = control.len() - 1;
1461        control[0] = Weighted::new(Point2::new(ua, v), 1.0, tol)?;
1462        control[last] = Weighted::new(Point2::new(ub, v), 1.0, tol)?;
1463        let piece = (fitted.curve.knots().clone(), control);
1464        joined = Some(match joined {
1465            None => piece,
1466            Some(before) => ogeom_math::bspline::join(&before, &piece)?,
1467        });
1468    }
1469    let Some((knots, control)) = joined else {
1470        ogeom_bail!(Construction, "a section edge has no extent");
1471    };
1472    let pcurve: PlanarCurve = BSpline2d::rational(knots, control)?.into();
1473    let mut worst: f64 = 0.0;
1474    for i in 0..=4096 {
1475        let t = range.0 + (range.1 - range.0) * f64::from(i) / 4096.0;
1476        let q = pcurve.point_at(t, tol)?;
1477        let on = surface.point_at(q.x, q.y, tol)?;
1478        worst = worst.max(on.distance(curve.point_at(t, tol)?));
1479    }
1480    if worst > tol.confusion() * 100.0 {
1481        ogeom_bail!(
1482            NotDone,
1483            "a section edge's image on the skin strays {worst:.3e} from the edge"
1484        );
1485    }
1486    if worst > tol.confusion() {
1487        let widened = ogeom_core::Tolerance::new(worst + tol.confusion())?;
1488        model.widen(edge, widened)?;
1489        if let Some((a, b)) = edge_vertices(model, edge)? {
1490            model.widen(&a, widened)?;
1491            model.widen(&b, widened)?;
1492        }
1493    }
1494    Ok((pcurve, range))
1495}
1496
1497/// The parameter of `section` at `p`, which lies on it: Newton's method
1498/// from `guess`.
1499fn foot_on(section: &BSplineCurve, p: Point, guess: f64, tol: Tolerances) -> OgeomResult<f64> {
1500    let mut u = guess;
1501    for _ in 0..64 {
1502        let c = section.point_at(u, tol)?;
1503        let d = section.d1_at(u, tol)?;
1504        let speed = d.dot(d);
1505        if speed <= f64::MIN_POSITIVE {
1506            break;
1507        }
1508        let next = (u + (p - c).dot(d) / speed).clamp(0.0, 1.0);
1509        let moved = (next - u).abs();
1510        u = next;
1511        if moved <= 1e-15 {
1512            break;
1513        }
1514    }
1515    let off = section.point_at(u, tol)?.distance(p);
1516    if off > tol.confusion() * 10.0 {
1517        ogeom_bail!(
1518            NotDone,
1519            "a section edge's point {p:?} was not found on its exact form ({off:.3e} away)"
1520        );
1521    }
1522    Ok(u)
1523}
1524
1525/// Where a sweep places its profile at one density: a run of motions and
1526/// the motions at which the path passes from one piece to the next.
1527struct Placements {
1528    /// An odd run of motions, the first the identity.
1529    motions: Vec<Transform>,
1530    /// Indices into `motions`, each even, where one piece of the path
1531    /// ends and the next begins.
1532    breaks: Vec<usize>,
1533}
1534
1535/// A sheet swept from copies of `profile` placed by `motions`: for a
1536/// density, an odd run of placements whose even members the skin passes
1537/// through and whose odd members (halfway between) it is measured against.
1538/// The skin is one face per profile edge and piece of the path, each
1539/// piece's skin interpolating its own copies, so a path that is smooth
1540/// only to its tangent at a break is not smoothed across it. The density
1541/// doubles until the skin keeps within `target` of the halfway copies.
1542fn swept_sheet(
1543    model: &mut Model,
1544    profile: &Section,
1545    motions: impl Fn(&Model, usize) -> OgeomResult<Placements>,
1546    target: f64,
1547    tol: Tolerances,
1548) -> OgeomResult<Shape> {
1549    let count = profile.edges.len();
1550    let mut density = 1;
1551    let mut reached = (f64::INFINITY, 0);
1552    loop {
1553        let Placements {
1554            motions: placed,
1555            breaks,
1556        } = motions(model, density)?;
1557        let skin_count = placed.len().div_ceil(2);
1558        if skin_count > MOST_SWEEP_SECTIONS {
1559            ogeom_bail!(
1560                NotDone,
1561                "the sweep's skin reached {:.3e} against a target of {target:.3e} through {} \
1562                 sections",
1563                reached.0,
1564                reached.1
1565            );
1566        }
1567        let mut sections: Vec<Section> = Vec::with_capacity(skin_count);
1568        for (k, motion) in placed.iter().step_by(2).enumerate() {
1569            let mut edges = Vec::with_capacity(count);
1570            for piece in &profile.edges {
1571                edges.push(SectionEdge {
1572                    edge: if k == 0 { piece.edge.clone() } else { None },
1573                    curve: moved(&piece.curve, motion, tol)?,
1574                    paced: piece.paced,
1575                });
1576            }
1577            sections.push(Section {
1578                edges,
1579                closed: profile.closed,
1580            });
1581        }
1582        let mut bounds = vec![0];
1583        bounds.extend(
1584            breaks
1585                .iter()
1586                .filter(|b| **b % 2 == 0)
1587                .map(|b| b / 2)
1588                .filter(|k| (1..skin_count - 1).contains(k)),
1589        );
1590        bounds.push(skin_count - 1);
1591        let spans: Vec<(usize, usize)> = bounds.windows(2).map(|w| (w[0], w[1])).collect();
1592        let mut surfaces: Vec<Vec<BSplineSurface>> = vec![Vec::with_capacity(spans.len()); count];
1593        let mut span_params = Vec::with_capacity(spans.len());
1594        for &(lo, hi) in &spans {
1595            let mut chords = Vec::with_capacity(hi - lo);
1596            for k in lo..hi {
1597                chords.push(section_chord(&sections[k], &sections[k + 1], k, tol)?);
1598            }
1599            let params = unit_params(&chords);
1600            for (e, per_span) in surfaces.iter_mut().enumerate() {
1601                let rows: Vec<Vec<Weighted<Point>>> = sections[lo..=hi]
1602                    .iter()
1603                    .map(|s| s.edges[e].curve.control_points().to_vec())
1604                    .collect();
1605                let (v_knots, net) = interpolated(&rows, &params, tol)?;
1606                per_span.push(surface_of(
1607                    profile.edges[e].curve.knots(),
1608                    v_knots,
1609                    &net,
1610                    tol,
1611                )?);
1612            }
1613            span_params.push(params);
1614        }
1615        // Halfway copies, projected onto the skin of their span from where
1616        // they should land.
1617        let mut worst: f64 = 0.0;
1618        for (s, &(lo, hi)) in spans.iter().enumerate() {
1619            let params = &span_params[s];
1620            for (e, piece) in profile.edges.iter().enumerate() {
1621                let geometry: SurfaceGeometry = surfaces[e][s].clone().into();
1622                for k in lo..hi {
1623                    let motion = &placed[2 * k + 1];
1624                    let guess_v = f64::midpoint(params[k - lo], params[k - lo + 1]);
1625                    for i in 0..=16 {
1626                        let u = f64::from(i) / 16.0;
1627                        let p = motion.apply(piece.curve.point_at(u, tol)?);
1628                        let foot =
1629                            ogeom_algo::project_on_surface_from(&geometry, p, (u, guess_v), tol)?;
1630                        worst = worst.max(foot.distance);
1631                    }
1632                }
1633            }
1634        }
1635        reached = (worst, skin_count);
1636        if worst <= target {
1637            return sheet(model, &sections, &surfaces, &spans, false, false, tol);
1638        }
1639        density *= 2;
1640    }
1641}
1642
1643/// Stations along an open spine with no sharp corner, evenly by parameter
1644/// on each edge, an even number per edge by turning (at most a
1645/// sixty-fourth of a turn apart at density one), `density` times as many.
1646fn sweep_stations(
1647    model: &Model,
1648    spine: &Shape,
1649    density: usize,
1650    tol: Tolerances,
1651) -> OgeomResult<Vec<SpineStation>> {
1652    let edges: Vec<Shape> = match model.kind_of(spine)? {
1653        ShapeType::Edge => vec![spine.clone()],
1654        ShapeType::Wire => model.ordered_children_of(spine)?,
1655        other => ogeom_bail!(
1656            Construction,
1657            "a sweep runs along an edge or a wire, not a {other:?}"
1658        ),
1659    };
1660    if edges.is_empty() {
1661        ogeom_bail!(Construction, "the spine has no edge to run along");
1662    }
1663    let mut out: Vec<SpineStation> = Vec::new();
1664    for (ei, edge) in edges.iter().enumerate() {
1665        let (curve, range) = spine_curve_of(model, edge)?;
1666        let curve = curve.transformed(&edge.transform(model.datums())?, tol)?;
1667        let reversed = edge.orientation() == Orientation::Reversed;
1668        let mut turning = 0.0_f64;
1669        let mut last: Option<Vector> = None;
1670        for i in 0..=16 {
1671            let t = range.0 + (range.1 - range.0) * f64::from(i) / 16.0;
1672            let d = curve.d1_at(t, tol)?;
1673            if d.magnitude() <= tol.confusion() {
1674                continue;
1675            }
1676            let unit = d / d.magnitude();
1677            if let Some(prev) = last {
1678                turning += prev.dot(unit).clamp(-1.0, 1.0).acos();
1679            }
1680            last = Some(unit);
1681        }
1682        #[allow(
1683            clippy::cast_possible_truncation,
1684            clippy::cast_sign_loss,
1685            reason = "a station count, bounded"
1686        )]
1687        let count =
1688            (turning / (core::f64::consts::TAU / 64.0)).ceil().max(8.0) as usize * 2 * density;
1689        for i in 0..=count {
1690            #[allow(clippy::cast_precision_loss)]
1691            let f = i as f64 / count as f64;
1692            let t = if reversed {
1693                range.1 - (range.1 - range.0) * f
1694            } else {
1695                range.0 + (range.1 - range.0) * f
1696            };
1697            let p = curve.point_at(t, tol)?;
1698            let d = curve.d1_at(t, tol)?;
1699            if d.magnitude() <= tol.confusion() {
1700                ogeom_bail!(Construction, "the spine stands still at {p:?}");
1701            }
1702            let tangent = (if reversed { -d } else { d }) / d.magnitude();
1703            if let Some(prev) = out.last()
1704                && prev.at.distance(p) <= tol.confusion()
1705            {
1706                if i == 0 && prev.tangent.dot(tangent) >= 1.0 - 1e-12 {
1707                    continue;
1708                }
1709                ogeom_bail!(
1710                    Construction,
1711                    "the spine turns a sharp corner at {p:?}; a sweep surface follows a \
1712                     spine whose direction is continuous"
1713                );
1714            }
1715            out.push(SpineStation {
1716                at: p,
1717                tangent,
1718                edge: ei,
1719                t,
1720            });
1721        }
1722    }
1723    if out[0].at.distance(out[out.len() - 1].at) <= tol.confusion() {
1724        ogeom_bail!(
1725            Construction,
1726            "a sweep surface along a closed spine is not built; sweep along an open one"
1727        );
1728    }
1729    Ok(out)
1730}
1731
1732/// A rail as a run of curves measured by length.
1733struct Rail {
1734    /// Each edge's curve where it stands, its range, whether it runs
1735    /// backwards, and its length.
1736    pieces: Vec<(Curve, (f64, f64), bool, f64)>,
1737    total: f64,
1738}
1739
1740impl Rail {
1741    fn read(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Self> {
1742        let edges = match model.kind_of(shape)? {
1743            ShapeType::Edge => vec![shape.clone()],
1744            ShapeType::Wire => model.ordered_children_of(shape)?,
1745            other => ogeom_bail!(Construction, "a rail is an edge or a wire, not a {other:?}"),
1746        };
1747        let mut pieces = Vec::with_capacity(edges.len());
1748        let mut total = 0.0;
1749        for edge in &edges {
1750            let (curve, range) = spine_curve_of(model, edge)?;
1751            let curve = curve.transformed(&edge.transform(model.datums())?, tol)?;
1752            let length = ogeom_algo::curve_length(&curve, range, tol)?;
1753            total += length;
1754            pieces.push((
1755                curve,
1756                range,
1757                edge.orientation() == Orientation::Reversed,
1758                length,
1759            ));
1760        }
1761        if total <= tol.confusion() {
1762            ogeom_bail!(Construction, "a rail has no length");
1763        }
1764        let rail = Self { pieces, total };
1765        let (head, tail) = (rail.at(0.0, tol)?.0, rail.at(1.0, tol)?.0);
1766        if head.distance(tail) <= tol.confusion() {
1767            ogeom_bail!(
1768                Construction,
1769                "a two-rail sweep runs along open rails; a rail is closed"
1770            );
1771        }
1772        Ok(rail)
1773    }
1774
1775    /// The point and unit tangent at fraction `f` of the rail's length.
1776    fn at(&self, f: f64, tol: Tolerances) -> OgeomResult<(Point, Vector)> {
1777        let mut along = self.total * f.clamp(0.0, 1.0);
1778        let last = self.pieces.len() - 1;
1779        for (i, (curve, range, reversed, length)) in self.pieces.iter().enumerate() {
1780            if along > *length && i < last {
1781                along -= length;
1782                continue;
1783            }
1784            let into = along.min(*length);
1785            let t = if *reversed {
1786                ogeom_algo::parameter_at_length(curve, *range, length - into, tol)?
1787            } else {
1788                ogeom_algo::parameter_at_length(curve, *range, into, tol)?
1789            };
1790            let d = curve.d1_at(t, tol)?;
1791            if d.magnitude() <= tol.confusion() {
1792                ogeom_bail!(Construction, "a rail stands still at {t}");
1793            }
1794            let tangent = (if *reversed { -d } else { d }) / d.magnitude();
1795            return Ok((curve.point_at(t, tol)?, tangent));
1796        }
1797        Err(ogeom_err!(Construction, "a rail has no edges"))
1798    }
1799}