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 delegated to `earcut` (MIT/Apache-2.0,
8//! pure Rust). ADR 0015 records why: hole bridging is a solved problem and a
9//! hand-rolled version failed its own area gate on the two-hole case.
10//! `axiolid_reference::triangulate_simple` is retained as the differential oracle for
11//! the hole-free case, so the adopted implementation is audited, not trusted.
12
13use axiolid_contracts::{GeomError, GeomResult};
14use axiolid_core::{Point2, Scalar, Tolerance};
15use axiolid_curve::Curve2;
16use axiolid_profile::{CircleProfile, EllipseProfile, Profile, RectangleProfile};
17
18/// Outer ring plus holes, all CCW/CW normalised by the caller's contract:
19/// outer counter-clockwise, holes clockwise.
20#[derive(Debug, Clone, Default, PartialEq)]
21pub struct Rings {
22    /// Outer boundary, counter-clockwise.
23    pub outer: Vec<Point2>,
24    /// Inner boundaries, clockwise.
25    pub holes: Vec<Vec<Point2>>,
26}
27
28/// Flatten a profile into rings under an explicit chord budget.
29///
30/// Only the families a format adapter currently emits are handled. Everything
31/// else returns `Unsupported` rather than a silently wrong approximation --
32/// a wrong wall is far more expensive than a missing one.
33pub fn profile_rings(
34    profile: &Profile,
35    chord_error: Scalar,
36    tolerance: Tolerance,
37) -> GeomResult<Rings> {
38    match profile {
39        // Rounded corners are exact arcs in the contour, chorded like any
40        // other arc; a sharp rectangle keeps its four exact corners.
41        Profile::Rectangle(r) if r.outer_radius.is_some() || r.inner_radius.is_some() => {
42            let contour = crate::section_lower::rectangle_contour(r)?;
43            contour_rings(&contour, chord_error, tolerance)
44        }
45        Profile::Rectangle(r) => rectangle_rings(r, chord_error, tolerance),
46        // Structural sections (#193): the same exact contour the exact
47        // extruder builds, fillets and toe radii as arcs, chorded here.
48        Profile::Section(section) => {
49            let contour = crate::section_lower::section_contour(section)?;
50            contour_rings(&contour, chord_error, tolerance)
51        }
52        Profile::Circle(c) => circle_rings(c, chord_error),
53        // Reachable only since curve evaluation moved into `axiolid-reference`:
54        // an ellipse has no closed-form segment count, so the old fixed-count
55        // flattener could not express it at all.
56        Profile::Ellipse(e) => ellipse_rings(e, chord_error),
57        Profile::Contour(c) => contour_rings(c, chord_error, tolerance),
58        Profile::Derived { basis, transform } => {
59            let mut rings = profile_rings(basis, chord_error, tolerance)?;
60            apply2(&mut rings.outer, transform);
61            for hole in &mut rings.holes {
62                apply2(hole, transform);
63            }
64            // A mirroring placement reverses ring orientation; earcut and the
65            // extruder both rely on outer CCW / holes CW, so restore it here
66            // rather than letting a silently inside-out solid reach them.
67            if transform.matrix2.determinant() < 0.0 {
68                rings.outer.reverse();
69                for hole in &mut rings.holes {
70                    hole.reverse();
71                }
72            }
73            Ok(rings)
74        }
75        Profile::CenterLine(cl) => {
76            crate::center_line::center_line_rings(cl, chord_error, tolerance, contour_points)
77        }
78        other => Err(GeomError::Unsupported {
79            backend: crate::BACKEND_ID,
80            operation: axiolid_contracts::Operation::ProfileTriangulation,
81        })
82        .inspect_err(|_| {
83            let _ = other;
84        }),
85    }
86}
87
88/// How far a profile's exact boundary may lie from the rings
89/// [`profile_rings`] flattens it to (#232).
90///
91/// `Bounded(d)`: every point of the exact boundary is within `d` of an edge
92/// of the rings, so every point of the exact region is within `d` of the
93/// rings' region too (a point between an arc and its chord is in the
94/// convex hull of the two, hence within the arc's distance of the chord).
95/// `Unbounded` names the part that has no certified bound.
96#[non_exhaustive]
97#[derive(Debug, Clone, Copy, PartialEq)]
98pub enum ProfileDeviation {
99    /// A certified upper bound, in the profile's own units.
100    Bounded(Scalar),
101    /// No certified bound; the reason names the family.
102    Unbounded(&'static str),
103}
104
105impl ProfileDeviation {
106    /// The worse of two deviations: unbounded wins, else the larger bound.
107    #[must_use]
108    pub fn max(self, other: Self) -> Self {
109        match (self, other) {
110            (Self::Unbounded(reason), _) | (_, Self::Unbounded(reason)) => Self::Unbounded(reason),
111            (Self::Bounded(a), Self::Bounded(b)) => Self::Bounded(a.max(b)),
112        }
113    }
114
115    fn scaled(self, factor: Scalar) -> Self {
116        match self {
117            Self::Bounded(d) => Self::Bounded(d * factor),
118            unbounded => unbounded,
119        }
120    }
121
122    fn plus(self, extra: Scalar) -> Self {
123        match self {
124            Self::Bounded(d) => Self::Bounded(d + extra),
125            unbounded => unbounded,
126        }
127    }
128}
129
130/// The deviation of [`profile_rings`]`(profile, chord_error, tolerance)`
131/// from the exact profile (#232).
132///
133/// The rings are rebuilt here, deterministically, and three things are
134/// read off them:
135///
136/// - each curved segment's family: one [`axiolid_reference::curve::flatten2`]
137///   certifies ([`axiolid_reference::bound::certifies_flattening2`]) is
138///   within `chord_error` of its chords; any other is `Unbounded`;
139/// - the points merged as coincident (within the linear tolerance of the
140///   point kept): a merged point moves the ring by at most its distance to
141///   the kept one, which is added;
142/// - a derived profile's transform, whose largest stretch scales the
143///   basis's bound.
144///
145/// # Errors
146///
147/// As [`profile_rings`] for the same arguments.
148pub fn profile_deviation(
149    profile: &Profile,
150    chord_error: Scalar,
151    tolerance: Tolerance,
152) -> GeomResult<ProfileDeviation> {
153    Ok(match profile {
154        Profile::Rectangle(r) if r.outer_radius.is_some() || r.inner_radius.is_some() => {
155            contour_deviation(
156                &crate::section_lower::rectangle_contour(r)?,
157                chord_error,
158                tolerance,
159            )?
160        }
161        Profile::Rectangle(r) => {
162            rectangle_rings(r, chord_error, tolerance)?;
163            match r.thickness {
164                // The hole is dropped when one of its extents vanishes
165                // within the tolerance; its slit walls then lie inside the
166                // solid, far from every ring.
167                Some(t) if tolerance.eq(r.x / 2.0 - t, 0.0) || tolerance.eq(r.y / 2.0 - t, 0.0) => {
168                    ProfileDeviation::Unbounded("hollow rectangle whose hole collapses")
169                }
170                _ => ProfileDeviation::Bounded(0.0),
171            }
172        }
173        Profile::Section(section) => contour_deviation(
174            &crate::section_lower::section_contour(section)?,
175            chord_error,
176            tolerance,
177        )?,
178        Profile::Circle(c) => {
179            circle_rings(c, chord_error)?;
180            ProfileDeviation::Bounded(chord_error)
181        }
182        Profile::Ellipse(e) => {
183            ellipse_rings(e, chord_error)?;
184            ProfileDeviation::Bounded(chord_error)
185        }
186        Profile::Contour(c) => contour_deviation(c, chord_error, tolerance)?,
187        Profile::Derived { basis, transform } => {
188            profile_deviation(basis, chord_error, tolerance)?.scaled(stretch2(transform))
189        }
190        Profile::CenterLine(cl) => {
191            profile_rings(profile, chord_error, tolerance)?;
192            let mut merged: Scalar = 0.0;
193            contour_points_measured(&cl.path, chord_error, tolerance, &mut merged)?;
194            let straight = cl
195                .path
196                .segments
197                .iter()
198                .all(|segment| matches!(segment.curve, Curve2::Line(_) | Curve2::Polyline(_)));
199            if straight && merged == 0.0 {
200                ProfileDeviation::Bounded(0.0)
201            } else {
202                ProfileDeviation::Unbounded("centre-line profile along a curved or merged path")
203            }
204        }
205        _ => {
206            profile_rings(profile, chord_error, tolerance)?;
207            ProfileDeviation::Unbounded("profile family")
208        }
209    })
210}
211
212/// The largest stretch of a 2D affine map's linear part: its largest
213/// singular value, `sqrt((F + sqrt(F^2 - 4 D^2)) / 2)` with `F` the squared
214/// Frobenius norm and `D` the determinant, inflated against rounding.
215fn stretch2(transform: &axiolid_core::Transform2) -> Scalar {
216    let m = transform.matrix2;
217    let f = m.x_axis.length_squared() + m.y_axis.length_squared();
218    let d = m.determinant();
219    let disc = (f * f - 4.0 * d * d).max(0.0).sqrt();
220    (0.5 * (f + disc)).sqrt() * (1.0 + 1e-12)
221}
222
223/// [`ProfileDeviation`] of one contour with holes.
224fn contour_deviation(
225    c: &axiolid_profile::ContourProfile,
226    chord_error: Scalar,
227    tolerance: Tolerance,
228) -> GeomResult<ProfileDeviation> {
229    let mut deviation = ProfileDeviation::Bounded(0.0);
230    for contour in core::iter::once(&c.outer).chain(&c.holes) {
231        let mut merged: Scalar = 0.0;
232        contour_points_measured(contour, chord_error, tolerance, &mut merged)?;
233        let mut chords = ProfileDeviation::Bounded(0.0);
234        for segment in &contour.segments {
235            chords = chords.max(segment_deviation(&segment.curve, chord_error));
236        }
237        deviation = deviation.max(chords.plus(merged));
238    }
239    Ok(deviation)
240}
241
242/// A segment's chord deviation: none for a straight one, the budget for a
243/// family the flattener certifies, else unbounded by family.
244fn segment_deviation(curve: &Curve2, chord_error: Scalar) -> ProfileDeviation {
245    match curve {
246        Curve2::Line(_) | Curve2::Polyline(_) => ProfileDeviation::Bounded(0.0),
247        other if axiolid_reference::bound::certifies_flattening2(other) => {
248            ProfileDeviation::Bounded(chord_error)
249        }
250        Curve2::Intrinsic(_) => ProfileDeviation::Unbounded("intrinsic (clothoid) profile segment"),
251        Curve2::BSpline(_) => {
252            ProfileDeviation::Unbounded("B-spline profile segment with a non-positive weight")
253        }
254        _ => ProfileDeviation::Unbounded("profile segment family"),
255    }
256}
257
258/// An exact contour with holes, flattened under the chord budget.
259fn contour_rings(
260    c: &axiolid_profile::ContourProfile,
261    chord_error: Scalar,
262    tolerance: Tolerance,
263) -> GeomResult<Rings> {
264    let outer = contour_points(&c.outer, chord_error, tolerance)?;
265    let mut holes = Vec::with_capacity(c.holes.len());
266    for hole in &c.holes {
267        holes.push(contour_points(hole, chord_error, tolerance)?);
268    }
269    Ok(orient_rings(outer, holes))
270}
271
272/// Rectangle, optionally hollow, with sharp corners (rounded ones go
273/// through the exact contour).
274fn rectangle_rings(
275    r: &RectangleProfile,
276    _chord_error: Scalar,
277    tolerance: Tolerance,
278) -> GeomResult<Rings> {
279    if !(r.x.is_finite() && r.y.is_finite()) || r.x <= 0.0 || r.y <= 0.0 {
280        return Err(GeomError::InvalidInput(format!(
281            "rectangle profile must have positive finite extents, got {} x {}",
282            r.x, r.y
283        )));
284    }
285    let (hx, hy) = (r.x / 2.0, r.y / 2.0);
286    let outer = vec![
287        Point2::new(-hx, -hy),
288        Point2::new(hx, -hy),
289        Point2::new(hx, hy),
290        Point2::new(-hx, hy),
291    ];
292    let mut holes = Vec::new();
293    if let Some(t) = r.thickness {
294        if t <= 0.0 || 2.0 * t >= r.x || 2.0 * t >= r.y {
295            return Err(GeomError::InvalidInput(format!(
296                "hollow rectangle wall thickness {t} does not fit inside {} x {}",
297                r.x, r.y
298            )));
299        }
300        let (ix, iy) = (hx - t, hy - t);
301        if !tolerance.eq(ix, 0.0) && !tolerance.eq(iy, 0.0) {
302            // Clockwise: opposite winding to the outer ring marks it a hole.
303            holes.push(vec![
304                Point2::new(-ix, -iy),
305                Point2::new(-ix, iy),
306                Point2::new(ix, iy),
307                Point2::new(ix, -iy),
308            ]);
309        }
310    }
311    Ok(Rings { outer, holes })
312}
313
314/// Circle, optionally annular.
315fn circle_rings(c: &CircleProfile, chord_error: Scalar) -> GeomResult<Rings> {
316    if !c.radius.is_finite() || c.radius <= 0.0 {
317        return Err(GeomError::InvalidInput(format!(
318            "circle profile radius must be positive and finite, got {}",
319            c.radius
320        )));
321    }
322    // Flattened by the scalar evaluator so a parameterized circle and a
323    // contour-declared circular arc obey exactly the same chord budget.
324    let outer = flatten_circle(c.radius, chord_error)?;
325    let mut holes = Vec::new();
326    if let Some(t) = c.thickness {
327        if t <= 0.0 || t >= c.radius {
328            return Err(GeomError::InvalidInput(format!(
329                "annulus wall thickness {t} does not fit inside radius {}",
330                c.radius
331            )));
332        }
333        let inner = c.radius - t;
334        let mut ring = flatten_circle(inner, chord_error)?;
335        ring.reverse(); // clockwise
336        holes.push(ring);
337    }
338    Ok(Rings { outer, holes })
339}
340
341/// Ellipse profile, flattened under the chord budget.
342fn ellipse_rings(e: &EllipseProfile, chord_error: Scalar) -> GeomResult<Rings> {
343    if !e.semi_axis_x.is_finite() || e.semi_axis_x <= 0.0 {
344        return Err(GeomError::InvalidInput(format!(
345            "ellipse semi-axis x must be positive and finite, got {}",
346            e.semi_axis_x
347        )));
348    }
349    if !e.semi_axis_y.is_finite() || e.semi_axis_y <= 0.0 {
350        return Err(GeomError::InvalidInput(format!(
351            "ellipse semi-axis y must be positive and finite, got {}",
352            e.semi_axis_y
353        )));
354    }
355    use axiolid_core::{Frame2, Interval, Vec2};
356    use axiolid_curve::{Curve2, Ellipse2};
357
358    let curve = Curve2::Ellipse(Ellipse2 {
359        frame: Frame2 {
360            origin: Point2::new(0.0, 0.0),
361            x: Vec2::new(1.0, 0.0),
362            y: Vec2::new(0.0, 1.0),
363        },
364        semi_axis_x: e.semi_axis_x,
365        semi_axis_y: e.semi_axis_y,
366    });
367    let mut ring = axiolid_reference::curve::flatten2(
368        &curve,
369        Interval {
370            start: 0.0,
371            end: core::f64::consts::TAU,
372        },
373        chord_error,
374        MAX_SUBDIVISION_DEPTH,
375    )?;
376    // Drop the duplicate closing vertex; a ring is implicitly closed.
377    ring.pop();
378    Ok(Rings {
379        outer: ring,
380        holes: Vec::new(),
381    })
382}
383
384/// Flatten a full circle of `radius` under the chord budget.
385///
386/// The closing duplicate of the start point is dropped: a ring is implicitly
387/// closed, and a repeated vertex would create a zero-length edge that the
388/// extruder would turn into a degenerate side quad.
389fn flatten_circle(radius: Scalar, chord_error: Scalar) -> GeomResult<Vec<Point2>> {
390    use axiolid_core::{Frame2, Interval, Vec2};
391    use axiolid_curve::{Circle2, Curve2};
392
393    let curve = Curve2::Circle(Circle2 {
394        frame: Frame2 {
395            origin: Point2::new(0.0, 0.0),
396            x: Vec2::new(1.0, 0.0),
397            y: Vec2::new(0.0, 1.0),
398        },
399        radius,
400    });
401    let flatten = |start: Scalar| {
402        axiolid_reference::curve::flatten2(
403            &curve,
404            Interval {
405                start,
406                end: start + core::f64::consts::TAU,
407            },
408            chord_error,
409            MAX_SUBDIVISION_DEPTH,
410        )
411    };
412    // Bisection puts a point at every quarter turn. Started half a step
413    // later, the same chords put a chord's middle there instead, strictly
414    // inside the circle: a void tangent to a face along an axis direction,
415    // as openings are, then leaves a sliver of material rather than a
416    // chord point on the face, which would pinch the solid there (#194).
417    let steps = flatten(0.0)?.len().saturating_sub(1).max(1);
418    let mut ring = flatten(core::f64::consts::PI / steps as Scalar)?;
419    ring.pop();
420    Ok(ring)
421}
422
423/// Triangulate rings into a flat index buffer over a single vertex list.
424///
425/// Delegates to `earcut`. The returned vertices are the concatenation
426/// `outer ++ holes`, matching earcut's hole-index convention.
427pub fn triangulate(rings: &Rings) -> GeomResult<(Vec<Point2>, Vec<[u32; 3]>)> {
428    if rings.outer.len() < 3 {
429        return Err(GeomError::InvalidInput(format!(
430            "profile outer ring needs at least 3 vertices, got {}",
431            rings.outer.len()
432        )));
433    }
434    let mut verts: Vec<[Scalar; 2]> = rings.outer.iter().map(|p| [p.x, p.y]).collect();
435    let mut hole_starts = Vec::with_capacity(rings.holes.len());
436    for hole in &rings.holes {
437        if hole.len() < 3 {
438            return Err(GeomError::InvalidInput(format!(
439                "profile hole needs at least 3 vertices, got {}",
440                hole.len()
441            )));
442        }
443        hole_starts.push(verts.len());
444        verts.extend(hole.iter().map(|p| [p.x, p.y]));
445    }
446
447    let mut earcutter = earcut::Earcut::new();
448    let mut flat: Vec<usize> = Vec::new();
449    earcutter.earcut(verts.iter().copied(), &hole_starts, &mut flat);
450
451    if flat.is_empty() || flat.len() % 3 != 0 {
452        return Err(GeomError::Degenerate(format!(
453            "triangulation produced {} indices for a {}-vertex profile",
454            flat.len(),
455            verts.len()
456        )));
457    }
458    let tris = flat
459        .chunks_exact(3)
460        .map(|c| [c[0] as u32, c[1] as u32, c[2] as u32])
461        .collect();
462    let points = verts.into_iter().map(|v| Point2::new(v[0], v[1])).collect();
463    Ok((points, tris))
464}
465
466/// Apply a 2D affine transform to a ring in place.
467fn apply2(ring: &mut [Point2], t: &axiolid_core::Transform2) {
468    for p in ring.iter_mut() {
469        *p = t.transform_point2(*p);
470    }
471}
472
473/// Flatten one closed contour into a point ring.
474///
475/// Consecutive duplicate points are dropped: adjoining segments share an
476/// endpoint by construction, and earcut treats a repeated vertex as a
477/// zero-length edge.
478fn contour_points(
479    contour: &axiolid_profile::Contour,
480    chord_error: Scalar,
481    tolerance: Tolerance,
482) -> GeomResult<Vec<Point2>> {
483    let mut merged = 0.0;
484    contour_points_measured(contour, chord_error, tolerance, &mut merged)
485}
486
487/// [`contour_points`], also raising `merged` to the largest distance
488/// between a dropped near-duplicate point and the point it merged into
489/// (#232): dropping it moves the ring by no more than that.
490fn contour_points_measured(
491    contour: &axiolid_profile::Contour,
492    chord_error: Scalar,
493    tolerance: Tolerance,
494    merged: &mut Scalar,
495) -> GeomResult<Vec<Point2>> {
496    let mut out: Vec<Point2> = Vec::new();
497    for segment in &contour.segments {
498        let mut pts = segment_points(segment, chord_error)?;
499        if !segment.same_sense {
500            pts.reverse();
501        }
502        for p in pts {
503            match out.last() {
504                Some(last) if near2(*last, p, tolerance.linear()) => {
505                    *merged = merged.max((*last - p).length());
506                }
507                _ => out.push(p),
508            }
509        }
510    }
511    // A closed ring must not repeat its first point as its last.
512    while out.len() > 1 && near2(out[0], *out.last().expect("non-empty"), tolerance.linear()) {
513        let dropped = out.pop().expect("non-empty");
514        *merged = merged.max((out[0] - dropped).length());
515    }
516    if out.len() < 3 {
517        return Err(GeomError::Degenerate(format!(
518            "contour flattened to {} points, need at least 3",
519            out.len()
520        )));
521    }
522    Ok(out)
523}
524
525/// Whether two points coincide within a linear tolerance.
526fn near2(a: Point2, b: Point2, linear: Scalar) -> bool {
527    (a.x - b.x).abs() <= linear && (a.y - b.y).abs() <= linear
528}
529
530/// Sample one bounded segment, flattening curves under the chord budget.
531///
532/// Delegates to `axiolid-reference`'s curve evaluator (ADR 0012). This crate used
533/// to carry a private circle flattener with a closed-form segment count, and
534/// refused ellipses and B-splines outright. Both limits are gone: the scalar
535/// evaluator subdivides adaptively on measured sagitta, so every declared
536/// `Curve2` family flattens under the same tolerance contract.
537///
538/// `MAX_SUBDIVISION_DEPTH` bounds the work. 24 levels is 16M potential
539/// segments -- far beyond any real tolerance -- so it is a runaway guard, not
540/// a quality knob.
541const MAX_SUBDIVISION_DEPTH: u32 = 24;
542
543fn segment_points(
544    segment: &axiolid_profile::ProfileSegment,
545    chord_error: Scalar,
546) -> GeomResult<Vec<Point2>> {
547    axiolid_reference::curve::flatten2(
548        &segment.curve,
549        segment.domain,
550        chord_error,
551        MAX_SUBDIVISION_DEPTH,
552    )
553}
554
555/// Force the ring-orientation convention earcut and the extruder expect:
556/// outer counter-clockwise, holes clockwise.
557///
558/// Source contours carry whatever orientation the authoring tool wrote, so
559/// normalising here is cheaper than rejecting otherwise-valid geometry.
560use axiolid_reference::signed_area2;
561
562fn orient_rings(mut outer: Vec<Point2>, mut holes: Vec<Vec<Point2>>) -> Rings {
563    if signed_area2(&outer) < 0.0 {
564        outer.reverse();
565    }
566    for hole in &mut holes {
567        if signed_area2(hole) > 0.0 {
568            hole.reverse();
569        }
570    }
571    Rings { outer, holes }
572}