Skip to main content

brepkit_wasm/bindings/
tessellate.rs

1//! Tessellation and wireframe bindings.
2
3#![allow(clippy::missing_errors_doc)]
4
5use brepkit_operations::tessellate;
6use wasm_bindgen::prelude::*;
7
8use crate::error::validate_positive;
9use crate::kernel::BrepKernel;
10use crate::shapes::{JsGroupedMesh, JsMesh};
11use crate::types::{GroupedMeshResult, UvMeshResult};
12
13/// Resolve an optional angular-tolerance argument, validating it when present
14/// and falling back to the default angular cap when absent.
15fn resolve_angular_tol(angular_tolerance: Option<f64>) -> Result<f64, JsError> {
16    match angular_tolerance {
17        Some(a) => {
18            validate_positive(a, "angularTolerance")?;
19            Ok(a)
20        }
21        None => Ok(brepkit_math::chord::DEFAULT_ANGULAR_TOL),
22    }
23}
24
25#[wasm_bindgen]
26impl BrepKernel {
27    // ── Tessellation ───────────────────────────────────────────────
28
29    /// Tessellate a single face into a triangle mesh.
30    ///
31    /// # Errors
32    ///
33    /// Returns an error if the face handle is invalid or tessellation fails.
34    #[wasm_bindgen(js_name = "tessellateFace")]
35    pub fn tessellate_face(
36        &self,
37        face: u32,
38        deflection: f64,
39        angular_tolerance: Option<f64>,
40    ) -> Result<JsMesh, JsError> {
41        validate_positive(deflection, "deflection")?;
42        let angular_tol = resolve_angular_tol(angular_tolerance)?;
43        let face_id = self.resolve_face(face)?;
44        let mesh =
45            tessellate::tessellate_with_tolerance(&self.topo, face_id, deflection, angular_tol)?;
46        Ok(mesh.into())
47    }
48
49    /// Tessellate all faces of a solid into a single merged triangle mesh.
50    ///
51    /// Includes both the outer shell and any inner shells (voids).
52    ///
53    /// # Errors
54    ///
55    /// Returns an error if the solid handle is invalid or tessellation fails.
56    #[wasm_bindgen(js_name = "tessellateSolid")]
57    pub fn tessellate_solid(
58        &self,
59        solid: u32,
60        deflection: f64,
61        angular_tolerance: Option<f64>,
62    ) -> Result<JsMesh, JsError> {
63        validate_positive(deflection, "deflection")?;
64        let angular_tol = resolve_angular_tol(angular_tolerance)?;
65        let solid_id = self.resolve_solid(solid)?;
66
67        // Use watertight tessellation that shares edge vertices between
68        // adjacent faces, eliminating cracks at face boundaries.
69        let merged = tessellate::tessellate_solid_with_tolerance(
70            &self.topo,
71            solid_id,
72            deflection,
73            angular_tol,
74        )?;
75
76        Ok(merged.into())
77    }
78
79    /// Tessellate a solid with per-face triangle grouping.
80    ///
81    /// Returns a JSON string containing `{ positions, normals, indices, faceOffsets }`.
82    /// `faceOffsets` is an array where `faceOffsets[i]` is the start index into
83    /// `indices` for face `i`, and the last element is `indices.length`.
84    ///
85    /// Uses the watertight shared-edge-pool tessellation: adjacent faces share
86    /// identical boundary vertices, so the exported mesh has no T-junctions
87    /// regardless of how the solid was constructed (booleans included).
88    #[wasm_bindgen(js_name = "tessellateSolidGrouped")]
89    pub fn tessellate_solid_grouped(
90        &self,
91        solid: u32,
92        deflection: f64,
93        angular_tolerance: Option<f64>,
94    ) -> Result<JsValue, JsError> {
95        validate_positive(deflection, "deflection")?;
96        let angular_tol = resolve_angular_tol(angular_tolerance)?;
97        let solid_id = self.resolve_solid(solid)?;
98
99        let (mesh, face_offsets) = tessellate::tessellate_solid_grouped_with_tolerance(
100            &self.topo,
101            solid_id,
102            deflection,
103            angular_tol,
104        )?;
105
106        let mut all_positions: Vec<f64> = Vec::with_capacity(mesh.positions.len() * 3);
107        for p in &mesh.positions {
108            all_positions.extend_from_slice(&[p.x(), p.y(), p.z()]);
109        }
110        let mut all_normals: Vec<f64> = Vec::with_capacity(mesh.normals.len() * 3);
111        for n in &mesh.normals {
112            all_normals.extend_from_slice(&[n.x(), n.y(), n.z()]);
113        }
114
115        let result = GroupedMeshResult {
116            positions: all_positions,
117            normals: all_normals,
118            indices: mesh.indices,
119            face_offsets,
120        };
121        Ok(serde_json::to_string(&result)
122            .map_err(|e| JsError::new(&e.to_string()))?
123            .into())
124    }
125
126    /// Tessellate a solid with per-face grouping, returned as packed binary
127    /// buffers ([`JsGroupedMesh`]) instead of a JSON string.
128    ///
129    /// Identical geometry to [`tessellate_solid_grouped`](Self::tessellate_solid_grouped),
130    /// but the mesh crosses the WASM boundary as `Float32Array`/`Uint32Array`
131    /// bulk copies rather than a (potentially multi-megabyte) JSON string that
132    /// the caller must `JSON.parse` and re-pack — far cheaper for large meshes.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if the solid handle is invalid or tessellation fails.
137    #[wasm_bindgen(js_name = "tessellateSolidGroupedBinary")]
138    pub fn tessellate_solid_grouped_binary(
139        &self,
140        solid: u32,
141        deflection: f64,
142        angular_tolerance: Option<f64>,
143    ) -> Result<JsGroupedMesh, JsError> {
144        validate_positive(deflection, "deflection")?;
145        let angular_tol = resolve_angular_tol(angular_tolerance)?;
146        let solid_id = self.resolve_solid(solid)?;
147
148        let (mesh, face_offsets) = tessellate::tessellate_solid_grouped_with_tolerance(
149            &self.topo,
150            solid_id,
151            deflection,
152            angular_tol,
153        )?;
154
155        Ok(JsGroupedMesh::new(mesh, face_offsets))
156    }
157
158    /// Tessellate a solid and include per-vertex UV coordinates.
159    ///
160    /// Returns a JSON string containing `{ positions, normals, indices, uvs }`.
161    /// `uvs` is a flat array of `[u0, v0, u1, v1, ...]` values, two per vertex.
162    /// For analytic and NURBS surfaces, these are the parametric (u, v) values.
163    /// For planar faces, UVs are computed by projection onto the face plane.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error if the solid handle is invalid or tessellation fails.
168    #[wasm_bindgen(js_name = "tessellateSolidUV")]
169    pub fn tessellate_solid_uv(
170        &self,
171        solid: u32,
172        deflection: f64,
173        angular_tolerance: Option<f64>,
174    ) -> Result<JsValue, JsError> {
175        validate_positive(deflection, "deflection")?;
176        let angular_tol = resolve_angular_tol(angular_tolerance)?;
177        let solid_id = self.resolve_solid(solid)?;
178        let faces = brepkit_topology::explorer::solid_faces(&self.topo, solid_id)?;
179
180        let mut all_positions: Vec<f64> = Vec::new();
181        let mut all_normals: Vec<f64> = Vec::new();
182        let mut all_uvs: Vec<f64> = Vec::new();
183        let mut all_indices: Vec<u32> = Vec::new();
184
185        for &face_id in &faces {
186            #[allow(clippy::cast_possible_truncation)]
187            let idx_offset = (all_positions.len() / 3) as u32;
188
189            let mesh_uv =
190                tessellate::tessellate_with_uvs_a(&self.topo, face_id, deflection, angular_tol)?;
191            for p in &mesh_uv.mesh.positions {
192                all_positions.extend_from_slice(&[p.x(), p.y(), p.z()]);
193            }
194            for n in &mesh_uv.mesh.normals {
195                all_normals.extend_from_slice(&[n.x(), n.y(), n.z()]);
196            }
197            for uv in &mesh_uv.uvs {
198                all_uvs.extend_from_slice(uv);
199            }
200            for &idx in &mesh_uv.mesh.indices {
201                all_indices.push(idx + idx_offset);
202            }
203        }
204
205        let result = UvMeshResult {
206            positions: all_positions,
207            normals: all_normals,
208            indices: all_indices,
209            uvs: all_uvs,
210        };
211        Ok(serde_json::to_string(&result)
212            .map_err(|e| JsError::new(&e.to_string()))?
213            .into())
214    }
215
216    // ── Edge wireframe ────────────────────────────────────────────
217
218    /// Sample edges of a solid into polylines for wireframe rendering.
219    ///
220    /// Returns a `JsEdgeLines` containing flattened positions and per-edge
221    /// offset indices. The `deflection` parameter controls sampling density.
222    ///
223    /// Smooth edges (between faces on the same underlying surface) are
224    /// automatically filtered out to reduce wireframe clutter. These edges
225    /// arise from boolean face-splitting and don't represent visible creases.
226    #[wasm_bindgen(js_name = "meshEdges")]
227    pub fn mesh_edges(
228        &self,
229        solid: u32,
230        deflection: f64,
231        angular_tolerance: Option<f64>,
232    ) -> Result<crate::shapes::JsEdgeLines, JsError> {
233        validate_positive(deflection, "deflection")?;
234        let angular_tol = resolve_angular_tol(angular_tolerance)?;
235        let solid_id = self.resolve_solid(solid)?;
236        let edge_lines = tessellate::sample_solid_edges_filtered(
237            &self.topo,
238            solid_id,
239            deflection,
240            angular_tol,
241            true,
242        )?;
243        Ok(edge_lines.into())
244    }
245
246    /// Sample ALL edges of a solid (no smooth-edge filtering).
247    ///
248    /// Same as `meshEdges` but includes edges between co-surface faces.
249    /// Useful for debugging topology.
250    #[wasm_bindgen(js_name = "meshEdgesAll")]
251    pub fn mesh_edges_all(
252        &self,
253        solid: u32,
254        deflection: f64,
255        angular_tolerance: Option<f64>,
256    ) -> Result<crate::shapes::JsEdgeLines, JsError> {
257        validate_positive(deflection, "deflection")?;
258        let angular_tol = resolve_angular_tol(angular_tolerance)?;
259        let solid_id = self.resolve_solid(solid)?;
260        let edge_lines = tessellate::sample_solid_edges_filtered(
261            &self.topo,
262            solid_id,
263            deflection,
264            angular_tol,
265            false,
266        )?;
267        Ok(edge_lines.into())
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    #![allow(clippy::unwrap_used, clippy::expect_used)]
274
275    use crate::kernel::BrepKernel;
276
277    /// Create a kernel containing a 2×3×4 box and return (kernel, solid_handle).
278    fn kernel_with_box() -> (BrepKernel, u32) {
279        let mut k = BrepKernel::new();
280        let solid = k.make_box_solid(2.0, 3.0, 4.0).unwrap();
281        (k, solid)
282    }
283
284    // ── tessellate_solid ──────────────────────────────────────────
285
286    #[test]
287    fn tessellate_solid_box_produces_nonempty_mesh() {
288        let (k, solid) = kernel_with_box();
289        let mesh = k.tessellate_solid(solid, 0.1, None).unwrap();
290        assert!(mesh.vertex_count() > 0, "expected vertices, got 0");
291        assert!(mesh.triangle_count() > 0, "expected triangles, got 0");
292    }
293
294    #[test]
295    fn tessellate_solid_positions_and_normals_lengths_match() {
296        let (k, solid) = kernel_with_box();
297        let mesh = k.tessellate_solid(solid, 0.1, None).unwrap();
298        let positions = mesh.positions();
299        let normals = mesh.normals();
300        // Both must be flat [x, y, z, …] arrays — same length
301        assert_eq!(
302            positions.len(),
303            normals.len(),
304            "positions.len()={} normals.len()={}",
305            positions.len(),
306            normals.len()
307        );
308        // Divisible by 3 (complete xyz triples)
309        assert_eq!(positions.len() % 3, 0);
310    }
311
312    #[test]
313    fn tessellate_solid_indices_are_valid_vertex_refs() {
314        let (k, solid) = kernel_with_box();
315        let mesh = k.tessellate_solid(solid, 0.1, None).unwrap();
316        let vertex_count = mesh.vertex_count();
317        let indices = mesh.indices();
318        for &idx in &indices {
319            assert!(
320                idx < vertex_count,
321                "index {idx} out of bounds (vertex_count={vertex_count})"
322            );
323        }
324    }
325
326    #[test]
327    fn tessellate_solid_coarser_deflection_has_fewer_triangles() {
328        let (k, solid) = kernel_with_box();
329        let fine = k.tessellate_solid(solid, 0.01, None).unwrap();
330        let coarse = k.tessellate_solid(solid, 1.0, None).unwrap();
331        assert!(
332            fine.triangle_count() >= coarse.triangle_count(),
333            "fine={} coarse={}",
334            fine.triangle_count(),
335            coarse.triangle_count()
336        );
337    }
338
339    // ── tessellate_solid_grouped ──────────────────────────────────
340    // tessellate_solid_grouped returns a JsValue (via JsValue::from_str),
341    // which panics on non-wasm targets. Test the underlying logic via the
342    // operations layer instead.
343
344    #[test]
345    fn tessellate_solid_grouped_via_operations() {
346        let mut topo = brepkit_topology::topology::Topology::new();
347        let solid = brepkit_operations::primitives::make_box(&mut topo, 2.0, 3.0, 4.0).unwrap();
348
349        let (mesh, face_offsets) =
350            brepkit_operations::tessellate::tessellate_solid_grouped_with_tolerance(
351                &topo,
352                solid,
353                0.1,
354                brepkit_math::chord::DEFAULT_ANGULAR_TOL,
355            )
356            .unwrap();
357
358        assert!(!mesh.positions.is_empty(), "expected vertices");
359        assert!(!mesh.indices.is_empty(), "expected indices");
360        // Box has 6 faces, so faceOffsets has 7 entries (6 starts + 1 sentinel).
361        assert_eq!(face_offsets.len(), 7, "expected 7 face offsets for a box");
362        assert_eq!(*face_offsets.last().unwrap() as usize, mesh.indices.len());
363        // Watertight grouped output: every group is a non-empty triangle run.
364        for w in face_offsets.windows(2) {
365            assert!(w[0] < w[1], "box face groups must be non-empty");
366            assert_eq!((w[1] - w[0]) % 3, 0);
367        }
368        assert!(
369            brepkit_operations::tessellate::is_watertight(&mesh),
370            "grouped box mesh must be watertight"
371        );
372    }
373
374    // ── mesh_edges_all ────────────────────────────────────────────
375
376    #[test]
377    fn mesh_edges_all_box_produces_nonempty_edge_lines() {
378        let (k, solid) = kernel_with_box();
379        let edge_lines = k.mesh_edges_all(solid, 0.1, None).unwrap();
380        assert!(edge_lines.edge_count() > 0, "expected edges, got 0");
381        assert!(
382            !edge_lines.positions().is_empty(),
383            "positions must be non-empty"
384        );
385    }
386
387    #[test]
388    fn mesh_edges_all_box_has_twelve_edges() {
389        // A box has exactly 12 edges.
390        let (k, solid) = kernel_with_box();
391        let edge_lines = k.mesh_edges_all(solid, 0.1, None).unwrap();
392        assert_eq!(
393            edge_lines.edge_count(),
394            12,
395            "expected 12 box edges, got {}",
396            edge_lines.edge_count()
397        );
398    }
399
400    // ── Invalid handle ────────────────────────────────────────────
401    // Error-path tests use internal operations to avoid JsError panics.
402
403    #[test]
404    fn tessellate_solid_invalid_handle_returns_error() {
405        let mut k = BrepKernel::new();
406        let r = k.execute_batch(
407            r#"[{"op": "tessellateSolid", "args": {"solid": 9999, "deflection": 0.1}}]"#,
408        );
409        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
410        assert!(parsed[0]["error"].is_string());
411    }
412
413    #[test]
414    fn mesh_edges_all_invalid_handle_returns_error() {
415        let mut k = BrepKernel::new();
416        let r = k.execute_batch(
417            r#"[{"op": "meshEdgesAll", "args": {"solid": 9999, "deflection": 0.1}}]"#,
418        );
419        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
420        assert!(parsed[0]["error"].is_string());
421    }
422
423    // ── Zero / non-positive deflection ────────────────────────────
424    // validate_positive is a pure function that returns WasmError (not JsError),
425    // so we test the validation logic directly.
426
427    #[test]
428    fn tessellate_solid_zero_deflection_is_invalid() {
429        use crate::error::validate_positive;
430        let result = validate_positive(0.0, "deflection");
431        assert!(result.is_err(), "zero deflection must be rejected");
432    }
433
434    #[test]
435    fn mesh_edges_all_zero_deflection_is_invalid() {
436        use crate::error::validate_positive;
437        let result = validate_positive(0.0, "deflection");
438        assert!(result.is_err(), "zero deflection must be rejected");
439    }
440
441    #[test]
442    fn negative_deflection_is_invalid() {
443        use crate::error::validate_positive;
444        let result = validate_positive(-1.0, "deflection");
445        assert!(result.is_err(), "negative deflection must be rejected");
446    }
447}