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