Skip to main content

axiolid_construct/
profile.rs

1//! Profile -> 2D polygon rings, then triangles.
2//!
3//! Curved boundaries are flattened to chords under an explicit
4//! `TessellationOptions`-style budget; nothing here invents a default
5//! tolerance.
6//!
7//! Triangulation of rings-with-holes is the crate's own certified ear clipper
8//! (`ring_triangulation`, ADR 0083, replacing the `earcut` adoption of ADR
9//! 0015 after it left T-junctions between holes in one band, #253).
10//! `axiolid_reference::triangulate_simple` stays the differential oracle for
11//! the hole-free case.
12//!
13//! [`profile_rings`] flattens each profile on its own. Profiles that change
14//! between stations and pair segment for segment are flattened together by
15//! [`paired_profile_rings`] (#361, `paired`), which also reports the
16//! deviation of every section interpolated between them.
17
18use axiolid_contracts::{GeomError, GeomResult};
19use axiolid_core::{Point2, Scalar, Tolerance};
20use axiolid_curve::Curve2;
21use axiolid_profile::{CircleProfile, EllipseProfile, Profile, RectangleProfile};
22
23mod paired;
24pub use paired::{paired_profile_rings, PairedRings};
25
26/// Outer ring plus holes, all CCW/CW normalised by the caller's contract:
27/// outer counter-clockwise, holes clockwise.
28#[derive(Debug, Clone, Default, PartialEq)]
29pub struct Rings {
30    /// Outer boundary, counter-clockwise.
31    pub outer: Vec<Point2>,
32    /// Inner boundaries, clockwise.
33    pub holes: Vec<Vec<Point2>>,
34}
35
36/// Flatten a profile into rings under an explicit chord budget.
37///
38/// Only the families a format adapter currently emits are handled. Everything
39/// else returns `Unsupported` rather than a silently wrong approximation --
40/// a wrong wall is far more expensive than a missing one.
41pub fn profile_rings(
42    profile: &Profile,
43    chord_error: Scalar,
44    tolerance: Tolerance,
45) -> GeomResult<Rings> {
46    match profile {
47        // Rounded corners are exact arcs in the contour, chorded like any
48        // other arc; a sharp rectangle keeps its four exact corners.
49        Profile::Rectangle(r) if r.outer_radius.is_some() || r.inner_radius.is_some() => {
50            let contour = crate::section_lower::rectangle_contour(r)?;
51            contour_rings(&contour, chord_error, tolerance)
52        }
53        Profile::Rectangle(r) => rectangle_rings(r, chord_error, tolerance),
54        // Structural sections (#193): the same exact contour the exact
55        // extruder builds, fillets and toe radii as arcs, chorded here.
56        Profile::Section(section) => {
57            let contour = crate::section_lower::section_contour(section)?;
58            contour_rings(&contour, chord_error, tolerance)
59        }
60        Profile::Circle(c) => circle_rings(c, chord_error),
61        // Reachable only since curve evaluation moved into `axiolid-reference`:
62        // an ellipse has no closed-form segment count, so the old fixed-count
63        // flattener could not express it at all.
64        Profile::Ellipse(e) => ellipse_rings(e, chord_error),
65        Profile::Contour(c) => contour_rings(c, chord_error, tolerance),
66        Profile::Derived { basis, transform } => {
67            let mut rings = profile_rings(basis, chord_error, tolerance)?;
68            apply2(&mut rings.outer, transform);
69            for hole in &mut rings.holes {
70                apply2(hole, transform);
71            }
72            // A mirroring placement reverses ring orientation; the extruder's
73            // walls rely on outer CCW / holes CW, so restore it here
74            // rather than letting a silently inside-out solid reach them.
75            if transform.matrix2.determinant() < 0.0 {
76                rings.outer.reverse();
77                for hole in &mut rings.holes {
78                    hole.reverse();
79                }
80            }
81            Ok(rings)
82        }
83        Profile::CenterLine(cl) => {
84            crate::center_line::center_line_rings(cl, chord_error, tolerance, open_contour_points)
85        }
86        other => Err(GeomError::Unsupported {
87            backend: crate::BACKEND_ID,
88            operation: axiolid_contracts::Operation::ProfileTriangulation,
89        })
90        .inspect_err(|_| {
91            let _ = other;
92        }),
93    }
94}
95
96/// How far a profile's exact boundary may lie from the rings
97/// [`profile_rings`] flattens it to (#232).
98///
99/// `Bounded(d)`: every point of the exact boundary is within `d` of an edge
100/// of the rings, so every point of the exact region is within `d` of the
101/// rings' region too (a point between an arc and its chord is in the
102/// convex hull of the two, hence within the arc's distance of the chord).
103/// `Unbounded` names the part that has no certified bound.
104#[non_exhaustive]
105#[derive(Debug, Clone, Copy, PartialEq)]
106pub enum ProfileDeviation {
107    /// A certified upper bound, in the profile's own units.
108    Bounded(Scalar),
109    /// No certified bound; the reason names the family.
110    Unbounded(&'static str),
111}
112
113impl ProfileDeviation {
114    /// The worse of two deviations: unbounded wins, else the larger bound.
115    #[must_use]
116    pub fn max(self, other: Self) -> Self {
117        match (self, other) {
118            (Self::Unbounded(reason), _) | (_, Self::Unbounded(reason)) => Self::Unbounded(reason),
119            (Self::Bounded(a), Self::Bounded(b)) => Self::Bounded(a.max(b)),
120        }
121    }
122
123    fn scaled(self, factor: Scalar) -> Self {
124        match self {
125            Self::Bounded(d) => Self::Bounded(d * factor),
126            unbounded => unbounded,
127        }
128    }
129
130    fn plus(self, extra: Scalar) -> Self {
131        match self {
132            Self::Bounded(d) => Self::Bounded(d + extra),
133            unbounded => unbounded,
134        }
135    }
136}
137
138/// The deviation of [`profile_rings`]`(profile, chord_error, tolerance)`
139/// from the exact profile (#232).
140///
141/// The rings are rebuilt here, deterministically, and three things are
142/// read off them:
143///
144/// - each curved segment's family: one [`axiolid_reference::curve::flatten2`]
145///   certifies ([`axiolid_reference::bound::certifies_flattening2`]) is
146///   within `chord_error` of its chords; any other is `Unbounded`;
147/// - the points merged as coincident (within the linear tolerance of the
148///   point kept): a merged point moves the ring by at most its distance to
149///   the kept one, which is added;
150/// - a derived profile's transform, whose largest stretch scales the
151///   basis's bound.
152///
153/// # Errors
154///
155/// As [`profile_rings`] for the same arguments.
156pub fn profile_deviation(
157    profile: &Profile,
158    chord_error: Scalar,
159    tolerance: Tolerance,
160) -> GeomResult<ProfileDeviation> {
161    Ok(match profile {
162        Profile::Rectangle(r) if r.outer_radius.is_some() || r.inner_radius.is_some() => {
163            contour_deviation(
164                &crate::section_lower::rectangle_contour(r)?,
165                chord_error,
166                tolerance,
167            )?
168        }
169        Profile::Rectangle(r) => {
170            rectangle_rings(r, chord_error, tolerance)?;
171            match r.thickness {
172                // The hole is dropped when one of its extents vanishes
173                // within the tolerance; its slit walls then lie inside the
174                // solid, far from every ring.
175                Some(t) if tolerance.eq(r.x / 2.0 - t, 0.0) || tolerance.eq(r.y / 2.0 - t, 0.0) => {
176                    ProfileDeviation::Unbounded("hollow rectangle whose hole collapses")
177                }
178                _ => ProfileDeviation::Bounded(0.0),
179            }
180        }
181        Profile::Section(section) => contour_deviation(
182            &crate::section_lower::section_contour(section)?,
183            chord_error,
184            tolerance,
185        )?,
186        Profile::Circle(c) => {
187            circle_rings(c, chord_error)?;
188            ProfileDeviation::Bounded(chord_error)
189        }
190        Profile::Ellipse(e) => {
191            ellipse_rings(e, chord_error)?;
192            ProfileDeviation::Bounded(chord_error)
193        }
194        Profile::Contour(c) => contour_deviation(c, chord_error, tolerance)?,
195        Profile::Derived { basis, transform } => {
196            profile_deviation(basis, chord_error, tolerance)?.scaled(stretch2(transform))
197        }
198        Profile::CenterLine(cl) => {
199            profile_rings(profile, chord_error, tolerance)?;
200            let mut merged: Scalar = 0.0;
201            contour_points_measured(&cl.path, chord_error, tolerance, &mut merged, false)?;
202            let straight = cl
203                .path
204                .segments
205                .iter()
206                .all(|segment| matches!(segment.curve, Curve2::Line(_) | Curve2::Polyline(_)));
207            if straight && merged == 0.0 {
208                ProfileDeviation::Bounded(0.0)
209            } else {
210                ProfileDeviation::Unbounded("centre-line profile along a curved or merged path")
211            }
212        }
213        _ => {
214            profile_rings(profile, chord_error, tolerance)?;
215            ProfileDeviation::Unbounded("profile family")
216        }
217    })
218}
219
220/// The largest stretch of a 2D affine map's linear part: its largest
221/// singular value, `sqrt((F + sqrt(F^2 - 4 D^2)) / 2)` with `F` the squared
222/// Frobenius norm and `D` the determinant, inflated against rounding.
223fn stretch2(transform: &axiolid_core::Transform2) -> Scalar {
224    let m = transform.matrix2;
225    let f = m.x_axis.length_squared() + m.y_axis.length_squared();
226    let d = m.determinant();
227    let disc = (f * f - 4.0 * d * d).max(0.0).sqrt();
228    (0.5 * (f + disc)).sqrt() * (1.0 + 1e-12)
229}
230
231/// [`ProfileDeviation`] of one contour with holes.
232fn contour_deviation(
233    c: &axiolid_profile::ContourProfile,
234    chord_error: Scalar,
235    tolerance: Tolerance,
236) -> GeomResult<ProfileDeviation> {
237    let mut deviation = ProfileDeviation::Bounded(0.0);
238    for contour in core::iter::once(&c.outer).chain(&c.holes) {
239        let mut merged: Scalar = 0.0;
240        contour_points_measured(contour, chord_error, tolerance, &mut merged, true)?;
241        let mut chords = ProfileDeviation::Bounded(0.0);
242        for segment in &contour.segments {
243            chords = chords.max(segment_deviation(&segment.curve, chord_error));
244        }
245        deviation = deviation.max(chords.plus(merged));
246    }
247    Ok(deviation)
248}
249
250/// A segment's chord deviation: none for a straight one, the budget for a
251/// family the flattener certifies, else unbounded by family.
252fn segment_deviation(curve: &Curve2, chord_error: Scalar) -> ProfileDeviation {
253    match curve {
254        Curve2::Line(_) | Curve2::Polyline(_) => ProfileDeviation::Bounded(0.0),
255        other if axiolid_reference::bound::certifies_flattening2(other) => {
256            ProfileDeviation::Bounded(chord_error)
257        }
258        Curve2::Intrinsic(_) => ProfileDeviation::Unbounded("intrinsic (clothoid) profile segment"),
259        Curve2::Chain(_) => {
260            ProfileDeviation::Unbounded("arc-length chain profile segment with an unbounded piece")
261        }
262        Curve2::BSpline(_) => {
263            ProfileDeviation::Unbounded("B-spline profile segment with a non-positive weight")
264        }
265        _ => ProfileDeviation::Unbounded("profile segment family"),
266    }
267}
268
269/// An exact contour with holes, flattened under the chord budget.
270fn contour_rings(
271    c: &axiolid_profile::ContourProfile,
272    chord_error: Scalar,
273    tolerance: Tolerance,
274) -> GeomResult<Rings> {
275    let outer = contour_points(&c.outer, chord_error, tolerance)?;
276    let mut holes = Vec::with_capacity(c.holes.len());
277    for hole in &c.holes {
278        holes.push(contour_points(hole, chord_error, tolerance)?);
279    }
280    Ok(orient_rings(outer, holes))
281}
282
283/// Rectangle, optionally hollow, with sharp corners (rounded ones go
284/// through the exact contour).
285fn rectangle_rings(
286    r: &RectangleProfile,
287    _chord_error: Scalar,
288    tolerance: Tolerance,
289) -> GeomResult<Rings> {
290    if !(r.x.is_finite() && r.y.is_finite()) || r.x <= 0.0 || r.y <= 0.0 {
291        return Err(GeomError::InvalidInput(format!(
292            "rectangle profile must have positive finite extents, got {} x {}",
293            r.x, r.y
294        )));
295    }
296    let (hx, hy) = (r.x / 2.0, r.y / 2.0);
297    let outer = vec![
298        Point2::new(-hx, -hy),
299        Point2::new(hx, -hy),
300        Point2::new(hx, hy),
301        Point2::new(-hx, hy),
302    ];
303    let mut holes = Vec::new();
304    if let Some(t) = r.thickness {
305        if t <= 0.0 || 2.0 * t >= r.x || 2.0 * t >= r.y {
306            return Err(GeomError::InvalidInput(format!(
307                "hollow rectangle wall thickness {t} does not fit inside {} x {}",
308                r.x, r.y
309            )));
310        }
311        let (ix, iy) = (hx - t, hy - t);
312        if !tolerance.eq(ix, 0.0) && !tolerance.eq(iy, 0.0) {
313            // Clockwise: opposite winding to the outer ring marks it a hole.
314            holes.push(vec![
315                Point2::new(-ix, -iy),
316                Point2::new(-ix, iy),
317                Point2::new(ix, iy),
318                Point2::new(ix, -iy),
319            ]);
320        }
321    }
322    Ok(Rings { outer, holes })
323}
324
325/// Circle, optionally annular.
326fn circle_rings(c: &CircleProfile, chord_error: Scalar) -> GeomResult<Rings> {
327    if !c.radius.is_finite() || c.radius <= 0.0 {
328        return Err(GeomError::InvalidInput(format!(
329            "circle profile radius must be positive and finite, got {}",
330            c.radius
331        )));
332    }
333    // Flattened by the scalar evaluator so a parameterized circle and a
334    // contour-declared circular arc obey exactly the same chord budget.
335    let outer = flatten_circle(c.radius, chord_error)?;
336    let mut holes = Vec::new();
337    if let Some(t) = c.thickness {
338        if t <= 0.0 || t >= c.radius {
339            return Err(GeomError::InvalidInput(format!(
340                "annulus wall thickness {t} does not fit inside radius {}",
341                c.radius
342            )));
343        }
344        let inner = c.radius - t;
345        let mut ring = flatten_circle(inner, chord_error)?;
346        ring.reverse(); // clockwise
347        holes.push(ring);
348    }
349    Ok(Rings { outer, holes })
350}
351
352/// Ellipse profile, flattened under the chord budget.
353fn ellipse_rings(e: &EllipseProfile, chord_error: Scalar) -> GeomResult<Rings> {
354    if !e.semi_axis_x.is_finite() || e.semi_axis_x <= 0.0 {
355        return Err(GeomError::InvalidInput(format!(
356            "ellipse semi-axis x must be positive and finite, got {}",
357            e.semi_axis_x
358        )));
359    }
360    if !e.semi_axis_y.is_finite() || e.semi_axis_y <= 0.0 {
361        return Err(GeomError::InvalidInput(format!(
362            "ellipse semi-axis y must be positive and finite, got {}",
363            e.semi_axis_y
364        )));
365    }
366    use axiolid_core::{Frame2, Interval, Vec2};
367    use axiolid_curve::{Curve2, Ellipse2};
368
369    let curve = Curve2::Ellipse(Ellipse2 {
370        frame: Frame2 {
371            origin: Point2::new(0.0, 0.0),
372            x: Vec2::new(1.0, 0.0),
373            y: Vec2::new(0.0, 1.0),
374        },
375        semi_axis_x: e.semi_axis_x,
376        semi_axis_y: e.semi_axis_y,
377    });
378    let mut ring = axiolid_reference::curve::flatten2(
379        &curve,
380        Interval {
381            start: 0.0,
382            end: core::f64::consts::TAU,
383        },
384        chord_error,
385        MAX_SUBDIVISION_DEPTH,
386    )?;
387    // Drop the duplicate closing vertex; a ring is implicitly closed.
388    ring.pop();
389    Ok(Rings {
390        outer: ring,
391        holes: Vec::new(),
392    })
393}
394
395/// Flatten a full circle of `radius` under the chord budget.
396///
397/// The closing duplicate of the start point is dropped: a ring is implicitly
398/// closed, and a repeated vertex would create a zero-length edge that the
399/// extruder would turn into a degenerate side quad.
400fn flatten_circle(radius: Scalar, chord_error: Scalar) -> GeomResult<Vec<Point2>> {
401    use axiolid_core::{Frame2, Interval, Vec2};
402    use axiolid_curve::{Circle2, Curve2};
403
404    let curve = Curve2::Circle(Circle2 {
405        frame: Frame2 {
406            origin: Point2::new(0.0, 0.0),
407            x: Vec2::new(1.0, 0.0),
408            y: Vec2::new(0.0, 1.0),
409        },
410        radius,
411    });
412    let flatten = |start: Scalar| {
413        axiolid_reference::curve::flatten2(
414            &curve,
415            Interval {
416                start,
417                end: start + core::f64::consts::TAU,
418            },
419            chord_error,
420            MAX_SUBDIVISION_DEPTH,
421        )
422    };
423    // Bisection puts a point at every quarter turn. Started half a step
424    // later, the same chords put a chord's middle there instead, strictly
425    // inside the circle: a void tangent to a face along an axis direction,
426    // as openings are, then leaves a sliver of material rather than a
427    // chord point on the face, which would pinch the solid there (#194).
428    let steps = flatten(0.0)?.len().saturating_sub(1).max(1);
429    let mut ring = flatten(core::f64::consts::PI / steps as Scalar)?;
430    ring.pop();
431    Ok(ring)
432}
433
434/// Triangulate rings into a flat index buffer over a single vertex list.
435///
436/// The returned vertices are the concatenation `outer ++ holes`, unchanged,
437/// and every one of them is a corner of some triangle. Rings may be given
438/// either way round; the triangles are counter-clockwise. The result is
439/// certified before it is returned (#253): every ring edge is a triangle
440/// edge, every other edge is shared by exactly two triangles, so no
441/// triangle edge runs past a vertex and an extrusion of the caps closes.
442///
443/// # Errors
444///
445/// `InvalidInput`, naming the ring, for rings that do not bound a polygon
446/// with holes: fewer than three or non-finite vertices, a repeated vertex,
447/// a ring that folds back on or crosses itself or runs along a seam both
448/// ways (a keyhole, #270), holes that overlap or touch
449/// each other or the outer ring, a hole outside the outer ring or inside
450/// another hole. `Degenerate` if the triangulation cannot be certified.
451///
452/// Touching rings are refused because this is the cap of a solid
453/// ([`PinchPolicy::Refuse`]); a 2D region or a planar surface patch uses
454/// [`triangulate_with`] and [`PinchPolicy::Accept`].
455pub fn triangulate(rings: &Rings) -> GeomResult<(Vec<Point2>, Vec<[u32; 3]>)> {
456    triangulate_with(rings, PinchPolicy::Refuse)
457}
458
459/// How a triangulation treats rings that touch at a single point (#262):
460/// a hole touching the outer ring or another hole at a vertex, an outer
461/// ring pinched at a vertex, or a vertex lying on another ring's edge.
462///
463/// The region such rings bound is valid, but the surface an extrusion
464/// builds over it is not a two-manifold: the walls of the two rings meet
465/// along one edge, which four faces then share. So solids refuse it and
466/// regions and surface patches accept it. The same holds for a ring that
467/// joins a hole by a seam it runs along both ways (#270): its region is
468/// the outer polygon less the hole, but an extrusion puts two coincident
469/// wall faces on the seam.
470#[non_exhaustive]
471#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
472pub enum PinchPolicy {
473    /// Refuse touching rings and seams with `InvalidInput` naming them. For
474    /// anything that must close into a two-manifold: extrusion and loft
475    /// caps.
476    Refuse,
477    /// Triangulate the region the touching rings bound. For 2D regions and
478    /// planar surface patches: a plan footprint that is a union of shadows
479    /// touching at a corner, a space-boundary surface, a planar face of a
480    /// B-rep (whose edges are the ring edges either way, once a vertex
481    /// inside an edge, which [`ring_touches`] lists, is inserted into the
482    /// neighbouring face's copy of that edge too). A ring joining a hole by
483    /// a seam run both ways (a keyhole, #270) is accepted too.
484    Accept,
485}
486
487/// [`triangulate`] under an explicit [`PinchPolicy`].
488///
489/// Under [`PinchPolicy::Refuse`] this is [`triangulate`]. Under
490/// [`PinchPolicy::Accept`] rings may also touch at single points: where two
491/// edges meet at a point that is a vertex of at least one of them. The
492/// region is then the points inside the outer ring and outside every hole
493/// (for a ring pinched into loops, inside an odd number of its loops),
494/// split into its connected parts at the pinches; each part is triangulated
495/// and the whole is certified as [`triangulate`] is, with each visit of a
496/// pinch point counted as its own vertex. A point several vertices share is
497/// referenced by its first index in `outer ++ holes`, and a vertex on
498/// another ring's edge splits that edge, so the ring edges of the result
499/// are the rings' edges cut at every touching vertex. Rings touching
500/// nowhere triangulate exactly as under [`PinchPolicy::Refuse`].
501///
502/// Under [`PinchPolicy::Accept`] a ring may also join a hole by a seam
503/// (#270): an edge and a later edge of the same ring between the same two
504/// points in opposite directions, which no other edge has, and which no
505/// other edge meets except at its ends -- a keyhole, the outer boundary,
506/// the seam, the hole and the seam back. The two copies cancel: the ring
507/// bounds what its other edges bound, and is split at its seams into the
508/// loops they join, triangulated as above (a hole loop drawn either way
509/// round is a hole; a seam between two loops side by side leaves two
510/// parts). A seam may be a triangle edge, once each way, or none at all;
511/// a planar face of a closed shell uses [`triangulate_shell_face`], which
512/// never makes it one (#283).
513///
514/// # Errors
515///
516/// As [`triangulate`], except that touching at single points and seams
517/// are accepted under [`PinchPolicy::Accept`]. Rings that cross, at a
518/// shared vertex included, or overlap along an edge are refused by name
519/// under both, and so are, under [`PinchPolicy::Accept`], an edge running
520/// back along only part of another, the same edge in one direction twice
521/// or more than twice in all, seams interleaving along their ring instead
522/// of nesting, seams in a row, an edge crossing, touching or running along
523/// a seam away from its ends, and a loop folding back where a seam left
524/// it. [`PinchPolicy::Refuse`] refuses any seam by name.
525pub fn triangulate_with(
526    rings: &Rings,
527    pinches: PinchPolicy,
528) -> GeomResult<(Vec<Point2>, Vec<[u32; 3]>)> {
529    check_ring_sizes(rings)?;
530    crate::ring_triangulation::triangulate_rings(&rings.outer, &rings.holes, pinches)
531}
532
533/// [`triangulate`] for the caps of a mesh solid: slivers are avoided
534/// whenever a triangulation avoids them, and a cap triangle that is flat to
535/// rounding is refused (#415).
536///
537/// A sliver has a doubled area within `tolerance.linear()^2`, the audit's
538/// degeneracy threshold. Ear clipping cuts the other ears first, and the
539/// slivers it still leaves are flipped away where a neighbour allows
540/// (`ring_triangulation::flip`), so a vertex a few ulps off its neighbours'
541/// line, which placement rounds flat, is joined across. A sliver no
542/// triangulation avoids stays, as a fine chord's thin triangles do; only
543/// one that is also flat to rounding ([`crate::ring_triangulation::is_flat`])
544/// is refused.
545///
546/// # Errors
547///
548/// As [`triangulate`]; `Degenerate`, naming the triangle, if a triangle
549/// that is a sliver and flat to rounding is unavoidable.
550pub(crate) fn triangulate_cap(
551    rings: &Rings,
552    tolerance: axiolid_core::Tolerance,
553) -> GeomResult<(Vec<Point2>, Vec<[u32; 3]>)> {
554    check_ring_sizes(rings)?;
555    let flat = tolerance.linear();
556    let (points, triangles) = crate::ring_triangulation::triangulate_rings_unflat(
557        &rings.outer,
558        &rings.holes,
559        PinchPolicy::Refuse,
560        flat,
561    )?;
562    for t in &triangles {
563        let [a, b, c] = t.map(|i| points[i as usize]);
564        if crate::ring_triangulation::is_sliver(a, b, c, flat)
565            && crate::ring_triangulation::is_flat(a, b, c)
566        {
567            return Err(GeomError::Degenerate(format!(
568                "cap triangle over vertices {}, {}, {} is a sliver and flat to rounding \
569                 and no cap triangulation avoids it",
570                t[0], t[1], t[2]
571            )));
572        }
573    }
574    Ok((points, triangles))
575}
576
577/// Refuse an outer ring or a hole of fewer than three vertices.
578fn check_ring_sizes(rings: &Rings) -> GeomResult<()> {
579    if rings.outer.len() < 3 {
580        return Err(GeomError::InvalidInput(format!(
581            "profile outer ring needs at least 3 vertices, got {}",
582            rings.outer.len()
583        )));
584    }
585    for hole in &rings.holes {
586        if hole.len() < 3 {
587            return Err(GeomError::InvalidInput(format!(
588                "profile hole needs at least 3 vertices, got {}",
589                hole.len()
590            )));
591        }
592    }
593    Ok(())
594}
595
596/// A vertex lying inside an edge of a ring, away from both its ends, as
597/// [`triangulate_with`] finds it under [`PinchPolicy::Accept`] (#265).
598///
599/// Indices follow [`triangulate_with`]: ring `0` is the outer ring and ring
600/// `h + 1` hole `h`; edge `k` of a ring runs from its vertex `k` to its
601/// vertex `k + 1` (the last back to the first); `vertex` indexes
602/// `outer ++ holes`.
603#[non_exhaustive]
604#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
605pub struct RingTouch {
606    /// The ring whose edge the vertex lies in.
607    pub ring: usize,
608    /// The edge of that ring.
609    pub edge: usize,
610    /// The vertex, by its index in `outer ++ holes`.
611    pub vertex: usize,
612}
613
614/// Every vertex lying inside another edge of `rings`, decided by the same
615/// exact `orient2d` tests with which [`triangulate_with`] under
616/// [`PinchPolicy::Accept`] inserts it into that edge (#265).
617///
618/// The triangulation then splits that edge at the vertex, so its ring edges
619/// are no longer the rings' edges. A caller whose rings share their edges
620/// with neighbouring patches (the faces of a closed shell) splits the
621/// neighbour's copy of each such edge at the same vertex, or the patches
622/// meet at a T-junction. Validation runs as in [`triangulate_with`] and in
623/// the same order, so the touches come in a deterministic order, each
624/// vertex and edge pair once; nothing is triangulated.
625///
626/// A ring with a seam run both ways (a keyhole, #270) is validated as
627/// [`triangulate_with`] validates it, without its seams: the touches are
628/// those of the loops the seams join, on their edges, which are the ring's
629/// other edges. No vertex lies inside a seam, and a seam's ends, where the
630/// ring meets itself, are shared vertices, not touches.
631///
632/// # Errors
633///
634/// As [`triangulate_with`] under [`PinchPolicy::Accept`] for rings that do
635/// not bound a region: too few, non-finite or repeated vertices, a ring
636/// folding back, rings that cross or overlap along an edge, a seam that is
637/// not one.
638pub fn ring_touches(rings: &Rings) -> GeomResult<Vec<RingTouch>> {
639    crate::ring_triangulation::ring_touches(&rings.outer, &rings.holes).map(|touches| {
640        touches
641            .into_iter()
642            .map(|(ring, edge, vertex)| RingTouch { ring, edge, vertex })
643            .collect()
644    })
645}
646
647/// A triangulation of a planar face of a closed shell, from
648/// [`triangulate_shell_face`] (#283).
649#[non_exhaustive]
650#[derive(Debug, Clone, PartialEq)]
651pub struct ShellFaceTriangulation {
652    /// `outer ++ holes`, then each of [`Self::steiner`] at the position
653    /// its chain of midpoints gives in the plane.
654    pub points: Vec<Point2>,
655    /// Counter-clockwise triangles over [`Self::points`].
656    pub triangles: Vec<[u32; 3]>,
657    /// The points added inside the face, in order: point `k` of these is
658    /// `points[outer.len() + holes' lengths + k]`.
659    pub steiner: Vec<SteinerPoint>,
660}
661
662/// A point [`triangulate_shell_face`] adds inside a face, as a chain of
663/// midpoints of the face's own points (#283): the midpoint `m` of the
664/// seam from `seam[0]` to `seam[1]`, then `halvings` times the midpoint
665/// of `m` and the point so far, starting from `toward`.
666///
667/// A caller that lifts the face back into space places the point with the
668/// same chain over its own points ([`Self::place`]). On a face lying in an
669/// axis plane, the midpoint of two points with that coordinate in common
670/// keeps it exactly, so the point lies exactly in the plane; elsewhere it
671/// lies within the rounding of the chain.
672#[non_exhaustive]
673#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
674pub struct SteinerPoint {
675    /// The seam's two ends, by index into
676    /// [`ShellFaceTriangulation::points`].
677    pub seam: [u32; 2],
678    /// The point the seam's midpoint is moved towards: a corner of one of
679    /// the seam's two triangles, possibly an earlier Steiner point.
680    pub toward: u32,
681    /// How often the point is moved half way, at least once.
682    pub halvings: u32,
683}
684
685impl SteinerPoint {
686    /// The point by its chain of midpoints, over any point type: `at`
687    /// gives a point by its index (earlier Steiner points included), and
688    /// `midpoint` the midpoint of two points.
689    pub fn place<T: Copy>(&self, at: impl Fn(u32) -> T, midpoint: impl Fn(T, T) -> T) -> T {
690        crate::ring_triangulation::Steiner {
691            seam: self.seam,
692            toward: self.toward,
693            halvings: self.halvings,
694        }
695        .place(at, midpoint)
696    }
697}
698
699/// Triangulate a planar face of a closed shell (#283): as
700/// [`triangulate_with`] under [`PinchPolicy::Accept`], and no triangle
701/// edge lies on a seam.
702///
703/// A seam (a keyhole's segment run both ways, #270) may be a triangle edge
704/// there. Inside one face that is only a diagonal, but other faces of a
705/// shell can run along the same segment (a rim's two end faces at its
706/// cut), and with the seam a triangle edge too that segment has four
707/// triangle uses. So where a seam would be a triangle edge, the two
708/// triangles on it are replaced: by the other diagonal of their
709/// quadrilateral where it is strictly convex, otherwise by four triangles
710/// around a [`SteinerPoint`] strictly inside it and off the seam's line
711/// (a thin rim seamed at its corner has no triangulation on its own
712/// vertices without the seam). A Steiner point adds no area and moves no
713/// boundary: the result is certified against the cover it replaces, the
714/// same boundary edges with every triangle strictly counter-clockwise.
715///
716/// Rings without a seam triangulate exactly as under
717/// [`PinchPolicy::Accept`], with no Steiner point.
718///
719/// # Errors
720///
721/// As [`triangulate_with`] under [`PinchPolicy::Accept`]; `Degenerate`
722/// naming the seam when no Steiner point can be placed off it, and when
723/// the result fails its certificate.
724pub fn triangulate_shell_face(rings: &Rings) -> GeomResult<ShellFaceTriangulation> {
725    check_ring_sizes(rings)?;
726    let (points, triangles, steiner) =
727        crate::ring_triangulation::triangulate_sealed(&rings.outer, &rings.holes)?;
728    Ok(ShellFaceTriangulation {
729        points,
730        triangles,
731        steiner: steiner
732            .into_iter()
733            .map(|s| SteinerPoint {
734                seam: s.seam,
735                toward: s.toward,
736                halvings: s.halvings,
737            })
738            .collect(),
739    })
740}
741
742/// Apply a 2D affine transform to a ring in place.
743fn apply2(ring: &mut [Point2], t: &axiolid_core::Transform2) {
744    for p in ring.iter_mut() {
745        *p = t.transform_point2(*p);
746    }
747}
748
749/// Flatten one closed contour into a point ring.
750///
751/// Consecutive duplicate points are dropped: adjoining segments share an
752/// endpoint by construction, and the triangulation refuses a repeated
753/// vertex as a zero-length edge.
754fn contour_points(
755    contour: &axiolid_profile::Contour,
756    chord_error: Scalar,
757    tolerance: Tolerance,
758) -> GeomResult<Vec<Point2>> {
759    let mut merged = 0.0;
760    contour_points_measured(contour, chord_error, tolerance, &mut merged, true)
761}
762
763/// [`contour_points`] for a centre-line path, which is open: its last point
764/// is not joined back to its first.
765fn open_contour_points(
766    contour: &axiolid_profile::Contour,
767    chord_error: Scalar,
768    tolerance: Tolerance,
769) -> GeomResult<Vec<Point2>> {
770    let mut merged = 0.0;
771    contour_points_measured(contour, chord_error, tolerance, &mut merged, false)
772}
773
774/// [`contour_points`], also raising `merged` to the largest distance
775/// between a dropped near-duplicate point and the point it merged into
776/// (#232): dropping it moves the ring by no more than that. A `closed`
777/// contour must join its last segment to its first (#368); an open path
778/// need not.
779fn contour_points_measured(
780    contour: &axiolid_profile::Contour,
781    chord_error: Scalar,
782    tolerance: Tolerance,
783    merged: &mut Scalar,
784    closed: bool,
785) -> GeomResult<Vec<Point2>> {
786    let mut out: Vec<Point2> = Vec::new();
787    let mut flattened = Vec::with_capacity(contour.segments.len());
788    for segment in &contour.segments {
789        let mut pts = segment_points(segment, chord_error)?;
790        if !segment.same_sense {
791            pts.reverse();
792        }
793        flattened.push(pts);
794    }
795    snap_joints(contour, &mut flattened, tolerance, merged, closed)?;
796    for pts in flattened {
797        for p in pts {
798            match out.last() {
799                Some(last) if near2(*last, p, tolerance.linear()) => {
800                    *merged = merged.max((*last - p).length());
801                }
802                _ => out.push(p),
803            }
804        }
805    }
806    // A closed ring must not repeat its first point as its last.
807    while out.len() > 1 && near2(out[0], *out.last().expect("non-empty"), tolerance.linear()) {
808        let dropped = out.pop().expect("non-empty");
809        *merged = merged.max((out[0] - dropped).length());
810    }
811    if out.len() < 3 {
812        return Err(GeomError::Degenerate(format!(
813            "contour flattened to {} points, need at least 3",
814            out.len()
815        )));
816    }
817    Ok(out)
818}
819
820/// Snap the two points at every joint of a contour to one exact point (#365).
821///
822/// The first and last point of a flattened segment are its endpoints, not
823/// samples, and how exactly a family evaluates one differs. A line at
824/// parameter 0 returns its stored origin; at any other parameter
825/// `origin + direction * t` rounds (twice for `t = 1`). A polyline returns a
826/// stored vertex. A curved segment goes through trigonometry, off by
827/// rounding (`0.5 * cos(pi / 2)` is `3e-17`, not `0`), so an arc ending on a
828/// revolution axis would lie beside the axis. At each joint whose two points
829/// lie within the linear tolerance, both become the more exact one, ranked:
830/// a line's endpoint at parameter 0, a polyline vertex, any other line
831/// endpoint, a curved endpoint; a tie keeps the earlier segment's end. Both
832/// segments then produce the bit-identical vertex. Only interior samples are
833/// ever trigonometric. A gap beyond the tolerance is refused with
834/// `InvalidInput` (#368), as the exact path refuses it, rather than bridged
835/// by a straight edge; for a `closed` contour that includes the joint from
836/// the last segment back to the first. `moved` is raised to the distance a point moved, a rounding error far
837/// below the tolerance that [`profile_deviation`] adds to its bound.
838fn snap_joints(
839    contour: &axiolid_profile::Contour,
840    flattened: &mut [Vec<Point2>],
841    tolerance: Tolerance,
842    moved: &mut Scalar,
843    closed: bool,
844) -> GeomResult<()> {
845    // Lower is more exact.
846    let rank = |segment: &axiolid_profile::ProfileSegment, at_end: bool| match &segment.curve {
847        Curve2::Line(_) => {
848            // The oriented end is the domain end for the forward sense.
849            let t = if at_end == segment.same_sense {
850                segment.domain.end
851            } else {
852                segment.domain.start
853            };
854            if t == 0.0 {
855                0
856            } else {
857                2
858            }
859        }
860        Curve2::Polyline(_) => 1,
861        _ => 3,
862    };
863    let n = flattened.len();
864    for i in 0..n {
865        let j = (i + 1) % n;
866        let wraps = j <= i;
867        if wraps && !closed {
868            continue;
869        }
870        let (Some(&end), Some(&start)) = (flattened[i].last(), flattened[j].first()) else {
871            continue;
872        };
873        // The tolerance, or the rounding of the two endpoint evaluations
874        // when that is larger, as the exact path allows (`joint_slack`).
875        let slack = crate::contour_lower::joint_slack(
876            tolerance,
877            crate::contour_lower::endpoint_scale(&contour.segments[i], true, end),
878            crate::contour_lower::endpoint_scale(&contour.segments[j], false, start),
879        );
880        if !near2(end, start, slack) {
881            let gap = (start - end).length();
882            return Err(GeomError::InvalidInput(if wraps {
883                format!("contour does not close: {gap} from last segment end to start")
884            } else {
885                format!("contour segments leave a gap of {gap} at {start:?}")
886            }));
887        }
888        if i == j {
889            continue;
890        }
891        let joint = if rank(&contour.segments[j], false) < rank(&contour.segments[i], true) {
892            start
893        } else {
894            end
895        };
896        *moved = moved
897            .max((end - joint).length())
898            .max((start - joint).length());
899        if let Some(last) = flattened[i].last_mut() {
900            *last = joint;
901        }
902        if let Some(first) = flattened[j].first_mut() {
903            *first = joint;
904        }
905    }
906    Ok(())
907}
908
909/// Whether two points coincide within a linear tolerance.
910fn near2(a: Point2, b: Point2, linear: Scalar) -> bool {
911    (a.x - b.x).abs() <= linear && (a.y - b.y).abs() <= linear
912}
913
914/// Sample one bounded segment, flattening curves under the chord budget.
915///
916/// Delegates to `axiolid-reference`'s curve evaluator (ADR 0012). This crate used
917/// to carry a private circle flattener with a closed-form segment count, and
918/// refused ellipses and B-splines outright. Both limits are gone: the scalar
919/// evaluator subdivides adaptively on measured sagitta, so every declared
920/// `Curve2` family flattens under the same tolerance contract.
921///
922/// `MAX_SUBDIVISION_DEPTH` bounds the work. 24 levels is 16M potential
923/// segments -- far beyond any real tolerance -- so it is a runaway guard, not
924/// a quality knob.
925const MAX_SUBDIVISION_DEPTH: u32 = 24;
926
927fn segment_points(
928    segment: &axiolid_profile::ProfileSegment,
929    chord_error: Scalar,
930) -> GeomResult<Vec<Point2>> {
931    axiolid_reference::curve::flatten2(
932        &segment.curve,
933        segment.domain,
934        chord_error,
935        MAX_SUBDIVISION_DEPTH,
936    )
937}
938
939/// Force the ring-orientation convention the extruder expects:
940/// outer counter-clockwise, holes clockwise.
941///
942/// Source contours carry whatever orientation the authoring tool wrote, so
943/// normalising here is cheaper than rejecting otherwise-valid geometry.
944use axiolid_reference::signed_area2;
945
946fn orient_rings(mut outer: Vec<Point2>, mut holes: Vec<Vec<Point2>>) -> Rings {
947    if signed_area2(&outer) < 0.0 {
948        outer.reverse();
949    }
950    for hole in &mut holes {
951        if signed_area2(hole) > 0.0 {
952            hole.reverse();
953        }
954    }
955    Rings { outer, holes }
956}