Skip to main content

cadvm_core/
meshdiff.rs

1//! Mesh-based geometric diff for STL/OBJ — **pure Rust, no Open CASCADE**.
2//!
3//! Triangle meshes have no B-Rep faces or solids, so the STEP pipeline does not
4//! apply. Instead each triangle is classified by **distance to the other mesh**:
5//! a triangle of the new version with no nearby surface in the old one is
6//! *added*, a triangle of the old version with nothing nearby in the new one is
7//! *removed*, and the rest is *unchanged*. The result is emitted in the very same
8//! [`geom::MeshDiff`] shape the 3D viewer already consumes (unchanged / added /
9//! removed triangle layers), so STL/OBJ get the same green/red/grey view as STEP
10//! — without needing the geometry helper.
11
12use std::collections::HashMap;
13
14use crate::format::CadFormat;
15use crate::geom::{BBox, Mesh, MeshDiff, MeshLayers};
16
17type Vec3 = [f64; 3];
18type Tri = [Vec3; 3];
19
20/// Diff two meshes (raw file bytes) and return the colored layers.
21pub fn diff(content_a: &[u8], content_b: &[u8], format: CadFormat) -> MeshDiff {
22    let a = match parse(content_a, format) {
23        Some(t) => t,
24        None => return error("could not parse the old mesh"),
25    };
26    let b = match parse(content_b, format) {
27        Some(t) => t,
28        None => return error("could not parse the new mesh"),
29    };
30
31    // Combined bounding box → adaptive tolerance (2% of the diagonal).
32    let mut bb = BBoxAccum::new();
33    for t in a.iter().chain(b.iter()) {
34        for v in t {
35            bb.add(*v);
36        }
37    }
38    let (min, max) = match bb.finish() {
39        Some(mm) => mm,
40        None => return ok_empty(),
41    };
42    let diag = dist(min, max);
43    let tol = (0.02 * diag).max(1e-9);
44    let cell = tol;
45    let tol2 = tol * tol;
46
47    let grid_a = TriGrid::build(&a, cell);
48    let grid_b = TriGrid::build(&b, cell);
49
50    let mut unchanged = MeshBuilder::new();
51    let mut added = MeshBuilder::new();
52    let mut removed = MeshBuilder::new();
53
54    // New-version triangles: unchanged if their centroid lies on (within `tol`
55    // of) the old surface, else added. Point-to-triangle distance means a shared
56    // face matches even if the two meshes triangulate it differently.
57    for t in &b {
58        if grid_a.surface_within(centroid(t), tol2) {
59            unchanged.push(t);
60        } else {
61            added.push(t);
62        }
63    }
64    // Old-version triangles whose centroid is off the new surface: removed.
65    for t in &a {
66        if !grid_b.surface_within(centroid(t), tol2) {
67            removed.push(t);
68        }
69    }
70
71    MeshDiff {
72        status: "ok".to_string(),
73        error: None,
74        bbox: Some(BBox { min, max }),
75        layers: Some(MeshLayers {
76            unchanged: unchanged.finish(),
77            added: added.finish(),
78            removed: removed.finish(),
79        }),
80    }
81}
82
83fn error(msg: &str) -> MeshDiff {
84    MeshDiff {
85        status: "error".to_string(),
86        error: Some(msg.to_string()),
87        bbox: None,
88        layers: None,
89    }
90}
91
92fn ok_empty() -> MeshDiff {
93    MeshDiff {
94        status: "ok".to_string(),
95        error: None,
96        bbox: None,
97        layers: Some(MeshLayers {
98            unchanged: Mesh {
99                positions: vec![],
100                normals: vec![],
101            },
102            added: Mesh {
103                positions: vec![],
104                normals: vec![],
105            },
106            removed: Mesh {
107                positions: vec![],
108                normals: vec![],
109            },
110        }),
111    }
112}
113
114// ---- geometry helpers ------------------------------------------------------
115
116fn centroid(t: &Tri) -> Vec3 {
117    [
118        (t[0][0] + t[1][0] + t[2][0]) / 3.0,
119        (t[0][1] + t[1][1] + t[2][1]) / 3.0,
120        (t[0][2] + t[1][2] + t[2][2]) / 3.0,
121    ]
122}
123
124fn dist(a: Vec3, b: Vec3) -> f64 {
125    let (dx, dy, dz) = (a[0] - b[0], a[1] - b[1], a[2] - b[2]);
126    (dx * dx + dy * dy + dz * dz).sqrt()
127}
128
129fn normal(t: &Tri) -> [f32; 3] {
130    let u = [t[1][0] - t[0][0], t[1][1] - t[0][1], t[1][2] - t[0][2]];
131    let v = [t[2][0] - t[0][0], t[2][1] - t[0][1], t[2][2] - t[0][2]];
132    let n = [
133        u[1] * v[2] - u[2] * v[1],
134        u[2] * v[0] - u[0] * v[2],
135        u[0] * v[1] - u[1] * v[0],
136    ];
137    let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
138    if len > 1e-12 {
139        [
140            (n[0] / len) as f32,
141            (n[1] / len) as f32,
142            (n[2] / len) as f32,
143        ]
144    } else {
145        [0.0, 0.0, 1.0]
146    }
147}
148
149struct BBoxAccum {
150    min: Vec3,
151    max: Vec3,
152    any: bool,
153}
154impl BBoxAccum {
155    fn new() -> Self {
156        BBoxAccum {
157            min: [f64::INFINITY; 3],
158            max: [f64::NEG_INFINITY; 3],
159            any: false,
160        }
161    }
162    fn add(&mut self, p: Vec3) {
163        self.any = true;
164        for (m, &v) in self.min.iter_mut().zip(p.iter()) {
165            *m = m.min(v);
166        }
167        for (m, &v) in self.max.iter_mut().zip(p.iter()) {
168            *m = m.max(v);
169        }
170    }
171    fn finish(self) -> Option<(Vec3, Vec3)> {
172        self.any.then_some((self.min, self.max))
173    }
174}
175
176/// Flat-shaded triangle-soup builder (9 floats + per-triangle normal repeated).
177struct MeshBuilder {
178    positions: Vec<f32>,
179    normals: Vec<f32>,
180}
181impl MeshBuilder {
182    fn new() -> Self {
183        MeshBuilder {
184            positions: Vec::new(),
185            normals: Vec::new(),
186        }
187    }
188    fn push(&mut self, t: &Tri) {
189        let n = normal(t);
190        for v in t {
191            self.positions.push(v[0] as f32);
192            self.positions.push(v[1] as f32);
193            self.positions.push(v[2] as f32);
194            self.normals.extend_from_slice(&n);
195        }
196    }
197    fn finish(self) -> Mesh {
198        Mesh {
199            positions: self.positions,
200            normals: self.normals,
201        }
202    }
203}
204
205fn cell_of(cell: f64, x: f64) -> i64 {
206    (x / cell).floor() as i64
207}
208
209/// Spatial grid of triangles for "is a point within `tol` of the surface?"
210/// queries. Each triangle is bucketed into every cell its bounding box touches,
211/// so a query checking the point's cell and its 26 neighbours (with
212/// `cell >= tol`) finds every triangle that could be within `tol`.
213struct TriGrid<'a> {
214    cell: f64,
215    tris: &'a [Tri],
216    map: HashMap<[i64; 3], Vec<usize>>,
217}
218impl<'a> TriGrid<'a> {
219    fn build(tris: &'a [Tri], cell: f64) -> TriGrid<'a> {
220        let cell = if cell > 0.0 { cell } else { 1.0 };
221        let mut map: HashMap<[i64; 3], Vec<usize>> = HashMap::new();
222        for (i, t) in tris.iter().enumerate() {
223            let (mut lo, mut hi) = (t[0], t[0]);
224            for v in &t[1..] {
225                for d in 0..3 {
226                    lo[d] = lo[d].min(v[d]);
227                    hi[d] = hi[d].max(v[d]);
228                }
229            }
230            for cx in cell_of(cell, lo[0])..=cell_of(cell, hi[0]) {
231                for cy in cell_of(cell, lo[1])..=cell_of(cell, hi[1]) {
232                    for cz in cell_of(cell, lo[2])..=cell_of(cell, hi[2]) {
233                        map.entry([cx, cy, cz]).or_default().push(i);
234                    }
235                }
236            }
237        }
238        TriGrid { cell, tris, map }
239    }
240
241    /// Is `p` within `sqrt(tol2)` of any triangle's surface?
242    fn surface_within(&self, p: Vec3, tol2: f64) -> bool {
243        let k = [
244            cell_of(self.cell, p[0]),
245            cell_of(self.cell, p[1]),
246            cell_of(self.cell, p[2]),
247        ];
248        for dx in -1..=1 {
249            for dy in -1..=1 {
250                for dz in -1..=1 {
251                    if let Some(ids) = self.map.get(&[k[0] + dx, k[1] + dy, k[2] + dz]) {
252                        for &i in ids {
253                            let t = &self.tris[i];
254                            if dist2_point_tri(p, t[0], t[1], t[2]) <= tol2 {
255                                return true;
256                            }
257                        }
258                    }
259                }
260            }
261        }
262        false
263    }
264}
265
266// ---- vector helpers + closest point on a triangle --------------------------
267
268fn sub(a: Vec3, b: Vec3) -> Vec3 {
269    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
270}
271fn dot(a: Vec3, b: Vec3) -> f64 {
272    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
273}
274
275/// Squared distance from point `p` to triangle `abc`
276/// (Ericson, *Real-Time Collision Detection*).
277fn dist2_point_tri(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
278    let ab = sub(b, a);
279    let ac = sub(c, a);
280    let ap = sub(p, a);
281    let d1 = dot(ab, ap);
282    let d2 = dot(ac, ap);
283    if d1 <= 0.0 && d2 <= 0.0 {
284        return dot(ap, ap);
285    }
286    let bp = sub(p, b);
287    let d3 = dot(ab, bp);
288    let d4 = dot(ac, bp);
289    if d3 >= 0.0 && d4 <= d3 {
290        return dot(bp, bp);
291    }
292    let vc = d1 * d4 - d3 * d2;
293    if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
294        let v = d1 / (d1 - d3);
295        let q = [a[0] + v * ab[0], a[1] + v * ab[1], a[2] + v * ab[2]];
296        return dot(sub(p, q), sub(p, q));
297    }
298    let cp = sub(p, c);
299    let d5 = dot(ab, cp);
300    let d6 = dot(ac, cp);
301    if d6 >= 0.0 && d5 <= d6 {
302        return dot(cp, cp);
303    }
304    let vb = d5 * d2 - d1 * d6;
305    if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
306        let w = d2 / (d2 - d6);
307        let q = [a[0] + w * ac[0], a[1] + w * ac[1], a[2] + w * ac[2]];
308        return dot(sub(p, q), sub(p, q));
309    }
310    let va = d3 * d6 - d5 * d4;
311    if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
312        let w = (d4 - d3) / ((d4 - d3) + (d5 - d6));
313        let bc = sub(c, b);
314        let q = [b[0] + w * bc[0], b[1] + w * bc[1], b[2] + w * bc[2]];
315        return dot(sub(p, q), sub(p, q));
316    }
317    let denom = 1.0 / (va + vb + vc);
318    let v = vb * denom;
319    let w = vc * denom;
320    let q = [
321        a[0] + ab[0] * v + ac[0] * w,
322        a[1] + ab[1] * v + ac[1] * w,
323        a[2] + ab[2] * v + ac[2] * w,
324    ];
325    dot(sub(p, q), sub(p, q))
326}
327
328// ---- parsing (STL binary/ASCII, OBJ) ---------------------------------------
329
330fn parse(content: &[u8], format: CadFormat) -> Option<Vec<Tri>> {
331    match format {
332        CadFormat::Stl => Some(parse_stl(content)),
333        CadFormat::Obj => Some(parse_obj(content)),
334        _ => None,
335    }
336}
337
338fn parse_stl(content: &[u8]) -> Vec<Tri> {
339    if content.len() >= 84 {
340        let count =
341            u32::from_le_bytes([content[80], content[81], content[82], content[83]]) as usize;
342        if content.len() == 84 + count * 50 {
343            return parse_stl_binary(content, count);
344        }
345    }
346    parse_stl_ascii(content)
347}
348
349fn parse_stl_binary(content: &[u8], count: usize) -> Vec<Tri> {
350    let read = |b: &[u8]| f32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64;
351    let mut tris = Vec::with_capacity(count);
352    for t in 0..count {
353        let base = 84 + t * 50 + 12; // skip the 3-float normal
354        let mut tri = [[0.0; 3]; 3];
355        for (v, slot) in tri.iter_mut().enumerate() {
356            let o = base + v * 12;
357            *slot = [
358                read(&content[o..o + 4]),
359                read(&content[o + 4..o + 8]),
360                read(&content[o + 8..o + 12]),
361            ];
362        }
363        tris.push(tri);
364    }
365    tris
366}
367
368fn parse_stl_ascii(content: &[u8]) -> Vec<Tri> {
369    let text = String::from_utf8_lossy(content);
370    let mut tris = Vec::new();
371    let mut current: Vec<Vec3> = Vec::new();
372    for line in text.lines() {
373        let line = line.trim_start();
374        if let Some(rest) = line.strip_prefix("vertex") {
375            if let Some(p) = parse_xyz(rest) {
376                current.push(p);
377            }
378        } else if line.starts_with("endfacet") {
379            if current.len() >= 3 {
380                tris.push([current[0], current[1], current[2]]);
381            }
382            current.clear();
383        }
384    }
385    tris
386}
387
388fn parse_obj(content: &[u8]) -> Vec<Tri> {
389    let text = String::from_utf8_lossy(content);
390    let mut verts: Vec<Vec3> = Vec::new();
391    let mut tris = Vec::new();
392    for line in text.lines() {
393        let line = line.trim_start();
394        if let Some(rest) = line.strip_prefix("v ") {
395            if let Some(p) = parse_xyz(rest) {
396                verts.push(p);
397            }
398        } else if let Some(rest) = line.strip_prefix("f ") {
399            // Each token is like `v`, `v/vt`, `v/vt/vn`, or `v//vn` (1-based,
400            // negative = relative). Fan-triangulate the polygon.
401            let idx: Vec<usize> = rest
402                .split_whitespace()
403                .filter_map(|tok| obj_index(tok.split('/').next().unwrap_or(""), verts.len()))
404                .collect();
405            // Fan-triangulate: (0, w, w+1) for w = 1..n-1.
406            for w in 1..idx.len().saturating_sub(1) {
407                tris.push([verts[idx[0]], verts[idx[w]], verts[idx[w + 1]]]);
408            }
409        }
410    }
411    tris
412}
413
414fn obj_index(tok: &str, n: usize) -> Option<usize> {
415    let i: i64 = tok.parse().ok()?;
416    if i > 0 {
417        Some((i - 1) as usize).filter(|&x| x < n)
418    } else if i < 0 {
419        let x = n as i64 + i;
420        (x >= 0).then_some(x as usize)
421    } else {
422        None
423    }
424}
425
426fn parse_xyz(s: &str) -> Option<Vec3> {
427    let mut it = s.split_whitespace();
428    let x = it.next()?.parse().ok()?;
429    let y = it.next()?.parse().ok()?;
430    let z = it.next()?.parse().ok()?;
431    Some([x, y, z])
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    // Two stacked triangles forming a square in A; B keeps one and moves the
439    // other far away → expect 1 unchanged, 1 added, 1 removed.
440    #[test]
441    fn classifies_moved_triangle() {
442        let a = "v 0 0 0\nv 1 0 0\nv 0 1 0\nv 9 9 0\nv 10 9 0\nv 9 10 0\nf 1 2 3\nf 4 5 6\n";
443        // B: same first triangle, second triangle moved far in +z.
444        let b = "v 0 0 0\nv 1 0 0\nv 0 1 0\nv 9 9 50\nv 10 9 50\nv 9 10 50\nf 1 2 3\nf 4 5 6\n";
445        let d = diff(a.as_bytes(), b.as_bytes(), CadFormat::Obj);
446        assert!(d.is_ok());
447        let l = d.layers.unwrap();
448        assert_eq!(l.unchanged.triangle_count(), 1);
449        assert_eq!(l.added.triangle_count(), 1);
450        assert_eq!(l.removed.triangle_count(), 1);
451    }
452
453    #[test]
454    fn identical_meshes_are_all_unchanged() {
455        let m = "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n";
456        let d = diff(m.as_bytes(), m.as_bytes(), CadFormat::Obj);
457        let l = d.layers.unwrap();
458        assert_eq!(l.unchanged.triangle_count(), 1);
459        assert_eq!(l.added.triangle_count(), 0);
460        assert_eq!(l.removed.triangle_count(), 0);
461    }
462}