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::{Built, History, attach_pcurve, edge_vertices, make_face, make_wire};
5use ogeom_core::{OgeomResult, Tolerance, Tolerances, ogeom_bail};
6use ogeom_geom::{
7    BSpline2d, BSplineSurface, Continuity, Curve, Curve2d as _, Curve3d as _, CurveKind,
8    PlanarCurve, Surface as _, SurfaceCurvature, SurfaceGeometry, Trig2d,
9};
10use ogeom_math::{Direction, Point, Point2, Vector, Vector2, Weighted};
11use ogeom_topo::{
12    EdgeRepr, Filter, Location, Model, NodeData, Orientation, Shape, ShapeType, explore,
13};
14
15use crate::fill_patch::{Condition, DEGREE, PlaneFrame, fit_height};
16
17/// One side of an N-sided filling.
18#[derive(Debug, Clone)]
19pub struct FillBoundary {
20    /// The boundary edge. The filling's face is bounded by this edge node
21    /// itself, not by a copy of it.
22    pub edge: Shape,
23    /// The face the edge belongs to, which the filling meets across it.
24    /// Required for [`Continuity::G1`] and [`Continuity::G2`]; on a
25    /// [`Continuity::C0`] side it is measured against and helps decide which
26    /// way the filling faces.
27    pub support: Option<Shape>,
28    /// How the filling meets `support`: [`Continuity::C0`] (position),
29    /// [`Continuity::G1`] (tangent plane) or [`Continuity::G2`] (tangent
30    /// plane and normal curvature).
31    pub continuity: Continuity,
32}
33
34/// What a filling achieved along one side, measured at stations spread
35/// over the edge.
36#[derive(Debug, Clone, PartialEq)]
37pub struct FillSide {
38    /// The side's edge.
39    pub edge: Shape,
40    /// The largest distance, in model units, between the edge's curve and
41    /// the filling's surface read through the edge's pcurve on it.
42    pub gap: f64,
43    /// The largest angle, in radians, between the filling's normal and the
44    /// support's; `None` on a side with no support.
45    pub angle: Option<f64>,
46    /// The largest difference, in inverse model units, between the
47    /// filling's and the support's normal curvatures square to the edge,
48    /// signed against one shared normal; `None` on a side with no support
49    /// or where no station gave a curvature on both surfaces.
50    pub curvature: Option<f64>,
51    /// How many stations were measured.
52    pub stations: usize,
53}
54
55/// A filling and what it achieved.
56#[derive(Debug, Clone)]
57pub struct Filled {
58    /// The face; every boundary edge and constraint generates it.
59    pub built: Built,
60    /// One report per side, in the order the sides were given.
61    pub sides: Vec<FillSide>,
62    /// The largest distance, along the filling plane's normal, from a
63    /// constraint point or a sampled point of a constraint curve to the
64    /// surface; zero with no constraints.
65    pub constraint_gap: f64,
66}
67
68/// Samples per side for the loop's outline.
69const OUTLINE_SAMPLES: usize = 64;
70/// The margin round the hole, as a share of its larger extent.
71const MARGIN: f64 = 0.05;
72/// The control counts across the larger extent, round by round.
73const NETS: [usize; 6] = [8, 12, 18, 27, 40, 60];
74/// The bending energy's weight against the conditions: small enough that
75/// the conditions win wherever they reach, leaving the energy to settle
76/// the controls they do not (the hole's interior, the margin round it).
77const SMOOTHING: f64 = 1e-12;
78/// The least cosine between a support's normal and the plane's normal:
79/// about 84 degrees.
80const MIN_LIFT: f64 = 0.1;
81/// A constraint point's weight, as the share of the hole's size a boundary
82/// sample of that length would carry.
83const POINT_SHARE: f64 = 0.05;
84/// Samples per constraint curve.
85const PER_CURVE: usize = 32;
86
87/// Fill the hole a loop of edges bounds with one face meeting each side's
88/// support at the continuity asked.
89///
90/// The sides may come in any order and either direction; they must chain
91/// into one simple closed loop. `constraints` are vertices and edges inside
92/// the hole that the surface passes through. The surface is a cubic
93/// B-spline height patch over the plane the loop spans, fitted by least
94/// squares to the sides' positions, to the tangent planes of G1 and G2
95/// sides' supports and to the normal curvatures of G2 sides' supports, with
96/// a thin-plate bending energy settling the rest; the control net is
97/// refined until every side meets `tolerance` or the refinement runs out.
98/// The face is trimmed by the given edges themselves, each given a pcurve
99/// on the patch, and faces the way the supports say: across each edge it
100/// runs opposite to its support's use of the edge, so sewing it to the
101/// supports gives a consistently oriented shell. With no supports it faces
102/// the side the loop turns counter-clockwise about, walked from the first
103/// side in that edge's own direction.
104///
105/// `tolerance` is the target for every measured deviation: a distance in
106/// model units for each side's gap and each constraint, an angle in radians
107/// for a G1 or G2 side's tangency, and a curvature difference in inverse
108/// model units for a G2 side. An edge the surface stands further from than
109/// the edge's own tolerance has that tolerance, and its vertices', widened
110/// to the measured gap.
111///
112/// # Errors
113///
114/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction), by
115/// name, where:
116///
117/// - `tolerance` is not positive and finite, or there are no sides;
118/// - a side is not an edge, has no 3D curve or no vertices, or is placed;
119/// - the same edge is given twice;
120/// - a side asks [`Continuity::C1`], [`Continuity::C2`] or
121///   [`Continuity::CInfinity`] (parametric continuity between two
122///   surfaces' charts), or G1 or G2 with no support;
123/// - a support is not a face, is placed, does not hold its edge, or the
124///   edge does not lie on its surface within `tolerance`;
125/// - the sides do not chain into one closed loop;
126/// - the loop encloses no area, or crosses itself seen along the normal of
127///   the plane it spans, so the hole is not a height field over that plane;
128/// - a G1 or G2 side's support stands within about 6 degrees of square to
129///   that plane;
130/// - a constraint is neither a vertex nor an edge, or lies outside the hole
131///   seen along the plane's normal.
132///
133/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the finest
134/// control net still misses `tolerance`, naming the side and the deviation.
135pub fn make_filling_n(
136    model: &mut Model,
137    boundary: &[FillBoundary],
138    constraints: &[Shape],
139    tolerance: f64,
140    tol: Tolerances,
141) -> OgeomResult<Filled> {
142    if !(tolerance.is_finite() && tolerance > 0.0) {
143        ogeom_bail!(
144            Construction,
145            "a filling's tolerance is positive and finite; got {tolerance}"
146        );
147    }
148    if boundary.is_empty() {
149        ogeom_bail!(Construction, "a filling needs at least one boundary edge");
150    }
151    let mut sides = Vec::with_capacity(boundary.len());
152    for (i, entry) in boundary.iter().enumerate() {
153        sides.push(read_side(model, i, entry, tolerance, tol)?);
154    }
155    for i in 0..sides.len() {
156        for j in (i + 1)..sides.len() {
157            if sides[i].edge.node() == sides[j].edge.node() {
158                ogeom_bail!(Construction, "side {j} is side {i}'s edge again");
159            }
160        }
161    }
162    let mut order = chain(model, &mut sides, tol)?;
163    face_the_supports(model, &mut sides, &mut order)?;
164
165    // The plane the loop spans, and the loop seen along its normal.
166    let outline = loop_points(&sides, &order, tol)?;
167    let frame = frame_of(&outline, tol)?;
168    let chart_outline: Vec<Point2> = outline.iter().map(|p| frame.chart(*p)).collect();
169    if crosses_itself(&chart_outline) {
170        ogeom_bail!(
171            Construction,
172            "the boundary loop crosses itself seen along the normal of the \
173             plane it spans; the hole is not a height field over that plane"
174        );
175    }
176    let interior = constraint_points(model, constraints, tol)?;
177    for (k, (p, _)) in interior.iter().enumerate() {
178        if !inside(&chart_outline, frame.chart(*p)) {
179            ogeom_bail!(
180                Construction,
181                "a point of constraint {k} at {p:?} lies outside the hole seen \
182                 along the normal of the plane the boundary spans"
183            );
184        }
185    }
186
187    // The rectangle the patch covers: the hole and a margin round it.
188    let (mut lo, mut hi) = (
189        Point2::new(f64::INFINITY, f64::INFINITY),
190        Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
191    );
192    for q in &chart_outline {
193        lo = Point2::new(lo.x.min(q.x), lo.y.min(q.y));
194        hi = Point2::new(hi.x.max(q.x), hi.y.max(q.y));
195    }
196    let margin = MARGIN * (hi.x - lo.x).max(hi.y - lo.y);
197    let domain = (
198        (lo.x - margin, hi.x + margin),
199        (lo.y - margin, hi.y + margin),
200    );
201    let (du, dv) = (domain.0.1 - domain.0.0, domain.1.1 - domain.1.0);
202    let size = du.max(dv);
203
204    let mut traces = Vec::with_capacity(sides.len());
205    let mut lengths = Vec::with_capacity(sides.len());
206    for side in &sides {
207        traces.push(trace(side, &frame, tolerance, tol)?);
208        lengths.push(side_length(side, tol)?);
209    }
210
211    let mut last_miss = String::new();
212    for base in NETS {
213        #[expect(
214            clippy::cast_possible_truncation,
215            clippy::cast_sign_loss,
216            clippy::cast_precision_loss,
217            reason = "a control count of a few dozen, from a positive ratio"
218        )]
219        let count = |extent: f64| -> usize {
220            ((base as f64 * extent / size).round() as usize).max(DEGREE + 2)
221        };
222        let controls = (count(du), count(dv));
223        let samples = 4 * controls.0.max(controls.1) + 8;
224
225        let mut conditions = Vec::new();
226        for (side, length) in sides.iter().zip(&lengths) {
227            side_conditions(
228                side,
229                *length / size,
230                size,
231                samples,
232                &frame,
233                &mut conditions,
234                tol,
235            )?;
236        }
237        for (p, share) in &interior {
238            conditions.push(Condition {
239                at: frame.chart(*p),
240                order: (0, 0),
241                target: frame.height(*p),
242                weight: share.sqrt() / size,
243            });
244        }
245        let surface = fit_height(&frame, domain, controls, &conditions, SMOOTHING, tol)?;
246
247        let mut reports = Vec::with_capacity(sides.len());
248        for (side, pcurve) in sides.iter().zip(&traces) {
249            reports.push(measure_side(side, pcurve, &surface, 3 * samples + 1, tol)?);
250        }
251        let mut constraint_gap = 0.0f64;
252        for (p, _) in &interior {
253            let q = frame.chart(*p);
254            constraint_gap = constraint_gap.max(surface.point_at(q.x, q.y, tol)?.distance(*p));
255        }
256
257        match first_miss(&sides, &reports, constraint_gap, tolerance) {
258            Some(miss) => {
259                last_miss = format!("at {}x{} controls, {miss}", controls.0, controls.1);
260            }
261            None => {
262                let face = build(model, &sides, &order, &traces, &reports, surface, tol)?;
263                let mut history = History::new();
264                for side in &sides {
265                    history.generate(&side.edge, face.clone());
266                }
267                for constraint in constraints {
268                    history.generate(constraint, face.clone());
269                }
270                return Ok(Filled {
271                    built: Built::new(face, history),
272                    sides: reports,
273                    constraint_gap,
274                });
275            }
276        }
277    }
278    ogeom_bail!(
279        NotDone,
280        "the filling misses its tolerance of {tolerance} on the finest net: {last_miss}"
281    )
282}
283
284/// The surface a side's support lies on and the side's pcurve there.
285struct Support {
286    face: Shape,
287    surface: SurfaceGeometry,
288    pcurve: PlanarCurve,
289    prange: (f64, f64),
290}
291
292/// One side, read and checked.
293struct Side {
294    entry: usize,
295    /// The edge node, forward.
296    edge: Shape,
297    curve: Curve,
298    range: (f64, f64),
299    edge_tolerance: f64,
300    /// 0, 1 or 2: positions, tangent planes, normal curvatures.
301    order: usize,
302    support: Option<Support>,
303    /// Whether the loop runs against the edge.
304    reversed: bool,
305}
306
307impl Side {
308    fn parameter(&self, f: f64) -> f64 {
309        (self.range.1 - self.range.0).mul_add(f, self.range.0)
310    }
311
312    /// The support's chart position and unit normal at the edge's
313    /// parameter `t`.
314    fn support_at(&self, t: f64, tol: Tolerances) -> OgeomResult<Option<(Point2, Vector)>> {
315        let Some(support) = &self.support else {
316            return Ok(None);
317        };
318        let f = (t - self.range.0) / (self.range.1 - self.range.0);
319        let pt = (support.prange.1 - support.prange.0).mul_add(f, support.prange.0);
320        let uv = support.pcurve.point_at(pt, tol)?;
321        let normal = support.surface.normal_at(uv.x, uv.y, tol)?.vector();
322        Ok(Some((uv, normal)))
323    }
324}
325
326fn read_side(
327    model: &Model,
328    i: usize,
329    entry: &FillBoundary,
330    tolerance: f64,
331    tol: Tolerances,
332) -> OgeomResult<Side> {
333    let edge = &entry.edge;
334    if model.kind_of(edge)? != ShapeType::Edge {
335        ogeom_bail!(Construction, "side {i} is not an edge");
336    }
337    if !edge.location().is_identity() {
338        ogeom_bail!(
339            Construction,
340            "side {i}'s edge is placed; bake its placement into its geometry first"
341        );
342    }
343    let order = match entry.continuity {
344        Continuity::C0 => 0,
345        Continuity::G1 => 1,
346        Continuity::G2 => 2,
347        other => ogeom_bail!(
348            Construction,
349            "side {i} asks {other:?}: parametric continuity between two \
350             surfaces' charts is not what a filling meets; ask for G1 or G2"
351        ),
352    };
353    let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
354        ogeom_bail!(Construction, "side {i}'s edge holds no edge data");
355    };
356    let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
357        ogeom_bail!(Construction, "side {i}'s edge has no 3D curve");
358    };
359    let Some(curve) = model.geometry().curve(*curve).cloned() else {
360        ogeom_bail!(Dangling, "side {i}'s curve is not in this model");
361    };
362    let range = *range;
363    let edge_tolerance = data.tolerance.get();
364    let edge = edge.oriented(Orientation::Forward);
365
366    let support = match &entry.support {
367        None if order > 0 => ogeom_bail!(
368            Construction,
369            "side {i} asks {:?} but names no support face to meet",
370            entry.continuity
371        ),
372        None => None,
373        Some(face) => Some(read_support(
374            model,
375            i,
376            face,
377            &edge,
378            &curve,
379            range,
380            tolerance.max(edge_tolerance),
381            tol,
382        )?),
383    };
384    Ok(Side {
385        entry: i,
386        edge,
387        curve,
388        range,
389        edge_tolerance,
390        order,
391        support,
392        reversed: false,
393    })
394}
395
396#[expect(
397    clippy::too_many_arguments,
398    reason = "the side's edge, curve and range are read once by the caller"
399)]
400fn read_support(
401    model: &Model,
402    i: usize,
403    face: &Shape,
404    edge: &Shape,
405    curve: &Curve,
406    range: (f64, f64),
407    reach: f64,
408    tol: Tolerances,
409) -> OgeomResult<Support> {
410    if model.kind_of(face)? != ShapeType::Face {
411        ogeom_bail!(Construction, "side {i}'s support is not a face");
412    }
413    let Some(NodeData::Face(face_data)) = model.node(face).map(|n| n.data()) else {
414        ogeom_bail!(Dangling, "side {i}'s support is not in this model");
415    };
416    if !face.location().is_identity() || !face_data.location.is_identity() {
417        ogeom_bail!(
418            Construction,
419            "side {i}'s support is placed; bake its placement into its geometry first"
420        );
421    }
422    let holds = explore(model, face, Filter::OfType(ShapeType::Edge))?
423        .iter()
424        .any(|e| e.node() == edge.node());
425    if !holds {
426        ogeom_bail!(
427            Construction,
428            "side {i}'s support face does not hold the side's edge"
429        );
430    }
431    let surface_id = face_data.surface;
432    let Some(surface) = model.geometry().surface(surface_id).cloned() else {
433        ogeom_bail!(Dangling, "side {i}'s support surface is not in this model");
434    };
435    let Some(edge_data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
436        ogeom_bail!(Construction, "side {i}'s edge holds no edge data");
437    };
438    let stored = match edge_data.pcurve_for(surface_id, edge.location()) {
439        Some(
440            EdgeRepr::PCurve { curve, range, .. }
441            | EdgeRepr::Seam {
442                forward: curve,
443                range,
444                ..
445            },
446        ) => model
447            .geometry()
448            .pcurve(*curve)
449            .cloned()
450            .map(|c| (c, *range)),
451        _ => None,
452    };
453    let (pcurve, prange) = if let Some(found) = stored {
454        found
455    } else {
456        let (fitted, _, _, _, _) =
457            ogeom_algo::pcurve_fit::fit_projected_pcurve(curve, range, &surface, tol)?;
458        (fitted, range)
459    };
460    // The edge must lie on the surface it is said to bound.
461    let mut worst = 0.0f64;
462    for k in 0..=16 {
463        let f = f64::from(k) / 16.0;
464        let t = (range.1 - range.0).mul_add(f, range.0);
465        let pt = (prange.1 - prange.0).mul_add(f, prange.0);
466        let uv = pcurve.point_at(pt, tol)?;
467        let on = surface.point_at(uv.x, uv.y, tol)?;
468        worst = worst.max(on.distance(curve.point_at(t, tol)?));
469    }
470    if worst > reach {
471        ogeom_bail!(
472            Construction,
473            "side {i}'s edge stands {worst} off its support face, past {reach}"
474        );
475    }
476    Ok(Support {
477        face: face.clone(),
478        surface,
479        pcurve,
480        prange,
481    })
482}
483
484/// Chain the sides into one loop: the order they are walked in, with each
485/// side's `reversed` set to the direction it is walked.
486fn chain(model: &Model, sides: &mut [Side], tol: Tolerances) -> OgeomResult<Vec<usize>> {
487    let mut ends = Vec::with_capacity(sides.len());
488    for side in sides.iter() {
489        let Some(pair) = edge_vertices(model, &side.edge)? else {
490            ogeom_bail!(
491                Construction,
492                "side {} has no vertices, so it cannot be shown to join the loop",
493                side.entry
494            );
495        };
496        ends.push(pair);
497    }
498    let meets = |a: &Shape, b: &Shape| -> OgeomResult<bool> {
499        Ok(a.is_same(b) || model.same_position(a, b, tol)?)
500    };
501    let n = sides.len();
502    let mut used = vec![false; n];
503    used[0] = true;
504    let mut order = vec![0];
505    let first = ends[0].0.clone();
506    let mut cursor = ends[0].1.clone();
507    while order.len() < n {
508        let last = sides[order[order.len() - 1]].entry;
509        let mut found: Vec<(usize, bool)> = Vec::new();
510        for j in 0..n {
511            if used[j] {
512                continue;
513            }
514            if meets(&ends[j].0, &cursor)? {
515                found.push((j, false));
516            } else if meets(&ends[j].1, &cursor)? {
517                found.push((j, true));
518            }
519        }
520        let (j, reversed) = match found.as_slice() {
521            [one] => *one,
522            [] => ogeom_bail!(
523                Construction,
524                "the boundary does not close: no side continues where side {last} ends"
525            ),
526            _ => ogeom_bail!(
527                Construction,
528                "{} sides continue where side {last} ends; a filling's boundary \
529                 is one simple loop",
530                found.len()
531            ),
532        };
533        used[j] = true;
534        sides[j].reversed = reversed;
535        cursor = if reversed {
536            ends[j].0.clone()
537        } else {
538            ends[j].1.clone()
539        };
540        order.push(j);
541    }
542    if !meets(&cursor, &first)? {
543        ogeom_bail!(
544            Construction,
545            "the boundary does not close: the last side ends away from where the first begins"
546        );
547    }
548    Ok(order)
549}
550
551/// Turn the loop to run against its supports' uses of the shared edges,
552/// the majority deciding where they disagree.
553fn face_the_supports(model: &Model, sides: &mut [Side], order: &mut [usize]) -> OgeomResult<()> {
554    let (mut agree, mut disagree) = (0usize, 0usize);
555    for side in sides.iter() {
556        let Some(support) = &side.support else {
557            continue;
558        };
559        let uses: Vec<Orientation> =
560            explore(model, &support.face, Filter::OfType(ShapeType::Edge))?
561                .iter()
562                .filter(|e| e.node() == side.edge.node())
563                .map(Shape::orientation)
564                .collect();
565        let Some(&first) = uses.first() else {
566            continue;
567        };
568        if uses.iter().any(|o| *o != first)
569            || !matches!(first, Orientation::Forward | Orientation::Reversed)
570        {
571            continue;
572        }
573        if (first == Orientation::Forward) == !side.reversed {
574            disagree += 1;
575        } else {
576            agree += 1;
577        }
578    }
579    if disagree > agree {
580        order.reverse();
581        for side in sides.iter_mut() {
582            side.reversed = !side.reversed;
583        }
584    }
585    Ok(())
586}
587
588/// Points round the loop in its walking order.
589fn loop_points(sides: &[Side], order: &[usize], tol: Tolerances) -> OgeomResult<Vec<Point>> {
590    let mut out = Vec::with_capacity(order.len() * OUTLINE_SAMPLES);
591    for &i in order {
592        let side = &sides[i];
593        for k in 0..OUTLINE_SAMPLES {
594            #[expect(
595                clippy::cast_precision_loss,
596                reason = "a sample index, far below the mantissa"
597            )]
598            let mut f = k as f64 / OUTLINE_SAMPLES as f64;
599            if side.reversed {
600                f = 1.0 - f;
601            }
602            out.push(side.curve.point_at(side.parameter(f), tol)?);
603        }
604    }
605    Ok(out)
606}
607
608/// The plane a closed loop spans: its vector area's normal, which the loop
609/// turns counter-clockwise about, through its centroid, with `e1` along the
610/// loop's widest spread in the plane.
611fn frame_of(outline: &[Point], tol: Tolerances) -> OgeomResult<PlaneFrame> {
612    let Ok(origin) = Point::centroid(outline) else {
613        ogeom_bail!(Construction, "the boundary loop has no points");
614    };
615    let mut area = Vector::ZERO;
616    let mut reach = 0.0f64;
617    for (k, p) in outline.iter().enumerate() {
618        let q = outline[(k + 1) % outline.len()];
619        area += (*p - origin).cross(q - origin) * 0.5;
620        reach = reach.max(p.distance(origin));
621    }
622    let magnitude = area.magnitude();
623    if magnitude <= 1e-9 * reach * reach || magnitude <= tol.confusion() * tol.confusion() {
624        ogeom_bail!(
625            Construction,
626            "the boundary loop encloses no area seen from any plane"
627        );
628    }
629    let n = area * (1.0 / magnitude);
630    let b1 = Direction::new(n, tol)?.any_perpendicular().vector();
631    let b2 = n.cross(b1);
632    let (mut sxx, mut syy, mut sxy) = (0.0f64, 0.0f64, 0.0f64);
633    for p in outline {
634        let d = *p - origin;
635        let (x, y) = (d.dot(b1), d.dot(b2));
636        sxx += x * x;
637        syy += y * y;
638        sxy += x * y;
639    }
640    let theta = 0.5 * (2.0 * sxy).atan2(sxx - syy);
641    let e1 = b1 * theta.cos() + b2 * theta.sin();
642    let e2 = n.cross(e1);
643    Ok(PlaneFrame { origin, e1, e2, n })
644}
645
646/// Whether a closed polygon crosses itself: any two segments that are not
647/// neighbours crossing.
648fn crosses_itself(polygon: &[Point2]) -> bool {
649    let n = polygon.len();
650    let orient = |a: Point2, b: Point2, c: Point2| (b - a).cross(c - a);
651    for i in 0..n {
652        let (a, b) = (polygon[i], polygon[(i + 1) % n]);
653        for j in (i + 2)..n {
654            if i == 0 && j == n - 1 {
655                continue;
656            }
657            let (c, d) = (polygon[j], polygon[(j + 1) % n]);
658            if orient(a, b, c) * orient(a, b, d) < 0.0 && orient(c, d, a) * orient(c, d, b) < 0.0 {
659                return true;
660            }
661        }
662    }
663    false
664}
665
666/// Whether a point lies inside a closed polygon, by its winding number.
667fn inside(polygon: &[Point2], q: Point2) -> bool {
668    let n = polygon.len();
669    let mut winding = 0i32;
670    for i in 0..n {
671        let (a, b) = (polygon[i], polygon[(i + 1) % n]);
672        let side = (b - a).cross(q - a);
673        if a.y <= q.y {
674            if b.y > q.y && side > 0.0 {
675                winding += 1;
676            }
677        } else if b.y <= q.y && side < 0.0 {
678            winding -= 1;
679        }
680    }
681    winding != 0
682}
683
684/// The points the constraints put inside the hole, each with its share of
685/// the fit's weight.
686fn constraint_points(
687    model: &Model,
688    constraints: &[Shape],
689    tol: Tolerances,
690) -> OgeomResult<Vec<(Point, f64)>> {
691    let mut out = Vec::new();
692    for (k, shape) in constraints.iter().enumerate() {
693        let placement = shape.transform(model.datums())?;
694        match model.kind_of(shape)? {
695            ShapeType::Vertex => {
696                let Some(data) = model.node(shape).and_then(|n| n.data().as_vertex()) else {
697                    ogeom_bail!(Construction, "constraint {k} holds no vertex data");
698                };
699                out.push((placement.apply(data.point), POINT_SHARE));
700            }
701            ShapeType::Edge => {
702                let Some(data) = model.node(shape).and_then(|n| n.data().as_edge()) else {
703                    ogeom_bail!(Construction, "constraint {k} holds no edge data");
704                };
705                let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
706                    ogeom_bail!(Construction, "constraint {k} has no 3D curve");
707                };
708                let Some(curve) = model.geometry().curve(*curve) else {
709                    ogeom_bail!(Dangling, "constraint {k}'s curve is not in this model");
710                };
711                for i in 0..=PER_CURVE {
712                    #[expect(
713                        clippy::cast_precision_loss,
714                        reason = "a sample index, far below the mantissa"
715                    )]
716                    let f = i as f64 / PER_CURVE as f64;
717                    let t = (range.1 - range.0).mul_add(f, range.0);
718                    out.push((placement.apply(curve.point_at(t, tol)?), POINT_SHARE / 4.0));
719                }
720            }
721            other => ogeom_bail!(
722                Construction,
723                "constraint {k} is a {other:?}; a filling passes through vertices and edges"
724            ),
725        }
726    }
727    Ok(out)
728}
729
730/// The basis curve of a forward trim, whose parameter is the trim's own.
731fn untrimmed(curve: &Curve) -> &Curve {
732    match curve {
733        Curve::Trimmed(t) if !t.is_reversed() => untrimmed(t.basis()),
734        other => other,
735    }
736}
737
738/// The side's curve seen in the plane, at the curve's own parameter: exact
739/// where the curve is a line, a circle or ellipse, or a B-spline (an affine
740/// image of each is a curve of the same form), checked against the curve
741/// before it is trusted; fitted at the curve's parameters otherwise.
742fn trace(
743    side: &Side,
744    frame: &PlaneFrame,
745    tolerance: f64,
746    tol: Tolerances,
747) -> OgeomResult<PlanarCurve> {
748    let truth = |t: f64| -> OgeomResult<Point2> { Ok(frame.chart(side.curve.point_at(t, tol)?)) };
749    let (t0, t1) = side.range;
750    let exact: Option<PlanarCurve> = match untrimmed(&side.curve) {
751        Curve::BSpline(spline) => {
752            let mut control = Vec::with_capacity(spline.control_points().len());
753            for w in spline.control_points() {
754                control.push(Weighted::new(frame.chart(w.point()), w.weight, tol)?);
755            }
756            BSpline2d::rational(spline.knots().clone(), control)
757                .ok()
758                .map(PlanarCurve::BSpline)
759        }
760        _ => match side.curve.kind() {
761            CurveKind::Line => {
762                let (q0, q1) = (truth(t0)?, truth(t1)?);
763                let d = (q1 - q0) * (1.0 / (t1 - t0));
764                let c = q0 - d * t0;
765                Trig2d::new(c, d, Vector2::ZERO, Vector2::ZERO, side.range)
766                    .ok()
767                    .map(PlanarCurve::Trig)
768            }
769            CurveKind::Circle | CurveKind::Ellipse => {
770                // Thirds rather than ends and middle: a full circle's ends
771                // are one point.
772                let ts = [
773                    t0,
774                    (t1 - t0).mul_add(1.0 / 3.0, t0),
775                    (t1 - t0).mul_add(2.0 / 3.0, t0),
776                ];
777                let rows = ts.map(|t| [1.0, t.cos(), t.sin()]);
778                let values = [truth(ts[0])?, truth(ts[1])?, truth(ts[2])?];
779                match (
780                    solve3(rows, values.map(|q| q.x)),
781                    solve3(rows, values.map(|q| q.y)),
782                ) {
783                    (Some(x), Some(y)) => Trig2d::new(
784                        Point2::new(x[0], y[0]),
785                        Vector2::ZERO,
786                        Vector2::new(x[1], y[1]),
787                        Vector2::new(x[2], y[2]),
788                        side.range,
789                    )
790                    .ok()
791                    .map(PlanarCurve::Trig),
792                    _ => None,
793                }
794            }
795            _ => None,
796        },
797    };
798    if let Some(curve) = exact {
799        let mut worst = 0.0f64;
800        let mut scale = 1.0f64;
801        for k in 0..=32 {
802            let t = (t1 - t0).mul_add(f64::from(k) / 32.0, t0);
803            let q = truth(t)?;
804            scale = scale.max(q.to_vector().magnitude());
805            worst = worst.max(curve.point_at(t, tol)?.distance(q));
806        }
807        if worst <= 1e-12 * scale + 1e-3 * tol.confusion() {
808            return Ok(curve);
809        }
810    }
811    const SAMPLES: usize = 96;
812    let mut parameters = Vec::with_capacity(SAMPLES + 1);
813    let mut points = Vec::with_capacity(SAMPLES + 1);
814    for k in 0..=SAMPLES {
815        #[expect(
816            clippy::cast_precision_loss,
817            reason = "a sample index, far below the mantissa"
818        )]
819        let t = (t1 - t0).mul_add(k as f64 / SAMPLES as f64, t0);
820        parameters.push(t);
821        points.push(truth(t)?);
822    }
823    let fitted = ogeom_geom::fit::fit_points_2d_at(&parameters, &points, 3, tolerance * 1e-2, tol)?;
824    Ok(PlanarCurve::BSpline(fitted.curve))
825}
826
827/// Solve a 3x3 system by Cramer's rule; `None` where it is singular.
828fn solve3(rows: [[f64; 3]; 3], rhs: [f64; 3]) -> Option<[f64; 3]> {
829    let det = |m: [[f64; 3]; 3]| {
830        m[0][0] * m[1][1].mul_add(m[2][2], -m[1][2] * m[2][1])
831            - m[0][1] * m[1][0].mul_add(m[2][2], -m[1][2] * m[2][0])
832            + m[0][2] * m[1][0].mul_add(m[2][1], -m[1][1] * m[2][0])
833    };
834    let d = det(rows);
835    if d.abs() < 1e-12 {
836        return None;
837    }
838    let mut out = [0.0; 3];
839    for (c, slot) in out.iter_mut().enumerate() {
840        let mut m = rows;
841        for r in 0..3 {
842            m[r][c] = rhs[r];
843        }
844        *slot = det(m) / d;
845    }
846    Some(out)
847}
848
849/// A side's length, by chords.
850fn side_length(side: &Side, tol: Tolerances) -> OgeomResult<f64> {
851    let mut length = 0.0;
852    let mut previous = side.curve.point_at(side.range.0, tol)?;
853    for k in 1..=OUTLINE_SAMPLES {
854        #[expect(
855            clippy::cast_precision_loss,
856            reason = "a sample index, far below the mantissa"
857        )]
858        let f = k as f64 / OUTLINE_SAMPLES as f64;
859        let p = side.curve.point_at(side.parameter(f), tol)?;
860        length += p.distance(previous);
861        previous = p;
862    }
863    Ok(length)
864}
865
866/// The fit's conditions along one side: positions always, tangent planes
867/// from G1 on, normal curvatures at G2. Every row carries the side's share
868/// of the boundary per sample (`share`, its length against the hole's
869/// `size`) and is scaled to a residual free of units.
870fn side_conditions(
871    side: &Side,
872    share: f64,
873    size: f64,
874    samples: usize,
875    frame: &PlaneFrame,
876    out: &mut Vec<Condition>,
877    tol: Tolerances,
878) -> OgeomResult<()> {
879    #[expect(
880        clippy::cast_precision_loss,
881        reason = "a sample count, far below the mantissa"
882    )]
883    let root = (share / samples as f64).max(f64::MIN_POSITIVE).sqrt();
884    for k in 0..=samples {
885        #[expect(
886            clippy::cast_precision_loss,
887            reason = "a sample index, far below the mantissa"
888        )]
889        let t = side.parameter(k as f64 / samples as f64);
890        let p = side.curve.point_at(t, tol)?;
891        let at = frame.chart(p);
892        out.push(Condition {
893            at,
894            order: (0, 0),
895            target: frame.height(p),
896            weight: root / size,
897        });
898        if side.order == 0 {
899            continue;
900        }
901        let (Some((uv, normal)), Some(support)) = (side.support_at(t, tol)?, &side.support) else {
902            continue;
903        };
904        let lift = normal.dot(frame.n);
905        if lift.abs() < MIN_LIFT {
906            ogeom_bail!(
907                Construction,
908                "side {}'s support stands within {:.1} degrees of square to the \
909                 plane the boundary spans; the hole is not a height field over it",
910                side.entry,
911                lift.abs().asin().to_degrees()
912            );
913        }
914        // The support's normal on the plane's side, and the slopes that
915        // put the patch's tangent plane on it.
916        let normal = if lift < 0.0 { normal * -1.0 } else { normal };
917        let lift = lift.abs();
918        let (hu, hv) = (-normal.dot(frame.e1) / lift, -normal.dot(frame.e2) / lift);
919        out.push(Condition {
920            at,
921            order: (1, 0),
922            target: hu,
923            weight: root,
924        });
925        out.push(Condition {
926            at,
927            order: (0, 1),
928            target: hv,
929            weight: root,
930        });
931        if side.order < 2 {
932            continue;
933        }
934        // With the tangent plane fixed, the patch's second fundamental form
935        // is `n · S_ab = h_ab (normal · n)`: the support's, read off its
936        // principal curvatures, fixes the three second derivatives.
937        let curvature = support.surface.curvature_at(uv.x, uv.y, tol)?;
938        let sense = if curvature.normal.dot_vector(normal) < 0.0 {
939            -1.0
940        } else {
941            1.0
942        };
943        let (dmax, dmin) = (
944            curvature.max_direction.vector(),
945            curvature.min_direction.vector(),
946        );
947        let second = |a: Vector, b: Vector| {
948            sense
949                * curvature.max.mul_add(
950                    a.dot(dmax) * b.dot(dmax),
951                    curvature.min * a.dot(dmin) * b.dot(dmin),
952                )
953                / lift
954        };
955        let su = frame.e1 + frame.n * hu;
956        let sv = frame.e2 + frame.n * hv;
957        for (order, target) in [
958            ((2, 0), second(su, su)),
959            ((1, 1), second(su, sv)),
960            ((0, 2), second(sv, sv)),
961        ] {
962            out.push(Condition {
963                at,
964                order,
965                target,
966                weight: root * size,
967            });
968        }
969    }
970    Ok(())
971}
972
973/// Measure one side at `stations` spread over its edge: the gap between
974/// the edge's curve and the surface through the pcurve, and against the
975/// support the angle between normals and the difference of normal
976/// curvatures square to the edge.
977fn measure_side(
978    side: &Side,
979    pcurve: &PlanarCurve,
980    surface: &BSplineSurface,
981    stations: usize,
982    tol: Tolerances,
983) -> OgeomResult<FillSide> {
984    let (mut gap, mut angle, mut curvature) = (0.0f64, 0.0f64, None::<f64>);
985    for k in 0..stations {
986        #[expect(
987            clippy::cast_precision_loss,
988            reason = "a station index, far below the mantissa"
989        )]
990        let t = side.parameter(k as f64 / (stations - 1) as f64);
991        let p = side.curve.point_at(t, tol)?;
992        let uv = pcurve.point_at(t, tol)?;
993        gap = gap.max(surface.point_at(uv.x, uv.y, tol)?.distance(p));
994        let (Some((suv, theirs)), Some(support)) = (side.support_at(t, tol)?, &side.support) else {
995            continue;
996        };
997        let ours = surface.normal_at(uv.x, uv.y, tol)?.vector();
998        angle = angle.max(ours.cross(theirs).magnitude().atan2(ours.dot(theirs).abs()));
999        let across = ours.cross(side.curve.d1_at(t, tol)?);
1000        let signed = |c: SurfaceCurvature| -> Option<f64> {
1001            let k = c.normal_curvature(across)?;
1002            Some(if c.normal.dot_vector(ours) < 0.0 {
1003                -k
1004            } else {
1005                k
1006            })
1007        };
1008        let a = surface.curvature_at(uv.x, uv.y, tol).ok().and_then(signed);
1009        let b = support
1010            .surface
1011            .curvature_at(suv.x, suv.y, tol)
1012            .ok()
1013            .and_then(signed);
1014        if let (Some(a), Some(b)) = (a, b) {
1015            curvature = Some(curvature.unwrap_or(0.0).max((a - b).abs()));
1016        }
1017    }
1018    let supported = side.support.is_some();
1019    Ok(FillSide {
1020        edge: side.edge.clone(),
1021        gap,
1022        angle: supported.then_some(angle),
1023        curvature: curvature.filter(|_| supported),
1024        stations,
1025    })
1026}
1027
1028/// The first deviation past `tolerance`, described, or `None` when every
1029/// side and constraint meets it.
1030fn first_miss(
1031    sides: &[Side],
1032    reports: &[FillSide],
1033    constraint_gap: f64,
1034    tolerance: f64,
1035) -> Option<String> {
1036    for (side, report) in sides.iter().zip(reports) {
1037        let i = side.entry;
1038        if report.gap > tolerance {
1039            return Some(format!("side {i} stands {} off its edge", report.gap));
1040        }
1041        if side.order >= 1 {
1042            let angle = report.angle.unwrap_or(f64::INFINITY);
1043            if angle > tolerance {
1044                return Some(format!(
1045                    "side {i} meets its support {angle} radians from tangent"
1046                ));
1047            }
1048        }
1049        if side.order >= 2 {
1050            match report.curvature {
1051                Some(c) if c <= tolerance => {}
1052                Some(c) => {
1053                    return Some(format!(
1054                        "side {i}'s normal curvature differs from its support's by {c}"
1055                    ));
1056                }
1057                None => {
1058                    return Some(format!(
1059                        "side {i}'s curvature could not be read on both surfaces"
1060                    ));
1061                }
1062            }
1063        }
1064    }
1065    (constraint_gap > tolerance)
1066        .then(|| format!("a constraint stands {constraint_gap} off the surface"))
1067}
1068
1069/// Build the face on the fitted patch, bounded by the sides' own edges,
1070/// each given its pcurve on it and a tolerance that holds its gap.
1071fn build(
1072    model: &mut Model,
1073    sides: &[Side],
1074    order: &[usize],
1075    traces: &[PlanarCurve],
1076    reports: &[FillSide],
1077    surface: BSplineSurface,
1078    tol: Tolerances,
1079) -> OgeomResult<Shape> {
1080    let walk: Vec<Shape> = order
1081        .iter()
1082        .map(|&i| {
1083            let side = &sides[i];
1084            if side.reversed {
1085                side.edge.reversed()
1086            } else {
1087                side.edge.clone()
1088            }
1089        })
1090        .collect();
1091    let wire = make_wire(model, &walk, tol)?.shape;
1092    let face = make_face(model, SurfaceGeometry::BSpline(surface), &[wire], tol)?.shape;
1093    let Some(NodeData::Face(data)) = model.node(&face).map(|n| n.data()) else {
1094        ogeom_bail!(Dangling, "the face just built is not in this model");
1095    };
1096    let surface_id = data.surface;
1097    for ((side, pcurve), report) in sides.iter().zip(traces).zip(reports) {
1098        attach_pcurve(
1099            model,
1100            &side.edge,
1101            pcurve.clone(),
1102            surface_id,
1103            Location::identity(),
1104            side.range,
1105        )?;
1106        if report.gap > side.edge_tolerance {
1107            // The edge owns the gap, and so do the vertices bounding it.
1108            let widened = Tolerance::new(report.gap + tol.confusion())?;
1109            model.widen(&side.edge, widened)?;
1110            if let Some((a, b)) = edge_vertices(model, &side.edge)? {
1111                model.widen(&a, widened)?;
1112                model.widen(&b, widened)?;
1113            }
1114        }
1115    }
1116    Ok(face)
1117}