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_profile::{CircleProfile, EllipseProfile, Profile, RectangleProfile};
16
17/// Outer ring plus holes, all CCW/CW normalised by the caller's contract:
18/// outer counter-clockwise, holes clockwise.
19#[derive(Debug, Clone, Default, PartialEq)]
20pub struct Rings {
21    /// Outer boundary, counter-clockwise.
22    pub outer: Vec<Point2>,
23    /// Inner boundaries, clockwise.
24    pub holes: Vec<Vec<Point2>>,
25}
26
27/// Flatten a profile into rings under an explicit chord budget.
28///
29/// Only the families a format adapter currently emits are handled. Everything
30/// else returns `Unsupported` rather than a silently wrong approximation --
31/// a wrong wall is far more expensive than a missing one.
32pub fn profile_rings(
33    profile: &Profile,
34    chord_error: Scalar,
35    tolerance: Tolerance,
36) -> GeomResult<Rings> {
37    match profile {
38        // Rounded corners are exact arcs in the contour, chorded like any
39        // other arc; a sharp rectangle keeps its four exact corners.
40        Profile::Rectangle(r) if r.outer_radius.is_some() || r.inner_radius.is_some() => {
41            let contour = crate::section_lower::rectangle_contour(r)?;
42            contour_rings(&contour, chord_error, tolerance)
43        }
44        Profile::Rectangle(r) => rectangle_rings(r, chord_error, tolerance),
45        // Structural sections (#193): the same exact contour the exact
46        // extruder builds, fillets and toe radii as arcs, chorded here.
47        Profile::Section(section) => {
48            let contour = crate::section_lower::section_contour(section)?;
49            contour_rings(&contour, chord_error, tolerance)
50        }
51        Profile::Circle(c) => circle_rings(c, chord_error),
52        // Reachable only since curve evaluation moved into `axiolid-reference`:
53        // an ellipse has no closed-form segment count, so the old fixed-count
54        // flattener could not express it at all.
55        Profile::Ellipse(e) => ellipse_rings(e, chord_error),
56        Profile::Contour(c) => contour_rings(c, chord_error, tolerance),
57        Profile::Derived { basis, transform } => {
58            let mut rings = profile_rings(basis, chord_error, tolerance)?;
59            apply2(&mut rings.outer, transform);
60            for hole in &mut rings.holes {
61                apply2(hole, transform);
62            }
63            // A mirroring placement reverses ring orientation; earcut and the
64            // extruder both rely on outer CCW / holes CW, so restore it here
65            // rather than letting a silently inside-out solid reach them.
66            if transform.matrix2.determinant() < 0.0 {
67                rings.outer.reverse();
68                for hole in &mut rings.holes {
69                    hole.reverse();
70                }
71            }
72            Ok(rings)
73        }
74        Profile::CenterLine(cl) => {
75            crate::center_line::center_line_rings(cl, chord_error, tolerance, contour_points)
76        }
77        other => Err(GeomError::Unsupported {
78            backend: crate::BACKEND_ID,
79            operation: axiolid_contracts::Operation::ProfileTriangulation,
80        })
81        .inspect_err(|_| {
82            let _ = other;
83        }),
84    }
85}
86
87/// An exact contour with holes, flattened under the chord budget.
88fn contour_rings(
89    c: &axiolid_profile::ContourProfile,
90    chord_error: Scalar,
91    tolerance: Tolerance,
92) -> GeomResult<Rings> {
93    let outer = contour_points(&c.outer, chord_error, tolerance)?;
94    let mut holes = Vec::with_capacity(c.holes.len());
95    for hole in &c.holes {
96        holes.push(contour_points(hole, chord_error, tolerance)?);
97    }
98    Ok(orient_rings(outer, holes))
99}
100
101/// Rectangle, optionally hollow, with sharp corners (rounded ones go
102/// through the exact contour).
103fn rectangle_rings(
104    r: &RectangleProfile,
105    _chord_error: Scalar,
106    tolerance: Tolerance,
107) -> GeomResult<Rings> {
108    if !(r.x.is_finite() && r.y.is_finite()) || r.x <= 0.0 || r.y <= 0.0 {
109        return Err(GeomError::InvalidInput(format!(
110            "rectangle profile must have positive finite extents, got {} x {}",
111            r.x, r.y
112        )));
113    }
114    let (hx, hy) = (r.x / 2.0, r.y / 2.0);
115    let outer = vec![
116        Point2::new(-hx, -hy),
117        Point2::new(hx, -hy),
118        Point2::new(hx, hy),
119        Point2::new(-hx, hy),
120    ];
121    let mut holes = Vec::new();
122    if let Some(t) = r.thickness {
123        if t <= 0.0 || 2.0 * t >= r.x || 2.0 * t >= r.y {
124            return Err(GeomError::InvalidInput(format!(
125                "hollow rectangle wall thickness {t} does not fit inside {} x {}",
126                r.x, r.y
127            )));
128        }
129        let (ix, iy) = (hx - t, hy - t);
130        if !tolerance.eq(ix, 0.0) && !tolerance.eq(iy, 0.0) {
131            // Clockwise: opposite winding to the outer ring marks it a hole.
132            holes.push(vec![
133                Point2::new(-ix, -iy),
134                Point2::new(-ix, iy),
135                Point2::new(ix, iy),
136                Point2::new(ix, -iy),
137            ]);
138        }
139    }
140    Ok(Rings { outer, holes })
141}
142
143/// Circle, optionally annular.
144fn circle_rings(c: &CircleProfile, chord_error: Scalar) -> GeomResult<Rings> {
145    if !c.radius.is_finite() || c.radius <= 0.0 {
146        return Err(GeomError::InvalidInput(format!(
147            "circle profile radius must be positive and finite, got {}",
148            c.radius
149        )));
150    }
151    // Flattened by the scalar evaluator so a parameterized circle and a
152    // contour-declared circular arc obey exactly the same chord budget.
153    let outer = flatten_circle(c.radius, chord_error)?;
154    let mut holes = Vec::new();
155    if let Some(t) = c.thickness {
156        if t <= 0.0 || t >= c.radius {
157            return Err(GeomError::InvalidInput(format!(
158                "annulus wall thickness {t} does not fit inside radius {}",
159                c.radius
160            )));
161        }
162        let inner = c.radius - t;
163        let mut ring = flatten_circle(inner, chord_error)?;
164        ring.reverse(); // clockwise
165        holes.push(ring);
166    }
167    Ok(Rings { outer, holes })
168}
169
170/// Ellipse profile, flattened under the chord budget.
171fn ellipse_rings(e: &EllipseProfile, chord_error: Scalar) -> GeomResult<Rings> {
172    if !e.semi_axis_x.is_finite() || e.semi_axis_x <= 0.0 {
173        return Err(GeomError::InvalidInput(format!(
174            "ellipse semi-axis x must be positive and finite, got {}",
175            e.semi_axis_x
176        )));
177    }
178    if !e.semi_axis_y.is_finite() || e.semi_axis_y <= 0.0 {
179        return Err(GeomError::InvalidInput(format!(
180            "ellipse semi-axis y must be positive and finite, got {}",
181            e.semi_axis_y
182        )));
183    }
184    use axiolid_core::{Frame2, Interval, Vec2};
185    use axiolid_curve::{Curve2, Ellipse2};
186
187    let curve = Curve2::Ellipse(Ellipse2 {
188        frame: Frame2 {
189            origin: Point2::new(0.0, 0.0),
190            x: Vec2::new(1.0, 0.0),
191            y: Vec2::new(0.0, 1.0),
192        },
193        semi_axis_x: e.semi_axis_x,
194        semi_axis_y: e.semi_axis_y,
195    });
196    let mut ring = axiolid_reference::curve::flatten2(
197        &curve,
198        Interval {
199            start: 0.0,
200            end: core::f64::consts::TAU,
201        },
202        chord_error,
203        MAX_SUBDIVISION_DEPTH,
204    )?;
205    // Drop the duplicate closing vertex; a ring is implicitly closed.
206    ring.pop();
207    Ok(Rings {
208        outer: ring,
209        holes: Vec::new(),
210    })
211}
212
213/// Flatten a full circle of `radius` under the chord budget.
214///
215/// The closing duplicate of the start point is dropped: a ring is implicitly
216/// closed, and a repeated vertex would create a zero-length edge that the
217/// extruder would turn into a degenerate side quad.
218fn flatten_circle(radius: Scalar, chord_error: Scalar) -> GeomResult<Vec<Point2>> {
219    use axiolid_core::{Frame2, Interval, Vec2};
220    use axiolid_curve::{Circle2, Curve2};
221
222    let curve = Curve2::Circle(Circle2 {
223        frame: Frame2 {
224            origin: Point2::new(0.0, 0.0),
225            x: Vec2::new(1.0, 0.0),
226            y: Vec2::new(0.0, 1.0),
227        },
228        radius,
229    });
230    let flatten = |start: Scalar| {
231        axiolid_reference::curve::flatten2(
232            &curve,
233            Interval {
234                start,
235                end: start + core::f64::consts::TAU,
236            },
237            chord_error,
238            MAX_SUBDIVISION_DEPTH,
239        )
240    };
241    // Bisection puts a point at every quarter turn. Started half a step
242    // later, the same chords put a chord's middle there instead, strictly
243    // inside the circle: a void tangent to a face along an axis direction,
244    // as openings are, then leaves a sliver of material rather than a
245    // chord point on the face, which would pinch the solid there (#194).
246    let steps = flatten(0.0)?.len().saturating_sub(1).max(1);
247    let mut ring = flatten(core::f64::consts::PI / steps as Scalar)?;
248    ring.pop();
249    Ok(ring)
250}
251
252/// Triangulate rings into a flat index buffer over a single vertex list.
253///
254/// Delegates to `earcut`. The returned vertices are the concatenation
255/// `outer ++ holes`, matching earcut's hole-index convention.
256pub fn triangulate(rings: &Rings) -> GeomResult<(Vec<Point2>, Vec<[u32; 3]>)> {
257    if rings.outer.len() < 3 {
258        return Err(GeomError::InvalidInput(format!(
259            "profile outer ring needs at least 3 vertices, got {}",
260            rings.outer.len()
261        )));
262    }
263    let mut verts: Vec<[Scalar; 2]> = rings.outer.iter().map(|p| [p.x, p.y]).collect();
264    let mut hole_starts = Vec::with_capacity(rings.holes.len());
265    for hole in &rings.holes {
266        if hole.len() < 3 {
267            return Err(GeomError::InvalidInput(format!(
268                "profile hole needs at least 3 vertices, got {}",
269                hole.len()
270            )));
271        }
272        hole_starts.push(verts.len());
273        verts.extend(hole.iter().map(|p| [p.x, p.y]));
274    }
275
276    let mut earcutter = earcut::Earcut::new();
277    let mut flat: Vec<usize> = Vec::new();
278    earcutter.earcut(verts.iter().copied(), &hole_starts, &mut flat);
279
280    if flat.is_empty() || flat.len() % 3 != 0 {
281        return Err(GeomError::Degenerate(format!(
282            "triangulation produced {} indices for a {}-vertex profile",
283            flat.len(),
284            verts.len()
285        )));
286    }
287    let tris = flat
288        .chunks_exact(3)
289        .map(|c| [c[0] as u32, c[1] as u32, c[2] as u32])
290        .collect();
291    let points = verts.into_iter().map(|v| Point2::new(v[0], v[1])).collect();
292    Ok((points, tris))
293}
294
295/// Apply a 2D affine transform to a ring in place.
296fn apply2(ring: &mut [Point2], t: &axiolid_core::Transform2) {
297    for p in ring.iter_mut() {
298        *p = t.transform_point2(*p);
299    }
300}
301
302/// Flatten one closed contour into a point ring.
303///
304/// Consecutive duplicate points are dropped: adjoining segments share an
305/// endpoint by construction, and earcut treats a repeated vertex as a
306/// zero-length edge.
307fn contour_points(
308    contour: &axiolid_profile::Contour,
309    chord_error: Scalar,
310    tolerance: Tolerance,
311) -> GeomResult<Vec<Point2>> {
312    let mut out: Vec<Point2> = Vec::new();
313    for segment in &contour.segments {
314        let mut pts = segment_points(segment, chord_error)?;
315        if !segment.same_sense {
316            pts.reverse();
317        }
318        for p in pts {
319            if out
320                .last()
321                .is_none_or(|last| !near2(*last, p, tolerance.linear()))
322            {
323                out.push(p);
324            }
325        }
326    }
327    // A closed ring must not repeat its first point as its last.
328    while out.len() > 1 && near2(out[0], *out.last().expect("non-empty"), tolerance.linear()) {
329        out.pop();
330    }
331    if out.len() < 3 {
332        return Err(GeomError::Degenerate(format!(
333            "contour flattened to {} points, need at least 3",
334            out.len()
335        )));
336    }
337    Ok(out)
338}
339
340/// Whether two points coincide within a linear tolerance.
341fn near2(a: Point2, b: Point2, linear: Scalar) -> bool {
342    (a.x - b.x).abs() <= linear && (a.y - b.y).abs() <= linear
343}
344
345/// Sample one bounded segment, flattening curves under the chord budget.
346///
347/// Delegates to `axiolid-reference`'s curve evaluator (ADR 0012). This crate used
348/// to carry a private circle flattener with a closed-form segment count, and
349/// refused ellipses and B-splines outright. Both limits are gone: the scalar
350/// evaluator subdivides adaptively on measured sagitta, so every declared
351/// `Curve2` family flattens under the same tolerance contract.
352///
353/// `MAX_SUBDIVISION_DEPTH` bounds the work. 24 levels is 16M potential
354/// segments -- far beyond any real tolerance -- so it is a runaway guard, not
355/// a quality knob.
356const MAX_SUBDIVISION_DEPTH: u32 = 24;
357
358fn segment_points(
359    segment: &axiolid_profile::ProfileSegment,
360    chord_error: Scalar,
361) -> GeomResult<Vec<Point2>> {
362    axiolid_reference::curve::flatten2(
363        &segment.curve,
364        segment.domain,
365        chord_error,
366        MAX_SUBDIVISION_DEPTH,
367    )
368}
369
370/// Force the ring-orientation convention earcut and the extruder expect:
371/// outer counter-clockwise, holes clockwise.
372///
373/// Source contours carry whatever orientation the authoring tool wrote, so
374/// normalising here is cheaper than rejecting otherwise-valid geometry.
375use axiolid_reference::signed_area2;
376
377fn orient_rings(mut outer: Vec<Point2>, mut holes: Vec<Vec<Point2>>) -> Rings {
378    if signed_area2(&outer) < 0.0 {
379        outer.reverse();
380    }
381    for hole in &mut holes {
382        if signed_area2(hole) > 0.0 {
383            hole.reverse();
384        }
385    }
386    Rings { outer, holes }
387}