#![allow(clippy::missing_errors_doc)]
use wasm_bindgen::prelude::*;
use brepkit_operations::boolean::{
BooleanOp, BooleanOptions, boolean, boolean_with_options, mesh_fallback_count,
};
use brepkit_operations::compound_ops;
use crate::handles::solid_id_to_u32;
use crate::helpers::{build_triangle_mesh, panic_message, parse_boolean_op, triangle_mesh_to_js};
use crate::kernel::BrepKernel;
use crate::shapes::JsMesh;
#[allow(clippy::redundant_pub_crate)]
pub(crate) fn coincident_face_pairs_to_json(
pairs: &[brepkit_algo::diagnostic::CoincidentFacePair],
) -> serde_json::Value {
let arr: Vec<serde_json::Value> = pairs
.iter()
.map(|p| {
serde_json::json!({
"faceA": crate::handles::face_id_to_u32(p.face_a),
"faceB": crate::handles::face_id_to_u32(p.face_b),
"sameOrientation": p.same_orientation,
"aabbOverlap": p.aabb_overlap,
})
})
.collect();
serde_json::Value::Array(arr)
}
#[wasm_bindgen]
impl BrepKernel {
#[wasm_bindgen(js_name = "fuse")]
pub fn fuse(&mut self, a: u32, b: u32) -> Result<u32, JsError> {
let a_id = self.resolve_solid(a)?;
let b_id = self.resolve_solid(b)?;
let result = boolean(self.topo_mut(), BooleanOp::Fuse, a_id, b_id)?;
Ok(solid_id_to_u32(result))
}
#[wasm_bindgen(js_name = "cut")]
pub fn cut(&mut self, a: u32, b: u32) -> Result<u32, JsError> {
let a_id = self.resolve_solid(a)?;
let b_id = self.resolve_solid(b)?;
let result = boolean(self.topo_mut(), BooleanOp::Cut, a_id, b_id)?;
Ok(solid_id_to_u32(result))
}
#[wasm_bindgen(js_name = "fuseWithOptions")]
pub fn fuse_with_options(&mut self, a: u32, b: u32, simplify: bool) -> Result<u32, JsError> {
self.boolean_with_options_impl(BooleanOp::Fuse, a, b, simplify)
}
#[wasm_bindgen(js_name = "cutWithOptions")]
pub fn cut_with_options(&mut self, a: u32, b: u32, simplify: bool) -> Result<u32, JsError> {
self.boolean_with_options_impl(BooleanOp::Cut, a, b, simplify)
}
#[wasm_bindgen(js_name = "intersectWithOptions")]
pub fn intersect_with_options(
&mut self,
a: u32,
b: u32,
simplify: bool,
) -> Result<u32, JsError> {
self.boolean_with_options_impl(BooleanOp::Intersect, a, b, simplify)
}
#[wasm_bindgen(js_name = "meshFallbackCount")]
#[must_use]
#[allow(clippy::cast_precision_loss, clippy::unused_self)]
pub fn mesh_fallback_count(&self) -> f64 {
mesh_fallback_count() as f64
}
#[wasm_bindgen(js_name = "detectCoincidentFaces")]
pub fn detect_coincident_faces(&self, a: u32, b: u32) -> Result<String, JsError> {
let a_id = self.resolve_solid(a)?;
let b_id = self.resolve_solid(b)?;
let pairs = brepkit_algo::diagnostic::detect_coincident_faces(
self.topo(),
a_id,
b_id,
brepkit_math::tolerance::Tolerance::default(),
)
.map_err(|e| JsError::new(&format!("{e}")))?;
Ok(coincident_face_pairs_to_json(&pairs).to_string())
}
#[wasm_bindgen(js_name = "fuseAll")]
pub fn fuse_all(&mut self, solid_handles: Vec<u32>) -> Result<u32, JsError> {
let solid_ids = solid_handles
.iter()
.map(|&h| self.resolve_solid(h))
.collect::<Result<Vec<_>, _>>()?;
let compound = self
.topo_mut()
.add_compound(brepkit_topology::compound::Compound::new(solid_ids));
let result = compound_ops::fuse_all(self.topo_mut(), compound)?;
Ok(solid_id_to_u32(result))
}
#[wasm_bindgen(js_name = "intersect")]
pub fn intersect_solids(&mut self, a: u32, b: u32) -> Result<u32, JsError> {
let a_id = self.resolve_solid(a)?;
let b_id = self.resolve_solid(b)?;
let result = boolean(self.topo_mut(), BooleanOp::Intersect, a_id, b_id)?;
Ok(solid_id_to_u32(result))
}
#[wasm_bindgen(js_name = "fuseWithEvolution")]
pub fn fuse_with_evolution(&mut self, a: u32, b: u32) -> Result<JsValue, JsError> {
let a_id = self.resolve_solid(a)?;
let b_id = self.resolve_solid(b)?;
let (result, evo) = brepkit_operations::boolean::boolean_with_evolution(
self.topo_mut(),
BooleanOp::Fuse,
a_id,
b_id,
)?;
let json = format!(
"{{\"solid\":{},\"evolution\":{}}}",
solid_id_to_u32(result),
evo.to_json()
);
Ok(JsValue::from_str(&json))
}
#[wasm_bindgen(js_name = "cutWithEvolution")]
pub fn cut_with_evolution(&mut self, a: u32, b: u32) -> Result<JsValue, JsError> {
let a_id = self.resolve_solid(a)?;
let b_id = self.resolve_solid(b)?;
let (result, evo) = brepkit_operations::boolean::boolean_with_evolution(
self.topo_mut(),
BooleanOp::Cut,
a_id,
b_id,
)?;
let json = format!(
"{{\"solid\":{},\"evolution\":{}}}",
solid_id_to_u32(result),
evo.to_json()
);
Ok(JsValue::from_str(&json))
}
#[wasm_bindgen(js_name = "intersectWithEvolution")]
pub fn intersect_with_evolution(&mut self, a: u32, b: u32) -> Result<JsValue, JsError> {
let a_id = self.resolve_solid(a)?;
let b_id = self.resolve_solid(b)?;
let (result, evo) = brepkit_operations::boolean::boolean_with_evolution(
self.topo_mut(),
BooleanOp::Intersect,
a_id,
b_id,
)?;
let json = format!(
"{{\"solid\":{},\"evolution\":{}}}",
solid_id_to_u32(result),
evo.to_json()
);
Ok(JsValue::from_str(&json))
}
#[wasm_bindgen(js_name = "meshBoolean")]
#[allow(
clippy::needless_pass_by_value,
clippy::too_many_arguments,
clippy::unused_self
)]
pub fn mesh_boolean(
&self,
positions_a: Vec<f64>,
indices_a: Vec<u32>,
positions_b: Vec<f64>,
indices_b: Vec<u32>,
op: &str,
tolerance: f64,
) -> Result<JsMesh, JsError> {
let mesh_a = build_triangle_mesh(&positions_a, &indices_a)?;
let mesh_b = build_triangle_mesh(&positions_b, &indices_b)?;
let bool_op = parse_boolean_op(op)?;
let result =
brepkit_operations::mesh_boolean::mesh_boolean(&mesh_a, &mesh_b, bool_op, tolerance)?;
Ok(triangle_mesh_to_js(&result.mesh))
}
}
impl BrepKernel {
fn boolean_with_options_impl(
&mut self,
op: BooleanOp,
a: u32,
b: u32,
simplify: bool,
) -> Result<u32, JsError> {
let a_id = self.resolve_solid(a)?;
let b_id = self.resolve_solid(b)?;
let opts = BooleanOptions {
unify_faces: simplify,
..Default::default()
};
let result = boolean_with_options(self.topo_mut(), op, a_id, b_id, opts)?;
Ok(solid_id_to_u32(result))
}
}
#[wasm_bindgen]
impl BrepKernel {
#[wasm_bindgen(js_name = "compoundCut")]
pub fn compound_cut(&mut self, target: u32, tool_ids: &[u32]) -> Result<u32, JsError> {
if self.poisoned {
return Err(JsError::new(
"Kernel poisoned after panic. Create a new BrepKernel instance.",
));
}
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let target_id = self.resolve_solid(target)?;
let tools: Vec<brepkit_topology::solid::SolidId> = tool_ids
.iter()
.map(|&h| self.resolve_solid(h))
.collect::<Result<Vec<_>, _>>()?;
let result = brepkit_operations::boolean::compound_cut(
self.topo_mut(),
target_id,
&tools,
brepkit_operations::boolean::BooleanOptions::default(),
)?;
Ok(solid_id_to_u32(result))
}));
match result {
Ok(inner) => inner.map_err(|e: crate::error::WasmError| JsError::new(&e.to_string())),
Err(panic_info) => {
self.poisoned = true;
Err(JsError::new(&panic_message(&panic_info, "compoundCut")))
}
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use crate::kernel::BrepKernel;
fn batch_has_ok(result: &str, idx: usize) -> bool {
let parsed: serde_json::Value = serde_json::from_str(result).unwrap();
parsed[idx]["ok"].is_number()
}
fn batch_has_error(result: &str, idx: usize) -> bool {
let parsed: serde_json::Value = serde_json::from_str(result).unwrap();
parsed[idx]["error"].is_string()
}
fn two_boxes_batch() -> (BrepKernel, String) {
let mut k = BrepKernel::new();
let r = k.execute_batch(
r#"[
{"op": "makeBox", "args": {"width": 2, "height": 2, "depth": 2}},
{"op": "makeBox", "args": {"width": 1, "height": 1, "depth": 1}}
]"#,
);
(k, r)
}
#[test]
fn batch_fuse_simplify_flag_and_fallback_count() {
let mut k = BrepKernel::new();
let r = k.execute_batch(
r#"[
{"op": "meshFallbackCount", "args": {}},
{"op": "makeBox", "args": {"width": 10, "height": 10, "depth": 10}},
{"op": "makeBox", "args": {"width": 10, "height": 10, "depth": 10}},
{"op": "transform", "args": {"solid": 1, "matrix": [1,0,0,5, 0,1,0,5, 0,0,1,5, 0,0,0,1]}},
{"op": "fuse", "args": {"solidA": 0, "solidB": 1, "simplify": true}},
{"op": "volume", "args": {"solid": 2}},
{"op": "meshFallbackCount", "args": {}}
]"#,
);
let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
assert!(batch_has_ok(&r, 4), "fuse with simplify must succeed: {r}");
let vol = parsed[5]["ok"].as_f64().unwrap();
assert!((vol - 1875.0).abs() < 5.0, "union volume ~1875, got {vol}");
let before = parsed[0]["ok"].as_f64().unwrap();
let after = parsed[6]["ok"].as_f64().unwrap();
assert!(
(after - before).abs() < 0.5,
"clean chain must not grow the fallback count: {before} -> {after}"
);
}
#[test]
fn fuse_two_boxes_returns_valid_handle() {
let (mut k, setup) = two_boxes_batch();
let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
let a = parsed[0]["ok"].as_u64().unwrap();
let b = parsed[1]["ok"].as_u64().unwrap();
let r = k.execute_batch(&format!(
r#"[{{"op": "fuse", "args": {{"solidA": {a}, "solidB": {b}}}}}]"#
));
assert!(batch_has_ok(&r, 0), "fuse must return ok: {r}");
}
#[test]
fn fuse_invalid_handle_a_errors() {
let (mut k, setup) = two_boxes_batch();
let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
let b = parsed[1]["ok"].as_u64().unwrap();
let r = k.execute_batch(&format!(
r#"[{{"op": "fuse", "args": {{"solidA": 9999, "solidB": {b}}}}}]"#
));
assert!(batch_has_error(&r, 0));
}
#[test]
fn fuse_invalid_handle_b_errors() {
let (mut k, setup) = two_boxes_batch();
let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
let a = parsed[0]["ok"].as_u64().unwrap();
let r = k.execute_batch(&format!(
r#"[{{"op": "fuse", "args": {{"solidA": {a}, "solidB": 9999}}}}]"#
));
assert!(batch_has_error(&r, 0));
}
#[test]
fn cut_two_boxes_returns_valid_handle() {
let (mut k, setup) = two_boxes_batch();
let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
let a = parsed[0]["ok"].as_u64().unwrap();
let b = parsed[1]["ok"].as_u64().unwrap();
let r = k.execute_batch(&format!(
r#"[{{"op": "cut", "args": {{"solidA": {a}, "solidB": {b}}}}}]"#
));
assert!(batch_has_ok(&r, 0), "cut must return ok: {r}");
}
#[test]
fn cut_invalid_target_errors() {
let (mut k, setup) = two_boxes_batch();
let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
let b = parsed[1]["ok"].as_u64().unwrap();
let r = k.execute_batch(&format!(
r#"[{{"op": "cut", "args": {{"solidA": 9999, "solidB": {b}}}}}]"#
));
assert!(batch_has_error(&r, 0));
}
#[test]
fn cut_invalid_tool_errors() {
let (mut k, setup) = two_boxes_batch();
let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
let a = parsed[0]["ok"].as_u64().unwrap();
let r = k.execute_batch(&format!(
r#"[{{"op": "cut", "args": {{"solidA": {a}, "solidB": 9999}}}}]"#
));
assert!(batch_has_error(&r, 0));
}
#[test]
fn intersect_two_boxes_returns_valid_handle() {
let (mut k, setup) = two_boxes_batch();
let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
let a = parsed[0]["ok"].as_u64().unwrap();
let b = parsed[1]["ok"].as_u64().unwrap();
let r = k.execute_batch(&format!(
r#"[{{"op": "intersect", "args": {{"solidA": {a}, "solidB": {b}}}}}]"#
));
assert!(batch_has_ok(&r, 0), "intersect must return ok: {r}");
}
#[test]
fn intersect_invalid_handle_errors() {
let (mut k, setup) = two_boxes_batch();
let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
let a = parsed[0]["ok"].as_u64().unwrap();
let r = k.execute_batch(&format!(
r#"[{{"op": "intersect", "args": {{"solidA": {a}, "solidB": 9999}}}}]"#
));
assert!(batch_has_error(&r, 0));
}
#[test]
fn compound_cut_single_tool() {
let (mut k, setup) = two_boxes_batch();
let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
let a = parsed[0]["ok"].as_u64().unwrap();
let b = parsed[1]["ok"].as_u64().unwrap();
let r = k.execute_batch(&format!(
r#"[{{"op": "compoundCut", "args": {{"target": {a}, "tools": [{b}]}}}}]"#
));
assert!(batch_has_ok(&r, 0), "compound_cut must return ok: {r}");
}
#[test]
fn compound_cut_multiple_tools() {
let mut k = BrepKernel::new();
let r = k.execute_batch(
r#"[
{"op": "makeBox", "args": {"width": 4, "height": 4, "depth": 4}},
{"op": "makeBox", "args": {"width": 1, "height": 1, "depth": 1}},
{"op": "makeBox", "args": {"width": 0.5, "height": 0.5, "depth": 0.5}},
{"op": "compoundCut", "args": {"target": 0, "tools": [1, 2]}}
]"#,
);
assert!(
batch_has_ok(&r, 3),
"compound_cut with two tools must return ok: {r}"
);
}
#[test]
fn compound_cut_invalid_target_errors() {
let (mut k, setup) = two_boxes_batch();
let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
let b = parsed[1]["ok"].as_u64().unwrap();
let r = k.execute_batch(&format!(
r#"[{{"op": "compoundCut", "args": {{"target": 9999, "tools": [{b}]}}}}]"#
));
assert!(batch_has_error(&r, 0));
}
#[test]
fn compound_cut_invalid_tool_errors() {
let (mut k, setup) = two_boxes_batch();
let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
let a = parsed[0]["ok"].as_u64().unwrap();
let r = k.execute_batch(&format!(
r#"[{{"op": "compoundCut", "args": {{"target": {a}, "tools": [9999]}}}}]"#
));
assert!(batch_has_error(&r, 0));
}
#[test]
fn compound_cut_empty_tool_list_is_identity() {
let (mut k, setup) = two_boxes_batch();
let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
let a = parsed[0]["ok"].as_u64().unwrap();
let r = k.execute_batch(&format!(
r#"[{{"op": "compoundCut", "args": {{"target": {a}, "tools": []}}}}]"#
));
assert!(batch_has_ok(&r, 0));
}
#[test]
fn detect_coincident_faces_overlapping_boxes_returns_sd_pairs() {
let (mut k, setup) = two_boxes_batch();
let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
let a = parsed[0]["ok"].as_u64().unwrap();
let b = parsed[1]["ok"].as_u64().unwrap();
let r = k.execute_batch(&format!(
r#"[{{"op": "detectCoincidentFaces", "args": {{"solidA": {a}, "solidB": {b}}}}}]"#
));
let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
let arr = parsed[0]["ok"].as_array().unwrap();
assert!(!arr.is_empty(), "overlapping boxes produce SD pairs: {r}");
for pair in arr {
assert!(pair["faceA"].is_u64());
assert!(pair["faceB"].is_u64());
assert!(pair["sameOrientation"].is_boolean());
assert!(pair["aabbOverlap"].is_boolean());
}
}
#[test]
fn detect_coincident_faces_invalid_handle_errors() {
let (mut k, setup) = two_boxes_batch();
let parsed: serde_json::Value = serde_json::from_str(&setup).unwrap();
let a = parsed[0]["ok"].as_u64().unwrap();
let r = k.execute_batch(&format!(
r#"[{{"op": "detectCoincidentFaces", "args": {{"solidA": {a}, "solidB": 9999}}}}]"#
));
assert!(batch_has_error(&r, 0));
}
#[test]
fn mesh_boolean_fuse_returns_non_empty_mesh() {
let k = BrepKernel::new();
#[rustfmt::skip]
let positions = vec![
0.0, 0.0, 0.0,
1.0, 0.0, 0.0,
0.0, 1.0, 0.0,
0.0, 0.0, 1.0,
];
let indices = vec![0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3];
let mesh = k
.mesh_boolean(
positions.clone(),
indices.clone(),
positions,
indices,
"fuse",
1e-7,
)
.unwrap();
assert!(
!mesh.positions().is_empty(),
"fused mesh must have vertices"
);
assert!(!mesh.indices().is_empty(), "fused mesh must have triangles");
assert_eq!(mesh.positions().len() % 3, 0);
assert_eq!(mesh.indices().len() % 3, 0);
}
#[test]
fn mesh_boolean_unknown_op_is_not_valid() {
let valid = [
"fuse",
"union",
"cut",
"difference",
"intersect",
"intersection",
];
assert!(
!valid.contains(&"explode"),
"explode should not be a valid op"
);
}
#[test]
fn mesh_boolean_bad_positions_length_is_invalid() {
let bad_len = 2;
assert_ne!(
bad_len % 3,
0,
"length 2 should fail the multiple-of-3 check"
);
}
#[test]
fn cut_reduces_volume() {
let mut k = BrepKernel::new();
let r = k.execute_batch(
r#"[
{"op": "makeBox", "args": {"width": 2, "height": 2, "depth": 2}},
{"op": "makeBox", "args": {"width": 1, "height": 1, "depth": 1}},
{"op": "volume", "args": {"solid": 0}},
{"op": "cut", "args": {"solidA": 0, "solidB": 1}},
{"op": "volume", "args": {"solid": 2}}
]"#,
);
let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
let vol_before = parsed[2]["ok"].as_f64().unwrap();
let vol_after = parsed[4]["ok"].as_f64().unwrap();
assert!(
vol_after < vol_before,
"cut must reduce volume: {vol_before} -> {vol_after}"
);
}
#[test]
fn compound_cut_volume_decreases() {
let mut k = BrepKernel::new();
let r = k.execute_batch(
r#"[
{"op": "makeBox", "args": {"width": 10, "height": 10, "depth": 10}},
{"op": "makeBox", "args": {"width": 1, "height": 1, "depth": 1}},
{"op": "volume", "args": {"solid": 0}},
{"op": "compoundCut", "args": {"target": 0, "tools": [1]}},
{"op": "volume", "args": {"solid": 2}}
]"#,
);
let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
let vol_before = parsed[2]["ok"].as_f64().unwrap();
assert!(batch_has_ok(&r, 3), "compoundCut must succeed: {r}");
let vol_after = parsed[4]["ok"].as_f64().unwrap();
assert!(
vol_after < vol_before && vol_after > 0.0,
"compound_cut must reduce volume: {vol_before} -> {vol_after}"
);
}
}