1#![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#[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 #[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 #[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 #[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 #[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 #[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 #[wasm_bindgen(js_name = "meshFallbackCount")]
135 #[must_use]
136 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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#[wasm_bindgen]
344impl BrepKernel {
345 #[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 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 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 #[test]
419 fn batch_fuse_simplify_flag_and_fallback_count() {
420 let mut k = BrepKernel::new();
421 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 #[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 #[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 #[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 #[test]
609 fn detect_coincident_faces_overlapping_boxes_returns_sd_pairs() {
610 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 #[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 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 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 #[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 #[test]
735 fn compound_cut_volume_decreases() {
736 let mut k = BrepKernel::new();
737 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}