Skip to main content

cadvm_core/
geom.rs

1//! Bridge to the `cadvm-geom` C++/OCCT helper (Step 2: geometric diff).
2//!
3//! The Rust core stays pure Rust: it shells out to a standalone C++ executable
4//! (`cadvm-geom`, built from `cpp/`) and parses its JSON output. This keeps the
5//! heavy Open CASCADE dependency isolated in a single subprocess with a narrow,
6//! stable contract.
7//!
8//! The binary is located via the `CADVM_GEOM_BIN` environment variable, falling
9//! back to `cadvm-geom` on `PATH`.
10
11use std::path::{Path, PathBuf};
12use std::process::Command;
13
14use serde::{Deserialize, Serialize};
15
16use crate::error::{CoreError, Result};
17
18/// Environment variable pointing at the `cadvm-geom` executable.
19pub const ENV_GEOM_BIN: &str = "CADVM_GEOM_BIN";
20
21/// An axis-aligned bounding box.
22#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
23pub struct BBox {
24    pub min: [f64; 3],
25    pub max: [f64; 3],
26}
27
28impl BBox {
29    /// Box dimensions `[dx, dy, dz]`.
30    pub fn size(&self) -> [f64; 3] {
31        [
32            self.max[0] - self.min[0],
33            self.max[1] - self.min[1],
34            self.max[2] - self.min[2],
35        ]
36    }
37}
38
39/// Full metrics for one input shape.
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct ShapeMetrics {
42    pub volume: f64,
43    pub area: f64,
44    pub solids: u64,
45    pub shells: u64,
46    pub faces: u64,
47    #[serde(default)]
48    pub bbox: Option<BBox>,
49}
50
51/// Metrics for a boolean-result piece (added/removed/common).
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53pub struct PieceMetrics {
54    pub volume: f64,
55    pub faces: u64,
56}
57
58/// Topological (face-to-face) classification counts.
59///
60/// Faces of A and B are matched by a coarse geometric signature; `common` faces
61/// appear in both, `added` only in B, `removed` only in A.
62#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
63pub struct FaceTopo {
64    pub common: u64,
65    pub added: u64,
66    pub removed: u64,
67}
68
69/// The geometric comparison of two STEP shapes, as reported by `cadvm-geom`.
70///
71/// `added`/`removed`/`common` are the boolean decomposition of the two inputs
72/// (B−A, A−B, A∩B). All metric fields are optional because the error path omits
73/// them.
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct GeomDiff {
76    /// `"ok"` or `"error"`.
77    pub status: String,
78    #[serde(default)]
79    pub error: Option<String>,
80    pub file_a: String,
81    pub file_b: String,
82    #[serde(default)]
83    pub a: Option<ShapeMetrics>,
84    #[serde(default)]
85    pub b: Option<ShapeMetrics>,
86    #[serde(default)]
87    pub common: Option<PieceMetrics>,
88    #[serde(default)]
89    pub added: Option<PieceMetrics>,
90    #[serde(default)]
91    pub removed: Option<PieceMetrics>,
92    /// Topological face-to-face classification.
93    #[serde(default)]
94    pub faces_topo: Option<FaceTopo>,
95}
96
97impl GeomDiff {
98    /// Whether the helper reported a successful comparison.
99    pub fn is_ok(&self) -> bool {
100        self.status == "ok"
101    }
102}
103
104/// A flat-shaded triangle soup: every 9 floats in `positions` is one triangle,
105/// with a matching per-vertex normal in `normals`.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct Mesh {
108    pub positions: Vec<f32>,
109    pub normals: Vec<f32>,
110}
111
112impl Mesh {
113    /// Number of triangles.
114    pub fn triangle_count(&self) -> usize {
115        self.positions.len() / 9
116    }
117
118    pub fn is_empty(&self) -> bool {
119        self.positions.is_empty()
120    }
121}
122
123/// The mesh layers of a geometric diff, classified per face: the faces of the
124/// new part that are unchanged or added, and the faces of the old part that were
125/// removed.
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127pub struct MeshLayers {
128    pub unchanged: Mesh,
129    pub added: Mesh,
130    pub removed: Mesh,
131}
132
133/// Tessellated geometric diff produced by `cadvm-geom mesh`.
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135pub struct MeshDiff {
136    pub status: String,
137    #[serde(default)]
138    pub error: Option<String>,
139    #[serde(default)]
140    pub bbox: Option<BBox>,
141    #[serde(default)]
142    pub layers: Option<MeshLayers>,
143}
144
145impl MeshDiff {
146    pub fn is_ok(&self) -> bool {
147        self.status == "ok"
148    }
149
150    /// Total triangle count across all layers.
151    pub fn total_triangles(&self) -> usize {
152        self.layers.as_ref().map_or(0, |l| {
153            l.unchanged.triangle_count() + l.added.triangle_count() + l.removed.triangle_count()
154        })
155    }
156
157    /// Serialize to the JSON the 3D viewer embeds.
158    pub fn to_json(&self) -> String {
159        serde_json::to_string(self).unwrap_or_default()
160    }
161}
162
163/// Resolve the path to the `cadvm-geom` binary.
164pub fn binary_path() -> PathBuf {
165    match std::env::var_os(ENV_GEOM_BIN) {
166        Some(p) if !p.is_empty() => PathBuf::from(p),
167        _ => PathBuf::from("cadvm-geom"),
168    }
169}
170
171/// Run the geometric diff between two STEP files using the configured binary.
172pub fn diff_files(a: &Path, b: &Path) -> Result<GeomDiff> {
173    diff_files_with(&binary_path(), a, b)
174}
175
176/// Run the geometric diff using an explicit binary path (used by tests).
177pub fn diff_files_with(bin: &Path, a: &Path, b: &Path) -> Result<GeomDiff> {
178    let output = Command::new(bin).arg("diff").arg(a).arg(b).output();
179    let output = match output {
180        Ok(o) => o,
181        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
182            return Err(CoreError::GeomBinaryNotFound(bin.to_path_buf()));
183        }
184        Err(e) => return Err(CoreError::io(bin, e)),
185    };
186
187    if !output.status.success() {
188        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
189        return Err(CoreError::GeomFailed(if stderr.is_empty() {
190            format!("exit status {}", output.status)
191        } else {
192            stderr
193        }));
194    }
195
196    let diff: GeomDiff = serde_json::from_slice(&output.stdout)
197        .map_err(|e| CoreError::GeomFailed(format!("invalid JSON from helper: {e}")))?;
198    Ok(diff)
199}
200
201/// Tessellate the geometric diff of two STEP files, writing the mesh JSON to
202/// `out_json` and returning the parsed result.
203pub fn mesh_files(a: &Path, b: &Path, out_json: &Path) -> Result<MeshDiff> {
204    mesh_files_with(&binary_path(), a, b, out_json)
205}
206
207/// Like [`mesh_files`] but with an explicit binary path (used by tests).
208pub fn mesh_files_with(bin: &Path, a: &Path, b: &Path, out_json: &Path) -> Result<MeshDiff> {
209    let output = Command::new(bin)
210        .arg("mesh")
211        .arg(a)
212        .arg(b)
213        .arg(out_json)
214        .output();
215    let output = match output {
216        Ok(o) => o,
217        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
218            return Err(CoreError::GeomBinaryNotFound(bin.to_path_buf()));
219        }
220        Err(e) => return Err(CoreError::io(bin, e)),
221    };
222    if !output.status.success() {
223        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
224        return Err(CoreError::GeomFailed(if stderr.is_empty() {
225            format!("exit status {}", output.status)
226        } else {
227            stderr
228        }));
229    }
230
231    // The helper prints a short ack to stdout; a geometry error appears there
232    // and means no usable output file was written.
233    if let Ok(ack) = serde_json::from_slice::<serde_json::Value>(&output.stdout) {
234        if ack.get("status").and_then(|s| s.as_str()) == Some("error") {
235            let msg = ack
236                .get("error")
237                .and_then(|e| e.as_str())
238                .unwrap_or("unknown geometry error");
239            return Err(CoreError::GeomFailed(msg.to_string()));
240        }
241    }
242
243    let bytes = std::fs::read(out_json).map_err(|e| CoreError::io(out_json, e))?;
244    let diff: MeshDiff = serde_json::from_slice(&bytes)
245        .map_err(|e| CoreError::GeomFailed(format!("invalid mesh JSON from helper: {e}")))?;
246    Ok(diff)
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn parses_ok_payload() {
255        let json = r#"{"status":"ok","file_a":"a.step","file_b":"b.step",
256            "a":{"volume":1000.0,"area":600.0,"solids":1,"shells":1,"faces":6,
257                 "bbox":{"min":[0,0,0],"max":[10,10,10]}},
258            "b":{"volume":1200.0,"area":640.0,"solids":1,"shells":1,"faces":6,"bbox":null},
259            "common":{"volume":950.0,"faces":6},
260            "added":{"volume":250.0,"faces":3},
261            "removed":{"volume":50.0,"faces":2},
262            "faces_topo":{"common":5,"added":1,"removed":1}}"#;
263        let d: GeomDiff = serde_json::from_str(json).unwrap();
264        assert!(d.is_ok());
265        assert_eq!(d.added.unwrap().volume, 250.0);
266        assert_eq!(d.b.as_ref().unwrap().shells, 1);
267        assert_eq!(d.faces_topo.unwrap().added, 1);
268        let bbox = d.a.unwrap().bbox.unwrap();
269        assert_eq!(bbox.size(), [10.0, 10.0, 10.0]);
270    }
271
272    #[test]
273    fn parses_error_payload() {
274        let json = r#"{"status":"error","error":"boolean Cut failed",
275            "file_a":"a.step","file_b":"b.step"}"#;
276        let d: GeomDiff = serde_json::from_str(json).unwrap();
277        assert!(!d.is_ok());
278        assert_eq!(d.error.as_deref(), Some("boolean Cut failed"));
279        assert!(d.a.is_none());
280    }
281
282    #[test]
283    fn missing_binary_is_reported() {
284        let err = diff_files_with(
285            Path::new("/nonexistent/cadvm-geom-xyz"),
286            Path::new("a.step"),
287            Path::new("b.step"),
288        )
289        .unwrap_err();
290        assert!(matches!(err, CoreError::GeomBinaryNotFound(_)));
291    }
292}