1#![allow(clippy::tuple_array_conversions)]
12
13use brepkit_math::aabb::Aabb3;
14use brepkit_math::bvh::Bvh;
15use brepkit_math::cdt::Cdt;
16use brepkit_math::det_hash::DetHashMap;
17use brepkit_math::predicates::orient3d;
18use brepkit_math::vec::{Point2, Point3, Vec3};
19
20use crate::OperationsError;
21use crate::boolean::BooleanOp;
22use crate::tessellate::TriangleMesh;
23
24#[derive(Debug, Clone)]
26pub struct MeshBooleanResult {
27 pub mesh: TriangleMesh,
29 pub boundary_edge_count: usize,
33 pub non_manifold_edge_count: usize,
36}
37
38const SELF_CHECK_GRID: f64 = crate::tessellate::COINCIDENT_DEDUPE_GRID;
43
44const MAX_INPUT_TRIANGLES: usize = 4_000_000;
60const MAX_INTERSECTING_PAIRS: usize = 2_000_000;
61
62pub fn mesh_boolean(
80 mesh_a: &TriangleMesh,
81 mesh_b: &TriangleMesh,
82 op: BooleanOp,
83 tolerance: f64,
84) -> Result<MeshBooleanResult, OperationsError> {
85 let (tris_a, tris_b) = (mesh_a.indices.len() / 3, mesh_b.indices.len() / 3);
86 log::debug!("mesh_boolean {op:?}: input triangles a={tris_a} b={tris_b}");
87 if tris_a + tris_b > MAX_INPUT_TRIANGLES {
88 return Err(OperationsError::InvalidInput {
89 reason: format!(
90 "mesh boolean operands too large to co-refine: {tris_a} + {tris_b} triangles \
91 exceeds the {MAX_INPUT_TRIANGLES} budget"
92 ),
93 });
94 }
95
96 let bvh_a = build_triangle_bvh(mesh_a);
98 let bvh_b = build_triangle_bvh(mesh_b);
99 let pairs = find_intersecting_pairs(mesh_a, &bvh_b, tolerance);
100 log::debug!("mesh_boolean {op:?}: {} intersecting pairs", pairs.len());
101 if pairs.len() > MAX_INTERSECTING_PAIRS {
102 return Err(OperationsError::InvalidInput {
103 reason: format!(
104 "mesh boolean co-refinement too large: {} intersecting triangle pairs \
105 exceeds the {MAX_INTERSECTING_PAIRS} budget (operands {tris_a} + {tris_b} triangles)",
106 pairs.len()
107 ),
108 });
109 }
110
111 let segments = compute_all_intersections(mesh_a, mesh_b, &pairs, tolerance);
113 log::debug!("mesh_boolean {op:?}: step2 {} segments", segments.len());
114
115 let split_a = split_mesh_conforming(mesh_a, &segments, true, tolerance);
117 let split_b = split_mesh_conforming(mesh_b, &segments, false, tolerance);
118
119 let classify_a = classify_split_triangles(&split_a, mesh_b, &bvh_b, tolerance);
121 let classify_b = classify_split_triangles(&split_b, mesh_a, &bvh_a, tolerance);
122
123 let mesh = assemble_result(&split_a, &split_b, &classify_a, &classify_b, op);
125 log::debug!(
126 "mesh_boolean {op:?}: assembled {} tris",
127 mesh.indices.len() / 3
128 );
129
130 if mesh.positions.is_empty() {
131 return Err(OperationsError::EmptyResult {
132 reason: "mesh boolean produced no output vertices".into(),
133 });
134 }
135
136 let (boundary_edge_count, non_manifold_edge_count) = welded_health(&mesh, SELF_CHECK_GRID);
137 Ok(MeshBooleanResult {
138 mesh,
139 boundary_edge_count,
140 non_manifold_edge_count,
141 })
142}
143
144fn welded_health(mesh: &TriangleMesh, grid: f64) -> (usize, usize) {
146 type Q = (i64, i64, i64);
147 let s = 1.0 / grid;
148 #[allow(clippy::cast_possible_truncation)]
149 let q = |p: Point3| -> Q {
150 (
151 (p.x() * s).round() as i64,
152 (p.y() * s).round() as i64,
153 (p.z() * s).round() as i64,
154 )
155 };
156 let mut occ: DetHashMap<(Q, Q), u32> = DetHashMap::default();
157 for tri in mesh.indices.chunks_exact(3) {
158 let a = q(mesh.positions[tri[0] as usize]);
159 let b = q(mesh.positions[tri[1] as usize]);
160 let c = q(mesh.positions[tri[2] as usize]);
161 if a == b || b == c || a == c {
162 continue;
163 }
164 for (p, r) in [(a, b), (b, c), (c, a)] {
165 let key = if p <= r { (p, r) } else { (r, p) };
166 *occ.entry(key).or_default() += 1;
167 }
168 }
169 let bnd = occ.values().filter(|&&c| c == 1).count();
170 let nm = occ.values().filter(|&&c| c > 2).count();
171 (bnd, nm)
172}
173
174fn build_triangle_bvh(mesh: &TriangleMesh) -> Bvh {
176 let tri_count = mesh.indices.len() / 3;
177 let mut entries = Vec::with_capacity(tri_count);
178 for i in 0..tri_count {
179 let (v0, v1, v2) = get_triangle(mesh, i);
180 entries.push((i, Aabb3::from_points([v0, v1, v2])));
181 }
182 Bvh::build(&entries)
183}
184
185fn find_intersecting_pairs(
187 mesh_a: &TriangleMesh,
188 bvh_b: &Bvh,
189 tolerance: f64,
190) -> Vec<(usize, usize)> {
191 let tri_count_a = mesh_a.indices.len() / 3;
192 let mut pairs = Vec::new();
193
194 for i in 0..tri_count_a {
195 let (v0, v1, v2) = get_triangle(mesh_a, i);
196 let aabb_a = Aabb3::from_points([v0, v1, v2]).expanded(tolerance);
197 let candidates = bvh_b.query_overlap(&aabb_a);
198 for j in candidates {
199 pairs.push((i, j));
200 }
201 }
202
203 pairs
204}
205
206#[derive(Debug, Clone)]
213struct IsectSegment {
214 p0: Point3,
215 p1: Point3,
216 tri_a: usize,
217 tri_b: usize,
218 apply_a: bool,
219 apply_b: bool,
220}
221
222fn compute_all_intersections(
224 mesh_a: &TriangleMesh,
225 mesh_b: &TriangleMesh,
226 pairs: &[(usize, usize)],
227 tolerance: f64,
228) -> Vec<IsectSegment> {
229 let mut result = Vec::new();
230
231 for &(tri_a, tri_b) in pairs {
232 let (a0, a1, a2) = get_triangle(mesh_a, tri_a);
233 let (b0, b1, b2) = get_triangle(mesh_b, tri_b);
234
235 for mut seg in intersect_triangles(a0, a1, a2, b0, b1, b2, tolerance) {
236 seg.tri_a = tri_a;
237 seg.tri_b = tri_b;
238 result.push(seg);
239 }
240 }
241
242 result
243}
244
245#[allow(clippy::similar_names)]
253fn intersect_triangles(
254 a0: Point3,
255 a1: Point3,
256 a2: Point3,
257 b0: Point3,
258 b1: Point3,
259 b2: Point3,
260 tolerance: f64,
261) -> Vec<IsectSegment> {
262 let na_pre = (a1 - a0).cross(a2 - a0);
268 let nb_pre = (b1 - b0).cross(b2 - b0);
269 let na_len = na_pre.length().max(1e-30);
270 let nb_len = nb_pre.length().max(1e-30);
271 let db0 = orient3d(a0, a1, a2, b0) / na_len;
272 let db1 = orient3d(a0, a1, a2, b1) / na_len;
273 let db2 = orient3d(a0, a1, a2, b2) / na_len;
274
275 if all_same_sign(db0, db1, db2, tolerance) {
276 return grazing_imprint([a0, a1, a2], [b0, b1, b2], [db0, db1, db2], true, tolerance);
283 }
284
285 let da0 = orient3d(b0, b1, b2, a0) / nb_len;
288 let da1 = orient3d(b0, b1, b2, a1) / nb_len;
289 let da2 = orient3d(b0, b1, b2, a2) / nb_len;
290
291 if all_same_sign(da0, da1, da2, tolerance) {
292 return grazing_imprint(
294 [b0, b1, b2],
295 [a0, a1, a2],
296 [da0, da1, da2],
297 false,
298 tolerance,
299 );
300 }
301
302 let na = (a1 - a0).cross(a2 - a0);
303 let nb = (b1 - b0).cross(b2 - b0);
304 let line_dir = na.cross(nb);
305
306 let line_len_sq = line_dir.dot(line_dir);
308 let na_len_sq = na.dot(na);
309 let nb_len_sq = nb.dot(nb);
310 if line_len_sq < (tolerance * tolerance) * na_len_sq.max(nb_len_sq) {
312 return coplanar_corefine_segments([a0, a1, a2], [b0, b1, b2], tolerance);
313 }
314
315 let ax = line_dir.x().abs();
317 let ay = line_dir.y().abs();
318 let az = line_dir.z().abs();
319
320 let project = |p: Point3| -> f64 {
321 if ax >= ay && ax >= az {
322 p.x()
323 } else if ay >= az {
324 p.y()
325 } else {
326 p.z()
327 }
328 };
329
330 let Some((ta_min, ta_max)) = triangle_interval(a0, a1, a2, da0, da1, da2, &project) else {
331 return Vec::new();
332 };
333 let Some((tb_min, tb_max)) = triangle_interval(b0, b1, b2, db0, db1, db2, &project) else {
334 return Vec::new();
335 };
336
337 let t_lo = ta_min.max(tb_min);
338 let t_hi = ta_max.min(tb_max);
339
340 if t_hi - t_lo < tolerance {
341 return Vec::new();
342 }
343
344 let tri_data = TriPlaneData {
345 v: [a0, a1, a2],
346 d: [da0, da1, da2],
347 };
348 let p0 = point_on_intersection_line(&tri_data, t_lo, &project);
349 let p1 = point_on_intersection_line(&tri_data, t_hi, &project);
350
351 vec![IsectSegment {
352 p0,
353 p1,
354 tri_a: 0,
355 tri_b: 0,
356 apply_a: true,
357 apply_b: true,
358 }]
359}
360
361fn grazing_imprint(
368 host: [Point3; 3],
369 touching: [Point3; 3],
370 touching_orients: [f64; 3],
371 host_is_a: bool,
372 tolerance: f64,
373) -> Vec<IsectSegment> {
374 let n = (host[1] - host[0]).cross(host[2] - host[0]);
376 if n.length() < 1e-30 {
377 return Vec::new();
378 }
379 let on_plane: Vec<usize> = (0..3)
380 .filter(|&i| touching_orients[i].abs() <= tolerance)
381 .collect();
382 if on_plane.len() != 2 {
383 return Vec::new();
384 }
385 let p0 = touching[on_plane[0]];
386 let p1 = touching[on_plane[1]];
387
388 let nax = n.x().abs();
391 let nay = n.y().abs();
392 let naz = n.z().abs();
393 let to_2d = |p: Point3| -> Point2 {
394 if naz >= nax && naz >= nay {
395 Point2::new(p.x(), p.y())
396 } else if nay >= nax {
397 Point2::new(p.x(), p.z())
398 } else {
399 Point2::new(p.y(), p.z())
400 }
401 };
402 let host2d = [to_2d(host[0]), to_2d(host[1]), to_2d(host[2])];
403 let Some((t0, t1)) = clip_segment_to_triangle_2d(to_2d(p0), to_2d(p1), &host2d, tolerance)
404 else {
405 return Vec::new();
406 };
407 let e = p1 - p0;
408 if (t1 - t0) * e.length() < tolerance * 2.0 {
409 return Vec::new();
410 }
411 let _ = host_is_a;
417 vec![IsectSegment {
418 p0: lerp_point(p0, p1, t0),
419 p1: lerp_point(p0, p1, t1),
420 tri_a: 0,
421 tri_b: 0,
422 apply_a: true,
423 apply_b: true,
424 }]
425}
426
427fn coplanar_corefine_segments(
436 a3d: [Point3; 3],
437 b3d: [Point3; 3],
438 tolerance: f64,
439) -> Vec<IsectSegment> {
440 let na = (a3d[1] - a3d[0]).cross(a3d[2] - a3d[0]);
441 let nax = na.x().abs();
442 let nay = na.y().abs();
443 let naz = na.z().abs();
444
445 let to_2d = |p: Point3| -> Point2 {
446 if naz >= nax && naz >= nay {
447 Point2::new(p.x(), p.y())
448 } else if nay >= nax {
449 Point2::new(p.x(), p.z())
450 } else {
451 Point2::new(p.y(), p.z())
452 }
453 };
454
455 let a2d = [to_2d(a3d[0]), to_2d(a3d[1]), to_2d(a3d[2])];
456 let b2d = [to_2d(b3d[0]), to_2d(b3d[1]), to_2d(b3d[2])];
457
458 let mut out = Vec::new();
459 clip_edges_into(&b2d, &b3d, &a2d, tolerance, true, &mut out);
461 clip_edges_into(&a2d, &a3d, &b2d, tolerance, false, &mut out);
463 out
464}
465
466fn clip_edges_into(
469 src2d: &[Point2; 3],
470 src3d: &[Point3; 3],
471 clip2d: &[Point2; 3],
472 tolerance: f64,
473 for_mesh_a: bool,
474 out: &mut Vec<IsectSegment>,
475) {
476 for (i, j) in [(0usize, 1usize), (1, 2), (2, 0)] {
477 let Some((t0, t1)) = clip_segment_to_triangle_2d(src2d[i], src2d[j], clip2d, tolerance)
478 else {
479 continue;
480 };
481 let e3d = src3d[j] - src3d[i];
482 let len = e3d.length();
483 if (t1 - t0) * len < tolerance * 2.0 {
484 continue;
485 }
486 let p0 = lerp_point(src3d[i], src3d[j], t0);
487 let p1 = lerp_point(src3d[i], src3d[j], t1);
488 out.push(IsectSegment {
489 p0,
490 p1,
491 tri_a: 0,
492 tri_b: 0,
493 apply_a: for_mesh_a,
494 apply_b: !for_mesh_a,
495 });
496 }
497}
498
499fn clip_segment_to_triangle_2d(
503 e0: Point2,
504 e1: Point2,
505 tri: &[Point2; 3],
506 tolerance: f64,
507) -> Option<(f64, f64)> {
508 let signed2 = (tri[1].x() - tri[0].x()) * (tri[2].y() - tri[0].y())
510 - (tri[1].y() - tri[0].y()) * (tri[2].x() - tri[0].x());
511 if signed2.abs() < 1e-30 {
512 return None;
513 }
514 let (o0, o1, o2) = if signed2 > 0.0 {
515 (tri[0], tri[1], tri[2])
516 } else {
517 (tri[0], tri[2], tri[1])
518 };
519
520 let mut t_lo = 0.0_f64;
521 let mut t_hi = 1.0_f64;
522 for (p, q) in [(o0, o1), (o1, o2), (o2, o0)] {
523 let ex = q.x() - p.x();
524 let ey = q.y() - p.y();
525 let elen = ex.hypot(ey);
526 if elen < 1e-30 {
527 return None;
528 }
529 let d0 = (ex * (e0.y() - p.y()) - ey * (e0.x() - p.x())) / elen;
532 let d1 = (ex * (e1.y() - p.y()) - ey * (e1.x() - p.x())) / elen;
533 let eps = tolerance;
534 if d0 < -eps && d1 < -eps {
535 return None;
536 }
537 if d0 >= -eps && d1 >= -eps {
538 continue; }
540 let t = d0 / (d0 - d1);
542 if d0 < -eps {
543 t_lo = t_lo.max(t);
544 } else {
545 t_hi = t_hi.min(t);
546 }
547 }
548 if t_hi <= t_lo {
549 return None;
550 }
551 Some((t_lo, t_hi))
552}
553
554fn all_same_sign(d0: f64, d1: f64, d2: f64, tolerance: f64) -> bool {
556 let pos = d0 > tolerance || d1 > tolerance || d2 > tolerance;
557 let neg = d0 < -tolerance || d1 < -tolerance || d2 < -tolerance;
558 (d0 >= -tolerance && d1 >= -tolerance && d2 >= -tolerance && pos && !neg)
560 || (d0 <= tolerance && d1 <= tolerance && d2 <= tolerance && neg && !pos)
561}
562
563fn triangle_interval(
569 v0: Point3,
570 v1: Point3,
571 v2: Point3,
572 d0: f64,
573 d1: f64,
574 d2: f64,
575 project: &dyn Fn(Point3) -> f64,
576) -> Option<(f64, f64)> {
577 let p0 = project(v0);
578 let p1 = project(v1);
579 let p2 = project(v2);
580
581 let (t0, t1) = if (d0 > 0.0) != (d1 > 0.0) && (d0 > 0.0) != (d2 > 0.0) {
584 let ta = interp_param(p0, p1, d0, d1);
586 let tb = interp_param(p0, p2, d0, d2);
587 (ta, tb)
588 } else if (d1 > 0.0) != (d0 > 0.0) && (d1 > 0.0) != (d2 > 0.0) {
589 let ta = interp_param(p1, p0, d1, d0);
591 let tb = interp_param(p1, p2, d1, d2);
592 (ta, tb)
593 } else if (d2 > 0.0) != (d0 > 0.0) && (d2 > 0.0) != (d1 > 0.0) {
594 let ta = interp_param(p2, p0, d2, d0);
596 let tb = interp_param(p2, p1, d2, d1);
597 (ta, tb)
598 } else {
599 let mut ts = Vec::new();
602 if d0.abs() < 1e-15 {
603 ts.push(p0);
604 }
605 if d1.abs() < 1e-15 {
606 ts.push(p1);
607 }
608 if d2.abs() < 1e-15 {
609 ts.push(p2);
610 }
611 if d0 * d1 < 0.0 {
613 ts.push(interp_param(p0, p1, d0, d1));
614 }
615 if d1 * d2 < 0.0 {
616 ts.push(interp_param(p1, p2, d1, d2));
617 }
618 if d0 * d2 < 0.0 {
619 ts.push(interp_param(p0, p2, d0, d2));
620 }
621
622 if ts.len() < 2 {
623 return None;
624 }
625
626 let mut lo = ts[0];
627 let mut hi = ts[0];
628 for &t in &ts[1..] {
629 if t < lo {
630 lo = t;
631 }
632 if t > hi {
633 hi = t;
634 }
635 }
636 (lo, hi)
637 };
638
639 let (lo, hi) = if t0 <= t1 { (t0, t1) } else { (t1, t0) };
640 Some((lo, hi))
641}
642
643fn interp_param(p_a: f64, p_b: f64, d_a: f64, d_b: f64) -> f64 {
646 let denom = d_a - d_b;
647 if denom.abs() < 1e-30 {
648 0.5 * (p_a + p_b)
649 } else {
650 (p_b - p_a).mul_add(d_a / denom, p_a)
651 }
652}
653
654struct TriPlaneData {
656 v: [Point3; 3],
657 d: [f64; 3],
658}
659
660fn point_on_intersection_line(
666 tri: &TriPlaneData,
667 t_target: f64,
668 project: &dyn Fn(Point3) -> f64,
669) -> Point3 {
670 let [v0, v1, v2] = tri.v;
671 let [d0, d1, d2] = tri.d;
672 let mut crossing_points: Vec<Point3> = Vec::with_capacity(2);
674 let mut crossing_params: Vec<f64> = Vec::with_capacity(2);
675
676 let edges = [(v0, v1, d0, d1), (v1, v2, d1, d2), (v0, v2, d0, d2)];
677
678 for &(va, vb, da, db) in &edges {
679 if da.abs() < 1e-15 && db.abs() < 1e-15 {
680 crossing_points.push(va);
682 crossing_params.push(project(va));
683 crossing_points.push(vb);
684 crossing_params.push(project(vb));
685 } else if da.abs() < 1e-15 {
686 crossing_points.push(va);
687 crossing_params.push(project(va));
688 } else if db.abs() < 1e-15 {
689 crossing_points.push(vb);
690 crossing_params.push(project(vb));
691 } else if da * db < 0.0 {
692 let t = da / (da - db);
693 let p = lerp_point(va, vb, t);
694 crossing_points.push(p);
695 crossing_params.push(project(p));
696 }
697 }
698
699 if crossing_points.len() < 2 {
700 return crossing_points
702 .first()
703 .copied()
704 .unwrap_or_else(|| triangle_centroid(v0, v1, v2));
705 }
706
707 let t0 = crossing_params[0];
709 let t1 = crossing_params[1];
710 let denom = t1 - t0;
711 if denom.abs() < 1e-30 {
712 crossing_points[0]
713 } else {
714 let s = (t_target - t0) / denom;
715 lerp_point(crossing_points[0], crossing_points[1], s)
716 }
717}
718
719fn lerp_point(a: Point3, b: Point3, t: f64) -> Point3 {
721 Point3::new(
722 (b.x() - a.x()).mul_add(t, a.x()),
723 (b.y() - a.y()).mul_add(t, a.y()),
724 (b.z() - a.z()).mul_add(t, a.z()),
725 )
726}
727
728fn triangle_centroid(v0: Point3, v1: Point3, v2: Point3) -> Point3 {
730 Point3::new(
731 (v0.x() + v1.x() + v2.x()) / 3.0,
732 (v0.y() + v1.y() + v2.y()) / 3.0,
733 (v0.z() + v1.z() + v2.z()) / 3.0,
734 )
735}
736
737#[derive(Debug, Clone)]
739struct SplitMesh {
740 positions: Vec<Point3>,
741 normals: Vec<Vec3>,
742 triangles: Vec<[u32; 3]>,
743}
744
745enum PointSite {
747 Corner,
748 Edge(usize),
749 Interior,
750}
751
752#[derive(Default)]
754struct HostSplit {
755 pts: Vec<Point3>,
757 constraints: Vec<(usize, usize)>,
759}
760
761type EdgeKey = ((i64, i64, i64), (i64, i64, i64));
763
764fn edge_key(a: Point3, b: Point3) -> EdgeKey {
768 const S: f64 = 1.0e9;
769 #[allow(clippy::cast_possible_truncation)]
770 let q = |p: Point3| -> (i64, i64, i64) {
771 (
772 (p.x() * S).round() as i64,
773 (p.y() * S).round() as i64,
774 (p.z() * S).round() as i64,
775 )
776 };
777 let (qa, qb) = (q(a), q(b));
778 if qa <= qb { (qa, qb) } else { (qb, qa) }
779}
780
781#[allow(clippy::too_many_lines)]
786fn split_mesh_conforming(
787 mesh: &TriangleMesh,
788 segments: &[IsectSegment],
789 is_mesh_a: bool,
790 tolerance: f64,
791) -> SplitMesh {
792 let tri_count = mesh.indices.len() / 3;
793
794 let mut host_segments: DetHashMap<usize, Vec<(Point3, Point3)>> = DetHashMap::default();
796 for seg in segments {
797 let applies = if is_mesh_a { seg.apply_a } else { seg.apply_b };
798 if !applies {
799 continue;
800 }
801 let host = if is_mesh_a { seg.tri_a } else { seg.tri_b };
802 host_segments
803 .entry(host)
804 .or_default()
805 .push((seg.p0, seg.p1));
806 }
807
808 let mut host_data: DetHashMap<usize, HostSplit> = DetHashMap::default();
812 let mut edge_points: DetHashMap<EdgeKey, Vec<Point3>> = DetHashMap::default();
813
814 let mut host_indices: Vec<usize> = host_segments.keys().copied().collect();
815 host_indices.sort_unstable();
816
817 for &host in &host_indices {
818 let segs = &host_segments[&host];
819 let (v0, v1, v2) = get_triangle(mesh, host);
820
821 let mut pts: Vec<Point3> = Vec::new();
822 let canon = |p: Point3, pts: &mut Vec<Point3>| -> usize {
823 let tol_sq = tolerance * tolerance;
824 for (i, q) in pts.iter().enumerate() {
825 if dist_sq(*q, p) < tol_sq {
826 return i;
827 }
828 }
829 pts.push(p);
830 pts.len() - 1
831 };
832
833 let mut seg_idx: Vec<(usize, usize)> = Vec::with_capacity(segs.len());
835 for &(p0, p1) in segs {
836 let i0 = canon(p0, &mut pts);
837 let i1 = canon(p1, &mut pts);
838 if i0 != i1 {
839 seg_idx.push((i0, i1));
840 }
841 }
842
843 let max_rounds = 16 + seg_idx.len() * seg_idx.len();
847 let mut changed = true;
848 let mut rounds = 0;
849 while changed && rounds < max_rounds {
850 changed = false;
851 rounds += 1;
852 'outer: for si in 0..seg_idx.len() {
853 for sj in (si + 1)..seg_idx.len() {
854 let (a0, a1) = seg_idx[si];
855 let (b0, b1) = seg_idx[sj];
856 if a0 == b0 || a0 == b1 || a1 == b0 || a1 == b1 {
857 continue;
858 }
859 if let Some(x) =
860 transversal_crossing(pts[a0], pts[a1], pts[b0], pts[b1], tolerance)
861 {
862 let xi = canon(x, &mut pts);
863 seg_idx[si] = (a0, xi);
864 seg_idx.push((xi, a1));
865 seg_idx[sj] = (b0, xi);
866 seg_idx.push((xi, b1));
867 changed = true;
868 break 'outer;
869 }
870 }
871 }
872 }
873 if changed {
874 log::warn!(
875 "mesh boolean: crossing resolution on host triangle {host} exhausted \
876 {max_rounds} rounds; unresolved crossings may force a non-conforming fan split"
877 );
878 }
879
880 let mut chains: Vec<(usize, usize)> = Vec::with_capacity(seg_idx.len());
883 for &(i0, i1) in &seg_idx {
884 let a = pts[i0];
885 let b = pts[i1];
886 let ab = b - a;
887 let len_sq = ab.dot(ab);
888 if len_sq < tolerance * tolerance {
889 continue;
890 }
891 let mut on_seg: Vec<(f64, usize)> = Vec::new();
892 for (k, &p) in pts.iter().enumerate() {
893 if k == i0 || k == i1 {
894 continue;
895 }
896 let t = (p - a).dot(ab) / len_sq;
897 let margin = tolerance / len_sq.sqrt();
898 if t <= margin || t >= 1.0 - margin {
899 continue;
900 }
901 let foot = lerp_point(a, b, t);
902 if dist_sq(p, foot) < tolerance * tolerance {
903 on_seg.push((t, k));
904 }
905 }
906 on_seg.sort_by(|x, y| x.0.total_cmp(&y.0));
907 let mut prev = i0;
908 for &(_, k) in &on_seg {
909 if prev != k {
910 chains.push((prev, k));
911 }
912 prev = k;
913 }
914 if prev != i1 {
915 chains.push((prev, i1));
916 }
917 }
918
919 let corners = [v0, v1, v2];
921 for &p in &pts {
922 match classify_point_site(p, v0, v1, v2, tolerance) {
923 PointSite::Corner | PointSite::Interior => {}
924 PointSite::Edge(e) => {
925 let (ea, eb) = (corners[e], corners[(e + 1) % 3]);
926 let entry = edge_points.entry(edge_key(ea, eb)).or_default();
927 let tol_sq = tolerance * tolerance;
928 if !entry.iter().any(|q| dist_sq(*q, p) < tol_sq) {
929 entry.push(p);
930 }
931 }
932 }
933 }
934
935 host_data.insert(
936 host,
937 HostSplit {
938 pts,
939 constraints: chains,
940 },
941 );
942 }
943
944 let mut positions = mesh.positions.clone();
947 let mut normals = mesh.normals.clone();
948 let mut triangles: Vec<[u32; 3]> = Vec::with_capacity(tri_count * 2);
949
950 for i in 0..tri_count {
951 let i0 = mesh.indices[i * 3] as usize;
952 let i1 = mesh.indices[i * 3 + 1] as usize;
953 let i2 = mesh.indices[i * 3 + 2] as usize;
954 let v0 = mesh.positions[i0];
955 let v1 = mesh.positions[i1];
956 let v2 = mesh.positions[i2];
957
958 let host = host_data.get(&i);
959 let corners = [v0, v1, v2];
960 let mut edge_pts: [Vec<Point3>; 3] = [Vec::new(), Vec::new(), Vec::new()];
961 for e in 0..3 {
962 let (ea, eb) = (corners[e], corners[(e + 1) % 3]);
963 if let Some(list) = edge_points.get(&edge_key(ea, eb)) {
964 let dir = eb - ea;
965 let len_sq = dir.dot(dir);
966 if len_sq < 1e-30 {
967 continue;
968 }
969 let mut with_t: Vec<(f64, Point3)> = list
970 .iter()
971 .map(|&p| ((p - ea).dot(dir) / len_sq, p))
972 .filter(|&(t, _)| t > 0.0 && t < 1.0)
973 .collect();
974 with_t.sort_by(|x, y| x.0.total_cmp(&y.0));
975 edge_pts[e] = with_t.into_iter().map(|(_, p)| p).collect();
976 }
977 }
978
979 let needs_split =
980 host.is_some_and(|h| !h.pts.is_empty()) || edge_pts.iter().any(|l| !l.is_empty());
981 if !needs_split {
982 #[allow(clippy::cast_possible_truncation)]
983 {
984 triangles.push([i0 as u32, i1 as u32, i2 as u32]);
985 }
986 continue;
987 }
988
989 let sub_tris = retriangulate_conforming(v0, v1, v2, &edge_pts, host, tolerance)
990 .unwrap_or_else(|| legacy_fan_split(v0, v1, v2, &edge_pts, host, tolerance));
991
992 let n0 = mesh.normals[i0];
993 for (sv0, sv1, sv2) in sub_tris {
994 #[allow(clippy::cast_possible_truncation)]
995 let base = positions.len() as u32;
996 positions.push(sv0);
997 positions.push(sv1);
998 positions.push(sv2);
999 normals.push(n0);
1000 normals.push(n0);
1001 normals.push(n0);
1002 triangles.push([base, base + 1, base + 2]);
1003 }
1004 }
1005
1006 SplitMesh {
1007 positions,
1008 normals,
1009 triangles,
1010 }
1011}
1012
1013fn transversal_crossing(
1017 a0: Point3,
1018 a1: Point3,
1019 b0: Point3,
1020 b1: Point3,
1021 tolerance: f64,
1022) -> Option<Point3> {
1023 let da = a1 - a0;
1024 let db = b1 - b0;
1025 let n = da.cross(db);
1026 let n_len_sq = n.dot(n);
1027 let la_sq = da.dot(da);
1028 let lb_sq = db.dot(db);
1029 if n_len_sq < 1e-12 * la_sq * lb_sq {
1030 return None; }
1032 let r = b0 - a0;
1033 let t = r.cross(db).dot(n) / n_len_sq;
1035 let u = r.cross(da).dot(n) / n_len_sq;
1036 let margin_t = tolerance / la_sq.sqrt();
1037 let margin_u = tolerance / lb_sq.sqrt();
1038 if t <= margin_t || t >= 1.0 - margin_t || u <= margin_u || u >= 1.0 - margin_u {
1039 return None;
1040 }
1041 let pa = lerp_point(a0, a1, t);
1042 let pb = lerp_point(b0, b1, u);
1043 if dist_sq(pa, pb) > tolerance * tolerance * 4.0 {
1044 return None; }
1046 Some(pa)
1047}
1048
1049fn classify_point_site(p: Point3, v0: Point3, v1: Point3, v2: Point3, tolerance: f64) -> PointSite {
1052 let tol_sq = tolerance * tolerance;
1053 if dist_sq(p, v0) < tol_sq || dist_sq(p, v1) < tol_sq || dist_sq(p, v2) < tol_sq {
1054 return PointSite::Corner;
1055 }
1056 if let Some(e) = point_on_edge(p, v0, v1, v2, tolerance) {
1057 return PointSite::Edge(e);
1058 }
1059 PointSite::Interior
1060}
1061
1062#[allow(clippy::too_many_lines)]
1069fn retriangulate_conforming(
1070 v0: Point3,
1071 v1: Point3,
1072 v2: Point3,
1073 edge_pts: &[Vec<Point3>; 3],
1074 host: Option<&HostSplit>,
1075 tolerance: f64,
1076) -> Option<Vec<(Point3, Point3, Point3)>> {
1077 let e01 = v1 - v0;
1079 let ng = e01.cross(v2 - v0);
1080 let ng_len = ng.length();
1081 let e01_len = e01.length();
1082 if ng_len < 1e-30 || e01_len < 1e-30 {
1083 return None;
1084 }
1085 let w = ng * (1.0 / ng_len);
1086 let u = e01 * (1.0 / e01_len);
1087 let vax = w.cross(u);
1088 let to2d = |p: Point3| -> Point2 {
1089 let d = p - v0;
1090 Point2::new(d.dot(u), d.dot(vax))
1091 };
1092
1093 let corners2d = [to2d(v0), to2d(v1), to2d(v2)];
1094 let mut min = corners2d[0];
1095 let mut max = corners2d[0];
1096 for c in &corners2d[1..] {
1097 min = Point2::new(min.x().min(c.x()), min.y().min(c.y()));
1098 max = Point2::new(max.x().max(c.x()), max.y().max(c.y()));
1099 }
1100
1101 let n_pts = host.map_or(0, |h| h.pts.len());
1102 let mut cdt = Cdt::with_capacity(
1103 (min, max),
1104 3 + n_pts + edge_pts.iter().map(Vec::len).sum::<usize>(),
1105 );
1106
1107 let mut back: DetHashMap<usize, Point3> = DetHashMap::default();
1109 let insert = |cdt: &mut Cdt, p: Point3, back: &mut DetHashMap<usize, Point3>| {
1110 let idx = cdt.insert_point(to2d(p)).ok()?;
1111 back.entry(idx).or_insert(p);
1112 Some(idx)
1113 };
1114
1115 let c0 = insert(&mut cdt, v0, &mut back)?;
1116 let c1 = insert(&mut cdt, v1, &mut back)?;
1117 let c2 = insert(&mut cdt, v2, &mut back)?;
1118 let corner_idx = [c0, c1, c2];
1119
1120 for e in 0..3 {
1122 let mut prev = corner_idx[e];
1123 for &p in &edge_pts[e] {
1124 let idx = insert(&mut cdt, p, &mut back)?;
1125 if idx != prev {
1126 cdt.insert_constraint(prev, idx).ok()?;
1127 prev = idx;
1128 }
1129 }
1130 let last = corner_idx[(e + 1) % 3];
1131 if prev != last {
1132 cdt.insert_constraint(prev, last).ok()?;
1133 }
1134 }
1135
1136 if let Some(h) = host {
1138 let mut pt_idx: Vec<usize> = Vec::with_capacity(h.pts.len());
1139 for &p in &h.pts {
1140 pt_idx.push(insert(&mut cdt, p, &mut back)?);
1141 }
1142 for &(i0, i1) in &h.constraints {
1143 let (a, b) = (pt_idx[i0], pt_idx[i1]);
1144 if a != b {
1145 cdt.insert_constraint(a, b).ok()?;
1146 }
1147 }
1148 }
1149
1150 let verts2d = cdt.vertices().to_vec();
1152 let mut out: Vec<(Point3, Point3, Point3)> = Vec::new();
1153 let mut area_sum = 0.0_f64;
1154 for (ia, ib, ic) in cdt.triangles() {
1155 let (pa, pb, pc) = (verts2d[ia], verts2d[ib], verts2d[ic]);
1156 let signed2 = (pb.x() - pa.x()) * (pc.y() - pa.y()) - (pb.y() - pa.y()) * (pc.x() - pa.x());
1157 area_sum += signed2.abs() * 0.5;
1158 let (qa, qb, qc) = (
1159 back.get(&ia).copied()?,
1160 back.get(&ib).copied()?,
1161 back.get(&ic).copied()?,
1162 );
1163 if signed2 >= 0.0 {
1166 out.push((qa, qb, qc));
1167 } else {
1168 out.push((qa, qc, qb));
1169 }
1170 }
1171
1172 let host_area = 0.5 * ng_len;
1174 if (area_sum - host_area).abs() > (host_area * 1e-6).max(tolerance) {
1175 return None;
1176 }
1177
1178 Some(out)
1179}
1180
1181fn legacy_fan_split(
1185 v0: Point3,
1186 v1: Point3,
1187 v2: Point3,
1188 edge_pts: &[Vec<Point3>; 3],
1189 host: Option<&HostSplit>,
1190 tolerance: f64,
1191) -> Vec<(Point3, Point3, Point3)> {
1192 let mut points: Vec<Point3> = Vec::new();
1193 for list in edge_pts {
1194 for &p in list {
1195 maybe_add_unique(&mut points, p, tolerance);
1196 }
1197 }
1198 if let Some(h) = host {
1199 for &p in &h.pts {
1200 maybe_add_unique(&mut points, p, tolerance);
1201 }
1202 }
1203 split_triangle_by_points(v0, v1, v2, &points, tolerance)
1204}
1205
1206fn maybe_add_unique(pts: &mut Vec<Point3>, p: Point3, tolerance: f64) {
1208 let tol_sq = tolerance * tolerance;
1209 for existing in pts.iter() {
1210 if dist_sq(*existing, p) < tol_sq {
1211 return;
1212 }
1213 }
1214 pts.push(p);
1215}
1216
1217fn split_triangle_by_points(
1222 v0: Point3,
1223 v1: Point3,
1224 v2: Point3,
1225 points: &[Point3],
1226 tolerance: f64,
1227) -> Vec<(Point3, Point3, Point3)> {
1228 if points.is_empty() {
1229 return vec![(v0, v1, v2)];
1230 }
1231
1232 let mut tris = vec![(v0, v1, v2)];
1234
1235 for &pt in points {
1236 let mut new_tris = Vec::new();
1237 let mut inserted = false;
1238
1239 for (tv0, tv1, tv2) in &tris {
1240 if !inserted && let Some(sub) = try_split_triangle(*tv0, *tv1, *tv2, pt, tolerance) {
1241 new_tris.extend(sub);
1242 inserted = true;
1243 continue;
1244 }
1245 new_tris.push((*tv0, *tv1, *tv2));
1246 }
1247
1248 tris = new_tris;
1249 }
1250
1251 tris
1252}
1253
1254fn try_split_triangle(
1259 v0: Point3,
1260 v1: Point3,
1261 v2: Point3,
1262 pt: Point3,
1263 tolerance: f64,
1264) -> Option<Vec<(Point3, Point3, Point3)>> {
1265 let tol_sq = tolerance * tolerance;
1266
1267 if dist_sq(pt, v0) < tol_sq || dist_sq(pt, v1) < tol_sq || dist_sq(pt, v2) < tol_sq {
1269 return None;
1270 }
1271
1272 if let Some(edge_idx) = point_on_edge(pt, v0, v1, v2, tolerance) {
1274 let result = match edge_idx {
1276 0 => vec![(v0, pt, v2), (pt, v1, v2)], 1 => vec![(v1, pt, v0), (pt, v2, v0)], _ => vec![(v2, pt, v1), (pt, v0, v1)], };
1280 return Some(result);
1281 }
1282
1283 let bary = barycentric(v0, v1, v2, pt);
1285 if bary.0 < -tolerance || bary.1 < -tolerance || bary.2 < -tolerance {
1286 return None; }
1288
1289 Some(vec![(v0, v1, pt), (v1, v2, pt), (v2, v0, pt)])
1291}
1292
1293fn point_on_edge(pt: Point3, v0: Point3, v1: Point3, v2: Point3, tolerance: f64) -> Option<usize> {
1297 let edges = [(v0, v1), (v1, v2), (v2, v0)];
1298 for (i, &(ea, eb)) in edges.iter().enumerate() {
1299 let edge = eb - ea;
1300 let len_sq = edge.length_squared();
1301 if len_sq < 1e-30 {
1302 continue;
1303 }
1304 let t = (pt - ea).dot(edge) / len_sq;
1305 if t < -tolerance || t > 1.0 + tolerance {
1306 continue;
1307 }
1308 let closest = Point3::new(
1309 edge.x().mul_add(t, ea.x()),
1310 edge.y().mul_add(t, ea.y()),
1311 edge.z().mul_add(t, ea.z()),
1312 );
1313 if dist_sq(pt, closest) < tolerance * tolerance {
1314 return Some(i);
1315 }
1316 }
1317 None
1318}
1319
1320fn barycentric(v0: Point3, v1: Point3, v2: Point3, p: Point3) -> (f64, f64, f64) {
1322 let e0 = v1 - v0;
1323 let e1 = v2 - v0;
1324 let ep = p - v0;
1325
1326 let d00 = e0.dot(e0);
1327 let d01 = e0.dot(e1);
1328 let d11 = e1.dot(e1);
1329 let d20 = ep.dot(e0);
1330 let d21 = ep.dot(e1);
1331
1332 let denom = d00.mul_add(d11, -(d01 * d01));
1333 if denom.abs() < 1e-30 {
1334 return (-1.0, -1.0, -1.0); }
1336
1337 let inv = 1.0 / denom;
1338 let v = d11.mul_add(d20, -(d01 * d21)) * inv;
1339 let w = d00.mul_add(d21, -(d01 * d20)) * inv;
1340 let u = 1.0 - v - w;
1341
1342 (u, v, w)
1343}
1344
1345fn dist_sq(a: Point3, b: Point3) -> f64 {
1347 let d = b - a;
1348 d.dot(d)
1349}
1350
1351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1353enum TriState {
1354 Inside,
1356 Outside,
1358 OnSame,
1360 OnOpp,
1362}
1363
1364fn classify_split_triangles(
1372 split: &SplitMesh,
1373 other_mesh: &TriangleMesh,
1374 other_bvh: &Bvh,
1375 tolerance: f64,
1376) -> Vec<TriState> {
1377 let eps_on = tolerance.max(1e-9);
1381 split
1382 .triangles
1383 .iter()
1384 .map(|tri| {
1385 let v0 = split.positions[tri[0] as usize];
1386 let v1 = split.positions[tri[1] as usize];
1387 let v2 = split.positions[tri[2] as usize];
1388 let centroid = triangle_centroid(v0, v1, v2);
1389 let host_n = (v1 - v0).cross(v2 - v0);
1390 let host_n_len = host_n.length();
1391
1392 if host_n_len > 1e-30
1393 && let Some((dist, other_n)) =
1394 closest_surface_normal(centroid, other_mesh, other_bvh, eps_on)
1395 && dist < eps_on
1396 {
1397 let cos = host_n.dot(other_n) / (host_n_len * other_n.length().max(1e-30));
1398 if cos > 0.9 {
1399 return TriState::OnSame;
1400 }
1401 if cos < -0.9 {
1402 return TriState::OnOpp;
1403 }
1404 }
1405
1406 let wn = winding_number_at_point(centroid, other_mesh);
1407 if wn.abs() > 0.5 {
1408 TriState::Inside
1409 } else {
1410 TriState::Outside
1411 }
1412 })
1413 .collect()
1414}
1415
1416fn closest_surface_normal(
1419 p: Point3,
1420 mesh: &TriangleMesh,
1421 bvh: &Bvh,
1422 radius: f64,
1423) -> Option<(f64, Vec3)> {
1424 let query = Aabb3::from_points([p]).expanded(radius);
1425 let candidates = bvh.query_overlap(&query);
1426 let mut best: Option<(f64, Vec3)> = None;
1427 for t in candidates {
1428 let (v0, v1, v2) = get_triangle(mesh, t);
1429 let n = (v1 - v0).cross(v2 - v0);
1430 if n.dot(n) < 1e-30 {
1431 continue;
1432 }
1433 let cp = closest_point_on_triangle(p, v0, v1, v2);
1434 let d = dist_sq(p, cp).sqrt();
1435 if best.is_none_or(|(bd, _)| d < bd) {
1436 best = Some((d, n));
1437 }
1438 }
1439 best
1440}
1441
1442#[allow(clippy::many_single_char_names)]
1445fn closest_point_on_triangle(p: Point3, a: Point3, b: Point3, c: Point3) -> Point3 {
1446 let ab = b - a;
1447 let ac = c - a;
1448 let ap = p - a;
1449
1450 let d1 = ab.dot(ap);
1451 let d2 = ac.dot(ap);
1452 if d1 <= 0.0 && d2 <= 0.0 {
1453 return a;
1454 }
1455
1456 let bp = p - b;
1457 let d3 = ab.dot(bp);
1458 let d4 = ac.dot(bp);
1459 if d3 >= 0.0 && d4 <= d3 {
1460 return b;
1461 }
1462
1463 let vc = d1.mul_add(d4, -(d3 * d2));
1464 if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
1465 let t = d1 / (d1 - d3);
1466 return lerp_point(a, b, t);
1467 }
1468
1469 let cp = p - c;
1470 let d5 = ab.dot(cp);
1471 let d6 = ac.dot(cp);
1472 if d6 >= 0.0 && d5 <= d6 {
1473 return c;
1474 }
1475
1476 let vb = d5.mul_add(d2, -(d1 * d6));
1477 if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
1478 let t = d2 / (d2 - d6);
1479 return lerp_point(a, c, t);
1480 }
1481
1482 let va = d3.mul_add(d6, -(d5 * d4));
1483 if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
1484 let t = (d4 - d3) / ((d4 - d3) + (d5 - d6));
1485 return lerp_point(b, c, t);
1486 }
1487
1488 let denom = 1.0 / (va + vb + vc);
1489 let v = vb * denom;
1490 let w = vc * denom;
1491 Point3::new(
1492 ac.x().mul_add(w, ab.x().mul_add(v, a.x())),
1493 ac.y().mul_add(w, ab.y().mul_add(v, a.y())),
1494 ac.z().mul_add(w, ab.z().mul_add(v, a.z())),
1495 )
1496}
1497
1498#[must_use]
1507pub(crate) fn winding_number_at_point(point: Point3, mesh: &TriangleMesh) -> f64 {
1508 let tri_count = mesh.indices.len() / 3;
1509 let mut total_solid_angle = 0.0;
1510
1511 for i in 0..tri_count {
1512 let (v0, v1, v2) = get_triangle(mesh, i);
1513
1514 let a = v0 - point;
1516 let b = v1 - point;
1517 let c = v2 - point;
1518
1519 let la = a.length();
1520 let lb = b.length();
1521 let lc = c.length();
1522
1523 if la < 1e-15 || lb < 1e-15 || lc < 1e-15 {
1525 continue;
1526 }
1527
1528 let numerator = a.dot(b.cross(c));
1531 let denominator = c
1532 .dot(a)
1533 .mul_add(lb, a.dot(b).mul_add(lc, b.dot(c).mul_add(la, la * lb * lc)));
1534
1535 total_solid_angle += 2.0 * numerator.atan2(denominator);
1537 }
1538
1539 total_solid_angle / (4.0 * std::f64::consts::PI)
1540}
1541
1542fn assemble_result(
1551 split_a: &SplitMesh,
1552 split_b: &SplitMesh,
1553 classify_a: &[TriState],
1554 classify_b: &[TriState],
1555 op: BooleanOp,
1556) -> TriangleMesh {
1557 let mut positions = Vec::new();
1558 let mut normals = Vec::new();
1559 let mut indices = Vec::new();
1560
1561 for (i, tri) in split_a.triangles.iter().enumerate() {
1562 let state = classify_a.get(i).copied().unwrap_or(TriState::Outside);
1563 let keep = match op {
1564 BooleanOp::Fuse => matches!(state, TriState::Outside | TriState::OnSame),
1565 BooleanOp::Cut => matches!(state, TriState::Outside | TriState::OnOpp),
1566 BooleanOp::Intersect => matches!(state, TriState::Inside | TriState::OnSame),
1567 };
1568 if keep {
1569 append_triangle(
1570 &split_a.positions,
1571 &split_a.normals,
1572 tri,
1573 false,
1574 &mut positions,
1575 &mut normals,
1576 &mut indices,
1577 );
1578 }
1579 }
1580
1581 for (i, tri) in split_b.triangles.iter().enumerate() {
1582 let state = classify_b.get(i).copied().unwrap_or(TriState::Outside);
1583 let (keep, flip) = match op {
1584 BooleanOp::Fuse => (state == TriState::Outside, false),
1585 BooleanOp::Cut => (state == TriState::Inside, true),
1586 BooleanOp::Intersect => (state == TriState::Inside, false),
1587 };
1588 if keep {
1589 append_triangle(
1590 &split_b.positions,
1591 &split_b.normals,
1592 tri,
1593 flip,
1594 &mut positions,
1595 &mut normals,
1596 &mut indices,
1597 );
1598 }
1599 }
1600
1601 TriangleMesh {
1602 positions,
1603 normals,
1604 indices,
1605 }
1606}
1607
1608fn append_triangle(
1610 src_positions: &[Point3],
1611 src_normals: &[Vec3],
1612 tri: &[u32; 3],
1613 flip: bool,
1614 positions: &mut Vec<Point3>,
1615 normals: &mut Vec<Vec3>,
1616 indices: &mut Vec<u32>,
1617) {
1618 #[allow(clippy::cast_possible_truncation)]
1619 let base = positions.len() as u32;
1620
1621 if flip {
1622 for &idx in tri.iter().rev() {
1623 let i = idx as usize;
1624 positions.push(src_positions[i]);
1625 normals.push(-src_normals[i]);
1626 }
1627 } else {
1628 for &idx in tri {
1629 let i = idx as usize;
1630 positions.push(src_positions[i]);
1631 normals.push(src_normals[i]);
1632 }
1633 }
1634
1635 indices.push(base);
1636 indices.push(base + 1);
1637 indices.push(base + 2);
1638}
1639
1640fn get_triangle(mesh: &TriangleMesh, tri_idx: usize) -> (Point3, Point3, Point3) {
1642 let base = tri_idx * 3;
1643 let i0 = mesh.indices[base] as usize;
1644 let i1 = mesh.indices[base + 1] as usize;
1645 let i2 = mesh.indices[base + 2] as usize;
1646 (mesh.positions[i0], mesh.positions[i1], mesh.positions[i2])
1647}
1648
1649#[cfg(test)]
1650mod tests {
1651 #![allow(clippy::unwrap_used, clippy::expect_used)]
1652
1653 use super::*;
1654
1655 #[test]
1664 fn oversized_operands_are_rejected_not_attempted() {
1665 let mut huge = TriangleMesh::default();
1666 huge.positions.push(Point3::new(0.0, 0.0, 0.0));
1667 huge.normals.push(Vec3::new(0.0, 0.0, 1.0));
1668 huge.indices = vec![0; (MAX_INPUT_TRIANGLES + 1) * 3];
1669
1670 let small = tetrahedron_mesh(Point3::new(0.0, 0.0, 0.0), 1.0);
1671 let err = mesh_boolean(&huge, &small, BooleanOp::Cut, 1e-7)
1672 .expect_err("oversized operands must be rejected");
1673 assert!(
1674 format!("{err}").contains("too large"),
1675 "expected a budget rejection, got: {err}"
1676 );
1677 }
1678
1679 fn tetrahedron_mesh(center: Point3, size: f64) -> TriangleMesh {
1681 let s = size;
1682 let v0 = Point3::new(center.x() + s, center.y() + s, center.z() + s);
1683 let v1 = Point3::new(center.x() + s, center.y() - s, center.z() - s);
1684 let v2 = Point3::new(center.x() - s, center.y() + s, center.z() - s);
1685 let v3 = Point3::new(center.x() - s, center.y() - s, center.z() + s);
1686
1687 let positions = [v0, v1, v2, v3];
1688
1689 let faces = [(0u32, 2, 1), (0, 1, 3), (0, 3, 2), (1, 2, 3)];
1690 let mut out_positions = Vec::new();
1691 let mut out_normals = Vec::new();
1692 let mut indices = Vec::new();
1693
1694 for &(i0, i1, i2) in &faces {
1695 let p0 = positions[i0 as usize];
1696 let p1 = positions[i1 as usize];
1697 let p2 = positions[i2 as usize];
1698
1699 let e1 = p1 - p0;
1700 let e2 = p2 - p0;
1701 let n = e1.cross(e2).normalize().unwrap_or(Vec3::new(0.0, 0.0, 1.0));
1702
1703 #[allow(clippy::cast_possible_truncation)]
1704 let base = out_positions.len() as u32;
1705 out_positions.push(p0);
1706 out_positions.push(p1);
1707 out_positions.push(p2);
1708 out_normals.push(n);
1709 out_normals.push(n);
1710 out_normals.push(n);
1711 indices.push(base);
1712 indices.push(base + 1);
1713 indices.push(base + 2);
1714 }
1715
1716 TriangleMesh {
1717 positions: out_positions,
1718 normals: out_normals,
1719 indices,
1720 }
1721 }
1722
1723 fn box_mesh(center: Point3, half_size: f64) -> TriangleMesh {
1725 box_mesh_half_extents(center, Vec3::new(half_size, half_size, half_size))
1726 }
1727
1728 fn box_mesh_half_extents(center: Point3, half: Vec3) -> TriangleMesh {
1730 let cx = center.x();
1731 let cy = center.y();
1732 let cz = center.z();
1733 let (sx, sy, sz) = (half.x(), half.y(), half.z());
1734
1735 let verts = [
1736 Point3::new(cx - sx, cy - sy, cz - sz), Point3::new(cx + sx, cy - sy, cz - sz), Point3::new(cx + sx, cy + sy, cz - sz), Point3::new(cx - sx, cy + sy, cz - sz), Point3::new(cx - sx, cy - sy, cz + sz), Point3::new(cx + sx, cy - sy, cz + sz), Point3::new(cx + sx, cy + sy, cz + sz), Point3::new(cx - sx, cy + sy, cz + sz), ];
1745
1746 let face_tris: [(usize, usize, usize, Vec3); 12] = [
1748 (0, 3, 2, Vec3::new(0.0, 0.0, -1.0)),
1750 (0, 2, 1, Vec3::new(0.0, 0.0, -1.0)),
1751 (4, 5, 6, Vec3::new(0.0, 0.0, 1.0)),
1753 (4, 6, 7, Vec3::new(0.0, 0.0, 1.0)),
1754 (0, 4, 7, Vec3::new(-1.0, 0.0, 0.0)),
1756 (0, 7, 3, Vec3::new(-1.0, 0.0, 0.0)),
1757 (1, 2, 6, Vec3::new(1.0, 0.0, 0.0)),
1759 (1, 6, 5, Vec3::new(1.0, 0.0, 0.0)),
1760 (0, 1, 5, Vec3::new(0.0, -1.0, 0.0)),
1762 (0, 5, 4, Vec3::new(0.0, -1.0, 0.0)),
1763 (3, 7, 6, Vec3::new(0.0, 1.0, 0.0)),
1765 (3, 6, 2, Vec3::new(0.0, 1.0, 0.0)),
1766 ];
1767
1768 let mut positions = Vec::new();
1769 let mut normals = Vec::new();
1770 let mut indices = Vec::new();
1771
1772 for &(i0, i1, i2, n) in &face_tris {
1773 #[allow(clippy::cast_possible_truncation)]
1774 let base = positions.len() as u32;
1775 positions.push(verts[i0]);
1776 positions.push(verts[i1]);
1777 positions.push(verts[i2]);
1778 normals.push(n);
1779 normals.push(n);
1780 normals.push(n);
1781 indices.push(base);
1782 indices.push(base + 1);
1783 indices.push(base + 2);
1784 }
1785
1786 TriangleMesh {
1787 positions,
1788 normals,
1789 indices,
1790 }
1791 }
1792
1793 fn mesh_signed_volume(mesh: &TriangleMesh) -> f64 {
1795 let mut vol = 0.0;
1796 for tri in mesh.indices.chunks_exact(3) {
1797 let a = mesh.positions[tri[0] as usize];
1798 let b = mesh.positions[tri[1] as usize];
1799 let c = mesh.positions[tri[2] as usize];
1800 let va = a - Point3::new(0.0, 0.0, 0.0);
1801 let vb = b - Point3::new(0.0, 0.0, 0.0);
1802 let vc = c - Point3::new(0.0, 0.0, 0.0);
1803 vol += va.dot(vb.cross(vc)) / 6.0;
1804 }
1805 vol
1806 }
1807
1808 #[test]
1809 fn mesh_boolean_disjoint_fuse() {
1810 let a = tetrahedron_mesh(Point3::new(0.0, 0.0, 0.0), 1.0);
1811 let b = tetrahedron_mesh(Point3::new(10.0, 0.0, 0.0), 1.0);
1812
1813 let a_tri_count = a.indices.len() / 3;
1814 let b_tri_count = b.indices.len() / 3;
1815
1816 let result = mesh_boolean(&a, &b, BooleanOp::Fuse, 1e-7).unwrap();
1817 let result_tri_count = result.mesh.indices.len() / 3;
1818
1819 assert_eq!(
1821 result_tri_count,
1822 a_tri_count + b_tri_count,
1823 "disjoint fuse should combine all triangles: expected {}, got {}",
1824 a_tri_count + b_tri_count,
1825 result_tri_count
1826 );
1827 }
1828
1829 #[test]
1830 fn mesh_boolean_overlapping_intersect() {
1831 let a = box_mesh(Point3::new(0.0, 0.0, 0.0), 1.0);
1832 let b = box_mesh(Point3::new(0.5, 0.5, 0.5), 1.0);
1833
1834 let result = mesh_boolean(&a, &b, BooleanOp::Intersect, 1e-7).unwrap();
1835 let result_tri_count = result.mesh.indices.len() / 3;
1836
1837 assert!(
1839 result_tri_count > 0,
1840 "intersection of overlapping boxes should have triangles, got {}",
1841 result_tri_count
1842 );
1843
1844 assert_eq!(result.boundary_edge_count, 0, "intersect should be closed");
1846 assert_eq!(
1847 result.non_manifold_edge_count, 0,
1848 "intersect should be manifold"
1849 );
1850 let vol = mesh_signed_volume(&result.mesh);
1851 assert!(
1852 (vol - 1.5_f64.powi(3)).abs() < 1e-9,
1853 "intersection volume should be 3.375, got {vol}"
1854 );
1855 }
1856
1857 #[test]
1858 fn mesh_boolean_overlapping_cut_watertight() {
1859 let a = box_mesh(Point3::new(0.0, 0.0, 0.0), 1.0);
1860 let b = box_mesh(Point3::new(0.5, 0.5, 0.5), 1.0);
1861
1862 let result = mesh_boolean(&a, &b, BooleanOp::Cut, 1e-7).unwrap();
1863 assert_eq!(result.boundary_edge_count, 0, "cut should be closed");
1864 assert_eq!(result.non_manifold_edge_count, 0, "cut should be manifold");
1865 let vol = mesh_signed_volume(&result.mesh);
1866 assert!(
1867 (vol - (8.0 - 1.5_f64.powi(3))).abs() < 1e-9,
1868 "cut volume should be 4.625, got {vol}"
1869 );
1870 }
1871
1872 #[test]
1873 fn mesh_boolean_coincident_wall_cut() {
1874 let a = box_mesh_half_extents(Point3::new(0.0, 0.0, 0.0), Vec3::new(2.0, 1.0, 1.0));
1878 let b = box_mesh_half_extents(Point3::new(-1.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 1.0));
1879
1880 let result = mesh_boolean(&a, &b, BooleanOp::Cut, 1e-7).unwrap();
1881 assert_eq!(
1882 result.boundary_edge_count, 0,
1883 "coincident-wall cut should be closed"
1884 );
1885 assert_eq!(
1886 result.non_manifold_edge_count, 0,
1887 "coincident-wall cut should be manifold"
1888 );
1889 let vol = mesh_signed_volume(&result.mesh);
1890 assert!(
1891 (vol - 8.0).abs() < 1e-9,
1892 "remaining half should have volume 8, got {vol}"
1893 );
1894 }
1895
1896 #[test]
1897 fn mesh_boolean_coincident_wall_fuse() {
1898 let a = box_mesh_half_extents(Point3::new(-1.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 1.0));
1900 let b = box_mesh_half_extents(Point3::new(1.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 1.0));
1901
1902 let result = mesh_boolean(&a, &b, BooleanOp::Fuse, 1e-7).unwrap();
1903 assert_eq!(
1904 result.boundary_edge_count, 0,
1905 "coincident-wall fuse should be closed"
1906 );
1907 assert_eq!(
1908 result.non_manifold_edge_count, 0,
1909 "coincident-wall fuse should be manifold"
1910 );
1911 let vol = mesh_signed_volume(&result.mesh);
1912 assert!(
1913 (vol - 16.0).abs() < 1e-9,
1914 "fused volume should be 16, got {vol}"
1915 );
1916 }
1917
1918 #[test]
1919 fn mesh_boolean_coplanar_top_stack_fuse() {
1920 let a = box_mesh_half_extents(Point3::new(0.0, 0.0, 0.0), Vec3::new(2.0, 2.0, 1.0));
1924 let b = box_mesh_half_extents(Point3::new(0.0, 0.0, 1.5), Vec3::new(0.5, 0.5, 0.5));
1925
1926 let result = mesh_boolean(&a, &b, BooleanOp::Fuse, 1e-7).unwrap();
1927 assert_eq!(result.boundary_edge_count, 0, "stack fuse should be closed");
1928 assert_eq!(
1929 result.non_manifold_edge_count, 0,
1930 "stack fuse should be manifold"
1931 );
1932 let vol = mesh_signed_volume(&result.mesh);
1933 assert!(
1934 (vol - 33.0).abs() < 1e-7,
1935 "stacked volume should be 32 + 1 = 33, got {vol}"
1936 );
1937 }
1938
1939 #[test]
1940 fn mesh_boolean_near_disjoint_fuse_keeps_facing_walls() {
1941 let gap = 5e-7;
1946 let a = box_mesh_half_extents(Point3::new(-1.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 1.0));
1947 let b = box_mesh_half_extents(Point3::new(1.0 + gap, 0.0, 0.0), Vec3::new(1.0, 1.0, 1.0));
1948
1949 let result = mesh_boolean(&a, &b, BooleanOp::Fuse, 1e-7).unwrap();
1950 assert_eq!(
1951 result.mesh.indices.len() / 3,
1952 24,
1953 "near-disjoint fuse must keep all 24 triangles"
1954 );
1955 assert_eq!(
1956 result.boundary_edge_count, 0,
1957 "near-disjoint fuse should be closed"
1958 );
1959 let vol = mesh_signed_volume(&result.mesh);
1960 assert!(
1961 (vol - 16.0).abs() < 1e-6,
1962 "near-disjoint fused volume should be 16, got {vol}"
1963 );
1964 }
1965
1966 #[test]
1967 fn mesh_boolean_produces_valid_mesh() {
1968 let a = box_mesh(Point3::new(0.0, 0.0, 0.0), 1.0);
1969 let b = box_mesh(Point3::new(0.5, 0.5, 0.5), 1.0);
1970
1971 let result = mesh_boolean(&a, &b, BooleanOp::Fuse, 1e-7).unwrap();
1972
1973 let n_verts = result.mesh.positions.len();
1975 for &idx in &result.mesh.indices {
1976 assert!(
1977 (idx as usize) < n_verts,
1978 "index {} out of bounds (n_verts = {})",
1979 idx,
1980 n_verts
1981 );
1982 }
1983
1984 assert_eq!(
1986 result.mesh.indices.len() % 3,
1987 0,
1988 "indices should be a multiple of 3"
1989 );
1990
1991 assert_eq!(
1993 result.mesh.positions.len(),
1994 result.mesh.normals.len(),
1995 "positions and normals should have the same count"
1996 );
1997 }
1998
1999 #[test]
2000 fn winding_number_inside_box() {
2001 let bx = box_mesh(Point3::new(0.0, 0.0, 0.0), 1.0);
2002
2003 let inside = winding_number_at_point(Point3::new(0.0, 0.0, 0.0), &bx);
2004 assert!(
2005 inside.abs() > 0.5,
2006 "winding number at center of box should be ~1, got {}",
2007 inside
2008 );
2009
2010 let outside = winding_number_at_point(Point3::new(5.0, 5.0, 5.0), &bx);
2011 assert!(
2012 outside.abs() < 0.5,
2013 "winding number outside box should be ~0, got {}",
2014 outside
2015 );
2016 }
2017
2018 #[test]
2019 fn mesh_boolean_disjoint_intersect_is_error() {
2020 let a = tetrahedron_mesh(Point3::new(0.0, 0.0, 0.0), 1.0);
2021 let b = tetrahedron_mesh(Point3::new(10.0, 0.0, 0.0), 1.0);
2022
2023 let result = mesh_boolean(&a, &b, BooleanOp::Intersect, 1e-7);
2024 assert!(
2025 result.is_err(),
2026 "intersection of disjoint meshes should error"
2027 );
2028 }
2029
2030 #[test]
2037 fn grazing_edge_contact_emits_mutual_imprint() {
2038 let a0 = Point3::new(0.0, 0.0, 0.0);
2040 let a1 = Point3::new(10.0, 0.0, 0.0);
2041 let a2 = Point3::new(0.0, 10.0, 0.0);
2042 let b0 = Point3::new(1.0, 1.0, 0.0);
2044 let b1 = Point3::new(5.0, 1.0, 0.0);
2045 let b2 = Point3::new(3.0, 1.0, 4.0);
2046
2047 let segs = intersect_triangles(a0, a1, a2, b0, b1, b2, 1e-7);
2048 assert_eq!(segs.len(), 1, "grazing edge must imprint one segment");
2049 let s = &segs[0];
2050 assert!(
2051 s.apply_a && s.apply_b,
2052 "the imprint must constrain both meshes"
2053 );
2054 let len = (s.p1 - s.p0).length();
2055 assert!(
2056 (len - 4.0).abs() < 1e-6,
2057 "imprint must span the touching edge, got {len}"
2058 );
2059 assert!(s.p0.z().abs() < 1e-9 && s.p1.z().abs() < 1e-9);
2060 }
2061}