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_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:
91/// about 84 degrees.
92const MIN_LIFT: f64 = 0.1;
93/// A constraint point's weight, as the share of the hole's size a boundary
94/// sample of that length would carry.
95const POINT_SHARE: f64 = 0.05;
96/// Samples per constraint curve.
97const PER_CURVE: usize = 32;
98
99/// Fill the hole a loop of edges bounds with one face meeting each side's
100/// support at the continuity asked.
101///
102/// The sides may come in any order and either direction; they must chain
103/// into one simple closed loop, each side's end meeting the next one's at
104/// a shared vertex node or where the two vertices lie within their own and
105/// their edges' tolerances of each other. `constraints` are vertices and
106/// edges inside the hole that the surface passes through. The surface is a
107/// cubic B-spline height patch over the plane the loop spans, fitted by least
108/// squares to the sides' positions, to the tangent planes of G1 and G2
109/// sides' supports and to the normal curvatures of G2 sides' supports, with
110/// a thin-plate bending energy settling the rest, weighted from
111/// `tolerance` so it costs the conditions a small share of it; the control
112/// net is refined until every side meets `tolerance` and the conditions'
113/// residual no longer outweighs the energy, or the refinement runs out.
114/// The face is trimmed by the given edges themselves where they are not
115/// placed and share vertex nodes end to end, each given a pcurve on the
116/// patch; any other side is stood in for by a new edge on its curve
117/// between vertices shared round the loop, so the face is one closed wire
118/// either way. A placed edge or support is read where it stands. The face
119/// faces the way the supports say: across each edge it
120/// runs opposite to its support's use of the edge, so sewing it to the
121/// supports gives a consistently oriented shell. With no supports it faces
122/// the side the loop turns counter-clockwise about, walked from the first
123/// side in that edge's own direction.
124///
125/// `tolerance` is the target for every measured deviation: a distance in
126/// model units for each side's gap and each constraint, an angle in radians
127/// for a G1 or G2 side's tangency, and a curvature difference in inverse
128/// model units for a G2 side. An edge the surface stands further from than
129/// the edge's own tolerance has that tolerance, and its vertices', widened
130/// to the measured gap.
131///
132/// # Errors
133///
134/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction), by
135/// name, where:
136///
137/// - `tolerance` is not positive and finite, or there are no sides;
138/// - a side is not an edge, or has no 3D curve or no vertices;
139/// - the same edge is given twice;
140/// - a side asks [`Continuity::C1`], [`Continuity::C2`] or
141///   [`Continuity::CInfinity`] (parametric continuity between two
142///   surfaces' charts), or G1 or G2 with no support;
143/// - a support is not a face, does not hold its edge, or the
144///   edge does not lie on its surface within `tolerance`;
145/// - the sides do not chain into one closed loop;
146/// - the loop encloses no area, or crosses itself seen along the normal of
147///   the plane it spans, so the hole is not a height field over that plane;
148/// - a G1 or G2 side's support stands within about 6 degrees of square to
149///   that plane;
150/// - a constraint is neither a vertex nor an edge, or lies outside the hole
151///   seen along the plane's normal.
152///
153/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the finest
154/// control net still misses `tolerance`, naming the side and the deviation.
155pub fn make_filling_n(
156    model: &mut Model,
157    boundary: &[FillBoundary],
158    constraints: &[Shape],
159    tolerance: f64,
160    tol: Tolerances,
161) -> OgeomResult<Filled> {
162    if !(tolerance.is_finite() && tolerance > 0.0) {
163        ogeom_bail!(
164            Construction,
165            "a filling's tolerance is positive and finite; got {tolerance}"
166        );
167    }
168    if boundary.is_empty() {
169        ogeom_bail!(Construction, "a filling needs at least one boundary edge");
170    }
171    let mut sides = Vec::with_capacity(boundary.len());
172    for (i, entry) in boundary.iter().enumerate() {
173        sides.push(read_side(model, i, entry, tolerance, tol)?);
174    }
175    for i in 0..sides.len() {
176        for j in (i + 1)..sides.len() {
177            if sides[i].given.is_same(&sides[j].given) {
178                ogeom_bail!(Construction, "side {j} is side {i}'s edge again");
179            }
180        }
181    }
182    let mut order = chain(model, &mut sides, tol)?;
183    face_the_supports(model, &mut sides, &mut order)?;
184
185    // The plane the loop spans, and the loop seen along its normal.
186    let outline = loop_points(&sides, &order, tol)?;
187    let frame = frame_of(&outline, tol)?;
188    let chart_outline: Vec<Point2> = outline.iter().map(|p| frame.chart(*p)).collect();
189    if crosses_itself(&chart_outline) {
190        ogeom_bail!(
191            Construction,
192            "the boundary loop crosses itself seen along the normal of the \
193             plane it spans; the hole is not a height field over that plane"
194        );
195    }
196    let interior = constraint_points(model, constraints, tol)?;
197    for (k, (p, _)) in interior.iter().enumerate() {
198        if !inside(&chart_outline, frame.chart(*p)) {
199            ogeom_bail!(
200                Construction,
201                "a point of constraint {k} at {p:?} lies outside the hole seen \
202                 along the normal of the plane the boundary spans"
203            );
204        }
205    }
206
207    // The rectangle the patch covers: the hole and a margin round it.
208    let (mut lo, mut hi) = (
209        Point2::new(f64::INFINITY, f64::INFINITY),
210        Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
211    );
212    for q in &chart_outline {
213        lo = Point2::new(lo.x.min(q.x), lo.y.min(q.y));
214        hi = Point2::new(hi.x.max(q.x), hi.y.max(q.y));
215    }
216    let margin = MARGIN * (hi.x - lo.x).max(hi.y - lo.y);
217    let domain = (
218        (lo.x - margin, hi.x + margin),
219        (lo.y - margin, hi.y + margin),
220    );
221    let (du, dv) = (domain.0.1 - domain.0.0, domain.1.1 - domain.1.0);
222    let size = du.max(dv);
223
224    let mut traces = Vec::with_capacity(sides.len());
225    let mut lengths = Vec::with_capacity(sides.len());
226    for side in &sides {
227        traces.push(trace(side, &frame, tolerance, tol)?);
228        lengths.push(side_length(side, tol)?);
229    }
230
231    let smoothing = smoothing_for(&sides, tolerance, size);
232    let mut last_miss = String::new();
233    // The latest fit that met the tolerance.
234    let mut fallback = None;
235    for base in NETS {
236        #[expect(
237            clippy::cast_possible_truncation,
238            clippy::cast_sign_loss,
239            clippy::cast_precision_loss,
240            reason = "a control count of a few dozen, from a positive ratio"
241        )]
242        let count = |extent: f64| -> usize {
243            ((base as f64 * extent / size).round() as usize).max(DEGREE + 2)
244        };
245        let controls = (count(du), count(dv));
246        let samples = 4 * controls.0.max(controls.1) + 8;
247
248        let mut conditions = Vec::new();
249        for (side, length) in sides.iter().zip(&lengths) {
250            side_conditions(
251                side,
252                *length / size,
253                size,
254                samples,
255                &frame,
256                &mut conditions,
257                tol,
258            )?;
259        }
260        for (p, share) in &interior {
261            conditions.push(Condition {
262                at: frame.chart(*p),
263                order: (0, 0),
264                target: frame.height(*p),
265                weight: share.sqrt() / size,
266            });
267        }
268        let fit = fit_height(&frame, domain, controls, &conditions, smoothing, tol)?;
269        let surface = fit.surface;
270
271        let mut reports = Vec::with_capacity(sides.len());
272        for (side, pcurve) in sides.iter().zip(&traces) {
273            reports.push(measure_side(side, pcurve, &surface, 3 * samples + 1, tol)?);
274        }
275        let mut constraint_gap = 0.0f64;
276        for (p, _) in &interior {
277            let q = frame.chart(*p);
278            constraint_gap = constraint_gap.max(surface.point_at(q.x, q.y, tol)?.distance(*p));
279        }
280
281        if let Some(miss) = first_miss(&sides, &reports, constraint_gap, tolerance) {
282            last_miss = format!("at {}x{} controls, {miss}", controls.0, controls.1);
283            continue;
284        }
285        fallback = Some((surface, reports, constraint_gap));
286        // A residual-led net is refined; see [`smoothing_for`].
287        if fit.residual <= smoothing {
288            break;
289        }
290    }
291    let Some((surface, reports, constraint_gap)) = fallback else {
292        ogeom_bail!(
293            NotDone,
294            "the filling misses its tolerance of {tolerance} on the finest net: {last_miss}"
295        )
296    };
297    let face = build(model, &mut sides, &order, &traces, &reports, surface, tol)?;
298    let mut history = History::new();
299    let mut reports = reports;
300    for (side, report) in sides.iter().zip(&mut reports) {
301        history.generate(&side.given, face.clone());
302        if !side.edge.is_same(&side.given) {
303            history.generate(&side.given, side.edge.clone());
304        }
305        report.edge = side.edge.clone();
306    }
307    for constraint in constraints {
308        history.generate(constraint, face.clone());
309    }
310    Ok(Filled {
311        built: Built::new(face, history),
312        sides: reports,
313        constraint_gap,
314    })
315}
316
317/// The surface a side's support lies on and the side's pcurve there.
318struct Support {
319    face: Shape,
320    surface: SurfaceGeometry,
321    pcurve: PlanarCurve,
322    prange: (f64, f64),
323}
324
325/// One side, read and checked.
326struct Side {
327    entry: usize,
328    /// The edge as given, forward.
329    given: Shape,
330    /// The edge bounding the face: the given one, or its stand-in once the
331    /// face is built.
332    edge: Shape,
333    /// Whether the given edge or its curve is placed.
334    placed: bool,
335    /// The curve where the edge stands.
336    curve: Curve,
337    range: (f64, f64),
338    edge_tolerance: f64,
339    /// 0, 1 or 2: positions, tangent planes, normal curvatures.
340    order: usize,
341    support: Option<Support>,
342    /// Whether the loop runs against the edge.
343    reversed: bool,
344}
345
346impl Side {
347    fn parameter(&self, f: f64) -> f64 {
348        (self.range.1 - self.range.0).mul_add(f, self.range.0)
349    }
350
351    /// The support's chart position and unit normal at the edge's
352    /// parameter `t`.
353    fn support_at(&self, t: f64, tol: Tolerances) -> OgeomResult<Option<(Point2, Vector)>> {
354        let Some(support) = &self.support else {
355            return Ok(None);
356        };
357        let f = (t - self.range.0) / (self.range.1 - self.range.0);
358        let pt = (support.prange.1 - support.prange.0).mul_add(f, support.prange.0);
359        let uv = support.pcurve.point_at(pt, tol)?;
360        let normal = support.surface.normal_at(uv.x, uv.y, tol)?.vector();
361        Ok(Some((uv, normal)))
362    }
363}
364
365fn read_side(
366    model: &Model,
367    i: usize,
368    entry: &FillBoundary,
369    tolerance: f64,
370    tol: Tolerances,
371) -> OgeomResult<Side> {
372    let edge = &entry.edge;
373    if model.kind_of(edge)? != ShapeType::Edge {
374        ogeom_bail!(Construction, "side {i} is not an edge");
375    }
376    let order = match entry.continuity {
377        Continuity::C0 => 0,
378        Continuity::G1 => 1,
379        Continuity::G2 => 2,
380        other => ogeom_bail!(
381            Construction,
382            "side {i} asks {other:?}: parametric continuity between two \
383             surfaces' charts is not what a filling meets; ask for G1 or G2"
384        ),
385    };
386    let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
387        ogeom_bail!(Construction, "side {i}'s edge holds no edge data");
388    };
389    let Some(EdgeRepr::Curve3d {
390        curve,
391        range,
392        location: own,
393    }) = data.curve3d()
394    else {
395        ogeom_bail!(Construction, "side {i}'s edge has no 3D curve");
396    };
397    let Some(curve) = model.geometry().curve(*curve).cloned() else {
398        ogeom_bail!(Dangling, "side {i}'s curve is not in this model");
399    };
400    let range = *range;
401    let edge_tolerance = data.tolerance.get();
402    let placed = !(edge.location().is_identity() && own.is_identity());
403    let curve = if placed {
404        curve
405            .transformed(&own.composed(model.datums())?, tol)?
406            .transformed(&edge.transform(model.datums())?, tol)?
407    } else {
408        curve
409    };
410    let edge = edge.oriented(Orientation::Forward);
411
412    let support = match &entry.support {
413        None if order > 0 => ogeom_bail!(
414            Construction,
415            "side {i} asks {:?} but names no support face to meet",
416            entry.continuity
417        ),
418        None => None,
419        Some(face) => Some(read_support(
420            model,
421            i,
422            face,
423            &edge,
424            &curve,
425            range,
426            tolerance.max(edge_tolerance),
427            tol,
428        )?),
429    };
430    Ok(Side {
431        entry: i,
432        given: edge.clone(),
433        edge,
434        placed,
435        curve,
436        range,
437        edge_tolerance,
438        order,
439        support,
440        reversed: false,
441    })
442}
443
444#[expect(
445    clippy::too_many_arguments,
446    reason = "the side's edge, curve and range are read once by the caller"
447)]
448fn read_support(
449    model: &Model,
450    i: usize,
451    face: &Shape,
452    edge: &Shape,
453    curve: &Curve,
454    range: (f64, f64),
455    reach: f64,
456    tol: Tolerances,
457) -> OgeomResult<Support> {
458    if model.kind_of(face)? != ShapeType::Face {
459        ogeom_bail!(Construction, "side {i}'s support is not a face");
460    }
461    let Some(NodeData::Face(face_data)) = model.node(face).map(|n| n.data()) else {
462        ogeom_bail!(Dangling, "side {i}'s support is not in this model");
463    };
464    let face_placed = !(face.location().is_identity() && face_data.location.is_identity());
465    let holds = explore(model, face, Filter::OfType(ShapeType::Edge))?
466        .iter()
467        .any(|e| e.is_same(edge));
468    if !holds {
469        ogeom_bail!(
470            Construction,
471            "side {i}'s support face does not hold the side's edge at its placement"
472        );
473    }
474    let surface_id = face_data.surface;
475    let Some(surface) = model.geometry().surface(surface_id).cloned() else {
476        ogeom_bail!(Dangling, "side {i}'s support surface is not in this model");
477    };
478    let surface = if face_placed {
479        surface
480            .transformed(&face_data.location.composed(model.datums())?, tol)?
481            .transformed(&face.transform(model.datums())?, tol)?
482    } else {
483        surface
484    };
485    let Some(edge_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    // A stored pcurve describes the surface's own chart, which a placed
489    // face's restated surface need not share.
490    let stored = match edge_data
491        .pcurve_for(surface_id, edge.location())
492        .filter(|_| !face_placed)
493    {
494        Some(
495            EdgeRepr::PCurve { curve, range, .. }
496            | EdgeRepr::Seam {
497                forward: curve,
498                range,
499                ..
500            },
501        ) => model
502            .geometry()
503            .pcurve(*curve)
504            .cloned()
505            .map(|c| (c, *range)),
506        _ => None,
507    };
508    // The edge must lie on the surface it is said to bound. A stored image
509    // that misses it (one kept for another placement of the same edge
510    // node) gives way to one fitted where the edge stands.
511    let off = |pcurve: &PlanarCurve, prange: (f64, f64)| -> OgeomResult<f64> {
512        let mut worst = 0.0f64;
513        for k in 0..=16 {
514            let f = f64::from(k) / 16.0;
515            let t = (range.1 - range.0).mul_add(f, range.0);
516            let pt = (prange.1 - prange.0).mul_add(f, prange.0);
517            let uv = pcurve.point_at(pt, tol)?;
518            let on = surface.point_at(uv.x, uv.y, tol)?;
519            worst = worst.max(on.distance(curve.point_at(t, tol)?));
520        }
521        Ok(worst)
522    };
523    let stored = match stored {
524        Some((pcurve, prange)) => {
525            let worst = off(&pcurve, prange)?;
526            (worst <= reach).then_some((pcurve, prange, worst))
527        }
528        None => None,
529    };
530    let (pcurve, prange, worst) = if let Some(found) = stored {
531        found
532    } else {
533        let (fitted, _, _, _, _) =
534            ogeom_algo::pcurve_fit::fit_projected_pcurve(curve, range, &surface, tol)?;
535        let worst = off(&fitted, range)?;
536        (fitted, range, worst)
537    };
538    if worst > reach {
539        ogeom_bail!(
540            Construction,
541            "side {i}'s edge stands {worst} off its support face, past {reach}"
542        );
543    }
544    Ok(Support {
545        face: face.clone(),
546        surface,
547        pcurve,
548        prange,
549    })
550}
551
552/// One end of a side: its vertex, where the vertex stands, and how far
553/// it reaches (its own tolerance or its edge's, the wider).
554#[derive(Clone)]
555struct End {
556    vertex: Shape,
557    at: Point,
558    reach: f64,
559}
560
561/// A side's two ends, in the edge's own direction.
562fn ends_of(model: &Model, side: &Side) -> OgeomResult<(End, End)> {
563    let Some((a, b)) = edge_vertices(model, &side.given)? else {
564        ogeom_bail!(
565            Construction,
566            "side {} has no vertices, so it cannot be shown to join the loop",
567            side.entry
568        );
569    };
570    let end = |vertex: Shape| -> OgeomResult<End> {
571        let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) else {
572            ogeom_bail!(Construction, "side {}'s vertex holds no point", side.entry);
573        };
574        Ok(End {
575            at: vertex.transform(model.datums())?.apply(data.point),
576            reach: data.tolerance.get().max(side.edge_tolerance),
577            vertex,
578        })
579    };
580    Ok((end(a)?, end(b)?))
581}
582
583/// Chain the sides into one loop: the order they are walked in, with each
584/// side's `reversed` set to the direction it is walked. Ends join where
585/// they are one vertex, or failing that where they lie within reach of
586/// each other.
587fn chain(model: &Model, sides: &mut [Side], tol: Tolerances) -> OgeomResult<Vec<usize>> {
588    let mut ends = Vec::with_capacity(sides.len());
589    for side in sides.iter() {
590        let (a, b) = ends_of(model, side)?;
591        ends.push((a, b));
592    }
593    let same = |a: &End, b: &End| -> OgeomResult<bool> {
594        Ok(a.vertex.is_same(&b.vertex) || model.same_position(&a.vertex, &b.vertex, tol)?)
595    };
596    let near = |a: &End, b: &End| a.at.distance(b.at) <= a.reach + b.reach + tol.confusion();
597    let meets = |a: &End, b: &End| -> OgeomResult<bool> { Ok(same(a, b)? || near(a, b)) };
598    let n = sides.len();
599    let mut used = vec![false; n];
600    used[0] = true;
601    let mut order = vec![0];
602    let first = ends[0].0.clone();
603    let mut cursor = ends[0].1.clone();
604    while order.len() < n {
605        let last = sides[order[order.len() - 1]].entry;
606        // A shared vertex is the stronger word: ends merely near the
607        // cursor count only where none is the cursor's own vertex.
608        let mut found: Vec<(usize, bool)> = Vec::new();
609        for strict in [true, false] {
610            for j in 0..n {
611                if used[j] {
612                    continue;
613                }
614                let joins = |e: &End| -> OgeomResult<bool> {
615                    if strict {
616                        same(e, &cursor)
617                    } else {
618                        meets(e, &cursor)
619                    }
620                };
621                if joins(&ends[j].0)? {
622                    found.push((j, false));
623                } else if joins(&ends[j].1)? {
624                    found.push((j, true));
625                }
626            }
627            if !found.is_empty() {
628                break;
629            }
630        }
631        let (j, reversed) = match found.as_slice() {
632            [one] => *one,
633            [] => ogeom_bail!(
634                Construction,
635                "the boundary does not close: no side continues where side {last} ends"
636            ),
637            _ => ogeom_bail!(
638                Construction,
639                "{} sides continue where side {last} ends; a filling's boundary \
640                 is one simple loop",
641                found.len()
642            ),
643        };
644        used[j] = true;
645        sides[j].reversed = reversed;
646        cursor = if reversed {
647            ends[j].0.clone()
648        } else {
649            ends[j].1.clone()
650        };
651        order.push(j);
652    }
653    if !meets(&cursor, &first)? {
654        ogeom_bail!(
655            Construction,
656            "the boundary does not close: the last side ends away from where the first begins"
657        );
658    }
659    Ok(order)
660}
661
662/// Turn the loop to run against its supports' uses of the shared edges,
663/// the majority deciding where they disagree.
664fn face_the_supports(model: &Model, sides: &mut [Side], order: &mut [usize]) -> OgeomResult<()> {
665    let (mut agree, mut disagree) = (0usize, 0usize);
666    for side in sides.iter() {
667        let Some(support) = &side.support else {
668            continue;
669        };
670        let uses: Vec<Orientation> =
671            explore(model, &support.face, Filter::OfType(ShapeType::Edge))?
672                .iter()
673                .filter(|e| e.is_same(&side.given))
674                .map(Shape::orientation)
675                .collect();
676        let Some(&first) = uses.first() else {
677            continue;
678        };
679        if uses.iter().any(|o| *o != first)
680            || !matches!(first, Orientation::Forward | Orientation::Reversed)
681        {
682            continue;
683        }
684        if (first == Orientation::Forward) == !side.reversed {
685            disagree += 1;
686        } else {
687            agree += 1;
688        }
689    }
690    if disagree > agree {
691        order.reverse();
692        for side in sides.iter_mut() {
693            side.reversed = !side.reversed;
694        }
695    }
696    Ok(())
697}
698
699/// Points round the loop in its walking order.
700fn loop_points(sides: &[Side], order: &[usize], tol: Tolerances) -> OgeomResult<Vec<Point>> {
701    let mut out = Vec::with_capacity(order.len() * OUTLINE_SAMPLES);
702    for &i in order {
703        let side = &sides[i];
704        for k in 0..OUTLINE_SAMPLES {
705            #[expect(
706                clippy::cast_precision_loss,
707                reason = "a sample index, far below the mantissa"
708            )]
709            let mut f = k as f64 / OUTLINE_SAMPLES as f64;
710            if side.reversed {
711                f = 1.0 - f;
712            }
713            out.push(side.curve.point_at(side.parameter(f), tol)?);
714        }
715    }
716    Ok(out)
717}
718
719/// The plane a closed loop spans: its vector area's normal, which the loop
720/// turns counter-clockwise about, through its centroid, with `e1` along the
721/// loop's widest spread in the plane.
722fn frame_of(outline: &[Point], tol: Tolerances) -> OgeomResult<PlaneFrame> {
723    let Ok(origin) = Point::centroid(outline) else {
724        ogeom_bail!(Construction, "the boundary loop has no points");
725    };
726    let mut area = Vector::ZERO;
727    let mut reach = 0.0f64;
728    for (k, p) in outline.iter().enumerate() {
729        let q = outline[(k + 1) % outline.len()];
730        area += (*p - origin).cross(q - origin) * 0.5;
731        reach = reach.max(p.distance(origin));
732    }
733    let magnitude = area.magnitude();
734    if magnitude <= 1e-9 * reach * reach || magnitude <= tol.confusion() * tol.confusion() {
735        ogeom_bail!(
736            Construction,
737            "the boundary loop encloses no area seen from any plane"
738        );
739    }
740    let n = area * (1.0 / magnitude);
741    let b1 = Direction::new(n, tol)?.any_perpendicular().vector();
742    let b2 = n.cross(b1);
743    let (mut sxx, mut syy, mut sxy) = (0.0f64, 0.0f64, 0.0f64);
744    for p in outline {
745        let d = *p - origin;
746        let (x, y) = (d.dot(b1), d.dot(b2));
747        sxx += x * x;
748        syy += y * y;
749        sxy += x * y;
750    }
751    let theta = 0.5 * (2.0 * sxy).atan2(sxx - syy);
752    let e1 = b1 * theta.cos() + b2 * theta.sin();
753    let e2 = n.cross(e1);
754    Ok(PlaneFrame { origin, e1, e2, n })
755}
756
757/// Whether a closed polygon crosses itself: any two segments that are not
758/// neighbours crossing.
759fn crosses_itself(polygon: &[Point2]) -> bool {
760    let n = polygon.len();
761    let orient = |a: Point2, b: Point2, c: Point2| (b - a).cross(c - a);
762    for i in 0..n {
763        let (a, b) = (polygon[i], polygon[(i + 1) % n]);
764        for j in (i + 2)..n {
765            if i == 0 && j == n - 1 {
766                continue;
767            }
768            let (c, d) = (polygon[j], polygon[(j + 1) % n]);
769            if orient(a, b, c) * orient(a, b, d) < 0.0 && orient(c, d, a) * orient(c, d, b) < 0.0 {
770                return true;
771            }
772        }
773    }
774    false
775}
776
777/// Whether a point lies inside a closed polygon, by its winding number.
778fn inside(polygon: &[Point2], q: Point2) -> bool {
779    let n = polygon.len();
780    let mut winding = 0i32;
781    for i in 0..n {
782        let (a, b) = (polygon[i], polygon[(i + 1) % n]);
783        let side = (b - a).cross(q - a);
784        if a.y <= q.y {
785            if b.y > q.y && side > 0.0 {
786                winding += 1;
787            }
788        } else if b.y <= q.y && side < 0.0 {
789            winding -= 1;
790        }
791    }
792    winding != 0
793}
794
795/// The points the constraints put inside the hole, each with its share of
796/// the fit's weight.
797fn constraint_points(
798    model: &Model,
799    constraints: &[Shape],
800    tol: Tolerances,
801) -> OgeomResult<Vec<(Point, f64)>> {
802    let mut out = Vec::new();
803    for (k, shape) in constraints.iter().enumerate() {
804        let placement = shape.transform(model.datums())?;
805        match model.kind_of(shape)? {
806            ShapeType::Vertex => {
807                let Some(data) = model.node(shape).and_then(|n| n.data().as_vertex()) else {
808                    ogeom_bail!(Construction, "constraint {k} holds no vertex data");
809                };
810                out.push((placement.apply(data.point), POINT_SHARE));
811            }
812            ShapeType::Edge => {
813                let Some(data) = model.node(shape).and_then(|n| n.data().as_edge()) else {
814                    ogeom_bail!(Construction, "constraint {k} holds no edge data");
815                };
816                let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
817                    ogeom_bail!(Construction, "constraint {k} has no 3D curve");
818                };
819                let Some(curve) = model.geometry().curve(*curve) else {
820                    ogeom_bail!(Dangling, "constraint {k}'s curve is not in this model");
821                };
822                for i in 0..=PER_CURVE {
823                    #[expect(
824                        clippy::cast_precision_loss,
825                        reason = "a sample index, far below the mantissa"
826                    )]
827                    let f = i as f64 / PER_CURVE as f64;
828                    let t = (range.1 - range.0).mul_add(f, range.0);
829                    out.push((placement.apply(curve.point_at(t, tol)?), POINT_SHARE / 4.0));
830                }
831            }
832            other => ogeom_bail!(
833                Construction,
834                "constraint {k} is a {other:?}; a filling passes through vertices and edges"
835            ),
836        }
837    }
838    Ok(out)
839}
840
841/// The basis curve of a forward trim, whose parameter is the trim's own.
842fn untrimmed(curve: &Curve) -> &Curve {
843    match curve {
844        Curve::Trimmed(t) if !t.is_reversed() => untrimmed(t.basis()),
845        other => other,
846    }
847}
848
849/// The side's curve seen in the plane, at the curve's own parameter: exact
850/// where the curve is a line, a circle or ellipse, or a B-spline (an affine
851/// image of each is a curve of the same form), checked against the curve
852/// before it is trusted; fitted at the curve's parameters otherwise.
853fn trace(
854    side: &Side,
855    frame: &PlaneFrame,
856    tolerance: f64,
857    tol: Tolerances,
858) -> OgeomResult<PlanarCurve> {
859    let truth = |t: f64| -> OgeomResult<Point2> { Ok(frame.chart(side.curve.point_at(t, tol)?)) };
860    let (t0, t1) = side.range;
861    let exact: Option<PlanarCurve> = match untrimmed(&side.curve) {
862        Curve::BSpline(spline) => {
863            let mut control = Vec::with_capacity(spline.control_points().len());
864            for w in spline.control_points() {
865                control.push(Weighted::new(frame.chart(w.point()), w.weight, tol)?);
866            }
867            BSpline2d::rational(spline.knots().clone(), control)
868                .ok()
869                .map(PlanarCurve::BSpline)
870        }
871        _ => match side.curve.kind() {
872            CurveKind::Line => {
873                let (q0, q1) = (truth(t0)?, truth(t1)?);
874                let d = (q1 - q0) * (1.0 / (t1 - t0));
875                let c = q0 - d * t0;
876                Trig2d::new(c, d, Vector2::ZERO, Vector2::ZERO, side.range)
877                    .ok()
878                    .map(PlanarCurve::Trig)
879            }
880            CurveKind::Circle | CurveKind::Ellipse => {
881                // Thirds rather than ends and middle: a full circle's ends
882                // are one point.
883                let ts = [
884                    t0,
885                    (t1 - t0).mul_add(1.0 / 3.0, t0),
886                    (t1 - t0).mul_add(2.0 / 3.0, t0),
887                ];
888                let rows = ts.map(|t| [1.0, t.cos(), t.sin()]);
889                let values = [truth(ts[0])?, truth(ts[1])?, truth(ts[2])?];
890                match (
891                    solve3(rows, values.map(|q| q.x)),
892                    solve3(rows, values.map(|q| q.y)),
893                ) {
894                    (Some(x), Some(y)) => Trig2d::new(
895                        Point2::new(x[0], y[0]),
896                        Vector2::ZERO,
897                        Vector2::new(x[1], y[1]),
898                        Vector2::new(x[2], y[2]),
899                        side.range,
900                    )
901                    .ok()
902                    .map(PlanarCurve::Trig),
903                    _ => None,
904                }
905            }
906            _ => None,
907        },
908    };
909    if let Some(curve) = exact {
910        let mut worst = 0.0f64;
911        let mut scale = 1.0f64;
912        for k in 0..=32 {
913            let t = (t1 - t0).mul_add(f64::from(k) / 32.0, t0);
914            let q = truth(t)?;
915            scale = scale.max(q.to_vector().magnitude());
916            worst = worst.max(curve.point_at(t, tol)?.distance(q));
917        }
918        if worst <= 1e-12 * scale + 1e-3 * tol.confusion() {
919            return Ok(curve);
920        }
921    }
922    const SAMPLES: usize = 96;
923    let mut parameters = Vec::with_capacity(SAMPLES + 1);
924    let mut points = Vec::with_capacity(SAMPLES + 1);
925    for k in 0..=SAMPLES {
926        #[expect(
927            clippy::cast_precision_loss,
928            reason = "a sample index, far below the mantissa"
929        )]
930        let t = (t1 - t0).mul_add(k as f64 / SAMPLES as f64, t0);
931        parameters.push(t);
932        points.push(truth(t)?);
933    }
934    let fitted = ogeom_geom::fit::fit_points_2d_at(&parameters, &points, 3, tolerance * 1e-2, tol)?;
935    Ok(PlanarCurve::BSpline(fitted.curve))
936}
937
938/// Solve a 3x3 system by Cramer's rule; `None` where it is singular.
939fn solve3(rows: [[f64; 3]; 3], rhs: [f64; 3]) -> Option<[f64; 3]> {
940    let det = |m: [[f64; 3]; 3]| {
941        m[0][0] * m[1][1].mul_add(m[2][2], -m[1][2] * m[2][1])
942            - m[0][1] * m[1][0].mul_add(m[2][2], -m[1][2] * m[2][0])
943            + m[0][2] * m[1][0].mul_add(m[2][1], -m[1][1] * m[2][0])
944    };
945    let d = det(rows);
946    if d.abs() < 1e-12 {
947        return None;
948    }
949    let mut out = [0.0; 3];
950    for (c, slot) in out.iter_mut().enumerate() {
951        let mut m = rows;
952        for r in 0..3 {
953            m[r][c] = rhs[r];
954        }
955        *slot = det(m) / d;
956    }
957    Some(out)
958}
959
960/// The bending energy's weight against the conditions.
961///
962/// The fit minimises `Q + λ·E`. `Q` sums the conditions' squared
963/// residuals, each free of units (a position over the patch size `size`, a
964/// slope as it is, a curvature times `size`) and weighted by its side's
965/// share of the boundary. `E` is the thin-plate energy, also free of units:
966/// about the square of the angle, in radians, the patch turns through.
967/// Scaling the whole problem changes neither, so `λ` means the same at any
968/// size and on any net; only the tolerance sets it.
969///
970/// For any surface `s` the net can carry, `Q(fit) + λ·E(fit) <= Q(s) +
971/// λ·E(s)`: the energy costs the conditions at most `λ·E(s)`. With `ε` the
972/// tolerance in the residuals' own units (the strictest of `tolerance /
973/// size` for positions, `tolerance` for G1 slopes and `tolerance · size`
974/// for G2 curvatures), `λ = (BUDGET·ε)²` holds that cost to a tenth of the
975/// tolerance, root mean square, per radian of turning, and gives the energy
976/// the largest weight the bound allows, so the energy rather than the
977/// conditions' approximation error settles every control the conditions
978/// reach only weakly.
979///
980/// The same bound says when a net is too coarse. Where the conditions'
981/// residual `Q` exceeds `λ`, the fit would bend the interior by up to a
982/// unit of energy to save that residual, so the hole's middle follows the
983/// boundary's approximation error (on a coarse net every control reaches
984/// the boundary, and the middle dips or bulges). Such a net is refined
985/// even when its sides meet the tolerance; where no net gets clear of it,
986/// the finest one that met the tolerance is taken.
987fn smoothing_for(sides: &[Side], tolerance: f64, size: f64) -> f64 {
988    let order = sides.iter().map(|s| s.order).max().unwrap_or(0);
989    let mut eps = tolerance / size;
990    if order >= 1 {
991        eps = eps.min(tolerance);
992    }
993    if order >= 2 {
994        eps = eps.min(tolerance * size);
995    }
996    (BUDGET * eps).powi(2).max(LEAST_SMOOTHING)
997}
998
999/// A side's length, by chords.
1000fn side_length(side: &Side, tol: Tolerances) -> OgeomResult<f64> {
1001    let mut length = 0.0;
1002    let mut previous = side.curve.point_at(side.range.0, tol)?;
1003    for k in 1..=OUTLINE_SAMPLES {
1004        #[expect(
1005            clippy::cast_precision_loss,
1006            reason = "a sample index, far below the mantissa"
1007        )]
1008        let f = k as f64 / OUTLINE_SAMPLES as f64;
1009        let p = side.curve.point_at(side.parameter(f), tol)?;
1010        length += p.distance(previous);
1011        previous = p;
1012    }
1013    Ok(length)
1014}
1015
1016/// The fit's conditions along one side: positions always, tangent planes
1017/// from G1 on, normal curvatures at G2. Every row carries the side's share
1018/// of the boundary per sample (`share`, its length against the hole's
1019/// `size`) and is scaled to a residual free of units.
1020fn side_conditions(
1021    side: &Side,
1022    share: f64,
1023    size: f64,
1024    samples: usize,
1025    frame: &PlaneFrame,
1026    out: &mut Vec<Condition>,
1027    tol: Tolerances,
1028) -> OgeomResult<()> {
1029    #[expect(
1030        clippy::cast_precision_loss,
1031        reason = "a sample count, far below the mantissa"
1032    )]
1033    let root = (share / samples as f64).max(f64::MIN_POSITIVE).sqrt();
1034    for k in 0..=samples {
1035        #[expect(
1036            clippy::cast_precision_loss,
1037            reason = "a sample index, far below the mantissa"
1038        )]
1039        let t = side.parameter(k as f64 / samples as f64);
1040        let p = side.curve.point_at(t, tol)?;
1041        let at = frame.chart(p);
1042        out.push(Condition {
1043            at,
1044            order: (0, 0),
1045            target: frame.height(p),
1046            weight: root / size,
1047        });
1048        if side.order == 0 {
1049            continue;
1050        }
1051        let (Some((uv, normal)), Some(support)) = (side.support_at(t, tol)?, &side.support) else {
1052            continue;
1053        };
1054        let lift = normal.dot(frame.n);
1055        if lift.abs() < MIN_LIFT {
1056            ogeom_bail!(
1057                Construction,
1058                "side {}'s support stands within {:.1} degrees of square to the \
1059                 plane the boundary spans; the hole is not a height field over it",
1060                side.entry,
1061                lift.abs().asin().to_degrees()
1062            );
1063        }
1064        // The support's normal on the plane's side, and the slopes that
1065        // put the patch's tangent plane on it.
1066        let normal = if lift < 0.0 { normal * -1.0 } else { normal };
1067        let lift = lift.abs();
1068        let (hu, hv) = (-normal.dot(frame.e1) / lift, -normal.dot(frame.e2) / lift);
1069        out.push(Condition {
1070            at,
1071            order: (1, 0),
1072            target: hu,
1073            weight: root,
1074        });
1075        out.push(Condition {
1076            at,
1077            order: (0, 1),
1078            target: hv,
1079            weight: root,
1080        });
1081        if side.order < 2 {
1082            continue;
1083        }
1084        // With the tangent plane fixed, the patch's second fundamental form
1085        // is `n · S_ab = h_ab (normal · n)`: the support's, read off its
1086        // principal curvatures, fixes the three second derivatives.
1087        let curvature = support.surface.curvature_at(uv.x, uv.y, tol)?;
1088        let sense = if curvature.normal.dot_vector(normal) < 0.0 {
1089            -1.0
1090        } else {
1091            1.0
1092        };
1093        let (dmax, dmin) = (
1094            curvature.max_direction.vector(),
1095            curvature.min_direction.vector(),
1096        );
1097        let second = |a: Vector, b: Vector| {
1098            sense
1099                * curvature.max.mul_add(
1100                    a.dot(dmax) * b.dot(dmax),
1101                    curvature.min * a.dot(dmin) * b.dot(dmin),
1102                )
1103                / lift
1104        };
1105        let su = frame.e1 + frame.n * hu;
1106        let sv = frame.e2 + frame.n * hv;
1107        for (order, target) in [
1108            ((2, 0), second(su, su)),
1109            ((1, 1), second(su, sv)),
1110            ((0, 2), second(sv, sv)),
1111        ] {
1112            out.push(Condition {
1113                at,
1114                order,
1115                target,
1116                weight: root * size,
1117            });
1118        }
1119    }
1120    Ok(())
1121}
1122
1123/// Measure one side at `stations` spread over its edge: the gap between
1124/// the edge's curve and the surface through the pcurve, and against the
1125/// support the angle between normals and the difference of normal
1126/// curvatures square to the edge.
1127fn measure_side(
1128    side: &Side,
1129    pcurve: &PlanarCurve,
1130    surface: &BSplineSurface,
1131    stations: usize,
1132    tol: Tolerances,
1133) -> OgeomResult<FillSide> {
1134    let (mut gap, mut angle, mut curvature) = (0.0f64, 0.0f64, None::<f64>);
1135    for k in 0..stations {
1136        #[expect(
1137            clippy::cast_precision_loss,
1138            reason = "a station index, far below the mantissa"
1139        )]
1140        let t = side.parameter(k as f64 / (stations - 1) as f64);
1141        let p = side.curve.point_at(t, tol)?;
1142        let uv = pcurve.point_at(t, tol)?;
1143        gap = gap.max(surface.point_at(uv.x, uv.y, tol)?.distance(p));
1144        let (Some((suv, theirs)), Some(support)) = (side.support_at(t, tol)?, &side.support) else {
1145            continue;
1146        };
1147        let ours = surface.normal_at(uv.x, uv.y, tol)?.vector();
1148        angle = angle.max(ours.cross(theirs).magnitude().atan2(ours.dot(theirs).abs()));
1149        let across = ours.cross(side.curve.d1_at(t, tol)?);
1150        let signed = |c: SurfaceCurvature| -> Option<f64> {
1151            let k = c.normal_curvature(across)?;
1152            Some(if c.normal.dot_vector(ours) < 0.0 {
1153                -k
1154            } else {
1155                k
1156            })
1157        };
1158        let a = surface.curvature_at(uv.x, uv.y, tol).ok().and_then(signed);
1159        let b = support
1160            .surface
1161            .curvature_at(suv.x, suv.y, tol)
1162            .ok()
1163            .and_then(signed);
1164        if let (Some(a), Some(b)) = (a, b) {
1165            curvature = Some(curvature.unwrap_or(0.0).max((a - b).abs()));
1166        }
1167    }
1168    let supported = side.support.is_some();
1169    Ok(FillSide {
1170        edge: side.edge.clone(),
1171        gap,
1172        angle: supported.then_some(angle),
1173        curvature: curvature.filter(|_| supported),
1174        stations,
1175    })
1176}
1177
1178/// The first deviation past `tolerance`, described, or `None` when every
1179/// side and constraint meets it.
1180fn first_miss(
1181    sides: &[Side],
1182    reports: &[FillSide],
1183    constraint_gap: f64,
1184    tolerance: f64,
1185) -> Option<String> {
1186    for (side, report) in sides.iter().zip(reports) {
1187        let i = side.entry;
1188        if report.gap > tolerance {
1189            return Some(format!("side {i} stands {} off its edge", report.gap));
1190        }
1191        if side.order >= 1 {
1192            let angle = report.angle.unwrap_or(f64::INFINITY);
1193            if angle > tolerance {
1194                return Some(format!(
1195                    "side {i} meets its support {angle} radians from tangent"
1196                ));
1197            }
1198        }
1199        if side.order >= 2 {
1200            match report.curvature {
1201                Some(c) if c <= tolerance => {}
1202                Some(c) => {
1203                    return Some(format!(
1204                        "side {i}'s normal curvature differs from its support's by {c}"
1205                    ));
1206                }
1207                None => {
1208                    return Some(format!(
1209                        "side {i}'s curvature could not be read on both surfaces"
1210                    ));
1211                }
1212            }
1213        }
1214    }
1215    (constraint_gap > tolerance)
1216        .then(|| format!("a constraint stands {constraint_gap} off the surface"))
1217}
1218
1219/// Build the face on the fitted patch, bounded by the sides' own edges or
1220/// their stand-ins, each given its pcurve on it and a tolerance that holds
1221/// its gap.
1222fn build(
1223    model: &mut Model,
1224    sides: &mut [Side],
1225    order: &[usize],
1226    traces: &[PlanarCurve],
1227    reports: &[FillSide],
1228    surface: BSplineSurface,
1229    tol: Tolerances,
1230) -> OgeomResult<Shape> {
1231    stand_in(model, sides, order, tol)?;
1232    let walk: Vec<Shape> = order
1233        .iter()
1234        .map(|&i| {
1235            let side = &sides[i];
1236            if side.reversed {
1237                side.edge.reversed()
1238            } else {
1239                side.edge.clone()
1240            }
1241        })
1242        .collect();
1243    let wire = make_wire(model, &walk, tol)?.shape;
1244    let face = make_face(model, SurfaceGeometry::BSpline(surface), &[wire], tol)?.shape;
1245    let Some(NodeData::Face(data)) = model.node(&face).map(|n| n.data()) else {
1246        ogeom_bail!(Dangling, "the face just built is not in this model");
1247    };
1248    let surface_id = data.surface;
1249    for ((side, pcurve), report) in sides.iter().zip(traces).zip(reports) {
1250        attach_pcurve(
1251            model,
1252            &side.edge,
1253            pcurve.clone(),
1254            surface_id,
1255            Location::identity(),
1256            side.range,
1257        )?;
1258        if report.gap > side.edge_tolerance {
1259            // The edge owns the gap, and so do the vertices bounding it.
1260            let widened = Tolerance::new(report.gap + tol.confusion())?;
1261            model.widen(&side.edge, widened)?;
1262            if let Some((a, b)) = edge_vertices(model, &side.edge)? {
1263                model.widen(&a, widened)?;
1264                model.widen(&b, widened)?;
1265            }
1266        }
1267    }
1268    Ok(face)
1269}
1270
1271/// Give each side the edge the face is bounded by: its own where it is not
1272/// placed and both its corners are vertex nodes it shares with its
1273/// neighbours, otherwise a new edge on its curve where it stands, between
1274/// the loop's corner vertices. A corner keeps the shared vertex where there
1275/// is one, unplaced; elsewhere it is a new vertex midway between the two
1276/// ends, reaching both.
1277fn stand_in(
1278    model: &mut Model,
1279    sides: &mut [Side],
1280    order: &[usize],
1281    tol: Tolerances,
1282) -> OgeomResult<()> {
1283    let n = order.len();
1284    // Corner k is where side `order[k]` ends its walk and `order[k + 1]`
1285    // starts its.
1286    let mut corners: Vec<(Shape, bool)> = Vec::with_capacity(n);
1287    for k in 0..n {
1288        let (a, b) = (&sides[order[k]], &sides[order[(k + 1) % n]]);
1289        let (a0, a1) = ends_of(model, a)?;
1290        let (b0, b1) = ends_of(model, b)?;
1291        let (arrive, leave) = (
1292            if a.reversed { a0 } else { a1 },
1293            if b.reversed { b1 } else { b0 },
1294        );
1295        let (from, to) = (
1296            a.curve
1297                .point_at(if a.reversed { a.range.0 } else { a.range.1 }, tol)?,
1298            b.curve
1299                .point_at(if b.reversed { b.range.1 } else { b.range.0 }, tol)?,
1300        );
1301        if arrive.vertex.is_same(&leave.vertex) && arrive.vertex.location().is_identity() {
1302            corners.push((arrive.vertex, true));
1303            continue;
1304        }
1305        let vertex = make_vertex(model, from.midpoint(to)).shape;
1306        let reach = (0.5 * from.distance(to) + tol.confusion())
1307            .max(a.edge_tolerance)
1308            .max(b.edge_tolerance);
1309        model.widen(&vertex, Tolerance::new(reach)?)?;
1310        corners.push((vertex, false));
1311    }
1312    for k in 0..n {
1313        let side = &sides[order[k]];
1314        let (start, end) = (&corners[(k + n - 1) % n], &corners[k]);
1315        if !side.placed && start.1 && end.1 {
1316            continue;
1317        }
1318        let (from, to) = if side.reversed {
1319            (&end.0, &start.0)
1320        } else {
1321            (&start.0, &end.0)
1322        };
1323        let edge = make_edge_between(model, side.curve.clone(), side.range, from, to, tol)?.shape;
1324        model.widen(&edge, Tolerance::new(side.edge_tolerance)?)?;
1325        sides[order[k]].edge = edge;
1326    }
1327    Ok(())
1328}