1use axiolid_contracts::{GeomError, GeomResult};
14use axiolid_core::{Point2, Scalar, Tolerance};
15use axiolid_curve::Curve2;
16use axiolid_profile::{CircleProfile, EllipseProfile, Profile, RectangleProfile};
17
18#[derive(Debug, Clone, Default, PartialEq)]
21pub struct Rings {
22 pub outer: Vec<Point2>,
24 pub holes: Vec<Vec<Point2>>,
26}
27
28pub fn profile_rings(
34 profile: &Profile,
35 chord_error: Scalar,
36 tolerance: Tolerance,
37) -> GeomResult<Rings> {
38 match profile {
39 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 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 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 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#[non_exhaustive]
97#[derive(Debug, Clone, Copy, PartialEq)]
98pub enum ProfileDeviation {
99 Bounded(Scalar),
101 Unbounded(&'static str),
103}
104
105impl ProfileDeviation {
106 #[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
130pub 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 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
212fn 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
223fn 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
242fn 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
261fn 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
275fn 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 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
317fn 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 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(); holes.push(ring);
340 }
341 Ok(Rings { outer, holes })
342}
343
344fn 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 ring.pop();
381 Ok(Rings {
382 outer: ring,
383 holes: Vec::new(),
384 })
385}
386
387fn 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 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
426pub fn triangulate(rings: &Rings) -> GeomResult<(Vec<Point2>, Vec<[u32; 3]>)> {
431 if rings.outer.len() < 3 {
432 return Err(GeomError::InvalidInput(format!(
433 "profile outer ring needs at least 3 vertices, got {}",
434 rings.outer.len()
435 )));
436 }
437 let mut verts: Vec<[Scalar; 2]> = rings.outer.iter().map(|p| [p.x, p.y]).collect();
438 let mut hole_starts = Vec::with_capacity(rings.holes.len());
439 for hole in &rings.holes {
440 if hole.len() < 3 {
441 return Err(GeomError::InvalidInput(format!(
442 "profile hole needs at least 3 vertices, got {}",
443 hole.len()
444 )));
445 }
446 hole_starts.push(verts.len());
447 verts.extend(hole.iter().map(|p| [p.x, p.y]));
448 }
449
450 let mut earcutter = earcut::Earcut::new();
451 let mut flat: Vec<usize> = Vec::new();
452 earcutter.earcut(verts.iter().copied(), &hole_starts, &mut flat);
453
454 if flat.is_empty() || flat.len() % 3 != 0 {
455 return Err(GeomError::Degenerate(format!(
456 "triangulation produced {} indices for a {}-vertex profile",
457 flat.len(),
458 verts.len()
459 )));
460 }
461 let tris = flat
462 .chunks_exact(3)
463 .map(|c| [c[0] as u32, c[1] as u32, c[2] as u32])
464 .collect();
465 let points = verts.into_iter().map(|v| Point2::new(v[0], v[1])).collect();
466 Ok((points, tris))
467}
468
469fn apply2(ring: &mut [Point2], t: &axiolid_core::Transform2) {
471 for p in ring.iter_mut() {
472 *p = t.transform_point2(*p);
473 }
474}
475
476fn contour_points(
482 contour: &axiolid_profile::Contour,
483 chord_error: Scalar,
484 tolerance: Tolerance,
485) -> GeomResult<Vec<Point2>> {
486 let mut merged = 0.0;
487 contour_points_measured(contour, chord_error, tolerance, &mut merged)
488}
489
490fn contour_points_measured(
494 contour: &axiolid_profile::Contour,
495 chord_error: Scalar,
496 tolerance: Tolerance,
497 merged: &mut Scalar,
498) -> GeomResult<Vec<Point2>> {
499 let mut out: Vec<Point2> = Vec::new();
500 for segment in &contour.segments {
501 let mut pts = segment_points(segment, chord_error)?;
502 if !segment.same_sense {
503 pts.reverse();
504 }
505 for p in pts {
506 match out.last() {
507 Some(last) if near2(*last, p, tolerance.linear()) => {
508 *merged = merged.max((*last - p).length());
509 }
510 _ => out.push(p),
511 }
512 }
513 }
514 while out.len() > 1 && near2(out[0], *out.last().expect("non-empty"), tolerance.linear()) {
516 let dropped = out.pop().expect("non-empty");
517 *merged = merged.max((out[0] - dropped).length());
518 }
519 if out.len() < 3 {
520 return Err(GeomError::Degenerate(format!(
521 "contour flattened to {} points, need at least 3",
522 out.len()
523 )));
524 }
525 Ok(out)
526}
527
528fn near2(a: Point2, b: Point2, linear: Scalar) -> bool {
530 (a.x - b.x).abs() <= linear && (a.y - b.y).abs() <= linear
531}
532
533const MAX_SUBDIVISION_DEPTH: u32 = 24;
545
546fn segment_points(
547 segment: &axiolid_profile::ProfileSegment,
548 chord_error: Scalar,
549) -> GeomResult<Vec<Point2>> {
550 axiolid_reference::curve::flatten2(
551 &segment.curve,
552 segment.domain,
553 chord_error,
554 MAX_SUBDIVISION_DEPTH,
555 )
556}
557
558use axiolid_reference::signed_area2;
564
565fn orient_rings(mut outer: Vec<Point2>, mut holes: Vec<Vec<Point2>>) -> Rings {
566 if signed_area2(&outer) < 0.0 {
567 outer.reverse();
568 }
569 for hole in &mut holes {
570 if signed_area2(hole) > 0.0 {
571 hole.reverse();
572 }
573 }
574 Rings { outer, holes }
575}