Skip to main content

ogeom_offset/
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, PlaneFrame, fit_free, fit_height};
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/// The share of the tolerance the bending energy may cost the conditions;
85/// see [`smoothing_for`].
86const BUDGET: f64 = 0.1;
87/// The least weight of the bending energy: below it rounding rather than
88/// the energy would settle the controls the conditions leave free.
89const LEAST_SMOOTHING: f64 = 1e-12;
90/// The least cosine between a support's normal and the plane's normal for
91/// a height over the plane to meet it (about 84 degrees between them);
92/// below it the free patch does.
93const MIN_LIFT: f64 = 0.1;
94/// A constraint point's weight, as the share of the hole's size a boundary
95/// sample of that length would carry.
96const POINT_SHARE: f64 = 0.05;
97/// Samples per constraint curve.
98const PER_CURVE: usize = 32;
99/// The least share of each corner's turn the loop keeps seen along its
100/// plane's normal for a height over that plane to fill it; below it the
101/// free patch does.
102const KEEP: f64 = 0.25;
103/// Stations per tangent side at which its support's lift off the plane is
104/// read.
105const STEEP_SAMPLES: usize = 256;
106/// Samples per side for the chart the free patch is drawn over.
107const CHART_SAMPLES: usize = 128;
108/// The largest share of a half turn a corner of that chart turns.
109const MOST_TURN: f64 = 0.9;
110/// The least angle, in radians, between the tangents meeting at a corner
111/// of the loop for it to count as one: about a degree.
112const CORNER: f64 = 0.0175;
113
114/// Fill the hole a loop of edges bounds with one face meeting each side's
115/// support at the continuity asked.
116///
117/// The sides may come in any order and either direction; they must chain
118/// into one simple closed loop, each side's end meeting the next one's at
119/// a shared vertex node or where the two vertices lie within their own and
120/// their edges' tolerances of each other. `constraints` are vertices and
121/// edges inside the hole that the surface passes through. The surface is a
122/// cubic B-spline height patch over the plane the loop spans, fitted by least
123/// squares to the sides' positions, to the tangent planes of G1 and G2
124/// sides' supports and to the normal curvatures of G2 sides' supports, with
125/// a thin-plate bending energy settling the rest, weighted from
126/// `tolerance` so it costs the conditions a small share of it; the control
127/// net is refined until every side meets `tolerance` and the conditions'
128/// residual no longer outweighs the energy, or the refinement runs out.
129/// Where a corner of the loop is seen smooth along the plane's normal (two
130/// sides meeting at an angle, each in a plane through that normal), no
131/// height over the plane meets both sides there; nor does one where a G1
132/// or G2 side's support stands within about 6 degrees of square to the
133/// plane, as a tube's wall does to its rim. The patch is then fitted in all
134/// three coordinates over a chart drawn from the loop itself, whose
135/// corners are the loop's: a tangent side fixes the patch's derivative
136/// across it to run into the support, square to the edge, at the side's
137/// own speed (so a cap leaves a tube's rim along the wall and crowns above
138/// it), and a G2 side fixes the second derivative across it to bend as the
139/// support does; the net is refined until the patch also does not fold
140/// over inside the hole.
141/// The face is trimmed by the given edges themselves where they are not
142/// placed and share vertex nodes end to end, each given a pcurve on the
143/// patch; any other side is stood in for by a new edge on its curve
144/// between vertices shared round the loop, so the face is one closed wire
145/// either way. A placed edge or support is read where it stands. The face
146/// faces the way the supports say: across each edge it
147/// runs opposite to its support's use of the edge, so sewing it to the
148/// supports gives a consistently oriented shell. With no supports it faces
149/// the side the loop turns counter-clockwise about, walked from the first
150/// side in that edge's own direction.
151///
152/// `tolerance` is the target for every measured deviation: a distance in
153/// model units for each side's gap and each constraint, an angle in radians
154/// for a G1 or G2 side's tangency, and a curvature difference in inverse
155/// model units for a G2 side. An edge the surface stands further from than
156/// the edge's own tolerance has that tolerance, and its vertices', widened
157/// to the measured gap.
158///
159/// # Errors
160///
161/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction), by
162/// name, where:
163///
164/// - `tolerance` is not positive and finite, or there are no sides;
165/// - a side is not an edge, or has no 3D curve or no vertices;
166/// - the same edge is given twice;
167/// - a side asks [`Continuity::C1`], [`Continuity::C2`] or
168///   [`Continuity::CInfinity`] (parametric continuity between two
169///   surfaces' charts), or G1 or G2 with no support;
170/// - a support is not a face, does not hold its edge, or the
171///   edge does not lie on its surface within `tolerance`;
172/// - the sides do not chain into one closed loop;
173/// - the loop encloses no area, or crosses itself seen along the normal of
174///   the plane it spans (the plane it encloses the most area seen square
175///   to), so the hole is not a height field over that plane;
176/// - two sides meet at a corner where a G1 or G2 side's support stands off
177///   the other side's tangent by more than `tolerance`, so no surface is
178///   tangent to it there;
179/// - a G1 or G2 side's support uses its edge both ways or neither where the
180///   patch is drawn over the loop's own chart;
181/// - a constraint is neither a vertex nor an edge, or lies outside the hole
182///   seen along the plane's normal, or is given where the patch is drawn
183///   over the loop's own chart (the message names the corner or the
184///   support's angle to the plane that called for it);
185/// - the loop's corners turn so far that no chart drawn from it closes.
186///
187/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the finest
188/// control net still misses `tolerance`, naming the side and the deviation.
189pub fn make_filling_n(
190    model: &mut Model,
191    boundary: &[FillBoundary],
192    constraints: &[Shape],
193    tolerance: f64,
194    tol: Tolerances,
195) -> OgeomResult<Filled> {
196    if !(tolerance.is_finite() && tolerance > 0.0) {
197        ogeom_bail!(
198            Construction,
199            "a filling's tolerance is positive and finite; got {tolerance}"
200        );
201    }
202    if boundary.is_empty() {
203        ogeom_bail!(Construction, "a filling needs at least one boundary edge");
204    }
205    let mut sides = Vec::with_capacity(boundary.len());
206    for (i, entry) in boundary.iter().enumerate() {
207        sides.push(read_side(model, i, entry, tolerance, tol)?);
208    }
209    for i in 0..sides.len() {
210        for j in (i + 1)..sides.len() {
211            if sides[i].given.is_same(&sides[j].given) {
212                ogeom_bail!(Construction, "side {j} is side {i}'s edge again");
213            }
214        }
215    }
216    let mut order = chain(model, &mut sides, tol)?;
217    face_the_supports(model, &mut sides, &mut order)?;
218
219    // The plane the loop spans, and the loop seen along its normal.
220    let outline = loop_points(&sides, &order, tol)?;
221    let frame = frame_of(&outline, tol)?;
222    let chart_outline: Vec<Point2> = outline.iter().map(|p| frame.chart(*p)).collect();
223    if crosses_itself(&chart_outline) {
224        ogeom_bail!(
225            Construction,
226            "the boundary loop crosses itself seen along the normal of the \
227             plane it spans; the hole is not a height field over that plane"
228        );
229    }
230    let interior = constraint_points(model, constraints, tol)?;
231    for (k, (p, _)) in interior.iter().enumerate() {
232        if !inside(&chart_outline, frame.chart(*p)) {
233            ogeom_bail!(
234                Construction,
235                "a point of constraint {k} at {p:?} lies outside the hole seen \
236                 along the normal of the plane the boundary spans"
237            );
238        }
239    }
240
241    // A corner seen smooth along the plane's normal asks a height over the
242    // plane for two slopes at one point, one from each side; the free patch
243    // over a chart drawn from the loop, whose corners are the loop's, takes
244    // it. So does a support standing square to the plane, which asks a
245    // height for an infinite slope: the free patch's derivative across a
246    // side is a vector, which may run square to the plane.
247    let corners = corners_of(&sides, &order, tol)?;
248    tangent_corners(&sides, &order, &corners, tolerance, tol)?;
249    let steepest = steepest_support(&sides, &frame, tol)?;
250    let free =
251        kept_turn(frame.n, &corners) < KEEP || steepest.is_some_and(|(_, lift)| lift < MIN_LIFT);
252    if free && !interior.is_empty() {
253        let why = match steepest {
254            Some((i, lift)) if lift < MIN_LIFT => format!(
255                "side {i}'s support stands at {:.1} degrees to the plane the \
256                 boundary spans, past the {:.1} a height over that plane takes",
257                lift.acos().to_degrees(),
258                MIN_LIFT.acos().to_degrees()
259            ),
260            _ => "a corner of the boundary loop is seen smooth along the normal \
261                  of the plane it spans"
262                .to_owned(),
263        };
264        ogeom_bail!(
265            Construction,
266            "{why}, so the filling is drawn over a chart of the loop's own, \
267             which places no interior constraints"
268        );
269    }
270    let mut traces = Vec::with_capacity(sides.len());
271    let mut lengths = Vec::with_capacity(sides.len());
272    for side in &sides {
273        if !free {
274            traces.push(trace(side, &frame, tolerance, tol)?);
275        }
276        lengths.push(side_length(side, tol)?);
277    }
278    let chart_outline = if free {
279        traces = boundary_chart(&sides, &order, &corners, tolerance, tol)?;
280        traced_outline(&sides, &order, &traces, tol)?
281    } else {
282        chart_outline
283    };
284
285    // The rectangle the patch covers: the hole and a margin round it.
286    let (mut lo, mut hi) = (
287        Point2::new(f64::INFINITY, f64::INFINITY),
288        Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
289    );
290    for q in &chart_outline {
291        lo = Point2::new(lo.x.min(q.x), lo.y.min(q.y));
292        hi = Point2::new(hi.x.max(q.x), hi.y.max(q.y));
293    }
294    let margin = MARGIN * (hi.x - lo.x).max(hi.y - lo.y);
295    let domain = (
296        (lo.x - margin, hi.x + margin),
297        (lo.y - margin, hi.y + margin),
298    );
299    let (du, dv) = (domain.0.1 - domain.0.0, domain.1.1 - domain.1.0);
300    let size = du.max(dv);
301
302    let smoothing = smoothing_for(&sides, tolerance, size);
303    let mut last_miss = String::new();
304    // The latest fit that met the tolerance.
305    let mut fallback = None;
306    for base in NETS {
307        #[expect(
308            clippy::cast_possible_truncation,
309            clippy::cast_sign_loss,
310            clippy::cast_precision_loss,
311            reason = "a control count of a few dozen, from a positive ratio"
312        )]
313        let count = |extent: f64| -> usize {
314            ((base as f64 * extent / size).round() as usize).max(DEGREE + 2)
315        };
316        let controls = (count(du), count(dv));
317        let samples = 4 * controls.0.max(controls.1) + 8;
318
319        let fit = if free {
320            let mut conditions = Vec::new();
321            for ((side, length), pcurve) in sides.iter().zip(&lengths).zip(&traces) {
322                free_conditions(
323                    side,
324                    pcurve,
325                    *length / size,
326                    size,
327                    samples,
328                    &mut conditions,
329                    tol,
330                )?;
331            }
332            fit_free(domain, controls, &conditions, smoothing, tol)?
333        } else {
334            let mut conditions = Vec::new();
335            for (side, length) in sides.iter().zip(&lengths) {
336                side_conditions(
337                    side,
338                    *length / size,
339                    size,
340                    samples,
341                    &frame,
342                    &mut conditions,
343                    tol,
344                )?;
345            }
346            for (p, share) in &interior {
347                conditions.push(Condition::partial(
348                    frame.chart(*p),
349                    (0, 0),
350                    [frame.height(*p)],
351                    share.sqrt() / size,
352                ));
353            }
354            fit_height(&frame, domain, controls, &conditions, smoothing, tol)?
355        };
356        let surface = fit.surface;
357
358        let mut reports = Vec::with_capacity(sides.len());
359        for (side, pcurve) in sides.iter().zip(&traces) {
360            reports.push(measure_side(side, pcurve, &surface, 3 * samples + 1, tol)?);
361        }
362        let mut constraint_gap = 0.0f64;
363        for (p, _) in &interior {
364            let q = frame.chart(*p);
365            constraint_gap = constraint_gap.max(surface.point_at(q.x, q.y, tol)?.distance(*p));
366        }
367
368        if let Some(miss) = first_miss(&sides, &reports, constraint_gap, tolerance) {
369            last_miss = format!("at {}x{} controls, {miss}", controls.0, controls.1);
370            continue;
371        }
372        if free && folds(&surface, &chart_outline, domain, tol)? {
373            last_miss = format!(
374                "at {}x{} controls, the patch folds over inside the hole",
375                controls.0, controls.1
376            );
377            continue;
378        }
379        fallback = Some((surface, reports, constraint_gap));
380        // A residual-led net is refined; see [`smoothing_for`].
381        if fit.residual <= smoothing {
382            break;
383        }
384    }
385    let Some((surface, reports, constraint_gap)) = fallback else {
386        ogeom_bail!(
387            NotDone,
388            "the filling misses its tolerance of {tolerance} on the finest net: {last_miss}"
389        )
390    };
391    let face = build(model, &mut sides, &order, &traces, &reports, surface, tol)?;
392    let mut history = History::new();
393    let mut reports = reports;
394    for (side, report) in sides.iter().zip(&mut reports) {
395        history.generate(&side.given, face.clone());
396        if !side.edge.is_same(&side.given) {
397            history.generate(&side.given, side.edge.clone());
398        }
399        report.edge = side.edge.clone();
400    }
401    for constraint in constraints {
402        history.generate(constraint, face.clone());
403    }
404    Ok(Filled {
405        built: Built::new(face, history),
406        sides: reports,
407        constraint_gap,
408    })
409}
410
411/// The surface a side's support lies on and the side's pcurve there.
412struct Support {
413    face: Shape,
414    surface: SurfaceGeometry,
415    pcurve: PlanarCurve,
416    prange: (f64, f64),
417    /// Which side of the edge the support's material lies on: the
418    /// direction into it is `material · (normal × d1)`, the surface's
419    /// normal crossed with the edge curve's derivative. `None` where the
420    /// face uses the edge both ways or neither.
421    material: Option<f64>,
422}
423
424/// One side, read and checked.
425struct Side {
426    entry: usize,
427    /// The edge as given, forward.
428    given: Shape,
429    /// The edge bounding the face: the given one, or its stand-in once the
430    /// face is built.
431    edge: Shape,
432    /// Whether the given edge or its curve is placed.
433    placed: bool,
434    /// The curve where the edge stands.
435    curve: Curve,
436    range: (f64, f64),
437    edge_tolerance: f64,
438    /// 0, 1 or 2: positions, tangent planes, normal curvatures.
439    order: usize,
440    support: Option<Support>,
441    /// Whether the loop runs against the edge.
442    reversed: bool,
443}
444
445impl Side {
446    fn parameter(&self, f: f64) -> f64 {
447        (self.range.1 - self.range.0).mul_add(f, self.range.0)
448    }
449
450    /// The support's chart position and unit normal at the edge's
451    /// parameter `t`.
452    fn support_at(&self, t: f64, tol: Tolerances) -> OgeomResult<Option<(Point2, Vector)>> {
453        let Some(support) = &self.support else {
454            return Ok(None);
455        };
456        let f = (t - self.range.0) / (self.range.1 - self.range.0);
457        let pt = (support.prange.1 - support.prange.0).mul_add(f, support.prange.0);
458        let uv = support.pcurve.point_at(pt, tol)?;
459        let normal = support.surface.normal_at(uv.x, uv.y, tol)?.vector();
460        Ok(Some((uv, normal)))
461    }
462}
463
464fn read_side(
465    model: &Model,
466    i: usize,
467    entry: &FillBoundary,
468    tolerance: f64,
469    tol: Tolerances,
470) -> OgeomResult<Side> {
471    let edge = &entry.edge;
472    if model.kind_of(edge)? != ShapeType::Edge {
473        ogeom_bail!(Construction, "side {i} is not an edge");
474    }
475    let order = match entry.continuity {
476        Continuity::C0 => 0,
477        Continuity::G1 => 1,
478        Continuity::G2 => 2,
479        other => ogeom_bail!(
480            Construction,
481            "side {i} asks {other:?}: parametric continuity between two \
482             surfaces' charts is not what a filling meets; ask for G1 or G2"
483        ),
484    };
485    let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
486        ogeom_bail!(Construction, "side {i}'s edge holds no edge data");
487    };
488    let Some(EdgeRepr::Curve3d {
489        curve,
490        range,
491        location: own,
492    }) = data.curve3d()
493    else {
494        ogeom_bail!(Construction, "side {i}'s edge has no 3D curve");
495    };
496    let Some(curve) = model.geometry().curve(*curve).cloned() else {
497        ogeom_bail!(Dangling, "side {i}'s curve is not in this model");
498    };
499    let range = *range;
500    let edge_tolerance = data.tolerance.get();
501    let placed = !(edge.location().is_identity() && own.is_identity());
502    let curve = if placed {
503        curve
504            .transformed(&own.composed(model.datums())?, tol)?
505            .transformed(&edge.transform(model.datums())?, tol)?
506    } else {
507        curve
508    };
509    let edge = edge.oriented(Orientation::Forward);
510
511    let support = match &entry.support {
512        None if order > 0 => ogeom_bail!(
513            Construction,
514            "side {i} asks {:?} but names no support face to meet",
515            entry.continuity
516        ),
517        None => None,
518        Some(face) => Some(read_support(
519            model,
520            i,
521            face,
522            &edge,
523            &curve,
524            range,
525            tolerance.max(edge_tolerance),
526            tol,
527        )?),
528    };
529    Ok(Side {
530        entry: i,
531        given: edge.clone(),
532        edge,
533        placed,
534        curve,
535        range,
536        edge_tolerance,
537        order,
538        support,
539        reversed: false,
540    })
541}
542
543#[expect(
544    clippy::too_many_arguments,
545    reason = "the side's edge, curve and range are read once by the caller"
546)]
547fn read_support(
548    model: &Model,
549    i: usize,
550    face: &Shape,
551    edge: &Shape,
552    curve: &Curve,
553    range: (f64, f64),
554    reach: f64,
555    tol: Tolerances,
556) -> OgeomResult<Support> {
557    if model.kind_of(face)? != ShapeType::Face {
558        ogeom_bail!(Construction, "side {i}'s support is not a face");
559    }
560    let Some(NodeData::Face(face_data)) = model.node(face).map(|n| n.data()) else {
561        ogeom_bail!(Dangling, "side {i}'s support is not in this model");
562    };
563    let face_placed = !(face.location().is_identity() && face_data.location.is_identity());
564    // A face's material lies on the left of each edge as its wire walks
565    // it, seen from the face's side of its surface.
566    let uses: Vec<Orientation> = explore(model, face, Filter::OfType(ShapeType::Edge))?
567        .iter()
568        .filter(|e| e.is_same(edge))
569        .map(Shape::orientation)
570        .collect();
571    let sense = |o: Orientation| match o {
572        Orientation::Forward => Some(1.0),
573        Orientation::Reversed => Some(-1.0),
574        _ => None,
575    };
576    let material = match uses.as_slice() {
577        [first, rest @ ..] if rest.iter().all(|o| o == first) => sense(face.orientation())
578            .zip(sense(*first))
579            .map(|(f, e)| f * e),
580        _ => None,
581    };
582    if uses.is_empty() {
583        ogeom_bail!(
584            Construction,
585            "side {i}'s support face does not hold the side's edge at its placement"
586        );
587    }
588    let surface_id = face_data.surface;
589    let Some(surface) = model.geometry().surface(surface_id).cloned() else {
590        ogeom_bail!(Dangling, "side {i}'s support surface is not in this model");
591    };
592    let surface = if face_placed {
593        surface
594            .transformed(&face_data.location.composed(model.datums())?, tol)?
595            .transformed(&face.transform(model.datums())?, tol)?
596    } else {
597        surface
598    };
599    let Some(edge_data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
600        ogeom_bail!(Construction, "side {i}'s edge holds no edge data");
601    };
602    // A stored pcurve describes the surface's own chart, which a placed
603    // face's restated surface need not share.
604    let stored = match edge_data
605        .pcurve_for(surface_id, edge.location())
606        .filter(|_| !face_placed)
607    {
608        Some(
609            EdgeRepr::PCurve { curve, range, .. }
610            | EdgeRepr::Seam {
611                forward: curve,
612                range,
613                ..
614            },
615        ) => model
616            .geometry()
617            .pcurve(*curve)
618            .cloned()
619            .map(|c| (c, *range)),
620        _ => None,
621    };
622    // The edge must lie on the surface it is said to bound. A stored image
623    // that misses it (one kept for another placement of the same edge
624    // node) gives way to one fitted where the edge stands.
625    let off = |pcurve: &PlanarCurve, prange: (f64, f64)| -> OgeomResult<f64> {
626        let mut worst = 0.0f64;
627        for k in 0..=16 {
628            let f = f64::from(k) / 16.0;
629            let t = (range.1 - range.0).mul_add(f, range.0);
630            let pt = (prange.1 - prange.0).mul_add(f, prange.0);
631            let uv = pcurve.point_at(pt, tol)?;
632            let on = surface.point_at(uv.x, uv.y, tol)?;
633            worst = worst.max(on.distance(curve.point_at(t, tol)?));
634        }
635        Ok(worst)
636    };
637    let stored = match stored {
638        Some((pcurve, prange)) => {
639            let worst = off(&pcurve, prange)?;
640            (worst <= reach).then_some((pcurve, prange, worst))
641        }
642        None => None,
643    };
644    let (pcurve, prange, worst) = if let Some(found) = stored {
645        found
646    } else {
647        let (fitted, _, _, _, _) =
648            ogeom_algo::pcurve_fit::fit_projected_pcurve(curve, range, &surface, tol)?;
649        let worst = off(&fitted, range)?;
650        (fitted, range, worst)
651    };
652    if worst > reach {
653        ogeom_bail!(
654            Construction,
655            "side {i}'s edge stands {worst} off its support face, past {reach}"
656        );
657    }
658    Ok(Support {
659        face: face.clone(),
660        surface,
661        pcurve,
662        prange,
663        material,
664    })
665}
666
667/// One end of a side: its vertex, where the vertex stands, and how far
668/// it reaches (its own tolerance or its edge's, the wider).
669#[derive(Clone)]
670struct End {
671    vertex: Shape,
672    at: Point,
673    reach: f64,
674}
675
676/// A side's two ends, in the edge's own direction.
677fn ends_of(model: &Model, side: &Side) -> OgeomResult<(End, End)> {
678    let Some((a, b)) = edge_vertices(model, &side.given)? else {
679        ogeom_bail!(
680            Construction,
681            "side {} has no vertices, so it cannot be shown to join the loop",
682            side.entry
683        );
684    };
685    let end = |vertex: Shape| -> OgeomResult<End> {
686        let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) else {
687            ogeom_bail!(Construction, "side {}'s vertex holds no point", side.entry);
688        };
689        Ok(End {
690            at: vertex.transform(model.datums())?.apply(data.point),
691            reach: data.tolerance.get().max(side.edge_tolerance),
692            vertex,
693        })
694    };
695    Ok((end(a)?, end(b)?))
696}
697
698/// Chain the sides into one loop: the order they are walked in, with each
699/// side's `reversed` set to the direction it is walked. Ends join where
700/// they are one vertex, or failing that where they lie within reach of
701/// each other.
702fn chain(model: &Model, sides: &mut [Side], tol: Tolerances) -> OgeomResult<Vec<usize>> {
703    let mut ends = Vec::with_capacity(sides.len());
704    for side in sides.iter() {
705        let (a, b) = ends_of(model, side)?;
706        ends.push((a, b));
707    }
708    let same = |a: &End, b: &End| -> OgeomResult<bool> {
709        Ok(a.vertex.is_same(&b.vertex) || model.same_position(&a.vertex, &b.vertex, tol)?)
710    };
711    let near = |a: &End, b: &End| a.at.distance(b.at) <= a.reach + b.reach + tol.confusion();
712    let meets = |a: &End, b: &End| -> OgeomResult<bool> { Ok(same(a, b)? || near(a, b)) };
713    let n = sides.len();
714    let mut used = vec![false; n];
715    used[0] = true;
716    let mut order = vec![0];
717    let first = ends[0].0.clone();
718    let mut cursor = ends[0].1.clone();
719    while order.len() < n {
720        let last = sides[order[order.len() - 1]].entry;
721        // A shared vertex is the stronger word: ends merely near the
722        // cursor count only where none is the cursor's own vertex.
723        let mut found: Vec<(usize, bool)> = Vec::new();
724        for strict in [true, false] {
725            for j in 0..n {
726                if used[j] {
727                    continue;
728                }
729                let joins = |e: &End| -> OgeomResult<bool> {
730                    if strict {
731                        same(e, &cursor)
732                    } else {
733                        meets(e, &cursor)
734                    }
735                };
736                if joins(&ends[j].0)? {
737                    found.push((j, false));
738                } else if joins(&ends[j].1)? {
739                    found.push((j, true));
740                }
741            }
742            if !found.is_empty() {
743                break;
744            }
745        }
746        let (j, reversed) = match found.as_slice() {
747            [one] => *one,
748            [] => ogeom_bail!(
749                Construction,
750                "the boundary does not close: no side continues where side {last} ends"
751            ),
752            _ => ogeom_bail!(
753                Construction,
754                "{} sides continue where side {last} ends; a filling's boundary \
755                 is one simple loop",
756                found.len()
757            ),
758        };
759        used[j] = true;
760        sides[j].reversed = reversed;
761        cursor = if reversed {
762            ends[j].0.clone()
763        } else {
764            ends[j].1.clone()
765        };
766        order.push(j);
767    }
768    if !meets(&cursor, &first)? {
769        ogeom_bail!(
770            Construction,
771            "the boundary does not close: the last side ends away from where the first begins"
772        );
773    }
774    Ok(order)
775}
776
777/// Turn the loop to run against its supports' uses of the shared edges,
778/// the majority deciding where they disagree.
779fn face_the_supports(model: &Model, sides: &mut [Side], order: &mut [usize]) -> OgeomResult<()> {
780    let (mut agree, mut disagree) = (0usize, 0usize);
781    for side in sides.iter() {
782        let Some(support) = &side.support else {
783            continue;
784        };
785        let uses: Vec<Orientation> =
786            explore(model, &support.face, Filter::OfType(ShapeType::Edge))?
787                .iter()
788                .filter(|e| e.is_same(&side.given))
789                .map(Shape::orientation)
790                .collect();
791        let Some(&first) = uses.first() else {
792            continue;
793        };
794        if uses.iter().any(|o| *o != first)
795            || !matches!(first, Orientation::Forward | Orientation::Reversed)
796        {
797            continue;
798        }
799        if (first == Orientation::Forward) == !side.reversed {
800            disagree += 1;
801        } else {
802            agree += 1;
803        }
804    }
805    if disagree > agree {
806        order.reverse();
807        for side in sides.iter_mut() {
808            side.reversed = !side.reversed;
809        }
810    }
811    Ok(())
812}
813
814/// Points round the loop in its walking order.
815fn loop_points(sides: &[Side], order: &[usize], tol: Tolerances) -> OgeomResult<Vec<Point>> {
816    let mut out = Vec::with_capacity(order.len() * OUTLINE_SAMPLES);
817    for &i in order {
818        let side = &sides[i];
819        for k in 0..OUTLINE_SAMPLES {
820            #[expect(
821                clippy::cast_precision_loss,
822                reason = "a sample index, far below the mantissa"
823            )]
824            let mut f = k as f64 / OUTLINE_SAMPLES as f64;
825            if side.reversed {
826                f = 1.0 - f;
827            }
828            out.push(side.curve.point_at(side.parameter(f), tol)?);
829        }
830    }
831    Ok(out)
832}
833
834/// The plane a closed loop spans: its vector area's normal, which the loop
835/// turns counter-clockwise about, through its centroid, with `e1` along the
836/// loop's widest spread in the plane.
837fn frame_of(outline: &[Point], tol: Tolerances) -> OgeomResult<PlaneFrame> {
838    let Ok(origin) = Point::centroid(outline) else {
839        ogeom_bail!(Construction, "the boundary loop has no points");
840    };
841    let mut area = Vector::ZERO;
842    let mut reach = 0.0f64;
843    for (k, p) in outline.iter().enumerate() {
844        let q = outline[(k + 1) % outline.len()];
845        area += (*p - origin).cross(q - origin) * 0.5;
846        reach = reach.max(p.distance(origin));
847    }
848    let magnitude = area.magnitude();
849    if magnitude <= 1e-9 * reach * reach || magnitude <= tol.confusion() * tol.confusion() {
850        ogeom_bail!(
851            Construction,
852            "the boundary loop encloses no area seen from any plane"
853        );
854    }
855    let n = area * (1.0 / magnitude);
856    let b1 = Direction::new(n, tol)?.any_perpendicular().vector();
857    let b2 = n.cross(b1);
858    let (mut sxx, mut syy, mut sxy) = (0.0f64, 0.0f64, 0.0f64);
859    for p in outline {
860        let d = *p - origin;
861        let (x, y) = (d.dot(b1), d.dot(b2));
862        sxx += x * x;
863        syy += y * y;
864        sxy += x * y;
865    }
866    let theta = 0.5 * (2.0 * sxy).atan2(sxx - syy);
867    let e1 = b1 * theta.cos() + b2 * theta.sin();
868    let e2 = n.cross(e1);
869    Ok(PlaneFrame { origin, e1, e2, n })
870}
871
872/// The loop's corners in its walking order, corner `k` where side
873/// `order[k]` ends and side `order[k + 1]` begins: the unit tangents
874/// arriving and leaving, and the angle between them, zero where the loop
875/// runs on smoothly (within [`CORNER`]) or a tangent vanishes.
876fn corners_of(
877    sides: &[Side],
878    order: &[usize],
879    tol: Tolerances,
880) -> OgeomResult<Vec<(Vector, Vector, f64)>> {
881    let walked = |side: &Side, end: bool| -> OgeomResult<Option<Vector>> {
882        let t = if end == side.reversed {
883            side.range.0
884        } else {
885            side.range.1
886        };
887        let d = side.curve.d1_at(t, tol)?;
888        let d = if side.reversed { d * -1.0 } else { d };
889        Ok(Direction::new(d, tol).ok().map(Direction::vector))
890    };
891    let mut out = Vec::with_capacity(order.len());
892    for (k, &i) in order.iter().enumerate() {
893        let next = &sides[order[(k + 1) % order.len()]];
894        let corner = match (walked(&sides[i], true)?, walked(next, false)?) {
895            (Some(a), Some(b)) => {
896                let turn = a.cross(b).magnitude().atan2(a.dot(b));
897                (a, b, if turn > CORNER { turn } else { 0.0 })
898            }
899            _ => (Vector::ZERO, Vector::ZERO, 0.0),
900        };
901        out.push(corner);
902    }
903    Ok(out)
904}
905
906/// The least share of its turn any corner of the loop still turns seen
907/// along the unit `n`; one where the loop has no corners.
908fn kept_turn(n: Vector, corners: &[(Vector, Vector, f64)]) -> f64 {
909    let flat = |v: Vector| v - n * v.dot(n);
910    let mut worst = 1.0f64;
911    for (a, b, turn) in corners {
912        if *turn > 0.0 {
913            let (a, b) = (flat(*a), flat(*b));
914            let seen = a.cross(b).magnitude().atan2(a.dot(b));
915            worst = worst.min((seen / turn).min(1.0));
916        }
917    }
918    worst
919}
920
921/// A chart drawn from the loop itself, for the free patch: each side's
922/// pcurve, by side index, at the side's own parameter.
923///
924/// The chart walks the loop at the loop's own speed, turning at each corner
925/// by the corner's turn (held under [`MOST_TURN`] of a half turn, and
926/// scaled down together where they would leave the sides no turn of their
927/// own) and spreading the rest of a full turn evenly along its length, so
928/// its corners are the loop's corners and it is smooth wherever the loop
929/// is. Such a walk ends short of where it began by a little; that drift is
930/// taken out evenly along it, one vector off every step's velocity, which
931/// leaves the walk smooth where it was smooth and its corners corners.
932fn boundary_chart(
933    sides: &[Side],
934    order: &[usize],
935    corners: &[(Vector, Vector, f64)],
936    tolerance: f64,
937    tol: Tolerances,
938) -> OgeomResult<Vec<PlanarCurve>> {
939    use std::f64::consts::{PI, TAU};
940    // Each side at even parameters, with the length walked from where the
941    // loop enters the side.
942    let mut walks: Vec<(Vec<f64>, Vec<f64>, f64)> =
943        vec![(Vec::new(), Vec::new(), 0.0); sides.len()];
944    let mut total = 0.0;
945    for &i in order {
946        let side = &sides[i];
947        let mut params = Vec::with_capacity(CHART_SAMPLES + 1);
948        let mut along = Vec::with_capacity(CHART_SAMPLES + 1);
949        let mut previous = side.curve.point_at(side.range.0, tol)?;
950        let mut length = 0.0;
951        for k in 0..=CHART_SAMPLES {
952            #[expect(
953                clippy::cast_precision_loss,
954                reason = "a sample index, far below the mantissa"
955            )]
956            let t = side.parameter(k as f64 / CHART_SAMPLES as f64);
957            let p = side.curve.point_at(t, tol)?;
958            length += p.distance(previous);
959            previous = p;
960            params.push(t);
961            along.push(length);
962        }
963        if side.reversed {
964            for a in &mut along {
965                *a = length - *a;
966            }
967        }
968        total += length;
969        walks[i] = (params, along, length);
970    }
971    if total <= tol.confusion() {
972        ogeom_bail!(Construction, "the boundary loop has no length");
973    }
974    let turns: Vec<f64> = corners.iter().map(|c| c.2.min(MOST_TURN * PI)).collect();
975    let sum: f64 = turns.iter().sum();
976    let scale = if sum > MOST_TURN * TAU {
977        MOST_TURN * TAU / sum
978    } else {
979        1.0
980    };
981    let bend = sum.mul_add(-scale, TAU) / total;
982    // The point a walk of length `s` from `start`, heading `heading`,
983    // reaches turning at `bend`.
984    let arc = |start: Point2, heading: f64, s: f64| -> Point2 {
985        let delta = bend * s;
986        if delta.abs() < 1e-9 {
987            start + Vector2::new(heading.cos(), heading.sin()) * s
988        } else {
989            start
990                + Vector2::new(
991                    (heading + delta).sin() - heading.sin(),
992                    heading.cos() - (heading + delta).cos(),
993                ) * (1.0 / bend)
994        }
995    };
996    let mut starts = vec![(Point2::new(0.0, 0.0), 0.0, 0.0); sides.len()];
997    let (mut at, mut heading, mut walked) = (Point2::new(0.0, 0.0), 0.0f64, 0.0f64);
998    for (k, &i) in order.iter().enumerate() {
999        starts[i] = (at, heading, walked);
1000        let length = walks[i].2;
1001        at = arc(at, heading, length);
1002        heading += bend.mul_add(length, turns[k] * scale);
1003        walked += length;
1004    }
1005    let drift = at - Point2::new(0.0, 0.0);
1006    if drift.magnitude() > 0.5 * total / TAU {
1007        ogeom_bail!(
1008            Construction,
1009            "the boundary loop's corners leave no chart drawn from it closing"
1010        );
1011    }
1012    let mut out = Vec::with_capacity(sides.len());
1013    for (i, (params, along, _)) in walks.iter().enumerate() {
1014        let (start, heading, before) = starts[i];
1015        let points: Vec<Point2> = along
1016            .iter()
1017            .map(|&s| arc(start, heading, s) - drift * ((before + s) / total))
1018            .collect();
1019        let fitted = ogeom_geom::fit::fit_points_2d_at(params, &points, 3, tolerance * 1e-2, tol)?;
1020        out.push(PlanarCurve::BSpline(fitted.curve));
1021    }
1022    Ok(out)
1023}
1024/// Whether a closed polygon crosses itself: any two segments that are not
1025/// neighbours crossing, each segment's ends standing clear of the other's
1026/// line on opposite sides.
1027///
1028/// A point within rounding of a line is on it, not on a side: samples of a
1029/// straight side are collinear, and two segments of one straight side
1030/// would otherwise cross wherever rounding scatters their ends' sides.
1031fn crosses_itself(polygon: &[Point2]) -> bool {
1032    let n = polygon.len();
1033    let reach = polygon
1034        .iter()
1035        .map(|q| q.x.abs().max(q.y.abs()))
1036        .fold(0.0f64, f64::max);
1037    let floor = 1e-9 * reach;
1038    // The side of line `ab` that `c` stands on: 1, -1, or 0 within `floor`.
1039    let side = |a: Point2, b: Point2, c: Point2| -> i8 {
1040        let ab = b - a;
1041        let o = ab.cross(c - a);
1042        if o.abs() <= floor * ab.magnitude() {
1043            0
1044        } else if o > 0.0 {
1045            1
1046        } else {
1047            -1
1048        }
1049    };
1050    for i in 0..n {
1051        let (a, b) = (polygon[i], polygon[(i + 1) % n]);
1052        for j in (i + 2)..n {
1053            if i == 0 && j == n - 1 {
1054                continue;
1055            }
1056            let (c, d) = (polygon[j], polygon[(j + 1) % n]);
1057            if side(a, b, c) * side(a, b, d) < 0 && side(c, d, a) * side(c, d, b) < 0 {
1058                return true;
1059            }
1060        }
1061    }
1062    false
1063}
1064
1065/// Whether a point lies inside a closed polygon, by its winding number.
1066fn inside(polygon: &[Point2], q: Point2) -> bool {
1067    let n = polygon.len();
1068    let mut winding = 0i32;
1069    for i in 0..n {
1070        let (a, b) = (polygon[i], polygon[(i + 1) % n]);
1071        let side = (b - a).cross(q - a);
1072        if a.y <= q.y {
1073            if b.y > q.y && side > 0.0 {
1074                winding += 1;
1075            }
1076        } else if b.y <= q.y && side < 0.0 {
1077            winding -= 1;
1078        }
1079    }
1080    winding != 0
1081}
1082
1083/// The points the constraints put inside the hole, each with its share of
1084/// the fit's weight.
1085fn constraint_points(
1086    model: &Model,
1087    constraints: &[Shape],
1088    tol: Tolerances,
1089) -> OgeomResult<Vec<(Point, f64)>> {
1090    let mut out = Vec::new();
1091    for (k, shape) in constraints.iter().enumerate() {
1092        let placement = shape.transform(model.datums())?;
1093        match model.kind_of(shape)? {
1094            ShapeType::Vertex => {
1095                let Some(data) = model.node(shape).and_then(|n| n.data().as_vertex()) else {
1096                    ogeom_bail!(Construction, "constraint {k} holds no vertex data");
1097                };
1098                out.push((placement.apply(data.point), POINT_SHARE));
1099            }
1100            ShapeType::Edge => {
1101                let Some(data) = model.node(shape).and_then(|n| n.data().as_edge()) else {
1102                    ogeom_bail!(Construction, "constraint {k} holds no edge data");
1103                };
1104                let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1105                    ogeom_bail!(Construction, "constraint {k} has no 3D curve");
1106                };
1107                let Some(curve) = model.geometry().curve(*curve) else {
1108                    ogeom_bail!(Dangling, "constraint {k}'s curve is not in this model");
1109                };
1110                for i in 0..=PER_CURVE {
1111                    #[expect(
1112                        clippy::cast_precision_loss,
1113                        reason = "a sample index, far below the mantissa"
1114                    )]
1115                    let f = i as f64 / PER_CURVE as f64;
1116                    let t = (range.1 - range.0).mul_add(f, range.0);
1117                    out.push((placement.apply(curve.point_at(t, tol)?), POINT_SHARE / 4.0));
1118                }
1119            }
1120            other => ogeom_bail!(
1121                Construction,
1122                "constraint {k} is a {other:?}; a filling passes through vertices and edges"
1123            ),
1124        }
1125    }
1126    Ok(out)
1127}
1128
1129/// The basis curve of a forward trim, whose parameter is the trim's own.
1130fn untrimmed(curve: &Curve) -> &Curve {
1131    match curve {
1132        Curve::Trimmed(t) if !t.is_reversed() => untrimmed(t.basis()),
1133        other => other,
1134    }
1135}
1136
1137/// The side's curve seen in the plane, at the curve's own parameter: exact
1138/// where the curve is a line, a circle or ellipse, or a B-spline (an affine
1139/// image of each is a curve of the same form), checked against the curve
1140/// before it is trusted; fitted at the curve's parameters otherwise.
1141fn trace(
1142    side: &Side,
1143    frame: &PlaneFrame,
1144    tolerance: f64,
1145    tol: Tolerances,
1146) -> OgeomResult<PlanarCurve> {
1147    let truth = |t: f64| -> OgeomResult<Point2> { Ok(frame.chart(side.curve.point_at(t, tol)?)) };
1148    let (t0, t1) = side.range;
1149    let exact: Option<PlanarCurve> = match untrimmed(&side.curve) {
1150        Curve::BSpline(spline) => {
1151            let mut control = Vec::with_capacity(spline.control_points().len());
1152            for w in spline.control_points() {
1153                control.push(Weighted::new(frame.chart(w.point()), w.weight, tol)?);
1154            }
1155            BSpline2d::rational(spline.knots().clone(), control)
1156                .ok()
1157                .map(PlanarCurve::BSpline)
1158        }
1159        _ => match side.curve.kind() {
1160            CurveKind::Line => {
1161                let (q0, q1) = (truth(t0)?, truth(t1)?);
1162                let d = (q1 - q0) * (1.0 / (t1 - t0));
1163                let c = q0 - d * t0;
1164                Trig2d::new(c, d, Vector2::ZERO, Vector2::ZERO, side.range)
1165                    .ok()
1166                    .map(PlanarCurve::Trig)
1167            }
1168            CurveKind::Circle | CurveKind::Ellipse => {
1169                // Thirds rather than ends and middle: a full circle's ends
1170                // are one point.
1171                let ts = [
1172                    t0,
1173                    (t1 - t0).mul_add(1.0 / 3.0, t0),
1174                    (t1 - t0).mul_add(2.0 / 3.0, t0),
1175                ];
1176                let rows = ts.map(|t| [1.0, t.cos(), t.sin()]);
1177                let values = [truth(ts[0])?, truth(ts[1])?, truth(ts[2])?];
1178                match (
1179                    solve3(rows, values.map(|q| q.x)),
1180                    solve3(rows, values.map(|q| q.y)),
1181                ) {
1182                    (Some(x), Some(y)) => Trig2d::new(
1183                        Point2::new(x[0], y[0]),
1184                        Vector2::ZERO,
1185                        Vector2::new(x[1], y[1]),
1186                        Vector2::new(x[2], y[2]),
1187                        side.range,
1188                    )
1189                    .ok()
1190                    .map(PlanarCurve::Trig),
1191                    _ => None,
1192                }
1193            }
1194            _ => None,
1195        },
1196    };
1197    if let Some(curve) = exact {
1198        let mut worst = 0.0f64;
1199        let mut scale = 1.0f64;
1200        for k in 0..=32 {
1201            let t = (t1 - t0).mul_add(f64::from(k) / 32.0, t0);
1202            let q = truth(t)?;
1203            scale = scale.max(q.to_vector().magnitude());
1204            worst = worst.max(curve.point_at(t, tol)?.distance(q));
1205        }
1206        if worst <= 1e-12 * scale + 1e-3 * tol.confusion() {
1207            return Ok(curve);
1208        }
1209    }
1210    const SAMPLES: usize = 96;
1211    let mut parameters = Vec::with_capacity(SAMPLES + 1);
1212    let mut points = Vec::with_capacity(SAMPLES + 1);
1213    for k in 0..=SAMPLES {
1214        #[expect(
1215            clippy::cast_precision_loss,
1216            reason = "a sample index, far below the mantissa"
1217        )]
1218        let t = (t1 - t0).mul_add(k as f64 / SAMPLES as f64, t0);
1219        parameters.push(t);
1220        points.push(truth(t)?);
1221    }
1222    let fitted = ogeom_geom::fit::fit_points_2d_at(&parameters, &points, 3, tolerance * 1e-2, tol)?;
1223    Ok(PlanarCurve::BSpline(fitted.curve))
1224}
1225
1226/// Solve a 3x3 system by Cramer's rule; `None` where it is singular.
1227fn solve3(rows: [[f64; 3]; 3], rhs: [f64; 3]) -> Option<[f64; 3]> {
1228    let det = |m: [[f64; 3]; 3]| {
1229        m[0][0] * m[1][1].mul_add(m[2][2], -m[1][2] * m[2][1])
1230            - m[0][1] * m[1][0].mul_add(m[2][2], -m[1][2] * m[2][0])
1231            + m[0][2] * m[1][0].mul_add(m[2][1], -m[1][1] * m[2][0])
1232    };
1233    let d = det(rows);
1234    if d.abs() < 1e-12 {
1235        return None;
1236    }
1237    let mut out = [0.0; 3];
1238    for (c, slot) in out.iter_mut().enumerate() {
1239        let mut m = rows;
1240        for r in 0..3 {
1241            m[r][c] = rhs[r];
1242        }
1243        *slot = det(m) / d;
1244    }
1245    Some(out)
1246}
1247
1248/// The bending energy's weight against the conditions.
1249///
1250/// The fit minimises `Q + λ·E`. `Q` sums the conditions' squared
1251/// residuals, each free of units (a position over the patch size `size`, a
1252/// slope as it is, a curvature times `size`) and weighted by its side's
1253/// share of the boundary. `E` is the thin-plate energy, also free of units:
1254/// about the square of the angle, in radians, the patch turns through.
1255/// Scaling the whole problem changes neither, so `λ` means the same at any
1256/// size and on any net; only the tolerance sets it.
1257///
1258/// For any surface `s` the net can carry, `Q(fit) + λ·E(fit) <= Q(s) +
1259/// λ·E(s)`: the energy costs the conditions at most `λ·E(s)`. With `ε` the
1260/// tolerance in the residuals' own units (the strictest of `tolerance /
1261/// size` for positions, `tolerance` for G1 slopes and `tolerance · size`
1262/// for G2 curvatures), `λ = (BUDGET·ε)²` holds that cost to a tenth of the
1263/// tolerance, root mean square, per radian of turning, and gives the energy
1264/// the largest weight the bound allows, so the energy rather than the
1265/// conditions' approximation error settles every control the conditions
1266/// reach only weakly.
1267///
1268/// The same bound says when a net is too coarse. Where the conditions'
1269/// residual `Q` exceeds `λ`, the fit would bend the interior by up to a
1270/// unit of energy to save that residual, so the hole's middle follows the
1271/// boundary's approximation error (on a coarse net every control reaches
1272/// the boundary, and the middle dips or bulges). Such a net is refined
1273/// even when its sides meet the tolerance; where no net gets clear of it,
1274/// the finest one that met the tolerance is taken.
1275fn smoothing_for(sides: &[Side], tolerance: f64, size: f64) -> f64 {
1276    let order = sides.iter().map(|s| s.order).max().unwrap_or(0);
1277    let mut eps = tolerance / size;
1278    if order >= 1 {
1279        eps = eps.min(tolerance);
1280    }
1281    if order >= 2 {
1282        eps = eps.min(tolerance * size);
1283    }
1284    (BUDGET * eps).powi(2).max(LEAST_SMOOTHING)
1285}
1286
1287/// A side's length, by chords.
1288fn side_length(side: &Side, tol: Tolerances) -> OgeomResult<f64> {
1289    let mut length = 0.0;
1290    let mut previous = side.curve.point_at(side.range.0, tol)?;
1291    for k in 1..=OUTLINE_SAMPLES {
1292        #[expect(
1293            clippy::cast_precision_loss,
1294            reason = "a sample index, far below the mantissa"
1295        )]
1296        let f = k as f64 / OUTLINE_SAMPLES as f64;
1297        let p = side.curve.point_at(side.parameter(f), tol)?;
1298        length += p.distance(previous);
1299        previous = p;
1300    }
1301    Ok(length)
1302}
1303
1304/// The fit's conditions along one side: positions always, tangent planes
1305/// from G1 on, normal curvatures at G2. Every row carries the side's share
1306/// of the boundary per sample (`share`, its length against the hole's
1307/// `size`) and is scaled to a residual free of units.
1308fn side_conditions(
1309    side: &Side,
1310    share: f64,
1311    size: f64,
1312    samples: usize,
1313    frame: &PlaneFrame,
1314    out: &mut Vec<Condition<1>>,
1315    tol: Tolerances,
1316) -> OgeomResult<()> {
1317    #[expect(
1318        clippy::cast_precision_loss,
1319        reason = "a sample count, far below the mantissa"
1320    )]
1321    let root = (share / samples as f64).max(f64::MIN_POSITIVE).sqrt();
1322    for k in 0..=samples {
1323        #[expect(
1324            clippy::cast_precision_loss,
1325            reason = "a sample index, far below the mantissa"
1326        )]
1327        let t = side.parameter(k as f64 / samples as f64);
1328        let p = side.curve.point_at(t, tol)?;
1329        let at = frame.chart(p);
1330        out.push(Condition::partial(
1331            at,
1332            (0, 0),
1333            [frame.height(p)],
1334            root / size,
1335        ));
1336        if side.order == 0 {
1337            continue;
1338        }
1339        let (Some((uv, normal)), Some(support)) = (side.support_at(t, tol)?, &side.support) else {
1340            continue;
1341        };
1342        let lift = normal.dot(frame.n);
1343        if lift.abs() < MIN_LIFT {
1344            ogeom_bail!(
1345                Construction,
1346                "side {}'s support stands at {:.1} degrees to the plane the \
1347                 boundary spans, past the {:.1} a height over that plane takes",
1348                side.entry,
1349                lift.abs().acos().to_degrees(),
1350                MIN_LIFT.acos().to_degrees()
1351            );
1352        }
1353        // The support's normal on the plane's side, and the slopes that
1354        // put the patch's tangent plane on it.
1355        let normal = if lift < 0.0 { normal * -1.0 } else { normal };
1356        let lift = lift.abs();
1357        let (hu, hv) = (-normal.dot(frame.e1) / lift, -normal.dot(frame.e2) / lift);
1358        out.push(Condition::partial(at, (1, 0), [hu], root));
1359        out.push(Condition::partial(at, (0, 1), [hv], root));
1360        if side.order < 2 {
1361            continue;
1362        }
1363        // With the tangent plane fixed, the patch's second fundamental form
1364        // is `n · S_ab = h_ab (normal · n)`: the support's, read off its
1365        // principal curvatures, fixes the three second derivatives.
1366        let curvature = support.surface.curvature_at(uv.x, uv.y, tol)?;
1367        let sense = if curvature.normal.dot_vector(normal) < 0.0 {
1368            -1.0
1369        } else {
1370            1.0
1371        };
1372        let (dmax, dmin) = (
1373            curvature.max_direction.vector(),
1374            curvature.min_direction.vector(),
1375        );
1376        let second = |a: Vector, b: Vector| {
1377            sense
1378                * curvature.max.mul_add(
1379                    a.dot(dmax) * b.dot(dmax),
1380                    curvature.min * a.dot(dmin) * b.dot(dmin),
1381                )
1382                / lift
1383        };
1384        let su = frame.e1 + frame.n * hu;
1385        let sv = frame.e2 + frame.n * hv;
1386        for (order, target) in [
1387            ((2, 0), second(su, su)),
1388            ((1, 1), second(su, sv)),
1389            ((0, 2), second(sv, sv)),
1390        ] {
1391            out.push(Condition::partial(at, order, [target], root * size));
1392        }
1393    }
1394    Ok(())
1395}
1396
1397/// The free patch's conditions along one side: the side's points, at its
1398/// pcurve on the chart drawn from the loop, weighted as in
1399/// [`side_conditions`].
1400fn free_conditions(
1401    side: &Side,
1402    pcurve: &PlanarCurve,
1403    share: f64,
1404    size: f64,
1405    samples: usize,
1406    out: &mut Vec<Condition<3>>,
1407    tol: Tolerances,
1408) -> OgeomResult<()> {
1409    #[expect(
1410        clippy::cast_precision_loss,
1411        reason = "a sample count, far below the mantissa"
1412    )]
1413    let root = (share / samples as f64).max(f64::MIN_POSITIVE).sqrt();
1414    for k in 0..=samples {
1415        #[expect(
1416            clippy::cast_precision_loss,
1417            reason = "a sample index, far below the mantissa"
1418        )]
1419        let t = side.parameter(k as f64 / samples as f64);
1420        let p = side.curve.point_at(t, tol)?;
1421        let at = pcurve.point_at(t, tol)?;
1422        out.push(Condition::partial(at, (0, 0), [p.x, p.y, p.z], root / size));
1423        if side.order == 0 {
1424            continue;
1425        }
1426        let (Some((uv, normal)), Some(support)) = (side.support_at(t, tol)?, &side.support) else {
1427            continue;
1428        };
1429        let Some(material) = support.material else {
1430            ogeom_bail!(
1431                Construction,
1432                "side {}'s support uses its edge both ways or neither, so which \
1433                 side of it the filling leaves from is not known",
1434                side.entry
1435            );
1436        };
1437        // The chart walks the loop counter-clockwise, so the hole lies on
1438        // the left of the walk and the support on its right: the ribbon
1439        // runs across the side along the chart's outward normal, into the
1440        // support, at the speed the side runs at along it.
1441        let d1 = side.curve.d1_at(t, tol)?;
1442        let c1 = pcurve.d1_at(t, tol)?;
1443        let walk = if side.reversed { -1.0 } else { 1.0 };
1444        let speed = c1.magnitude();
1445        if speed <= f64::MIN_POSITIVE {
1446            continue;
1447        }
1448        let along = c1 * (walk / speed);
1449        let outward = Vector2::new(along.y, -along.x);
1450        let into = normal.cross(d1) * material;
1451        let Ok(into) = Direction::new(into, tol) else {
1452            continue;
1453        };
1454        let into = into.vector();
1455        let ribbon = into * (d1.magnitude() / speed);
1456        out.push(Condition::along(
1457            at,
1458            outward,
1459            false,
1460            [ribbon.x, ribbon.y, ribbon.z],
1461            root,
1462        ));
1463        if side.order < 2 {
1464            continue;
1465        }
1466        // Across the side the patch bends as the support does: the second
1467        // derivative along the ribbon has the support's normal curvature
1468        // there times the ribbon's squared length along the normal, and
1469        // nothing along the surface.
1470        let curvature = support.surface.curvature_at(uv.x, uv.y, tol)?;
1471        let Some(bend) = curvature.normal_curvature(into) else {
1472            continue;
1473        };
1474        let second = curvature.normal.vector() * (bend * ribbon.dot(ribbon));
1475        out.push(Condition::along(
1476            at,
1477            outward,
1478            true,
1479            [second.x, second.y, second.z],
1480            root * size,
1481        ));
1482    }
1483    Ok(())
1484}
1485
1486/// The tangent sides' supports' least lift off the plane, at stations
1487/// spread over each: the side and the cosine between the support's normal
1488/// and the plane's. `None` with no tangent side.
1489fn steepest_support(
1490    sides: &[Side],
1491    frame: &PlaneFrame,
1492    tol: Tolerances,
1493) -> OgeomResult<Option<(usize, f64)>> {
1494    let mut steepest: Option<(usize, f64)> = None;
1495    for side in sides.iter().filter(|s| s.order > 0) {
1496        for k in 0..=STEEP_SAMPLES {
1497            #[expect(
1498                clippy::cast_precision_loss,
1499                reason = "a sample index, far below the mantissa"
1500            )]
1501            let t = side.parameter(k as f64 / STEEP_SAMPLES as f64);
1502            let Some((_, normal)) = side.support_at(t, tol)? else {
1503                continue;
1504            };
1505            let lift = normal.dot(frame.n).abs();
1506            if steepest.is_none_or(|(_, least)| lift < least) {
1507                steepest = Some((side.entry, lift));
1508            }
1509        }
1510    }
1511    Ok(steepest)
1512}
1513
1514/// Refuse a corner where two sides meet and no surface is tangent to the
1515/// supports of both: a surface through the corner holds both sides'
1516/// tangents in its tangent plane, so a tangent side's support must hold
1517/// the other side's tangent too, and stands off tangent to any filling by
1518/// at least the angle between that tangent and its own tangent plane.
1519fn tangent_corners(
1520    sides: &[Side],
1521    order: &[usize],
1522    corners: &[(Vector, Vector, f64)],
1523    tolerance: f64,
1524    tol: Tolerances,
1525) -> OgeomResult<()> {
1526    for (k, (arrive, leave, _)) in corners.iter().enumerate() {
1527        let (a, b) = (&sides[order[k]], &sides[order[(k + 1) % order.len()]]);
1528        let end = |side: &Side, last: bool| {
1529            if last == side.reversed {
1530                side.range.0
1531            } else {
1532                side.range.1
1533            }
1534        };
1535        for (side, at, other, tangent) in
1536            [(a, end(a, true), b, *leave), (b, end(b, false), a, *arrive)]
1537        {
1538            if side.order == 0 {
1539                continue;
1540            }
1541            let Some((_, normal)) = side.support_at(at, tol)? else {
1542                continue;
1543            };
1544            let off = normal.dot(tangent).abs().min(1.0).asin();
1545            if off > tolerance {
1546                ogeom_bail!(
1547                    Construction,
1548                    "sides {} and {} meet at a corner no surface is tangent to \
1549                     both supports at: side {}'s support stands {:.1} degrees off \
1550                     side {}'s tangent there, past the tolerance of {tolerance} \
1551                     radians",
1552                    side.entry,
1553                    other.entry,
1554                    side.entry,
1555                    off.to_degrees(),
1556                    other.entry,
1557                );
1558            }
1559        }
1560    }
1561    Ok(())
1562}
1563
1564/// Points round the loop on the chart, in its walking order.
1565fn traced_outline(
1566    sides: &[Side],
1567    order: &[usize],
1568    traces: &[PlanarCurve],
1569    tol: Tolerances,
1570) -> OgeomResult<Vec<Point2>> {
1571    let mut out = Vec::with_capacity(order.len() * OUTLINE_SAMPLES);
1572    for &i in order {
1573        let side = &sides[i];
1574        for k in 0..OUTLINE_SAMPLES {
1575            #[expect(
1576                clippy::cast_precision_loss,
1577                reason = "a sample index, far below the mantissa"
1578            )]
1579            let mut f = k as f64 / OUTLINE_SAMPLES as f64;
1580            if side.reversed {
1581                f = 1.0 - f;
1582            }
1583            out.push(traces[i].point_at(side.parameter(f), tol)?);
1584        }
1585    }
1586    Ok(out)
1587}
1588
1589/// Whether a free patch folds over inside the hole: on a grid over the
1590/// chart, inside its outline, the normal vanishes against the largest, or
1591/// turns over between neighbouring samples.
1592fn folds(
1593    surface: &BSplineSurface,
1594    outline: &[Point2],
1595    domain: ((f64, f64), (f64, f64)),
1596    tol: Tolerances,
1597) -> OgeomResult<bool> {
1598    const GRID: usize = 40;
1599    let ((ua, ub), (va, vb)) = domain;
1600    let mut normals = vec![None; (GRID + 1) * (GRID + 1)];
1601    let mut largest = 0.0f64;
1602    for i in 0..=GRID {
1603        for j in 0..=GRID {
1604            #[expect(
1605                clippy::cast_precision_loss,
1606                reason = "a grid index, far below the mantissa"
1607            )]
1608            let q = Point2::new(
1609                (ub - ua).mul_add(i as f64 / GRID as f64, ua),
1610                (vb - va).mul_add(j as f64 / GRID as f64, va),
1611            );
1612            if !inside(outline, q) {
1613                continue;
1614            }
1615            let (du, dv) = surface.d1_at(q.x, q.y, tol)?;
1616            let normal = du.cross(dv);
1617            largest = largest.max(normal.magnitude());
1618            normals[i * (GRID + 1) + j] = Some(normal);
1619        }
1620    }
1621    for i in 0..=GRID {
1622        for j in 0..=GRID {
1623            let Some(here) = normals[i * (GRID + 1) + j] else {
1624                continue;
1625            };
1626            if here.magnitude() <= 1e-6 * largest {
1627                return Ok(true);
1628            }
1629            let right = if i < GRID {
1630                normals[(i + 1) * (GRID + 1) + j]
1631            } else {
1632                None
1633            };
1634            let up = if j < GRID {
1635                normals[i * (GRID + 1) + j + 1]
1636            } else {
1637                None
1638            };
1639            if [right, up]
1640                .into_iter()
1641                .flatten()
1642                .any(|n| n.dot(here) <= 0.0)
1643            {
1644                return Ok(true);
1645            }
1646        }
1647    }
1648    Ok(false)
1649}
1650
1651/// Measure one side at `stations` spread over its edge: the gap between
1652/// the edge's curve and the surface through the pcurve, and against the
1653/// support the angle between normals and the difference of normal
1654/// curvatures square to the edge.
1655fn measure_side(
1656    side: &Side,
1657    pcurve: &PlanarCurve,
1658    surface: &BSplineSurface,
1659    stations: usize,
1660    tol: Tolerances,
1661) -> OgeomResult<FillSide> {
1662    let (mut gap, mut angle, mut curvature) = (0.0f64, 0.0f64, None::<f64>);
1663    for k in 0..stations {
1664        #[expect(
1665            clippy::cast_precision_loss,
1666            reason = "a station index, far below the mantissa"
1667        )]
1668        let t = side.parameter(k as f64 / (stations - 1) as f64);
1669        let p = side.curve.point_at(t, tol)?;
1670        let uv = pcurve.point_at(t, tol)?;
1671        gap = gap.max(surface.point_at(uv.x, uv.y, tol)?.distance(p));
1672        let (Some((suv, theirs)), Some(support)) = (side.support_at(t, tol)?, &side.support) else {
1673            continue;
1674        };
1675        let ours = surface.normal_at(uv.x, uv.y, tol)?.vector();
1676        angle = angle.max(ours.cross(theirs).magnitude().atan2(ours.dot(theirs).abs()));
1677        let across = ours.cross(side.curve.d1_at(t, tol)?);
1678        let signed = |c: SurfaceCurvature| -> Option<f64> {
1679            let k = c.normal_curvature(across)?;
1680            Some(if c.normal.dot_vector(ours) < 0.0 {
1681                -k
1682            } else {
1683                k
1684            })
1685        };
1686        let a = surface.curvature_at(uv.x, uv.y, tol).ok().and_then(signed);
1687        let b = support
1688            .surface
1689            .curvature_at(suv.x, suv.y, tol)
1690            .ok()
1691            .and_then(signed);
1692        if let (Some(a), Some(b)) = (a, b) {
1693            curvature = Some(curvature.unwrap_or(0.0).max((a - b).abs()));
1694        }
1695    }
1696    let supported = side.support.is_some();
1697    Ok(FillSide {
1698        edge: side.edge.clone(),
1699        gap,
1700        angle: supported.then_some(angle),
1701        curvature: curvature.filter(|_| supported),
1702        stations,
1703    })
1704}
1705
1706/// The first deviation past `tolerance`, described, or `None` when every
1707/// side and constraint meets it.
1708fn first_miss(
1709    sides: &[Side],
1710    reports: &[FillSide],
1711    constraint_gap: f64,
1712    tolerance: f64,
1713) -> Option<String> {
1714    for (side, report) in sides.iter().zip(reports) {
1715        let i = side.entry;
1716        if report.gap > tolerance {
1717            return Some(format!("side {i} stands {} off its edge", report.gap));
1718        }
1719        if side.order >= 1 {
1720            let angle = report.angle.unwrap_or(f64::INFINITY);
1721            if angle > tolerance {
1722                return Some(format!(
1723                    "side {i} meets its support {angle} radians from tangent"
1724                ));
1725            }
1726        }
1727        if side.order >= 2 {
1728            match report.curvature {
1729                Some(c) if c <= tolerance => {}
1730                Some(c) => {
1731                    return Some(format!(
1732                        "side {i}'s normal curvature differs from its support's by {c}"
1733                    ));
1734                }
1735                None => {
1736                    return Some(format!(
1737                        "side {i}'s curvature could not be read on both surfaces"
1738                    ));
1739                }
1740            }
1741        }
1742    }
1743    (constraint_gap > tolerance)
1744        .then(|| format!("a constraint stands {constraint_gap} off the surface"))
1745}
1746
1747/// Build the face on the fitted patch, bounded by the sides' own edges or
1748/// their stand-ins, each given its pcurve on it and a tolerance that holds
1749/// its gap.
1750fn build(
1751    model: &mut Model,
1752    sides: &mut [Side],
1753    order: &[usize],
1754    traces: &[PlanarCurve],
1755    reports: &[FillSide],
1756    surface: BSplineSurface,
1757    tol: Tolerances,
1758) -> OgeomResult<Shape> {
1759    stand_in(model, sides, order, tol)?;
1760    let walk: Vec<Shape> = order
1761        .iter()
1762        .map(|&i| {
1763            let side = &sides[i];
1764            if side.reversed {
1765                side.edge.reversed()
1766            } else {
1767                side.edge.clone()
1768            }
1769        })
1770        .collect();
1771    let wire = make_wire(model, &walk, tol)?.shape;
1772    let face = make_face(model, SurfaceGeometry::BSpline(surface), &[wire], tol)?.shape;
1773    let Some(NodeData::Face(data)) = model.node(&face).map(|n| n.data()) else {
1774        ogeom_bail!(Dangling, "the face just built is not in this model");
1775    };
1776    let surface_id = data.surface;
1777    for ((side, pcurve), report) in sides.iter().zip(traces).zip(reports) {
1778        attach_pcurve(
1779            model,
1780            &side.edge,
1781            pcurve.clone(),
1782            surface_id,
1783            Location::identity(),
1784            side.range,
1785        )?;
1786        if report.gap > side.edge_tolerance {
1787            // The edge owns the gap, and so do the vertices bounding it.
1788            let widened = Tolerance::new(report.gap + tol.confusion())?;
1789            model.widen(&side.edge, widened)?;
1790            if let Some((a, b)) = edge_vertices(model, &side.edge)? {
1791                model.widen(&a, widened)?;
1792                model.widen(&b, widened)?;
1793            }
1794        }
1795    }
1796    Ok(face)
1797}
1798
1799/// Give each side the edge the face is bounded by: its own where it is not
1800/// placed and both its corners are vertex nodes it shares with its
1801/// neighbours, otherwise a new edge on its curve where it stands, between
1802/// the loop's corner vertices. A corner keeps the shared vertex where there
1803/// is one, unplaced; elsewhere it is a new vertex midway between the two
1804/// ends, reaching both.
1805fn stand_in(
1806    model: &mut Model,
1807    sides: &mut [Side],
1808    order: &[usize],
1809    tol: Tolerances,
1810) -> OgeomResult<()> {
1811    let n = order.len();
1812    // Corner k is where side `order[k]` ends its walk and `order[k + 1]`
1813    // starts its.
1814    let mut corners: Vec<(Shape, bool)> = Vec::with_capacity(n);
1815    for k in 0..n {
1816        let (a, b) = (&sides[order[k]], &sides[order[(k + 1) % n]]);
1817        let (a0, a1) = ends_of(model, a)?;
1818        let (b0, b1) = ends_of(model, b)?;
1819        let (arrive, leave) = (
1820            if a.reversed { a0 } else { a1 },
1821            if b.reversed { b1 } else { b0 },
1822        );
1823        let (from, to) = (
1824            a.curve
1825                .point_at(if a.reversed { a.range.0 } else { a.range.1 }, tol)?,
1826            b.curve
1827                .point_at(if b.reversed { b.range.1 } else { b.range.0 }, tol)?,
1828        );
1829        if arrive.vertex.is_same(&leave.vertex) && arrive.vertex.location().is_identity() {
1830            corners.push((arrive.vertex, true));
1831            continue;
1832        }
1833        let vertex = make_vertex(model, from.midpoint(to)).shape;
1834        let reach = (0.5 * from.distance(to) + tol.confusion())
1835            .max(a.edge_tolerance)
1836            .max(b.edge_tolerance);
1837        model.widen(&vertex, Tolerance::new(reach)?)?;
1838        corners.push((vertex, false));
1839    }
1840    for k in 0..n {
1841        let side = &sides[order[k]];
1842        let (start, end) = (&corners[(k + n - 1) % n], &corners[k]);
1843        if !side.placed && start.1 && end.1 {
1844            continue;
1845        }
1846        let (from, to) = if side.reversed {
1847            (&end.0, &start.0)
1848        } else {
1849            (&start.0, &end.0)
1850        };
1851        let edge = make_edge_between(model, side.curve.clone(), side.range, from, to, tol)?.shape;
1852        model.widen(&edge, Tolerance::new(side.edge_tolerance)?)?;
1853        sides[order[k]].edge = edge;
1854    }
1855    Ok(())
1856}