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}