1use std::path::{Path, PathBuf};
12use std::process::Command;
13
14use serde::{Deserialize, Serialize};
15
16use crate::error::{CoreError, Result};
17
18pub const ENV_GEOM_BIN: &str = "CADVM_GEOM_BIN";
20
21#[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 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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53pub struct PieceMetrics {
54 pub volume: f64,
55 pub faces: u64,
56}
57
58#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct GeomDiff {
76 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 #[serde(default)]
94 pub faces_topo: Option<FaceTopo>,
95}
96
97impl GeomDiff {
98 pub fn is_ok(&self) -> bool {
100 self.status == "ok"
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct Mesh {
108 pub positions: Vec<f32>,
109 pub normals: Vec<f32>,
110}
111
112impl Mesh {
113 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127pub struct MeshLayers {
128 pub unchanged: Mesh,
129 pub added: Mesh,
130 pub removed: Mesh,
131}
132
133#[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 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 pub fn to_json(&self) -> String {
159 serde_json::to_string(self).unwrap_or_default()
160 }
161}
162
163pub 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
171pub fn diff_files(a: &Path, b: &Path) -> Result<GeomDiff> {
173 diff_files_with(&binary_path(), a, b)
174}
175
176pub 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
201pub fn mesh_files(a: &Path, b: &Path, out_json: &Path) -> Result<MeshDiff> {
204 mesh_files_with(&binary_path(), a, b, out_json)
205}
206
207pub 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 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}