Skip to main content

brepkit_wasm/bindings/
heal.rs

1//! Shape healing, validation, and feature recognition bindings.
2
3#![allow(clippy::missing_errors_doc)]
4
5use wasm_bindgen::prelude::*;
6
7use brepkit_topology::face::Face;
8
9use crate::handles::{face_id_to_u32, solid_id_to_u32};
10use crate::helpers::{TOL, serialize_feature};
11use crate::kernel::BrepKernel;
12
13#[wasm_bindgen]
14impl BrepKernel {
15    // -- Sewing ----------------------------------------------------------------
16
17    /// Sew loose faces into a connected solid.
18    ///
19    /// `face_handles` is an array of face handles. Returns a solid handle.
20    ///
21    /// # Errors
22    ///
23    /// Returns an error if fewer than 2 faces or sewing fails.
24    #[wasm_bindgen(js_name = "sewFaces")]
25    #[allow(clippy::needless_pass_by_value)]
26    pub fn sew_faces(&mut self, face_handles: Vec<u32>, tolerance: f64) -> Result<u32, JsError> {
27        let face_ids: Vec<brepkit_topology::face::FaceId> = face_handles
28            .iter()
29            .map(|&h| self.resolve_face(h))
30            .collect::<Result<_, _>>()?;
31        let solid = brepkit_operations::sew::sew_faces(self.topo_mut(), &face_ids, tolerance)?;
32        Ok(solid_id_to_u32(solid))
33    }
34
35    /// Create a solid from a set of faces by sewing them together.
36    ///
37    /// Alias for `sewFaces` with a default tolerance. This is the equivalent
38    /// of sewing faces into a closed shell and building a solid.
39    #[wasm_bindgen(js_name = "makeSolid")]
40    #[allow(clippy::needless_pass_by_value)]
41    pub fn make_solid_from_faces(&mut self, face_handles: Vec<u32>) -> Result<u32, JsError> {
42        let face_ids: Vec<brepkit_topology::face::FaceId> = face_handles
43            .iter()
44            .map(|&h| self.resolve_face(h))
45            .collect::<Result<_, _>>()?;
46        let tolerance = brepkit_math::tolerance::Tolerance::new().linear;
47        let solid = brepkit_operations::sew::sew_faces(self.topo_mut(), &face_ids, tolerance)?;
48        Ok(solid_id_to_u32(solid))
49    }
50
51    /// Remove all holes from a face, returning a new face with only the outer wire.
52    #[wasm_bindgen(js_name = "removeHolesFromFace")]
53    pub fn remove_holes_from_face(&mut self, face: u32) -> Result<u32, JsError> {
54        let face_id = self.resolve_face(face)?;
55        let face_data = self.topo.face(face_id)?;
56        let outer_wire = face_data.outer_wire();
57        let surface = face_data.surface().clone();
58        let new_face = Face::new(outer_wire, vec![], surface);
59        let fid = self.topo_mut().add_face(new_face);
60        Ok(face_id_to_u32(fid))
61    }
62
63    /// Weld shells and faces into a single solid by sewing.
64    ///
65    /// Accepts an array of face handles from potentially different shells.
66    /// Sews all faces together into a single solid.
67    #[wasm_bindgen(js_name = "weldShellsAndFaces")]
68    #[allow(clippy::needless_pass_by_value)]
69    pub fn weld_shells_and_faces(
70        &mut self,
71        face_handles: Vec<u32>,
72        tolerance: f64,
73    ) -> Result<u32, JsError> {
74        let face_ids: Vec<brepkit_topology::face::FaceId> = face_handles
75            .iter()
76            .map(|&h| self.resolve_face(h))
77            .collect::<Result<_, _>>()?;
78        let tol = if tolerance > 0.0 {
79            tolerance
80        } else {
81            brepkit_math::tolerance::Tolerance::new().linear
82        };
83        let solid = brepkit_operations::sew::sew_faces(self.topo_mut(), &face_ids, tol)?;
84        Ok(solid_id_to_u32(solid))
85    }
86
87    // -- Healing ---------------------------------------------------------------
88
89    /// Unify adjacent faces that lie on the same geometric surface.
90    ///
91    /// Merges co-surface face fragments (produced by boolean operations)
92    /// back into single faces, reducing face count and improving topology.
93    /// Returns the number of faces removed.
94    #[wasm_bindgen(js_name = "unifyFaces")]
95    pub fn unify_faces(&mut self, solid: u32) -> Result<u32, JsError> {
96        let solid_id = self.resolve_solid(solid)?;
97        let removed = brepkit_operations::heal::unify_faces(self.topo_mut(), solid_id)?;
98        #[allow(clippy::cast_possible_truncation)]
99        Ok(removed as u32)
100    }
101
102    /// Convert all analytic geometry in a solid to NURBS representation.
103    ///
104    /// Replaces planes, cylinders, cones, spheres, tori with NURBS surfaces and
105    /// lines, circles, ellipses with NURBS curves. NURBS surfaces and curves
106    /// already in the model are left untouched. Returns the number of entities
107    /// converted.
108    ///
109    /// Converts every analytic surface and curve to a NURBS representation.
110    /// Stored pcurves are dropped during conversion — callers that depend on
111    /// pcurves should recompute them afterwards.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if the solid handle is invalid or conversion fails.
116    #[wasm_bindgen(js_name = "convertToBspline")]
117    pub fn convert_to_bspline(&mut self, solid: u32) -> Result<u32, JsError> {
118        let solid_id = self.resolve_solid(solid)?;
119        let count = brepkit_operations::heal::convert_to_bspline(self.topo_mut(), solid_id)?;
120        #[allow(clippy::cast_possible_truncation)]
121        Ok(count as u32)
122    }
123
124    /// Recognize and replace NURBS faces and edges with their analytic
125    /// (elementary) forms wherever possible (Plane/Cylinder/Sphere/
126    /// Cone/Torus surfaces; Line/Circle/Ellipse edges).
127    ///
128    /// Inverse of `convertToBspline`: useful after STEP/IGES import
129    /// to recover analytic types from B-spline-only exports.
130    /// Returns the total number of faces and edges converted.
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if topology lookups fail.
135    #[wasm_bindgen(js_name = "convertToElementary")]
136    pub fn convert_to_elementary(&mut self, solid: u32) -> Result<u32, JsError> {
137        let solid_id = self.resolve_solid(solid)?;
138        let count =
139            brepkit_operations::heal::convert_to_elementary(self.topo_mut(), solid_id, TOL)?;
140        #[allow(clippy::cast_possible_truncation)]
141        Ok(count as u32)
142    }
143
144    /// Heal a solid topology.
145    ///
146    /// Returns the number of issues fixed.
147    #[wasm_bindgen(js_name = "healSolid")]
148    pub fn heal_solid(&mut self, solid: u32) -> Result<u32, JsError> {
149        let solid_id = self.resolve_solid(solid)?;
150        let report = brepkit_operations::heal::heal_solid(self.topo_mut(), solid_id, TOL)?;
151        #[allow(clippy::cast_possible_truncation)]
152        Ok((report.vertices_merged
153            + report.degenerate_edges_removed
154            + report.orientations_fixed
155            + report.wire_gaps_closed
156            + report.small_faces_removed
157            + report.duplicate_faces_removed) as u32)
158    }
159
160    /// Validate, heal, and re-validate a solid in one pass.
161    ///
162    /// Returns the number of remaining validation errors after repair.
163    /// A return value of 0 means the solid is valid after repair.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error if the solid handle is invalid.
168    #[wasm_bindgen(js_name = "repairSolid")]
169    pub fn repair_solid(&mut self, solid: u32) -> Result<u32, JsError> {
170        let solid_id = self.resolve_solid(solid)?;
171        let report = brepkit_operations::heal::repair_solid(self.topo_mut(), solid_id, TOL)?;
172        #[allow(clippy::cast_possible_truncation)]
173        Ok(report.after.error_count() as u32)
174    }
175
176    /// Remove degenerate (zero-length) edges from a solid.
177    ///
178    /// Returns the number of edges removed.
179    #[wasm_bindgen(js_name = "removeDegenerateEdges")]
180    pub fn remove_degenerate_edges(&mut self, solid: u32, tolerance: f64) -> Result<u32, JsError> {
181        let solid_id = self.resolve_solid(solid)?;
182        let count = brepkit_operations::heal::remove_degenerate_edges(
183            self.topo_mut(),
184            solid_id,
185            tolerance,
186        )?;
187        #[allow(clippy::cast_possible_truncation)]
188        Ok(count as u32)
189    }
190
191    /// Fix face orientations to ensure consistent outward normals.
192    ///
193    /// Returns the number of faces fixed.
194    #[wasm_bindgen(js_name = "fixFaceOrientations")]
195    pub fn fix_face_orientations(&mut self, solid: u32) -> Result<u32, JsError> {
196        let solid_id = self.resolve_solid(solid)?;
197        let count = brepkit_operations::heal::fix_face_orientations(self.topo_mut(), solid_id)?;
198        #[allow(clippy::cast_possible_truncation)]
199        Ok(count as u32)
200    }
201
202    // -- Defeaturing & Feature Recognition -------------------------------------
203
204    /// Remove specified faces from a solid (defeaturing).
205    ///
206    /// `face_handles` is an array of face handles to remove.
207    /// Returns a new solid handle.
208    #[wasm_bindgen(js_name = "defeature")]
209    #[allow(clippy::needless_pass_by_value)]
210    pub fn defeature(&mut self, solid: u32, face_handles: Vec<u32>) -> Result<u32, JsError> {
211        let solid_id = self.resolve_solid(solid)?;
212        let face_ids: Vec<_> = face_handles
213            .iter()
214            .map(|&h| self.resolve_face(h))
215            .collect::<Result<Vec<_>, _>>()?;
216        let result =
217            brepkit_operations::defeature::defeature(self.topo_mut(), solid_id, &face_ids)?;
218        Ok(solid_id_to_u32(result))
219    }
220
221    /// Detect small features (faces below an area threshold).
222    ///
223    /// Returns an array of face handles.
224    #[wasm_bindgen(js_name = "detectSmallFeatures")]
225    pub fn detect_small_features(
226        &self,
227        solid: u32,
228        area_threshold: f64,
229        deflection: f64,
230    ) -> Result<Vec<u32>, JsError> {
231        let solid_id = self.resolve_solid(solid)?;
232        let faces = brepkit_operations::defeature::detect_small_features(
233            &self.topo,
234            solid_id,
235            area_threshold,
236            deflection,
237        )?;
238        Ok(faces.iter().map(|f| face_id_to_u32(*f)).collect())
239    }
240
241    /// Recognize geometric features in a solid.
242    ///
243    /// Returns a JSON string describing the recognized features.
244    #[wasm_bindgen(js_name = "recognizeFeatures")]
245    pub fn recognize_features(&self, solid: u32, deflection: f64) -> Result<String, JsError> {
246        let solid_id = self.resolve_solid(solid)?;
247        let features = brepkit_operations::feature_recognition::recognize_features(
248            &self.topo, solid_id, deflection,
249        )?;
250        let json_features: Vec<serde_json::Value> =
251            features.iter().map(serialize_feature).collect();
252        Ok(serde_json::Value::Array(json_features).to_string())
253    }
254}
255
256#[cfg(test)]
257#[allow(clippy::unwrap_used, clippy::expect_used)]
258mod tests {
259    use crate::kernel::BrepKernel;
260
261    #[test]
262    fn convert_to_bspline_returns_count_and_solid() {
263        let mut k = BrepKernel::new();
264        let r = k.execute_batch(
265            r#"[
266                {"op": "makeCylinder", "args": {"radius": 1, "height": 2}},
267                {"op": "convertToBspline", "args": {"solid": 0}}
268            ]"#,
269        );
270        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
271        let ok = parsed[1]["ok"].as_object().expect("expected ok object");
272        assert!(ok.get("solid").is_some(), "missing 'solid' field");
273        let converted = ok["converted"].as_u64().expect("expected 'converted' u64");
274        // Cylinder has 3 faces (lateral + 2 caps) and 3 edges (2 circles + 1 seam)
275        // → 6 conversions on first run.
276        assert!(converted >= 5, "expected >=5 conversions, got {converted}");
277    }
278
279    #[test]
280    fn convert_to_bspline_invalid_handle_errors() {
281        let mut k = BrepKernel::new();
282        let r = k.execute_batch(r#"[{"op": "convertToBspline", "args": {"solid": 999}}]"#);
283        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
284        assert!(
285            parsed[0]["error"].is_string(),
286            "expected error for invalid handle, got: {}",
287            parsed[0]
288        );
289    }
290
291    #[test]
292    fn convert_to_bspline_idempotent_second_call_is_zero() {
293        let mut k = BrepKernel::new();
294        let r = k.execute_batch(
295            r#"[
296                {"op": "makeBox", "args": {"width": 1, "height": 1, "depth": 1}},
297                {"op": "convertToBspline", "args": {"solid": 0}},
298                {"op": "convertToBspline", "args": {"solid": 0}}
299            ]"#,
300        );
301        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
302        let first = parsed[1]["ok"]["converted"].as_u64().unwrap();
303        let second = parsed[2]["ok"]["converted"].as_u64().unwrap();
304        assert!(first > 0);
305        assert_eq!(second, 0, "second pass should convert nothing");
306    }
307
308    #[test]
309    fn convert_to_elementary_via_batch_round_trip() {
310        // Round-trip a cylinder through the batch dispatch: NURBS-ify
311        // it, then recognize back. The convertToElementary entry was
312        // missing from `dispatch_op` before this PR, so prior to the
313        // fix this test would have hit the catch-all "unknown
314        // operation" arm.
315        let mut k = BrepKernel::new();
316        let r = k.execute_batch(
317            r#"[
318                {"op": "makeCylinder", "args": {"radius": 1, "height": 2}},
319                {"op": "convertToBspline", "args": {"solid": 0}},
320                {"op": "convertToElementary", "args": {"solid": 0}}
321            ]"#,
322        );
323        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
324        // convertToElementary must reach the dispatch arm — not the
325        // catch-all — so the result is `ok`, not `error`.
326        let ok = parsed[2]["ok"]
327            .as_object()
328            .expect("convertToElementary should be dispatched, got error");
329        assert!(ok.get("solid").is_some(), "missing 'solid' field");
330        let converted = ok["converted"].as_u64().expect("expected 'converted' u64");
331        assert!(
332            converted > 0,
333            "expected >=1 recognition (the cylinder lateral face at minimum), got {converted}"
334        );
335    }
336
337    #[test]
338    fn convert_to_elementary_via_batch_invalid_handle_errors() {
339        let mut k = BrepKernel::new();
340        let r = k.execute_batch(r#"[{"op": "convertToElementary", "args": {"solid": 999}}]"#);
341        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
342        assert!(
343            parsed[0]["error"].is_string(),
344            "expected error for invalid handle, got: {}",
345            parsed[0]
346        );
347    }
348}