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
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; the extruder's
65 // walls 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::Chain(_) => {
252 ProfileDeviation::Unbounded("arc-length chain profile segment with an unbounded piece")
253 }
254 Curve2::BSpline(_) => {
255 ProfileDeviation::Unbounded("B-spline profile segment with a non-positive weight")
256 }
257 _ => ProfileDeviation::Unbounded("profile segment family"),
258 }
259}
260
261/// An exact contour with holes, flattened under the chord budget.
262fn contour_rings(
263 c: &axiolid_profile::ContourProfile,
264 chord_error: Scalar,
265 tolerance: Tolerance,
266) -> GeomResult<Rings> {
267 let outer = contour_points(&c.outer, chord_error, tolerance)?;
268 let mut holes = Vec::with_capacity(c.holes.len());
269 for hole in &c.holes {
270 holes.push(contour_points(hole, chord_error, tolerance)?);
271 }
272 Ok(orient_rings(outer, holes))
273}
274
275/// Rectangle, optionally hollow, with sharp corners (rounded ones go
276/// through the exact contour).
277fn rectangle_rings(
278 r: &RectangleProfile,
279 _chord_error: Scalar,
280 tolerance: Tolerance,
281) -> GeomResult<Rings> {
282 if !(r.x.is_finite() && r.y.is_finite()) || r.x <= 0.0 || r.y <= 0.0 {
283 return Err(GeomError::InvalidInput(format!(
284 "rectangle profile must have positive finite extents, got {} x {}",
285 r.x, r.y
286 )));
287 }
288 let (hx, hy) = (r.x / 2.0, r.y / 2.0);
289 let outer = vec![
290 Point2::new(-hx, -hy),
291 Point2::new(hx, -hy),
292 Point2::new(hx, hy),
293 Point2::new(-hx, hy),
294 ];
295 let mut holes = Vec::new();
296 if let Some(t) = r.thickness {
297 if t <= 0.0 || 2.0 * t >= r.x || 2.0 * t >= r.y {
298 return Err(GeomError::InvalidInput(format!(
299 "hollow rectangle wall thickness {t} does not fit inside {} x {}",
300 r.x, r.y
301 )));
302 }
303 let (ix, iy) = (hx - t, hy - t);
304 if !tolerance.eq(ix, 0.0) && !tolerance.eq(iy, 0.0) {
305 // Clockwise: opposite winding to the outer ring marks it a hole.
306 holes.push(vec![
307 Point2::new(-ix, -iy),
308 Point2::new(-ix, iy),
309 Point2::new(ix, iy),
310 Point2::new(ix, -iy),
311 ]);
312 }
313 }
314 Ok(Rings { outer, holes })
315}
316
317/// Circle, optionally annular.
318fn circle_rings(c: &CircleProfile, chord_error: Scalar) -> GeomResult<Rings> {
319 if !c.radius.is_finite() || c.radius <= 0.0 {
320 return Err(GeomError::InvalidInput(format!(
321 "circle profile radius must be positive and finite, got {}",
322 c.radius
323 )));
324 }
325 // Flattened by the scalar evaluator so a parameterized circle and a
326 // contour-declared circular arc obey exactly the same chord budget.
327 let outer = flatten_circle(c.radius, chord_error)?;
328 let mut holes = Vec::new();
329 if let Some(t) = c.thickness {
330 if t <= 0.0 || t >= c.radius {
331 return Err(GeomError::InvalidInput(format!(
332 "annulus wall thickness {t} does not fit inside radius {}",
333 c.radius
334 )));
335 }
336 let inner = c.radius - t;
337 let mut ring = flatten_circle(inner, chord_error)?;
338 ring.reverse(); // clockwise
339 holes.push(ring);
340 }
341 Ok(Rings { outer, holes })
342}
343
344/// Ellipse profile, flattened under the chord budget.
345fn ellipse_rings(e: &EllipseProfile, chord_error: Scalar) -> GeomResult<Rings> {
346 if !e.semi_axis_x.is_finite() || e.semi_axis_x <= 0.0 {
347 return Err(GeomError::InvalidInput(format!(
348 "ellipse semi-axis x must be positive and finite, got {}",
349 e.semi_axis_x
350 )));
351 }
352 if !e.semi_axis_y.is_finite() || e.semi_axis_y <= 0.0 {
353 return Err(GeomError::InvalidInput(format!(
354 "ellipse semi-axis y must be positive and finite, got {}",
355 e.semi_axis_y
356 )));
357 }
358 use axiolid_core::{Frame2, Interval, Vec2};
359 use axiolid_curve::{Curve2, Ellipse2};
360
361 let curve = Curve2::Ellipse(Ellipse2 {
362 frame: Frame2 {
363 origin: Point2::new(0.0, 0.0),
364 x: Vec2::new(1.0, 0.0),
365 y: Vec2::new(0.0, 1.0),
366 },
367 semi_axis_x: e.semi_axis_x,
368 semi_axis_y: e.semi_axis_y,
369 });
370 let mut ring = axiolid_reference::curve::flatten2(
371 &curve,
372 Interval {
373 start: 0.0,
374 end: core::f64::consts::TAU,
375 },
376 chord_error,
377 MAX_SUBDIVISION_DEPTH,
378 )?;
379 // Drop the duplicate closing vertex; a ring is implicitly closed.
380 ring.pop();
381 Ok(Rings {
382 outer: ring,
383 holes: Vec::new(),
384 })
385}
386
387/// Flatten a full circle of `radius` under the chord budget.
388///
389/// The closing duplicate of the start point is dropped: a ring is implicitly
390/// closed, and a repeated vertex would create a zero-length edge that the
391/// extruder would turn into a degenerate side quad.
392fn flatten_circle(radius: Scalar, chord_error: Scalar) -> GeomResult<Vec<Point2>> {
393 use axiolid_core::{Frame2, Interval, Vec2};
394 use axiolid_curve::{Circle2, Curve2};
395
396 let curve = Curve2::Circle(Circle2 {
397 frame: Frame2 {
398 origin: Point2::new(0.0, 0.0),
399 x: Vec2::new(1.0, 0.0),
400 y: Vec2::new(0.0, 1.0),
401 },
402 radius,
403 });
404 let flatten = |start: Scalar| {
405 axiolid_reference::curve::flatten2(
406 &curve,
407 Interval {
408 start,
409 end: start + core::f64::consts::TAU,
410 },
411 chord_error,
412 MAX_SUBDIVISION_DEPTH,
413 )
414 };
415 // Bisection puts a point at every quarter turn. Started half a step
416 // later, the same chords put a chord's middle there instead, strictly
417 // inside the circle: a void tangent to a face along an axis direction,
418 // as openings are, then leaves a sliver of material rather than a
419 // chord point on the face, which would pinch the solid there (#194).
420 let steps = flatten(0.0)?.len().saturating_sub(1).max(1);
421 let mut ring = flatten(core::f64::consts::PI / steps as Scalar)?;
422 ring.pop();
423 Ok(ring)
424}
425
426/// Triangulate rings into a flat index buffer over a single vertex list.
427///
428/// The returned vertices are the concatenation `outer ++ holes`, unchanged,
429/// and every one of them is a corner of some triangle. Rings may be given
430/// either way round; the triangles are counter-clockwise. The result is
431/// certified before it is returned (#253): every ring edge is a triangle
432/// edge, every other edge is shared by exactly two triangles, so no
433/// triangle edge runs past a vertex and an extrusion of the caps closes.
434///
435/// # Errors
436///
437/// `InvalidInput`, naming the ring, for rings that do not bound a polygon
438/// with holes: fewer than three or non-finite vertices, a repeated vertex,
439/// a ring that folds back on or crosses itself or runs along a seam both
440/// ways (a keyhole, #270), holes that overlap or touch
441/// each other or the outer ring, a hole outside the outer ring or inside
442/// another hole. `Degenerate` if the triangulation cannot be certified.
443///
444/// Touching rings are refused because this is the cap of a solid
445/// ([`PinchPolicy::Refuse`]); a 2D region or a planar surface patch uses
446/// [`triangulate_with`] and [`PinchPolicy::Accept`].
447pub fn triangulate(rings: &Rings) -> GeomResult<(Vec<Point2>, Vec<[u32; 3]>)> {
448 triangulate_with(rings, PinchPolicy::Refuse)
449}
450
451/// How a triangulation treats rings that touch at a single point (#262):
452/// a hole touching the outer ring or another hole at a vertex, an outer
453/// ring pinched at a vertex, or a vertex lying on another ring's edge.
454///
455/// The region such rings bound is valid, but the surface an extrusion
456/// builds over it is not a two-manifold: the walls of the two rings meet
457/// along one edge, which four faces then share. So solids refuse it and
458/// regions and surface patches accept it. The same holds for a ring that
459/// joins a hole by a seam it runs along both ways (#270): its region is
460/// the outer polygon less the hole, but an extrusion puts two coincident
461/// wall faces on the seam.
462#[non_exhaustive]
463#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
464pub enum PinchPolicy {
465 /// Refuse touching rings and seams with `InvalidInput` naming them. For
466 /// anything that must close into a two-manifold: extrusion and loft
467 /// caps.
468 Refuse,
469 /// Triangulate the region the touching rings bound. For 2D regions and
470 /// planar surface patches: a plan footprint that is a union of shadows
471 /// touching at a corner, a space-boundary surface, a planar face of a
472 /// B-rep (whose edges are the ring edges either way, once a vertex
473 /// inside an edge, which [`ring_touches`] lists, is inserted into the
474 /// neighbouring face's copy of that edge too). A ring joining a hole by
475 /// a seam run both ways (a keyhole, #270) is accepted too.
476 Accept,
477}
478
479/// [`triangulate`] under an explicit [`PinchPolicy`].
480///
481/// Under [`PinchPolicy::Refuse`] this is [`triangulate`]. Under
482/// [`PinchPolicy::Accept`] rings may also touch at single points: where two
483/// edges meet at a point that is a vertex of at least one of them. The
484/// region is then the points inside the outer ring and outside every hole
485/// (for a ring pinched into loops, inside an odd number of its loops),
486/// split into its connected parts at the pinches; each part is triangulated
487/// and the whole is certified as [`triangulate`] is, with each visit of a
488/// pinch point counted as its own vertex. A point several vertices share is
489/// referenced by its first index in `outer ++ holes`, and a vertex on
490/// another ring's edge splits that edge, so the ring edges of the result
491/// are the rings' edges cut at every touching vertex. Rings touching
492/// nowhere triangulate exactly as under [`PinchPolicy::Refuse`].
493///
494/// Under [`PinchPolicy::Accept`] a ring may also join a hole by a seam
495/// (#270): an edge and a later edge of the same ring between the same two
496/// points in opposite directions, which no other edge has, and which no
497/// other edge meets except at its ends -- a keyhole, the outer boundary,
498/// the seam, the hole and the seam back. The two copies cancel: the ring
499/// bounds what its other edges bound, and is split at its seams into the
500/// loops they join, triangulated as above (a hole loop drawn either way
501/// round is a hole; a seam between two loops side by side leaves two
502/// parts). A seam may be a triangle edge, once each way, or none at all;
503/// a planar face of a closed shell uses [`triangulate_shell_face`], which
504/// never makes it one (#283).
505///
506/// # Errors
507///
508/// As [`triangulate`], except that touching at single points and seams
509/// are accepted under [`PinchPolicy::Accept`]. Rings that cross, at a
510/// shared vertex included, or overlap along an edge are refused by name
511/// under both, and so are, under [`PinchPolicy::Accept`], an edge running
512/// back along only part of another, the same edge in one direction twice
513/// or more than twice in all, seams interleaving along their ring instead
514/// of nesting, seams in a row, an edge crossing, touching or running along
515/// a seam away from its ends, and a loop folding back where a seam left
516/// it. [`PinchPolicy::Refuse`] refuses any seam by name.
517pub fn triangulate_with(
518 rings: &Rings,
519 pinches: PinchPolicy,
520) -> GeomResult<(Vec<Point2>, Vec<[u32; 3]>)> {
521 check_ring_sizes(rings)?;
522 crate::ring_triangulation::triangulate_rings(&rings.outer, &rings.holes, pinches)
523}
524
525/// Refuse an outer ring or a hole of fewer than three vertices.
526fn check_ring_sizes(rings: &Rings) -> GeomResult<()> {
527 if rings.outer.len() < 3 {
528 return Err(GeomError::InvalidInput(format!(
529 "profile outer ring needs at least 3 vertices, got {}",
530 rings.outer.len()
531 )));
532 }
533 for hole in &rings.holes {
534 if hole.len() < 3 {
535 return Err(GeomError::InvalidInput(format!(
536 "profile hole needs at least 3 vertices, got {}",
537 hole.len()
538 )));
539 }
540 }
541 Ok(())
542}
543
544/// A vertex lying inside an edge of a ring, away from both its ends, as
545/// [`triangulate_with`] finds it under [`PinchPolicy::Accept`] (#265).
546///
547/// Indices follow [`triangulate_with`]: ring `0` is the outer ring and ring
548/// `h + 1` hole `h`; edge `k` of a ring runs from its vertex `k` to its
549/// vertex `k + 1` (the last back to the first); `vertex` indexes
550/// `outer ++ holes`.
551#[non_exhaustive]
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
553pub struct RingTouch {
554 /// The ring whose edge the vertex lies in.
555 pub ring: usize,
556 /// The edge of that ring.
557 pub edge: usize,
558 /// The vertex, by its index in `outer ++ holes`.
559 pub vertex: usize,
560}
561
562/// Every vertex lying inside another edge of `rings`, decided by the same
563/// exact `orient2d` tests with which [`triangulate_with`] under
564/// [`PinchPolicy::Accept`] inserts it into that edge (#265).
565///
566/// The triangulation then splits that edge at the vertex, so its ring edges
567/// are no longer the rings' edges. A caller whose rings share their edges
568/// with neighbouring patches (the faces of a closed shell) splits the
569/// neighbour's copy of each such edge at the same vertex, or the patches
570/// meet at a T-junction. Validation runs as in [`triangulate_with`] and in
571/// the same order, so the touches come in a deterministic order, each
572/// vertex and edge pair once; nothing is triangulated.
573///
574/// A ring with a seam run both ways (a keyhole, #270) is validated as
575/// [`triangulate_with`] validates it, without its seams: the touches are
576/// those of the loops the seams join, on their edges, which are the ring's
577/// other edges. No vertex lies inside a seam, and a seam's ends, where the
578/// ring meets itself, are shared vertices, not touches.
579///
580/// # Errors
581///
582/// As [`triangulate_with`] under [`PinchPolicy::Accept`] for rings that do
583/// not bound a region: too few, non-finite or repeated vertices, a ring
584/// folding back, rings that cross or overlap along an edge, a seam that is
585/// not one.
586pub fn ring_touches(rings: &Rings) -> GeomResult<Vec<RingTouch>> {
587 crate::ring_triangulation::ring_touches(&rings.outer, &rings.holes).map(|touches| {
588 touches
589 .into_iter()
590 .map(|(ring, edge, vertex)| RingTouch { ring, edge, vertex })
591 .collect()
592 })
593}
594
595/// A triangulation of a planar face of a closed shell, from
596/// [`triangulate_shell_face`] (#283).
597#[non_exhaustive]
598#[derive(Debug, Clone, PartialEq)]
599pub struct ShellFaceTriangulation {
600 /// `outer ++ holes`, then each of [`Self::steiner`] at the position
601 /// its chain of midpoints gives in the plane.
602 pub points: Vec<Point2>,
603 /// Counter-clockwise triangles over [`Self::points`].
604 pub triangles: Vec<[u32; 3]>,
605 /// The points added inside the face, in order: point `k` of these is
606 /// `points[outer.len() + holes' lengths + k]`.
607 pub steiner: Vec<SteinerPoint>,
608}
609
610/// A point [`triangulate_shell_face`] adds inside a face, as a chain of
611/// midpoints of the face's own points (#283): the midpoint `m` of the
612/// seam from `seam[0]` to `seam[1]`, then `halvings` times the midpoint
613/// of `m` and the point so far, starting from `toward`.
614///
615/// A caller that lifts the face back into space places the point with the
616/// same chain over its own points ([`Self::place`]). On a face lying in an
617/// axis plane, the midpoint of two points with that coordinate in common
618/// keeps it exactly, so the point lies exactly in the plane; elsewhere it
619/// lies within the rounding of the chain.
620#[non_exhaustive]
621#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
622pub struct SteinerPoint {
623 /// The seam's two ends, by index into
624 /// [`ShellFaceTriangulation::points`].
625 pub seam: [u32; 2],
626 /// The point the seam's midpoint is moved towards: a corner of one of
627 /// the seam's two triangles, possibly an earlier Steiner point.
628 pub toward: u32,
629 /// How often the point is moved half way, at least once.
630 pub halvings: u32,
631}
632
633impl SteinerPoint {
634 /// The point by its chain of midpoints, over any point type: `at`
635 /// gives a point by its index (earlier Steiner points included), and
636 /// `midpoint` the midpoint of two points.
637 pub fn place<T: Copy>(&self, at: impl Fn(u32) -> T, midpoint: impl Fn(T, T) -> T) -> T {
638 crate::ring_triangulation::Steiner {
639 seam: self.seam,
640 toward: self.toward,
641 halvings: self.halvings,
642 }
643 .place(at, midpoint)
644 }
645}
646
647/// Triangulate a planar face of a closed shell (#283): as
648/// [`triangulate_with`] under [`PinchPolicy::Accept`], and no triangle
649/// edge lies on a seam.
650///
651/// A seam (a keyhole's segment run both ways, #270) may be a triangle edge
652/// there. Inside one face that is only a diagonal, but other faces of a
653/// shell can run along the same segment (a rim's two end faces at its
654/// cut), and with the seam a triangle edge too that segment has four
655/// triangle uses. So where a seam would be a triangle edge, the two
656/// triangles on it are replaced: by the other diagonal of their
657/// quadrilateral where it is strictly convex, otherwise by four triangles
658/// around a [`SteinerPoint`] strictly inside it and off the seam's line
659/// (a thin rim seamed at its corner has no triangulation on its own
660/// vertices without the seam). A Steiner point adds no area and moves no
661/// boundary: the result is certified against the cover it replaces, the
662/// same boundary edges with every triangle strictly counter-clockwise.
663///
664/// Rings without a seam triangulate exactly as under
665/// [`PinchPolicy::Accept`], with no Steiner point.
666///
667/// # Errors
668///
669/// As [`triangulate_with`] under [`PinchPolicy::Accept`]; `Degenerate`
670/// naming the seam when no Steiner point can be placed off it, and when
671/// the result fails its certificate.
672pub fn triangulate_shell_face(rings: &Rings) -> GeomResult<ShellFaceTriangulation> {
673 check_ring_sizes(rings)?;
674 let (points, triangles, steiner) =
675 crate::ring_triangulation::triangulate_sealed(&rings.outer, &rings.holes)?;
676 Ok(ShellFaceTriangulation {
677 points,
678 triangles,
679 steiner: steiner
680 .into_iter()
681 .map(|s| SteinerPoint {
682 seam: s.seam,
683 toward: s.toward,
684 halvings: s.halvings,
685 })
686 .collect(),
687 })
688}
689
690/// Apply a 2D affine transform to a ring in place.
691fn apply2(ring: &mut [Point2], t: &axiolid_core::Transform2) {
692 for p in ring.iter_mut() {
693 *p = t.transform_point2(*p);
694 }
695}
696
697/// Flatten one closed contour into a point ring.
698///
699/// Consecutive duplicate points are dropped: adjoining segments share an
700/// endpoint by construction, and the triangulation refuses a repeated
701/// vertex as a zero-length edge.
702fn contour_points(
703 contour: &axiolid_profile::Contour,
704 chord_error: Scalar,
705 tolerance: Tolerance,
706) -> GeomResult<Vec<Point2>> {
707 let mut merged = 0.0;
708 contour_points_measured(contour, chord_error, tolerance, &mut merged)
709}
710
711/// [`contour_points`], also raising `merged` to the largest distance
712/// between a dropped near-duplicate point and the point it merged into
713/// (#232): dropping it moves the ring by no more than that.
714fn contour_points_measured(
715 contour: &axiolid_profile::Contour,
716 chord_error: Scalar,
717 tolerance: Tolerance,
718 merged: &mut Scalar,
719) -> GeomResult<Vec<Point2>> {
720 let mut out: Vec<Point2> = Vec::new();
721 let mut flattened = Vec::with_capacity(contour.segments.len());
722 for segment in &contour.segments {
723 let mut pts = segment_points(segment, chord_error)?;
724 if !segment.same_sense {
725 pts.reverse();
726 }
727 flattened.push(pts);
728 }
729 snap_joints(contour, &mut flattened, tolerance, merged);
730 for pts in flattened {
731 for p in pts {
732 match out.last() {
733 Some(last) if near2(*last, p, tolerance.linear()) => {
734 *merged = merged.max((*last - p).length());
735 }
736 _ => out.push(p),
737 }
738 }
739 }
740 // A closed ring must not repeat its first point as its last.
741 while out.len() > 1 && near2(out[0], *out.last().expect("non-empty"), tolerance.linear()) {
742 let dropped = out.pop().expect("non-empty");
743 *merged = merged.max((out[0] - dropped).length());
744 }
745 if out.len() < 3 {
746 return Err(GeomError::Degenerate(format!(
747 "contour flattened to {} points, need at least 3",
748 out.len()
749 )));
750 }
751 Ok(out)
752}
753
754/// Snap the two points at every joint of a contour to one exact point (#365).
755///
756/// The first and last point of a flattened segment are its endpoints, not
757/// samples, and how exactly a family evaluates one differs. A line at
758/// parameter 0 returns its stored origin; at any other parameter
759/// `origin + direction * t` rounds (twice for `t = 1`). A polyline returns a
760/// stored vertex. A curved segment goes through trigonometry, off by
761/// rounding (`0.5 * cos(pi / 2)` is `3e-17`, not `0`), so an arc ending on a
762/// revolution axis would lie beside the axis. At each joint whose two points
763/// lie within the linear tolerance, both become the more exact one, ranked:
764/// a line's endpoint at parameter 0, a polyline vertex, any other line
765/// endpoint, a curved endpoint; a tie keeps the earlier segment's end. Both
766/// segments then produce the bit-identical vertex. Only interior samples are
767/// ever trigonometric. A gap beyond the tolerance is left alone, as before.
768/// `moved` is raised to the distance a point moved, a rounding error far
769/// below the tolerance that [`profile_deviation`] adds to its bound.
770fn snap_joints(
771 contour: &axiolid_profile::Contour,
772 flattened: &mut [Vec<Point2>],
773 tolerance: Tolerance,
774 moved: &mut Scalar,
775) {
776 // Lower is more exact.
777 let rank = |segment: &axiolid_profile::ProfileSegment, at_end: bool| match &segment.curve {
778 Curve2::Line(_) => {
779 // The oriented end is the domain end for the forward sense.
780 let t = if at_end == segment.same_sense {
781 segment.domain.end
782 } else {
783 segment.domain.start
784 };
785 if t == 0.0 {
786 0
787 } else {
788 2
789 }
790 }
791 Curve2::Polyline(_) => 1,
792 _ => 3,
793 };
794 let n = flattened.len();
795 for i in 0..n {
796 let j = (i + 1) % n;
797 if i == j {
798 continue;
799 }
800 let (Some(&end), Some(&start)) = (flattened[i].last(), flattened[j].first()) else {
801 continue;
802 };
803 if !near2(end, start, tolerance.linear()) {
804 continue;
805 }
806 let joint = if rank(&contour.segments[j], false) < rank(&contour.segments[i], true) {
807 start
808 } else {
809 end
810 };
811 *moved = moved
812 .max((end - joint).length())
813 .max((start - joint).length());
814 if let Some(last) = flattened[i].last_mut() {
815 *last = joint;
816 }
817 if let Some(first) = flattened[j].first_mut() {
818 *first = joint;
819 }
820 }
821}
822
823/// Whether two points coincide within a linear tolerance.
824fn near2(a: Point2, b: Point2, linear: Scalar) -> bool {
825 (a.x - b.x).abs() <= linear && (a.y - b.y).abs() <= linear
826}
827
828/// Sample one bounded segment, flattening curves under the chord budget.
829///
830/// Delegates to `axiolid-reference`'s curve evaluator (ADR 0012). This crate used
831/// to carry a private circle flattener with a closed-form segment count, and
832/// refused ellipses and B-splines outright. Both limits are gone: the scalar
833/// evaluator subdivides adaptively on measured sagitta, so every declared
834/// `Curve2` family flattens under the same tolerance contract.
835///
836/// `MAX_SUBDIVISION_DEPTH` bounds the work. 24 levels is 16M potential
837/// segments -- far beyond any real tolerance -- so it is a runaway guard, not
838/// a quality knob.
839const MAX_SUBDIVISION_DEPTH: u32 = 24;
840
841fn segment_points(
842 segment: &axiolid_profile::ProfileSegment,
843 chord_error: Scalar,
844) -> GeomResult<Vec<Point2>> {
845 axiolid_reference::curve::flatten2(
846 &segment.curve,
847 segment.domain,
848 chord_error,
849 MAX_SUBDIVISION_DEPTH,
850 )
851}
852
853/// Force the ring-orientation convention the extruder expects:
854/// outer counter-clockwise, holes clockwise.
855///
856/// Source contours carry whatever orientation the authoring tool wrote, so
857/// normalising here is cheaper than rejecting otherwise-valid geometry.
858use axiolid_reference::signed_area2;
859
860fn orient_rings(mut outer: Vec<Point2>, mut holes: Vec<Vec<Point2>>) -> Rings {
861 if signed_area2(&outer) < 0.0 {
862 outer.reverse();
863 }
864 for hole in &mut holes {
865 if signed_area2(hole) > 0.0 {
866 hole.reverse();
867 }
868 }
869 Rings { outer, holes }
870}