1use crate::shape::{RayHit, Shape};
8use oxiphysics_core::Aabb;
9use oxiphysics_core::math::{Mat3, Real, Vec3};
10use std::collections::{BinaryHeap, HashMap, HashSet};
11
12#[derive(Debug, Clone)]
14pub struct TriangleMesh {
15 pub vertices: Vec<Vec3>,
17 pub indices: Vec<[usize; 3]>,
19}
20
21impl TriangleMesh {
22 pub fn new(vertices: Vec<Vec3>, indices: Vec<[usize; 3]>) -> Self {
24 Self { vertices, indices }
25 }
26
27 #[allow(dead_code)]
29 pub fn surface_area(&self) -> Real {
30 let mut total = 0.0;
31 for tri in &self.indices {
32 let a = self.vertices[tri[0]];
33 let b = self.vertices[tri[1]];
34 let c = self.vertices[tri[2]];
35 let ab = b - a;
36 let ac = c - a;
37 total += ab.cross(&ac).norm() * 0.5;
38 }
39 total
40 }
41
42 #[allow(dead_code)]
44 pub fn volume_explicit(&self) -> Real {
45 let mut vol = 0.0;
46 for tri in &self.indices {
47 let a = self.vertices[tri[0]];
48 let b = self.vertices[tri[1]];
49 let c = self.vertices[tri[2]];
50 vol += a.dot(&b.cross(&c)) / 6.0;
51 }
52 vol.abs()
53 }
54
55 #[allow(dead_code)]
57 pub fn center_of_mass_explicit(&self) -> [f64; 3] {
58 if self.indices.is_empty() {
59 return [0.0, 0.0, 0.0];
60 }
61 let mut weighted = Vec3::zeros();
62 let mut total_vol = 0.0;
63 for tri in &self.indices {
64 let a = self.vertices[tri[0]];
65 let b = self.vertices[tri[1]];
66 let c = self.vertices[tri[2]];
67 let tet_vol = a.dot(&b.cross(&c)) / 6.0;
68 let tet_center = (a + b + c) * 0.25;
69 weighted += tet_center * tet_vol;
70 total_vol += tet_vol;
71 }
72 if total_vol.abs() > 1e-12 {
73 let com = weighted / total_vol;
74 [com.x, com.y, com.z]
75 } else {
76 [0.0, 0.0, 0.0]
77 }
78 }
79
80 #[allow(dead_code)]
82 pub fn compute_normals(&self) -> Vec<[f64; 3]> {
83 self.indices
84 .iter()
85 .map(|tri| {
86 let a = self.vertices[tri[0]];
87 let b = self.vertices[tri[1]];
88 let c = self.vertices[tri[2]];
89 let ab = b - a;
90 let ac = c - a;
91 let n = ab.cross(&ac);
92 let len = n.norm();
93 if len < 1e-12 {
94 [0.0, 1.0, 0.0]
95 } else {
96 [n.x / len, n.y / len, n.z / len]
97 }
98 })
99 .collect()
100 }
101
102 #[allow(dead_code)]
104 pub fn ray_cast_full(
105 &self,
106 origin: [f64; 3],
107 direction: [f64; 3],
108 max_toi: f64,
109 ) -> Option<(f64, usize, [f64; 3])> {
110 let o = Vec3::new(origin[0], origin[1], origin[2]);
111 let d = Vec3::new(direction[0], direction[1], direction[2]);
112 let mut best: Option<(f64, usize, [f64; 3])> = None;
113
114 for (face_idx, tri) in self.indices.iter().enumerate() {
115 let v0 = self.vertices[tri[0]];
116 let v1 = self.vertices[tri[1]];
117 let v2 = self.vertices[tri[2]];
118
119 if let Some(hit) = ray_triangle(&o, &d, &v0, &v1, &v2)
120 && hit.toi >= 0.0
121 && hit.toi <= max_toi
122 && best.as_ref().is_none_or(|b| hit.toi < b.0)
123 {
124 let n = [hit.normal.x, hit.normal.y, hit.normal.z];
125 best = Some((hit.toi, face_idx, n));
126 }
127 }
128 best
129 }
130
131 #[allow(dead_code)]
133 pub fn is_watertight(&self) -> bool {
134 let mut edge_count: HashMap<(usize, usize), usize> = HashMap::new();
135
136 for tri in &self.indices {
137 let verts = [tri[0], tri[1], tri[2]];
138 for i in 0..3 {
139 let a = verts[i];
140 let b = verts[(i + 1) % 3];
141 let key = if a < b { (a, b) } else { (b, a) };
142 *edge_count.entry(key).or_insert(0) += 1;
143 }
144 }
145
146 edge_count.values().all(|&count| count == 2)
147 }
148
149 #[allow(dead_code)]
156 pub fn vertex_to_faces(&self) -> Vec<Vec<usize>> {
157 let mut v2f = vec![Vec::new(); self.vertices.len()];
158 for (fi, tri) in self.indices.iter().enumerate() {
159 for &vi in tri {
160 v2f[vi].push(fi);
161 }
162 }
163 v2f
164 }
165
166 #[allow(dead_code)]
170 pub fn face_adjacency(&self) -> Vec<Vec<usize>> {
171 let mut edge_faces: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
173 for (fi, tri) in self.indices.iter().enumerate() {
174 for k in 0..3 {
175 let a = tri[k];
176 let b = tri[(k + 1) % 3];
177 let key = if a < b { (a, b) } else { (b, a) };
178 edge_faces.entry(key).or_default().push(fi);
179 }
180 }
181 let mut adj = vec![Vec::new(); self.indices.len()];
182 for faces in edge_faces.values() {
183 for i in 0..faces.len() {
184 for j in (i + 1)..faces.len() {
185 adj[faces[i]].push(faces[j]);
186 adj[faces[j]].push(faces[i]);
187 }
188 }
189 }
190 for v in &mut adj {
192 v.sort_unstable();
193 v.dedup();
194 }
195 adj
196 }
197
198 #[allow(dead_code)]
200 pub fn unique_edges(&self) -> Vec<(usize, usize)> {
201 let mut set: HashSet<(usize, usize)> = HashSet::new();
202 for tri in &self.indices {
203 for k in 0..3 {
204 let a = tri[k];
205 let b = tri[(k + 1) % 3];
206 let key = if a < b { (a, b) } else { (b, a) };
207 set.insert(key);
208 }
209 }
210 let mut edges: Vec<(usize, usize)> = set.into_iter().collect();
211 edges.sort_unstable();
212 edges
213 }
214
215 #[allow(dead_code)]
217 pub fn vertex_neighbors(&self) -> Vec<Vec<usize>> {
218 let mut nbrs = vec![HashSet::<usize>::new(); self.vertices.len()];
219 for tri in &self.indices {
220 for k in 0..3 {
221 let a = tri[k];
222 let b = tri[(k + 1) % 3];
223 nbrs[a].insert(b);
224 nbrs[b].insert(a);
225 }
226 }
227 nbrs.into_iter()
228 .map(|s| {
229 let mut v: Vec<usize> = s.into_iter().collect();
230 v.sort_unstable();
231 v
232 })
233 .collect()
234 }
235
236 #[allow(dead_code)]
246 pub fn edge_collapse(&mut self, v0: usize, v1: usize) -> bool {
247 let edge_found = self
249 .indices
250 .iter()
251 .any(|tri| tri.contains(&v0) && tri.contains(&v1));
252 if !edge_found {
253 return false;
254 }
255
256 let mid = (self.vertices[v0] + self.vertices[v1]) * 0.5;
258 self.vertices[v0] = mid;
259
260 for tri in &mut self.indices {
262 for idx in tri.iter_mut() {
263 if *idx == v1 {
264 *idx = v0;
265 }
266 }
267 }
268
269 self.indices
271 .retain(|tri| tri[0] != tri[1] && tri[1] != tri[2] && tri[0] != tri[2]);
272
273 true
274 }
275
276 #[allow(dead_code)]
283 pub fn compute_vertex_normals(&self) -> Vec<[f64; 3]> {
284 let mut normals = vec![Vec3::zeros(); self.vertices.len()];
285 for tri in &self.indices {
286 let v0 = self.vertices[tri[0]];
287 let v1 = self.vertices[tri[1]];
288 let v2 = self.vertices[tri[2]];
289 let e01 = v1 - v0;
290 let e02 = v2 - v0;
291 let face_n = e01.cross(&e02);
292
293 let angle_at = |va: Vec3, vb: Vec3, vc: Vec3| -> f64 {
294 let ab = (vb - va).normalize();
295 let ac = (vc - va).normalize();
296 ab.dot(&ac).clamp(-1.0, 1.0).acos()
297 };
298
299 let a0 = angle_at(v0, v1, v2);
300 let a1 = angle_at(v1, v2, v0);
301 let a2 = angle_at(v2, v0, v1);
302
303 normals[tri[0]] += face_n * a0;
304 normals[tri[1]] += face_n * a1;
305 normals[tri[2]] += face_n * a2;
306 }
307 normals
308 .iter()
309 .map(|n| {
310 let len = n.norm();
311 if len < 1e-12 {
312 [0.0, 1.0, 0.0]
313 } else {
314 [n.x / len, n.y / len, n.z / len]
315 }
316 })
317 .collect()
318 }
319
320 #[allow(dead_code)]
328 pub fn laplacian_smooth(&mut self, factor: f64, iterations: usize) {
329 for _ in 0..iterations {
330 let nbrs = self.vertex_neighbors();
331 let mut new_pos = self.vertices.clone();
332 for (vi, neighbours) in nbrs.iter().enumerate() {
333 if neighbours.is_empty() {
334 continue;
335 }
336 let mut avg = Vec3::zeros();
337 for &ni in neighbours {
338 avg += self.vertices[ni];
339 }
340 avg /= neighbours.len() as f64;
341 let diff = avg - self.vertices[vi];
342 new_pos[vi] = self.vertices[vi] + diff * factor;
343 }
344 self.vertices = new_pos;
345 }
346 }
347
348 #[allow(dead_code)]
357 pub fn geodesic_distance(&self, source: usize) -> Vec<f64> {
358 let nbrs = self.vertex_neighbors();
359 let n = self.vertices.len();
360 let mut dist = vec![f64::INFINITY; n];
361 dist[source] = 0.0;
362
363 let mut heap = BinaryHeap::new();
365 heap.push(std::cmp::Reverse(OrdF64(0.0, source)));
366
367 while let Some(std::cmp::Reverse(OrdF64(d, u))) = heap.pop() {
368 if d > dist[u] {
369 continue;
370 }
371 for &v in &nbrs[u] {
372 let edge_len = (self.vertices[v] - self.vertices[u]).norm();
373 let new_d = d + edge_len;
374 if new_d < dist[v] {
375 dist[v] = new_d;
376 heap.push(std::cmp::Reverse(OrdF64(new_d, v)));
377 }
378 }
379 }
380 dist
381 }
382
383 #[allow(dead_code)]
393 pub fn loop_subdivide(&mut self) {
394 let n_verts = self.vertices.len();
395
396 let mut edge_count: HashMap<(usize, usize), usize> = HashMap::new();
398 for tri in &self.indices {
399 for k in 0..3 {
400 let a = tri[k];
401 let b = tri[(k + 1) % 3];
402 let key = if a < b { (a, b) } else { (b, a) };
403 *edge_count.entry(key).or_insert(0) += 1;
404 }
405 }
406
407 let mut edge_verts: HashMap<(usize, usize), usize> = HashMap::new();
409 let mut new_vertices = self.vertices.clone();
410
411 let mut edge_opposite: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
413 for tri in &self.indices {
414 for k in 0..3 {
415 let a = tri[k];
416 let b = tri[(k + 1) % 3];
417 let c = tri[(k + 2) % 3]; let key = if a < b { (a, b) } else { (b, a) };
419 edge_opposite.entry(key).or_default().push(c);
420 }
421 }
422
423 let get_or_create_edge_vert = |a: usize,
424 b: usize,
425 edge_verts: &mut HashMap<(usize, usize), usize>,
426 new_vertices: &mut Vec<Vec3>,
427 edge_count: &HashMap<(usize, usize), usize>,
428 edge_opposite: &HashMap<(usize, usize), Vec<usize>>,
429 orig_verts: &[Vec3]|
430 -> usize {
431 let key = if a < b { (a, b) } else { (b, a) };
432 if let Some(&idx) = edge_verts.get(&key) {
433 return idx;
434 }
435 let idx = new_vertices.len();
436 let count = edge_count.get(&key).copied().unwrap_or(1);
437 let pos = if count == 2 {
438 let opposites = edge_opposite.get(&key).expect("key must exist");
440 if opposites.len() == 2 {
441 let c = orig_verts[opposites[0]];
442 let d = orig_verts[opposites[1]];
443 (orig_verts[a] + orig_verts[b]) * (3.0 / 8.0) + (c + d) * (1.0 / 8.0)
444 } else {
445 (orig_verts[a] + orig_verts[b]) * 0.5
446 }
447 } else {
448 (orig_verts[a] + orig_verts[b]) * 0.5
450 };
451 new_vertices.push(pos);
452 edge_verts.insert(key, idx);
453 idx
454 };
455
456 let orig_verts = self.vertices.clone();
457
458 let mut new_indices = Vec::with_capacity(self.indices.len() * 4);
460 for tri in &self.indices {
461 let v0 = tri[0];
462 let v1 = tri[1];
463 let v2 = tri[2];
464 let m01 = get_or_create_edge_vert(
465 v0,
466 v1,
467 &mut edge_verts,
468 &mut new_vertices,
469 &edge_count,
470 &edge_opposite,
471 &orig_verts,
472 );
473 let m12 = get_or_create_edge_vert(
474 v1,
475 v2,
476 &mut edge_verts,
477 &mut new_vertices,
478 &edge_count,
479 &edge_opposite,
480 &orig_verts,
481 );
482 let m20 = get_or_create_edge_vert(
483 v2,
484 v0,
485 &mut edge_verts,
486 &mut new_vertices,
487 &edge_count,
488 &edge_opposite,
489 &orig_verts,
490 );
491 new_indices.push([v0, m01, m20]);
493 new_indices.push([v1, m12, m01]);
494 new_indices.push([v2, m20, m12]);
495 new_indices.push([m01, m12, m20]);
496 }
497
498 let nbrs = self.vertex_neighbors();
500 let mut is_boundary = vec![false; n_verts];
502 for (&(a, b), &cnt) in &edge_count {
503 if cnt == 1 {
504 is_boundary[a] = true;
505 is_boundary[b] = true;
506 }
507 }
508 for vi in 0..n_verts {
509 if is_boundary[vi] {
510 continue;
513 }
514 let n = nbrs[vi].len();
515 if n < 3 {
516 continue;
517 }
518 let beta = if n == 3 {
519 3.0 / 16.0
520 } else {
521 3.0 / (8.0 * n as f64)
522 };
523 let mut neighbour_sum = Vec3::zeros();
524 for &ni in &nbrs[vi] {
525 neighbour_sum += orig_verts[ni];
526 }
527 new_vertices[vi] = orig_verts[vi] * (1.0 - n as f64 * beta) + neighbour_sum * beta;
528 }
529
530 self.vertices = new_vertices;
531 self.indices = new_indices;
532 }
533
534 #[allow(dead_code)]
540 pub fn boundary_edges(&self) -> Vec<(usize, usize)> {
541 let mut edge_count: HashMap<(usize, usize), usize> = HashMap::new();
542 for tri in &self.indices {
543 for k in 0..3 {
544 let a = tri[k];
545 let b = tri[(k + 1) % 3];
546 let key = if a < b { (a, b) } else { (b, a) };
547 *edge_count.entry(key).or_insert(0) += 1;
548 }
549 }
550 edge_count
551 .into_iter()
552 .filter(|&(_, c)| c == 1)
553 .map(|(e, _)| e)
554 .collect()
555 }
556
557 #[allow(dead_code)]
559 pub fn euler_characteristic(&self) -> i64 {
560 let v = self.vertices.len() as i64;
561 let e = self.unique_edges().len() as i64;
562 let f = self.indices.len() as i64;
563 v - e + f
564 }
565
566 #[allow(dead_code)]
568 pub fn non_manifold_edge_count(&self) -> usize {
569 let mut edge_count: HashMap<(usize, usize), usize> = HashMap::new();
570 for tri in &self.indices {
571 for k in 0..3 {
572 let a = tri[k];
573 let b = tri[(k + 1) % 3];
574 let key = if a < b { (a, b) } else { (b, a) };
575 *edge_count.entry(key).or_insert(0) += 1;
576 }
577 }
578 edge_count.values().filter(|&&c| c > 2).count()
579 }
580
581 #[allow(dead_code)]
594 pub fn compute_laplacian_matrix(&self) -> HashMap<(usize, usize), f64> {
595 let mut weights: HashMap<(usize, usize), f64> = HashMap::new();
596
597 for tri in &self.indices {
598 let [i0, i1, i2] = *tri;
599 let p0 = self.vertices[i0];
600 let p1 = self.vertices[i1];
601 let p2 = self.vertices[i2];
602
603 let cot_at = |a: Vec3, b: Vec3, c: Vec3| -> f64 {
606 let ab = b - a;
608 let ac = c - a;
609 let cos_t = ab.dot(&ac);
610 let sin_t = ab.cross(&ac).norm();
611 if sin_t.abs() < 1e-12 {
612 0.0
613 } else {
614 cos_t / sin_t
615 }
616 };
617
618 let cot0 = cot_at(p0, p1, p2); let cot1 = cot_at(p1, p0, p2); let cot2 = cot_at(p2, p0, p1); *weights.entry((i1, i2)).or_insert(0.0) += 0.5 * cot0;
624 *weights.entry((i2, i1)).or_insert(0.0) += 0.5 * cot0;
625
626 *weights.entry((i0, i2)).or_insert(0.0) += 0.5 * cot1;
628 *weights.entry((i2, i0)).or_insert(0.0) += 0.5 * cot1;
629
630 *weights.entry((i0, i1)).or_insert(0.0) += 0.5 * cot2;
632 *weights.entry((i1, i0)).or_insert(0.0) += 0.5 * cot2;
633 }
634
635 weights
636 }
637
638 #[allow(dead_code)]
657 pub fn compute_heat_kernel_signature(&self, t_values: &[f64]) -> Vec<Vec<f64>> {
658 let n = self.vertices.len();
659 if n == 0 || t_values.is_empty() {
660 return vec![vec![0.0; t_values.len()]; n];
661 }
662
663 let weights = self.compute_laplacian_matrix();
665
666 let mut vertex_area = vec![0.0f64; n];
668 for tri in &self.indices {
669 let [i0, i1, i2] = *tri;
670 let a = self.vertices[i0];
671 let b = self.vertices[i1];
672 let c = self.vertices[i2];
673 let area = (b - a).cross(&(c - a)).norm() * 0.5;
674 let a_third = area / 3.0;
675 vertex_area[i0] += a_third;
676 vertex_area[i1] += a_third;
677 vertex_area[i2] += a_third;
678 }
679
680 let mut diag = vec![0.0f64; n];
682 for (&(i, _j), &w) in &weights {
683 diag[i] += w;
684 }
685
686 let lambda: Vec<f64> = (0..n)
688 .map(|i| {
689 if vertex_area[i] > 1e-15 {
690 diag[i] / vertex_area[i]
691 } else {
692 0.0
693 }
694 })
695 .collect();
696
697 (0..n)
699 .map(|i| t_values.iter().map(|&t| (-t * lambda[i]).exp()).collect())
700 .collect()
701 }
702
703 #[allow(dead_code)]
719 pub fn smooth_laplacian(&mut self, factor: f64, iterations: usize) {
720 if self.vertices.is_empty() || self.indices.is_empty() {
721 return;
722 }
723
724 let mut edge_count: HashMap<(usize, usize), usize> = HashMap::new();
726 for tri in &self.indices {
727 for k in 0..3 {
728 let a = tri[k];
729 let b = tri[(k + 1) % 3];
730 let key = if a < b { (a, b) } else { (b, a) };
731 *edge_count.entry(key).or_insert(0) += 1;
732 }
733 }
734 let mut is_boundary = vec![false; self.vertices.len()];
735 for (&(a, b), &c) in &edge_count {
736 if c == 1 {
737 is_boundary[a] = true;
738 is_boundary[b] = true;
739 }
740 }
741
742 for _ in 0..iterations {
743 let weights = self.compute_laplacian_matrix();
744 let mut new_vertices = self.vertices.clone();
745
746 for i in 0..self.vertices.len() {
747 if is_boundary[i] {
748 continue;
749 }
750 let mut weight_sum = 0.0f64;
751 let mut delta = Vec3::zeros();
752
753 for tri in &self.indices {
754 for k in 0..3 {
755 if tri[k] == i {
756 let j = tri[(k + 1) % 3];
757 let w = *weights.get(&(i, j)).unwrap_or(&0.0);
758 delta += (self.vertices[j] - self.vertices[i]) * w;
759 weight_sum += w;
760 }
761 }
762 }
763
764 if weight_sum > 1e-15 {
765 new_vertices[i] += delta * (factor / weight_sum);
766 }
767 }
768
769 self.vertices = new_vertices;
770 }
771 }
772}
773
774#[derive(Clone, Copy, PartialEq)]
779struct OrdF64(f64, usize);
780
781impl Eq for OrdF64 {}
782
783impl PartialOrd for OrdF64 {
784 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
785 Some(self.cmp(other))
786 }
787}
788
789impl Ord for OrdF64 {
790 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
791 self.0
792 .partial_cmp(&other.0)
793 .unwrap_or(std::cmp::Ordering::Equal)
794 }
795}
796
797impl Shape for TriangleMesh {
802 fn bounding_box(&self) -> Aabb {
803 if self.vertices.is_empty() {
804 return Aabb::new(Vec3::zeros(), Vec3::zeros());
805 }
806 let mut min = self.vertices[0];
807 let mut max = self.vertices[0];
808 for v in &self.vertices[1..] {
809 min = min.inf(v);
810 max = max.sup(v);
811 }
812 Aabb::new(min, max)
813 }
814
815 fn support_point(&self, direction: &Vec3) -> Vec3 {
816 self.vertices
817 .iter()
818 .max_by(|a, b| {
819 a.dot(direction)
820 .partial_cmp(&b.dot(direction))
821 .unwrap_or(std::cmp::Ordering::Equal)
822 })
823 .copied()
824 .unwrap_or_else(Vec3::zeros)
825 }
826
827 fn volume(&self) -> Real {
828 let mut vol = 0.0;
829 for tri in &self.indices {
830 let a = self.vertices[tri[0]];
831 let b = self.vertices[tri[1]];
832 let c = self.vertices[tri[2]];
833 vol += a.dot(&b.cross(&c)) / 6.0;
834 }
835 vol.abs()
836 }
837
838 fn center_of_mass(&self) -> Vec3 {
839 if self.indices.is_empty() {
840 return Vec3::zeros();
841 }
842 let mut weighted = Vec3::zeros();
843 let mut total_vol = 0.0;
844 for tri in &self.indices {
845 let a = self.vertices[tri[0]];
846 let b = self.vertices[tri[1]];
847 let c = self.vertices[tri[2]];
848 let tet_vol = a.dot(&b.cross(&c)) / 6.0;
849 let tet_center = (a + b + c) * 0.25;
850 weighted += tet_center * tet_vol;
851 total_vol += tet_vol;
852 }
853 if total_vol.abs() > 1e-12 {
854 weighted / total_vol
855 } else {
856 Vec3::zeros()
857 }
858 }
859
860 fn inertia_tensor(&self, mass: Real) -> Mat3 {
861 let bb = self.bounding_box();
862 let size = bb.max - bb.min;
863 let k = mass / 12.0;
864 Mat3::new(
865 k * (size.y * size.y + size.z * size.z),
866 0.0,
867 0.0,
868 0.0,
869 k * (size.x * size.x + size.z * size.z),
870 0.0,
871 0.0,
872 0.0,
873 k * (size.x * size.x + size.y * size.y),
874 )
875 }
876
877 fn ray_cast(&self, ray_origin: &Vec3, ray_direction: &Vec3, max_toi: Real) -> Option<RayHit> {
878 let mut best: Option<RayHit> = None;
879 for tri in &self.indices {
880 let v0 = self.vertices[tri[0]];
881 let v1 = self.vertices[tri[1]];
882 let v2 = self.vertices[tri[2]];
883 if let Some(hit) = ray_triangle(ray_origin, ray_direction, &v0, &v1, &v2)
884 && hit.toi <= max_toi
885 && hit.toi >= 0.0
886 && best.as_ref().is_none_or(|b| hit.toi < b.toi)
887 {
888 best = Some(hit);
889 }
890 }
891 best
892 }
893}
894
895fn ray_triangle(
897 origin: &Vec3,
898 direction: &Vec3,
899 v0: &Vec3,
900 v1: &Vec3,
901 v2: &Vec3,
902) -> Option<RayHit> {
903 let edge1 = v1 - v0;
904 let edge2 = v2 - v0;
905 let h = direction.cross(&edge2);
906 let a = edge1.dot(&h);
907
908 if a.abs() < 1e-10 {
909 return None;
910 }
911
912 let f = 1.0 / a;
913 let s = origin - v0;
914 let u = f * s.dot(&h);
915 if !(0.0..=1.0).contains(&u) {
916 return None;
917 }
918
919 let q = s.cross(&edge1);
920 let v = f * direction.dot(&q);
921 if v < 0.0 || u + v > 1.0 {
922 return None;
923 }
924
925 let t = f * edge2.dot(&q);
926 if t < 0.0 {
927 return None;
928 }
929
930 let point = origin + direction * t;
931 let normal = edge1.cross(&edge2).normalize();
932 Some(RayHit {
933 point,
934 normal,
935 toi: t,
936 })
937}
938
939#[cfg(test)]
941fn unit_cube_mesh() -> TriangleMesh {
942 let v = vec![
943 Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 0.0), Vec3::new(0.0, 1.0, 0.0), Vec3::new(0.0, 0.0, 1.0), Vec3::new(1.0, 0.0, 1.0), Vec3::new(1.0, 1.0, 1.0), Vec3::new(0.0, 1.0, 1.0), ];
952 let idx = vec![
953 [0, 2, 1],
955 [0, 3, 2],
956 [4, 5, 6],
958 [4, 6, 7],
959 [0, 4, 7],
961 [0, 7, 3],
962 [1, 2, 6],
964 [1, 6, 5],
965 [0, 1, 5],
967 [0, 5, 4],
968 [3, 7, 6],
970 [3, 6, 2],
971 ];
972 TriangleMesh::new(v, idx)
973}
974
975#[cfg(test)]
977fn tetrahedron_mesh() -> TriangleMesh {
978 let v = vec![
979 Vec3::new(1.0, 1.0, 1.0),
980 Vec3::new(-1.0, -1.0, 1.0),
981 Vec3::new(-1.0, 1.0, -1.0),
982 Vec3::new(1.0, -1.0, -1.0),
983 ];
984 let idx = vec![[0, 1, 2], [0, 3, 1], [0, 2, 3], [1, 3, 2]];
985 TriangleMesh::new(v, idx)
986}
987
988#[cfg(test)]
989mod tests {
990 use super::*;
991 use crate::TriangleMesh;
992 use oxiphysics_core::math::Vec3;
993
994 #[test]
995 fn test_triangle_mesh_raycast() {
996 let mesh = TriangleMesh::new(
997 vec![
998 Vec3::new(-1.0, 0.0, -1.0),
999 Vec3::new(1.0, 0.0, -1.0),
1000 Vec3::new(0.0, 0.0, 1.0),
1001 ],
1002 vec![[0, 1, 2]],
1003 );
1004 let origin = Vec3::new(0.0, 5.0, 0.0);
1005 let dir = Vec3::new(0.0, -1.0, 0.0);
1006 let hit = mesh.ray_cast(&origin, &dir, 100.0).unwrap();
1007 assert!((hit.toi - 5.0).abs() < 1e-10);
1008 }
1009
1010 #[test]
1011 fn test_triangle_mesh_raycast_miss() {
1012 let mesh = TriangleMesh::new(
1013 vec![
1014 Vec3::new(-1.0, 0.0, -1.0),
1015 Vec3::new(1.0, 0.0, -1.0),
1016 Vec3::new(0.0, 0.0, 1.0),
1017 ],
1018 vec![[0, 1, 2]],
1019 );
1020 let origin = Vec3::new(10.0, 5.0, 0.0);
1021 let dir = Vec3::new(0.0, -1.0, 0.0);
1022 assert!(mesh.ray_cast(&origin, &dir, 100.0).is_none());
1023 }
1024
1025 #[test]
1026 fn test_triangle_mesh_cube_volume() {
1027 let mesh = unit_cube_mesh();
1028 let vol = mesh.volume();
1029 assert!((vol - 1.0).abs() < 1e-6, "expected volume=1, got {}", vol);
1030 }
1031
1032 #[test]
1033 fn test_triangle_mesh_cube_surface_area() {
1034 let mesh = unit_cube_mesh();
1035 let sa = mesh.surface_area();
1036 assert!((sa - 6.0).abs() < 1e-6, "expected SA=6, got {}", sa);
1037 }
1038
1039 #[test]
1040 fn test_triangle_mesh_cube_is_watertight() {
1041 let mesh = unit_cube_mesh();
1042 assert!(mesh.is_watertight(), "unit cube should be watertight");
1043 }
1044
1045 #[test]
1046 fn test_triangle_mesh_open_not_watertight() {
1047 let mesh = TriangleMesh::new(
1048 vec![
1049 Vec3::new(0.0, 0.0, 0.0),
1050 Vec3::new(1.0, 0.0, 0.0),
1051 Vec3::new(0.0, 1.0, 0.0),
1052 ],
1053 vec![[0, 1, 2]],
1054 );
1055 assert!(!mesh.is_watertight(), "open mesh should not be watertight");
1056 }
1057
1058 #[test]
1059 fn test_triangle_mesh_compute_normals() {
1060 let mesh = TriangleMesh::new(
1061 vec![
1062 Vec3::new(0.0, 0.0, 0.0),
1063 Vec3::new(1.0, 0.0, 0.0),
1064 Vec3::new(0.0, 1.0, 0.0),
1065 ],
1066 vec![[0, 1, 2]],
1067 );
1068 let normals = mesh.compute_normals();
1069 assert_eq!(normals.len(), 1);
1070 assert!((normals[0][2] - 1.0).abs() < 1e-10, "expected +Z normal");
1071 }
1072
1073 #[test]
1074 fn test_triangle_mesh_ray_cast_full() {
1075 let mesh = unit_cube_mesh();
1076 let (t, _face, n) = mesh
1077 .ray_cast_full([0.5, 5.0, 0.5], [0.0, -1.0, 0.0], 100.0)
1078 .expect("should hit cube");
1079 assert!((t - 4.0).abs() < 1e-6, "expected t=4, got {}", t);
1080 assert!(n[1].abs() > 0.9, "normal should be roughly Y");
1081 }
1082
1083 #[test]
1084 fn test_triangle_mesh_surface_area_triangle() {
1085 let mesh = TriangleMesh::new(
1086 vec![
1087 Vec3::new(0.0, 0.0, 0.0),
1088 Vec3::new(3.0, 0.0, 0.0),
1089 Vec3::new(0.0, 4.0, 0.0),
1090 ],
1091 vec![[0, 1, 2]],
1092 );
1093 let sa = mesh.surface_area();
1094 assert!((sa - 6.0).abs() < 1e-10, "expected SA=6, got {}", sa);
1095 }
1096
1097 #[test]
1102 fn test_vertex_to_faces() {
1103 let mesh = unit_cube_mesh();
1104 let v2f = mesh.vertex_to_faces();
1105 assert_eq!(v2f.len(), 8);
1106 for faces in &v2f {
1110 assert!(
1111 !faces.is_empty(),
1112 "every vertex should have at least one face"
1113 );
1114 }
1115 assert_eq!(
1118 v2f[0].len(),
1119 6,
1120 "vertex 0 in unit cube should appear in 6 triangles"
1121 );
1122 }
1123
1124 #[test]
1125 fn test_face_adjacency_single_triangle() {
1126 let mesh = TriangleMesh::new(
1127 vec![
1128 Vec3::new(0.0, 0.0, 0.0),
1129 Vec3::new(1.0, 0.0, 0.0),
1130 Vec3::new(0.0, 1.0, 0.0),
1131 ],
1132 vec![[0, 1, 2]],
1133 );
1134 let adj = mesh.face_adjacency();
1135 assert_eq!(adj.len(), 1);
1136 assert!(adj[0].is_empty(), "single triangle has no adjacent faces");
1137 }
1138
1139 #[test]
1140 fn test_face_adjacency_cube() {
1141 let mesh = unit_cube_mesh();
1142 let adj = mesh.face_adjacency();
1143 assert_eq!(adj.len(), 12);
1144 for neighbors in &adj {
1146 assert!(
1147 !neighbors.is_empty(),
1148 "each cube triangle should have neighbors"
1149 );
1150 }
1151 }
1152
1153 #[test]
1154 fn test_unique_edges_single_triangle() {
1155 let mesh = TriangleMesh::new(
1156 vec![
1157 Vec3::new(0.0, 0.0, 0.0),
1158 Vec3::new(1.0, 0.0, 0.0),
1159 Vec3::new(0.0, 1.0, 0.0),
1160 ],
1161 vec![[0, 1, 2]],
1162 );
1163 let edges = mesh.unique_edges();
1164 assert_eq!(edges.len(), 3);
1165 }
1166
1167 #[test]
1168 fn test_unique_edges_cube() {
1169 let mesh = unit_cube_mesh();
1170 let edges = mesh.unique_edges();
1171 assert_eq!(edges.len(), 18);
1173 }
1174
1175 #[test]
1176 fn test_vertex_neighbors() {
1177 let mesh = TriangleMesh::new(
1178 vec![
1179 Vec3::new(0.0, 0.0, 0.0),
1180 Vec3::new(1.0, 0.0, 0.0),
1181 Vec3::new(0.5, 1.0, 0.0),
1182 ],
1183 vec![[0, 1, 2]],
1184 );
1185 let nbrs = mesh.vertex_neighbors();
1186 assert_eq!(nbrs.len(), 3);
1187 assert_eq!(nbrs[0].len(), 2); assert_eq!(nbrs[1].len(), 2);
1189 assert_eq!(nbrs[2].len(), 2);
1190 }
1191
1192 #[test]
1197 fn test_edge_collapse_basic() {
1198 let mut mesh = TriangleMesh::new(
1200 vec![
1201 Vec3::new(0.0, 0.0, 0.0),
1202 Vec3::new(1.0, 0.0, 0.0),
1203 Vec3::new(0.5, 1.0, 0.0),
1204 Vec3::new(0.5, -1.0, 0.0),
1205 ],
1206 vec![[0, 1, 2], [0, 3, 1]],
1207 );
1208 let ok = mesh.edge_collapse(0, 1);
1209 assert!(ok, "edge collapse should succeed");
1210 assert!((mesh.vertices[0].x - 0.5).abs() < 1e-10);
1212 assert!(mesh.indices.is_empty(), "both triangles should degenerate");
1217 }
1218
1219 #[test]
1220 fn test_edge_collapse_nonexistent() {
1221 let mut mesh = TriangleMesh::new(
1222 vec![
1223 Vec3::new(0.0, 0.0, 0.0),
1224 Vec3::new(1.0, 0.0, 0.0),
1225 Vec3::new(0.5, 1.0, 0.0),
1226 ],
1227 vec![[0, 1, 2]],
1228 );
1229 assert!(!mesh.edge_collapse(0, 100));
1231 }
1232
1233 #[test]
1234 fn test_edge_collapse_preserves_other_faces() {
1235 let mut mesh = TriangleMesh::new(
1237 vec![
1238 Vec3::new(0.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 0.0), Vec3::new(1.0, -1.0, 0.0), Vec3::new(3.0, 1.0, 0.0), ],
1244 vec![[0, 1, 2], [0, 3, 1], [1, 4, 2]],
1245 );
1246 let ok = mesh.edge_collapse(0, 1);
1247 assert!(ok);
1248 assert_eq!(
1251 mesh.indices.len(),
1252 1,
1253 "two degenerate tris removed, one survives"
1254 );
1255 }
1256
1257 #[test]
1262 fn test_vertex_normals_flat_surface() {
1263 let mesh = TriangleMesh::new(
1264 vec![
1265 Vec3::new(0.0, 0.0, 0.0),
1266 Vec3::new(1.0, 0.0, 0.0),
1267 Vec3::new(1.0, 1.0, 0.0),
1268 Vec3::new(0.0, 1.0, 0.0),
1269 ],
1270 vec![[0, 1, 2], [0, 2, 3]],
1271 );
1272 let normals = mesh.compute_vertex_normals();
1273 assert_eq!(normals.len(), 4);
1274 for n in &normals {
1275 assert!(
1276 (n[2] - 1.0).abs() < 1e-6,
1277 "flat XY surface normal should be +Z"
1278 );
1279 }
1280 }
1281
1282 #[test]
1283 fn test_vertex_normals_unit_length() {
1284 let mesh = unit_cube_mesh();
1285 let normals = mesh.compute_vertex_normals();
1286 for n in &normals {
1287 let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
1288 assert!(
1289 (len - 1.0).abs() < 1e-6,
1290 "vertex normal should be unit length, got {}",
1291 len
1292 );
1293 }
1294 }
1295
1296 #[test]
1301 fn test_laplacian_smooth_doesnt_crash() {
1302 let mut mesh = unit_cube_mesh();
1303 mesh.laplacian_smooth(0.5, 3);
1304 assert_eq!(mesh.vertices.len(), 8);
1306 assert_eq!(mesh.indices.len(), 12);
1307 }
1308
1309 #[test]
1310 fn test_laplacian_smooth_converges_to_center() {
1311 let mut mesh = TriangleMesh::new(
1313 vec![
1314 Vec3::new(0.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 0.0), Vec3::new(1.0, 2.0, 0.0), Vec3::new(1.0, 0.5, 0.0), ],
1319 vec![[0, 1, 3], [1, 2, 3], [2, 0, 3]],
1320 );
1321 let orig_v3 = mesh.vertices[3];
1322 mesh.laplacian_smooth(1.0, 1);
1323 let avg =
1325 (Vec3::new(0.0, 0.0, 0.0) + Vec3::new(2.0, 0.0, 0.0) + Vec3::new(1.0, 2.0, 0.0)) / 3.0;
1326 let new_v3 = mesh.vertices[3];
1327 let d_new = (new_v3 - avg).norm();
1328 let d_old = (orig_v3 - avg).norm();
1329 assert!(
1330 d_new < d_old + 1e-10,
1331 "vertex should move towards neighbor average"
1332 );
1333 }
1334
1335 #[test]
1340 fn test_geodesic_distance_source() {
1341 let mesh = unit_cube_mesh();
1342 let dist = mesh.geodesic_distance(0);
1343 assert_eq!(dist[0], 0.0, "distance to self should be 0");
1344 }
1345
1346 #[test]
1347 fn test_geodesic_distance_adjacent() {
1348 let mesh = TriangleMesh::new(
1349 vec![
1350 Vec3::new(0.0, 0.0, 0.0),
1351 Vec3::new(1.0, 0.0, 0.0),
1352 Vec3::new(0.5, 1.0, 0.0),
1353 ],
1354 vec![[0, 1, 2]],
1355 );
1356 let dist = mesh.geodesic_distance(0);
1357 assert!(
1359 (dist[1] - 1.0).abs() < 1e-10,
1360 "expected dist=1, got {}",
1361 dist[1]
1362 );
1363 }
1364
1365 #[test]
1366 fn test_geodesic_distance_symmetry() {
1367 let mesh = unit_cube_mesh();
1368 let d0 = mesh.geodesic_distance(0);
1369 let d6 = mesh.geodesic_distance(6);
1370 assert!(
1372 (d0[6] - d6[0]).abs() < 1e-10,
1373 "geodesic distance should be symmetric: {} vs {}",
1374 d0[6],
1375 d6[0]
1376 );
1377 }
1378
1379 #[test]
1384 fn test_loop_subdivide_increases_faces() {
1385 let mut mesh = TriangleMesh::new(
1386 vec![
1387 Vec3::new(0.0, 0.0, 0.0),
1388 Vec3::new(1.0, 0.0, 0.0),
1389 Vec3::new(0.5, 1.0, 0.0),
1390 ],
1391 vec![[0, 1, 2]],
1392 );
1393 mesh.loop_subdivide();
1394 assert_eq!(mesh.indices.len(), 4, "one triangle should become four");
1395 }
1396
1397 #[test]
1398 fn test_loop_subdivide_cube() {
1399 let mut mesh = unit_cube_mesh();
1400 assert_eq!(mesh.indices.len(), 12);
1401 mesh.loop_subdivide();
1402 assert_eq!(mesh.indices.len(), 48, "12 tris * 4 = 48");
1403 assert!(
1405 mesh.vertices.len() > 8,
1406 "should have more vertices after subdivision"
1407 );
1408 }
1409
1410 #[test]
1411 fn test_loop_subdivide_watertight_preserved() {
1412 let mut mesh = tetrahedron_mesh();
1413 assert!(mesh.is_watertight(), "tetrahedron should be watertight");
1414 mesh.loop_subdivide();
1415 assert!(
1416 mesh.is_watertight(),
1417 "subdivided tetrahedron should still be watertight"
1418 );
1419 }
1420
1421 #[test]
1426 fn test_boundary_edges_closed_mesh() {
1427 let mesh = unit_cube_mesh();
1428 let be = mesh.boundary_edges();
1429 assert!(
1430 be.is_empty(),
1431 "watertight mesh should have no boundary edges"
1432 );
1433 }
1434
1435 #[test]
1436 fn test_boundary_edges_open_mesh() {
1437 let mesh = TriangleMesh::new(
1438 vec![
1439 Vec3::new(0.0, 0.0, 0.0),
1440 Vec3::new(1.0, 0.0, 0.0),
1441 Vec3::new(0.5, 1.0, 0.0),
1442 ],
1443 vec![[0, 1, 2]],
1444 );
1445 let be = mesh.boundary_edges();
1446 assert_eq!(be.len(), 3, "single triangle has 3 boundary edges");
1447 }
1448
1449 #[test]
1450 fn test_euler_characteristic_cube() {
1451 let mesh = unit_cube_mesh();
1452 let chi = mesh.euler_characteristic();
1453 assert_eq!(chi, 2, "closed cube Euler characteristic should be 2");
1454 }
1455
1456 #[test]
1457 fn test_euler_characteristic_tetrahedron() {
1458 let mesh = tetrahedron_mesh();
1459 let chi = mesh.euler_characteristic();
1460 assert_eq!(
1461 chi, 2,
1462 "closed tetrahedron Euler characteristic should be 2"
1463 );
1464 }
1465
1466 #[test]
1467 fn test_non_manifold_edge_count_clean_mesh() {
1468 let mesh = unit_cube_mesh();
1469 assert_eq!(mesh.non_manifold_edge_count(), 0);
1470 }
1471
1472 #[test]
1473 fn test_non_manifold_edge_count_with_extra_face() {
1474 let mesh = TriangleMesh::new(
1476 vec![
1477 Vec3::new(0.0, 0.0, 0.0),
1478 Vec3::new(1.0, 0.0, 0.0),
1479 Vec3::new(0.5, 1.0, 0.0),
1480 Vec3::new(0.5, -1.0, 0.0),
1481 Vec3::new(0.5, 0.5, 1.0),
1482 ],
1483 vec![[0, 1, 2], [0, 3, 1], [0, 4, 1]], );
1485 assert_eq!(mesh.non_manifold_edge_count(), 1);
1486 }
1487}
1488
1489#[cfg(test)]
1494mod tests_laplacian_hks {
1495
1496 use crate::TriangleMesh;
1497 use oxiphysics_core::math::Vec3;
1498
1499 fn flat_quad() -> TriangleMesh {
1501 TriangleMesh::new(
1502 vec![
1503 Vec3::new(0.0, 0.0, 0.0),
1504 Vec3::new(1.0, 0.0, 0.0),
1505 Vec3::new(1.0, 1.0, 0.0),
1506 Vec3::new(0.0, 1.0, 0.0),
1507 ],
1508 vec![[0, 1, 2], [0, 2, 3]],
1509 )
1510 }
1511
1512 fn single_triangle() -> TriangleMesh {
1514 TriangleMesh::new(
1515 vec![
1516 Vec3::new(0.0, 0.0, 0.0),
1517 Vec3::new(1.0, 0.0, 0.0),
1518 Vec3::new(0.5, 1.0, 0.0),
1519 ],
1520 vec![[0, 1, 2]],
1521 )
1522 }
1523
1524 #[test]
1527 fn test_laplacian_matrix_nonempty() {
1528 let mesh = flat_quad();
1529 let w = mesh.compute_laplacian_matrix();
1530 assert!(!w.is_empty(), "Laplacian weights should be non-empty");
1531 }
1532
1533 #[test]
1534 fn test_laplacian_matrix_symmetric() {
1535 let mesh = flat_quad();
1536 let w = mesh.compute_laplacian_matrix();
1537 for (&(i, j), &wij) in &w {
1539 let wji = w.get(&(j, i)).copied().unwrap_or(0.0);
1540 assert!(
1541 (wij - wji).abs() < 1e-10,
1542 "Laplacian not symmetric at ({i},{j}): {wij} vs {wji}"
1543 );
1544 }
1545 }
1546
1547 #[test]
1548 fn test_laplacian_matrix_single_triangle_all_edges_present() {
1549 let mesh = single_triangle();
1550 let w = mesh.compute_laplacian_matrix();
1551 assert_eq!(
1553 w.len(),
1554 6,
1555 "single triangle should produce 6 directed entries"
1556 );
1557 }
1558
1559 #[test]
1560 fn test_laplacian_matrix_empty_mesh() {
1561 let mesh = TriangleMesh::new(vec![], vec![]);
1562 let w = mesh.compute_laplacian_matrix();
1563 assert!(w.is_empty());
1564 }
1565
1566 #[test]
1569 fn test_hks_output_shape() {
1570 let mesh = flat_quad();
1571 let t_vals = vec![0.1, 1.0, 10.0];
1572 let hks = mesh.compute_heat_kernel_signature(&t_vals);
1573 assert_eq!(hks.len(), mesh.vertices.len(), "one HKS row per vertex");
1574 for row in &hks {
1575 assert_eq!(row.len(), t_vals.len(), "one entry per time scale");
1576 }
1577 }
1578
1579 #[test]
1580 fn test_hks_values_in_range() {
1581 let mesh = flat_quad();
1583 let t_vals = vec![0.01, 0.1, 1.0, 10.0];
1584 let hks = mesh.compute_heat_kernel_signature(&t_vals);
1585 for row in &hks {
1586 for &val in row {
1587 assert!(
1588 val > 0.0 && val <= 1.0 + 1e-12,
1589 "HKS value out of range: {val}"
1590 );
1591 }
1592 }
1593 }
1594
1595 #[test]
1596 fn test_hks_decreasing_with_time() {
1597 let mesh = flat_quad();
1599 let t_vals = vec![0.1, 1.0, 10.0];
1600 let hks = mesh.compute_heat_kernel_signature(&t_vals);
1601 for (vi, row) in hks.iter().enumerate() {
1602 for k in 0..(row.len() - 1) {
1603 assert!(
1604 row[k] >= row[k + 1] - 1e-12,
1605 "HKS not decreasing for vertex {vi}: t={}, val={}; t={}, val={}",
1606 t_vals[k],
1607 row[k],
1608 t_vals[k + 1],
1609 row[k + 1]
1610 );
1611 }
1612 }
1613 }
1614
1615 #[test]
1616 fn test_hks_empty_mesh() {
1617 let mesh = TriangleMesh::new(vec![], vec![]);
1618 let hks = mesh.compute_heat_kernel_signature(&[1.0, 2.0]);
1619 assert!(hks.is_empty());
1620 }
1621
1622 #[test]
1625 fn test_smooth_laplacian_vertex_count_unchanged() {
1626 let mut mesh = flat_quad();
1627 let n_before = mesh.vertices.len();
1628 mesh.smooth_laplacian(0.5, 3);
1629 assert_eq!(
1630 mesh.vertices.len(),
1631 n_before,
1632 "smoothing must not add/remove vertices"
1633 );
1634 }
1635
1636 #[test]
1637 fn test_smooth_laplacian_triangle_count_unchanged() {
1638 let mut mesh = flat_quad();
1639 let n_before = mesh.indices.len();
1640 mesh.smooth_laplacian(0.5, 3);
1641 assert_eq!(
1642 mesh.indices.len(),
1643 n_before,
1644 "smoothing must not change connectivity"
1645 );
1646 }
1647
1648 #[test]
1649 fn test_smooth_laplacian_zero_iterations_no_change() {
1650 let mut mesh = flat_quad();
1651 let verts_before: Vec<Vec3> = mesh.vertices.clone();
1652 mesh.smooth_laplacian(0.5, 0);
1653 for (a, b) in verts_before.iter().zip(mesh.vertices.iter()) {
1654 assert!(
1655 (a - b).norm() < 1e-12,
1656 "zero iterations should not move vertices"
1657 );
1658 }
1659 }
1660
1661 #[test]
1662 fn test_smooth_laplacian_boundary_fixed() {
1663 let mut mesh = single_triangle();
1665 let verts_before: Vec<Vec3> = mesh.vertices.clone();
1666 mesh.smooth_laplacian(0.5, 5);
1667 for (a, b) in verts_before.iter().zip(mesh.vertices.iter()) {
1668 assert!(
1669 (a - b).norm() < 1e-12,
1670 "boundary vertices must not move, delta={}",
1671 (a - b).norm()
1672 );
1673 }
1674 }
1675}