1use std::collections::HashMap;
27
28use anyhow::{bail, Result};
29use serde::{Deserialize, Serialize};
30
31use crate::mesh::Mesh;
32
33pub const VERSION: i16 = 4;
41pub const D2_BINS: usize = 32;
43pub const D2_PAIRS: usize = 32_768;
45pub const AXIS_SAMPLE: usize = 8_192;
47pub const CLOSED_CHECK_LIMIT: usize = 2_000_000;
50const MAX_ANGLES: usize = 720;
52const WELD: f64 = 1e-6;
55const MAX_COORD: f64 = 1e15;
57const SEED: u64 = 0x7EA3_C4AF_D25E_0001;
59
60#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
62pub struct Signature {
63 pub version: i16,
64 pub extents: [f64; 3],
66 pub area: f64,
68 pub volume: f64,
70 pub closed: bool,
72 pub triangles: u64,
73 pub d2: Vec<f32>,
75}
76
77#[derive(Clone, Debug, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum Outcome {
81 Signature(Signature),
82 Error(String),
83}
84
85impl Signature {
86 pub fn proportions(&self) -> [f64; 2] {
88 let e = self.extents;
89 if e[0] > 0.0 {
90 [e[1] / e[0], e[2] / e[0]]
91 } else {
92 [0.0, 0.0]
93 }
94 }
95
96 pub fn sphericity(&self) -> Option<f64> {
98 (self.closed && self.volume > 0.0 && self.area > 0.0).then(|| {
99 (std::f64::consts::PI.cbrt() * (6.0 * self.volume).powf(2.0 / 3.0) / self.area).min(1.0)
100 })
101 }
102
103 pub fn is_valid(&self) -> bool {
105 self.version == VERSION
106 && self.extents.iter().all(|e| e.is_finite() && *e >= 0.0)
107 && self.extents[0] > 0.0
108 && self.area.is_finite()
109 && self.volume.is_finite()
110 && self.d2.len() == D2_BINS
111 && self.d2.iter().all(|v| v.is_finite() && *v >= 0.0)
112 }
113}
114
115type V3 = [f64; 3];
116
117fn sub(a: V3, b: V3) -> V3 {
118 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
119}
120fn dot(a: V3, b: V3) -> f64 {
121 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
122}
123fn cross(a: V3, b: V3) -> V3 {
124 [
125 a[1] * b[2] - a[2] * b[1],
126 a[2] * b[0] - a[0] * b[2],
127 a[0] * b[1] - a[1] * b[0],
128 ]
129}
130fn norm(a: V3) -> f64 {
131 dot(a, a).sqrt()
132}
133
134struct SplitMix(u64);
137
138impl SplitMix {
139 fn next(&mut self) -> u64 {
140 self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
141 let mut z = self.0;
142 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
143 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
144 z ^ (z >> 31)
145 }
146
147 fn unit(&mut self) -> f64 {
149 (self.next() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
150 }
151}
152
153struct Surface<'a> {
155 mesh: &'a Mesh,
156 ok: Vec<bool>,
158 centre: V3,
160}
161
162impl Surface<'_> {
163 fn raw(&self, i: u32) -> V3 {
164 let i = i as usize * 3;
165 let p = &self.mesh.positions[i..i + 3];
166 [p[0] as f64, p[1] as f64, p[2] as f64]
167 }
168
169 fn point(&self, i: u32) -> V3 {
170 sub(self.raw(i), self.centre)
171 }
172
173 fn triangle(&self, t: usize) -> Option<[u32; 3]> {
175 let tri = self.mesh.indices.get(t * 3..t * 3 + 3)?;
176 let tri = [tri[0], tri[1], tri[2]];
177 tri.iter()
178 .all(|&i| self.ok.get(i as usize).copied().unwrap_or(false))
179 .then_some(tri)
180 }
181}
182
183pub fn compute(mesh: &Mesh) -> Result<Signature> {
186 if mesh.is_point_cloud() {
187 bail!("Point clouds have no shape signature.");
188 }
189 if mesh.positions.len() % 3 != 0 || mesh.indices.len() % 3 != 0 {
190 bail!("Incomplete vertices or triangles.");
191 }
192 let triangles = mesh.triangle_count();
193 let ok: Vec<bool> = mesh
194 .positions
195 .chunks_exact(3)
196 .map(|p| {
197 p.iter()
198 .all(|v| v.is_finite() && (*v as f64).abs() < MAX_COORD)
199 })
200 .collect();
201 let mut surface = Surface {
202 mesh,
203 ok,
204 centre: [0.0; 3],
205 };
206
207 let mut area = 0.0;
209 let mut weighted = [0.0; 3];
210 for t in 0..triangles {
211 let Some([a, b, c]) = surface.triangle(t) else {
212 continue;
213 };
214 let (a, b, c) = (surface.raw(a), surface.raw(b), surface.raw(c));
215 let w = 0.5 * norm(cross(sub(b, a), sub(c, a)));
216 if !w.is_finite() {
217 continue;
218 }
219 area += w;
220 for k in 0..3 {
221 weighted[k] += w * (a[k] + b[k] + c[k]) / 3.0;
222 }
223 }
224 if !(area.is_finite() && area > 0.0) {
225 bail!("The model has no surface (degenerate or empty triangles).");
226 }
227 surface.centre = weighted.map(|v| v / area);
228
229 let mut moment = [[0.0f64; 3]; 3];
231 let mut volume = 0.0;
232 let mut cumulative = Vec::with_capacity(triangles);
233 let mut running = 0.0;
234 let mut used = vec![false; mesh.vertex_count()];
235 for t in 0..triangles {
236 if let Some(tri) = surface.triangle(t) {
237 let [a, b, c] = tri.map(|i| surface.point(i));
238 let w = 0.5 * norm(cross(sub(b, a), sub(c, a)));
239 if w.is_finite() {
240 running += w;
241 let m = [
242 (a[0] + b[0] + c[0]) / 3.0,
243 (a[1] + b[1] + c[1]) / 3.0,
244 (a[2] + b[2] + c[2]) / 3.0,
245 ];
246 for (i, row) in moment.iter_mut().enumerate() {
248 for (j, cell) in row.iter_mut().enumerate() {
249 *cell += w / 12.0
250 * (9.0 * m[i] * m[j] + a[i] * a[j] + b[i] * b[j] + c[i] * c[j]);
251 }
252 }
253 volume += dot(a, cross(b, c)) / 6.0;
254 }
255 for i in tri {
256 used[i as usize] = true;
257 }
258 }
259 cumulative.push(running);
260 }
261 let covariance = moment.map(|row| row.map(|v| v / area));
262 if covariance.iter().flatten().any(|v| !v.is_finite()) || !volume.is_finite() {
263 bail!("The model coordinates are out of range.");
264 }
265
266 let points: Vec<u32> = (0..mesh.vertex_count() as u32)
268 .filter(|&i| used[i as usize])
269 .collect();
270 let stride = points.len().div_ceil(AXIS_SAMPLE).max(1);
271 let sample: Vec<V3> = points
272 .iter()
273 .step_by(stride)
274 .map(|&i| surface.point(i))
275 .collect();
276 let axes = refine_axes(principal_axes(covariance), &sample);
277 let mut lo = [f64::INFINITY; 3];
278 let mut hi = [f64::NEG_INFINITY; 3];
279 for &i in &points {
280 let p = surface.point(i);
281 for k in 0..3 {
282 let v = dot(p, axes[k]);
283 lo[k] = lo[k].min(v);
284 hi[k] = hi[k].max(v);
285 }
286 }
287 let mut extents = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];
288 extents.sort_by(|a, b| b.total_cmp(a));
289 let diagonal = norm(extents);
290 if !(diagonal.is_finite() && diagonal > 0.0) {
291 bail!("The model has no extent.");
292 }
293
294 let closed = triangles <= CLOSED_CHECK_LIMIT && is_closed(&surface, triangles, diagonal * WELD);
295 let d2 = d2_histogram(&surface, &cumulative, running, diagonal);
296 Ok(Signature {
297 version: VERSION,
298 extents,
299 area,
300 volume: volume.abs(),
301 closed,
302 triangles: triangles as u64,
303 d2,
304 })
305}
306
307fn principal_axes(mut a: [[f64; 3]; 3]) -> [V3; 3] {
310 let mut v = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
311 for _ in 0..50 {
312 let off = a[0][1].abs() + a[0][2].abs() + a[1][2].abs();
313 let scale = a[0][0].abs() + a[1][1].abs() + a[2][2].abs();
314 if off <= 1e-15 * scale.max(f64::MIN_POSITIVE) {
315 break;
316 }
317 for (p, q) in [(0, 1), (0, 2), (1, 2)] {
318 if a[p][q].abs() <= f64::MIN_POSITIVE {
319 continue;
320 }
321 let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
322 let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
323 let t = if theta == 0.0 { 1.0 } else { t };
324 let c = 1.0 / (t * t + 1.0).sqrt();
325 let s = t * c;
326 for row in a.iter_mut() {
328 let (akp, akq) = (row[p], row[q]);
329 row[p] = c * akp - s * akq;
330 row[q] = s * akp + c * akq;
331 }
332 let (row_p, row_q) = (a[p], a[q]);
333 for k in 0..3 {
334 a[p][k] = c * row_p[k] - s * row_q[k];
335 a[q][k] = s * row_p[k] + c * row_q[k];
336 }
337 for row in v.iter_mut() {
338 let (vp, vq) = (row[p], row[q]);
339 row[p] = c * vp - s * vq;
340 row[q] = s * vp + c * vq;
341 }
342 }
343 }
344 let mut order = [0usize, 1, 2];
346 order.sort_by(|&x, &y| a[y][y].total_cmp(&a[x][x]));
347 let axes = order.map(|k| [v[0][k], v[1][k], v[2][k]]);
348 if axes.iter().flatten().all(|x| x.is_finite()) {
349 axes
350 } else {
351 [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
352 }
353}
354
355fn refine_axes(mut axes: [V3; 3], sample: &[V3]) -> [V3; 3] {
359 if sample.len() < 3 {
360 return axes;
361 }
362 for _ in 0..4 {
363 let mut improved = false;
364 for fixed in [2usize, 0, 1] {
365 let (i, j) = match fixed {
366 0 => (1, 2),
367 1 => (0, 2),
368 _ => (0, 1),
369 };
370 let flat: Vec<[f64; 2]> = sample
371 .iter()
372 .map(|p| [dot(*p, axes[i]), dot(*p, axes[j])])
373 .collect();
374 let current = rect_area(&flat, 0.0);
375 let (angle, best) = min_area_angle(&flat);
376 if best < current * (1.0 - 1e-9) {
377 let (c, s) = (angle.cos(), angle.sin());
378 let (ai, aj) = (axes[i], axes[j]);
379 axes[i] = [
380 c * ai[0] + s * aj[0],
381 c * ai[1] + s * aj[1],
382 c * ai[2] + s * aj[2],
383 ];
384 axes[j] = [
385 c * aj[0] - s * ai[0],
386 c * aj[1] - s * ai[1],
387 c * aj[2] - s * ai[2],
388 ];
389 improved = true;
390 }
391 }
392 if !improved {
393 break;
394 }
395 }
396 axes
397}
398
399fn rect_area(points: &[[f64; 2]], angle: f64) -> f64 {
402 let (c, s) = (angle.cos(), angle.sin());
403 let mut lo = [f64::INFINITY; 2];
404 let mut hi = [f64::NEG_INFINITY; 2];
405 for p in points {
406 let u = c * p[0] + s * p[1];
407 let v = c * p[1] - s * p[0];
408 lo = [lo[0].min(u), lo[1].min(v)];
409 hi = [hi[0].max(u), hi[1].max(v)];
410 }
411 (hi[0] - lo[0]) * (hi[1] - lo[1])
412}
413
414fn min_area_angle(points: &[[f64; 2]]) -> (f64, f64) {
417 let hull = convex_hull(points);
418 let mut angles: Vec<f64> = vec![0.0];
419 let quarter = std::f64::consts::FRAC_PI_2;
420 for k in 0..hull.len() {
421 let (a, b) = (hull[k], hull[(k + 1) % hull.len()]);
422 let angle = (b[1] - a[1]).atan2(b[0] - a[0]).rem_euclid(quarter);
423 if angle.is_finite() {
424 angles.push(angle);
425 }
426 }
427 angles.sort_by(f64::total_cmp);
428 angles.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
429 if angles.len() > MAX_ANGLES {
430 let step = angles.len().div_ceil(MAX_ANGLES);
431 angles = angles.into_iter().step_by(step).collect();
432 }
433 let mut best = (0.0, rect_area(&hull, 0.0));
434 for angle in angles {
435 let area = rect_area(&hull, angle);
436 if area < best.1 {
437 best = (angle, area);
438 }
439 }
440 best
441}
442
443fn convex_hull(points: &[[f64; 2]]) -> Vec<[f64; 2]> {
445 let mut p: Vec<[f64; 2]> = points.to_vec();
446 p.sort_by(|a, b| a[0].total_cmp(&b[0]).then(a[1].total_cmp(&b[1])));
447 p.dedup();
448 if p.len() < 3 {
449 return p;
450 }
451 let turn = |o: [f64; 2], a: [f64; 2], b: [f64; 2]| {
452 (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
453 };
454 let mut hull: Vec<[f64; 2]> = Vec::with_capacity(p.len() * 2);
455 for &q in p.iter().chain(p.iter().rev().skip(1)) {
456 while hull.len() >= 2 && turn(hull[hull.len() - 2], hull[hull.len() - 1], q) <= 0.0 {
457 hull.pop();
458 }
459 hull.push(q);
460 }
461 hull.pop();
462 hull
463}
464
465fn is_closed(surface: &Surface<'_>, triangles: usize, tolerance: f64) -> bool {
468 let mut ids: HashMap<[i64; 3], u32> = HashMap::new();
469 let mut welded: Vec<u32> = vec![u32::MAX; surface.mesh.vertex_count()];
470 let mut edges: Vec<(u64, i8)> = Vec::new();
471 for t in 0..triangles {
472 let Some(tri) = surface.triangle(t) else {
473 return false;
474 };
475 let w = tri.map(|i| {
476 let slot = &mut welded[i as usize];
477 if *slot == u32::MAX {
478 let key = surface
481 .point(i)
482 .map(|v| (v / tolerance).round().clamp(-9e15, 9e15) as i64);
483 let next = ids.len() as u32;
484 *slot = *ids.entry(key).or_insert(next);
485 }
486 *slot
487 });
488 if w[0] == w[1] || w[1] == w[2] || w[0] == w[2] {
489 continue; }
491 for (a, b) in [(w[0], w[1]), (w[1], w[2]), (w[2], w[0])] {
492 let (lo, hi, dir) = if a < b { (a, b, 1) } else { (b, a, -1) };
493 edges.push(((u64::from(lo) << 32) | u64::from(hi), dir));
494 }
495 }
496 if edges.is_empty() {
497 return false;
498 }
499 edges.sort_unstable_by_key(|e| e.0);
500 edges
501 .chunk_by(|a, b| a.0 == b.0)
502 .all(|run| run.len() == 2 && run.iter().map(|e| i32::from(e.1)).sum::<i32>() == 0)
503}
504
505fn d2_histogram(surface: &Surface<'_>, cumulative: &[f64], total: f64, diagonal: f64) -> Vec<f32> {
507 let mut rng = SplitMix(SEED);
508 let last = cumulative.len().saturating_sub(1);
509 let sample = |rng: &mut SplitMix| -> Option<V3> {
510 let u = rng.unit() * total;
511 let mut t = cumulative.partition_point(|&c| c <= u).min(last);
512 while surface.triangle(t).is_none() && t > 0 {
514 t -= 1;
515 }
516 let [a, b, c] = surface.triangle(t)?.map(|i| surface.point(i));
517 let r1 = rng.unit().sqrt();
518 let r2 = rng.unit();
519 let (wa, wb, wc) = (1.0 - r1, r1 * (1.0 - r2), r1 * r2);
520 Some([
521 wa * a[0] + wb * b[0] + wc * c[0],
522 wa * a[1] + wb * b[1] + wc * c[1],
523 wa * a[2] + wb * b[2] + wc * c[2],
524 ])
525 };
526 let mut bins = [0u32; D2_BINS];
527 let mut n = 0u32;
528 for _ in 0..D2_PAIRS {
529 let (Some(p), Some(q)) = (sample(&mut rng), sample(&mut rng)) else {
530 continue;
531 };
532 let r = norm(sub(p, q)) / diagonal;
533 if r.is_finite() {
534 let bin = ((r * D2_BINS as f64) as usize).min(D2_BINS - 1);
535 bins[bin] += 1;
536 n += 1;
537 }
538 }
539 let n = n.max(1) as f32;
540 bins.iter().map(|&b| b as f32 / n).collect()
541}
542
543#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
545pub struct Similarity {
546 pub score: f64,
548 pub shape: f64,
550 pub size: f64,
553}
554
555const THIN: f64 = 1e-3;
558
559fn floor_extents(e: [f64; 3]) -> [f64; 3] {
560 let min = (e[0] * THIN).max(f64::MIN_POSITIVE);
561 e.map(|x| x.max(min))
562}
563
564pub fn compare(a: &Signature, b: &Signature, ignore_scale: bool) -> Similarity {
575 let l1: f64 =
576 a.d2.iter()
577 .zip(&b.d2)
578 .map(|(x, y)| f64::from((x - y).abs()))
579 .sum();
580 let shape = (1.0 - 0.5 * l1).clamp(0.0, 1.0);
581 let (ea, eb) = (floor_extents(a.extents), floor_extents(b.extents));
582 let d = if ignore_scale {
583 ((ea[1] / ea[0]).ln() - (eb[1] / eb[0]).ln()).abs() / 2.0
584 + ((ea[2] / ea[0]).ln() - (eb[2] / eb[0]).ln()).abs() / 2.0
585 } else {
586 (0..3).map(|k| (ea[k] / eb[k]).ln().abs()).sum::<f64>() / 3.0
587 };
588 let size = if d.is_finite() { (-2.0 * d).exp() } else { 0.0 };
589 Similarity {
590 score: shape * size,
591 shape,
592 size,
593 }
594}
595
596pub fn fmt_len(x: f64) -> String {
598 if !x.is_finite() {
599 return "?".into();
600 }
601 let a = x.abs();
602 let s = if a >= 1e6 {
603 return format!("{:.3e}", x);
604 } else if a >= 100.0 {
605 format!("{x:.0}")
606 } else if a >= 10.0 {
607 format!("{x:.1}")
608 } else if a >= 1.0 {
609 format!("{x:.2}")
610 } else if a >= 0.001 {
611 format!("{x:.4}")
612 } else if a == 0.0 {
613 "0".into()
614 } else {
615 return format!("{:.2e}", x);
616 };
617 if s.contains('.') {
618 s.trim_end_matches('0').trim_end_matches('.').to_string()
619 } else {
620 s
621 }
622}
623
624pub fn fmt_extents(e: [f64; 3]) -> String {
626 format!("{} × {} × {}", fmt_len(e[0]), fmt_len(e[1]), fmt_len(e[2]))
627}
628
629pub fn explain(source: &Signature, candidate: &Signature, ignore_scale: bool) -> String {
632 let sim = compare(source, candidate, ignore_scale);
633 let mut out = if ignore_scale {
634 let (p, q) = (source.proportions(), candidate.proportions());
635 let scale = candidate.extents[0] / source.extents[0];
636 format!(
637 "proportions 1 : {:.2} : {:.2} vs 1 : {:.2} : {:.2} (scale ×{})",
638 q[0],
639 q[1],
640 p[0],
641 p[1],
642 fmt_len(scale)
643 )
644 } else {
645 format!(
646 "extents {} vs {}",
647 fmt_extents(candidate.extents),
648 fmt_extents(source.extents)
649 )
650 };
651 out.push_str(&format!(
652 ", shape distribution {:.0} % similar",
653 (sim.shape * 100.0).floor()
654 ));
655 if source.closed && candidate.closed {
656 out.push_str(&format!(
657 ", volume {} vs {}",
658 fmt_len(candidate.volume),
659 fmt_len(source.volume)
660 ));
661 }
662 out
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668
669 fn cuboid(sx: f32, sy: f32, sz: f32) -> Mesh {
671 let mut positions = Vec::new();
672 for i in 0..8 {
673 positions.extend([
674 if i & 1 == 0 { 0.0 } else { sx },
675 if i & 2 == 0 { 0.0 } else { sy },
676 if i & 4 == 0 { 0.0 } else { sz },
677 ]);
678 }
679 let quads = [
680 [0, 2, 3, 1],
681 [4, 5, 7, 6],
682 [0, 1, 5, 4],
683 [2, 6, 7, 3],
684 [0, 4, 6, 2],
685 [1, 3, 7, 5],
686 ];
687 let mut indices = Vec::new();
688 for q in quads {
689 indices.extend([q[0], q[1], q[2], q[0], q[2], q[3]]);
690 }
691 Mesh {
692 positions,
693 indices,
694 colors: Vec::new(),
695 parts: 1,
696 fields: Vec::new(),
697 faces: None,
698 }
699 }
700
701 fn cylinder(r: f32, h: f32, n: u32) -> Mesh {
703 let mut positions = vec![0.0, 0.0, 0.0, 0.0, 0.0, h];
704 for k in 0..n {
705 let a = std::f32::consts::TAU * k as f32 / n as f32;
706 positions.extend([r * a.cos(), r * a.sin(), 0.0]);
707 positions.extend([r * a.cos(), r * a.sin(), h]);
708 }
709 let mut indices = Vec::new();
710 for k in 0..n {
711 let (b0, t0) = (2 + 2 * k, 3 + 2 * k);
712 let (b1, t1) = (2 + 2 * ((k + 1) % n), 3 + 2 * ((k + 1) % n));
713 indices.extend([0, b1, b0, 1, t0, t1, b0, b1, t1, b0, t1, t0]);
714 }
715 Mesh {
716 positions,
717 indices,
718 colors: Vec::new(),
719 parts: 1,
720 fields: Vec::new(),
721 faces: None,
722 }
723 }
724
725 fn transform(mut m: Mesh, f: impl Fn([f64; 3]) -> [f64; 3]) -> Mesh {
726 for p in m.positions.chunks_exact_mut(3) {
727 let q = f([p[0] as f64, p[1] as f64, p[2] as f64]);
728 p.copy_from_slice(&[q[0] as f32, q[1] as f32, q[2] as f32]);
729 }
730 m
731 }
732
733 fn rotated(m: Mesh, (az, ay, ax): (f64, f64, f64), shift: [f64; 3]) -> Mesh {
735 transform(m, |p| {
736 let (s, c) = az.sin_cos();
737 let p = [c * p[0] - s * p[1], s * p[0] + c * p[1], p[2]];
738 let (s, c) = ay.sin_cos();
739 let p = [c * p[0] + s * p[2], p[1], -s * p[0] + c * p[2]];
740 let (s, c) = ax.sin_cos();
741 let p = [p[0], c * p[1] - s * p[2], s * p[1] + c * p[2]];
742 [p[0] + shift[0], p[1] + shift[1], p[2] + shift[2]]
743 })
744 }
745
746 fn close(a: f64, b: f64, rel: f64) -> bool {
747 (a - b).abs() <= rel * a.abs().max(b.abs()).max(1e-12)
748 }
749
750 #[test]
751 fn box_signature_has_its_extents_area_and_volume() {
752 let s = compute(&cuboid(120.0, 40.0, 8.0)).unwrap();
753 assert!(close(s.extents[0], 120.0, 1e-6), "{:?}", s.extents);
754 assert!(close(s.extents[1], 40.0, 1e-6), "{:?}", s.extents);
755 assert!(close(s.extents[2], 8.0, 1e-6), "{:?}", s.extents);
756 assert!(close(
757 s.area,
758 2.0 * (120.0 * 40.0 + 120.0 * 8.0 + 40.0 * 8.0),
759 1e-9
760 ));
761 assert!(close(s.volume, 120.0 * 40.0 * 8.0, 1e-9));
762 assert!(s.closed);
763 assert_eq!(s.triangles, 12);
764 assert_eq!(s.d2.len(), D2_BINS);
765 assert!(close(s.d2.iter().map(|&v| v as f64).sum(), 1.0, 1e-4));
766 assert!(s.is_valid());
767 assert_eq!(s, compute(&cuboid(120.0, 40.0, 8.0)).unwrap());
769 }
770
771 #[test]
772 fn rotation_and_position_do_not_change_the_signature() {
773 let base = compute(&cuboid(10.0, 10.0, 10.0)).unwrap();
774 let plate = compute(&cuboid(120.0, 40.0, 8.0)).unwrap();
775 for angles in [(0.3, 0.0, 0.0), (0.7, 1.1, -0.4), (2.0, -0.9, 0.35)] {
776 for (mesh, reference) in [
777 (cuboid(10.0, 10.0, 10.0), &base),
778 (cuboid(120.0, 40.0, 8.0), &plate),
779 ] {
780 let s = compute(&rotated(mesh, angles, [500.0, -20.0, 3.0])).unwrap();
781 for k in 0..3 {
782 assert!(
783 close(s.extents[k], reference.extents[k], 0.01),
784 "{angles:?}: {:?} vs {:?}",
785 s.extents,
786 reference.extents
787 );
788 }
789 assert!(close(s.area, reference.area, 1e-4));
790 assert!(close(s.volume, reference.volume, 1e-4));
791 let sim = compare(reference, &s, false);
792 assert!(sim.shape > 0.97, "{angles:?}: {sim:?}");
793 assert!(sim.score > 0.95, "{angles:?}: {sim:?}");
794 }
795 }
796 let cyl = compute(&cylinder(20.0, 60.0, 48)).unwrap();
798 let turned = compute(&rotated(
799 cylinder(20.0, 60.0, 48),
800 (0.4, 0.8, 0.2),
801 [1.0; 3],
802 ))
803 .unwrap();
804 for k in 0..3 {
805 assert!(
806 close(cyl.extents[k], turned.extents[k], 0.01),
807 "{:?} vs {:?}",
808 cyl.extents,
809 turned.extents
810 );
811 }
812 assert!(close(cyl.extents[0], 60.0, 1e-3));
813 assert!(cyl.closed);
814 }
815
816 #[test]
817 fn scale_counts_unless_ignored() {
818 let cube = compute(&cuboid(10.0, 10.0, 10.0)).unwrap();
819 let double = compute(&cuboid(20.0, 20.0, 20.0)).unwrap();
820 let near = compute(&cuboid(10.5, 10.0, 9.8)).unwrap();
821 let scaled = compare(&cube, &double, false);
823 assert!(scaled.shape > 0.97, "{scaled:?}");
824 assert!(scaled.size < 0.3, "twice the size: {scaled:?}");
825 let free = compare(&cube, &double, true);
826 assert!(free.score > 0.97, "ignoring scale: {free:?}");
827 assert!(compare(&cube, &near, false).score > scaled.score);
829 let text = explain(&cube, &near, false);
830 assert!(
831 text.starts_with("extents 10.5 × 10 × 9.8 vs 10 × 10 × 10"),
832 "{text}"
833 );
834 assert!(
835 text.contains("% similar") && text.contains("volume"),
836 "{text}"
837 );
838 let text = explain(&cube, &double, true);
839 assert!(text.contains("scale ×2"), "{text}");
840 }
841
842 #[test]
843 fn ranking_puts_the_like_shape_first() {
844 let cube = compute(&cuboid(30.0, 30.0, 30.0)).unwrap();
845 let candidates = [
846 (
847 "rotated cube",
848 compute(&rotated(
849 cuboid(30.0, 30.0, 30.0),
850 (0.5, 0.5, 0.5),
851 [0.0; 3],
852 ))
853 .unwrap(),
854 ),
855 (
856 "slightly larger cube",
857 compute(&cuboid(31.0, 30.0, 30.0)).unwrap(),
858 ),
859 (
860 "cylinder of the same size",
861 compute(&cylinder(15.0, 30.0, 64)).unwrap(),
862 ),
863 ("plate", compute(&cuboid(30.0, 30.0, 3.0)).unwrap()),
864 ("large cube", compute(&cuboid(90.0, 90.0, 90.0)).unwrap()),
865 ];
866 let mut ranked: Vec<(&str, f64)> = candidates
867 .iter()
868 .map(|(name, s)| (*name, compare(&cube, s, false).score))
869 .collect();
870 ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
871 let names: Vec<&str> = ranked.iter().map(|r| r.0).collect();
872 assert_eq!(
873 &names[..2],
874 &["rotated cube", "slightly larger cube"],
875 "{ranked:?}"
876 );
877 assert_eq!(names[2], "cylinder of the same size", "{ranked:?}");
878 let cyl = compare(&cube, &candidates[2].1, false);
880 assert!(cyl.size > 0.99 && cyl.shape < 0.97, "{cyl:?}");
881 }
882
883 #[test]
884 fn hostile_meshes_are_errors_not_panics() {
885 let empty = Mesh::default();
886 assert!(compute(&empty).is_err());
887 let cloud = Mesh {
889 positions: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
890 ..Mesh::default()
891 };
892 assert!(compute(&cloud).is_err());
893 let line = Mesh {
895 positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 2.0, 0.0, 0.0],
896 indices: vec![0, 1, 2, 2, 1, 0],
897 ..Mesh::default()
898 };
899 assert!(compute(&line).is_err());
900 let mut nan = cuboid(1.0, 1.0, 1.0);
902 nan.positions.iter_mut().for_each(|v| *v = f32::NAN);
903 assert!(compute(&nan).is_err());
904 let mut huge = cuboid(1.0, 1.0, 1.0);
905 huge.positions[0] = f32::INFINITY;
906 huge.positions[4] = f32::MAX;
907 let s = compute(&huge);
908 assert!(s.map(|s| s.is_valid()).unwrap_or(true));
909 let mut out_of_range = cuboid(1.0, 1.0, 1.0);
910 out_of_range.indices.extend([0, 1, 99]);
911 let s = compute(&out_of_range).unwrap();
912 assert!(!s.closed, "a broken triangle makes the volume unreliable");
913 let partial = Mesh {
914 positions: vec![0.0, 0.0],
915 indices: vec![0],
916 ..Mesh::default()
917 };
918 assert!(compute(&partial).is_err());
919 let tri = Mesh {
921 positions: vec![0.0, 0.0, 0.0, 4.0, 0.0, 0.0, 0.0, 3.0, 0.0],
922 indices: vec![0, 1, 2],
923 ..Mesh::default()
924 };
925 let s = compute(&tri).unwrap();
926 assert!(!s.closed && s.volume == 0.0 && s.extents[2] == 0.0, "{s:?}");
927 let cube = compute(&cuboid(4.0, 3.0, 1.0)).unwrap();
929 let sim = compare(&cube, &s, false);
930 assert!(sim.score.is_finite() && sim.score >= 0.0, "{sim:?}");
931 assert!(compare(&s, &s, true).score > 0.99);
932 let mut open = cuboid(2.0, 2.0, 2.0);
934 open.indices.truncate(30);
935 assert!(!compute(&open).unwrap().closed);
936 let mut flipped = cuboid(2.0, 2.0, 2.0);
938 flipped.indices.swap(0, 1);
939 flipped.indices.swap(3, 4);
940 assert!(!compute(&flipped).unwrap().closed);
941 }
942
943 #[test]
944 fn more_hostile_meshes() {
945 let mut m = Mesh {
948 positions: vec![0.0, 0.0, 0.0, 4.0, 0.0, 0.0, 0.0, 3.0, 0.0],
949 indices: vec![u32::MAX, 0, 1],
950 ..Mesh::default()
951 };
952 m.indices.extend([0, 1, 2]);
953 for _ in 0..10_000 {
954 m.indices.extend([0, u32::MAX, 2]);
955 }
956 let s = compute(&m).unwrap();
957 assert!(s.is_valid() && !s.closed, "{s:?}");
958 assert!(close(s.d2.iter().map(|&v| v as f64).sum(), 1.0, 1e-4));
959 let far = transform(cuboid(1.0, 2.0, 3.0), |p| p.map(|v| v * 1e14 + 5e14));
961 let s = compute(&far).unwrap();
962 assert!(s.is_valid(), "{s:?}");
963 assert!(close(s.extents[0], 3e14, 1e-3), "{:?}", s.extents);
964 let tiny = transform(cuboid(1.0, 2.0, 3.0), |p| p.map(|v| v * 1e-30));
966 let s = compute(&tiny).unwrap();
967 assert!(s.is_valid(), "{s:?}");
968 let mut slivers = cuboid(2.0, 3.0, 4.0);
970 for _ in 0..1000 {
971 slivers.indices.extend([0, 0, 0]);
972 }
973 let s = compute(&slivers).unwrap();
974 assert!(s.closed, "collapsed slivers are skipped");
975 assert!(close(s.volume, 24.0, 1e-9));
976 let mut empty_d2 = compute(&cuboid(1.0, 1.0, 1.0)).unwrap();
978 empty_d2.d2 = vec![0.0; D2_BINS];
979 let sim = compare(&s, &empty_d2, false);
980 assert!((0.0..=1.0).contains(&sim.score), "{sim:?}");
981 }
982
983 #[test]
984 fn welded_duplicates_still_count_as_closed() {
985 let cube = cuboid(5.0, 6.0, 7.0);
987 let mut soup = Mesh::default();
988 for t in cube.indices.chunks_exact(3) {
989 for &i in t {
990 let i = i as usize * 3;
991 soup.positions.extend_from_slice(&cube.positions[i..i + 3]);
992 soup.indices.push(soup.indices.len() as u32);
993 }
994 }
995 let s = compute(&soup).unwrap();
996 assert!(s.closed);
997 assert!(close(s.volume, 210.0, 1e-9));
998 assert_eq!(s, compute(&soup).unwrap());
999 }
1000
1001 #[test]
1004 fn large_meshes_are_bounded() {
1005 let big = cylinder(10.0, 50.0, 40_000);
1006 assert!(big.triangle_count() >= 160_000);
1007 let started = std::time::Instant::now();
1008 let s = compute(&big).unwrap();
1009 assert!(s.closed);
1010 assert!(
1011 close(s.extents[0], 50.0, 1e-3) && close(s.extents[1], 20.0, 1e-3),
1012 "{:?}",
1013 s.extents
1014 );
1015 assert!(
1017 started.elapsed() < std::time::Duration::from_secs(20),
1018 "{:?}",
1019 started.elapsed()
1020 );
1021 }
1022
1023 #[test]
1024 fn numbers_are_short() {
1025 assert_eq!(fmt_len(120.0), "120");
1026 assert_eq!(fmt_len(40.26), "40.3");
1027 assert_eq!(fmt_len(8.0), "8");
1028 assert_eq!(fmt_len(0.0125), "0.0125");
1029 assert_eq!(fmt_len(0.0), "0");
1030 assert_eq!(fmt_extents([120.0, 40.0, 8.0]), "120 × 40 × 8");
1031 assert_eq!(fmt_len(f64::NAN), "?");
1032 }
1033}