Skip to main content

teamcraft/
shape.rs

1//! Shape signatures for the similar part search – cheap, explainable, no ML.
2//!
3//! A [`Signature`] summarises the 3D preview mesh of a file (see `mesh`):
4//!
5//! * **principal extents** – the edge lengths of an oriented bounding box,
6//!   largest first. The box starts from the principal axes of the surface
7//!   (area-weighted covariance) and is then tightened plane by plane with
8//!   the minimum-area rectangle of the projected convex hull, so it neither
9//!   depends on how the part lies in the file nor on the tessellation;
10//! * **surface area** and **enclosed volume** (sum of signed tetrahedra) –
11//!   the volume is only meaningful when every edge is shared by exactly two
12//!   consistently oriented triangles (`closed`);
13//! * a **D2 shape distribution** (Osada et al. 2002): the histogram of the
14//!   distances between random point pairs on the surface, divided by the
15//!   diagonal of the box – a fingerprint that ignores position, rotation and
16//!   scale. The points are drawn with a fixed seed, so a file always gets
17//!   the same signature;
18//! * the triangle count.
19//!
20//! [`compare`] ranks two signatures (see there for the formula) and
21//! [`explain`] says why in one line. All work is bounded: the box search uses
22//! at most [`AXIS_SAMPLE`] vertices, D2 draws [`D2_PAIRS`] pairs, the
23//! closedness check stops at [`CLOSED_CHECK_LIMIT`] triangles. The server
24//! computes signatures in the isolated preview process (see `preview`).
25
26use std::collections::HashMap;
27
28use anyhow::{bail, Result};
29use serde::{Deserialize, Serialize};
30
31use crate::mesh::Mesh;
32
33/// Bump when the computation changes: stored signatures of another version
34/// are ignored and computed again – also when the preview meshes change
35/// (`preview::CACHE_VERSION`), since the signature is computed from them.
36/// 2: the STEP readers of preview cache version 2 (tessellated AP242,
37/// partial previews without a signature). 3: preview cache version 3
38/// (Inventor parts with Zstandard segments, #271). 4: preview cache
39/// version 4 (Creo parts in millimetres).
40pub const VERSION: i16 = 4;
41/// Bins of the D2 histogram.
42pub const D2_BINS: usize = 32;
43/// Point pairs drawn for the D2 histogram.
44pub const D2_PAIRS: usize = 32_768;
45/// Vertices used to orient the bounding box (evenly spaced subset).
46pub const AXIS_SAMPLE: usize = 8_192;
47/// Larger meshes are not checked for closedness (their volume is flagged
48/// as unreliable instead).
49pub const CLOSED_CHECK_LIMIT: usize = 2_000_000;
50/// Hull edge directions tried per plane.
51const MAX_ANGLES: usize = 720;
52/// Vertices closer than this share of the diagonal are one for the
53/// closedness check.
54const WELD: f64 = 1e-6;
55/// Coordinates beyond this are treated as broken (products must stay finite).
56const MAX_COORD: f64 = 1e15;
57/// Seed of the point sampling (any fixed value; changing it needs a new VERSION).
58const SEED: u64 = 0x7EA3_C4AF_D25E_0001;
59
60/// The shape signature of one mesh.
61#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
62pub struct Signature {
63    pub version: i16,
64    /// Oriented bounding box edge lengths, largest first (model units).
65    pub extents: [f64; 3],
66    /// Surface area (model units²).
67    pub area: f64,
68    /// Enclosed volume (model units³); reliable only if `closed`.
69    pub volume: f64,
70    /// Watertight and consistently oriented.
71    pub closed: bool,
72    pub triangles: u64,
73    /// D2 histogram, [`D2_BINS`] values that sum to 1.
74    pub d2: Vec<f32>,
75}
76
77/// What the preview process writes: the signature or why there is none.
78#[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    /// Middle and smallest extent relative to the largest (scale-free).
87    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    /// Sphericity π^⅓·(6V)^⅔ / A (1 = sphere), only for closed meshes.
97    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    /// Plausible values (e.g. read back from the database).
104    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
134/// Deterministic random numbers (SplitMix64) – stable across crate versions,
135/// unlike a library RNG, because signatures are stored.
136struct 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    /// Uniform in [0, 1).
148    fn unit(&mut self) -> f64 {
149        (self.next() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
150    }
151}
152
153/// The valid triangles of a mesh: indices in range, finite coordinates.
154struct Surface<'a> {
155    mesh: &'a Mesh,
156    /// Vertex usable (finite, not absurdly large).
157    ok: Vec<bool>,
158    /// Area-weighted centre; coordinates are taken relative to it.
159    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    /// The three corners of triangle `t`, if valid.
174    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
183/// Computes the signature of a triangle mesh. Point clouds, meshes without
184/// surface and broken coordinates are errors, never panics.
185pub 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    // Pass 1: area and area-weighted centre (from raw coordinates).
208    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    // Pass 2: second moments of the surface, volume, cumulative areas.
230    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                // ∫ x xᵀ dA over a triangle = A/12 · (9 m mᵀ + a aᵀ + b bᵀ + c cᵀ)
247                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    // Oriented bounding box.
267    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
307/// Eigenvectors of the symmetric covariance (cyclic Jacobi), as rows,
308/// largest eigenvalue first.
309fn 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            // A' = Jᵀ A J for the rotation J in the (p, q) plane
327            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    // columns of v are the eigenvectors
345    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
355/// Tightens the box: per plane of two axes, rotates them to the
356/// minimum-area rectangle of the projected points (coordinate descent on
357/// the box volume, which never grows).
358fn 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
399/// Area of the axis-aligned rectangle of the points after rotating the
400/// frame by `angle`.
401fn 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
414/// The rotation (0 ≤ angle < 90°) with the smallest enclosing rectangle:
415/// one of its sides lies on an edge of the convex hull.
416fn 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
443/// Andrew's monotone chain; collinear points dropped.
444fn 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
465/// Every edge (after welding equal positions) belongs to exactly two
466/// triangles that run through it in opposite directions.
467fn 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                // positions on a grid of `tolerance` (B-rep tessellations
479                // repeat edge vertices per face with tiny differences)
480                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; // collapsed sliver
490        }
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
505/// Histogram of point-pair distances on the surface relative to `diagonal`.
506fn 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        // skip back over invalid or empty triangles at the very end
513        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/// How alike two signatures are.
544#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
545pub struct Similarity {
546    /// `shape × size`, 0…1 – the ranking key.
547    pub score: f64,
548    /// 1 − half the L1 distance of the D2 histograms (0…1).
549    pub shape: f64,
550    /// exp(−2·d) with d the mean |ln ratio| of the extents (or, ignoring
551    /// scale, of the two proportions); 1 = same size.
552    pub size: f64,
553}
554
555/// Extents below this fraction of the largest count as this fraction (flat
556/// parts would otherwise divide by zero).
557const 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
564/// Compares two signatures:
565///
566/// * `shape = 1 − ½·Σ|d2ₐ − d2ᵦ|` (the histograms sum to 1, so 0…1);
567/// * `size = exp(−2·d)`, `d` = mean of `|ln(eₐ/eᵦ)|` over the three sorted
568///   extents – or, with `ignore_scale`, over the two proportions
569///   (middle/largest, smallest/largest), so a scaled copy scores 1;
570/// * `score = shape × size`.
571///
572/// 5 % larger in every direction costs about 10 % of the score; twice the
573/// size leaves a quarter.
574pub 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
596/// A length with three significant digits at most: 120, 40.5, 8.25, 0.012.
597pub 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
624/// `120 × 40 × 8`.
625pub fn fmt_extents(e: [f64; 3]) -> String {
626    format!("{} × {} × {}", fmt_len(e[0]), fmt_len(e[1]), fmt_len(e[2]))
627}
628
629/// One line for people: the extents (or proportions) of both, the shape
630/// similarity and – when both are watertight – the volumes.
631pub 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    /// An axis-aligned box mesh (12 triangles, outward normals).
670    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    /// A closed cylinder (radius r, height h, n segments) along z.
702    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    /// Rotation by Euler angles (z, y, x) plus a shift.
734    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        // deterministic
768        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        // A rotated cylinder keeps its extents as well.
797        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        // the shape distribution is scale-free
822        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        // a slightly different cube beats the scaled one with scale
828        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        // the cylinder has the cube's extents but another shape distribution
879        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        // point cloud
888        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        // all triangles collapsed onto a line
894        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        // NaN and infinite coordinates, indices out of range
901        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        // a single flat triangle: valid, but no volume
920        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        // comparing with a flat part stays finite
928        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        // an open box (one face missing) is not closed
933        let mut open = cuboid(2.0, 2.0, 2.0);
934        open.indices.truncate(30);
935        assert!(!compute(&open).unwrap().closed);
936        // inconsistent orientation: a flipped face
937        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        // indices at u32::MAX, and one good triangle among many broken ones
946        // (the D2 sampling must find it, not walk or index out of range)
947        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        // coordinates just below the cut-off stay finite
960        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        // tiny coordinates (a part in kilometres)
965        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        // degenerate (zero-area) triangles around a real one: same signature
969        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        // a comparison with a stored signature of all-zero D2 stays in range
977        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        // every triangle with its own three vertices, as STL files have them
986        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    /// A finely tessellated cylinder: the work stays bounded (sampled box
1002    /// search, fixed number of D2 pairs).
1003    #[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        // generous for unoptimised test builds on slow CI machines
1016        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}