Skip to main content

brepkit_wasm/
shapes.rs

1//! JS-facing shape types via `wasm-bindgen`.
2
3use brepkit_operations::tessellate::{EdgeLines, TriangleMesh};
4use wasm_bindgen::prelude::*;
5
6/// A 3D point exposed to JavaScript.
7#[wasm_bindgen]
8#[derive(Debug, Clone, Copy)]
9pub struct JsPoint3 {
10    /// X coordinate.
11    pub x: f64,
12    /// Y coordinate.
13    pub y: f64,
14    /// Z coordinate.
15    pub z: f64,
16}
17
18#[wasm_bindgen]
19impl JsPoint3 {
20    /// Create a new 3D point.
21    #[wasm_bindgen(constructor)]
22    #[must_use]
23    #[allow(clippy::missing_const_for_fn)]
24    pub fn new(x: f64, y: f64, z: f64) -> Self {
25        Self { x, y, z }
26    }
27}
28
29/// A 3D vector exposed to JavaScript.
30#[wasm_bindgen]
31#[derive(Debug, Clone, Copy)]
32pub struct JsVec3 {
33    /// X component.
34    pub x: f64,
35    /// Y component.
36    pub y: f64,
37    /// Z component.
38    pub z: f64,
39}
40
41#[wasm_bindgen]
42impl JsVec3 {
43    /// Create a new 3D vector.
44    #[wasm_bindgen(constructor)]
45    #[must_use]
46    #[allow(clippy::missing_const_for_fn)]
47    pub fn new(x: f64, y: f64, z: f64) -> Self {
48        Self { x, y, z }
49    }
50
51    /// Compute the length of this vector.
52    #[must_use]
53    pub fn length(&self) -> f64 {
54        self.x
55            .mul_add(self.x, self.y.mul_add(self.y, self.z * self.z))
56            .sqrt()
57    }
58}
59
60/// A triangle mesh exposed to JavaScript.
61///
62/// Positions and normals are flattened to `[x, y, z, x, y, z, ...]` format
63/// for efficient WASM transfer and direct use as GPU vertex buffers.
64#[wasm_bindgen]
65#[derive(Debug)]
66pub struct JsMesh {
67    positions: Vec<f64>,
68    normals: Vec<f64>,
69    indices: Vec<u32>,
70}
71
72#[wasm_bindgen]
73impl JsMesh {
74    /// Flattened vertex positions as `[x, y, z, ...]`.
75    #[wasm_bindgen(getter)]
76    #[must_use]
77    pub fn positions(&self) -> Vec<f64> {
78        self.positions.clone()
79    }
80
81    /// Flattened per-vertex normals as `[nx, ny, nz, ...]`.
82    #[wasm_bindgen(getter)]
83    #[must_use]
84    pub fn normals(&self) -> Vec<f64> {
85        self.normals.clone()
86    }
87
88    /// Triangle indices (groups of 3).
89    #[wasm_bindgen(getter)]
90    #[must_use]
91    pub fn indices(&self) -> Vec<u32> {
92        self.indices.clone()
93    }
94
95    /// Number of vertices in the mesh.
96    #[wasm_bindgen(getter, js_name = "vertexCount")]
97    #[must_use]
98    #[allow(clippy::cast_possible_truncation)]
99    pub fn vertex_count(&self) -> u32 {
100        (self.positions.len() / 3) as u32
101    }
102
103    /// Number of triangles in the mesh.
104    #[wasm_bindgen(getter, js_name = "triangleCount")]
105    #[must_use]
106    #[allow(clippy::cast_possible_truncation)]
107    pub fn triangle_count(&self) -> u32 {
108        (self.indices.len() / 3) as u32
109    }
110
111    /// Return all mesh data in a single packed buffer for efficient FFI transfer.
112    ///
113    /// Layout: `[pos_bytes: u32 LE, norm_bytes: u32 LE, idx_bytes: u32 LE,
114    ///          positions: f64 LE..., normals: f64 LE..., indices: u32 LE...]`
115    ///
116    /// This avoids three separate `.clone()` + FFI copies that the individual
117    /// getters (`positions`, `normals`, `indices`) would incur.
118    #[wasm_bindgen(js_name = "packedBuffer")]
119    #[must_use]
120    #[allow(clippy::cast_possible_truncation)]
121    pub fn packed_buffer(&self) -> Vec<u8> {
122        let pos_bytes = self.positions.len() * 8; // f64 = 8 bytes
123        let norm_bytes = self.normals.len() * 8;
124        let idx_bytes = self.indices.len() * 4; // u32 = 4 bytes
125        let header_size = 12; // 3 × u32
126
127        let mut buf = Vec::with_capacity(header_size + pos_bytes + norm_bytes + idx_bytes);
128
129        // Header: byte lengths of each section
130        buf.extend_from_slice(&(pos_bytes as u32).to_le_bytes());
131        buf.extend_from_slice(&(norm_bytes as u32).to_le_bytes());
132        buf.extend_from_slice(&(idx_bytes as u32).to_le_bytes());
133
134        // Positions (f64 LE)
135        for &v in &self.positions {
136            buf.extend_from_slice(&v.to_le_bytes());
137        }
138        // Normals (f64 LE)
139        for &v in &self.normals {
140            buf.extend_from_slice(&v.to_le_bytes());
141        }
142        // Indices (u32 LE)
143        for &i in &self.indices {
144            buf.extend_from_slice(&i.to_le_bytes());
145        }
146
147        buf
148    }
149}
150
151/// A triangle mesh with per-face triangle grouping, exposed to JavaScript.
152///
153/// The binary counterpart to the JSON `tessellateSolidGrouped`: positions and
154/// normals are packed `Float32Array`s and indices/`faceOffsets` are
155/// `Uint32Array`s, so the whole mesh crosses the WASM boundary as bulk memory
156/// copies instead of a JSON string round-trip. `f32` matches what mesh
157/// consumers (GPU vertex buffers) use, halving the transfer versus `f64`.
158#[wasm_bindgen]
159#[derive(Debug)]
160pub struct JsGroupedMesh {
161    positions: Vec<f32>,
162    normals: Vec<f32>,
163    indices: Vec<u32>,
164    face_offsets: Vec<u32>,
165}
166
167#[wasm_bindgen]
168impl JsGroupedMesh {
169    /// Flattened vertex positions as `[x, y, z, ...]`.
170    #[wasm_bindgen(getter)]
171    #[must_use]
172    pub fn positions(&self) -> Vec<f32> {
173        self.positions.clone()
174    }
175
176    /// Flattened per-vertex normals as `[nx, ny, nz, ...]`.
177    #[wasm_bindgen(getter)]
178    #[must_use]
179    pub fn normals(&self) -> Vec<f32> {
180        self.normals.clone()
181    }
182
183    /// Triangle indices (groups of 3).
184    #[wasm_bindgen(getter)]
185    #[must_use]
186    pub fn indices(&self) -> Vec<u32> {
187        self.indices.clone()
188    }
189
190    /// Per-face start offsets into `indices`: `faceOffsets[i]` is the start of
191    /// face `i`, and the final element equals `indices.length`.
192    #[wasm_bindgen(getter, js_name = "faceOffsets")]
193    #[must_use]
194    pub fn face_offsets(&self) -> Vec<u32> {
195        self.face_offsets.clone()
196    }
197}
198
199impl JsGroupedMesh {
200    /// Build from a tessellated mesh and its per-face offsets (crate-internal).
201    ///
202    /// Takes the mesh by value so `indices` is moved rather than cloned — index
203    /// buffers can be large (3 × triangle count) and this is the only consumer.
204    #[must_use]
205    #[allow(clippy::cast_possible_truncation)]
206    pub(crate) fn new(mesh: TriangleMesh, face_offsets: Vec<u32>) -> Self {
207        let mut positions = Vec::with_capacity(mesh.positions.len() * 3);
208        for p in &mesh.positions {
209            positions.push(p.x() as f32);
210            positions.push(p.y() as f32);
211            positions.push(p.z() as f32);
212        }
213        let mut normals = Vec::with_capacity(mesh.normals.len() * 3);
214        for n in &mesh.normals {
215            normals.push(n.x() as f32);
216            normals.push(n.y() as f32);
217            normals.push(n.z() as f32);
218        }
219        Self {
220            positions,
221            normals,
222            indices: mesh.indices,
223            face_offsets,
224        }
225    }
226}
227
228/// Edge polylines for wireframe rendering, exposed to JavaScript.
229///
230/// Positions are flattened to `[x, y, z, x, y, z, ...]` format.
231/// Offsets are float-array indices into `positions` (already multiplied by 3).
232#[wasm_bindgen]
233#[derive(Debug)]
234pub struct JsEdgeLines {
235    positions: Vec<f64>,
236    offsets: Vec<u32>,
237}
238
239#[wasm_bindgen]
240impl JsEdgeLines {
241    /// Flattened vertex positions as `[x, y, z, ...]`.
242    #[wasm_bindgen(getter)]
243    #[must_use]
244    pub fn positions(&self) -> Vec<f64> {
245        self.positions.clone()
246    }
247
248    /// Start index into the flattened positions array for each edge polyline.
249    ///
250    /// The i-th edge's positions span from `positions[offsets[i]]` to
251    /// `positions[offsets[i+1]]` (or to the end for the last edge).
252    /// Each offset is already a float-array index (vertex index × 3).
253    #[wasm_bindgen(getter)]
254    #[must_use]
255    pub fn offsets(&self) -> Vec<u32> {
256        self.offsets.clone()
257    }
258
259    /// Number of edges.
260    #[wasm_bindgen(getter, js_name = "edgeCount")]
261    #[must_use]
262    #[allow(clippy::cast_possible_truncation)]
263    pub fn edge_count(&self) -> u32 {
264        self.offsets.len() as u32
265    }
266
267    /// Return all data in a single packed buffer for efficient FFI transfer.
268    ///
269    /// Layout: `[pos_bytes: u32 LE, off_bytes: u32 LE,
270    ///          positions: f64 LE..., offsets: u32 LE...]`
271    #[wasm_bindgen(js_name = "packedBuffer")]
272    #[must_use]
273    #[allow(clippy::cast_possible_truncation)]
274    pub fn packed_buffer(&self) -> Vec<u8> {
275        let pos_bytes = self.positions.len() * 8;
276        let off_bytes = self.offsets.len() * 4;
277        let header_size = 8; // 2 × u32
278
279        let mut buf = Vec::with_capacity(header_size + pos_bytes + off_bytes);
280
281        buf.extend_from_slice(&(pos_bytes as u32).to_le_bytes());
282        buf.extend_from_slice(&(off_bytes as u32).to_le_bytes());
283
284        for &v in &self.positions {
285            buf.extend_from_slice(&v.to_le_bytes());
286        }
287        for &o in &self.offsets {
288            buf.extend_from_slice(&o.to_le_bytes());
289        }
290
291        buf
292    }
293}
294
295impl From<EdgeLines> for JsEdgeLines {
296    #[allow(clippy::cast_possible_truncation)]
297    fn from(edge_lines: EdgeLines) -> Self {
298        let positions = edge_lines
299            .positions
300            .iter()
301            .flat_map(|p| [p.x(), p.y(), p.z()])
302            .collect();
303
304        let offsets = edge_lines.offsets.iter().map(|&o| (o * 3) as u32).collect();
305
306        Self { positions, offsets }
307    }
308}
309
310impl From<TriangleMesh> for JsMesh {
311    fn from(mesh: TriangleMesh) -> Self {
312        let positions = mesh
313            .positions
314            .iter()
315            .flat_map(|p| [p.x(), p.y(), p.z()])
316            .collect();
317
318        let normals = mesh
319            .normals
320            .iter()
321            .flat_map(|n| [n.x(), n.y(), n.z()])
322            .collect();
323
324        Self {
325            positions,
326            normals,
327            indices: mesh.indices,
328        }
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    #![allow(clippy::unwrap_used, clippy::expect_used)]
335    use super::*;
336    use brepkit_math::vec::{Point3, Vec3};
337
338    #[test]
339    fn grouped_mesh_packs_f32_moves_indices_and_keeps_offsets() {
340        let mesh = TriangleMesh {
341            positions: vec![
342                Point3::new(0.0, 0.0, 0.0),
343                Point3::new(1.0, 0.0, 0.0),
344                Point3::new(0.0, 1.0, 0.0),
345                Point3::new(1.0, 1.0, 0.0),
346            ],
347            normals: vec![Vec3::new(0.0, 0.0, 1.0); 4],
348            indices: vec![0, 1, 2, 1, 3, 2],
349        };
350        // Two faces: first triangle (indices 0..3), second (indices 3..6).
351        let offsets = vec![0, 3, 6];
352        let gm = JsGroupedMesh::new(mesh, offsets.clone());
353
354        // Positions are flattened f32 [x,y,z,...] — 4 verts × 3 = 12 floats.
355        assert_eq!(gm.positions().len(), 12);
356        assert_eq!(
357            gm.positions(),
358            vec![
359                0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0
360            ]
361        );
362        assert_eq!(gm.normals(), [0.0_f32, 0.0, 1.0].repeat(4));
363        assert_eq!(gm.indices(), vec![0_u32, 1, 2, 1, 3, 2]);
364
365        // faceOffsets are stored verbatim and the last entry == indices length.
366        assert_eq!(gm.face_offsets(), offsets);
367        assert_eq!(
368            *gm.face_offsets().last().unwrap() as usize,
369            gm.indices().len()
370        );
371    }
372}