Skip to main content

neco_brep/
tessellate.rs

1//! Tessellation: B-Rep Shell to triangle mesh conversion.
2
3use neco_cdt::CdtError;
4
5use crate::brep::{Curve3D, Edge, Face, Shell, Surface};
6use crate::vec3;
7
8/// Triangle mesh.
9#[derive(Debug, Clone)]
10pub struct TriMesh {
11    pub vertices: Vec<[f64; 3]>,
12    pub normals: Vec<[f64; 3]>,     // per-vertex
13    pub triangles: Vec<[usize; 3]>, // vertex indices
14}
15
16#[derive(Debug, Clone, Copy)]
17struct TrimSample {
18    uv: [f64; 2],
19    point: [f64; 3],
20}
21
22impl TriMesh {
23    /// Create an empty mesh.
24    pub fn new() -> Self {
25        Self {
26            vertices: Vec::new(),
27            normals: Vec::new(),
28            triangles: Vec::new(),
29        }
30    }
31
32    /// Merge another mesh, offsetting indices.
33    pub fn merge(&mut self, other: &TriMesh) {
34        let offset = self.vertices.len();
35        self.vertices.extend_from_slice(&other.vertices);
36        self.normals.extend_from_slice(&other.normals);
37        for tri in &other.triangles {
38            self.triangles
39                .push([tri[0] + offset, tri[1] + offset, tri[2] + offset]);
40        }
41    }
42}
43
44impl Default for TriMesh {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50impl TriMesh {
51    /// Weld vertices within the given distance tolerance to produce a watertight mesh.
52    pub fn weld_vertices(&mut self, tolerance: f64) {
53        let n = self.vertices.len();
54        if n == 0 {
55            return;
56        }
57
58        // Compute vertex remap
59        let mut remap = vec![0usize; n];
60        let mut new_vertices: Vec<[f64; 3]> = Vec::new();
61        let mut new_normals: Vec<[f64; 3]> = Vec::new();
62        let tol_sq = tolerance * tolerance;
63
64        for (i, remap_slot) in remap.iter_mut().enumerate().take(n) {
65            let mut found = None;
66            for (j, nv) in new_vertices.iter().enumerate() {
67                let d = vec3::sub(self.vertices[i], *nv);
68                if d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < tol_sq {
69                    found = Some(j);
70                    break;
71                }
72            }
73            match found {
74                Some(j) => {
75                    *remap_slot = j;
76                }
77                None => {
78                    *remap_slot = new_vertices.len();
79                    new_vertices.push(self.vertices[i]);
80                    new_normals.push(self.normals[i]);
81                }
82            }
83        }
84
85        // Remap triangle indices
86        for tri in &mut self.triangles {
87            tri[0] = remap[tri[0]];
88            tri[1] = remap[tri[1]];
89            tri[2] = remap[tri[2]];
90        }
91
92        self.vertices = new_vertices;
93        self.normals = new_normals;
94    }
95}
96
97impl Shell {
98    /// Convert the Shell to a triangle mesh.
99    ///
100    /// `density` is the number of parametric subdivisions per axis.
101    /// Plane faces use constrained Delaunay triangulation (CDT).
102    pub fn tessellate(&self, density: usize) -> Result<TriMesh, CdtError> {
103        let density = density.max(2);
104        let mut mesh = TriMesh::new();
105
106        let debug = std::env::var("DEBUG_FACE_TESSELLATE").is_ok();
107        for (face_index, face) in self.faces.iter().enumerate() {
108            if debug {
109                println!(
110                    "tessellate face[{face_index}]: kind={} loop_edges={} reversed={}",
111                    tessellation_surface_kind(&face.surface),
112                    face.loop_edges.len(),
113                    face.orientation_reversed
114                );
115            }
116            let face_mesh = tessellate_face(face, &self.vertices, &self.edges, density)?;
117            if debug {
118                println!(
119                    "  -> vertices={} triangles={}",
120                    face_mesh.vertices.len(),
121                    face_mesh.triangles.len()
122                );
123            }
124            mesh.merge(&face_mesh);
125        }
126
127        // Weld duplicate vertices for watertightness
128        mesh.weld_vertices(1e-10);
129
130        Ok(mesh)
131    }
132}
133
134fn tessellation_surface_kind(surface: &Surface) -> &'static str {
135    match surface {
136        Surface::Plane { .. } => "Plane",
137        Surface::Cylinder { .. } => "Cylinder",
138        Surface::Cone { .. } => "Cone",
139        Surface::Sphere { .. } => "Sphere",
140        Surface::Ellipsoid { .. } => "Ellipsoid",
141        Surface::Torus { .. } => "Torus",
142        Surface::SurfaceOfRevolution { .. } => "SurfaceOfRevolution",
143        Surface::SurfaceOfSweep { .. } => "SurfaceOfSweep",
144        Surface::NurbsSurface { .. } => "NurbsSurface",
145    }
146}
147
148/// Tessellate a single Face.
149fn tessellate_face(
150    face: &Face,
151    vertices: &[[f64; 3]],
152    edges: &[Edge],
153    density: usize,
154) -> Result<TriMesh, CdtError> {
155    match &face.surface {
156        Surface::Plane { .. } => tessellate_plane(face, vertices, edges, density),
157        _ => tessellate_parametric(face, edges, density),
158    }
159}
160
161/// Tessellate a Plane face using CDT.
162///
163/// Treats loop_edges as a single outer loop. Inner loops (holes) are not yet supported.
164fn tessellate_plane(
165    face: &Face,
166    vertices: &[[f64; 3]],
167    edges: &[Edge],
168    density: usize,
169) -> Result<TriMesh, CdtError> {
170    // Sample curved boundary edges so plane trims follow arc / spline boundaries too.
171    let pts_3d = collect_loop_points(face, edges, density);
172    match tessellate_plane_polygon(face, &pts_3d) {
173        Ok(mesh) if !mesh.triangles.is_empty() => Ok(mesh),
174        Ok(_) | Err(_) => {
175            let fallback_pts = collect_loop_vertex_points(face, vertices, edges);
176            tessellate_plane_polygon(face, &fallback_pts)
177        }
178    }
179}
180
181fn tessellate_plane_polygon(face: &Face, pts_3d: &[[f64; 3]]) -> Result<TriMesh, CdtError> {
182    let mut normal = face.surface.normal_at(0.0, 0.0);
183    if face.orientation_reversed {
184        normal = vec3::scale(normal, -1.0);
185    }
186
187    // Build local coordinate frame
188    let n = vec3::normalized(normal);
189    let up = if n[0].abs() < 0.9 {
190        [1.0, 0.0, 0.0]
191    } else {
192        [0.0, 1.0, 0.0]
193    };
194    let u_vec = vec3::normalized(vec3::cross(n, up));
195    let v_vec = vec3::cross(n, u_vec);
196
197    if pts_3d.len() < 3 {
198        return Ok(TriMesh::new());
199    }
200
201    // Use first vertex as local origin
202    let origin = pts_3d[0];
203
204    // Project 3D to 2D
205    let pts_2d: Vec<[f64; 2]> = pts_3d
206        .iter()
207        .map(|p| {
208            let d = vec3::sub(*p, origin);
209            [vec3::dot(d, u_vec), vec3::dot(d, v_vec)]
210        })
211        .collect();
212
213    // Compute bounding box
214    let mut min_x = f64::INFINITY;
215    let mut min_y = f64::INFINITY;
216    let mut max_x = f64::NEG_INFINITY;
217    let mut max_y = f64::NEG_INFINITY;
218    for p in &pts_2d {
219        min_x = min_x.min(p[0]);
220        min_y = min_y.min(p[1]);
221        max_x = max_x.max(p[0]);
222        max_y = max_y.max(p[1]);
223    }
224    let margin = ((max_x - min_x).max(max_y - min_y)) * 0.01;
225    let bounds = (
226        min_x - margin,
227        min_y - margin,
228        max_x + margin,
229        max_y + margin,
230    );
231
232    // Triangulate via CDT
233    let mut cdt = neco_cdt::Cdt::new(bounds);
234    cdt.add_constraint_edges(&pts_2d, true)?;
235    let cdt_tris = cdt.triangles();
236
237    // Build result mesh
238    let mut mesh = TriMesh::new();
239
240    // Map CDT user_vertices back to 3D
241    let user_verts = cdt.user_vertices();
242    for uv in user_verts {
243        let p3d = vec3::add(
244            vec3::add(origin, vec3::scale(u_vec, uv[0])),
245            vec3::scale(v_vec, uv[1]),
246        );
247        mesh.vertices.push(p3d);
248        mesh.normals.push(normal);
249    }
250
251    // Add only triangles whose centroid lies inside the polygon
252    for tri in &cdt_tris {
253        // Centroid-in-polygon test
254        let centroid_2d = [
255            (user_verts[tri[0]][0] + user_verts[tri[1]][0] + user_verts[tri[2]][0]) / 3.0,
256            (user_verts[tri[0]][1] + user_verts[tri[1]][1] + user_verts[tri[2]][1]) / 3.0,
257        ];
258        if point_in_polygon_2d(centroid_2d, &pts_2d) {
259            mesh.triangles.push(*tri);
260        }
261    }
262
263    Ok(mesh)
264}
265
266/// Tessellate a parametric surface on a uniform grid.
267///
268/// If the face has trim edges, triangulate its UV trim loop via CDT.
269/// Otherwise, fall back to the full surface parameter range.
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271enum TrimmedParametricFailure {
272    TrimLoopUnavailable,
273    CdtFailure,
274    EmptyMesh,
275    TriangleLeakage,
276}
277
278fn tessellate_parametric(face: &Face, edges: &[Edge], density: usize) -> Result<TriMesh, CdtError> {
279    if face.loop_edges.is_empty() {
280        return Ok(tessellate_parametric_full(face, density));
281    }
282
283    let trim_samples = match try_collect_trim_loop_samples(face, edges, density) {
284        Ok(trim_samples) => trim_samples,
285        Err(failure) => {
286            return Ok(parametric_failure_mesh(
287                face,
288                density,
289                failure,
290                TriMesh::new(),
291            ));
292        }
293    };
294
295    let trimmed = if matches!(
296        face.surface,
297        Surface::Sphere { .. } | Surface::Ellipsoid { .. }
298    ) && (face.loop_edges.len() == 3
299        || trim_loop_touches_parametric_singularity(&trim_samples))
300    {
301        Ok(tessellate_parametric_fan(
302            face,
303            density.max(2),
304            &trim_samples,
305        ))
306    } else {
307        tessellate_parametric_trimmed(face, density, &trim_samples)
308    };
309
310    match trimmed {
311        Ok(mesh) => {
312            if mesh.triangles.is_empty() {
313                return Ok(parametric_failure_mesh(
314                    face,
315                    density,
316                    TrimmedParametricFailure::EmptyMesh,
317                    mesh,
318                ));
319            }
320            if triangles_stay_inside_trim(face, &mesh, &trim_samples) {
321                Ok(mesh)
322            } else {
323                Ok(parametric_failure_mesh(
324                    face,
325                    density,
326                    TrimmedParametricFailure::TriangleLeakage,
327                    mesh,
328                ))
329            }
330        }
331        Err(err) => {
332            if allow_parametric_full_fallback(&face.surface, TrimmedParametricFailure::CdtFailure) {
333                Ok(tessellate_parametric_full(face, density))
334            } else {
335                Err(err)
336            }
337        }
338    }
339}
340
341fn allow_parametric_full_fallback(surface: &Surface, failure: TrimmedParametricFailure) -> bool {
342    matches!(
343        surface,
344        Surface::SurfaceOfRevolution { .. }
345            | Surface::SurfaceOfSweep { .. }
346            | Surface::NurbsSurface { .. }
347    ) && matches!(
348        failure,
349        TrimmedParametricFailure::TrimLoopUnavailable | TrimmedParametricFailure::CdtFailure
350    )
351}
352
353fn parametric_failure_mesh(
354    face: &Face,
355    density: usize,
356    failure: TrimmedParametricFailure,
357    default_mesh: TriMesh,
358) -> TriMesh {
359    if allow_parametric_full_fallback(&face.surface, failure) {
360        tessellate_parametric_full(face, density)
361    } else {
362        default_mesh
363    }
364}
365
366/// Tessellate an untrimmed parametric surface on a uniform grid.
367fn tessellate_parametric_full(face: &Face, density: usize) -> TriMesh {
368    let (u_min, u_max, v_min, v_max) = face.surface.param_range();
369    let nu = density;
370    let nv = density;
371
372    let mut mesh = TriMesh::new();
373
374    // Generate grid vertices and normals
375    for iv in 0..=nv {
376        let v = v_min + (v_max - v_min) * iv as f64 / nv as f64;
377        for iu in 0..=nu {
378            let u = u_min + (u_max - u_min) * iu as f64 / nu as f64;
379            let p = face.surface.evaluate(u, v);
380            let mut n = face.surface.normal_at(u, v);
381            if face.orientation_reversed {
382                n = vec3::scale(n, -1.0);
383            }
384            mesh.vertices.push(p);
385            mesh.normals.push(n);
386        }
387    }
388
389    // Split quad grid into triangles
390    let cols = nu + 1;
391    for iv in 0..nv {
392        for iu in 0..nu {
393            let i00 = iv * cols + iu;
394            let i10 = iv * cols + (iu + 1);
395            let i01 = (iv + 1) * cols + iu;
396            let i11 = (iv + 1) * cols + (iu + 1);
397            mesh.triangles.push([i00, i10, i11]);
398            mesh.triangles.push([i00, i11, i01]);
399        }
400    }
401
402    mesh
403}
404
405fn tessellate_parametric_trimmed(
406    face: &Face,
407    density: usize,
408    trim_samples: &[TrimSample],
409) -> Result<TriMesh, CdtError> {
410    let trim_loop: Vec<[f64; 2]> = trim_samples.iter().map(|sample| sample.uv).collect();
411
412    if trim_loop.len() == 3 {
413        return Ok(tessellate_parametric_triangle(
414            face,
415            density.max(2),
416            [trim_loop[0], trim_loop[1], trim_loop[2]],
417        ));
418    }
419
420    let (min_u, max_u, min_v, max_v) = bounds_2d(&trim_loop);
421    let extent = (max_u - min_u).max(max_v - min_v).max(1e-9);
422    let margin = extent * 0.01;
423    let bounds = (
424        min_u - margin,
425        min_v - margin,
426        max_u + margin,
427        max_v + margin,
428    );
429
430    let mut cdt = neco_cdt::Cdt::new(bounds);
431    cdt.add_constraint_edges(&trim_loop, true)?;
432
433    let nu = density.max(2);
434    let nv = density.max(2);
435    let du = (max_u - min_u) / nu as f64;
436    let dv = (max_v - min_v) / nv as f64;
437    if du.is_finite() && dv.is_finite() && du > 0.0 && dv > 0.0 {
438        for iv in 0..nv {
439            let v = min_v + (iv as f64 + 0.5) * dv;
440            for iu in 0..nu {
441                let u = min_u + (iu as f64 + 0.5) * du;
442                let uv = [u, v];
443                if point_in_polygon_2d(uv, &trim_loop) {
444                    cdt.insert(u, v);
445                }
446            }
447        }
448    }
449
450    let user_verts = cdt.user_vertices();
451    let cdt_tris = cdt.triangles();
452    let mut mesh = TriMesh::new();
453    for uv in user_verts {
454        push_parametric_vertex_with_boundary(&mut mesh, face, *uv, trim_samples);
455    }
456
457    for tri in &cdt_tris {
458        let centroid = [
459            (user_verts[tri[0]][0] + user_verts[tri[1]][0] + user_verts[tri[2]][0]) / 3.0,
460            (user_verts[tri[0]][1] + user_verts[tri[1]][1] + user_verts[tri[2]][1]) / 3.0,
461        ];
462        if point_in_polygon_2d(centroid, &trim_loop) {
463            mesh.triangles.push(*tri);
464        }
465    }
466
467    Ok(mesh)
468}
469
470fn tessellate_parametric_triangle(
471    face: &Face,
472    density: usize,
473    triangle_uv: [[f64; 2]; 3],
474) -> TriMesh {
475    let mut mesh = TriMesh::new();
476    let mut row_starts = Vec::with_capacity(density + 1);
477    let n = density as f64;
478
479    for i in 0..=density {
480        row_starts.push(mesh.vertices.len());
481        for j in 0..=(density - i) {
482            let w0 = (density - i - j) as f64 / n;
483            let w1 = i as f64 / n;
484            let w2 = j as f64 / n;
485            let uv = [
486                w0 * triangle_uv[0][0] + w1 * triangle_uv[1][0] + w2 * triangle_uv[2][0],
487                w0 * triangle_uv[0][1] + w1 * triangle_uv[1][1] + w2 * triangle_uv[2][1],
488            ];
489            push_parametric_vertex(&mut mesh, face, uv);
490        }
491    }
492
493    let ccw = polygon_signed_area_2d(&triangle_uv) >= 0.0;
494    for i in 0..density {
495        let row_len = density - i + 1;
496        for j in 0..(row_len - 1) {
497            let a = row_starts[i] + j;
498            let b = row_starts[i] + j + 1;
499            let c = row_starts[i + 1] + j;
500            push_parametric_triangle(&mut mesh, [a, c, b], ccw);
501
502            if j < row_len - 2 {
503                let d = row_starts[i + 1] + j + 1;
504                push_parametric_triangle(&mut mesh, [b, c, d], ccw);
505            }
506        }
507    }
508
509    mesh
510}
511
512fn tessellate_parametric_fan(face: &Face, density: usize, trim_samples: &[TrimSample]) -> TriMesh {
513    let trim_loop: Vec<[f64; 2]> = trim_samples.iter().map(|sample| sample.uv).collect();
514    let mut mesh = TriMesh::new();
515    let ccw = polygon_signed_area_2d(&trim_loop) >= 0.0;
516    let n = trim_loop.len();
517    let center = average_polygon_point_2d(&trim_loop);
518    push_parametric_vertex(&mut mesh, face, center);
519    let center_id = 0usize;
520    let mut previous_ring = vec![center_id; n];
521
522    for level in 1..=density {
523        let t = level as f64 / density as f64;
524        let mut ring = Vec::with_capacity(n);
525        for (idx, boundary_uv) in trim_loop.iter().enumerate() {
526            let uv = [
527                center[0] * (1.0 - t) + boundary_uv[0] * t,
528                center[1] * (1.0 - t) + boundary_uv[1] * t,
529            ];
530            ring.push(mesh.vertices.len());
531            if level == density {
532                push_parametric_boundary_vertex(&mut mesh, face, uv, trim_samples[idx].point);
533            } else {
534                push_parametric_vertex(&mut mesh, face, uv);
535            }
536        }
537
538        if level == 1 {
539            for i in 0..n {
540                let next = (i + 1) % n;
541                push_parametric_triangle(&mut mesh, [center_id, ring[i], ring[next]], ccw);
542            }
543        } else {
544            for i in 0..n {
545                let next = (i + 1) % n;
546                let inner_a = previous_ring[i];
547                let inner_b = previous_ring[next];
548                let outer_a = ring[i];
549                let outer_b = ring[next];
550                push_parametric_triangle(&mut mesh, [inner_a, outer_a, outer_b], ccw);
551                push_parametric_triangle(&mut mesh, [inner_a, outer_b, inner_b], ccw);
552            }
553        }
554
555        previous_ring = ring;
556    }
557
558    mesh
559}
560
561fn trim_loop_touches_parametric_singularity(trim_samples: &[TrimSample]) -> bool {
562    let singular_tol = 1e-6;
563    trim_samples.iter().any(|sample| {
564        sample.uv[1].abs() <= singular_tol
565            || (std::f64::consts::PI - sample.uv[1]).abs() <= singular_tol
566    })
567}
568
569fn push_parametric_vertex(mesh: &mut TriMesh, face: &Face, uv: [f64; 2]) {
570    let p = face.surface.evaluate(uv[0], uv[1]);
571    push_parametric_boundary_vertex(mesh, face, uv, p);
572}
573
574fn push_parametric_boundary_vertex(mesh: &mut TriMesh, face: &Face, uv: [f64; 2], point: [f64; 3]) {
575    let mut n = face.surface.normal_at(uv[0], uv[1]);
576    if face.orientation_reversed {
577        n = vec3::scale(n, -1.0);
578    }
579    mesh.vertices.push(point);
580    mesh.normals.push(n);
581}
582
583fn push_parametric_vertex_with_boundary(
584    mesh: &mut TriMesh,
585    face: &Face,
586    uv: [f64; 2],
587    trim_samples: &[TrimSample],
588) {
589    if let Some(sample) = find_trim_sample(uv, trim_samples) {
590        push_parametric_boundary_vertex(mesh, face, uv, sample.point);
591    } else {
592        push_parametric_vertex(mesh, face, uv);
593    }
594}
595
596fn push_parametric_triangle(mesh: &mut TriMesh, triangle: [usize; 3], ccw: bool) {
597    if ccw {
598        mesh.triangles.push(triangle);
599    } else {
600        mesh.triangles.push([triangle[0], triangle[2], triangle[1]]);
601    }
602}
603
604#[cfg(test)]
605fn collect_trim_loop_samples(face: &Face, edges: &[Edge], density: usize) -> Vec<TrimSample> {
606    try_collect_trim_loop_samples(face, edges, density)
607        .expect("trim loop point must be inverse-projectable onto its face surface")
608}
609
610fn try_collect_trim_loop_samples(
611    face: &Face,
612    edges: &[Edge],
613    density: usize,
614) -> Result<Vec<TrimSample>, TrimmedParametricFailure> {
615    let periods = surface_param_periods(&face.surface);
616    let mut trim_loop = Vec::new();
617    let mut previous_uv = None;
618
619    for edge_ref in &face.loop_edges {
620        let edge = &edges[edge_ref.edge_id];
621        let mut samples = sample_edge_parametric(edge, &face.surface, density)?;
622        if !edge_ref.forward {
623            samples.reverse();
624        }
625        if !trim_loop.is_empty() && !samples.is_empty() {
626            samples.remove(0);
627        }
628        for sample in samples {
629            let raw_uv = normalize_singular_uv(&face.surface, sample.uv.into(), previous_uv);
630            let uv = if let Some(prev) = previous_uv {
631                unwrap_uv_near_reference(raw_uv, prev, periods)
632            } else {
633                [raw_uv.0, raw_uv.1]
634            };
635            if previous_uv.is_none_or(|prev| !same_point_2d(prev, uv, 1e-10)) {
636                trim_loop.push(TrimSample {
637                    uv,
638                    point: sample.point,
639                });
640                previous_uv = Some(uv);
641            }
642        }
643    }
644
645    simplify_trim_samples(&mut trim_loop, 1e-8);
646
647    if trim_loop.len() >= 2 {
648        let first = trim_loop[0].uv;
649        let last = trim_loop[trim_loop.len() - 1].uv;
650        if same_point_2d(first, last, 1e-10) {
651            trim_loop.pop();
652        }
653    }
654
655    if trim_loop.len() < 3 {
656        return Err(TrimmedParametricFailure::TrimLoopUnavailable);
657    }
658
659    Ok(trim_loop)
660}
661
662fn triangles_stay_inside_trim(face: &Face, mesh: &TriMesh, trim_samples: &[TrimSample]) -> bool {
663    let trim_loop: Vec<[f64; 2]> = trim_samples.iter().map(|sample| sample.uv).collect();
664    if trim_loop.len() < 3 {
665        return false;
666    }
667    let reference = average_polygon_point_2d(&trim_loop);
668    let periods = surface_param_periods(&face.surface);
669
670    mesh.triangles.iter().all(|tri| {
671        let mut uv = [[0.0, 0.0]; 3];
672        for (slot, vertex_id) in uv.iter_mut().zip(tri) {
673            let Some(raw) = face.surface.inverse_project(&mesh.vertices[*vertex_id]) else {
674                return false;
675            };
676            *slot = unwrap_uv_near_reference(raw, reference, periods);
677        }
678        let centroid = [
679            (uv[0][0] + uv[1][0] + uv[2][0]) / 3.0,
680            (uv[0][1] + uv[1][1] + uv[2][1]) / 3.0,
681        ];
682        point_in_polygon_2d(centroid, &trim_loop)
683    })
684}
685
686fn surface_param_periods(surface: &Surface) -> (Option<f64>, Option<f64>) {
687    let tau = std::f64::consts::TAU;
688    match surface {
689        Surface::Cylinder { .. }
690        | Surface::Cone { .. }
691        | Surface::Sphere { .. }
692        | Surface::Ellipsoid { .. } => (Some(tau), None),
693        Surface::Torus { .. } => (Some(tau), Some(tau)),
694        Surface::SurfaceOfRevolution { theta_range, .. } if *theta_range >= tau - 1e-12 => {
695            (Some(tau), None)
696        }
697        _ => (None, None),
698    }
699}
700
701fn normalize_singular_uv(
702    surface: &Surface,
703    uv: (f64, f64),
704    previous_uv: Option<[f64; 2]>,
705) -> (f64, f64) {
706    let singular_tol = 1e-6;
707    match surface {
708        Surface::Sphere { .. } | Surface::Ellipsoid { .. }
709            if uv.1.abs() <= singular_tol
710                || (std::f64::consts::PI - uv.1).abs() <= singular_tol =>
711        {
712            (previous_uv.map_or(uv.0, |prev| prev[0]), uv.1)
713        }
714        _ => uv,
715    }
716}
717
718fn unwrap_uv_near_reference(
719    uv: (f64, f64),
720    reference: [f64; 2],
721    periods: (Option<f64>, Option<f64>),
722) -> [f64; 2] {
723    [
724        unwrap_periodic_component(uv.0, reference[0], periods.0),
725        unwrap_periodic_component(uv.1, reference[1], periods.1),
726    ]
727}
728
729fn unwrap_periodic_component(value: f64, reference: f64, period: Option<f64>) -> f64 {
730    match period {
731        Some(period) if period > 0.0 => {
732            let shift = ((reference - value) / period).round();
733            value + shift * period
734        }
735        _ => value,
736    }
737}
738
739fn bounds_2d(points: &[[f64; 2]]) -> (f64, f64, f64, f64) {
740    let mut min_x = f64::INFINITY;
741    let mut min_y = f64::INFINITY;
742    let mut max_x = f64::NEG_INFINITY;
743    let mut max_y = f64::NEG_INFINITY;
744
745    for point in points {
746        min_x = min_x.min(point[0]);
747        min_y = min_y.min(point[1]);
748        max_x = max_x.max(point[0]);
749        max_y = max_y.max(point[1]);
750    }
751
752    (min_x, max_x, min_y, max_y)
753}
754
755fn same_point_2d(a: [f64; 2], b: [f64; 2], tol: f64) -> bool {
756    let dx = a[0] - b[0];
757    let dy = a[1] - b[1];
758    dx * dx + dy * dy <= tol * tol
759}
760
761fn polygon_signed_area_2d(points: &[[f64; 2]]) -> f64 {
762    let mut area = 0.0;
763    for i in 0..points.len() {
764        let p = points[i];
765        let q = points[(i + 1) % points.len()];
766        area += p[0] * q[1] - q[0] * p[1];
767    }
768    area * 0.5
769}
770
771fn average_polygon_point_2d(points: &[[f64; 2]]) -> [f64; 2] {
772    let sum = points
773        .iter()
774        .copied()
775        .fold([0.0, 0.0], |acc, p| [acc[0] + p[0], acc[1] + p[1]]);
776    [sum[0] / points.len() as f64, sum[1] / points.len() as f64]
777}
778
779fn simplify_trim_samples(points: &mut Vec<TrimSample>, tol: f64) {
780    if points.len() < 3 {
781        return;
782    }
783
784    loop {
785        let n = points.len();
786        let mut simplified = Vec::with_capacity(n);
787        let mut changed = false;
788
789        for i in 0..n {
790            let prev = points[(i + n - 1) % n];
791            let curr = points[i];
792            let next = points[(i + 1) % n];
793            if point_is_redundant_2d(prev.uv, curr.uv, next.uv, tol) {
794                changed = true;
795                continue;
796            }
797            simplified.push(curr);
798        }
799
800        if !changed || simplified.len() < 3 {
801            if simplified.len() >= 3 {
802                *points = simplified;
803            }
804            return;
805        }
806
807        *points = simplified;
808    }
809}
810
811fn point_is_redundant_2d(prev: [f64; 2], curr: [f64; 2], next: [f64; 2], tol: f64) -> bool {
812    if same_point_2d(prev, curr, tol) || same_point_2d(curr, next, tol) {
813        return true;
814    }
815
816    let ab = [next[0] - prev[0], next[1] - prev[1]];
817    let ap = [curr[0] - prev[0], curr[1] - prev[1]];
818    let cross = ab[0] * ap[1] - ab[1] * ap[0];
819    let scale = (ab[0] * ab[0] + ab[1] * ab[1]).sqrt().max(1.0);
820    if cross.abs() > tol * scale {
821        return false;
822    }
823
824    let dot = ap[0] * ab[0] + ap[1] * ab[1];
825    let ab_len_sq = ab[0] * ab[0] + ab[1] * ab[1];
826    dot >= 0.0 && dot <= ab_len_sq
827}
828
829/// Collect vertex positions from a Face's loop_edges.
830fn collect_loop_points(face: &Face, edges: &[Edge], density: usize) -> Vec<[f64; 3]> {
831    let mut pts = Vec::new();
832    for eref in &face.loop_edges {
833        let edge = &edges[eref.edge_id];
834        let mut points = sample_curve_parametric_points(&edge.curve, density);
835        if !eref.forward {
836            points.reverse();
837        }
838        if !pts.is_empty() && !points.is_empty() {
839            points.remove(0);
840        }
841        for point in points {
842            if pts
843                .last()
844                .is_none_or(|prev| vec3::distance(*prev, point) > 1e-10)
845            {
846                pts.push(point);
847            }
848        }
849    }
850    pts
851}
852
853fn collect_loop_vertex_points(face: &Face, vertices: &[[f64; 3]], edges: &[Edge]) -> Vec<[f64; 3]> {
854    let mut pts = Vec::with_capacity(face.loop_edges.len());
855    for edge_ref in &face.loop_edges {
856        let edge = &edges[edge_ref.edge_id];
857        let point = if edge_ref.forward {
858            vertices[edge.v_start]
859        } else {
860            vertices[edge.v_end]
861        };
862        if pts
863            .last()
864            .is_none_or(|prev| vec3::distance(*prev, point) > 1e-10)
865        {
866            pts.push(point);
867        }
868    }
869    pts
870}
871
872fn sample_curve_parametric_points(curve: &Curve3D, density: usize) -> Vec<[f64; 3]> {
873    let (t0, t1) = curve.param_range();
874    let tolerance = curve_sampling_tolerance(curve, density);
875    let mut points = vec![curve.evaluate(t0)];
876    sample_curve_parametric_segment(curve, t0, t1, tolerance, &mut points);
877    points
878}
879
880fn sample_curve_parametric_segment(
881    curve: &Curve3D,
882    t0: f64,
883    t1: f64,
884    tolerance: f64,
885    points: &mut Vec<[f64; 3]>,
886) {
887    if (t1 - t0).abs() < 1e-10 {
888        points.push(curve.evaluate(t1));
889        return;
890    }
891
892    let mid_t = (t0 + t1) * 0.5;
893    let p0 = curve.evaluate(t0);
894    let p1 = curve.evaluate(t1);
895    let mid_curve = curve.evaluate(mid_t);
896    let mid_chord = vec3::scale(vec3::add(p0, p1), 0.5);
897    let chord_height = vec3::length(vec3::sub(mid_curve, mid_chord));
898    if chord_height > tolerance {
899        sample_curve_parametric_segment(curve, t0, mid_t, tolerance, points);
900        sample_curve_parametric_segment(curve, mid_t, t1, tolerance, points);
901    } else {
902        points.push(curve.evaluate(t1));
903    }
904}
905
906fn curve_sampling_tolerance(curve: &Curve3D, density: usize) -> f64 {
907    let (t0, t1) = curve.param_range();
908    let p0 = curve.evaluate(t0);
909    let p1 = curve.evaluate(t1);
910    let pm = curve.evaluate((t0 + t1) * 0.5);
911    let scale = vec3::distance(p0, p1)
912        .max(vec3::distance(p0, pm))
913        .max(vec3::distance(pm, p1))
914        .max(1e-6);
915    scale / density.max(4) as f64 * 0.25
916}
917
918fn sample_edge_parametric(
919    edge: &Edge,
920    surface: &Surface,
921    density: usize,
922) -> Result<Vec<TrimSample>, TrimmedParametricFailure> {
923    let points = sample_curve_parametric_points(&edge.curve, density);
924    let mut samples = Vec::with_capacity(points.len());
925    for point in points {
926        let Some((u, v)) = surface.inverse_project(&point) else {
927            return Err(TrimmedParametricFailure::TrimLoopUnavailable);
928        };
929        samples.push(TrimSample { uv: [u, v], point });
930    }
931
932    Ok(samples)
933}
934
935fn find_trim_sample(uv: [f64; 2], trim_samples: &[TrimSample]) -> Option<TrimSample> {
936    trim_samples
937        .iter()
938        .copied()
939        .find(|sample| same_point_2d(sample.uv, uv, 1e-10))
940}
941
942/// 2D point-in-polygon test (ray casting).
943fn point_in_polygon_2d(point: [f64; 2], polygon: &[[f64; 2]]) -> bool {
944    let n = polygon.len();
945    if n < 3 {
946        return false;
947    }
948    let mut inside = false;
949    let (px, py) = (point[0], point[1]);
950    let mut j = n - 1;
951    for i in 0..n {
952        let (xi, yi) = (polygon[i][0], polygon[i][1]);
953        let (xj, yj) = (polygon[j][0], polygon[j][1]);
954        if ((yi > py) != (yj > py)) && (px < (xj - xi) * (py - yi) / (yj - yi) + xi) {
955            inside = !inside;
956        }
957        j = i;
958    }
959    inside
960}
961
962#[cfg(test)]
963mod tests {
964    use super::*;
965    use crate::brep::{Curve3D, EdgeRef, Surface};
966    use crate::primitives::{shell_from_cylinder, shell_from_sphere};
967
968    /// Build a unit cube (0,0,0)-(1,1,1) Shell.
969    fn make_box_shell() -> Shell {
970        let mut shell = Shell::new();
971
972        // 8 vertices
973        let v = [
974            shell.add_vertex([0.0, 0.0, 0.0]), // 0: ---
975            shell.add_vertex([1.0, 0.0, 0.0]), // 1: +--
976            shell.add_vertex([1.0, 1.0, 0.0]), // 2: ++-
977            shell.add_vertex([0.0, 1.0, 0.0]), // 3: -+-
978            shell.add_vertex([0.0, 0.0, 1.0]), // 4: --+
979            shell.add_vertex([1.0, 0.0, 1.0]), // 5: +-+
980            shell.add_vertex([1.0, 1.0, 1.0]), // 6: +++
981            shell.add_vertex([0.0, 1.0, 1.0]), // 7: -++
982        ];
983
984        // 12 line edges
985        // Bottom (z=0): 0-1, 1-2, 2-3, 3-0
986        let e_bot = [
987            shell.add_edge(
988                v[0],
989                v[1],
990                Curve3D::Line {
991                    start: [0.0, 0.0, 0.0],
992                    end: [1.0, 0.0, 0.0],
993                },
994            ),
995            shell.add_edge(
996                v[1],
997                v[2],
998                Curve3D::Line {
999                    start: [1.0, 0.0, 0.0],
1000                    end: [1.0, 1.0, 0.0],
1001                },
1002            ),
1003            shell.add_edge(
1004                v[2],
1005                v[3],
1006                Curve3D::Line {
1007                    start: [1.0, 1.0, 0.0],
1008                    end: [0.0, 1.0, 0.0],
1009                },
1010            ),
1011            shell.add_edge(
1012                v[3],
1013                v[0],
1014                Curve3D::Line {
1015                    start: [0.0, 1.0, 0.0],
1016                    end: [0.0, 0.0, 0.0],
1017                },
1018            ),
1019        ];
1020
1021        // Top (z=1): 4-5, 5-6, 6-7, 7-4
1022        let e_top = [
1023            shell.add_edge(
1024                v[4],
1025                v[5],
1026                Curve3D::Line {
1027                    start: [0.0, 0.0, 1.0],
1028                    end: [1.0, 0.0, 1.0],
1029                },
1030            ),
1031            shell.add_edge(
1032                v[5],
1033                v[6],
1034                Curve3D::Line {
1035                    start: [1.0, 0.0, 1.0],
1036                    end: [1.0, 1.0, 1.0],
1037                },
1038            ),
1039            shell.add_edge(
1040                v[6],
1041                v[7],
1042                Curve3D::Line {
1043                    start: [1.0, 1.0, 1.0],
1044                    end: [0.0, 1.0, 1.0],
1045                },
1046            ),
1047            shell.add_edge(
1048                v[7],
1049                v[4],
1050                Curve3D::Line {
1051                    start: [0.0, 1.0, 1.0],
1052                    end: [0.0, 0.0, 1.0],
1053                },
1054            ),
1055        ];
1056
1057        // Vertical: 0-4, 1-5, 2-6, 3-7
1058        let e_vert = [
1059            shell.add_edge(
1060                v[0],
1061                v[4],
1062                Curve3D::Line {
1063                    start: [0.0, 0.0, 0.0],
1064                    end: [0.0, 0.0, 1.0],
1065                },
1066            ),
1067            shell.add_edge(
1068                v[1],
1069                v[5],
1070                Curve3D::Line {
1071                    start: [1.0, 0.0, 0.0],
1072                    end: [1.0, 0.0, 1.0],
1073                },
1074            ),
1075            shell.add_edge(
1076                v[2],
1077                v[6],
1078                Curve3D::Line {
1079                    start: [1.0, 1.0, 0.0],
1080                    end: [1.0, 1.0, 1.0],
1081                },
1082            ),
1083            shell.add_edge(
1084                v[3],
1085                v[7],
1086                Curve3D::Line {
1087                    start: [0.0, 1.0, 0.0],
1088                    end: [0.0, 1.0, 1.0],
1089                },
1090            ),
1091        ];
1092
1093        // 6 faces
1094        // Bottom (z=0): normal -Z, CCW from outside -> 0-3-2-1 (reversed)
1095        shell.faces.push(Face {
1096            loop_edges: vec![
1097                EdgeRef {
1098                    edge_id: e_bot[3],
1099                    forward: false,
1100                }, // 0←3
1101                EdgeRef {
1102                    edge_id: e_bot[2],
1103                    forward: false,
1104                }, // 3←2
1105                EdgeRef {
1106                    edge_id: e_bot[1],
1107                    forward: false,
1108                }, // 2←1
1109                EdgeRef {
1110                    edge_id: e_bot[0],
1111                    forward: false,
1112                }, // 1←0
1113            ],
1114            surface: Surface::Plane {
1115                origin: [0.0, 0.0, 0.0],
1116                normal: [0.0, 0.0, -1.0],
1117            },
1118            orientation_reversed: false,
1119        });
1120
1121        // Top (z=1): normal +Z, edges 4-5-6-7
1122        shell.faces.push(Face {
1123            loop_edges: vec![
1124                EdgeRef {
1125                    edge_id: e_top[0],
1126                    forward: true,
1127                },
1128                EdgeRef {
1129                    edge_id: e_top[1],
1130                    forward: true,
1131                },
1132                EdgeRef {
1133                    edge_id: e_top[2],
1134                    forward: true,
1135                },
1136                EdgeRef {
1137                    edge_id: e_top[3],
1138                    forward: true,
1139                },
1140            ],
1141            surface: Surface::Plane {
1142                origin: [0.0, 0.0, 1.0],
1143                normal: [0.0, 0.0, 1.0],
1144            },
1145            orientation_reversed: false,
1146        });
1147
1148        // Front (y=0): normal -Y, edges 0-1-5-4
1149        shell.faces.push(Face {
1150            loop_edges: vec![
1151                EdgeRef {
1152                    edge_id: e_bot[0],
1153                    forward: true,
1154                }, // 0→1
1155                EdgeRef {
1156                    edge_id: e_vert[1],
1157                    forward: true,
1158                }, // 1→5
1159                EdgeRef {
1160                    edge_id: e_top[0],
1161                    forward: false,
1162                }, // 5←4
1163                EdgeRef {
1164                    edge_id: e_vert[0],
1165                    forward: false,
1166                }, // 4←0
1167            ],
1168            surface: Surface::Plane {
1169                origin: [0.0, 0.0, 0.0],
1170                normal: [0.0, -1.0, 0.0],
1171            },
1172            orientation_reversed: false,
1173        });
1174
1175        // Right (x=1): normal +X, edges 1-2-6-5
1176        shell.faces.push(Face {
1177            loop_edges: vec![
1178                EdgeRef {
1179                    edge_id: e_bot[1],
1180                    forward: true,
1181                }, // 1→2
1182                EdgeRef {
1183                    edge_id: e_vert[2],
1184                    forward: true,
1185                }, // 2→6
1186                EdgeRef {
1187                    edge_id: e_top[1],
1188                    forward: false,
1189                }, // 6←5
1190                EdgeRef {
1191                    edge_id: e_vert[1],
1192                    forward: false,
1193                }, // 5←1
1194            ],
1195            surface: Surface::Plane {
1196                origin: [1.0, 0.0, 0.0],
1197                normal: [1.0, 0.0, 0.0],
1198            },
1199            orientation_reversed: false,
1200        });
1201
1202        // Back (y=1): normal +Y, edges 2-3-7-6
1203        shell.faces.push(Face {
1204            loop_edges: vec![
1205                EdgeRef {
1206                    edge_id: e_bot[2],
1207                    forward: true,
1208                }, // 2→3
1209                EdgeRef {
1210                    edge_id: e_vert[3],
1211                    forward: true,
1212                }, // 3→7
1213                EdgeRef {
1214                    edge_id: e_top[2],
1215                    forward: false,
1216                }, // 7←6
1217                EdgeRef {
1218                    edge_id: e_vert[2],
1219                    forward: false,
1220                }, // 6←2
1221            ],
1222            surface: Surface::Plane {
1223                origin: [0.0, 1.0, 0.0],
1224                normal: [0.0, 1.0, 0.0],
1225            },
1226            orientation_reversed: false,
1227        });
1228
1229        // Left (x=0): normal -X, edges 3-0-4-7
1230        shell.faces.push(Face {
1231            loop_edges: vec![
1232                EdgeRef {
1233                    edge_id: e_bot[3],
1234                    forward: true,
1235                }, // 3→0
1236                EdgeRef {
1237                    edge_id: e_vert[0],
1238                    forward: true,
1239                }, // 0→4
1240                EdgeRef {
1241                    edge_id: e_top[3],
1242                    forward: false,
1243                }, // 4←7
1244                EdgeRef {
1245                    edge_id: e_vert[3],
1246                    forward: false,
1247                }, // 7←3
1248            ],
1249            surface: Surface::Plane {
1250                origin: [0.0, 0.0, 0.0],
1251                normal: [-1.0, 0.0, 0.0],
1252            },
1253            orientation_reversed: false,
1254        });
1255
1256        shell
1257    }
1258
1259    #[test]
1260    fn manual_box_tessellation() {
1261        let shell = make_box_shell();
1262        let mesh = shell.tessellate(4).unwrap();
1263
1264        // 6 faces x 4 vertices = 24 vertices (each face has independent vertices)
1265        assert!(!mesh.vertices.is_empty(), "vertices empty");
1266        assert!(
1267            mesh.triangles.len() >= 12,
1268            "too few triangles: {}",
1269            mesh.triangles.len()
1270        );
1271
1272        let v = mesh.validate();
1273        assert!(v.is_watertight, "not watertight");
1274        assert!(v.is_consistently_oriented, "inconsistent orientation");
1275        assert!(v.has_no_degenerate_faces, "degenerate faces exist");
1276        assert_eq!(v.euler_number, 2, "euler number != 2: {}", v.euler_number);
1277        assert!(
1278            v.signed_volume > 0.0,
1279            "signed volume not positive: {}",
1280            v.signed_volume
1281        );
1282        assert!(
1283            (v.signed_volume - 1.0).abs() < 1e-6,
1284            "volume != 1.0: {}",
1285            v.signed_volume
1286        );
1287        assert_eq!(
1288            v.n_connected_components, 1,
1289            "connected components != 1: {}",
1290            v.n_connected_components
1291        );
1292    }
1293
1294    #[test]
1295    fn parametric_sphere_tessellation() {
1296        let mut shell = Shell::new();
1297        shell.faces.push(Face {
1298            loop_edges: vec![],
1299            surface: Surface::Sphere {
1300                center: [0.0, 0.0, 0.0],
1301                radius: 1.0,
1302            },
1303            orientation_reversed: false,
1304        });
1305        let mesh = shell.tessellate(16).unwrap();
1306        assert!(!mesh.vertices.is_empty());
1307        assert!(!mesh.triangles.is_empty());
1308        // 16*16*2 = 512 triangles
1309        // Welding merges poles and u=0/u=2pi seam, so vertex count < 289
1310        assert_eq!(mesh.triangles.len(), 16 * 16 * 2);
1311        assert!(
1312            mesh.vertices.len() < 17 * 17,
1313            "poles and seam should be welded"
1314        );
1315        assert!(mesh.vertices.len() > 100, "too few vertices");
1316    }
1317
1318    #[test]
1319    fn point_in_polygon_basic() {
1320        let polygon = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
1321        assert!(point_in_polygon_2d([0.5, 0.5], &polygon));
1322        assert!(!point_in_polygon_2d([1.5, 0.5], &polygon));
1323        assert!(!point_in_polygon_2d([-0.5, 0.5], &polygon));
1324    }
1325
1326    #[test]
1327    fn sample_curve_parametric_points_line_and_arc() {
1328        let line = Curve3D::Line {
1329            start: [0.0, 0.0, 0.0],
1330            end: [1.0, 0.0, 0.0],
1331        };
1332        let line_points = sample_curve_parametric_points(&line, 8);
1333        assert_eq!(line_points.len(), 2);
1334        assert!(vec3::distance(line_points[0], [0.0, 0.0, 0.0]) < 1e-12);
1335        assert!(vec3::distance(line_points[1], [1.0, 0.0, 0.0]) < 1e-12);
1336
1337        let arc = Curve3D::Arc {
1338            center: [0.0, 0.0, 0.0],
1339            axis: [0.0, 0.0, 1.0],
1340            start: [1.0, 0.0, 0.0],
1341            end: [0.0, 1.0, 0.0],
1342            radius: 1.0,
1343        };
1344        let arc_points = sample_curve_parametric_points(&arc, 8);
1345        assert!(arc_points.len() > 2);
1346        assert!(vec3::distance(arc_points[0], [1.0, 0.0, 0.0]) < 1e-12);
1347        assert!(vec3::distance(*arc_points.last().unwrap(), [0.0, 1.0, 0.0]) < 1e-12);
1348    }
1349
1350    #[test]
1351    fn collect_loop_points_uses_parametric_sampling_for_full_ellipse() {
1352        let ellipse = Curve3D::Ellipse {
1353            center: [0.0, 0.0, 0.0],
1354            axis_u: [1.0, 0.0, 0.0],
1355            axis_v: [0.0, 0.5, 0.0],
1356            t_start: 0.0,
1357            t_end: std::f64::consts::TAU,
1358        };
1359        let face = Face {
1360            loop_edges: vec![EdgeRef {
1361                edge_id: 0,
1362                forward: true,
1363            }],
1364            surface: Surface::Plane {
1365                origin: [0.0, 0.0, 0.0],
1366                normal: [0.0, 0.0, 1.0],
1367            },
1368            orientation_reversed: false,
1369        };
1370        let edges = vec![Edge {
1371            v_start: 0,
1372            v_end: 0,
1373            curve: ellipse,
1374        }];
1375
1376        let points = collect_loop_points(&face, &edges, 12);
1377        assert!(
1378            points.len() > 8,
1379            "ellipse boundary should be adaptively sampled"
1380        );
1381        assert!(vec3::distance(points[0], [1.0, 0.0, 0.0]) < 1e-12);
1382    }
1383
1384    fn average_uv(points: &[[f64; 2]]) -> [f64; 2] {
1385        let sum = points
1386            .iter()
1387            .copied()
1388            .fold([0.0, 0.0], |acc, p| [acc[0] + p[0], acc[1] + p[1]]);
1389        [sum[0] / points.len() as f64, sum[1] / points.len() as f64]
1390    }
1391
1392    fn assert_triangles_stay_inside_trim(shell: &Shell, face_index: usize, density: usize) {
1393        let face = &shell.faces[face_index];
1394        let trim_samples = collect_trim_loop_samples(face, &shell.edges, density);
1395        let trim_loop: Vec<[f64; 2]> = trim_samples.iter().map(|sample| sample.uv).collect();
1396        let reference = average_uv(&trim_loop);
1397        let periods = surface_param_periods(&face.surface);
1398        let mesh = tessellate_face(face, &shell.vertices, &shell.edges, density).unwrap();
1399
1400        assert!(
1401            !mesh.triangles.is_empty(),
1402            "trimmed face produced no triangles"
1403        );
1404        for tri in &mesh.triangles {
1405            let mut uv = [[0.0, 0.0]; 3];
1406            for (slot, vertex_id) in uv.iter_mut().zip(tri) {
1407                let raw = face
1408                    .surface
1409                    .inverse_project(&mesh.vertices[*vertex_id])
1410                    .unwrap();
1411                *slot = unwrap_uv_near_reference(raw, reference, periods);
1412            }
1413            let centroid = [
1414                (uv[0][0] + uv[1][0] + uv[2][0]) / 3.0,
1415                (uv[0][1] + uv[1][1] + uv[2][1]) / 3.0,
1416            ];
1417            assert!(
1418                point_in_polygon_2d(centroid, &trim_loop),
1419                "triangle centroid escaped trim loop: face_index={face_index} centroid={centroid:?} trim_loop={trim_loop:?}"
1420            );
1421        }
1422    }
1423
1424    #[test]
1425    fn sphere_trim_crossing_seam_stays_inside_trim() {
1426        let shell = shell_from_sphere(1.0);
1427        assert_triangles_stay_inside_trim(&shell, 3, 12);
1428    }
1429
1430    #[test]
1431    fn cylinder_trim_crossing_seam_stays_inside_trim() {
1432        let shell = shell_from_cylinder(1.0, None, 2.0);
1433        assert_triangles_stay_inside_trim(&shell, 3, 12);
1434    }
1435}