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