ifc_lite_geometry/
extrusion.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Extrusion operations - converting 2D profiles to 3D meshes
6
7use crate::error::{Error, Result};
8use crate::mesh::Mesh;
9use crate::profile::{Profile2D, Profile2DWithVoids, Triangulation, VoidInfo};
10use crate::extrusion_generic::{
11    apply_transform_generic, create_cap_mesh, create_side_walls, extrude_rings_into,
12    outward_is_right_hand, Ring,
13};
14use nalgebra::{Matrix4, Point2, Point3, Vector3};
15
16/// Extrude a 2D profile along the Z axis
17#[inline]
18pub fn extrude_profile(
19    profile: &Profile2D,
20    depth: f64,
21    transform: Option<Matrix4<f64>>,
22) -> Result<Mesh> {
23    let mut mesh = Mesh::new();
24    extrude_rings_into(&profile.outer, &profile.holes, depth, transform, &mut mesh)?;
25    Ok(mesh)
26}
27
28/// Watertight polygon-with-holes extrude for the 2D opening-subtraction path
29/// ([`crate::router`]'s `bool2d_path`). The caps are triangulated by the
30/// boundary-preserving constrained triangulator
31/// ([`crate::cdt::triangulate_constrained`]) instead of earcut: an earcut cap
32/// over a many-hole profile sprouts hole-bridge slivers that leave it
33/// non-manifold and that `clean_degenerate` later drops into cracks. The
34/// constrained triangulator performs NO refinement and adds NO Steiner points;
35/// it keeps every profile-ring edge un-subdivided, so the caps close bit-for-bit
36/// against the side walls and the cap↔wall seam stays watertight.
37#[inline]
38pub fn extrude_profile_watertight(
39    profile: &Profile2D,
40    depth: f64,
41    transform: Option<Matrix4<f64>>,
42) -> Result<Mesh> {
43    if depth <= 0.0 {
44        return Err(Error::InvalidExtrusion(
45            "Depth must be positive".to_string(),
46        ));
47    }
48    if profile.outer.len() < 3 {
49        return Err(Error::InvalidExtrusion(
50            "Outer boundary needs >= 3 vertices".to_string(),
51        ));
52    }
53    // Reject degenerate hole rings BEFORE triangulation: `triangulate_constrained`
54    // silently drops a < 3-vertex ring from the cap, but `create_side_walls` below
55    // would still emit its walls (cap/wall mismatch → cracked mesh). Erroring here
56    // defers the whole cut to the exact kernel (`bool2d_path`'s `.ok()?`).
57    if profile.holes.iter().any(|h| h.len() < 3) {
58        return Err(Error::InvalidExtrusion(
59            "Hole ring needs >= 3 vertices".to_string(),
60        ));
61    }
62    // Unrefined CDT caps: manifold (no earcut hole-bridge slivers) and fast (no
63    // Ruppert refinement, which dominates on a large multi-hole face). Fall back
64    // to the earcut polygon-with-holes triangulation if the CDT declines.
65    let (points, indices) =
66        match crate::cdt::triangulate_constrained(&profile.outer, &profile.holes) {
67            Some(t) => t,
68            None => {
69                let mut all: Vec<nalgebra::Point2<f64>> = profile.outer.clone();
70                for h in &profile.holes {
71                    all.extend_from_slice(h);
72                }
73                let idx = crate::triangulation::triangulate_polygon_with_holes(
74                    &profile.outer,
75                    &profile.holes,
76                )
77                .map_err(|e| Error::InvalidExtrusion(format!("cap triangulation failed: {e}")))?;
78                (all, idx)
79            }
80        };
81    let tri = Triangulation { points, indices };
82
83    let mut mesh = Mesh::new();
84    create_cap_mesh(&tri, 0.0, Vector3::new(0.0, 0.0, -1.0), &mut mesh);
85    create_cap_mesh(&tri, depth, Vector3::new(0.0, 0.0, 1.0), &mut mesh);
86    create_side_walls(&profile.outer, 0.0, depth, Ring::Outer, &mut mesh);
87    for hole in &profile.holes {
88        create_side_walls(hole, 0.0, depth, Ring::Hole, &mut mesh);
89    }
90    // The CDT emits cap triangles in arbitrary orientation, so the assembled
91    // solid closes as a 2-manifold but with INCONSISTENT winding — harmless for
92    // the (undirected) watertight check and for per-vertex-normal rendering, but
93    // it breaks the divergence volume that downstream consumers (and the #1297
94    // local-frame regression test) compute. Flood-fill a consistent OUTWARD
95    // orientation across the closed surface; this only flips triangle winding
96    // (per-face vertices are never welded, so creases / flat shading are intact).
97    crate::mesh_orient::orient_mesh_outward(&mut mesh);
98    if let Some(mat) = transform {
99        apply_transform(&mut mesh, &mat);
100    }
101    Ok(mesh)
102}
103
104/// Extrude a 2D profile with void awareness
105///
106/// This function handles both through-voids and partial-depth voids:
107/// - Through voids: Added as holes to the profile before extrusion
108/// - Partial-depth voids: Generate internal caps at depth boundaries
109///
110/// # Arguments
111/// * `profile_with_voids` - Profile with classified void information
112/// * `depth` - Total extrusion depth
113/// * `transform` - Optional transformation matrix
114///
115/// # Returns
116/// The extruded mesh with voids properly handled
117#[inline]
118pub fn extrude_profile_with_voids(
119    profile_with_voids: &Profile2DWithVoids,
120    depth: f64,
121    transform: Option<Matrix4<f64>>,
122) -> Result<Mesh> {
123    if depth <= 0.0 {
124        return Err(Error::InvalidExtrusion(
125            "Depth must be positive".to_string(),
126        ));
127    }
128
129    // Create profile with through-voids as holes
130    let profile_with_holes = profile_with_voids.profile_with_through_holes();
131
132    // Each outer cap is also opened by the partial voids that reach it: a
133    // pocket entering from the bottom face is a hole in the bottom cap (#5410;
134    // a full cap there left the pocket's walls hanging off a closed lid).
135    let cap_profile = |at_bottom: bool| {
136        let mut p = profile_with_holes.clone();
137        for v in profile_with_voids.partial_voids() {
138            let reaches = if at_bottom {
139                v.depth_start <= PARTIAL_VOID_EPSILON
140            } else {
141                v.depth_end >= depth - PARTIAL_VOID_EPSILON
142            };
143            if reaches && v.contour.len() >= 3 {
144                p.add_hole(v.contour.clone());
145            }
146        }
147        p
148    };
149    let bottom_triangulation = cap_profile(true).triangulate()?;
150    let triangulation = cap_profile(false).triangulate()?;
151
152    // Estimate capacity
153    let partial_void_count = profile_with_voids.partial_voids().count();
154
155    let vertex_count = triangulation.points.len() * 2;
156    let side_vertex_count = profile_with_holes.outer.len() * 2
157        + profile_with_holes
158            .holes
159            .iter()
160            .map(|h| h.len() * 2)
161            .sum::<usize>();
162    let partial_void_vertices = partial_void_count * 100; // Estimate
163    let total_vertices = vertex_count + side_vertex_count + partial_void_vertices;
164
165    let mut mesh = Mesh::with_capacity(
166        total_vertices,
167        triangulation.indices.len() * 2 + profile_with_holes.outer.len() * 6,
168    );
169
170    // Create top and bottom caps (with through-void holes included)
171    create_cap_mesh(&bottom_triangulation, 0.0, Vector3::new(0.0, 0.0, -1.0), &mut mesh);
172    create_cap_mesh(
173        &triangulation,
174        depth,
175        Vector3::new(0.0, 0.0, 1.0),
176        &mut mesh,
177    );
178
179    // Create side walls for outer boundary
180    create_side_walls(&profile_with_holes.outer, 0.0, depth, Ring::Outer, &mut mesh);
181
182    // Create side walls for holes (including through-voids)
183    for hole in &profile_with_holes.holes {
184        create_side_walls(hole, 0.0, depth, Ring::Hole, &mut mesh);
185    }
186
187    // Handle partial-depth voids
188    for void in profile_with_voids.partial_voids() {
189        create_partial_void_geometry(void, depth, &mut mesh)?;
190    }
191
192    // Apply transformation if provided
193    if let Some(mat) = transform {
194        apply_transform(&mut mesh, &mat);
195    }
196
197    Ok(mesh)
198}
199
200/// Tolerance for a partial void reaching an outer cap.
201const PARTIAL_VOID_EPSILON: f64 = 0.001;
202
203/// Create geometry for a partial-depth void: its side walls between its two
204/// depths, and an internal cap at each depth that is inside the solid. Every
205/// face is wound out of the solid, i.e. into the void: the walls as a
206/// [`Ring::Hole`], the floor at `depth_start` facing +Z and the ceiling at
207/// `depth_end` facing -Z (#5410; they faced the other way).
208fn create_partial_void_geometry(void: &VoidInfo, total_depth: f64, mesh: &mut Mesh) -> Result<()> {
209    if void.contour.len() < 3 {
210        return Ok(());
211    }
212    let void_triangulation = match Profile2D::new(void.contour.clone()).triangulate() {
213        Ok(t) => t,
214        Err(_) => return Ok(()), // Skip if triangulation fails
215    };
216    // Floor: the triangulation's own (CCW, +Z) winding at a non-zero depth.
217    if void.depth_start > PARTIAL_VOID_EPSILON {
218        create_cap_mesh(&void_triangulation, void.depth_start, Vector3::new(0.0, 0.0, 1.0), mesh);
219    }
220    // Ceiling: the same cap, mirrored to face -Z.
221    if void.depth_end < total_depth - PARTIAL_VOID_EPSILON {
222        let first = mesh.indices.len();
223        create_cap_mesh(&void_triangulation, void.depth_end, Vector3::new(0.0, 0.0, -1.0), mesh);
224        for tri in mesh.indices[first..].chunks_exact_mut(3) {
225            tri.swap(1, 2);
226        }
227    }
228    if void.depth_end - void.depth_start > PARTIAL_VOID_EPSILON {
229        create_side_walls(&void.contour, void.depth_start, void.depth_end, Ring::Hole, mesh);
230    }
231    Ok(())
232}
233
234/// Extrude with a different cross section at the top (lofted/tapered extrusion).
235///
236/// Used for `IfcExtrudedAreaSolidTapered`. The two profiles must share topology
237/// per the IFC WR2 constraint, which we trust to mean **same winding direction
238/// and corresponding start vertex** — the side walls are stitched 1:1 by
239/// vertex index, so reversed winding or rotated start indices would cross.
240/// In practice authoring tools sometimes emit loops with different vertex
241/// counts; we resample the shorter loop to the longer one's length (by arc
242/// length) so a side wall can always be built. Holes are paired by index; any
243/// pair where either side has fewer than 3 vertices is dropped from both caps
244/// so the mesh stays manifold.
245#[inline]
246pub fn extrude_profile_lofted(
247    start: &Profile2D,
248    end: &Profile2D,
249    depth: f64,
250    transform: Option<Matrix4<f64>>,
251) -> Result<Mesh> {
252    if depth <= 0.0 {
253        return Err(Error::InvalidExtrusion(
254            "Depth must be positive".to_string(),
255        ));
256    }
257    if start.outer.len() < 3 || end.outer.len() < 3 {
258        return Err(Error::InvalidProfile(
259            "Lofted extrusion requires both profiles to have ≥3 vertices".to_string(),
260        ));
261    }
262
263    // Match outer-loop vertex counts so we can pair sides 1:1.
264    let (outer_start, outer_end) = match_loop_lengths(&start.outer, &end.outer);
265    let n = outer_start.len();
266    debug_assert_eq!(n, outer_end.len());
267
268    // Pair holes by index, dropping any pair where either side has <3 verts
269    // (e.g. one profile has an extra hole). The caps must reflect only the
270    // holes we'll actually loft, otherwise the cap shows an opening with no
271    // matching side wall and the mesh becomes non-manifold.
272    let lofted_hole_pairs: Vec<(Vec<Point2<f64>>, Vec<Point2<f64>>)> = start
273        .holes
274        .iter()
275        .zip(end.holes.iter())
276        .filter(|(s, e)| s.len() >= 3 && e.len() >= 3)
277        .map(|(s, e)| match_loop_lengths(s, e))
278        .collect();
279
280    // Build filtered profiles so cap triangulation only sees the holes we loft.
281    let mut start_profile = Profile2D::new(outer_start.clone());
282    let mut end_profile = Profile2D::new(outer_end.clone());
283    for (sh, eh) in &lofted_hole_pairs {
284        start_profile.add_hole(sh.clone());
285        end_profile.add_hole(eh.clone());
286    }
287
288    // Triangulate each cap independently (silhouettes differ).
289    let start_tri = start_profile.triangulate()?;
290    let end_tri = end_profile.triangulate()?;
291    let cap_vertex_count = start_tri.points.len() + end_tri.points.len();
292    let cap_index_count = start_tri.indices.len() + end_tri.indices.len();
293    let side_vertex_count =
294        n * 4 + lofted_hole_pairs.iter().map(|(s, _)| s.len() * 4).sum::<usize>();
295    let mut mesh = Mesh::with_capacity(
296        cap_vertex_count + side_vertex_count,
297        cap_index_count + n * 6,
298    );
299    create_cap_mesh(&start_tri, 0.0, Vector3::new(0.0, 0.0, -1.0), &mut mesh);
300    create_cap_mesh(&end_tri, depth, Vector3::new(0.0, 0.0, 1.0), &mut mesh);
301    create_lofted_side_walls(&outer_start, &outer_end, depth, Ring::Outer, &mut mesh);
302    for (sh, eh) in &lofted_hole_pairs {
303        create_lofted_side_walls(sh, eh, depth, Ring::Hole, &mut mesh);
304    }
305    if let Some(mat) = transform {
306        apply_transform(&mut mesh, &mat);
307    }
308    Ok(mesh)
309}
310
311/// Resample the shorter loop to the longer one's vertex count via arc-length
312/// interpolation, returning owned copies of both loops at equal length.
313fn match_loop_lengths(
314    a: &[Point2<f64>],
315    b: &[Point2<f64>],
316) -> (Vec<Point2<f64>>, Vec<Point2<f64>>) {
317    if a.len() == b.len() {
318        return (a.to_vec(), b.to_vec());
319    }
320    if a.len() > b.len() {
321        (a.to_vec(), resample_loop(b, a.len()))
322    } else {
323        (resample_loop(a, b.len()), b.to_vec())
324    }
325}
326
327/// Resample a closed polyline to `target` evenly-spaced points (by arc length).
328fn resample_loop(loop_pts: &[Point2<f64>], target: usize) -> Vec<Point2<f64>> {
329    let n = loop_pts.len();
330    if n == 0 || target == 0 {
331        return Vec::new();
332    }
333    // Cumulative arc length around the closed loop
334    let mut cum = Vec::with_capacity(n + 1);
335    cum.push(0.0);
336    for i in 0..n {
337        let p0 = &loop_pts[i];
338        let p1 = &loop_pts[(i + 1) % n];
339        let d = ((p1.x - p0.x).powi(2) + (p1.y - p0.y).powi(2)).sqrt();
340        cum.push(cum[i] + d);
341    }
342    let total = *cum.last().unwrap();
343    if total <= 0.0 {
344        return loop_pts.to_vec();
345    }
346    let mut out = Vec::with_capacity(target);
347    for k in 0..target {
348        let s = (k as f64 / target as f64) * total;
349        // Find segment such that cum[i] <= s < cum[i+1]
350        let mut i = 0;
351        while i + 1 < cum.len() && cum[i + 1] <= s {
352            i += 1;
353        }
354        if i >= n {
355            i = n - 1;
356        }
357        let seg_len = cum[i + 1] - cum[i];
358        let t = if seg_len > 0.0 { (s - cum[i]) / seg_len } else { 0.0 };
359        let p0 = &loop_pts[i];
360        let p1 = &loop_pts[(i + 1) % n];
361        out.push(Point2::new(
362            p0.x + (p1.x - p0.x) * t,
363            p0.y + (p1.y - p0.y) * t,
364        ));
365    }
366    out
367}
368
369/// Side walls between two paired loops at z=0 and z=depth, wound outward from
370/// the solid by the same rule as [`create_side_walls`]: `ring` says which side
371/// of the loop the solid is on, the bottom loop's winding says which way the
372/// loop runs.
373fn create_lofted_side_walls(
374    bottom: &[Point2<f64>],
375    top: &[Point2<f64>],
376    depth: f64,
377    ring: Ring,
378    mesh: &mut Mesh,
379) {
380    let n = bottom.len();
381    if n < 2 || top.len() != n {
382        return;
383    }
384    // The shared rule, on the bottom loop: `edge_a × edge_b` and the
385    // unmirrored quad face out of a CCW outer loop and a CW hole.
386    let outward_is_right_hand = outward_is_right_hand(bottom, ring);
387    let winding_sign = if outward_is_right_hand { 1.0 } else { -1.0 };
388    let base_index = mesh.vertex_count() as u32;
389    let mut quad_count = 0u32;
390    for i in 0..n {
391        let j = (i + 1) % n;
392        let p0 = &bottom[i];
393        let p1 = &bottom[j];
394        let q0 = &top[i];
395        let q1 = &top[j];
396        let v0 = Point3::new(p0.x, p0.y, 0.0);
397        let v1 = Point3::new(p1.x, p1.y, 0.0);
398        let v2 = Point3::new(q1.x, q1.y, depth);
399        let v3 = Point3::new(q0.x, q0.y, depth);
400        let edge_a = v1 - v0;
401        let edge_b = v3 - v0;
402        let normal = match edge_a.cross(&edge_b).try_normalize(1e-10) {
403            Some(n) => n * winding_sign,
404            None => continue,
405        };
406        let idx = base_index + (quad_count * 4);
407        mesh.add_vertex(v0, normal);
408        mesh.add_vertex(v1, normal);
409        mesh.add_vertex(v2, normal);
410        mesh.add_vertex(v3, normal);
411        if outward_is_right_hand {
412            mesh.add_triangle(idx, idx + 1, idx + 2);
413            mesh.add_triangle(idx, idx + 2, idx + 3);
414        } else {
415            mesh.add_triangle(idx, idx + 2, idx + 1);
416            mesh.add_triangle(idx, idx + 3, idx + 2);
417        }
418        quad_count += 1;
419    }
420}
421
422
423/// Apply transformation matrix to mesh
424#[inline]
425pub fn apply_transform(mesh: &mut Mesh, transform: &Matrix4<f64>) {
426    apply_transform_generic(mesh, transform);
427}
428
429
430// `apply_transform_with_rtc` was deleted here: the C3.2 `pub(crate)` narrowing
431// orphaned it (zero callers, production or test), so per the anti-cruft rule it
432// goes rather than gain a dead-code allow. The RTC rebase runs in the
433// processing orchestrator (see rust/AGENTS.md), not this per-mesh helper.
434
435#[cfg(test)]
436#[path = "extrusion_watertight_tests.rs"]
437mod watertight_tests;
438
439#[cfg(test)]
440#[path = "extrusion_byte_identity_tests.rs"]
441mod byte_identity_tests;
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use crate::extrusion_generic::is_approximately_circular_profile;
447    use crate::profile::create_rectangle;
448
449    #[test]
450    fn test_extrude_rectangle() {
451        let profile = create_rectangle(10.0, 5.0);
452        let mesh = extrude_profile(&profile, 20.0, None).unwrap();
453
454        // Should have vertices for top, bottom, and sides
455        assert!(mesh.vertex_count() > 0);
456        assert!(mesh.triangle_count() > 0);
457
458        // Check bounds
459        let (min, max) = mesh.bounds();
460        assert!((min.x - -5.0).abs() < 0.01);
461        assert!((max.x - 5.0).abs() < 0.01);
462        assert!((min.y - -2.5).abs() < 0.01);
463        assert!((max.y - 2.5).abs() < 0.01);
464        assert!((min.z - 0.0).abs() < 0.01);
465        assert!((max.z - 20.0).abs() < 0.01);
466    }
467
468    #[test]
469    fn test_extrude_with_transform() {
470        let profile = create_rectangle(10.0, 5.0);
471
472        // Translation transform
473        let transform = Matrix4::new_translation(&Vector3::new(100.0, 200.0, 300.0));
474
475        let mesh = extrude_profile(&profile, 20.0, Some(transform)).unwrap();
476
477        // Check bounds are transformed
478        let (min, max) = mesh.bounds();
479        assert!((min.x - 95.0).abs() < 0.01); // -5 + 100
480        assert!((max.x - 105.0).abs() < 0.01); // 5 + 100
481        assert!((min.y - 197.5).abs() < 0.01); // -2.5 + 200
482        assert!((max.y - 202.5).abs() < 0.01); // 2.5 + 200
483        assert!((min.z - 300.0).abs() < 0.01); // 0 + 300
484        assert!((max.z - 320.0).abs() < 0.01); // 20 + 300
485    }
486
487    /// Assert every flat (non-cap) side-wall normal points away from the mesh's
488    /// XY centroid — i.e. outward, not into the solid.
489    fn assert_side_walls_outward(mesh: &Mesh) {
490        let vc = mesh.vertex_count();
491        let (mut cx, mut cy) = (0.0f32, 0.0f32);
492        for i in 0..vc {
493            cx += mesh.positions[i * 3];
494            cy += mesh.positions[i * 3 + 1];
495        }
496        cx /= vc as f32;
497        cy /= vc as f32;
498
499        let mut checked = 0;
500        for i in 0..vc {
501            let nz = mesh.normals[i * 3 + 2];
502            if nz.abs() >= 0.5 {
503                continue; // cap vertex (normal ~ ±Z), not a side wall
504            }
505            let nx = mesh.normals[i * 3];
506            let ny = mesh.normals[i * 3 + 1];
507            let rx = mesh.positions[i * 3] - cx;
508            let ry = mesh.positions[i * 3 + 1] - cy;
509            let dot = nx * rx + ny * ry;
510            assert!(
511                dot > 0.0,
512                "side-wall normal points inward at vertex {i}: n=({nx},{ny}) r=({rx},{ry}) dot={dot}"
513            );
514            checked += 1;
515        }
516        assert!(checked > 0, "expected side-wall vertices to check");
517    }
518
519    #[test]
520    fn test_side_wall_normals_outward_ccw_profile() {
521        // create_rectangle is CCW. Before #1006-follow-up these side-wall normals
522        // pointed inward (the AC20-FZK-Haus roof slab shaded inside-out).
523        let profile = create_rectangle(10.0, 6.0);
524        let mesh = extrude_profile(&profile, 4.0, None).unwrap();
525        assert_side_walls_outward(&mesh);
526    }
527
528    #[test]
529    fn test_side_wall_normals_outward_cw_profile() {
530        // The same rectangle wound clockwise. This was the previously-correct
531        // case; the fix must keep CW outer walls outward (no sign regression).
532        let mut pts = create_rectangle(10.0, 6.0).outer;
533        pts.reverse();
534        let profile = Profile2D::new(pts);
535        let mesh = extrude_profile(&profile, 4.0, None).unwrap();
536        assert_side_walls_outward(&mesh);
537    }
538
539    #[test]
540    fn test_extrude_circle() {
541        use crate::profile::create_circle;
542        use crate::tessellation::TessellationQuality;
543
544        let profile = create_circle(5.0, None, TessellationQuality::Medium);
545        let mesh = extrude_profile(&profile, 10.0, None).unwrap();
546
547        assert!(mesh.vertex_count() > 0);
548        assert!(mesh.triangle_count() > 0);
549
550        // Check it's roughly cylindrical
551        let (min, max) = mesh.bounds();
552        assert!((min.x - -5.0).abs() < 0.1);
553        assert!((max.x - 5.0).abs() < 0.1);
554        assert!((min.y - -5.0).abs() < 0.1);
555        assert!((max.y - 5.0).abs() < 0.1);
556    }
557
558    #[test]
559    fn test_extrude_hollow_circle() {
560        use crate::profile::create_circle;
561        use crate::tessellation::TessellationQuality;
562
563        let profile = create_circle(10.0, Some(5.0), TessellationQuality::Medium);
564        let mesh = extrude_profile(&profile, 15.0, None).unwrap();
565
566        // Hollow circle should have more triangles than solid
567        assert!(mesh.triangle_count() > 20);
568    }
569
570    #[test]
571    fn test_invalid_depth() {
572        let profile = create_rectangle(10.0, 5.0);
573        let result = extrude_profile(&profile, -1.0, None);
574        assert!(result.is_err());
575    }
576
577    #[test]
578    fn test_circular_profile_detection() {
579        use crate::profile::create_circle;
580        use crate::tessellation::TessellationQuality;
581
582        // Radius is chosen so `calculate_circle_segments` produces ≥20 points
583        // (the threshold raised in #424 to stop 12-point I-beam profiles from
584        // being smooth-shaded as circles). At r=10, segments = ceil(√10 * 8) = 26.
585        let circle = create_circle(10.0, None, TessellationQuality::Medium);
586        assert!(
587            circle.outer.len() >= 20,
588            "test setup expects ≥20 segments to pass the heuristic threshold; got {}",
589            circle.outer.len()
590        );
591        let mut cx = 0.0;
592        let mut cy = 0.0;
593        for p in &circle.outer {
594            cx += p.x;
595            cy += p.y;
596        }
597        cx /= circle.outer.len() as f64;
598        cy /= circle.outer.len() as f64;
599
600        assert!(is_approximately_circular_profile(&circle.outer, cx, cy));
601    }
602
603    #[test]
604    fn test_rectangular_profile_not_detected_as_circular() {
605        let rect = create_rectangle(10.0, 5.0);
606        let mut cx = 0.0;
607        let mut cy = 0.0;
608        for p in &rect.outer {
609            cx += p.x;
610            cy += p.y;
611        }
612        cx /= rect.outer.len() as f64;
613        cy /= rect.outer.len() as f64;
614
615        assert!(!is_approximately_circular_profile(&rect.outer, cx, cy));
616    }
617
618    #[test]
619    fn test_lofted_extrusion_rectangle_to_rectangle() {
620        // Start: 200x200 (centered at origin), End: 200x600 — matches the IFC
621        // sample in issue #628. With Depth=2000 the bounds should be
622        // (±100, -100..-100→±300, 0..2000). Caps differ in size so the side
623        // walls slope outward in Y.
624        let start = create_rectangle(200.0, 200.0);
625        let end = create_rectangle(200.0, 600.0);
626        let mesh = extrude_profile_lofted(&start, &end, 2000.0, None).unwrap();
627
628        assert!(!mesh.is_empty());
629        let (min, max) = mesh.bounds();
630        assert!((min.x - -100.0).abs() < 0.01, "min.x = {}", min.x);
631        assert!((max.x - 100.0).abs() < 0.01, "max.x = {}", max.x);
632        // At the top the rectangle is 600 tall so reaches ±300
633        assert!((min.y - -300.0).abs() < 0.01, "min.y = {}", min.y);
634        assert!((max.y - 300.0).abs() < 0.01, "max.y = {}", max.y);
635        assert!((min.z - 0.0).abs() < 0.01);
636        assert!((max.z - 2000.0).abs() < 0.01);
637    }
638
639    #[test]
640    fn test_lofted_side_walls_not_vertical_when_profiles_differ() {
641        // For a tapering quad, side normals must have a non-zero Z component
642        // (otherwise the cap and side normals collide and shading goes wrong).
643        let start = create_rectangle(200.0, 200.0);
644        let end = create_rectangle(200.0, 600.0);
645        let mesh = extrude_profile_lofted(&start, &end, 2000.0, None).unwrap();
646
647        // Skip the cap normals (first start_tri + end_tri vertices). At least
648        // one side-wall vertex must have |nz| > 0 because the wall is sloped.
649        let mut has_sloped_normal = false;
650        for chunk in mesh.normals.chunks_exact(3) {
651            if chunk[2].abs() > 1e-3 && chunk[2].abs() < 0.9999 {
652                has_sloped_normal = true;
653                break;
654            }
655        }
656        assert!(
657            has_sloped_normal,
658            "expected at least one side-wall normal with sloped Z component"
659        );
660    }
661
662    #[test]
663    fn test_lofted_outer_normals_match_uniform_extrusion_convention() {
664        // In the untapered limit (start == end), the lofted side-wall normals
665        // must match the convention used by the uniform `extrude_profile`:
666        // the radial component (in XY) points outward from the profile center.
667        // Without this, tapered faces shade inverted vs regular extrusions.
668        let rect = create_rectangle(200.0, 200.0);
669        let lofted = extrude_profile_lofted(&rect, &rect, 1000.0, None).unwrap();
670        let uniform = extrude_profile(&rect, 1000.0, None).unwrap();
671
672        // Sample the bottom-left side-wall vertex (x = -100, y = -100, z = 0)
673        // and check the XY normal sign on both meshes.
674        fn radial_sign_at(mesh: &Mesh, x: f32, y: f32) -> Option<(f32, f32)> {
675            for i in 0..mesh.vertex_count() {
676                let px = mesh.positions[i * 3];
677                let py = mesh.positions[i * 3 + 1];
678                let pz = mesh.positions[i * 3 + 2];
679                if (px - x).abs() < 0.5 && (py - y).abs() < 0.5 && pz < 0.5 {
680                    let nx = mesh.normals[i * 3];
681                    let ny = mesh.normals[i * 3 + 1];
682                    if nx.abs() > 0.1 || ny.abs() > 0.1 {
683                        return Some((nx, ny));
684                    }
685                }
686            }
687            None
688        }
689        let lofted_n = radial_sign_at(&lofted, -100.0, -100.0)
690            .expect("lofted: no side-wall vertex at (-100, -100, 0)");
691        let uniform_n = radial_sign_at(&uniform, -100.0, -100.0)
692            .expect("uniform: no side-wall vertex at (-100, -100, 0)");
693        assert!(
694            lofted_n.0.signum() == uniform_n.0.signum()
695                || lofted_n.1.signum() == uniform_n.1.signum(),
696            "lofted normal {:?} disagrees with uniform normal {:?}",
697            lofted_n,
698            uniform_n,
699        );
700    }
701
702    #[test]
703    fn test_lofted_extrusion_invalid_depth() {
704        let start = create_rectangle(10.0, 10.0);
705        let end = create_rectangle(10.0, 20.0);
706        assert!(extrude_profile_lofted(&start, &end, 0.0, None).is_err());
707        assert!(extrude_profile_lofted(&start, &end, -1.0, None).is_err());
708    }
709
710    #[test]
711    fn probe_lofted_hole_normal_vs_uniform_hole_normal() {
712        // Untapered loft (start == end) with a hole must agree with uniform
713        // extrusion's hole side-wall normal direction (into the void).
714        let mut outer = create_rectangle(200.0, 200.0);
715        let mut hole = vec![
716            Point2::new(-50.0, -50.0),
717            Point2::new(-50.0, 50.0),
718            Point2::new(50.0, 50.0),
719            Point2::new(50.0, -50.0),
720        ];
721        hole.reverse(); // CW per Profile2D::add_hole contract
722        outer.add_hole(hole.clone());
723
724        let uniform = extrude_profile(&outer, 1000.0, None).unwrap();
725        let lofted = extrude_profile_lofted(&outer, &outer, 1000.0, None).unwrap();
726
727        fn radial_sign_at(mesh: &Mesh, x: f32, y: f32) -> Option<(f32, f32)> {
728            for i in 0..mesh.vertex_count() {
729                let px = mesh.positions[i * 3];
730                let py = mesh.positions[i * 3 + 1];
731                let pz = mesh.positions[i * 3 + 2];
732                if (px - x).abs() < 0.5 && (py - y).abs() < 0.5 && pz < 0.5 {
733                    let nx = mesh.normals[i * 3];
734                    let ny = mesh.normals[i * 3 + 1];
735                    if nx.abs() > 0.1 || ny.abs() > 0.1 {
736                        return Some((nx, ny));
737                    }
738                }
739            }
740            None
741        }
742        let u = radial_sign_at(&uniform, -50.0, -50.0).expect("uniform hole vertex");
743        let l = radial_sign_at(&lofted, -50.0, -50.0).expect("lofted hole vertex");
744        // Same physical construction (untapered loft == uniform extrusion), so
745        // the two independently-computed normals must point the same way: a
746        // positive dot product, not merely matching per-component signs (which
747        // a harmless +0.0/-0.0 pair can fail spuriously).
748        let dot = u.0 * l.0 + u.1 * l.1;
749        assert!(
750            dot > 0.0,
751            "lofted hole normal {:?} disagrees with uniform hole normal {:?} (dot={dot})",
752            l, u
753        );
754    }
755
756    #[test]
757    fn test_resample_loop_preserves_total_length() {
758        let rect = create_rectangle(10.0, 4.0);
759        let resampled = resample_loop(&rect.outer, 16);
760        assert_eq!(resampled.len(), 16);
761        let total: f64 = (0..resampled.len())
762            .map(|i| {
763                let p0 = &resampled[i];
764                let p1 = &resampled[(i + 1) % resampled.len()];
765                ((p1.x - p0.x).powi(2) + (p1.y - p0.y).powi(2)).sqrt()
766            })
767            .sum();
768        // Original perimeter is 28; resampled chord-perimeter is ≤ original
769        // (chords cut corners). Allow up to 5% loss.
770        assert!(total > 26.5 && total <= 28.0 + 1e-6, "resampled perimeter = {}", total);
771    }
772}