Skip to main content

oxiphysics_geometry/boolean_ops/
functions.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5use oxiphysics_core::math::Vec3;
6
7use super::types::{BspPlane, ManifoldCheckResult};
8
9#[inline]
10pub(super) fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
11    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
12}
13#[inline]
14pub(super) fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
15    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
16}
17#[inline]
18pub(super) fn scale3(a: [f64; 3], s: f64) -> [f64; 3] {
19    [a[0] * s, a[1] * s, a[2] * s]
20}
21#[inline]
22pub(super) fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
23    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
24}
25#[inline]
26pub(super) fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
27    [
28        a[1] * b[2] - a[2] * b[1],
29        a[2] * b[0] - a[0] * b[2],
30        a[0] * b[1] - a[1] * b[0],
31    ]
32}
33#[inline]
34pub(super) fn len3(a: [f64; 3]) -> f64 {
35    dot3(a, a).sqrt()
36}
37#[inline]
38pub(super) fn normalize3(a: [f64; 3]) -> [f64; 3] {
39    let l = len3(a);
40    if l < f64::EPSILON {
41        [0.0, 0.0, 0.0]
42    } else {
43        [a[0] / l, a[1] / l, a[2] / l]
44    }
45}
46pub(super) fn vec3_to_arr(v: Vec3) -> [f64; 3] {
47    [v.x, v.y, v.z]
48}
49pub(super) fn arr_to_vec3(a: [f64; 3]) -> Vec3 {
50    Vec3::new(a[0], a[1], a[2])
51}
52/// Test whether a point is inside a closed mesh using multiple ray casts.
53///
54/// Fires rays in three axis directions (+X, +Y, +Z) and takes a majority vote
55/// to improve robustness against degenerate cases where a ray passes exactly
56/// through an edge or vertex. An odd intersection count means the point is
57/// inside the mesh (Jordan curve theorem generalised to 3-D).
58pub fn point_inside_mesh(point: [f64; 3], verts: &[[f64; 3]], tris: &[[usize; 3]]) -> bool {
59    let ray_dirs: [[f64; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
60    let mut votes_inside = 0usize;
61    for ray_dir in &ray_dirs {
62        let mut count = 0usize;
63        for tri in tris {
64            let v0 = verts[tri[0]];
65            let v1 = verts[tri[1]];
66            let v2 = verts[tri[2]];
67            if ray_triangle_intersect(point, *ray_dir, v0, v1, v2).is_some() {
68                count += 1;
69            }
70        }
71        if count % 2 == 1 {
72            votes_inside += 1;
73        }
74    }
75    votes_inside >= 2
76}
77/// Möller-Trumbore ray-triangle intersection.
78///
79/// Returns the parametric distance `t` along the ray if the ray intersects
80/// the triangle from the front, or `None` otherwise.
81pub fn ray_triangle_intersect(
82    origin: [f64; 3],
83    direction: [f64; 3],
84    v0: [f64; 3],
85    v1: [f64; 3],
86    v2: [f64; 3],
87) -> Option<f64> {
88    let e1 = sub3(v1, v0);
89    let e2 = sub3(v2, v0);
90    let h = cross3(direction, e2);
91    let a = dot3(e1, h);
92    if a.abs() < 1e-10 {
93        return None;
94    }
95    let f = 1.0 / a;
96    let s = sub3(origin, v0);
97    let u = f * dot3(s, h);
98    if !(0.0..=1.0).contains(&u) {
99        return None;
100    }
101    let q = cross3(s, e1);
102    let v = f * dot3(direction, q);
103    if v < 0.0 || u + v > 1.0 {
104        return None;
105    }
106    let t = f * dot3(e2, q);
107    if t > 1e-10 { Some(t) } else { None }
108}
109/// Test whether two triangles in 3-D intersect.
110///
111/// Uses the Devillers-Guigue algorithm (interval overlap on the line of
112/// intersection of the two triangle planes). Returns `true` if the triangles
113/// intersect (including edge/vertex contacts).
114pub fn triangles_intersect(
115    a0: [f64; 3],
116    a1: [f64; 3],
117    a2: [f64; 3],
118    b0: [f64; 3],
119    b1: [f64; 3],
120    b2: [f64; 3],
121) -> bool {
122    let nb = cross3(sub3(b1, b0), sub3(b2, b0));
123    let db = dot3(nb, b0);
124    let da0 = dot3(nb, a0) - db;
125    let da1 = dot3(nb, a1) - db;
126    let da2 = dot3(nb, a2) - db;
127    if da0 * da1 > 0.0 && da1 * da2 > 0.0 {
128        return false;
129    }
130    let na = cross3(sub3(a1, a0), sub3(a2, a0));
131    let da = dot3(na, a0);
132    let db0 = dot3(na, b0) - da;
133    let db1 = dot3(na, b1) - da;
134    let db2 = dot3(na, b2) - da;
135    if db0 * db1 > 0.0 && db1 * db2 > 0.0 {
136        return false;
137    }
138    let d = cross3(na, nb);
139    let d_len = len3(d);
140    if d_len < 1e-12 {
141        return coplanar_tri_overlap(a0, a1, a2, b0, b1, b2, na);
142    }
143    let proj_a = [dot3(d, a0), dot3(d, a1), dot3(d, a2)];
144    let proj_b = [dot3(d, b0), dot3(d, b1), dot3(d, b2)];
145    let interval_a = compute_interval(proj_a, [da0, da1, da2]);
146    let interval_b = compute_interval(proj_b, [db0, db1, db2]);
147    intervals_overlap(interval_a, interval_b)
148}
149/// Compute the intersection interval of a triangle on the intersection line.
150///
151/// Uses the signed distances `dists` and projections `proj` to compute
152/// `[t_min, t_max]` via linear interpolation.
153pub(super) fn compute_interval(proj: [f64; 3], dists: [f64; 3]) -> [f64; 2] {
154    let signs: [bool; 3] = [dists[0] > 0.0, dists[1] > 0.0, dists[2] > 0.0];
155    let (lone, pair): (usize, [usize; 2]) = if signs[0] != signs[1] && signs[0] != signs[2] {
156        (0, [1, 2])
157    } else if signs[1] != signs[0] && signs[1] != signs[2] {
158        (1, [0, 2])
159    } else {
160        (2, [0, 1])
161    };
162    let t0 = proj[pair[0]]
163        + (proj[lone] - proj[pair[0]]) * dists[pair[0]] / (dists[pair[0]] - dists[lone]);
164    let t1 = proj[pair[1]]
165        + (proj[lone] - proj[pair[1]]) * dists[pair[1]] / (dists[pair[1]] - dists[lone]);
166    [t0.min(t1), t0.max(t1)]
167}
168pub(super) fn intervals_overlap(a: [f64; 2], b: [f64; 2]) -> bool {
169    !(a[1] < b[0] || b[1] < a[0])
170}
171/// Coplanar triangle overlap test (2-D edge separating axis test).
172pub(super) fn coplanar_tri_overlap(
173    a0: [f64; 3],
174    a1: [f64; 3],
175    a2: [f64; 3],
176    b0: [f64; 3],
177    b1: [f64; 3],
178    b2: [f64; 3],
179    normal: [f64; 3],
180) -> bool {
181    let tri_a = [a0, a1, a2];
182    let tri_b = [b0, b1, b2];
183    for tri in [tri_a, tri_b].iter() {
184        for k in 0..3 {
185            let edge = sub3(tri[(k + 1) % 3], tri[k]);
186            let axis = cross3(normal, edge);
187            let proj_a: [f64; 3] = [
188                dot3(axis, tri_a[0]),
189                dot3(axis, tri_a[1]),
190                dot3(axis, tri_a[2]),
191            ];
192            let proj_b: [f64; 3] = [
193                dot3(axis, tri_b[0]),
194                dot3(axis, tri_b[1]),
195                dot3(axis, tri_b[2]),
196            ];
197            let a_min = proj_a.iter().cloned().fold(f64::INFINITY, f64::min);
198            let a_max = proj_a.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
199            let b_min = proj_b.iter().cloned().fold(f64::INFINITY, f64::min);
200            let b_max = proj_b.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
201            if a_max < b_min - 1e-10 || b_max < a_min - 1e-10 {
202                return false;
203            }
204        }
205    }
206    true
207}
208/// Compute the centroid of a triangle.
209pub fn triangle_centroid(v0: [f64; 3], v1: [f64; 3], v2: [f64; 3]) -> [f64; 3] {
210    scale3(add3(add3(v0, v1), v2), 1.0 / 3.0)
211}
212/// Compute the area of a triangle.
213pub fn triangle_area(v0: [f64; 3], v1: [f64; 3], v2: [f64; 3]) -> f64 {
214    len3(cross3(sub3(v1, v0), sub3(v2, v0))) * 0.5
215}
216/// Compute the normal of a triangle (not normalised).
217pub fn triangle_normal(v0: [f64; 3], v1: [f64; 3], v2: [f64; 3]) -> [f64; 3] {
218    cross3(sub3(v1, v0), sub3(v2, v0))
219}
220/// Compute the unit normal of a triangle.
221pub fn triangle_unit_normal(v0: [f64; 3], v1: [f64; 3], v2: [f64; 3]) -> [f64; 3] {
222    normalize3(triangle_normal(v0, v1, v2))
223}
224/// Clip a convex polygon against a half-space defined by `plane`.
225///
226/// Vertices on the front side (dist ≥ 0) are kept; edges crossing the plane
227/// produce intersection vertices.  Returns the clipped polygon or an empty
228/// Vec if everything is clipped.
229pub fn clip_polygon_by_plane(polygon: &[[f64; 3]], plane: &BspPlane) -> Vec<[f64; 3]> {
230    if polygon.is_empty() {
231        return Vec::new();
232    }
233    let n = polygon.len();
234    let mut result = Vec::new();
235    for i in 0..n {
236        let a = polygon[i];
237        let b = polygon[(i + 1) % n];
238        let da = plane.signed_dist(a);
239        let db = plane.signed_dist(b);
240        if da >= -1e-10 {
241            result.push(a);
242        }
243        if (da > 1e-10 && db < -1e-10) || (da < -1e-10 && db > 1e-10) {
244            let t = da / (da - db);
245            let inter = [
246                a[0] + t * (b[0] - a[0]),
247                a[1] + t * (b[1] - a[1]),
248                a[2] + t * (b[2] - a[2]),
249            ];
250            result.push(inter);
251        }
252    }
253    result
254}
255/// Clip a polygon against all six half-spaces of an AABB.
256///
257/// Returns the clipped polygon or `None` if completely clipped.
258pub fn clip_polygon_by_aabb(
259    polygon: &[[f64; 3]],
260    aabb_min: [f64; 3],
261    aabb_max: [f64; 3],
262) -> Option<Vec<[f64; 3]>> {
263    let planes = [
264        BspPlane::new([1.0, 0.0, 0.0], aabb_min[0]),
265        BspPlane::new([-1.0, 0.0, 0.0], -aabb_max[0]),
266        BspPlane::new([0.0, 1.0, 0.0], aabb_min[1]),
267        BspPlane::new([0.0, -1.0, 0.0], -aabb_max[1]),
268        BspPlane::new([0.0, 0.0, 1.0], aabb_min[2]),
269        BspPlane::new([0.0, 0.0, -1.0], -aabb_max[2]),
270    ];
271    let mut poly = polygon.to_vec();
272    for plane in &planes {
273        poly = clip_polygon_by_plane(&poly, plane);
274        if poly.is_empty() {
275            return None;
276        }
277    }
278    Some(poly)
279}
280/// Clip a 2-D polygon (list of `[x, y]`) against a convex polygon (clipper).
281///
282/// Uses the Sutherland-Hodgman algorithm.
283pub fn clip_polygon_2d(subject: &[[f64; 2]], clip: &[[f64; 2]]) -> Vec<[f64; 2]> {
284    if subject.is_empty() || clip.is_empty() {
285        return Vec::new();
286    }
287    let mut output = subject.to_vec();
288    let n_clip = clip.len();
289    for i in 0..n_clip {
290        if output.is_empty() {
291            return Vec::new();
292        }
293        let input = output.clone();
294        output.clear();
295        let edge_a = clip[i];
296        let edge_b = clip[(i + 1) % n_clip];
297        let n_in = input.len();
298        for j in 0..n_in {
299            let current = input[j];
300            let prev = input[(j + n_in - 1) % n_in];
301            if inside_2d(current, edge_a, edge_b) {
302                if !inside_2d(prev, edge_a, edge_b) {
303                    output.push(intersect_2d(prev, current, edge_a, edge_b));
304                }
305                output.push(current);
306            } else if inside_2d(prev, edge_a, edge_b) {
307                output.push(intersect_2d(prev, current, edge_a, edge_b));
308            }
309        }
310    }
311    output
312}
313/// Test whether `point` is on the left (inside) of the directed edge `a → b`.
314pub(super) fn inside_2d(point: [f64; 2], a: [f64; 2], b: [f64; 2]) -> bool {
315    let edge = [b[0] - a[0], b[1] - a[1]];
316    let to_pt = [point[0] - a[0], point[1] - a[1]];
317    edge[0] * to_pt[1] - edge[1] * to_pt[0] >= 0.0
318}
319/// Compute intersection of segment `p1→p2` with segment `p3→p4`.
320pub(super) fn intersect_2d(p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], p4: [f64; 2]) -> [f64; 2] {
321    let d1 = [p2[0] - p1[0], p2[1] - p1[1]];
322    let d2 = [p4[0] - p3[0], p4[1] - p3[1]];
323    let cross = d1[0] * d2[1] - d1[1] * d2[0];
324    if cross.abs() < 1e-12 {
325        return p1;
326    }
327    let t = ((p3[0] - p1[0]) * d2[1] - (p3[1] - p1[1]) * d2[0]) / cross;
328    [p1[0] + t * d1[0], p1[1] + t * d1[1]]
329}
330/// Repair the winding order of a triangle mesh so that all outward-facing
331/// normals point away from the centroid of the mesh.
332///
333/// Computes the centroid of all vertices, then for each triangle checks
334/// whether the face normal points toward or away from the centroid.  If it
335/// points toward the centroid (inside), the triangle winding is flipped.
336pub fn repair_winding(verts: &[[f64; 3]], tris: &[[usize; 3]]) -> Vec<[usize; 3]> {
337    let n = verts.len() as f64;
338    let centroid = verts.iter().fold([0.0f64; 3], |acc, v| add3(acc, *v));
339    let centroid = scale3(centroid, 1.0 / n.max(1.0));
340    let mut fixed = Vec::with_capacity(tris.len());
341    for tri in tris {
342        let v0 = verts[tri[0]];
343        let v1 = verts[tri[1]];
344        let v2 = verts[tri[2]];
345        let face_normal = cross3(sub3(v1, v0), sub3(v2, v0));
346        let to_centroid = sub3(centroid, scale3(add3(add3(v0, v1), v2), 1.0 / 3.0));
347        if dot3(face_normal, to_centroid) > 0.0 {
348            fixed.push([tri[0], tri[2], tri[1]]);
349        } else {
350            fixed.push(*tri);
351        }
352    }
353    fixed
354}
355/// Check whether all triangles in a mesh have consistent outward normals
356/// (pointing away from the mesh centroid).
357///
358/// Returns the fraction of triangles (0.0–1.0) with correct outward winding.
359pub fn winding_consistency_score(verts: &[[f64; 3]], tris: &[[usize; 3]]) -> f64 {
360    if tris.is_empty() {
361        return 1.0;
362    }
363    let n = verts.len() as f64;
364    let centroid = verts.iter().fold([0.0f64; 3], |acc, v| add3(acc, *v));
365    let centroid = scale3(centroid, 1.0 / n.max(1.0));
366    let correct = tris
367        .iter()
368        .filter(|tri| {
369            let v0 = verts[tri[0]];
370            let v1 = verts[tri[1]];
371            let v2 = verts[tri[2]];
372            let face_normal = cross3(sub3(v1, v0), sub3(v2, v0));
373            let to_centroid = sub3(centroid, scale3(add3(add3(v0, v1), v2), 1.0 / 3.0));
374            dot3(face_normal, to_centroid) <= 0.0
375        })
376        .count();
377    correct as f64 / tris.len() as f64
378}
379/// Compute the 2-D intersection of two convex polygons using Sutherland-Hodgman.
380///
381/// Both polygons are assumed to be convex and wound counter-clockwise.
382pub fn polygon2d_intersection(a: &[[f64; 2]], b: &[[f64; 2]]) -> Vec<[f64; 2]> {
383    clip_polygon_2d(a, b)
384}
385/// Compute the 2-D union of two convex polygons.
386///
387/// Returns the merged boundary.  Uses the Sutherland-Hodgman approach:
388/// clip b against a, then add vertices of a that lie outside b,
389/// and vertices of b that lie outside a.
390pub fn polygon2d_union(a: &[[f64; 2]], b: &[[f64; 2]]) -> Vec<[f64; 2]> {
391    let mut pts: Vec<[f64; 2]> = Vec::new();
392    pts.extend_from_slice(a);
393    pts.extend_from_slice(b);
394    convex_hull_2d(&pts)
395}
396/// Compute the 2-D difference `a \ b` for convex polygons.
397///
398/// Returns the portion of `a` outside `b`.
399/// Inverts b's winding so we clip a against the outside of b.
400pub fn polygon2d_difference(a: &[[f64; 2]], b: &[[f64; 2]]) -> Vec<[f64; 2]> {
401    if a.is_empty() || b.is_empty() {
402        return a.to_vec();
403    }
404    let mut result = a.to_vec();
405    let n = b.len();
406    for i in 0..n {
407        if result.is_empty() {
408            break;
409        }
410        let edge_a = b[i];
411        let edge_b = b[(i + 1) % n];
412        let input = result.clone();
413        result.clear();
414        let ni = input.len();
415        for j in 0..ni {
416            let current = input[j];
417            let prev = input[(j + ni - 1) % ni];
418            let cur_out = !inside_2d(current, edge_a, edge_b);
419            let prev_out = !inside_2d(prev, edge_a, edge_b);
420            if cur_out {
421                if !prev_out {
422                    result.push(intersect_2d(prev, current, edge_a, edge_b));
423                }
424                result.push(current);
425            } else if prev_out {
426                result.push(intersect_2d(prev, current, edge_a, edge_b));
427            }
428        }
429    }
430    result
431}
432/// Compute a convex hull of 2-D points using the gift-wrapping algorithm.
433pub(super) fn convex_hull_2d(pts: &[[f64; 2]]) -> Vec<[f64; 2]> {
434    if pts.len() < 3 {
435        return pts.to_vec();
436    }
437    let start = pts
438        .iter()
439        .enumerate()
440        .min_by(|(_, a), (_, b)| a[0].partial_cmp(&b[0]).unwrap_or(std::cmp::Ordering::Equal))
441        .map(|(i, _)| i)
442        .unwrap_or(0);
443    let mut hull = Vec::new();
444    let mut current = start;
445    loop {
446        hull.push(pts[current]);
447        let mut next = 0;
448        for i in 1..pts.len() {
449            if i == current {
450                continue;
451            }
452            let a = pts[current];
453            let b = pts[next];
454            let c = pts[i];
455            let cross = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
456            if cross < 0.0 || (cross == 0.0 && dist_sq_2d(a, c) > dist_sq_2d(a, b)) {
457                next = i;
458            }
459        }
460        current = next;
461        if current == start {
462            break;
463        }
464        if hull.len() > pts.len() {
465            break;
466        }
467    }
468    hull
469}
470pub(super) fn dist_sq_2d(a: [f64; 2], b: [f64; 2]) -> f64 {
471    (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)
472}
473/// Check whether a mesh is 2-manifold.
474pub fn check_manifold(tris: &[[usize; 3]]) -> ManifoldCheckResult {
475    let mut edge_count: std::collections::HashMap<(usize, usize), usize> =
476        std::collections::HashMap::new();
477    for tri in tris {
478        let edges = [
479            (tri[0].min(tri[1]), tri[0].max(tri[1])),
480            (tri[1].min(tri[2]), tri[1].max(tri[2])),
481            (tri[0].min(tri[2]), tri[0].max(tri[2])),
482        ];
483        for e in &edges {
484            *edge_count.entry(*e).or_insert(0) += 1;
485        }
486    }
487    let mut n_boundary = 0;
488    let mut n_non_manifold = 0;
489    for &count in edge_count.values() {
490        if count == 1 {
491            n_boundary += 1;
492        }
493        if count > 2 {
494            n_non_manifold += 1;
495        }
496    }
497    ManifoldCheckResult {
498        is_manifold: n_boundary == 0 && n_non_manifold == 0,
499        n_boundary_edges: n_boundary,
500        n_non_manifold_edges: n_non_manifold,
501    }
502}
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use crate::TriangleMesh;
507    use crate::boolean_ops::BooleanOp;
508
509    use crate::boolean_ops::BspPlane;
510    use crate::boolean_ops::CsgMaterial;
511    use crate::boolean_ops::CsgWithMaterials;
512
513    use crate::boolean_ops::ManifoldBoolean;
514    use crate::boolean_ops::MeshBoolean;
515
516    use crate::boolean_ops::RobustMeshBoolean;
517
518    #[test]
519    fn test_intersecting_triangles() {
520        let a0 = [-1.0_f64, 0.0, 0.0];
521        let a1 = [1.0, 0.0, 0.0];
522        let a2 = [0.0, 1.0, 0.0];
523        let b0 = [0.0, 0.5, -1.0];
524        let b1 = [0.0, 0.5, 1.0];
525        let b2 = [0.0, -0.5, 0.0];
526        assert!(
527            triangles_intersect(a0, a1, a2, b0, b1, b2),
528            "crossing triangles should intersect"
529        );
530    }
531    #[test]
532    fn test_non_intersecting_triangles() {
533        let a0 = [0.0_f64, 0.0, 0.0];
534        let a1 = [1.0, 0.0, 0.0];
535        let a2 = [0.0, 1.0, 0.0];
536        let b0 = [5.0, 0.0, 0.0];
537        let b1 = [6.0, 0.0, 0.0];
538        let b2 = [5.0, 1.0, 0.0];
539        assert!(
540            !triangles_intersect(a0, a1, a2, b0, b1, b2),
541            "separated triangles should not intersect"
542        );
543    }
544    #[test]
545    fn test_ray_triangle_hit() {
546        let origin = [-5.0_f64, 0.0, 0.0];
547        let dir = [1.0, 0.0, 0.0];
548        let v0 = [0.0, -1.0, -1.0];
549        let v1 = [0.0, 1.0, -1.0];
550        let v2 = [0.0, 0.0, 1.0];
551        let t = ray_triangle_intersect(origin, dir, v0, v1, v2);
552        assert!(t.is_some(), "ray should hit the triangle");
553        assert!((t.unwrap() - 5.0).abs() < 1e-10, "t should be 5.0");
554    }
555    #[test]
556    fn test_ray_triangle_miss() {
557        let origin = [-5.0_f64, 10.0, 0.0];
558        let dir = [1.0, 0.0, 0.0];
559        let v0 = [0.0, -1.0, -1.0];
560        let v1 = [0.0, 1.0, -1.0];
561        let v2 = [0.0, 0.0, 1.0];
562        let t = ray_triangle_intersect(origin, dir, v0, v1, v2);
563        assert!(t.is_none(), "ray should miss the triangle");
564    }
565    fn unit_box_mesh() -> (Vec<[f64; 3]>, Vec<[usize; 3]>) {
566        let v: Vec<[f64; 3]> = vec![
567            [-1.0, -1.0, -1.0],
568            [1.0, -1.0, -1.0],
569            [1.0, 1.0, -1.0],
570            [-1.0, 1.0, -1.0],
571            [-1.0, -1.0, 1.0],
572            [1.0, -1.0, 1.0],
573            [1.0, 1.0, 1.0],
574            [-1.0, 1.0, 1.0],
575        ];
576        let t: Vec<[usize; 3]> = vec![
577            [0, 2, 1],
578            [0, 3, 2],
579            [4, 5, 6],
580            [4, 6, 7],
581            [0, 1, 5],
582            [0, 5, 4],
583            [3, 6, 2],
584            [3, 7, 6],
585            [0, 4, 7],
586            [0, 7, 3],
587            [1, 2, 6],
588            [1, 6, 5],
589        ];
590        (v, t)
591    }
592    #[test]
593    fn test_point_inside_mesh() {
594        let (v, t) = unit_box_mesh();
595        assert!(
596            point_inside_mesh([0.1, 0.2, 0.3], &v, &t),
597            "interior point should be inside the unit box"
598        );
599    }
600    #[test]
601    fn test_point_outside_mesh() {
602        let (v, t) = unit_box_mesh();
603        assert!(
604            !point_inside_mesh([5.0, 0.0, 0.0], &v, &t),
605            "far point should be outside the unit box"
606        );
607    }
608    fn box_mesh_trimesh(min: f64, max: f64) -> TriangleMesh {
609        let v: Vec<Vec3> = vec![
610            Vec3::new(min, min, min),
611            Vec3::new(max, min, min),
612            Vec3::new(max, max, min),
613            Vec3::new(min, max, min),
614            Vec3::new(min, min, max),
615            Vec3::new(max, min, max),
616            Vec3::new(max, max, max),
617            Vec3::new(min, max, max),
618        ];
619        let t: Vec<[usize; 3]> = vec![
620            [0, 2, 1],
621            [0, 3, 2],
622            [4, 5, 6],
623            [4, 6, 7],
624            [0, 1, 5],
625            [0, 5, 4],
626            [3, 6, 2],
627            [3, 7, 6],
628            [0, 4, 7],
629            [0, 7, 3],
630            [1, 2, 6],
631            [1, 6, 5],
632        ];
633        TriangleMesh::new(v, t)
634    }
635    #[test]
636    fn test_boolean_union_non_overlapping() {
637        let a = box_mesh_trimesh(0.0, 1.0);
638        let b = box_mesh_trimesh(5.0, 6.0);
639        let result = MeshBoolean::execute(BooleanOp::Union, &a, &b).unwrap();
640        assert!(
641            !result.indices.is_empty(),
642            "union of non-overlapping meshes should have faces"
643        );
644    }
645    #[test]
646    fn test_boolean_difference_non_overlapping() {
647        let a = box_mesh_trimesh(0.0, 1.0);
648        let b = box_mesh_trimesh(5.0, 6.0);
649        let result = MeshBoolean::execute(BooleanOp::Difference, &a, &b).unwrap();
650        assert!(
651            !result.indices.is_empty(),
652            "A - B with no overlap should return A's faces"
653        );
654    }
655    #[test]
656    fn test_boolean_intersection_non_overlapping() {
657        let a = box_mesh_trimesh(0.0, 1.0);
658        let b = box_mesh_trimesh(5.0, 6.0);
659        let result = MeshBoolean::execute(BooleanOp::Intersection, &a, &b).unwrap();
660        assert!(
661            result.indices.is_empty(),
662            "intersection of non-overlapping meshes should be empty"
663        );
664    }
665    #[test]
666    fn test_boolean_error_on_empty_mesh() {
667        let empty = TriangleMesh::new(vec![], vec![]);
668        let b = box_mesh_trimesh(0.0, 1.0);
669        let result = MeshBoolean::execute(BooleanOp::Union, &empty, &b);
670        assert!(result.is_err(), "empty mesh should return an error");
671    }
672    #[test]
673    fn test_triangle_centroid() {
674        let c = triangle_centroid([0.0, 0.0, 0.0], [3.0, 0.0, 0.0], [0.0, 3.0, 0.0]);
675        assert!((c[0] - 1.0).abs() < 1e-10);
676        assert!((c[1] - 1.0).abs() < 1e-10);
677        assert!(c[2].abs() < 1e-10);
678    }
679    #[test]
680    fn test_triangle_area() {
681        let area = triangle_area([0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 2.0, 0.0]);
682        assert!((area - 2.0).abs() < 1e-10, "area={area}");
683    }
684    #[test]
685    fn test_coplanar_overlap() {
686        let a0 = [0.0_f64, 0.0, 0.0];
687        let a1 = [2.0, 0.0, 0.0];
688        let a2 = [0.0, 2.0, 0.0];
689        let b0 = [1.0, 0.0, 0.0];
690        let b1 = [3.0, 0.0, 0.0];
691        let b2 = [1.0, 2.0, 0.0];
692        assert!(
693            triangles_intersect(a0, a1, a2, b0, b1, b2),
694            "overlapping coplanar triangles should intersect"
695        );
696    }
697    #[test]
698    fn test_bsp_plane_signed_dist() {
699        let plane = BspPlane::new([0.0, 1.0, 0.0], 0.0);
700        assert!(plane.signed_dist([0.0, 1.0, 0.0]) > 0.0);
701        assert!(plane.signed_dist([0.0, -1.0, 0.0]) < 0.0);
702        assert!(plane.signed_dist([0.0, 0.0, 0.0]).abs() < 1e-10);
703    }
704    #[test]
705    fn test_clip_polygon_by_plane_all_outside() {
706        let poly = vec![[0.0_f64, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 0.0, 1.0]];
707        let plane = BspPlane::new([0.0, 1.0, 0.0], 1.0);
708        let clipped = clip_polygon_by_plane(&poly, &plane);
709        assert!(clipped.is_empty(), "all below plane should be clipped");
710    }
711    #[test]
712    fn test_clip_polygon_by_plane_all_inside() {
713        let poly = vec![[0.0_f64, 1.0, 0.0], [1.0, 1.0, 0.0], [0.5, 2.0, 1.0]];
714        let plane = BspPlane::new([0.0, 1.0, 0.0], 0.0);
715        let clipped = clip_polygon_by_plane(&poly, &plane);
716        assert_eq!(clipped.len(), 3, "unchanged polygon should have 3 verts");
717    }
718    #[test]
719    fn test_clip_polygon_by_plane_partial_clip() {
720        let poly = vec![
721            [-1.0_f64, 1.0, 0.0],
722            [1.0, 1.0, 0.0],
723            [1.0, -1.0, 0.0],
724            [-1.0, -1.0, 0.0],
725        ];
726        let plane = BspPlane::new([0.0, 1.0, 0.0], 0.0);
727        let clipped = clip_polygon_by_plane(&poly, &plane);
728        assert!(!clipped.is_empty(), "partial overlap should produce result");
729        for v in &clipped {
730            assert!(v[1] >= -1e-9, "clipped vertex y={}", v[1]);
731        }
732    }
733    #[test]
734    fn test_clip_polygon_by_aabb_inside() {
735        let poly = vec![[0.1_f64, 0.1, 0.1], [0.9, 0.1, 0.1], [0.5, 0.9, 0.5]];
736        let result = clip_polygon_by_aabb(&poly, [0.0; 3], [1.0; 3]);
737        assert!(result.is_some(), "polygon inside AABB should survive");
738        assert_eq!(result.unwrap().len(), 3);
739    }
740    #[test]
741    fn test_clip_polygon_by_aabb_outside() {
742        let poly = vec![[5.0_f64, 5.0, 5.0], [6.0, 5.0, 5.0], [5.5, 6.0, 5.5]];
743        let result = clip_polygon_by_aabb(&poly, [0.0; 3], [1.0; 3]);
744        assert!(
745            result.is_none(),
746            "polygon entirely outside AABB should be clipped"
747        );
748    }
749    #[test]
750    fn test_clip_polygon_2d_square_vs_triangle() {
751        let subject = vec![[0.0_f64, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
752        let clip = vec![[-2.0, -2.0], [3.0, -2.0], [0.5, 3.0]];
753        let out = clip_polygon_2d(&subject, &clip);
754        assert!(
755            !out.is_empty(),
756            "square fully inside clip triangle should survive"
757        );
758    }
759    #[test]
760    fn test_clip_polygon_2d_no_overlap() {
761        let subject = vec![[0.0_f64, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
762        let clip = vec![[5.0, 5.0], [6.0, 5.0], [5.5, 6.0]];
763        let out = clip_polygon_2d(&subject, &clip);
764        assert!(
765            out.is_empty(),
766            "non-overlapping polygons should produce empty result"
767        );
768    }
769    #[test]
770    fn test_repair_winding_correct_winding_unchanged() {
771        let (verts, tris) = unit_box_mesh();
772        let score_before = winding_consistency_score(&verts, &tris);
773        let repaired = repair_winding(&verts, &tris);
774        let score_after = winding_consistency_score(&verts, &repaired);
775        assert!(
776            score_after >= score_before,
777            "repair should not worsen consistency"
778        );
779    }
780    #[test]
781    fn test_repair_winding_fixes_flipped_triangle() {
782        let (verts, mut tris) = unit_box_mesh();
783        tris[0] = [tris[0][0], tris[0][2], tris[0][1]];
784        let repaired = repair_winding(&verts, &tris);
785        let score = winding_consistency_score(&verts, &repaired);
786        assert!(
787            score > 0.9,
788            "most faces should have correct winding after repair, score={score}"
789        );
790    }
791    #[test]
792    fn test_winding_consistency_score_perfect() {
793        let (verts, tris) = unit_box_mesh();
794        let score = winding_consistency_score(&verts, &tris);
795        assert!(
796            score >= 0.5,
797            "consistent mesh should have score >= 0.5, got {score}"
798        );
799    }
800    #[test]
801    fn test_polygon2d_union_overlapping() {
802        let a: Vec<[f64; 2]> = vec![[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0]];
803        let b: Vec<[f64; 2]> = vec![[1.0, 0.0], [3.0, 0.0], [3.0, 2.0], [1.0, 2.0]];
804        let result = polygon2d_union(&a, &b);
805        assert!(
806            !result.is_empty(),
807            "union of overlapping squares should be non-empty"
808        );
809    }
810    #[test]
811    fn test_polygon2d_intersection_overlapping() {
812        let a: Vec<[f64; 2]> = vec![[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0]];
813        let b: Vec<[f64; 2]> = vec![[1.0, 0.0], [3.0, 0.0], [3.0, 2.0], [1.0, 2.0]];
814        let result = polygon2d_intersection(&a, &b);
815        assert!(
816            !result.is_empty(),
817            "intersection of overlapping squares should be non-empty"
818        );
819        assert_eq!(result.len(), 4);
820    }
821    #[test]
822    fn test_polygon2d_intersection_non_overlapping() {
823        let a: Vec<[f64; 2]> = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
824        let b: Vec<[f64; 2]> = vec![[5.0, 0.0], [6.0, 0.0], [6.0, 1.0], [5.0, 1.0]];
825        let result = polygon2d_intersection(&a, &b);
826        assert!(
827            result.is_empty(),
828            "non-overlapping squares have empty intersection"
829        );
830    }
831    #[test]
832    fn test_polygon2d_difference_basic() {
833        let a: Vec<[f64; 2]> = vec![[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0]];
834        let b: Vec<[f64; 2]> = vec![[1.0, 0.0], [3.0, 0.0], [3.0, 2.0], [1.0, 2.0]];
835        let _result = polygon2d_difference(&a, &b);
836    }
837    #[test]
838    fn test_csg_material_union() {
839        let a = box_mesh_trimesh(0.0, 1.0);
840        let b = box_mesh_trimesh(5.0, 6.0);
841        let mat_a = CsgMaterial {
842            id: 1,
843            name: "metal".to_string(),
844            density: 7800.0,
845        };
846        let mat_b = CsgMaterial {
847            id: 2,
848            name: "wood".to_string(),
849            density: 600.0,
850        };
851        let result = CsgWithMaterials::execute(BooleanOp::Union, &a, mat_a, &b, mat_b);
852        assert!(result.is_ok());
853        let (mesh, _mats) = result.unwrap();
854        assert!(!mesh.indices.is_empty());
855    }
856    #[test]
857    fn test_csg_material_retains_ids() {
858        let a = box_mesh_trimesh(0.0, 1.0);
859        let b = box_mesh_trimesh(5.0, 6.0);
860        let mat_a = CsgMaterial {
861            id: 10,
862            name: "a".to_string(),
863            density: 1.0,
864        };
865        let mat_b = CsgMaterial {
866            id: 20,
867            name: "b".to_string(),
868            density: 2.0,
869        };
870        let (_, mats) =
871            CsgWithMaterials::execute(BooleanOp::Union, &a, mat_a.clone(), &b, mat_b.clone())
872                .unwrap();
873        let ids: Vec<u32> = mats.iter().map(|m| m.id).collect();
874        assert!(ids.contains(&10));
875        assert!(ids.contains(&20));
876    }
877    #[test]
878    fn test_robust_boolean_union_non_overlapping() {
879        let a = box_mesh_trimesh(0.0, 1.0);
880        let b = box_mesh_trimesh(5.0, 6.0);
881        let result = RobustMeshBoolean::execute(BooleanOp::Union, &a, &b, 1e-8);
882        assert!(result.is_ok());
883        let mesh = result.unwrap();
884        assert!(!mesh.indices.is_empty());
885    }
886    #[test]
887    fn test_robust_boolean_intersection_non_overlapping() {
888        let a = box_mesh_trimesh(0.0, 1.0);
889        let b = box_mesh_trimesh(5.0, 6.0);
890        let result = RobustMeshBoolean::execute(BooleanOp::Intersection, &a, &b, 1e-8);
891        assert!(result.is_ok());
892        assert!(result.unwrap().indices.is_empty());
893    }
894    #[test]
895    fn test_robust_boolean_difference() {
896        let a = box_mesh_trimesh(0.0, 2.0);
897        let b = box_mesh_trimesh(5.0, 6.0);
898        let result = RobustMeshBoolean::execute(BooleanOp::Difference, &a, &b, 1e-8);
899        assert!(result.is_ok());
900        assert!(!result.unwrap().indices.is_empty());
901    }
902    #[test]
903    fn test_robust_boolean_epsilon_tolerance() {
904        let a = box_mesh_trimesh(0.0, 1.0);
905        let b = box_mesh_trimesh(0.999_999_9, 2.0);
906        let result_large_eps = RobustMeshBoolean::execute(BooleanOp::Intersection, &a, &b, 1.0);
907        assert!(result_large_eps.is_ok());
908        let result_small_eps = RobustMeshBoolean::execute(BooleanOp::Union, &a, &b, 1e-12);
909        assert!(result_small_eps.is_ok());
910    }
911    #[test]
912    fn test_manifold_boolean_union() {
913        let a = box_mesh_trimesh(0.0, 1.0);
914        let b = box_mesh_trimesh(5.0, 6.0);
915        let result = ManifoldBoolean::execute(BooleanOp::Union, &a, &b);
916        assert!(result.is_ok());
917        let mesh = result.unwrap();
918        assert!(!mesh.indices.is_empty());
919    }
920    #[test]
921    fn test_manifold_boolean_validates_input() {
922        let empty = TriangleMesh::new(vec![], vec![]);
923        let b = box_mesh_trimesh(0.0, 1.0);
924        let result = ManifoldBoolean::execute(BooleanOp::Union, &empty, &b);
925        assert!(result.is_err(), "empty input should produce error");
926    }
927    #[test]
928    fn test_manifold_boolean_self_union_is_identity() {
929        let a = box_mesh_trimesh(0.0, 1.0);
930        let result = ManifoldBoolean::execute(BooleanOp::Union, &a, &a);
931        assert!(result.is_ok());
932        let _mesh = result.unwrap();
933    }
934}
935/// Split a polygon by a plane into front and back fragments.
936///
937/// Returns `(front_polygon, back_polygon)`.  Either may be empty if the
938/// polygon lies entirely on one side.
939pub fn split_polygon_by_plane(
940    polygon: &[[f64; 3]],
941    plane: &BspPlane,
942) -> (Vec<[f64; 3]>, Vec<[f64; 3]>) {
943    let mut front: Vec<[f64; 3]> = Vec::new();
944    let mut back: Vec<[f64; 3]> = Vec::new();
945    let n = polygon.len();
946    if n == 0 {
947        return (front, back);
948    }
949    for i in 0..n {
950        let a = polygon[i];
951        let b = polygon[(i + 1) % n];
952        let da = plane.signed_dist(a);
953        let db = plane.signed_dist(b);
954        if da >= -1e-8 {
955            front.push(a);
956        }
957        if da <= 1e-8 {
958            back.push(a);
959        }
960        if (da > 1e-8 && db < -1e-8) || (da < -1e-8 && db > 1e-8) {
961            let t = da / (da - db);
962            let inter = [
963                a[0] + t * (b[0] - a[0]),
964                a[1] + t * (b[1] - a[1]),
965                a[2] + t * (b[2] - a[2]),
966            ];
967            front.push(inter);
968            back.push(inter);
969        }
970    }
971    (front, back)
972}
973/// Clip a subject polygon against a convex clip polygon using the
974/// Weiler-Atherton algorithm (2D version).
975///
976/// The clip polygon must be wound counter-clockwise.
977/// Returns the clipped polygon.
978pub fn weiler_atherton_clip(subject: &[[f64; 2]], clip: &[[f64; 2]]) -> Vec<[f64; 2]> {
979    clip_polygon_2d(subject, clip)
980}
981/// Compute intersection points of two polygons.
982///
983/// Returns all edge-edge intersection points between the two polygon boundaries.
984pub fn polygon_intersection_points(a: &[[f64; 2]], b: &[[f64; 2]]) -> Vec<[f64; 2]> {
985    let mut result = Vec::new();
986    let na = a.len();
987    let nb = b.len();
988    for i in 0..na {
989        let a0 = a[i];
990        let a1 = a[(i + 1) % na];
991        for j in 0..nb {
992            let b0 = b[j];
993            let b1 = b[(j + 1) % nb];
994            if let Some(pt) = segment_segment_intersect_2d(a0, a1, b0, b1) {
995                result.push(pt);
996            }
997        }
998    }
999    result
1000}
1001/// Compute the intersection of two 2D line segments.
1002///
1003/// Returns the intersection point if segments properly intersect, or `None`.
1004pub fn segment_segment_intersect_2d(
1005    p1: [f64; 2],
1006    p2: [f64; 2],
1007    p3: [f64; 2],
1008    p4: [f64; 2],
1009) -> Option<[f64; 2]> {
1010    let d1 = [p2[0] - p1[0], p2[1] - p1[1]];
1011    let d2 = [p4[0] - p3[0], p4[1] - p3[1]];
1012    let denom = d1[0] * d2[1] - d1[1] * d2[0];
1013    if denom.abs() < 1e-12 {
1014        return None;
1015    }
1016    let t = ((p3[0] - p1[0]) * d2[1] - (p3[1] - p1[1]) * d2[0]) / denom;
1017    let u = ((p3[0] - p1[0]) * d1[1] - (p3[1] - p1[1]) * d1[0]) / denom;
1018    if (0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u) {
1019        Some([p1[0] + t * d1[0], p1[1] + t * d1[1]])
1020    } else {
1021        None
1022    }
1023}
1024/// Offset a 2D polygon outward by `delta` (positive) or inward by `|delta|`
1025/// (negative).
1026///
1027/// Each edge is displaced by `delta` in its outward normal direction.
1028/// The offset polygon is computed by offsetting each edge and re-intersecting
1029/// adjacent pairs.  This is the Minkowski sum with a disk of radius `|delta|`
1030/// for convex polygons.
1031///
1032/// Returns the offset polygon or an empty vector if degenerate.
1033pub fn offset_polygon_2d(polygon: &[[f64; 2]], delta: f64) -> Vec<[f64; 2]> {
1034    let n = polygon.len();
1035    if n < 3 {
1036        return Vec::new();
1037    }
1038    let mut offset_lines: Vec<([f64; 2], [f64; 2])> = Vec::with_capacity(n);
1039    for i in 0..n {
1040        let a = polygon[i];
1041        let b = polygon[(i + 1) % n];
1042        let edge = [b[0] - a[0], b[1] - a[1]];
1043        let len = (edge[0] * edge[0] + edge[1] * edge[1]).sqrt();
1044        if len < 1e-12 {
1045            continue;
1046        }
1047        let normal = [edge[1] / len, -edge[0] / len];
1048        let oa = [a[0] + delta * normal[0], a[1] + delta * normal[1]];
1049        let ob = [b[0] + delta * normal[0], b[1] + delta * normal[1]];
1050        offset_lines.push((oa, ob));
1051    }
1052    if offset_lines.len() < 3 {
1053        return Vec::new();
1054    }
1055    let m = offset_lines.len();
1056    let mut result = Vec::with_capacity(m);
1057    for i in 0..m {
1058        let (a0, a1) = offset_lines[i];
1059        let (b0, b1) = offset_lines[(i + 1) % m];
1060        if let Some(pt) = line_line_intersect_2d(a0, a1, b0, b1) {
1061            result.push(pt);
1062        } else {
1063            result.push([(a1[0] + b0[0]) * 0.5, (a1[1] + b0[1]) * 0.5]);
1064        }
1065    }
1066    result
1067}
1068/// Compute the intersection of two infinite 2D lines (given as two points each).
1069///
1070/// Returns `None` if the lines are parallel.
1071pub fn line_line_intersect_2d(
1072    a0: [f64; 2],
1073    a1: [f64; 2],
1074    b0: [f64; 2],
1075    b1: [f64; 2],
1076) -> Option<[f64; 2]> {
1077    let da = [a1[0] - a0[0], a1[1] - a0[1]];
1078    let db = [b1[0] - b0[0], b1[1] - b0[1]];
1079    let denom = da[0] * db[1] - da[1] * db[0];
1080    if denom.abs() < 1e-12 {
1081        return None;
1082    }
1083    let t = ((b0[0] - a0[0]) * db[1] - (b0[1] - a0[1]) * db[0]) / denom;
1084    Some([a0[0] + t * da[0], a0[1] + t * da[1]])
1085}
1086/// Compute the solid angle subtended by a triangle at a point `p`.
1087///
1088/// This is used in the winding number computation.
1089/// Formula from Oosterom and Strackee (1983).
1090pub(super) fn solid_angle_triangle(p: [f64; 3], a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> f64 {
1091    let ra = sub3(a, p);
1092    let rb = sub3(b, p);
1093    let rc = sub3(c, p);
1094    let la = len3(ra);
1095    let lb = len3(rb);
1096    let lc = len3(rc);
1097    if la < 1e-15 || lb < 1e-15 || lc < 1e-15 {
1098        return 0.0;
1099    }
1100    let numerator = dot3(ra, cross3(rb, rc));
1101    let denominator = la * lb * lc + dot3(ra, rb) * lc + dot3(rb, rc) * la + dot3(ra, rc) * lb;
1102    if denominator.abs() < 1e-15 {
1103        return 0.0;
1104    }
1105    2.0 * numerator.atan2(denominator)
1106}
1107/// Compute the winding number of a closed triangle mesh around point `p`.
1108///
1109/// The winding number is an integer for points strictly inside/outside:
1110/// - `0` → outside the mesh
1111/// - `±1` → inside the mesh
1112/// - other values indicate the point lies on the surface
1113///
1114/// This is more robust than ray casting for points near mesh boundaries.
1115pub fn winding_number_3d(p: [f64; 3], verts: &[[f64; 3]], tris: &[[usize; 3]]) -> f64 {
1116    let mut total = 0.0f64;
1117    for tri in tris {
1118        let a = verts[tri[0]];
1119        let b = verts[tri[1]];
1120        let c = verts[tri[2]];
1121        total += solid_angle_triangle(p, a, b, c);
1122    }
1123    total / (4.0 * std::f64::consts::PI)
1124}
1125/// Test whether a point is strictly inside a closed mesh using the winding number.
1126///
1127/// Returns `true` if the winding number is close to ±1 (i.e., the point is enclosed).
1128pub fn winding_number_inside(p: [f64; 3], verts: &[[f64; 3]], tris: &[[usize; 3]]) -> bool {
1129    let wn = winding_number_3d(p, verts, tris);
1130    wn.abs() > 0.5
1131}
1132/// Compute the signed area of a 2D polygon (positive for CCW winding).
1133///
1134/// Uses the shoelace (Gauss) formula.
1135pub fn polygon2d_signed_area(poly: &[[f64; 2]]) -> f64 {
1136    let n = poly.len();
1137    if n < 3 {
1138        return 0.0;
1139    }
1140    let mut area = 0.0f64;
1141    for i in 0..n {
1142        let a = poly[i];
1143        let b = poly[(i + 1) % n];
1144        area += a[0] * b[1] - b[0] * a[1];
1145    }
1146    area * 0.5
1147}
1148/// Compute the unsigned area of a 2D polygon.
1149pub fn polygon2d_area(poly: &[[f64; 2]]) -> f64 {
1150    polygon2d_signed_area(poly).abs()
1151}
1152/// Compute the centroid of a 2D polygon.
1153///
1154/// Uses the formula based on vertex cross products.
1155pub fn polygon2d_centroid(poly: &[[f64; 2]]) -> Option<[f64; 2]> {
1156    let n = poly.len();
1157    if n < 3 {
1158        return None;
1159    }
1160    let area = polygon2d_signed_area(poly);
1161    if area.abs() < 1e-24 {
1162        return None;
1163    }
1164    let mut cx = 0.0f64;
1165    let mut cy = 0.0f64;
1166    for i in 0..n {
1167        let a = poly[i];
1168        let b = poly[(i + 1) % n];
1169        let cross = a[0] * b[1] - b[0] * a[1];
1170        cx += (a[0] + b[0]) * cross;
1171        cy += (a[1] + b[1]) * cross;
1172    }
1173    Some([cx / (6.0 * area), cy / (6.0 * area)])
1174}
1175/// Test whether a 2D point is inside a (possibly non-convex) polygon.
1176///
1177/// Uses ray casting in the +X direction.
1178pub fn point_in_polygon_2d(p: [f64; 2], poly: &[[f64; 2]]) -> bool {
1179    let n = poly.len();
1180    if n < 3 {
1181        return false;
1182    }
1183    let mut inside = false;
1184    let mut j = n - 1;
1185    for i in 0..n {
1186        let vi = poly[i];
1187        let vj = poly[j];
1188        if ((vi[1] > p[1]) != (vj[1] > p[1]))
1189            && p[0] < (vj[0] - vi[0]) * (p[1] - vi[1]) / (vj[1] - vi[1]) + vi[0]
1190        {
1191            inside = !inside;
1192        }
1193        j = i;
1194    }
1195    inside
1196}
1197/// Compute the perimeter of a 2D polygon.
1198pub fn polygon2d_perimeter(poly: &[[f64; 2]]) -> f64 {
1199    let n = poly.len();
1200    if n < 2 {
1201        return 0.0;
1202    }
1203    let mut perimeter = 0.0f64;
1204    for i in 0..n {
1205        let a = poly[i];
1206        let b = poly[(i + 1) % n];
1207        let d = [a[0] - b[0], a[1] - b[1]];
1208        perimeter += (d[0] * d[0] + d[1] * d[1]).sqrt();
1209    }
1210    perimeter
1211}
1212/// Test whether a 2D polygon is convex.
1213///
1214/// Returns `true` if all cross products of consecutive edges have the same sign.
1215pub fn polygon2d_is_convex(poly: &[[f64; 2]]) -> bool {
1216    let n = poly.len();
1217    if n < 3 {
1218        return true;
1219    }
1220    let mut sign = 0.0f64;
1221    for i in 0..n {
1222        let a = poly[i];
1223        let b = poly[(i + 1) % n];
1224        let c = poly[(i + 2) % n];
1225        let cross = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
1226        if cross.abs() < 1e-12 {
1227            continue;
1228        }
1229        if sign == 0.0 {
1230            sign = cross.signum();
1231        } else if cross.signum() != sign {
1232            return false;
1233        }
1234    }
1235    true
1236}
1237#[cfg(test)]
1238mod tests_new_bool {
1239
1240    use crate::boolean_ops::BspNode;
1241    use crate::boolean_ops::BspPlane;
1242
1243    use crate::boolean_ops::HalfEdgeMesh;
1244
1245    use crate::boolean_ops::PlaneClass;
1246
1247    use crate::boolean_ops::line_line_intersect_2d;
1248    use crate::boolean_ops::offset_polygon_2d;
1249    use crate::boolean_ops::point_in_polygon_2d;
1250    use crate::boolean_ops::polygon_intersection_points;
1251    use crate::boolean_ops::polygon2d_area;
1252    use crate::boolean_ops::polygon2d_centroid;
1253    use crate::boolean_ops::polygon2d_is_convex;
1254    use crate::boolean_ops::polygon2d_perimeter;
1255    use crate::boolean_ops::polygon2d_signed_area;
1256    use crate::boolean_ops::segment_segment_intersect_2d;
1257    use crate::boolean_ops::split_polygon_by_plane;
1258    use crate::boolean_ops::weiler_atherton_clip;
1259    use crate::boolean_ops::winding_number_3d;
1260    use crate::boolean_ops::winding_number_inside;
1261    #[test]
1262    fn test_bsp_node_classify_point() {
1263        let plane = BspPlane::new([0.0, 0.0, 1.0], 0.0);
1264        let mut node = BspNode::new(plane);
1265        node.polygons
1266            .push(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]]);
1267        assert_eq!(node.classify_point([0.0, 0.0, 1.0]), PlaneClass::Front);
1268        assert_eq!(node.classify_point([0.0, 0.0, -1.0]), PlaneClass::Back);
1269        assert_eq!(node.classify_point([0.0, 0.0, 0.0]), PlaneClass::OnPlane);
1270    }
1271    #[test]
1272    fn test_bsp_node_insert_front_polygon() {
1273        let plane = BspPlane::new([0.0, 0.0, 1.0], 0.0);
1274        let mut node = BspNode::new(plane);
1275        let poly = vec![[0.0, 0.0, 1.0], [1.0, 0.0, 1.0], [0.5, 1.0, 1.0]];
1276        node.insert_polygon(poly);
1277        assert!(node.count_polygons() >= 1);
1278    }
1279    #[test]
1280    fn test_bsp_node_insert_back_polygon() {
1281        let plane = BspPlane::new([0.0, 0.0, 1.0], 0.0);
1282        let mut node = BspNode::new(plane);
1283        let poly = vec![[0.0, 0.0, -1.0], [1.0, 0.0, -1.0], [0.5, 1.0, -1.0]];
1284        node.insert_polygon(poly);
1285        assert!(node.count_polygons() >= 1);
1286    }
1287    #[test]
1288    fn test_bsp_node_coplanar_polygon() {
1289        let plane = BspPlane::new([0.0, 0.0, 1.0], 0.0);
1290        let mut node = BspNode::new(plane);
1291        let poly = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]];
1292        node.insert_polygon(poly);
1293        assert!(node.count_polygons() >= 1);
1294    }
1295    #[test]
1296    fn test_split_polygon_by_plane_clean_separation() {
1297        let plane = BspPlane::new([0.0, 0.0, 1.0], 0.0);
1298        let poly = vec![[0.0, 0.0, 1.0], [1.0, 0.0, 1.0], [0.5, 0.5, -1.0]];
1299        let (front, back) = split_polygon_by_plane(&poly, &plane);
1300        assert!(!front.is_empty());
1301        assert!(!back.is_empty());
1302        for v in &front {
1303            assert!(v[2] >= -1e-7, "front z={}", v[2]);
1304        }
1305        for v in &back {
1306            assert!(v[2] <= 1e-7, "back z={}", v[2]);
1307        }
1308    }
1309    #[test]
1310    fn test_split_polygon_all_front() {
1311        let plane = BspPlane::new([0.0, 0.0, 1.0], 0.0);
1312        let poly = vec![[0.0, 0.0, 1.0], [1.0, 0.0, 2.0], [0.5, 1.0, 1.5]];
1313        let (front, back) = split_polygon_by_plane(&poly, &plane);
1314        assert!(!front.is_empty());
1315        assert!(back.is_empty() || back.iter().all(|v| v[2] <= 1e-7));
1316    }
1317    #[test]
1318    fn test_weiler_atherton_clip_contained() {
1319        let subject = vec![[0.2, 0.2], [0.8, 0.2], [0.8, 0.8], [0.2, 0.8]];
1320        let clip = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
1321        let result = weiler_atherton_clip(&subject, &clip);
1322        assert!(!result.is_empty(), "fully contained polygon should survive");
1323    }
1324    #[test]
1325    fn test_weiler_atherton_clip_partial() {
1326        let subject = vec![[0.5, 0.0], [1.5, 0.0], [1.5, 1.0], [0.5, 1.0]];
1327        let clip = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
1328        let result = weiler_atherton_clip(&subject, &clip);
1329        assert!(!result.is_empty(), "partial overlap should produce result");
1330    }
1331    #[test]
1332    fn test_segment_segment_intersect_basic() {
1333        let p = segment_segment_intersect_2d([0.0, 0.0], [1.0, 1.0], [0.0, 1.0], [1.0, 0.0]);
1334        assert!(p.is_some(), "crossing segments should intersect");
1335        let pt = p.unwrap();
1336        assert!((pt[0] - 0.5).abs() < 1e-10 && (pt[1] - 0.5).abs() < 1e-10);
1337    }
1338    #[test]
1339    fn test_segment_segment_no_intersect() {
1340        let p = segment_segment_intersect_2d([0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0]);
1341        assert!(
1342            p.is_none(),
1343            "parallel non-overlapping segments should not intersect"
1344        );
1345    }
1346    #[test]
1347    fn test_segment_segment_parallel() {
1348        let p = segment_segment_intersect_2d([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]);
1349        assert!(
1350            p.is_none(),
1351            "parallel horizontal segments should not intersect"
1352        );
1353    }
1354    #[test]
1355    fn test_polygon_intersection_points_two_squares() {
1356        let a = vec![[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0]];
1357        let b = vec![[1.0, 0.0], [3.0, 0.0], [3.0, 2.0], [1.0, 2.0]];
1358        let pts = polygon_intersection_points(&a, &b);
1359        assert!(
1360            pts.len() >= 2,
1361            "two overlapping squares should have intersection points, got {}",
1362            pts.len()
1363        );
1364    }
1365    #[test]
1366    fn test_polygon_intersection_points_no_overlap() {
1367        let a = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
1368        let b = vec![[5.0, 0.0], [6.0, 0.0], [6.0, 1.0], [5.0, 1.0]];
1369        let pts = polygon_intersection_points(&a, &b);
1370        assert!(
1371            pts.is_empty(),
1372            "non-overlapping polygons should have no intersection points"
1373        );
1374    }
1375    #[test]
1376    fn test_offset_polygon_2d_outward() {
1377        let poly = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
1378        let offset = offset_polygon_2d(&poly, 0.1);
1379        assert_eq!(offset.len(), 4, "square offset should have 4 vertices");
1380        let area_orig = polygon2d_area(&poly);
1381        let area_offset = polygon2d_area(&offset);
1382        assert!(
1383            area_offset > area_orig,
1384            "outward offset should increase area: {} vs {}",
1385            area_offset,
1386            area_orig
1387        );
1388    }
1389    #[test]
1390    fn test_offset_polygon_2d_inward() {
1391        let poly = vec![[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0]];
1392        let offset = offset_polygon_2d(&poly, -0.1);
1393        assert!(
1394            !offset.is_empty(),
1395            "inward offset of large polygon should survive"
1396        );
1397    }
1398    #[test]
1399    fn test_offset_polygon_2d_zero() {
1400        let poly = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
1401        let offset = offset_polygon_2d(&poly, 0.0);
1402        let area_orig = polygon2d_area(&poly);
1403        let area_offset = polygon2d_area(&offset);
1404        assert!(
1405            (area_orig - area_offset).abs() < 0.01,
1406            "zero offset should not change area"
1407        );
1408    }
1409    #[test]
1410    fn test_line_line_intersect_crossing() {
1411        let pt = line_line_intersect_2d([0.0, 0.0], [2.0, 2.0], [0.0, 2.0], [2.0, 0.0]);
1412        assert!(pt.is_some());
1413        let p = pt.unwrap();
1414        assert!((p[0] - 1.0).abs() < 1e-10 && (p[1] - 1.0).abs() < 1e-10);
1415    }
1416    #[test]
1417    fn test_line_line_intersect_parallel() {
1418        let pt = line_line_intersect_2d([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]);
1419        assert!(pt.is_none(), "parallel lines should not intersect");
1420    }
1421    #[test]
1422    fn test_half_edge_mesh_tetrahedron() {
1423        let verts = vec![
1424            [0.0, 0.0, 0.0],
1425            [1.0, 0.0, 0.0],
1426            [0.5, 1.0, 0.0],
1427            [0.5, 0.5, 1.0],
1428        ];
1429        let tris = vec![[0, 1, 2], [0, 1, 3], [1, 2, 3], [0, 2, 3]];
1430        let mesh = HalfEdgeMesh::from_triangle_mesh(&verts, &tris);
1431        assert_eq!(mesh.n_faces(), 4);
1432        assert_eq!(mesh.n_vertices(), 4);
1433        assert_eq!(mesh.half_edges.len(), 12, "4 triangles * 3 half-edges = 12");
1434    }
1435    #[test]
1436    fn test_half_edge_mesh_face_vertices() {
1437        let verts = vec![
1438            [0.0, 0.0, 0.0],
1439            [1.0, 0.0, 0.0],
1440            [0.0, 1.0, 0.0],
1441            [0.0, 0.0, 1.0],
1442        ];
1443        let tris = vec![[0, 1, 2], [0, 1, 3], [1, 2, 3], [0, 2, 3]];
1444        let mesh = HalfEdgeMesh::from_triangle_mesh(&verts, &tris);
1445        for fi in 0..mesh.n_faces() {
1446            let fv = mesh.face_vertices(fi);
1447            for &v in &fv {
1448                assert!(v < mesh.n_vertices(), "vertex index out of range: {v}");
1449            }
1450        }
1451    }
1452    #[test]
1453    fn test_half_edge_mesh_face_area_positive() {
1454        let verts = vec![
1455            [0.0, 0.0, 0.0],
1456            [1.0, 0.0, 0.0],
1457            [0.5, 1.0, 0.0],
1458            [0.5, 0.5, 1.0],
1459        ];
1460        let tris = vec![[0, 1, 2], [0, 1, 3], [1, 2, 3], [0, 2, 3]];
1461        let mesh = HalfEdgeMesh::from_triangle_mesh(&verts, &tris);
1462        for fi in 0..mesh.n_faces() {
1463            let area = mesh.face_area(fi);
1464            assert!(area > 0.0, "face {fi} area should be positive, got {area}");
1465        }
1466    }
1467    #[test]
1468    fn test_winding_number_inside_unit_box() {
1469        let (verts, tris) = unit_box_mesh_raw();
1470        let inside = winding_number_inside([0.5, 0.5, 0.5], &verts, &tris);
1471        assert!(inside, "center of unit box should be inside");
1472    }
1473    #[test]
1474    fn test_winding_number_outside_unit_box() {
1475        let (verts, tris) = unit_box_mesh_raw();
1476        let inside = winding_number_inside([5.0, 5.0, 5.0], &verts, &tris);
1477        assert!(!inside, "far exterior point should not be inside");
1478    }
1479    #[test]
1480    fn test_winding_number_value_inside_is_one() {
1481        let (verts, tris) = unit_box_mesh_raw();
1482        let wn = winding_number_3d([0.5, 0.5, 0.5], &verts, &tris);
1483        assert!(
1484            (wn.abs() - 1.0).abs() < 0.1,
1485            "winding number inside should be ≈1, got {wn}"
1486        );
1487    }
1488    #[test]
1489    fn test_winding_number_value_outside_is_zero() {
1490        let (verts, tris) = unit_box_mesh_raw();
1491        let wn = winding_number_3d([5.0, 5.0, 5.0], &verts, &tris);
1492        assert!(
1493            wn.abs() < 0.1,
1494            "winding number outside should be ≈0, got {wn}"
1495        );
1496    }
1497    #[test]
1498    fn test_polygon2d_area_unit_square() {
1499        let poly = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
1500        let area = polygon2d_area(&poly);
1501        assert!(
1502            (area - 1.0).abs() < 1e-12,
1503            "unit square area should be 1.0, got {area}"
1504        );
1505    }
1506    #[test]
1507    fn test_polygon2d_signed_area_ccw_positive() {
1508        let poly = vec![[0.0, 0.0], [1.0, 0.0], [0.5, 1.0]];
1509        let area = polygon2d_signed_area(&poly);
1510        assert!(
1511            area > 0.0,
1512            "CCW polygon should have positive signed area, got {area}"
1513        );
1514    }
1515    #[test]
1516    fn test_polygon2d_signed_area_cw_negative() {
1517        let poly = vec![[0.0, 0.0], [0.5, 1.0], [1.0, 0.0]];
1518        let area = polygon2d_signed_area(&poly);
1519        assert!(
1520            area < 0.0,
1521            "CW polygon should have negative signed area, got {area}"
1522        );
1523    }
1524    #[test]
1525    fn test_polygon2d_centroid_square() {
1526        let poly = vec![[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0]];
1527        let c = polygon2d_centroid(&poly).expect("centroid");
1528        assert!(
1529            (c[0] - 1.0).abs() < 1e-10 && (c[1] - 1.0).abs() < 1e-10,
1530            "centroid of 2x2 square should be (1,1), got ({}, {})",
1531            c[0],
1532            c[1]
1533        );
1534    }
1535    #[test]
1536    fn test_point_in_polygon_2d_inside() {
1537        let poly = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
1538        assert!(
1539            point_in_polygon_2d([0.5, 0.5], &poly),
1540            "center should be inside"
1541        );
1542    }
1543    #[test]
1544    fn test_point_in_polygon_2d_outside() {
1545        let poly = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
1546        assert!(
1547            !point_in_polygon_2d([2.0, 2.0], &poly),
1548            "exterior point should be outside"
1549        );
1550    }
1551    #[test]
1552    fn test_polygon2d_perimeter_unit_square() {
1553        let poly = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
1554        let p = polygon2d_perimeter(&poly);
1555        assert!(
1556            (p - 4.0).abs() < 1e-12,
1557            "unit square perimeter should be 4, got {p}"
1558        );
1559    }
1560    #[test]
1561    fn test_polygon2d_is_convex_square() {
1562        let poly = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
1563        assert!(polygon2d_is_convex(&poly), "unit square should be convex");
1564    }
1565    #[test]
1566    fn test_polygon2d_is_not_convex_star() {
1567        let poly = vec![[0.0, 0.0], [2.0, 1.0], [1.0, 0.0], [2.0, -1.0]];
1568        let _ = polygon2d_is_convex(&poly);
1569    }
1570    #[test]
1571    fn test_polygon2d_perimeter_equilateral_triangle() {
1572        let side = 1.0_f64;
1573        let h = (3.0_f64.sqrt() / 2.0) * side;
1574        let poly = vec![[0.0, 0.0], [side, 0.0], [side / 2.0, h]];
1575        let p = polygon2d_perimeter(&poly);
1576        assert!(
1577            (p - 3.0 * side).abs() < 1e-10,
1578            "equilateral triangle perimeter should be 3, got {p}"
1579        );
1580    }
1581    /// Helper: build a unit box mesh as raw arrays.
1582    fn unit_box_mesh_raw() -> (Vec<[f64; 3]>, Vec<[usize; 3]>) {
1583        let verts = vec![
1584            [0.0, 0.0, 0.0],
1585            [1.0, 0.0, 0.0],
1586            [1.0, 1.0, 0.0],
1587            [0.0, 1.0, 0.0],
1588            [0.0, 0.0, 1.0],
1589            [1.0, 0.0, 1.0],
1590            [1.0, 1.0, 1.0],
1591            [0.0, 1.0, 1.0],
1592        ];
1593        let tris = vec![
1594            [0, 3, 2],
1595            [0, 2, 1],
1596            [4, 5, 6],
1597            [4, 6, 7],
1598            [0, 1, 5],
1599            [0, 5, 4],
1600            [2, 3, 7],
1601            [2, 7, 6],
1602            [0, 4, 7],
1603            [0, 7, 3],
1604            [1, 2, 6],
1605            [1, 6, 5],
1606        ];
1607        (verts, tris)
1608    }
1609}