Skip to main content

ogeom_fillet/
fill_n.rs

1//! The N-sided filling: one face over a hole bounded by any number of
2//! edges, meeting each side's face at G0, G1 or G2.
3
4use ogeom_algo::{
5    Built, History, attach_pcurve, edge_vertices, make_edge_between, make_face, make_vertex,
6    make_wire,
7};
8use ogeom_core::{OgeomResult, Tolerance, Tolerances, ogeom_bail};
9use ogeom_geom::{
10    BSpline2d, BSplineSurface, Continuity, Curve, Curve2d as _, Curve3d as _, CurveKind,
11    PlanarCurve, Surface as _, SurfaceCurvature, SurfaceGeometry, Transformable as _, Trig2d,
12};
13use ogeom_math::{Direction, Point, Point2, Vector, Vector2, Weighted};
14use ogeom_topo::{
15    EdgeRepr, Filter, Location, Model, NodeData, Orientation, Shape, ShapeType, explore,
16};
17
18use crate::fill_patch::{Condition, DEGREE, Factor, PlaneFrame, fit_free, fit_height_with};
19
20/// One side of an N-sided filling.
21#[derive(Debug, Clone)]
22pub struct FillBoundary {
23    /// The boundary edge, placed or not. The filling's face is bounded by
24    /// this edge node itself where the edge is not placed and its ends are
25    /// the vertex nodes its neighbours' ends are; otherwise by a new edge
26    /// on the edge's curve, where it stands, between vertices shared with
27    /// the neighbouring sides, which the history records as generated from
28    /// this edge. Either way the face sews to the edge's own faces.
29    pub edge: Shape,
30    /// The face the edge belongs to, placed or not, which the filling
31    /// meets across it; the edge as an edge of this face, at the same
32    /// placement.
33    /// Required for [`Continuity::G1`] and [`Continuity::G2`]; on a
34    /// [`Continuity::C0`] side it is measured against and helps decide which
35    /// way the filling faces.
36    pub support: Option<Shape>,
37    /// How the filling meets `support`: [`Continuity::C0`] (position),
38    /// [`Continuity::G1`] (tangent plane) or [`Continuity::G2`] (tangent
39    /// plane and normal curvature).
40    pub continuity: Continuity,
41}
42
43/// What a filling achieved along one side, measured at stations spread
44/// over the edge.
45#[derive(Debug, Clone, PartialEq)]
46pub struct FillSide {
47    /// The edge bounding the filling along this side: the side's own edge,
48    /// or the new edge standing in for it.
49    pub edge: Shape,
50    /// The largest distance, in model units, between the edge's curve and
51    /// the filling's surface read through the edge's pcurve on it.
52    pub gap: f64,
53    /// The largest angle, in radians, between the filling's normal and the
54    /// support's; `None` on a side with no support.
55    pub angle: Option<f64>,
56    /// The largest difference, in inverse model units, between the
57    /// filling's and the support's normal curvatures square to the edge,
58    /// signed against one shared normal; `None` on a side with no support
59    /// or where no station gave a curvature on both surfaces.
60    pub curvature: Option<f64>,
61    /// How many stations were measured.
62    pub stations: usize,
63}
64
65/// A filling and what it achieved.
66#[derive(Debug, Clone)]
67pub struct Filled {
68    /// The face; every boundary edge and constraint generates it.
69    pub built: Built,
70    /// One report per side, in the order the sides were given.
71    pub sides: Vec<FillSide>,
72    /// The largest distance, along the filling plane's normal, from a
73    /// constraint point or a sampled point of a constraint curve to the
74    /// surface; zero with no constraints.
75    pub constraint_gap: f64,
76}
77
78/// Samples per side for the loop's outline.
79const OUTLINE_SAMPLES: usize = 64;
80/// The margin round the hole, as a share of its larger extent.
81const MARGIN: f64 = 0.05;
82/// The control counts across the larger extent, round by round.
83const NETS: [usize; 6] = [8, 12, 18, 27, 40, 60];
84/// Further control counts, each half again the last, tried only while no
85/// round has met the tolerance: where a side's support turns tightly into
86/// a corner beside a flat one (a narrow band's end meeting a face's trim),
87/// the tangency there closes about twofold a round. A filling that met its
88/// tolerance stops on [`NETS`] as it would without them.
89const FURTHER: [usize; 2] = [90, 135];
90/// The share of the tolerance the bending energy may cost the conditions;
91/// see [`smoothing_for`].
92const BUDGET: f64 = 0.1;
93/// The least weight of the bending energy: below it rounding rather than
94/// the energy would settle the controls the conditions leave free.
95const LEAST_SMOOTHING: f64 = 1e-12;
96/// The least cosine between a support's normal and the plane's normal for
97/// a height over the plane to meet it (about 84 degrees between them);
98/// below it the free patch does.
99const MIN_LIFT: f64 = 0.1;
100/// A constraint point's weight, as the share of the hole's size a boundary
101/// sample of that length would carry.
102const POINT_SHARE: f64 = 0.05;
103/// Samples per constraint curve.
104const PER_CURVE: usize = 32;
105/// The least share of each corner's turn the loop keeps seen along its
106/// plane's normal for a height over that plane to fill it; below it the
107/// free patch does.
108const KEEP: f64 = 0.25;
109/// Stations per tangent side at which its support's lift off the plane is
110/// read.
111const STEEP_SAMPLES: usize = 256;
112/// Samples per side for the chart the free patch is drawn over.
113const CHART_SAMPLES: usize = 128;
114/// The largest share of a half turn a corner of that chart turns.
115const MOST_TURN: f64 = 0.9;
116/// The least angle, in radians, between the tangents meeting at a corner
117/// of the loop for it to count as one: about a degree.
118const CORNER: f64 = 0.0175;
119
120/// Fill the hole a loop of edges bounds with one face meeting each side's
121/// support at the continuity asked.
122///
123/// The sides may come in any order and either direction; they must chain
124/// into one simple closed loop, each side's end meeting the next one's at
125/// a shared vertex node or where the two vertices lie within their own and
126/// their edges' tolerances of each other. `constraints` are vertices and
127/// edges inside the hole that the surface passes through. The surface is a
128/// cubic B-spline height patch over the plane the loop spans, fitted by least
129/// squares to the sides' positions, to the tangent planes of G1 and G2
130/// sides' supports and to the normal curvatures of G2 sides' supports, with
131/// a thin-plate bending energy settling the rest, weighted from
132/// `tolerance` so it costs the conditions a small share of it; the control
133/// net is refined until every side meets `tolerance` and the conditions'
134/// residual no longer outweighs the energy, or the refinement runs out.
135/// Where a corner of the loop is seen smooth along the plane's normal (two
136/// sides meeting at an angle, each in a plane through that normal), no
137/// height over the plane meets both sides there; nor does one where a G1
138/// or G2 side's support stands within about 6 degrees of square to the
139/// plane, as a tube's wall does to its rim. The patch is then fitted in all
140/// three coordinates over a chart drawn from the loop itself, whose
141/// corners are the loop's: a tangent side fixes the patch's derivative
142/// across it to run into the support, square to the edge, at the side's
143/// own speed (so a cap leaves a tube's rim along the wall and crowns above
144/// it), and a G2 side fixes the second derivative across it to bend as the
145/// support does; the net is refined until the patch also does not fold
146/// over inside the hole.
147/// The face is trimmed by the given edges themselves where they are not
148/// placed and share vertex nodes end to end, each given a pcurve on the
149/// patch; any other side is stood in for by a new edge on its curve
150/// between vertices shared round the loop, so the face is one closed wire
151/// either way. A placed edge or support is read where it stands. The face
152/// faces the way the supports say: across each edge it
153/// runs opposite to its support's use of the edge, so sewing it to the
154/// supports gives a consistently oriented shell. With no supports it faces
155/// the side the loop turns counter-clockwise about, walked from the first
156/// side in that edge's own direction.
157///
158/// `tolerance` is the target for every measured deviation: a distance in
159/// model units for each side's gap and each constraint, an angle in radians
160/// for a G1 or G2 side's tangency, and a curvature difference in inverse
161/// model units for a G2 side. An edge the surface stands further from than
162/// the edge's own tolerance has that tolerance, and its vertices', widened
163/// to the measured gap.
164///
165/// # Errors
166///
167/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction), by
168/// name, where:
169///
170/// - `tolerance` is not positive and finite, or there are no sides;
171/// - a side is not an edge, or has no 3D curve or no vertices;
172/// - the same edge is given twice;
173/// - a side asks [`Continuity::C1`], [`Continuity::C2`] or
174///   [`Continuity::CInfinity`] (parametric continuity between two
175///   surfaces' charts), or G1 or G2 with no support;
176/// - a support is not a face, does not hold its edge, or the
177///   edge does not lie on its surface within `tolerance`;
178/// - the sides do not chain into one closed loop;
179/// - the loop encloses no area, or crosses itself seen along the normal of
180///   the plane it spans (the plane it encloses the most area seen square
181///   to), so the hole is not a height field over that plane;
182/// - two sides meet at a corner where a G1 or G2 side's support stands off
183///   the other side's tangent by more than `tolerance`, so no surface is
184///   tangent to it there;
185/// - a G1 or G2 side's support uses its edge both ways or neither where the
186///   patch is drawn over the loop's own chart;
187/// - a constraint is neither a vertex nor an edge, or lies outside the hole
188///   seen along the plane's normal, or is given where the patch is drawn
189///   over the loop's own chart (the message names the corner or the
190///   support's angle to the plane that called for it);
191/// - the loop's corners turn so far that no chart drawn from it closes.
192///
193/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the finest
194/// control net still misses `tolerance`, naming the side and the deviation.
195pub fn make_filling_n(
196    model: &mut Model,
197    boundary: &[FillBoundary],
198    constraints: &[Shape],
199    tolerance: f64,
200    tol: Tolerances,
201) -> OgeomResult<Filled> {
202    make_filling_n_within(model, boundary, constraints, tolerance, &[], tol)
203}
204
205/// [`make_filling_n`], the patch also held inside the half-spaces
206/// `within`, each an outward unit normal and a point on its plane: a net
207/// whose patch stands past any of them by more than `tolerance` inside the
208/// hole misses as a side does, and the net is refined on; where no net
209/// holds it, the filling is refused by name. A corner closed against the
210/// planes of a convex vertex passes those planes, so the patch takes
211/// nothing outside the solid. With none it is [`make_filling_n`].
212pub(crate) fn make_filling_n_within(
213    model: &mut Model,
214    boundary: &[FillBoundary],
215    constraints: &[Shape],
216    tolerance: f64,
217    within: &[(Vector, Point)],
218    tol: Tolerances,
219) -> OgeomResult<Filled> {
220    if within.is_empty() {
221        return filling(model, boundary, constraints, tolerance, None, tol);
222    }
223    // The half-spaces as one region: how far past the plane the point
224    // stands farthest past, and that plane's normal.
225    let region = |p: Point| -> (f64, Vector) {
226        let mut worst = (f64::NEG_INFINITY, within[0].0);
227        for (normal, on) in within {
228            let d = normal.dot(p - *on);
229            if d > worst.0 {
230                worst = (d, *normal);
231            }
232        }
233        worst
234    };
235    make_filling_n_region(model, boundary, constraints, tolerance, &region, tol)
236}
237
238/// What a region a filling is held inside says of a point: its signed
239/// distance outside (positive outside) and the outward unit normal of the
240/// region's boundary nearest it.
241pub(crate) type Region<'a> = &'a (dyn Fn(Point) -> (f64, Vector) + Sync);
242
243/// [`make_filling_n`], the patch held inside `region`: a net whose patch
244/// stands outside it by more than `tolerance` inside the hole misses as a
245/// side does, as [`make_filling_n_within`] holds it inside half-spaces,
246/// which are one such region.
247pub(crate) fn make_filling_n_region(
248    model: &mut Model,
249    boundary: &[FillBoundary],
250    constraints: &[Shape],
251    tolerance: f64,
252    region: Region<'_>,
253    tol: Tolerances,
254) -> OgeomResult<Filled> {
255    filling(model, boundary, constraints, tolerance, Some(region), tol)
256}
257
258/// The filling of `boundary` fitted once, on a net of `controls` across
259/// its larger extent, and built whatever it achieved: the patch, each
260/// side's report (its gap and its angle from its support), and how far it
261/// stands past `region`, the most past any of the region's pieces inside
262/// the hole (or negative infinity with none). Nothing is refined until a
263/// check passes; the caller reads the residual and decides.
264///
265/// # Errors
266///
267/// As [`make_filling_n`], except that a net missing its tolerance is
268/// built and reported rather than refused.
269pub(crate) fn make_filling_n_fixed(
270    model: &mut Model,
271    boundary: &[FillBoundary],
272    constraints: &[Shape],
273    controls: usize,
274    region: Option<Region<'_>>,
275    tol: Tolerances,
276) -> OgeomResult<(Filled, f64)> {
277    let tolerance = crate::marched::band_fit_target(tol);
278    filling_with(
279        model,
280        boundary,
281        constraints,
282        tolerance,
283        region,
284        Some(controls),
285        tol,
286    )
287}
288
289/// [`make_filling_n_region`], or [`make_filling_n`] with no region.
290fn filling(
291    model: &mut Model,
292    boundary: &[FillBoundary],
293    constraints: &[Shape],
294    tolerance: f64,
295    within: Option<Region<'_>>,
296    tol: Tolerances,
297) -> OgeomResult<Filled> {
298    Ok(filling_with(model, boundary, constraints, tolerance, within, None, tol)?.0)
299}
300
301/// [`filling`], its nets refined until one holds `tolerance`, or, with
302/// `fixed`, fitted once on that net and built whatever it achieved; with
303/// how far the patch stands past `within`.
304#[allow(clippy::too_many_lines, reason = "one filling, read, fitted and built")]
305fn filling_with(
306    model: &mut Model,
307    boundary: &[FillBoundary],
308    constraints: &[Shape],
309    tolerance: f64,
310    within: Option<Region<'_>>,
311    fixed: Option<usize>,
312    tol: Tolerances,
313) -> OgeomResult<(Filled, f64)> {
314    if !(tolerance.is_finite() && tolerance > 0.0) {
315        ogeom_bail!(
316            Construction,
317            "a filling's tolerance is positive and finite; got {tolerance}"
318        );
319    }
320    if boundary.is_empty() {
321        ogeom_bail!(Construction, "a filling needs at least one boundary edge");
322    }
323    let mut sides = Vec::with_capacity(boundary.len());
324    for (i, entry) in boundary.iter().enumerate() {
325        sides.push(read_side(model, i, entry, tolerance, tol)?);
326    }
327    for i in 0..sides.len() {
328        for j in (i + 1)..sides.len() {
329            if sides[i].given.is_same(&sides[j].given) {
330                ogeom_bail!(Construction, "side {j} is side {i}'s edge again");
331            }
332        }
333    }
334    let mut order = chain(model, &mut sides, tol)?;
335    face_the_supports(model, &mut sides, &mut order)?;
336
337    // The plane the loop spans, and the loop seen along its normal.
338    let outline = loop_points(&sides, &order, tol)?;
339    let frame = frame_of(&outline, tol)?;
340    let chart_outline: Vec<Point2> = outline.iter().map(|p| frame.chart(*p)).collect();
341    if crosses_itself(&chart_outline) {
342        ogeom_bail!(
343            Construction,
344            "the boundary loop crosses itself seen along the normal of the \
345             plane it spans; the hole is not a height field over that plane"
346        );
347    }
348    let interior = constraint_points(model, constraints, tol)?;
349    for (k, (p, _)) in interior.iter().enumerate() {
350        if !inside(&chart_outline, frame.chart(*p)) {
351            ogeom_bail!(
352                Construction,
353                "a point of constraint {k} at {p:?} lies outside the hole seen \
354                 along the normal of the plane the boundary spans"
355            );
356        }
357    }
358
359    // A corner seen smooth along the plane's normal asks a height over the
360    // plane for two slopes at one point, one from each side; the free patch
361    // over a chart drawn from the loop, whose corners are the loop's, takes
362    // it. So does a support standing square to the plane, which asks a
363    // height for an infinite slope: the free patch's derivative across a
364    // side is a vector, which may run square to the plane.
365    let corners = corners_of(&sides, &order, tol)?;
366    tangent_corners(&sides, &order, &corners, tolerance, tol)?;
367    let steepest = steepest_support(&sides, &frame, tol)?;
368    let free =
369        kept_turn(frame.n, &corners) < KEEP || steepest.is_some_and(|(_, lift)| lift < MIN_LIFT);
370    if free && !interior.is_empty() {
371        let why = match steepest {
372            Some((i, lift)) if lift < MIN_LIFT => format!(
373                "side {i}'s support stands at {:.1} degrees to the plane the \
374                 boundary spans, past the {:.1} a height over that plane takes",
375                lift.acos().to_degrees(),
376                MIN_LIFT.acos().to_degrees()
377            ),
378            _ => "a corner of the boundary loop is seen smooth along the normal \
379                  of the plane it spans"
380                .to_owned(),
381        };
382        ogeom_bail!(
383            Construction,
384            "{why}, so the filling is drawn over a chart of the loop's own, \
385             which places no interior constraints"
386        );
387    }
388    let mut traces = Vec::with_capacity(sides.len());
389    let mut lengths = Vec::with_capacity(sides.len());
390    for side in &sides {
391        if !free {
392            traces.push(trace(side, &frame, tolerance, tol)?);
393        }
394        lengths.push(side_length(side, tol)?);
395    }
396    let chart_outline = if free {
397        traces = boundary_chart(&sides, &order, &corners, tolerance, tol)?;
398        traced_outline(&sides, &order, &traces, tol)?
399    } else {
400        chart_outline
401    };
402
403    // The rectangle the patch covers: the hole and a margin round it.
404    let (mut lo, mut hi) = (
405        Point2::new(f64::INFINITY, f64::INFINITY),
406        Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
407    );
408    for q in &chart_outline {
409        lo = Point2::new(lo.x.min(q.x), lo.y.min(q.y));
410        hi = Point2::new(hi.x.max(q.x), hi.y.max(q.y));
411    }
412    let margin = MARGIN * (hi.x - lo.x).max(hi.y - lo.y);
413    let domain = (
414        (lo.x - margin, hi.x + margin),
415        (lo.y - margin, hi.y + margin),
416    );
417    let (du, dv) = (domain.0.1 - domain.0.0, domain.1.1 - domain.1.0);
418    let size = du.max(dv);
419
420    let smoothing = smoothing_for(&sides, tolerance, size);
421    let mut last_miss = String::new();
422    // The latest fit that met the tolerance.
423    let mut fallback = None;
424    // The first round whose net met every side, within the planes or not,
425    // and its fit, which a hold pass from that net starts from.
426    let mut sides_met: Option<usize> = None;
427    let mut first_met: Option<FirstMet> = None;
428    let nets: Vec<usize> = match fixed {
429        Some(controls) => vec![controls],
430        None => NETS.into_iter().chain(FURTHER).collect(),
431    };
432    let mut region_past = f64::NEG_INFINITY;
433    for (round, base) in nets.into_iter().enumerate() {
434        if round >= NETS.len() && fallback.is_some() {
435            break;
436        }
437        #[expect(
438            clippy::cast_possible_truncation,
439            clippy::cast_sign_loss,
440            clippy::cast_precision_loss,
441            reason = "a control count of a few dozen, from a positive ratio"
442        )]
443        let count = |extent: f64| -> usize {
444            ((base as f64 * extent / size).round() as usize).max(DEGREE + 2)
445        };
446        let controls = (count(du), count(dv));
447        let samples = 4 * controls.0.max(controls.1) + 8;
448
449        let mut factor = None;
450        let fit = if free {
451            let mut conditions = Vec::new();
452            for ((side, length), pcurve) in sides.iter().zip(&lengths).zip(&traces) {
453                free_conditions(
454                    side,
455                    pcurve,
456                    *length / size,
457                    size,
458                    samples,
459                    &mut conditions,
460                    tol,
461                )?;
462            }
463            fit_free(domain, controls, &conditions, smoothing, tol)?
464        } else {
465            let mut conditions = Vec::new();
466            for (side, length) in sides.iter().zip(&lengths) {
467                side_conditions(
468                    side,
469                    *length / size,
470                    size,
471                    samples,
472                    &frame,
473                    &mut conditions,
474                    tol,
475                )?;
476            }
477            for (p, share) in &interior {
478                conditions.push(Condition::partial(
479                    frame.chart(*p),
480                    (0, 0),
481                    [frame.height(*p)],
482                    share.sqrt() / size,
483                ));
484            }
485            let (fit, kept) =
486                fit_height_with(&frame, domain, controls, &conditions, smoothing, None, tol)?;
487            factor = Some(kept);
488            fit
489        };
490        let surface = fit.surface;
491        let reports = measure_sides(&sides, &traces, &surface, 3 * samples + 1, tol)?;
492        let mut constraint_gap = 0.0f64;
493        for (p, _) in &interior {
494            let q = frame.chart(*p);
495            constraint_gap = constraint_gap.max(surface.point_at(q.x, q.y, tol)?.distance(*p));
496        }
497
498        if let Some(miss) = first_miss(&sides, &reports, constraint_gap, tolerance) {
499            last_miss = format!("at {}x{} controls, {miss}", controls.0, controls.1);
500            if fixed.is_none() {
501                continue;
502            }
503        }
504        if sides_met.is_none() {
505            sides_met = Some(round);
506            if let (Some(_), Some(factor)) = (within, factor.take()) {
507                first_met = Some((surface.clone(), reports.clone(), constraint_gap, factor));
508            }
509        }
510        if free && folds(&surface, &chart_outline, domain, tol)? {
511            last_miss = format!(
512                "at {}x{} controls, the patch folds over inside the hole",
513                controls.0, controls.1
514            );
515            continue;
516        }
517        if let Some(region) = within {
518            let (past, at) = past_planes(&surface, &chart_outline, domain, region, tol)?;
519            region_past = past;
520            if past > tolerance && fixed.is_none() {
521                last_miss = format!(
522                    "at {}x{} controls, the patch stands {past} past a plane it is \
523                     held within, at {at:?}",
524                    controls.0, controls.1
525                );
526                continue;
527            }
528        }
529        fallback = Some((surface, reports, constraint_gap));
530        // A residual-led net is refined; see [`smoothing_for`].
531        if fit.residual <= smoothing {
532            break;
533        }
534    }
535    // Where nets met every side but none within the planes, the patch is
536    // held inside them where it strayed past, and fitted again, from the
537    // first such net on: holding it does not bring a side nearer.
538    if let (None, false, Some(region), Some(from), None) =
539        (&fallback, free, within, sides_met, fixed)
540    {
541        let held = HeldFit {
542            sides: &sides,
543            lengths: &lengths,
544            traces: &traces,
545            frame: &frame,
546            domain,
547            size,
548            interior: &interior,
549            smoothing,
550            tolerance,
551            region,
552            outline: &chart_outline,
553            first: first_met.as_ref(),
554        };
555        // Held as the rounds hold it first; where no net holds the patch so,
556        // again with the points still astray held firmer, past the nets the
557        // first pass showed it would only repeat.
558        let mut repeat = Vec::new();
559        match held.fit(from, false, &mut repeat, tol)? {
560            Ok(found) => fallback = Some(found),
561            Err(miss) => match held.fit(from, true, &mut repeat, tol)? {
562                Ok(found) => fallback = Some(found),
563                Err(firm) => {
564                    last_miss =
565                        format!("{last_miss}; held within its planes, {miss}; held firmer, {firm}");
566                }
567            },
568        }
569    }
570    let Some((surface, reports, constraint_gap)) = fallback else {
571        ogeom_bail!(
572            NotDone,
573            "the filling misses its tolerance of {tolerance} on the finest net: {last_miss}"
574        )
575    };
576    let face = build(model, &mut sides, &order, &traces, &reports, surface, tol)?;
577    let mut history = History::new();
578    let mut reports = reports;
579    for (side, report) in sides.iter().zip(&mut reports) {
580        history.generate(&side.given, face.clone());
581        if !side.edge.is_same(&side.given) {
582            history.generate(&side.given, side.edge.clone());
583        }
584        report.edge = side.edge.clone();
585    }
586    for constraint in constraints {
587        history.generate(constraint, face.clone());
588    }
589    Ok((
590        Filled {
591            built: Built::new(face, history),
592            sides: reports,
593            constraint_gap,
594        },
595        region_past,
596    ))
597}
598
599/// The surface a side's support lies on and the side's pcurve there.
600struct Support {
601    face: Shape,
602    surface: SurfaceGeometry,
603    pcurve: PlanarCurve,
604    prange: (f64, f64),
605    /// Which side of the edge the support's material lies on: the
606    /// direction into it is `material · (normal × d1)`, the surface's
607    /// normal crossed with the edge curve's derivative. `None` where the
608    /// face uses the edge both ways or neither.
609    material: Option<f64>,
610}
611
612/// One side, read and checked.
613struct Side {
614    entry: usize,
615    /// The edge as given, forward.
616    given: Shape,
617    /// The edge bounding the face: the given one, or its stand-in once the
618    /// face is built.
619    edge: Shape,
620    /// Whether the given edge or its curve is placed.
621    placed: bool,
622    /// The curve where the edge stands.
623    curve: Curve,
624    range: (f64, f64),
625    edge_tolerance: f64,
626    /// 0, 1 or 2: positions, tangent planes, normal curvatures.
627    order: usize,
628    support: Option<Support>,
629    /// Whether the loop runs against the edge.
630    reversed: bool,
631}
632
633impl Side {
634    fn parameter(&self, f: f64) -> f64 {
635        (self.range.1 - self.range.0).mul_add(f, self.range.0)
636    }
637
638    /// The support's chart position and unit normal at the edge's
639    /// parameter `t`.
640    fn support_at(&self, t: f64, tol: Tolerances) -> OgeomResult<Option<(Point2, Vector)>> {
641        let Some(support) = &self.support else {
642            return Ok(None);
643        };
644        let f = (t - self.range.0) / (self.range.1 - self.range.0);
645        let pt = (support.prange.1 - support.prange.0).mul_add(f, support.prange.0);
646        let uv = support.pcurve.point_at(pt, tol)?;
647        let normal = support.surface.normal_at(uv.x, uv.y, tol)?.vector();
648        Ok(Some((uv, normal)))
649    }
650}
651
652fn read_side(
653    model: &Model,
654    i: usize,
655    entry: &FillBoundary,
656    tolerance: f64,
657    tol: Tolerances,
658) -> OgeomResult<Side> {
659    let edge = &entry.edge;
660    if model.kind_of(edge)? != ShapeType::Edge {
661        ogeom_bail!(Construction, "side {i} is not an edge");
662    }
663    let order = match entry.continuity {
664        Continuity::C0 => 0,
665        Continuity::G1 => 1,
666        Continuity::G2 => 2,
667        other => ogeom_bail!(
668            Construction,
669            "side {i} asks {other:?}: parametric continuity between two \
670             surfaces' charts is not what a filling meets; ask for G1 or G2"
671        ),
672    };
673    let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
674        ogeom_bail!(Construction, "side {i}'s edge holds no edge data");
675    };
676    let Some(EdgeRepr::Curve3d {
677        curve,
678        range,
679        location: own,
680    }) = data.curve3d()
681    else {
682        ogeom_bail!(Construction, "side {i}'s edge has no 3D curve");
683    };
684    let Some(curve) = model.geometry().curve(*curve).cloned() else {
685        ogeom_bail!(Dangling, "side {i}'s curve is not in this model");
686    };
687    let range = *range;
688    let edge_tolerance = data.tolerance.get();
689    let placed = !(edge.location().is_identity() && own.is_identity());
690    let curve = if placed {
691        curve
692            .transformed(&own.composed(model.datums())?, tol)?
693            .transformed(&edge.transform(model.datums())?, tol)?
694    } else {
695        curve
696    };
697    let edge = edge.oriented(Orientation::Forward);
698
699    let support = match &entry.support {
700        None if order > 0 => ogeom_bail!(
701            Construction,
702            "side {i} asks {:?} but names no support face to meet",
703            entry.continuity
704        ),
705        None => None,
706        Some(face) => Some(read_support(
707            model,
708            i,
709            face,
710            &edge,
711            &curve,
712            range,
713            tolerance.max(edge_tolerance),
714            tol,
715        )?),
716    };
717    Ok(Side {
718        entry: i,
719        given: edge.clone(),
720        edge,
721        placed,
722        curve,
723        range,
724        edge_tolerance,
725        order,
726        support,
727        reversed: false,
728    })
729}
730
731#[expect(
732    clippy::too_many_arguments,
733    reason = "the side's edge, curve and range are read once by the caller"
734)]
735fn read_support(
736    model: &Model,
737    i: usize,
738    face: &Shape,
739    edge: &Shape,
740    curve: &Curve,
741    range: (f64, f64),
742    reach: f64,
743    tol: Tolerances,
744) -> OgeomResult<Support> {
745    if model.kind_of(face)? != ShapeType::Face {
746        ogeom_bail!(Construction, "side {i}'s support is not a face");
747    }
748    let Some(NodeData::Face(face_data)) = model.node(face).map(|n| n.data()) else {
749        ogeom_bail!(Dangling, "side {i}'s support is not in this model");
750    };
751    let face_placed = !(face.location().is_identity() && face_data.location.is_identity());
752    // A face's material lies on the left of each edge as its wire walks
753    // it, seen from the face's side of its surface.
754    let uses: Vec<Orientation> = explore(model, face, Filter::OfType(ShapeType::Edge))?
755        .iter()
756        .filter(|e| e.is_same(edge))
757        .map(Shape::orientation)
758        .collect();
759    let sense = |o: Orientation| match o {
760        Orientation::Forward => Some(1.0),
761        Orientation::Reversed => Some(-1.0),
762        _ => None,
763    };
764    let material = match uses.as_slice() {
765        [first, rest @ ..] if rest.iter().all(|o| o == first) => sense(face.orientation())
766            .zip(sense(*first))
767            .map(|(f, e)| f * e),
768        _ => None,
769    };
770    if uses.is_empty() {
771        ogeom_bail!(
772            Construction,
773            "side {i}'s support face does not hold the side's edge at its placement"
774        );
775    }
776    let surface_id = face_data.surface;
777    let Some(surface) = model.geometry().surface(surface_id).cloned() else {
778        ogeom_bail!(Dangling, "side {i}'s support surface is not in this model");
779    };
780    let surface = if face_placed {
781        surface
782            .transformed(&face_data.location.composed(model.datums())?, tol)?
783            .transformed(&face.transform(model.datums())?, tol)?
784    } else {
785        surface
786    };
787    let Some(edge_data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
788        ogeom_bail!(Construction, "side {i}'s edge holds no edge data");
789    };
790    // A stored pcurve describes the surface's own chart, which a placed
791    // face's restated surface need not share.
792    let stored = match edge_data
793        .pcurve_for(surface_id, edge.location())
794        .filter(|_| !face_placed)
795    {
796        Some(
797            EdgeRepr::PCurve { curve, range, .. }
798            | EdgeRepr::Seam {
799                forward: curve,
800                range,
801                ..
802            },
803        ) => model
804            .geometry()
805            .pcurve(*curve)
806            .cloned()
807            .map(|c| (c, *range)),
808        _ => None,
809    };
810    // The edge must lie on the surface it is said to bound. A stored image
811    // that misses it (one kept for another placement of the same edge
812    // node) gives way to one fitted where the edge stands.
813    let off = |pcurve: &PlanarCurve, prange: (f64, f64)| -> OgeomResult<f64> {
814        let mut worst = 0.0f64;
815        for k in 0..=16 {
816            let f = f64::from(k) / 16.0;
817            let t = (range.1 - range.0).mul_add(f, range.0);
818            let pt = (prange.1 - prange.0).mul_add(f, prange.0);
819            let uv = pcurve.point_at(pt, tol)?;
820            let on = surface.point_at(uv.x, uv.y, tol)?;
821            worst = worst.max(on.distance(curve.point_at(t, tol)?));
822        }
823        Ok(worst)
824    };
825    let stored = match stored {
826        Some((pcurve, prange)) => {
827            let worst = off(&pcurve, prange)?;
828            (worst <= reach).then_some((pcurve, prange, worst))
829        }
830        None => None,
831    };
832    let (pcurve, prange, worst) = if let Some(found) = stored {
833        found
834    } else {
835        let (fitted, _, _, _, _) =
836            ogeom_algo::pcurve_fit::fit_projected_pcurve(curve, range, &surface, tol)?;
837        let worst = off(&fitted, range)?;
838        (fitted, range, worst)
839    };
840    if worst > reach {
841        ogeom_bail!(
842            Construction,
843            "side {i}'s edge stands {worst} off its support face, past {reach}"
844        );
845    }
846    Ok(Support {
847        face: face.clone(),
848        surface,
849        pcurve,
850        prange,
851        material,
852    })
853}
854
855/// One end of a side: its vertex, where the vertex stands, and how far
856/// it reaches (its own tolerance or its edge's, the wider).
857#[derive(Clone)]
858struct End {
859    vertex: Shape,
860    at: Point,
861    reach: f64,
862}
863
864/// A side's two ends, in the edge's own direction.
865fn ends_of(model: &Model, side: &Side) -> OgeomResult<(End, End)> {
866    let Some((a, b)) = edge_vertices(model, &side.given)? else {
867        ogeom_bail!(
868            Construction,
869            "side {} has no vertices, so it cannot be shown to join the loop",
870            side.entry
871        );
872    };
873    let end = |vertex: Shape| -> OgeomResult<End> {
874        let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) else {
875            ogeom_bail!(Construction, "side {}'s vertex holds no point", side.entry);
876        };
877        Ok(End {
878            at: vertex.transform(model.datums())?.apply(data.point),
879            reach: data.tolerance.get().max(side.edge_tolerance),
880            vertex,
881        })
882    };
883    Ok((end(a)?, end(b)?))
884}
885
886/// Chain the sides into one loop: the order they are walked in, with each
887/// side's `reversed` set to the direction it is walked. Ends join where
888/// they are one vertex, or failing that where they lie within reach of
889/// each other.
890fn chain(model: &Model, sides: &mut [Side], tol: Tolerances) -> OgeomResult<Vec<usize>> {
891    let mut ends = Vec::with_capacity(sides.len());
892    for side in sides.iter() {
893        let (a, b) = ends_of(model, side)?;
894        ends.push((a, b));
895    }
896    let same = |a: &End, b: &End| -> OgeomResult<bool> {
897        Ok(a.vertex.is_same(&b.vertex) || model.same_position(&a.vertex, &b.vertex, tol)?)
898    };
899    let near = |a: &End, b: &End| a.at.distance(b.at) <= a.reach + b.reach + tol.confusion();
900    let meets = |a: &End, b: &End| -> OgeomResult<bool> { Ok(same(a, b)? || near(a, b)) };
901    let n = sides.len();
902    let mut used = vec![false; n];
903    used[0] = true;
904    let mut order = vec![0];
905    let first = ends[0].0.clone();
906    let mut cursor = ends[0].1.clone();
907    while order.len() < n {
908        let last = sides[order[order.len() - 1]].entry;
909        // A shared vertex is the stronger word: ends merely near the
910        // cursor count only where none is the cursor's own vertex.
911        let mut found: Vec<(usize, bool)> = Vec::new();
912        for strict in [true, false] {
913            for j in 0..n {
914                if used[j] {
915                    continue;
916                }
917                let joins = |e: &End| -> OgeomResult<bool> {
918                    if strict {
919                        same(e, &cursor)
920                    } else {
921                        meets(e, &cursor)
922                    }
923                };
924                if joins(&ends[j].0)? {
925                    found.push((j, false));
926                } else if joins(&ends[j].1)? {
927                    found.push((j, true));
928                }
929            }
930            if !found.is_empty() {
931                break;
932            }
933        }
934        let (j, reversed) = match found.as_slice() {
935            [one] => *one,
936            [] => ogeom_bail!(
937                Construction,
938                "the boundary does not close: no side continues where side {last} ends"
939            ),
940            _ => ogeom_bail!(
941                Construction,
942                "{} sides continue where side {last} ends; a filling's boundary \
943                 is one simple loop",
944                found.len()
945            ),
946        };
947        used[j] = true;
948        sides[j].reversed = reversed;
949        cursor = if reversed {
950            ends[j].0.clone()
951        } else {
952            ends[j].1.clone()
953        };
954        order.push(j);
955    }
956    if !meets(&cursor, &first)? {
957        ogeom_bail!(
958            Construction,
959            "the boundary does not close: the last side ends away from where the first begins"
960        );
961    }
962    Ok(order)
963}
964
965/// Turn the loop to run against its supports' uses of the shared edges,
966/// the majority deciding where they disagree.
967fn face_the_supports(model: &Model, sides: &mut [Side], order: &mut [usize]) -> OgeomResult<()> {
968    let (mut agree, mut disagree) = (0usize, 0usize);
969    for side in sides.iter() {
970        let Some(support) = &side.support else {
971            continue;
972        };
973        let uses: Vec<Orientation> =
974            explore(model, &support.face, Filter::OfType(ShapeType::Edge))?
975                .iter()
976                .filter(|e| e.is_same(&side.given))
977                .map(Shape::orientation)
978                .collect();
979        let Some(&first) = uses.first() else {
980            continue;
981        };
982        if uses.iter().any(|o| *o != first)
983            || !matches!(first, Orientation::Forward | Orientation::Reversed)
984        {
985            continue;
986        }
987        if (first == Orientation::Forward) == !side.reversed {
988            disagree += 1;
989        } else {
990            agree += 1;
991        }
992    }
993    if disagree > agree {
994        order.reverse();
995        for side in sides.iter_mut() {
996            side.reversed = !side.reversed;
997        }
998    }
999    Ok(())
1000}
1001
1002/// Points round the loop in its walking order.
1003fn loop_points(sides: &[Side], order: &[usize], tol: Tolerances) -> OgeomResult<Vec<Point>> {
1004    let mut out = Vec::with_capacity(order.len() * OUTLINE_SAMPLES);
1005    for &i in order {
1006        let side = &sides[i];
1007        for k in 0..OUTLINE_SAMPLES {
1008            #[expect(
1009                clippy::cast_precision_loss,
1010                reason = "a sample index, far below the mantissa"
1011            )]
1012            let mut f = k as f64 / OUTLINE_SAMPLES as f64;
1013            if side.reversed {
1014                f = 1.0 - f;
1015            }
1016            out.push(side.curve.point_at(side.parameter(f), tol)?);
1017        }
1018    }
1019    Ok(out)
1020}
1021
1022/// The plane a closed loop spans: its vector area's normal, which the loop
1023/// turns counter-clockwise about, through its centroid, with `e1` along the
1024/// loop's widest spread in the plane.
1025fn frame_of(outline: &[Point], tol: Tolerances) -> OgeomResult<PlaneFrame> {
1026    let Ok(origin) = Point::centroid(outline) else {
1027        ogeom_bail!(Construction, "the boundary loop has no points");
1028    };
1029    let mut area = Vector::ZERO;
1030    let mut reach = 0.0f64;
1031    for (k, p) in outline.iter().enumerate() {
1032        let q = outline[(k + 1) % outline.len()];
1033        area += (*p - origin).cross(q - origin) * 0.5;
1034        reach = reach.max(p.distance(origin));
1035    }
1036    let magnitude = area.magnitude();
1037    if magnitude <= 1e-9 * reach * reach || magnitude <= tol.confusion() * tol.confusion() {
1038        ogeom_bail!(
1039            Construction,
1040            "the boundary loop encloses no area seen from any plane"
1041        );
1042    }
1043    let n = area * (1.0 / magnitude);
1044    let b1 = Direction::new(n, tol)?.any_perpendicular().vector();
1045    let b2 = n.cross(b1);
1046    let (mut sxx, mut syy, mut sxy) = (0.0f64, 0.0f64, 0.0f64);
1047    for p in outline {
1048        let d = *p - origin;
1049        let (x, y) = (d.dot(b1), d.dot(b2));
1050        sxx += x * x;
1051        syy += y * y;
1052        sxy += x * y;
1053    }
1054    let theta = 0.5 * (2.0 * sxy).atan2(sxx - syy);
1055    let e1 = b1 * theta.cos() + b2 * theta.sin();
1056    let e2 = n.cross(e1);
1057    Ok(PlaneFrame { origin, e1, e2, n })
1058}
1059
1060/// The loop's corners in its walking order, corner `k` where side
1061/// `order[k]` ends and side `order[k + 1]` begins: the unit tangents
1062/// arriving and leaving, and the angle between them, zero where the loop
1063/// runs on smoothly (within [`CORNER`]) or a tangent vanishes.
1064fn corners_of(
1065    sides: &[Side],
1066    order: &[usize],
1067    tol: Tolerances,
1068) -> OgeomResult<Vec<(Vector, Vector, f64)>> {
1069    let walked = |side: &Side, end: bool| -> OgeomResult<Option<Vector>> {
1070        let t = if end == side.reversed {
1071            side.range.0
1072        } else {
1073            side.range.1
1074        };
1075        let d = side.curve.d1_at(t, tol)?;
1076        let d = if side.reversed { d * -1.0 } else { d };
1077        Ok(Direction::new(d, tol).ok().map(Direction::vector))
1078    };
1079    let mut out = Vec::with_capacity(order.len());
1080    for (k, &i) in order.iter().enumerate() {
1081        let next = &sides[order[(k + 1) % order.len()]];
1082        let corner = match (walked(&sides[i], true)?, walked(next, false)?) {
1083            (Some(a), Some(b)) => {
1084                let turn = a.cross(b).magnitude().atan2(a.dot(b));
1085                (a, b, if turn > CORNER { turn } else { 0.0 })
1086            }
1087            _ => (Vector::ZERO, Vector::ZERO, 0.0),
1088        };
1089        out.push(corner);
1090    }
1091    Ok(out)
1092}
1093
1094/// The least share of its turn any corner of the loop still turns seen
1095/// along the unit `n`; one where the loop has no corners.
1096fn kept_turn(n: Vector, corners: &[(Vector, Vector, f64)]) -> f64 {
1097    let flat = |v: Vector| v - n * v.dot(n);
1098    let mut worst = 1.0f64;
1099    for (a, b, turn) in corners {
1100        if *turn > 0.0 {
1101            let (a, b) = (flat(*a), flat(*b));
1102            let seen = a.cross(b).magnitude().atan2(a.dot(b));
1103            worst = worst.min((seen / turn).min(1.0));
1104        }
1105    }
1106    worst
1107}
1108
1109/// A chart drawn from the loop itself, for the free patch: each side's
1110/// pcurve, by side index, at the side's own parameter.
1111///
1112/// The chart walks the loop at the loop's own speed, turning at each corner
1113/// by the corner's turn (held under [`MOST_TURN`] of a half turn, and
1114/// scaled down together where they would leave the sides no turn of their
1115/// own) and spreading the rest of a full turn evenly along its length, so
1116/// its corners are the loop's corners and it is smooth wherever the loop
1117/// is. Such a walk ends short of where it began by a little; that drift is
1118/// taken out evenly along it, one vector off every step's velocity, which
1119/// leaves the walk smooth where it was smooth and its corners corners.
1120fn boundary_chart(
1121    sides: &[Side],
1122    order: &[usize],
1123    corners: &[(Vector, Vector, f64)],
1124    tolerance: f64,
1125    tol: Tolerances,
1126) -> OgeomResult<Vec<PlanarCurve>> {
1127    use std::f64::consts::{PI, TAU};
1128    // Each side at even parameters, with the length walked from where the
1129    // loop enters the side.
1130    let mut walks: Vec<(Vec<f64>, Vec<f64>, f64)> =
1131        vec![(Vec::new(), Vec::new(), 0.0); sides.len()];
1132    let mut total = 0.0;
1133    for &i in order {
1134        let side = &sides[i];
1135        let mut params = Vec::with_capacity(CHART_SAMPLES + 1);
1136        let mut along = Vec::with_capacity(CHART_SAMPLES + 1);
1137        let mut previous = side.curve.point_at(side.range.0, tol)?;
1138        let mut length = 0.0;
1139        for k in 0..=CHART_SAMPLES {
1140            #[expect(
1141                clippy::cast_precision_loss,
1142                reason = "a sample index, far below the mantissa"
1143            )]
1144            let t = side.parameter(k as f64 / CHART_SAMPLES as f64);
1145            let p = side.curve.point_at(t, tol)?;
1146            length += p.distance(previous);
1147            previous = p;
1148            params.push(t);
1149            along.push(length);
1150        }
1151        if side.reversed {
1152            for a in &mut along {
1153                *a = length - *a;
1154            }
1155        }
1156        total += length;
1157        walks[i] = (params, along, length);
1158    }
1159    if total <= tol.confusion() {
1160        ogeom_bail!(Construction, "the boundary loop has no length");
1161    }
1162    let turns: Vec<f64> = corners.iter().map(|c| c.2.min(MOST_TURN * PI)).collect();
1163    let sum: f64 = turns.iter().sum();
1164    let scale = if sum > MOST_TURN * TAU {
1165        MOST_TURN * TAU / sum
1166    } else {
1167        1.0
1168    };
1169    let bend = sum.mul_add(-scale, TAU) / total;
1170    // The point a walk of length `s` from `start`, heading `heading`,
1171    // reaches turning at `bend`.
1172    let arc = |start: Point2, heading: f64, s: f64| -> Point2 {
1173        let delta = bend * s;
1174        if delta.abs() < 1e-9 {
1175            start + Vector2::new(heading.cos(), heading.sin()) * s
1176        } else {
1177            start
1178                + Vector2::new(
1179                    (heading + delta).sin() - heading.sin(),
1180                    heading.cos() - (heading + delta).cos(),
1181                ) * (1.0 / bend)
1182        }
1183    };
1184    let mut starts = vec![(Point2::new(0.0, 0.0), 0.0, 0.0); sides.len()];
1185    let (mut at, mut heading, mut walked) = (Point2::new(0.0, 0.0), 0.0f64, 0.0f64);
1186    for (k, &i) in order.iter().enumerate() {
1187        starts[i] = (at, heading, walked);
1188        let length = walks[i].2;
1189        at = arc(at, heading, length);
1190        heading += bend.mul_add(length, turns[k] * scale);
1191        walked += length;
1192    }
1193    let drift = at - Point2::new(0.0, 0.0);
1194    if drift.magnitude() > 0.5 * total / TAU {
1195        ogeom_bail!(
1196            Construction,
1197            "the boundary loop's corners leave no chart drawn from it closing"
1198        );
1199    }
1200    let mut out = Vec::with_capacity(sides.len());
1201    for (i, (params, along, _)) in walks.iter().enumerate() {
1202        let (start, heading, before) = starts[i];
1203        let points: Vec<Point2> = along
1204            .iter()
1205            .map(|&s| arc(start, heading, s) - drift * ((before + s) / total))
1206            .collect();
1207        let fitted = ogeom_geom::fit::fit_points_2d_at(params, &points, 3, tolerance * 1e-2, tol)?;
1208        out.push(PlanarCurve::BSpline(fitted.curve));
1209    }
1210    Ok(out)
1211}
1212/// Whether a closed polygon crosses itself: any two segments that are not
1213/// neighbours crossing, each segment's ends standing clear of the other's
1214/// line on opposite sides.
1215///
1216/// A point within rounding of a line is on it, not on a side: samples of a
1217/// straight side are collinear, and two segments of one straight side
1218/// would otherwise cross wherever rounding scatters their ends' sides.
1219fn crosses_itself(polygon: &[Point2]) -> bool {
1220    let n = polygon.len();
1221    let reach = polygon
1222        .iter()
1223        .map(|q| q.x.abs().max(q.y.abs()))
1224        .fold(0.0f64, f64::max);
1225    let floor = 1e-9 * reach;
1226    // The side of line `ab` that `c` stands on: 1, -1, or 0 within `floor`.
1227    let side = |a: Point2, b: Point2, c: Point2| -> i8 {
1228        let ab = b - a;
1229        let o = ab.cross(c - a);
1230        if o.abs() <= floor * ab.magnitude() {
1231            0
1232        } else if o > 0.0 {
1233            1
1234        } else {
1235            -1
1236        }
1237    };
1238    for i in 0..n {
1239        let (a, b) = (polygon[i], polygon[(i + 1) % n]);
1240        for j in (i + 2)..n {
1241            if i == 0 && j == n - 1 {
1242                continue;
1243            }
1244            let (c, d) = (polygon[j], polygon[(j + 1) % n]);
1245            if side(a, b, c) * side(a, b, d) < 0 && side(c, d, a) * side(c, d, b) < 0 {
1246                return true;
1247            }
1248        }
1249    }
1250    false
1251}
1252
1253/// Whether a point lies inside a closed polygon, by its winding number.
1254fn inside(polygon: &[Point2], q: Point2) -> bool {
1255    let n = polygon.len();
1256    let mut winding = 0i32;
1257    for i in 0..n {
1258        let (a, b) = (polygon[i], polygon[(i + 1) % n]);
1259        let side = (b - a).cross(q - a);
1260        if a.y <= q.y {
1261            if b.y > q.y && side > 0.0 {
1262                winding += 1;
1263            }
1264        } else if b.y <= q.y && side < 0.0 {
1265            winding -= 1;
1266        }
1267    }
1268    winding != 0
1269}
1270
1271/// The points the constraints put inside the hole, each with its share of
1272/// the fit's weight.
1273fn constraint_points(
1274    model: &Model,
1275    constraints: &[Shape],
1276    tol: Tolerances,
1277) -> OgeomResult<Vec<(Point, f64)>> {
1278    let mut out = Vec::new();
1279    for (k, shape) in constraints.iter().enumerate() {
1280        let placement = shape.transform(model.datums())?;
1281        match model.kind_of(shape)? {
1282            ShapeType::Vertex => {
1283                let Some(data) = model.node(shape).and_then(|n| n.data().as_vertex()) else {
1284                    ogeom_bail!(Construction, "constraint {k} holds no vertex data");
1285                };
1286                out.push((placement.apply(data.point), POINT_SHARE));
1287            }
1288            ShapeType::Edge => {
1289                let Some(data) = model.node(shape).and_then(|n| n.data().as_edge()) else {
1290                    ogeom_bail!(Construction, "constraint {k} holds no edge data");
1291                };
1292                let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1293                    ogeom_bail!(Construction, "constraint {k} has no 3D curve");
1294                };
1295                let Some(curve) = model.geometry().curve(*curve) else {
1296                    ogeom_bail!(Dangling, "constraint {k}'s curve is not in this model");
1297                };
1298                for i in 0..=PER_CURVE {
1299                    #[expect(
1300                        clippy::cast_precision_loss,
1301                        reason = "a sample index, far below the mantissa"
1302                    )]
1303                    let f = i as f64 / PER_CURVE as f64;
1304                    let t = (range.1 - range.0).mul_add(f, range.0);
1305                    out.push((placement.apply(curve.point_at(t, tol)?), POINT_SHARE / 4.0));
1306                }
1307            }
1308            other => ogeom_bail!(
1309                Construction,
1310                "constraint {k} is a {other:?}; a filling passes through vertices and edges"
1311            ),
1312        }
1313    }
1314    Ok(out)
1315}
1316
1317/// The basis curve of a forward trim, whose parameter is the trim's own.
1318fn untrimmed(curve: &Curve) -> &Curve {
1319    match curve {
1320        Curve::Trimmed(t) if !t.is_reversed() => untrimmed(t.basis()),
1321        other => other,
1322    }
1323}
1324
1325/// The side's curve seen in the plane, at the curve's own parameter: exact
1326/// where the curve is a line, a circle or ellipse, or a B-spline (an affine
1327/// image of each is a curve of the same form), checked against the curve
1328/// before it is trusted; fitted at the curve's parameters otherwise.
1329fn trace(
1330    side: &Side,
1331    frame: &PlaneFrame,
1332    tolerance: f64,
1333    tol: Tolerances,
1334) -> OgeomResult<PlanarCurve> {
1335    let truth = |t: f64| -> OgeomResult<Point2> { Ok(frame.chart(side.curve.point_at(t, tol)?)) };
1336    let (t0, t1) = side.range;
1337    let exact: Option<PlanarCurve> = match untrimmed(&side.curve) {
1338        Curve::BSpline(spline) => {
1339            let mut control = Vec::with_capacity(spline.control_points().len());
1340            for w in spline.control_points() {
1341                control.push(Weighted::new(frame.chart(w.point()), w.weight, tol)?);
1342            }
1343            BSpline2d::rational(spline.knots().clone(), control)
1344                .ok()
1345                .map(PlanarCurve::BSpline)
1346        }
1347        _ => match side.curve.kind() {
1348            CurveKind::Line => {
1349                let (q0, q1) = (truth(t0)?, truth(t1)?);
1350                let d = (q1 - q0) * (1.0 / (t1 - t0));
1351                let c = q0 - d * t0;
1352                Trig2d::new(c, d, Vector2::ZERO, Vector2::ZERO, side.range)
1353                    .ok()
1354                    .map(PlanarCurve::Trig)
1355            }
1356            CurveKind::Circle | CurveKind::Ellipse => {
1357                // Thirds rather than ends and middle: a full circle's ends
1358                // are one point.
1359                let ts = [
1360                    t0,
1361                    (t1 - t0).mul_add(1.0 / 3.0, t0),
1362                    (t1 - t0).mul_add(2.0 / 3.0, t0),
1363                ];
1364                let rows = ts.map(|t| [1.0, t.cos(), t.sin()]);
1365                let values = [truth(ts[0])?, truth(ts[1])?, truth(ts[2])?];
1366                match (
1367                    solve3(rows, values.map(|q| q.x)),
1368                    solve3(rows, values.map(|q| q.y)),
1369                ) {
1370                    (Some(x), Some(y)) => Trig2d::new(
1371                        Point2::new(x[0], y[0]),
1372                        Vector2::ZERO,
1373                        Vector2::new(x[1], y[1]),
1374                        Vector2::new(x[2], y[2]),
1375                        side.range,
1376                    )
1377                    .ok()
1378                    .map(PlanarCurve::Trig),
1379                    _ => None,
1380                }
1381            }
1382            _ => None,
1383        },
1384    };
1385    if let Some(curve) = exact {
1386        let mut worst = 0.0f64;
1387        let mut scale = 1.0f64;
1388        for k in 0..=32 {
1389            let t = (t1 - t0).mul_add(f64::from(k) / 32.0, t0);
1390            let q = truth(t)?;
1391            scale = scale.max(q.to_vector().magnitude());
1392            worst = worst.max(curve.point_at(t, tol)?.distance(q));
1393        }
1394        if worst <= 1e-12 * scale + 1e-3 * tol.confusion() {
1395            return Ok(curve);
1396        }
1397    }
1398    const SAMPLES: usize = 96;
1399    let mut parameters = Vec::with_capacity(SAMPLES + 1);
1400    let mut points = Vec::with_capacity(SAMPLES + 1);
1401    for k in 0..=SAMPLES {
1402        #[expect(
1403            clippy::cast_precision_loss,
1404            reason = "a sample index, far below the mantissa"
1405        )]
1406        let t = (t1 - t0).mul_add(k as f64 / SAMPLES as f64, t0);
1407        parameters.push(t);
1408        points.push(truth(t)?);
1409    }
1410    let fitted = ogeom_geom::fit::fit_points_2d_at(&parameters, &points, 3, tolerance * 1e-2, tol)?;
1411    Ok(PlanarCurve::BSpline(fitted.curve))
1412}
1413
1414/// Solve a 3x3 system by Cramer's rule; `None` where it is singular.
1415fn solve3(rows: [[f64; 3]; 3], rhs: [f64; 3]) -> Option<[f64; 3]> {
1416    let det = |m: [[f64; 3]; 3]| {
1417        m[0][0] * m[1][1].mul_add(m[2][2], -m[1][2] * m[2][1])
1418            - m[0][1] * m[1][0].mul_add(m[2][2], -m[1][2] * m[2][0])
1419            + m[0][2] * m[1][0].mul_add(m[2][1], -m[1][1] * m[2][0])
1420    };
1421    let d = det(rows);
1422    if d.abs() < 1e-12 {
1423        return None;
1424    }
1425    let mut out = [0.0; 3];
1426    for (c, slot) in out.iter_mut().enumerate() {
1427        let mut m = rows;
1428        for r in 0..3 {
1429            m[r][c] = rhs[r];
1430        }
1431        *slot = det(m) / d;
1432    }
1433    Some(out)
1434}
1435
1436/// The bending energy's weight against the conditions.
1437///
1438/// The fit minimises `Q + λ·E`. `Q` sums the conditions' squared
1439/// residuals, each free of units (a position over the patch size `size`, a
1440/// slope as it is, a curvature times `size`) and weighted by its side's
1441/// share of the boundary. `E` is the thin-plate energy, also free of units:
1442/// about the square of the angle, in radians, the patch turns through.
1443/// Scaling the whole problem changes neither, so `λ` means the same at any
1444/// size and on any net; only the tolerance sets it.
1445///
1446/// For any surface `s` the net can carry, `Q(fit) + λ·E(fit) <= Q(s) +
1447/// λ·E(s)`: the energy costs the conditions at most `λ·E(s)`. With `ε` the
1448/// tolerance in the residuals' own units (the strictest of `tolerance /
1449/// size` for positions, `tolerance` for G1 slopes and `tolerance · size`
1450/// for G2 curvatures), `λ = (BUDGET·ε)²` holds that cost to a tenth of the
1451/// tolerance, root mean square, per radian of turning, and gives the energy
1452/// the largest weight the bound allows, so the energy rather than the
1453/// conditions' approximation error settles every control the conditions
1454/// reach only weakly.
1455///
1456/// The same bound says when a net is too coarse. Where the conditions'
1457/// residual `Q` exceeds `λ`, the fit would bend the interior by up to a
1458/// unit of energy to save that residual, so the hole's middle follows the
1459/// boundary's approximation error (on a coarse net every control reaches
1460/// the boundary, and the middle dips or bulges). Such a net is refined
1461/// even when its sides meet the tolerance; where no net gets clear of it,
1462/// the finest one that met the tolerance is taken.
1463fn smoothing_for(sides: &[Side], tolerance: f64, size: f64) -> f64 {
1464    let order = sides.iter().map(|s| s.order).max().unwrap_or(0);
1465    let mut eps = tolerance / size;
1466    if order >= 1 {
1467        eps = eps.min(tolerance);
1468    }
1469    if order >= 2 {
1470        eps = eps.min(tolerance * size);
1471    }
1472    (BUDGET * eps).powi(2).max(LEAST_SMOOTHING)
1473}
1474
1475/// A side's length, by chords.
1476fn side_length(side: &Side, tol: Tolerances) -> OgeomResult<f64> {
1477    let mut length = 0.0;
1478    let mut previous = side.curve.point_at(side.range.0, tol)?;
1479    for k in 1..=OUTLINE_SAMPLES {
1480        #[expect(
1481            clippy::cast_precision_loss,
1482            reason = "a sample index, far below the mantissa"
1483        )]
1484        let f = k as f64 / OUTLINE_SAMPLES as f64;
1485        let p = side.curve.point_at(side.parameter(f), tol)?;
1486        length += p.distance(previous);
1487        previous = p;
1488    }
1489    Ok(length)
1490}
1491
1492/// The fit's conditions along one side: positions always, tangent planes
1493/// from G1 on, normal curvatures at G2. Every row carries the side's share
1494/// of the boundary per sample (`share`, its length against the hole's
1495/// `size`) and is scaled to a residual free of units.
1496fn side_conditions(
1497    side: &Side,
1498    share: f64,
1499    size: f64,
1500    samples: usize,
1501    frame: &PlaneFrame,
1502    out: &mut Vec<Condition<1>>,
1503    tol: Tolerances,
1504) -> OgeomResult<()> {
1505    #[expect(
1506        clippy::cast_precision_loss,
1507        reason = "a sample count, far below the mantissa"
1508    )]
1509    let root = (share / samples as f64).max(f64::MIN_POSITIVE).sqrt();
1510    for k in 0..=samples {
1511        #[expect(
1512            clippy::cast_precision_loss,
1513            reason = "a sample index, far below the mantissa"
1514        )]
1515        let t = side.parameter(k as f64 / samples as f64);
1516        let p = side.curve.point_at(t, tol)?;
1517        let at = frame.chart(p);
1518        out.push(Condition::partial(
1519            at,
1520            (0, 0),
1521            [frame.height(p)],
1522            root / size,
1523        ));
1524        if side.order == 0 {
1525            continue;
1526        }
1527        let (Some((uv, normal)), Some(support)) = (side.support_at(t, tol)?, &side.support) else {
1528            continue;
1529        };
1530        let lift = normal.dot(frame.n);
1531        if lift.abs() < MIN_LIFT {
1532            ogeom_bail!(
1533                Construction,
1534                "side {}'s support stands at {:.1} degrees to the plane the \
1535                 boundary spans, past the {:.1} a height over that plane takes",
1536                side.entry,
1537                lift.abs().acos().to_degrees(),
1538                MIN_LIFT.acos().to_degrees()
1539            );
1540        }
1541        // The support's normal on the plane's side, and the slopes that
1542        // put the patch's tangent plane on it.
1543        let normal = if lift < 0.0 { normal * -1.0 } else { normal };
1544        let lift = lift.abs();
1545        let (hu, hv) = (-normal.dot(frame.e1) / lift, -normal.dot(frame.e2) / lift);
1546        out.push(Condition::partial(at, (1, 0), [hu], root));
1547        out.push(Condition::partial(at, (0, 1), [hv], root));
1548        if side.order < 2 {
1549            continue;
1550        }
1551        // With the tangent plane fixed, the patch's second fundamental form
1552        // is `n · S_ab = h_ab (normal · n)`: the support's, read off its
1553        // principal curvatures, fixes the three second derivatives.
1554        let curvature = support.surface.curvature_at(uv.x, uv.y, tol)?;
1555        let sense = if curvature.normal.dot_vector(normal) < 0.0 {
1556            -1.0
1557        } else {
1558            1.0
1559        };
1560        let (dmax, dmin) = (
1561            curvature.max_direction.vector(),
1562            curvature.min_direction.vector(),
1563        );
1564        let second = |a: Vector, b: Vector| {
1565            sense
1566                * curvature.max.mul_add(
1567                    a.dot(dmax) * b.dot(dmax),
1568                    curvature.min * a.dot(dmin) * b.dot(dmin),
1569                )
1570                / lift
1571        };
1572        let su = frame.e1 + frame.n * hu;
1573        let sv = frame.e2 + frame.n * hv;
1574        for (order, target) in [
1575            ((2, 0), second(su, su)),
1576            ((1, 1), second(su, sv)),
1577            ((0, 2), second(sv, sv)),
1578        ] {
1579            out.push(Condition::partial(at, order, [target], root * size));
1580        }
1581    }
1582    Ok(())
1583}
1584
1585/// The free patch's conditions along one side: the side's points, at its
1586/// pcurve on the chart drawn from the loop, weighted as in
1587/// [`side_conditions`].
1588fn free_conditions(
1589    side: &Side,
1590    pcurve: &PlanarCurve,
1591    share: f64,
1592    size: f64,
1593    samples: usize,
1594    out: &mut Vec<Condition<3>>,
1595    tol: Tolerances,
1596) -> OgeomResult<()> {
1597    #[expect(
1598        clippy::cast_precision_loss,
1599        reason = "a sample count, far below the mantissa"
1600    )]
1601    let root = (share / samples as f64).max(f64::MIN_POSITIVE).sqrt();
1602    for k in 0..=samples {
1603        #[expect(
1604            clippy::cast_precision_loss,
1605            reason = "a sample index, far below the mantissa"
1606        )]
1607        let t = side.parameter(k as f64 / samples as f64);
1608        let p = side.curve.point_at(t, tol)?;
1609        let at = pcurve.point_at(t, tol)?;
1610        out.push(Condition::partial(at, (0, 0), [p.x, p.y, p.z], root / size));
1611        if side.order == 0 {
1612            continue;
1613        }
1614        let (Some((uv, normal)), Some(support)) = (side.support_at(t, tol)?, &side.support) else {
1615            continue;
1616        };
1617        let Some(material) = support.material else {
1618            ogeom_bail!(
1619                Construction,
1620                "side {}'s support uses its edge both ways or neither, so which \
1621                 side of it the filling leaves from is not known",
1622                side.entry
1623            );
1624        };
1625        // The chart walks the loop counter-clockwise, so the hole lies on
1626        // the left of the walk and the support on its right: the ribbon
1627        // runs across the side along the chart's outward normal, into the
1628        // support, at the speed the side runs at along it.
1629        let d1 = side.curve.d1_at(t, tol)?;
1630        let c1 = pcurve.d1_at(t, tol)?;
1631        let walk = if side.reversed { -1.0 } else { 1.0 };
1632        let speed = c1.magnitude();
1633        if speed <= f64::MIN_POSITIVE {
1634            continue;
1635        }
1636        let along = c1 * (walk / speed);
1637        let outward = Vector2::new(along.y, -along.x);
1638        let into = normal.cross(d1) * material;
1639        let Ok(into) = Direction::new(into, tol) else {
1640            continue;
1641        };
1642        let into = into.vector();
1643        let ribbon = into * (d1.magnitude() / speed);
1644        out.push(Condition::along(
1645            at,
1646            outward,
1647            false,
1648            [ribbon.x, ribbon.y, ribbon.z],
1649            root,
1650        ));
1651        if side.order < 2 {
1652            continue;
1653        }
1654        // Across the side the patch bends as the support does: the second
1655        // derivative along the ribbon has the support's normal curvature
1656        // there times the ribbon's squared length along the normal, and
1657        // nothing along the surface.
1658        let curvature = support.surface.curvature_at(uv.x, uv.y, tol)?;
1659        let Some(bend) = curvature.normal_curvature(into) else {
1660            continue;
1661        };
1662        let second = curvature.normal.vector() * (bend * ribbon.dot(ribbon));
1663        out.push(Condition::along(
1664            at,
1665            outward,
1666            true,
1667            [second.x, second.y, second.z],
1668            root * size,
1669        ));
1670    }
1671    Ok(())
1672}
1673
1674/// The tangent sides' supports' least lift off the plane, at stations
1675/// spread over each: the side and the cosine between the support's normal
1676/// and the plane's. `None` with no tangent side.
1677fn steepest_support(
1678    sides: &[Side],
1679    frame: &PlaneFrame,
1680    tol: Tolerances,
1681) -> OgeomResult<Option<(usize, f64)>> {
1682    let mut steepest: Option<(usize, f64)> = None;
1683    for side in sides.iter().filter(|s| s.order > 0) {
1684        for k in 0..=STEEP_SAMPLES {
1685            #[expect(
1686                clippy::cast_precision_loss,
1687                reason = "a sample index, far below the mantissa"
1688            )]
1689            let t = side.parameter(k as f64 / STEEP_SAMPLES as f64);
1690            let Some((_, normal)) = side.support_at(t, tol)? else {
1691                continue;
1692            };
1693            let lift = normal.dot(frame.n).abs();
1694            if steepest.is_none_or(|(_, least)| lift < least) {
1695                steepest = Some((side.entry, lift));
1696            }
1697        }
1698    }
1699    Ok(steepest)
1700}
1701
1702/// Refuse a corner where two sides meet and no surface is tangent to the
1703/// supports of both: a surface through the corner holds both sides'
1704/// tangents in its tangent plane, so a tangent side's support must hold
1705/// the other side's tangent too, and stands off tangent to any filling by
1706/// at least the angle between that tangent and its own tangent plane.
1707fn tangent_corners(
1708    sides: &[Side],
1709    order: &[usize],
1710    corners: &[(Vector, Vector, f64)],
1711    tolerance: f64,
1712    tol: Tolerances,
1713) -> OgeomResult<()> {
1714    for (k, (arrive, leave, _)) in corners.iter().enumerate() {
1715        let (a, b) = (&sides[order[k]], &sides[order[(k + 1) % order.len()]]);
1716        let end = |side: &Side, last: bool| {
1717            if last == side.reversed {
1718                side.range.0
1719            } else {
1720                side.range.1
1721            }
1722        };
1723        for (side, at, other, tangent) in
1724            [(a, end(a, true), b, *leave), (b, end(b, false), a, *arrive)]
1725        {
1726            if side.order == 0 {
1727                continue;
1728            }
1729            let Some((_, normal)) = side.support_at(at, tol)? else {
1730                continue;
1731            };
1732            let off = normal.dot(tangent).abs().min(1.0).asin();
1733            if off > tolerance {
1734                ogeom_bail!(
1735                    Construction,
1736                    "sides {} and {} meet at a corner no surface is tangent to \
1737                     both supports at: side {}'s support stands {:.1} degrees off \
1738                     side {}'s tangent there, past the tolerance of {tolerance} \
1739                     radians",
1740                    side.entry,
1741                    other.entry,
1742                    side.entry,
1743                    off.to_degrees(),
1744                    other.entry,
1745                );
1746            }
1747        }
1748    }
1749    Ok(())
1750}
1751
1752/// Points round the loop on the chart, in its walking order.
1753fn traced_outline(
1754    sides: &[Side],
1755    order: &[usize],
1756    traces: &[PlanarCurve],
1757    tol: Tolerances,
1758) -> OgeomResult<Vec<Point2>> {
1759    let mut out = Vec::with_capacity(order.len() * OUTLINE_SAMPLES);
1760    for &i in order {
1761        let side = &sides[i];
1762        for k in 0..OUTLINE_SAMPLES {
1763            #[expect(
1764                clippy::cast_precision_loss,
1765                reason = "a sample index, far below the mantissa"
1766            )]
1767            let mut f = k as f64 / OUTLINE_SAMPLES as f64;
1768            if side.reversed {
1769                f = 1.0 - f;
1770            }
1771            out.push(traces[i].point_at(side.parameter(f), tol)?);
1772        }
1773    }
1774    Ok(out)
1775}
1776
1777/// Whether a free patch folds over inside the hole: on a grid over the
1778/// chart, inside its outline, the normal vanishes against the largest, or
1779/// turns over between neighbouring samples.
1780/// How far `surface` stands outside `region` over the hole `outline` bounds
1781/// in its chart, read on a grid over `domain` fine against the patch's own
1782/// spans: the worst, and where. The hole's boundary lies on or inside the
1783/// region, so a point the grid reads just inside it stands outside by no
1784/// more than the patch's own miss there.
1785fn past_planes(
1786    surface: &BSplineSurface,
1787    outline: &[Point2],
1788    domain: ((f64, f64), (f64, f64)),
1789    region: Region<'_>,
1790    tol: Tolerances,
1791) -> OgeomResult<(f64, Point)> {
1792    let mut worst = (f64::NEG_INFINITY, Point::ORIGIN);
1793    for q in Grid::over(surface, outline, domain).points {
1794        let p = surface.point_at(q.x, q.y, tol)?;
1795        let (d, _) = region(p);
1796        if d > worst.0 {
1797            worst = (d, p);
1798        }
1799    }
1800    Ok(worst)
1801}
1802
1803/// How many times a net is fitted again, holding the patch at the points it
1804/// strayed past a plane, before a finer net is tried.
1805const HOLD_ROUNDS: usize = 16;
1806
1807/// Further rounds, in a second pass tried only where the first held no net
1808/// inside the region: in it a point held before and still astray is held
1809/// again at the plane it strays past now, at twice its weight.
1810const FIRM_ROUNDS: usize = 32;
1811
1812/// A finer net the firmer pass goes on to: where a side's support turns
1813/// from a round's curvature into a flat face at a corner, the patch's
1814/// tangency and its stray there close about twofold a net.
1815const FIRMER: [usize; 1] = [202];
1816
1817/// A height-field filling's fit held inside half-spaces: what
1818/// [`make_filling_n_within`] has read off the hole.
1819struct HeldFit<'a> {
1820    sides: &'a [Side],
1821    lengths: &'a [f64],
1822    traces: &'a [PlanarCurve],
1823    frame: &'a PlaneFrame,
1824    domain: ((f64, f64), (f64, f64)),
1825    size: f64,
1826    interior: &'a [(Point, f64)],
1827    smoothing: f64,
1828    tolerance: f64,
1829    region: Region<'a>,
1830    outline: &'a [Point2],
1831    /// The plain fit at the first net that met every side: the hold pass's
1832    /// first round there, its conditions the same.
1833    first: Option<&'a FirstMet>,
1834}
1835
1836/// The plain fit at the first net that met every side, with its factored
1837/// system.
1838type FirstMet = (BSplineSurface, Vec<FillSide>, f64, Factor);
1839
1840impl HeldFit<'_> {
1841    /// Each net in turn from round `from`, fitted to the sides and the
1842    /// interior points as
1843    /// [`make_filling_n_within`] fits it; wherever the patch stands past a
1844    /// plane inside the hole, a condition there holds its height half the
1845    /// tolerance inside the plane, and the net is fitted again, until it
1846    /// stands past none by more than the tolerance or misses a side. Each
1847    /// round holds the worst stray point in each span cell of the net, so a
1848    /// net is not held at more points than it has cells. The first net that
1849    /// meets every side within the planes, or why the finest did not.
1850    ///
1851    /// The first pass (`firm` false) records in `repeat` each net it ended
1852    /// on a missed side or with nothing to hold, having held no point twice:
1853    /// the firmer pass would fit such a net round for round the same, and
1854    /// passes over it.
1855    #[allow(clippy::type_complexity, reason = "a fit, or why none held")]
1856    fn fit(
1857        &self,
1858        from: usize,
1859        firm: bool,
1860        repeat: &mut Vec<usize>,
1861        tol: Tolerances,
1862    ) -> OgeomResult<Result<(BSplineSurface, Vec<FillSide>, f64), String>> {
1863        let ((ua, ub), (va, vb)) = self.domain;
1864        let (du, dv) = (ub - ua, vb - va);
1865        let mut miss = String::new();
1866        let firmer: &[usize] = if firm { &FIRMER } else { &[] };
1867        for (net, base) in NETS
1868            .into_iter()
1869            .chain(FURTHER)
1870            .chain(firmer.iter().copied())
1871            .enumerate()
1872            .skip(from)
1873        {
1874            if firm && repeat.contains(&net) {
1875                continue;
1876            }
1877            #[expect(
1878                clippy::cast_possible_truncation,
1879                clippy::cast_sign_loss,
1880                clippy::cast_precision_loss,
1881                reason = "a control count of a few dozen, from a positive ratio"
1882            )]
1883            let count = |extent: f64| -> usize {
1884                ((base as f64 * extent / self.size).round() as usize).max(DEGREE + 2)
1885            };
1886            let controls = (count(du), count(dv));
1887            let samples = 4 * controls.0.max(controls.1) + 8;
1888            let mut conditions = Vec::new();
1889            for (side, length) in self.sides.iter().zip(self.lengths) {
1890                side_conditions(
1891                    side,
1892                    *length / self.size,
1893                    self.size,
1894                    samples,
1895                    self.frame,
1896                    &mut conditions,
1897                    tol,
1898                )?;
1899            }
1900            for (p, share) in self.interior {
1901                conditions.push(Condition::partial(
1902                    self.frame.chart(*p),
1903                    (0, 0),
1904                    [self.frame.height(*p)],
1905                    share.sqrt() / self.size,
1906                ));
1907            }
1908            // A held point weighs as a side's whole run of points does.
1909            let weight = 1.0 / self.size;
1910            let mut held: Vec<Point2> = Vec::new();
1911            // Each held point's condition, by its place among the
1912            // conditions, and its weight.
1913            let mut holds: Vec<(usize, f64)> = Vec::new();
1914            let rounds = if firm {
1915                HOLD_ROUNDS + FIRM_ROUNDS
1916            } else {
1917                HOLD_ROUNDS
1918            };
1919            // Whether a stray point was found held already, and how the
1920            // net's rounds ended.
1921            let mut twice = false;
1922            let mut settled = false;
1923            let mut grid: Option<Grid> = None;
1924            // The last fit's factored system, and the conditions changed
1925            // since: a refit takes the factor's rows none of them reaches.
1926            let mut last: Option<Factor> = None;
1927            let mut changed: Vec<usize> = Vec::new();
1928            for round in 0..rounds {
1929                let (surface, reports, gap) = match self.first {
1930                    Some(first) if net == from && round == 0 => {
1931                        (first.0.clone(), first.1.clone(), first.2)
1932                    }
1933                    _ => {
1934                        let again = match (&last, self.first) {
1935                            (Some(factor), _) => Some((factor, changed.as_slice())),
1936                            (None, Some(first)) if net == from => {
1937                                Some((&first.3, changed.as_slice()))
1938                            }
1939                            _ => None,
1940                        };
1941                        let (fit, factor) = fit_height_with(
1942                            self.frame,
1943                            self.domain,
1944                            controls,
1945                            &conditions,
1946                            self.smoothing,
1947                            again,
1948                            tol,
1949                        )?;
1950                        last = Some(factor);
1951                        changed.clear();
1952                        let surface = fit.surface;
1953                        let reports =
1954                            measure_sides(self.sides, self.traces, &surface, 3 * samples + 1, tol)?;
1955                        let mut gap = 0.0f64;
1956                        for (p, _) in self.interior {
1957                            let q = self.frame.chart(*p);
1958                            gap = gap.max(surface.point_at(q.x, q.y, tol)?.distance(*p));
1959                        }
1960                        (surface, reports, gap)
1961                    }
1962                };
1963                if let Some(side) = first_miss(self.sides, &reports, gap, self.tolerance) {
1964                    miss = format!("at {}x{} controls, {side}", controls.0, controls.1);
1965                    settled = true;
1966                    break;
1967                }
1968                let grid =
1969                    grid.get_or_insert_with(|| Grid::over(&surface, self.outline, self.domain));
1970                let strays = grid.strays(&surface, self.region, tol)?;
1971                let past = strays.iter().map(|s| s.1).fold(f64::NEG_INFINITY, f64::max);
1972                if past <= self.tolerance {
1973                    return Ok(Ok((surface, reports, gap)));
1974                }
1975                miss = format!(
1976                    "at {}x{} controls, the patch stands {past} past a plane it is held within",
1977                    controls.0, controls.1
1978                );
1979                let mut added = false;
1980                // Points within half the tolerance of a plane (the patch's
1981                // own sides lie on the faces' planes) are left free; of the
1982                // rest, the worst in each span cell of the net is held.
1983                let cells = (controls.0 - DEGREE, controls.1 - DEGREE);
1984                let mut worst: std::collections::BTreeMap<(usize, usize), Stray> =
1985                    std::collections::BTreeMap::new();
1986                for stray in strays.into_iter().filter(|s| s.1 > 0.5 * self.tolerance) {
1987                    let key = cell_of(stray.0, self.domain, cells);
1988                    match worst.get(&key) {
1989                        Some(kept) if kept.1 >= stray.1 => {}
1990                        _ => {
1991                            worst.insert(key, stray);
1992                        }
1993                    }
1994                }
1995                for (q, d, normal, p) in worst.into_values() {
1996                    let before = held
1997                        .iter()
1998                        .position(|h| (h.x - q.x).abs() + (h.y - q.y).abs() <= 1e-12);
1999                    if before.is_some() {
2000                        twice = true;
2001                        if !firm {
2002                            continue;
2003                        }
2004                    }
2005                    // The region's boundary there, as the plane through
2006                    // the nearest point on it, square to its normal.
2007                    let on = p - normal * d;
2008                    let lift = normal.dot(self.frame.n);
2009                    if lift.abs() < MIN_LIFT {
2010                        continue;
2011                    }
2012                    // The height that puts the point half the tolerance
2013                    // inside the plane, straight above its chart point.
2014                    let foot = self.frame.origin + self.frame.e1 * q.x + self.frame.e2 * q.y;
2015                    let height = (-0.5 * self.tolerance - normal.dot(foot - on)) / lift;
2016                    if let Some(k) = before {
2017                        let (at, w) = holds[k];
2018                        holds[k].1 = w * 2.0;
2019                        conditions[at] = Condition::partial(q, (0, 0), [height], w * 2.0);
2020                        changed.push(at);
2021                    } else {
2022                        conditions.push(Condition::partial(q, (0, 0), [height], weight));
2023                        holds.push((conditions.len() - 1, weight));
2024                        changed.push(conditions.len() - 1);
2025                        held.push(q);
2026                    }
2027                    added = true;
2028                }
2029                if !added {
2030                    settled = true;
2031                    break;
2032                }
2033            }
2034            if !firm && settled && !twice {
2035                repeat.push(net);
2036            }
2037        }
2038        Ok(Err(miss))
2039    }
2040}
2041
2042/// A stray point: its chart position, how far outside the region, the
2043/// region's outward normal there, and the point itself.
2044type Stray = (Point2, f64, Vector, Point);
2045
2046/// For each of a set of heights, the edges of a polygon that [`inside`]
2047/// counts at a point of that height: an edge whose ends straddle it.
2048/// [`inside`] adds nothing for any other edge, so reading only these, in
2049/// the same order and by the same test, is its answer exactly.
2050struct Crossings {
2051    rows: Vec<Vec<usize>>,
2052}
2053
2054impl Crossings {
2055    fn of(polygon: &[Point2], heights: &[f64]) -> Self {
2056        let n = polygon.len();
2057        let rows = heights
2058            .iter()
2059            .map(|&y| {
2060                (0..n)
2061                    .filter(|&i| {
2062                        let (a, b) = (polygon[i], polygon[(i + 1) % n]);
2063                        (a.y <= y && b.y > y) || (a.y > y && b.y <= y)
2064                    })
2065                    .collect()
2066            })
2067            .collect();
2068        Self { rows }
2069    }
2070
2071    /// [`inside`] for a point `q` at height number `row`.
2072    fn inside(&self, polygon: &[Point2], row: usize, q: Point2) -> bool {
2073        let n = polygon.len();
2074        let mut winding = 0i32;
2075        for &i in &self.rows[row] {
2076            let (a, b) = (polygon[i], polygon[(i + 1) % n]);
2077            let side = (b - a).cross(q - a);
2078            if a.y <= q.y {
2079                if b.y > q.y && side > 0.0 {
2080                    winding += 1;
2081                }
2082            } else if b.y <= q.y && side < 0.0 {
2083                winding -= 1;
2084            }
2085        }
2086        winding != 0
2087    }
2088}
2089
2090/// [`measure_side`] for every side, the sides on the workers, in order.
2091fn measure_sides(
2092    sides: &[Side],
2093    traces: &[PlanarCurve],
2094    surface: &BSplineSurface,
2095    samples: usize,
2096    tol: Tolerances,
2097) -> OgeomResult<Vec<FillSide>> {
2098    let pairs: Vec<(&Side, &PlanarCurve)> = sides.iter().zip(traces).collect();
2099    ogeom_core::parallel::map_ordered(&pairs, |_, (side, pcurve)| {
2100        measure_side(side, pcurve, surface, samples, tol)
2101    })
2102    .into_iter()
2103    .collect()
2104}
2105
2106/// The grid points a worker reads at a time in [`Grid::strays`].
2107const STRAY_CHUNK: usize = 4096;
2108
2109/// The span cell of a net with `cells` spans each way over `domain` that
2110/// chart point `q` falls in.
2111fn cell_of(q: Point2, domain: ((f64, f64), (f64, f64)), cells: (usize, usize)) -> (usize, usize) {
2112    let ((ua, ub), (va, vb)) = domain;
2113    #[expect(
2114        clippy::cast_possible_truncation,
2115        clippy::cast_sign_loss,
2116        clippy::cast_precision_loss,
2117        reason = "a cell index from a share of the domain, clamped into range"
2118    )]
2119    let at = |t: f64, lo: f64, hi: f64, n: usize| -> usize {
2120        (((t - lo) / (hi - lo) * n as f64).floor().max(0.0) as usize).min(n - 1)
2121    };
2122    (at(q.x, ua, ub, cells.0), at(q.y, va, vb, cells.1))
2123}
2124
2125/// The grid [`Grid::strays`] reads a net's patch on: its points inside the hole,
2126/// found once for every round of the net.
2127struct Grid {
2128    points: Vec<Point2>,
2129}
2130
2131impl Grid {
2132    /// The grid [`past_planes`] reads `surface` on over `domain`, its points
2133    /// inside `outline` in the same order.
2134    fn over(
2135        surface: &BSplineSurface,
2136        outline: &[Point2],
2137        domain: ((f64, f64), (f64, f64)),
2138    ) -> Self {
2139        let ((ua, ub), (va, vb)) = domain;
2140        let spans = surface
2141            .u_knots()
2142            .control_point_count()
2143            .max(surface.v_knots().control_point_count());
2144        let grid = (4 * spans).max(64);
2145        #[expect(
2146            clippy::cast_precision_loss,
2147            reason = "a grid index, far below the mantissa"
2148        )]
2149        let ys: Vec<f64> = (0..=grid)
2150            .map(|j| (vb - va).mul_add(j as f64 / grid as f64, va))
2151            .collect();
2152        let rows = Crossings::of(outline, &ys);
2153        let mut points = Vec::new();
2154        for i in 0..=grid {
2155            #[expect(
2156                clippy::cast_precision_loss,
2157                reason = "a grid index, far below the mantissa"
2158            )]
2159            let x = (ub - ua).mul_add(i as f64 / grid as f64, ua);
2160            for (j, &y) in ys.iter().enumerate() {
2161                let q = Point2::new(x, y);
2162                if rows.inside(outline, j, q) {
2163                    points.push(q);
2164                }
2165            }
2166        }
2167        Self { points }
2168    }
2169
2170    /// The grid points where `surface` stands outside `region`, in the
2171    /// grid's order.
2172    fn strays(
2173        &self,
2174        surface: &BSplineSurface,
2175        region: Region<'_>,
2176        tol: Tolerances,
2177    ) -> OgeomResult<Vec<Stray>> {
2178        // In chunks on the workers, joined in the grid's order.
2179        let chunks: Vec<&[Point2]> = self.points.chunks(STRAY_CHUNK).collect();
2180        let parts = ogeom_core::parallel::map_ordered(&chunks, |_, chunk| {
2181            let mut found = Vec::new();
2182            for &q in *chunk {
2183                let p = surface.point_at(q.x, q.y, tol)?;
2184                let (d, normal) = region(p);
2185                if d > 0.0 {
2186                    found.push((q, d, normal, p));
2187                }
2188            }
2189            Ok::<_, ogeom_core::OgeomError>(found)
2190        });
2191        let mut found = Vec::new();
2192        for part in parts {
2193            found.extend(part?);
2194        }
2195        Ok(found)
2196    }
2197}
2198
2199fn folds(
2200    surface: &BSplineSurface,
2201    outline: &[Point2],
2202    domain: ((f64, f64), (f64, f64)),
2203    tol: Tolerances,
2204) -> OgeomResult<bool> {
2205    const GRID: usize = 40;
2206    let ((ua, ub), (va, vb)) = domain;
2207    let mut normals = vec![None; (GRID + 1) * (GRID + 1)];
2208    let mut largest = 0.0f64;
2209    for i in 0..=GRID {
2210        for j in 0..=GRID {
2211            #[expect(
2212                clippy::cast_precision_loss,
2213                reason = "a grid index, far below the mantissa"
2214            )]
2215            let q = Point2::new(
2216                (ub - ua).mul_add(i as f64 / GRID as f64, ua),
2217                (vb - va).mul_add(j as f64 / GRID as f64, va),
2218            );
2219            if !inside(outline, q) {
2220                continue;
2221            }
2222            let (du, dv) = surface.d1_at(q.x, q.y, tol)?;
2223            let normal = du.cross(dv);
2224            largest = largest.max(normal.magnitude());
2225            normals[i * (GRID + 1) + j] = Some(normal);
2226        }
2227    }
2228    for i in 0..=GRID {
2229        for j in 0..=GRID {
2230            let Some(here) = normals[i * (GRID + 1) + j] else {
2231                continue;
2232            };
2233            if here.magnitude() <= 1e-6 * largest {
2234                return Ok(true);
2235            }
2236            let right = if i < GRID {
2237                normals[(i + 1) * (GRID + 1) + j]
2238            } else {
2239                None
2240            };
2241            let up = if j < GRID {
2242                normals[i * (GRID + 1) + j + 1]
2243            } else {
2244                None
2245            };
2246            if [right, up]
2247                .into_iter()
2248                .flatten()
2249                .any(|n| n.dot(here) <= 0.0)
2250            {
2251                return Ok(true);
2252            }
2253        }
2254    }
2255    Ok(false)
2256}
2257
2258/// Measure one side at `stations` spread over its edge: the gap between
2259/// the edge's curve and the surface through the pcurve, and against the
2260/// support the angle between normals and the difference of normal
2261/// curvatures square to the edge.
2262fn measure_side(
2263    side: &Side,
2264    pcurve: &PlanarCurve,
2265    surface: &BSplineSurface,
2266    stations: usize,
2267    tol: Tolerances,
2268) -> OgeomResult<FillSide> {
2269    let (mut gap, mut angle, mut curvature) = (0.0f64, 0.0f64, None::<f64>);
2270    for k in 0..stations {
2271        #[expect(
2272            clippy::cast_precision_loss,
2273            reason = "a station index, far below the mantissa"
2274        )]
2275        let t = side.parameter(k as f64 / (stations - 1) as f64);
2276        let p = side.curve.point_at(t, tol)?;
2277        let uv = pcurve.point_at(t, tol)?;
2278        gap = gap.max(surface.point_at(uv.x, uv.y, tol)?.distance(p));
2279        let (Some((suv, theirs)), Some(support)) = (side.support_at(t, tol)?, &side.support) else {
2280            continue;
2281        };
2282        let ours = surface.normal_at(uv.x, uv.y, tol)?.vector();
2283        angle = angle.max(ours.cross(theirs).magnitude().atan2(ours.dot(theirs).abs()));
2284        let across = ours.cross(side.curve.d1_at(t, tol)?);
2285        let signed = |c: SurfaceCurvature| -> Option<f64> {
2286            let k = c.normal_curvature(across)?;
2287            Some(if c.normal.dot_vector(ours) < 0.0 {
2288                -k
2289            } else {
2290                k
2291            })
2292        };
2293        let a = surface.curvature_at(uv.x, uv.y, tol).ok().and_then(signed);
2294        let b = support
2295            .surface
2296            .curvature_at(suv.x, suv.y, tol)
2297            .ok()
2298            .and_then(signed);
2299        if let (Some(a), Some(b)) = (a, b) {
2300            curvature = Some(curvature.unwrap_or(0.0).max((a - b).abs()));
2301        }
2302    }
2303    let supported = side.support.is_some();
2304    Ok(FillSide {
2305        edge: side.edge.clone(),
2306        gap,
2307        angle: supported.then_some(angle),
2308        curvature: curvature.filter(|_| supported),
2309        stations,
2310    })
2311}
2312
2313/// The first deviation past `tolerance`, described, or `None` when every
2314/// side and constraint meets it.
2315fn first_miss(
2316    sides: &[Side],
2317    reports: &[FillSide],
2318    constraint_gap: f64,
2319    tolerance: f64,
2320) -> Option<String> {
2321    for (side, report) in sides.iter().zip(reports) {
2322        let i = side.entry;
2323        if report.gap > tolerance {
2324            return Some(format!("side {i} stands {} off its edge", report.gap));
2325        }
2326        if side.order >= 1 {
2327            let angle = report.angle.unwrap_or(f64::INFINITY);
2328            if angle > tolerance {
2329                return Some(format!(
2330                    "side {i} meets its support {angle} radians from tangent"
2331                ));
2332            }
2333        }
2334        if side.order >= 2 {
2335            match report.curvature {
2336                Some(c) if c <= tolerance => {}
2337                Some(c) => {
2338                    return Some(format!(
2339                        "side {i}'s normal curvature differs from its support's by {c}"
2340                    ));
2341                }
2342                None => {
2343                    return Some(format!(
2344                        "side {i}'s curvature could not be read on both surfaces"
2345                    ));
2346                }
2347            }
2348        }
2349    }
2350    (constraint_gap > tolerance)
2351        .then(|| format!("a constraint stands {constraint_gap} off the surface"))
2352}
2353
2354/// Build the face on the fitted patch, bounded by the sides' own edges or
2355/// their stand-ins, each given its pcurve on it and a tolerance that holds
2356/// its gap.
2357fn build(
2358    model: &mut Model,
2359    sides: &mut [Side],
2360    order: &[usize],
2361    traces: &[PlanarCurve],
2362    reports: &[FillSide],
2363    surface: BSplineSurface,
2364    tol: Tolerances,
2365) -> OgeomResult<Shape> {
2366    stand_in(model, sides, order, tol)?;
2367    let walk: Vec<Shape> = order
2368        .iter()
2369        .map(|&i| {
2370            let side = &sides[i];
2371            if side.reversed {
2372                side.edge.reversed()
2373            } else {
2374                side.edge.clone()
2375            }
2376        })
2377        .collect();
2378    let wire = make_wire(model, &walk, tol)?.shape;
2379    let face = make_face(model, SurfaceGeometry::BSpline(surface), &[wire], tol)?.shape;
2380    let Some(NodeData::Face(data)) = model.node(&face).map(|n| n.data()) else {
2381        ogeom_bail!(Dangling, "the face just built is not in this model");
2382    };
2383    let surface_id = data.surface;
2384    for ((side, pcurve), report) in sides.iter().zip(traces).zip(reports) {
2385        attach_pcurve(
2386            model,
2387            &side.edge,
2388            pcurve.clone(),
2389            surface_id,
2390            Location::identity(),
2391            side.range,
2392        )?;
2393        if report.gap > side.edge_tolerance {
2394            // The edge owns the gap, and so do the vertices bounding it.
2395            let widened = Tolerance::new(report.gap + tol.confusion())?;
2396            model.widen(&side.edge, widened)?;
2397            if let Some((a, b)) = edge_vertices(model, &side.edge)? {
2398                model.widen(&a, widened)?;
2399                model.widen(&b, widened)?;
2400            }
2401        }
2402    }
2403    Ok(face)
2404}
2405
2406/// Give each side the edge the face is bounded by: its own where it is not
2407/// placed and both its corners are vertex nodes it shares with its
2408/// neighbours, otherwise a new edge on its curve where it stands, between
2409/// the loop's corner vertices. A corner keeps the shared vertex where there
2410/// is one, unplaced; elsewhere it is a new vertex midway between the two
2411/// ends, reaching both.
2412fn stand_in(
2413    model: &mut Model,
2414    sides: &mut [Side],
2415    order: &[usize],
2416    tol: Tolerances,
2417) -> OgeomResult<()> {
2418    let n = order.len();
2419    // Corner k is where side `order[k]` ends its walk and `order[k + 1]`
2420    // starts its.
2421    let mut corners: Vec<(Shape, bool)> = Vec::with_capacity(n);
2422    for k in 0..n {
2423        let (a, b) = (&sides[order[k]], &sides[order[(k + 1) % n]]);
2424        let (a0, a1) = ends_of(model, a)?;
2425        let (b0, b1) = ends_of(model, b)?;
2426        let (arrive, leave) = (
2427            if a.reversed { a0 } else { a1 },
2428            if b.reversed { b1 } else { b0 },
2429        );
2430        let (from, to) = (
2431            a.curve
2432                .point_at(if a.reversed { a.range.0 } else { a.range.1 }, tol)?,
2433            b.curve
2434                .point_at(if b.reversed { b.range.1 } else { b.range.0 }, tol)?,
2435        );
2436        if arrive.vertex.is_same(&leave.vertex) && arrive.vertex.location().is_identity() {
2437            corners.push((arrive.vertex, true));
2438            continue;
2439        }
2440        let vertex = make_vertex(model, from.midpoint(to)).shape;
2441        let reach = (0.5 * from.distance(to) + tol.confusion())
2442            .max(a.edge_tolerance)
2443            .max(b.edge_tolerance);
2444        model.widen(&vertex, Tolerance::new(reach)?)?;
2445        corners.push((vertex, false));
2446    }
2447    for k in 0..n {
2448        let side = &sides[order[k]];
2449        let (start, end) = (&corners[(k + n - 1) % n], &corners[k]);
2450        if !side.placed && start.1 && end.1 {
2451            continue;
2452        }
2453        let (from, to) = if side.reversed {
2454            (&end.0, &start.0)
2455        } else {
2456            (&start.0, &end.0)
2457        };
2458        let edge = make_edge_between(model, side.curve.clone(), side.range, from, to, tol)?.shape;
2459        model.widen(&edge, Tolerance::new(side.edge_tolerance)?)?;
2460        sides[order[k]].edge = edge;
2461    }
2462    Ok(())
2463}