Skip to main content

brepkit_wasm/bindings/
booleans.rs

1//! Boolean operation bindings.
2
3#![allow(clippy::missing_errors_doc)]
4
5use wasm_bindgen::prelude::*;
6
7use brepkit_operations::boolean::{
8    BooleanOp, BooleanOptions, boolean, boolean_with_options, mesh_fallback_count,
9};
10use brepkit_operations::compound_ops;
11
12use crate::handles::solid_id_to_u32;
13use crate::helpers::{build_triangle_mesh, panic_message, parse_boolean_op, triangle_mesh_to_js};
14use crate::kernel::BrepKernel;
15use crate::shapes::JsMesh;
16
17/// Serialise a slice of `CoincidentFacePair` values to a JSON array.
18///
19/// Shared by both the direct WASM binding (`detectCoincidentFaces`)
20/// and the batch dispatcher (`executeBatch` "detectCoincidentFaces"
21/// arm) so the JSON shape is guaranteed identical across the two
22/// paths — a field-name typo or boolean formatting drift in only one
23/// copy would otherwise be silently shipped to JS callers.
24///
25/// Visibility note: `pub(crate)` triggers `clippy::redundant_pub_crate`
26/// because `bindings` is a private module — the lint folds it to `pub`
27/// in this scope. We keep `pub(crate)` to make the cross-module-but-
28/// crate-internal sharing explicit.
29#[allow(clippy::redundant_pub_crate)]
30pub(crate) fn coincident_face_pairs_to_json(
31    pairs: &[brepkit_algo::diagnostic::CoincidentFacePair],
32) -> serde_json::Value {
33    let arr: Vec<serde_json::Value> = pairs
34        .iter()
35        .map(|p| {
36            serde_json::json!({
37                "faceA": crate::handles::face_id_to_u32(p.face_a),
38                "faceB": crate::handles::face_id_to_u32(p.face_b),
39                "sameOrientation": p.same_orientation,
40                "aabbOverlap": p.aabb_overlap,
41            })
42        })
43        .collect();
44    serde_json::Value::Array(arr)
45}
46
47#[wasm_bindgen]
48impl BrepKernel {
49    // ── Boolean operations ──────────────────────────────────────────
50
51    /// Fuse (union) two solids into one.
52    ///
53    /// Returns a new solid handle (`u32`).
54    ///
55    /// # Errors
56    ///
57    /// Returns an error if either solid handle is invalid or the operation
58    /// produces an empty or non-manifold result.
59    #[wasm_bindgen(js_name = "fuse")]
60    pub fn fuse(&mut self, a: u32, b: u32) -> Result<u32, JsError> {
61        let a_id = self.resolve_solid(a)?;
62        let b_id = self.resolve_solid(b)?;
63        let result = boolean(self.topo_mut(), BooleanOp::Fuse, a_id, b_id)?;
64        Ok(solid_id_to_u32(result))
65    }
66
67    /// Cut (subtract) solid `b` from solid `a`.
68    ///
69    /// Returns a new solid handle (`u32`).
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if either solid handle is invalid or the operation
74    /// produces an empty or non-manifold result.
75    #[wasm_bindgen(js_name = "cut")]
76    pub fn cut(&mut self, a: u32, b: u32) -> Result<u32, JsError> {
77        let a_id = self.resolve_solid(a)?;
78        let b_id = self.resolve_solid(b)?;
79        let result = boolean(self.topo_mut(), BooleanOp::Cut, a_id, b_id)?;
80        Ok(solid_id_to_u32(result))
81    }
82
83    /// Fuse (union) two solids with post-processing options.
84    ///
85    /// `simplify` merges co-surface face fragments after the boolean
86    /// (the `BooleanOptions.simplify` request from brepjs).
87    ///
88    /// # Errors
89    ///
90    /// Returns an error if either solid handle is invalid or the operation
91    /// produces an empty or non-manifold result.
92    #[wasm_bindgen(js_name = "fuseWithOptions")]
93    pub fn fuse_with_options(&mut self, a: u32, b: u32, simplify: bool) -> Result<u32, JsError> {
94        self.boolean_with_options_impl(BooleanOp::Fuse, a, b, simplify)
95    }
96
97    /// Cut (subtract) solid `b` from solid `a` with post-processing options.
98    ///
99    /// # Errors
100    ///
101    /// Returns an error if either solid handle is invalid or the operation
102    /// produces an empty or non-manifold result.
103    #[wasm_bindgen(js_name = "cutWithOptions")]
104    pub fn cut_with_options(&mut self, a: u32, b: u32, simplify: bool) -> Result<u32, JsError> {
105        self.boolean_with_options_impl(BooleanOp::Cut, a, b, simplify)
106    }
107
108    /// Intersect two solids with post-processing options.
109    ///
110    /// # Errors
111    ///
112    /// Returns an error if either solid handle is invalid or the operation
113    /// produces an empty or non-manifold result.
114    #[wasm_bindgen(js_name = "intersectWithOptions")]
115    pub fn intersect_with_options(
116        &mut self,
117        a: u32,
118        b: u32,
119        simplify: bool,
120    ) -> Result<u32, JsError> {
121        self.boolean_with_options_impl(BooleanOp::Intersect, a, b, simplify)
122    }
123
124    /// Number of boolean operations that have used the mesh (co-refinement)
125    /// fallback since module load.
126    ///
127    /// The counter is process-wide: it is shared across all `BrepKernel`
128    /// instances in the same wasm module and never resets. Snapshot it
129    /// before an operation chain and compare after — a relative check, so
130    /// the shared scope does not matter to single-threaded callers. If it
131    /// grew, the chain contains at least one approximate result (analytic
132    /// surface types lost, watertightness not guaranteed), and an export
133    /// pipeline can refuse the output.
134    #[wasm_bindgen(js_name = "meshFallbackCount")]
135    #[must_use]
136    // &self keeps this an instance method on the JS kernel object.
137    #[allow(clippy::cast_precision_loss, clippy::unused_self)]
138    pub fn mesh_fallback_count(&self) -> f64 {
139        mesh_fallback_count() as f64
140    }
141
142    /// Detect surface-level coincident face pairs between two solids
143    /// without performing a boolean operation.
144    ///
145    /// Useful for warning users about same-domain configurations
146    /// (face stacks, coaxial cylinders, concentric spheres) before a
147    /// boolean. Returns a JSON array string of objects:
148    /// `[{"faceA": <u32>, "faceB": <u32>, "sameOrientation": <bool>, "aabbOverlap": <bool>}, ...]`.
149    ///
150    /// `sameOrientation` is `true` when the surface normals point the
151    /// same way at corresponding parametric points (e.g., two coplanar
152    /// faces with the same `+z` normal). `aabbOverlap` filters pairs
153    /// that are same-domain on the surface but geometrically disjoint.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if either solid handle is invalid or any face /
158    /// edge / vertex lookup fails internally.
159    #[wasm_bindgen(js_name = "detectCoincidentFaces")]
160    pub fn detect_coincident_faces(&self, a: u32, b: u32) -> Result<String, JsError> {
161        let a_id = self.resolve_solid(a)?;
162        let b_id = self.resolve_solid(b)?;
163        let pairs = brepkit_algo::diagnostic::detect_coincident_faces(
164            self.topo(),
165            a_id,
166            b_id,
167            brepkit_math::tolerance::Tolerance::default(),
168        )
169        .map_err(|e| JsError::new(&format!("{e}")))?;
170        Ok(coincident_face_pairs_to_json(&pairs).to_string())
171    }
172
173    /// Fuse (union) many solids into one in a single call.
174    ///
175    /// Faster than a left-fold over `fuse`: overlapping solids are reduced
176    /// pairwise in a balanced tree while disjoint groups are merged directly
177    /// without a boolean.
178    ///
179    /// Returns a new solid handle (`u32`).
180    ///
181    /// # Errors
182    ///
183    /// Returns an error if any solid handle is invalid, the list is empty,
184    /// or a boolean operation produces an empty or non-manifold result.
185    #[wasm_bindgen(js_name = "fuseAll")]
186    pub fn fuse_all(&mut self, solid_handles: Vec<u32>) -> Result<u32, JsError> {
187        let solid_ids = solid_handles
188            .iter()
189            .map(|&h| self.resolve_solid(h))
190            .collect::<Result<Vec<_>, _>>()?;
191        let compound = self
192            .topo_mut()
193            .add_compound(brepkit_topology::compound::Compound::new(solid_ids));
194        let result = compound_ops::fuse_all(self.topo_mut(), compound)?;
195        Ok(solid_id_to_u32(result))
196    }
197
198    /// Intersect two solids, keeping only their common volume.
199    ///
200    /// Returns a new solid handle (`u32`).
201    ///
202    /// # Errors
203    ///
204    /// Returns an error if either solid handle is invalid or the operation
205    /// produces an empty result.
206    #[wasm_bindgen(js_name = "intersect")]
207    pub fn intersect_solids(&mut self, a: u32, b: u32) -> Result<u32, JsError> {
208        let a_id = self.resolve_solid(a)?;
209        let b_id = self.resolve_solid(b)?;
210        let result = boolean(self.topo_mut(), BooleanOp::Intersect, a_id, b_id)?;
211        Ok(solid_id_to_u32(result))
212    }
213
214    // ── Boolean operations with evolution tracking ─────────────────
215
216    /// Fuse (union) two solids and return evolution tracking data.
217    ///
218    /// Returns a JSON string: `{"solid": <u32>, "evolution": {...}}`.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if either solid handle is invalid or the operation
223    /// produces an empty or non-manifold result.
224    #[wasm_bindgen(js_name = "fuseWithEvolution")]
225    pub fn fuse_with_evolution(&mut self, a: u32, b: u32) -> Result<JsValue, JsError> {
226        let a_id = self.resolve_solid(a)?;
227        let b_id = self.resolve_solid(b)?;
228        let (result, evo) = brepkit_operations::boolean::boolean_with_evolution(
229            self.topo_mut(),
230            BooleanOp::Fuse,
231            a_id,
232            b_id,
233        )?;
234        let json = format!(
235            "{{\"solid\":{},\"evolution\":{}}}",
236            solid_id_to_u32(result),
237            evo.to_json()
238        );
239        Ok(JsValue::from_str(&json))
240    }
241
242    /// Cut (subtract) solid `b` from solid `a` and return evolution tracking data.
243    ///
244    /// Returns a JSON string: `{"solid": <u32>, "evolution": {...}}`.
245    ///
246    /// # Errors
247    ///
248    /// Returns an error if either solid handle is invalid or the operation
249    /// produces an empty or non-manifold result.
250    #[wasm_bindgen(js_name = "cutWithEvolution")]
251    pub fn cut_with_evolution(&mut self, a: u32, b: u32) -> Result<JsValue, JsError> {
252        let a_id = self.resolve_solid(a)?;
253        let b_id = self.resolve_solid(b)?;
254        let (result, evo) = brepkit_operations::boolean::boolean_with_evolution(
255            self.topo_mut(),
256            BooleanOp::Cut,
257            a_id,
258            b_id,
259        )?;
260        let json = format!(
261            "{{\"solid\":{},\"evolution\":{}}}",
262            solid_id_to_u32(result),
263            evo.to_json()
264        );
265        Ok(JsValue::from_str(&json))
266    }
267
268    /// Intersect two solids and return evolution tracking data.
269    ///
270    /// Returns a JSON string: `{"solid": <u32>, "evolution": {...}}`.
271    ///
272    /// # Errors
273    ///
274    /// Returns an error if either solid handle is invalid or the operation
275    /// produces an empty result.
276    #[wasm_bindgen(js_name = "intersectWithEvolution")]
277    pub fn intersect_with_evolution(&mut self, a: u32, b: u32) -> Result<JsValue, JsError> {
278        let a_id = self.resolve_solid(a)?;
279        let b_id = self.resolve_solid(b)?;
280        let (result, evo) = brepkit_operations::boolean::boolean_with_evolution(
281            self.topo_mut(),
282            BooleanOp::Intersect,
283            a_id,
284            b_id,
285        )?;
286        let json = format!(
287            "{{\"solid\":{},\"evolution\":{}}}",
288            solid_id_to_u32(result),
289            evo.to_json()
290        );
291        Ok(JsValue::from_str(&json))
292    }
293
294    /// Perform a mesh boolean on raw triangle data.
295    ///
296    /// Returns a `JsMesh` with the result.
297    #[wasm_bindgen(js_name = "meshBoolean")]
298    #[allow(
299        clippy::needless_pass_by_value,
300        clippy::too_many_arguments,
301        clippy::unused_self
302    )]
303    pub fn mesh_boolean(
304        &self,
305        positions_a: Vec<f64>,
306        indices_a: Vec<u32>,
307        positions_b: Vec<f64>,
308        indices_b: Vec<u32>,
309        op: &str,
310        tolerance: f64,
311    ) -> Result<JsMesh, JsError> {
312        let mesh_a = build_triangle_mesh(&positions_a, &indices_a)?;
313        let mesh_b = build_triangle_mesh(&positions_b, &indices_b)?;
314        let bool_op = parse_boolean_op(op)?;
315        let result =
316            brepkit_operations::mesh_boolean::mesh_boolean(&mesh_a, &mesh_b, bool_op, tolerance)?;
317        Ok(triangle_mesh_to_js(&result.mesh))
318    }
319}
320
321impl BrepKernel {
322    fn boolean_with_options_impl(
323        &mut self,
324        op: BooleanOp,
325        a: u32,
326        b: u32,
327        simplify: bool,
328    ) -> Result<u32, JsError> {
329        let a_id = self.resolve_solid(a)?;
330        let b_id = self.resolve_solid(b)?;
331        let opts = BooleanOptions {
332            unify_faces: simplify,
333            ..Default::default()
334        };
335        let result = boolean_with_options(self.topo_mut(), op, a_id, b_id, opts)?;
336        Ok(solid_id_to_u32(result))
337    }
338}
339
340// Separate impl block: `compound_cut` uses manual `catch_unwind` for panic
341// safety — any panic that unwinds across the wasm-bindgen boundary leaves
342// its internal RefCell borrowed, breaking all subsequent JS calls.
343#[wasm_bindgen]
344impl BrepKernel {
345    /// Cut a target solid by multiple tool solids in a single pass.
346    ///
347    /// This is more efficient than sequential `cut()` calls when many tools
348    /// are applied to the same target — it avoids re-processing unchanged
349    /// faces at each step.
350    ///
351    /// `tool_ids` is a JS `Uint32Array` or array of solid handles.
352    ///
353    /// # Errors
354    ///
355    /// Returns an error if any handle is invalid or the operation fails.
356    #[wasm_bindgen(js_name = "compoundCut")]
357    pub fn compound_cut(&mut self, target: u32, tool_ids: &[u32]) -> Result<u32, JsError> {
358        if self.poisoned {
359            return Err(JsError::new(
360                "Kernel poisoned after panic. Create a new BrepKernel instance.",
361            ));
362        }
363        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
364            let target_id = self.resolve_solid(target)?;
365            let tools: Vec<brepkit_topology::solid::SolidId> = tool_ids
366                .iter()
367                .map(|&h| self.resolve_solid(h))
368                .collect::<Result<Vec<_>, _>>()?;
369            let result = brepkit_operations::boolean::compound_cut(
370                self.topo_mut(),
371                target_id,
372                &tools,
373                brepkit_operations::boolean::BooleanOptions::default(),
374            )?;
375            Ok(solid_id_to_u32(result))
376        }));
377        match result {
378            Ok(inner) => inner.map_err(|e: crate::error::WasmError| JsError::new(&e.to_string())),
379            Err(panic_info) => {
380                self.poisoned = true;
381                Err(JsError::new(&panic_message(&panic_info, "compoundCut")))
382            }
383        }
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    #![allow(clippy::unwrap_used, clippy::expect_used)]
390
391    use crate::kernel::BrepKernel;
392
393    /// Helper: parse batch result and check a single op returned ok or error.
394    fn batch_has_ok(result: &str, idx: usize) -> bool {
395        let parsed: serde_json::Value = serde_json::from_str(result).unwrap();
396        parsed[idx]["ok"].is_number()
397    }
398
399    fn batch_has_error(result: &str, idx: usize) -> bool {
400        let parsed: serde_json::Value = serde_json::from_str(result).unwrap();
401        parsed[idx]["error"].is_string()
402    }
403
404    /// Create two overlapping boxes via batch, return the raw JSON result.
405    fn two_boxes_batch() -> (BrepKernel, String) {
406        let mut k = BrepKernel::new();
407        let r = k.execute_batch(
408            r#"[
409                {"op": "makeBox", "args": {"width": 2, "height": 2, "depth": 2}},
410                {"op": "makeBox", "args": {"width": 1, "height": 1, "depth": 1}}
411            ]"#,
412        );
413        (k, r)
414    }
415
416    // ── fuse ─────────────────────────────────────────────────────────
417
418    #[test]
419    fn batch_fuse_simplify_flag_and_fallback_count() {
420        let mut k = BrepKernel::new();
421        // Overlapping boxes fuse analytically; the optional simplify flag
422        // must be accepted, and meshFallbackCount must report a number
423        // that does not grow across a clean chain.
424        let r = k.execute_batch(
425            r#"[
426                {"op": "meshFallbackCount", "args": {}},
427                {"op": "makeBox", "args": {"width": 10, "height": 10, "depth": 10}},
428                {"op": "makeBox", "args": {"width": 10, "height": 10, "depth": 10}},
429                {"op": "transform", "args": {"solid": 1, "matrix": [1,0,0,5, 0,1,0,5, 0,0,1,5, 0,0,0,1]}},
430                {"op": "fuse", "args": {"solidA": 0, "solidB": 1, "simplify": true}},
431                {"op": "volume", "args": {"solid": 2}},
432                {"op": "meshFallbackCount", "args": {}}
433            ]"#,
434        );
435        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
436        assert!(batch_has_ok(&r, 4), "fuse with simplify must succeed: {r}");
437        let vol = parsed[5]["ok"].as_f64().unwrap();
438        assert!((vol - 1875.0).abs() < 5.0, "union volume ~1875, got {vol}");
439        let before = parsed[0]["ok"].as_f64().unwrap();
440        let after = parsed[6]["ok"].as_f64().unwrap();
441        assert!(
442            (after - before).abs() < 0.5,
443            "clean chain must not grow the fallback count: {before} -> {after}"
444        );
445    }
446
447    #[test]
448    fn fuse_two_boxes_returns_valid_handle() {
449        let (mut k, setup) = two_boxes_batch();
450        let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
451        let a = parsed[0]["ok"].as_u64().unwrap();
452        let b = parsed[1]["ok"].as_u64().unwrap();
453        let r = k.execute_batch(&format!(
454            r#"[{{"op": "fuse", "args": {{"solidA": {a}, "solidB": {b}}}}}]"#
455        ));
456        assert!(batch_has_ok(&r, 0), "fuse must return ok: {r}");
457    }
458
459    #[test]
460    fn fuse_invalid_handle_a_errors() {
461        let (mut k, setup) = two_boxes_batch();
462        let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
463        let b = parsed[1]["ok"].as_u64().unwrap();
464        let r = k.execute_batch(&format!(
465            r#"[{{"op": "fuse", "args": {{"solidA": 9999, "solidB": {b}}}}}]"#
466        ));
467        assert!(batch_has_error(&r, 0));
468    }
469
470    #[test]
471    fn fuse_invalid_handle_b_errors() {
472        let (mut k, setup) = two_boxes_batch();
473        let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
474        let a = parsed[0]["ok"].as_u64().unwrap();
475        let r = k.execute_batch(&format!(
476            r#"[{{"op": "fuse", "args": {{"solidA": {a}, "solidB": 9999}}}}]"#
477        ));
478        assert!(batch_has_error(&r, 0));
479    }
480
481    // ── cut ──────────────────────────────────────────────────────────
482
483    #[test]
484    fn cut_two_boxes_returns_valid_handle() {
485        let (mut k, setup) = two_boxes_batch();
486        let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
487        let a = parsed[0]["ok"].as_u64().unwrap();
488        let b = parsed[1]["ok"].as_u64().unwrap();
489        let r = k.execute_batch(&format!(
490            r#"[{{"op": "cut", "args": {{"solidA": {a}, "solidB": {b}}}}}]"#
491        ));
492        assert!(batch_has_ok(&r, 0), "cut must return ok: {r}");
493    }
494
495    #[test]
496    fn cut_invalid_target_errors() {
497        let (mut k, setup) = two_boxes_batch();
498        let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
499        let b = parsed[1]["ok"].as_u64().unwrap();
500        let r = k.execute_batch(&format!(
501            r#"[{{"op": "cut", "args": {{"solidA": 9999, "solidB": {b}}}}}]"#
502        ));
503        assert!(batch_has_error(&r, 0));
504    }
505
506    #[test]
507    fn cut_invalid_tool_errors() {
508        let (mut k, setup) = two_boxes_batch();
509        let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
510        let a = parsed[0]["ok"].as_u64().unwrap();
511        let r = k.execute_batch(&format!(
512            r#"[{{"op": "cut", "args": {{"solidA": {a}, "solidB": 9999}}}}]"#
513        ));
514        assert!(batch_has_error(&r, 0));
515    }
516
517    // ── intersect ────────────────────────────────────────────────────
518
519    #[test]
520    fn intersect_two_boxes_returns_valid_handle() {
521        let (mut k, setup) = two_boxes_batch();
522        let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
523        let a = parsed[0]["ok"].as_u64().unwrap();
524        let b = parsed[1]["ok"].as_u64().unwrap();
525        let r = k.execute_batch(&format!(
526            r#"[{{"op": "intersect", "args": {{"solidA": {a}, "solidB": {b}}}}}]"#
527        ));
528        assert!(batch_has_ok(&r, 0), "intersect must return ok: {r}");
529    }
530
531    #[test]
532    fn intersect_invalid_handle_errors() {
533        let (mut k, setup) = two_boxes_batch();
534        let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
535        let a = parsed[0]["ok"].as_u64().unwrap();
536        let r = k.execute_batch(&format!(
537            r#"[{{"op": "intersect", "args": {{"solidA": {a}, "solidB": 9999}}}}]"#
538        ));
539        assert!(batch_has_error(&r, 0));
540    }
541
542    // ── compound_cut ─────────────────────────────────────────────────
543
544    #[test]
545    fn compound_cut_single_tool() {
546        let (mut k, setup) = two_boxes_batch();
547        let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
548        let a = parsed[0]["ok"].as_u64().unwrap();
549        let b = parsed[1]["ok"].as_u64().unwrap();
550        let r = k.execute_batch(&format!(
551            r#"[{{"op": "compoundCut", "args": {{"target": {a}, "tools": [{b}]}}}}]"#
552        ));
553        assert!(batch_has_ok(&r, 0), "compound_cut must return ok: {r}");
554    }
555
556    #[test]
557    fn compound_cut_multiple_tools() {
558        let mut k = BrepKernel::new();
559        let r = k.execute_batch(
560            r#"[
561                {"op": "makeBox", "args": {"width": 4, "height": 4, "depth": 4}},
562                {"op": "makeBox", "args": {"width": 1, "height": 1, "depth": 1}},
563                {"op": "makeBox", "args": {"width": 0.5, "height": 0.5, "depth": 0.5}},
564                {"op": "compoundCut", "args": {"target": 0, "tools": [1, 2]}}
565            ]"#,
566        );
567        assert!(
568            batch_has_ok(&r, 3),
569            "compound_cut with two tools must return ok: {r}"
570        );
571    }
572
573    #[test]
574    fn compound_cut_invalid_target_errors() {
575        let (mut k, setup) = two_boxes_batch();
576        let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
577        let b = parsed[1]["ok"].as_u64().unwrap();
578        let r = k.execute_batch(&format!(
579            r#"[{{"op": "compoundCut", "args": {{"target": 9999, "tools": [{b}]}}}}]"#
580        ));
581        assert!(batch_has_error(&r, 0));
582    }
583
584    #[test]
585    fn compound_cut_invalid_tool_errors() {
586        let (mut k, setup) = two_boxes_batch();
587        let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
588        let a = parsed[0]["ok"].as_u64().unwrap();
589        let r = k.execute_batch(&format!(
590            r#"[{{"op": "compoundCut", "args": {{"target": {a}, "tools": [9999]}}}}]"#
591        ));
592        assert!(batch_has_error(&r, 0));
593    }
594
595    #[test]
596    fn compound_cut_empty_tool_list_is_identity() {
597        let (mut k, setup) = two_boxes_batch();
598        let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
599        let a = parsed[0]["ok"].as_u64().unwrap();
600        let r = k.execute_batch(&format!(
601            r#"[{{"op": "compoundCut", "args": {{"target": {a}, "tools": []}}}}]"#
602        ));
603        assert!(batch_has_ok(&r, 0));
604    }
605
606    // ── detectCoincidentFaces ────────────────────────────────────────
607
608    #[test]
609    fn detect_coincident_faces_overlapping_boxes_returns_sd_pairs() {
610        // `two_boxes_batch()` creates two axis-aligned boxes (2×2×2 and
611        // 1×1×1) both at the origin — the smaller is fully contained in
612        // the larger. Each pair of axis-aligned faces shares a parallel
613        // plane normal, so the SD detector reports several same-domain
614        // pairs. We verify (a) the JSON shape and (b) at least one pair
615        // is reported with a valid `aabbOverlap` flag.
616        let (mut k, setup) = two_boxes_batch();
617        let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
618        let a = parsed[0]["ok"].as_u64().unwrap();
619        let b = parsed[1]["ok"].as_u64().unwrap();
620        let r = k.execute_batch(&format!(
621            r#"[{{"op": "detectCoincidentFaces", "args": {{"solidA": {a}, "solidB": {b}}}}}]"#
622        ));
623        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
624        let arr = parsed[0]["ok"].as_array().unwrap();
625        assert!(!arr.is_empty(), "overlapping boxes produce SD pairs: {r}");
626        for pair in arr {
627            assert!(pair["faceA"].is_u64());
628            assert!(pair["faceB"].is_u64());
629            assert!(pair["sameOrientation"].is_boolean());
630            assert!(pair["aabbOverlap"].is_boolean());
631        }
632    }
633
634    #[test]
635    fn detect_coincident_faces_invalid_handle_errors() {
636        let (mut k, setup) = two_boxes_batch();
637        let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
638        let a = parsed[0]["ok"].as_u64().unwrap();
639        let r = k.execute_batch(&format!(
640            r#"[{{"op": "detectCoincidentFaces", "args": {{"solidA": {a}, "solidB": 9999}}}}]"#
641        ));
642        assert!(batch_has_error(&r, 0));
643    }
644
645    // ── mesh_boolean ─────────────────────────────────────────────────
646    // mesh_boolean is not in the batch dispatcher, but its happy-path
647    // works on native (JsError is only constructed on the error path).
648    // For error paths, we test the internal operations layer directly.
649
650    #[test]
651    fn mesh_boolean_fuse_returns_non_empty_mesh() {
652        let k = BrepKernel::new();
653        #[rustfmt::skip]
654        let positions = vec![
655            0.0, 0.0, 0.0,
656            1.0, 0.0, 0.0,
657            0.0, 1.0, 0.0,
658            0.0, 0.0, 1.0,
659        ];
660        let indices = vec![0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3];
661        let mesh = k
662            .mesh_boolean(
663                positions.clone(),
664                indices.clone(),
665                positions,
666                indices,
667                "fuse",
668                1e-7,
669            )
670            .unwrap();
671        assert!(
672            !mesh.positions().is_empty(),
673            "fused mesh must have vertices"
674        );
675        assert!(!mesh.indices().is_empty(), "fused mesh must have triangles");
676        assert_eq!(mesh.positions().len() % 3, 0);
677        assert_eq!(mesh.indices().len() % 3, 0);
678    }
679
680    #[test]
681    fn mesh_boolean_unknown_op_is_not_valid() {
682        // Verify the operation string validation logic without calling
683        // JsError-returning helpers (JsError panics on non-wasm).
684        let valid = [
685            "fuse",
686            "union",
687            "cut",
688            "difference",
689            "intersect",
690            "intersection",
691        ];
692        assert!(
693            !valid.contains(&"explode"),
694            "explode should not be a valid op"
695        );
696    }
697
698    #[test]
699    fn mesh_boolean_bad_positions_length_is_invalid() {
700        // Verify the validation condition directly: positions must be multiple of 3.
701        let bad_len = 2;
702        assert_ne!(
703            bad_len % 3,
704            0,
705            "length 2 should fail the multiple-of-3 check"
706        );
707    }
708
709    // ── boolean volume check ─────────────────────────────────────────
710
711    #[test]
712    fn cut_reduces_volume() {
713        let mut k = BrepKernel::new();
714        let r = k.execute_batch(
715            r#"[
716                {"op": "makeBox", "args": {"width": 2, "height": 2, "depth": 2}},
717                {"op": "makeBox", "args": {"width": 1, "height": 1, "depth": 1}},
718                {"op": "volume", "args": {"solid": 0}},
719                {"op": "cut", "args": {"solidA": 0, "solidB": 1}},
720                {"op": "volume", "args": {"solid": 2}}
721            ]"#,
722        );
723        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
724        let vol_before = parsed[2]["ok"].as_f64().unwrap();
725        let vol_after = parsed[4]["ok"].as_f64().unwrap();
726        assert!(
727            vol_after < vol_before,
728            "cut must reduce volume: {vol_before} -> {vol_after}"
729        );
730    }
731
732    // ── compound_cut volume regression ───────────────────────────────
733
734    #[test]
735    fn compound_cut_volume_decreases() {
736        let mut k = BrepKernel::new();
737        // Target: 10x10x10 box at origin. Tool: 1x1x1 box at origin.
738        // The tool overlaps one corner, so volume decreases.
739        let r = k.execute_batch(
740            r#"[
741                {"op": "makeBox", "args": {"width": 10, "height": 10, "depth": 10}},
742                {"op": "makeBox", "args": {"width": 1, "height": 1, "depth": 1}},
743                {"op": "volume", "args": {"solid": 0}},
744                {"op": "compoundCut", "args": {"target": 0, "tools": [1]}},
745                {"op": "volume", "args": {"solid": 2}}
746            ]"#,
747        );
748        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
749        let vol_before = parsed[2]["ok"].as_f64().unwrap();
750        assert!(batch_has_ok(&r, 3), "compoundCut must succeed: {r}");
751        let vol_after = parsed[4]["ok"].as_f64().unwrap();
752        assert!(
753            vol_after < vol_before && vol_after > 0.0,
754            "compound_cut must reduce volume: {vol_before} -> {vol_after}"
755        );
756    }
757}