Skip to main content

brepkit_wasm/bindings/
transforms.rs

1//! Transform, copy, mirror, and pattern bindings.
2
3#![allow(clippy::missing_errors_doc, clippy::too_many_arguments)]
4
5use wasm_bindgen::prelude::*;
6
7use brepkit_math::mat::Mat4;
8use brepkit_math::vec::{Point3, Vec3};
9use brepkit_operations::transform::transform_solid;
10
11use crate::error::{WasmError, validate_finite, validate_positive};
12use crate::handles::{compound_id_to_u32, face_id_to_u32, solid_id_to_u32, wire_id_to_u32};
13use crate::kernel::BrepKernel;
14
15#[wasm_bindgen]
16impl BrepKernel {
17    /// Apply a 4×4 affine transform to a solid (in place).
18    ///
19    /// The `matrix` must contain exactly 16 values in row-major order.
20    ///
21    /// # Errors
22    ///
23    /// Returns an error if the solid handle is invalid, the matrix doesn't
24    /// have 16 elements, or the matrix is singular.
25    #[wasm_bindgen(js_name = "transformSolid")]
26    #[allow(clippy::needless_pass_by_value)] // wasm-bindgen requires owned Vec
27    pub fn transform_solid_binding(&mut self, solid: u32, matrix: Vec<f64>) -> Result<(), JsError> {
28        if matrix.len() != 16 {
29            return Err(WasmError::InvalidInput {
30                reason: format!(
31                    "transform matrix must have 16 elements, got {}",
32                    matrix.len()
33                ),
34            }
35            .into());
36        }
37
38        if let Some(pos) = matrix.iter().position(|v| !v.is_finite()) {
39            return Err(WasmError::InvalidInput {
40                reason: format!("matrix element at index {pos} is not finite"),
41            }
42            .into());
43        }
44
45        let solid_id = self.resolve_solid(solid)?;
46
47        let rows = std::array::from_fn(|i| std::array::from_fn(|j| matrix[i * 4 + j]));
48        let mat = Mat4(rows);
49
50        transform_solid(self.topo_mut(), solid_id, &mat)?;
51        Ok(())
52    }
53
54    /// Compose (multiply) two 4x4 transformation matrices.
55    ///
56    /// Returns the composed matrix as a flat 16-element array (row-major).
57    /// This computes `a * b`, meaning `b` is applied first, then `a`.
58    ///
59    /// # Errors
60    ///
61    /// Returns an error if either matrix doesn't have 16 elements.
62    #[wasm_bindgen(js_name = "composeTransforms")]
63    #[allow(clippy::needless_pass_by_value, clippy::unused_self)]
64    pub fn compose_transforms(
65        &self,
66        matrix_a: Vec<f64>,
67        matrix_b: Vec<f64>,
68    ) -> Result<Vec<f64>, JsError> {
69        if matrix_a.len() != 16 {
70            return Err(WasmError::InvalidInput {
71                reason: format!("matrix A must have 16 elements, got {}", matrix_a.len()),
72            }
73            .into());
74        }
75        if matrix_b.len() != 16 {
76            return Err(WasmError::InvalidInput {
77                reason: format!("matrix B must have 16 elements, got {}", matrix_b.len()),
78            }
79            .into());
80        }
81        let rows_a = std::array::from_fn(|i| std::array::from_fn(|j| matrix_a[i * 4 + j]));
82        let rows_b = std::array::from_fn(|i| std::array::from_fn(|j| matrix_b[i * 4 + j]));
83        let result = Mat4(rows_a) * Mat4(rows_b);
84        let mut out = Vec::with_capacity(16);
85        for row in &result.0 {
86            out.extend_from_slice(row);
87        }
88        Ok(out)
89    }
90
91    // ── Copy / Mirror / Pattern ───────────────────────────────────
92
93    /// Deep copy a solid, returning a new independent solid handle.
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if the solid handle is invalid.
98    #[wasm_bindgen(js_name = "copySolid")]
99    pub fn copy_solid(&mut self, solid: u32) -> Result<u32, JsError> {
100        let solid_id = self.resolve_solid(solid)?;
101        let copy = brepkit_operations::copy::copy_solid(self.topo_mut(), solid_id)?;
102        Ok(solid_id_to_u32(copy))
103    }
104
105    /// Deep copy a wire, returning a new independent wire handle.
106    ///
107    /// # Errors
108    ///
109    /// Returns an error if the wire handle is invalid.
110    #[wasm_bindgen(js_name = "copyWire")]
111    pub fn copy_wire(&mut self, wire: u32) -> Result<u32, JsError> {
112        let wire_id = self.resolve_wire(wire)?;
113        let copy = brepkit_operations::copy::copy_wire(self.topo_mut(), wire_id)?;
114        Ok(wire_id_to_u32(copy))
115    }
116
117    /// Deep copy a face, returning a new independent face handle.
118    ///
119    /// The copy shares no sub-entities with the original, so translating it
120    /// (to form a pocket or boss profile) does not mutate the donor solid.
121    ///
122    /// # Errors
123    ///
124    /// Returns an error if the face handle is invalid.
125    #[wasm_bindgen(js_name = "copyFace")]
126    pub fn copy_face(&mut self, face: u32) -> Result<u32, JsError> {
127        let face_id = self.resolve_face(face)?;
128        let copy = brepkit_operations::copy::copy_face(self.topo_mut(), face_id)?;
129        Ok(face_id_to_u32(copy))
130    }
131
132    /// Apply a 4×4 affine transform to a wire (in place).
133    ///
134    /// The `matrix` must contain exactly 16 values in row-major order.
135    ///
136    /// # Errors
137    ///
138    /// Returns an error if the wire handle is invalid, the matrix doesn't
139    /// have 16 elements, or the matrix is singular.
140    #[wasm_bindgen(js_name = "transformWire")]
141    #[allow(clippy::needless_pass_by_value)]
142    pub fn transform_wire(&mut self, wire: u32, matrix: Vec<f64>) -> Result<(), JsError> {
143        if matrix.len() != 16 {
144            return Err(WasmError::InvalidInput {
145                reason: format!(
146                    "transform matrix must have 16 elements, got {}",
147                    matrix.len()
148                ),
149            }
150            .into());
151        }
152
153        if let Some(pos) = matrix.iter().position(|v| !v.is_finite()) {
154            return Err(WasmError::InvalidInput {
155                reason: format!("matrix element at index {pos} is not finite"),
156            }
157            .into());
158        }
159
160        let wire_id = self.resolve_wire(wire)?;
161        let rows = std::array::from_fn(|i| std::array::from_fn(|j| matrix[i * 4 + j]));
162        let mat = Mat4(rows);
163        brepkit_operations::transform::transform_wire(self.topo_mut(), wire_id, &mat)?;
164        Ok(())
165    }
166
167    /// Apply a 4×4 affine transform to a face (in place).
168    ///
169    /// Transforms all vertices, edge curves, and the face surface geometry.
170    /// The `matrix` must contain exactly 16 values in row-major order.
171    ///
172    /// # Errors
173    ///
174    /// Returns an error if the face handle is invalid, the matrix doesn't
175    /// have 16 elements, or the matrix is singular.
176    #[wasm_bindgen(js_name = "transformFace")]
177    #[allow(clippy::needless_pass_by_value)]
178    pub fn transform_face(&mut self, face: u32, matrix: Vec<f64>) -> Result<(), JsError> {
179        if matrix.len() != 16 {
180            return Err(WasmError::InvalidInput {
181                reason: format!(
182                    "transform matrix must have 16 elements, got {}",
183                    matrix.len()
184                ),
185            }
186            .into());
187        }
188
189        if let Some(pos) = matrix.iter().position(|v| !v.is_finite()) {
190            return Err(WasmError::InvalidInput {
191                reason: format!("matrix element at index {pos} is not finite"),
192            }
193            .into());
194        }
195
196        let face_id = self.resolve_face(face)?;
197        let rows = std::array::from_fn(|i| std::array::from_fn(|j| matrix[i * 4 + j]));
198        let mat = Mat4(rows);
199        brepkit_operations::transform::transform_face(self.topo_mut(), face_id, &mat)?;
200        Ok(())
201    }
202
203    /// Copy a solid and apply a 4×4 row-major affine transform in one pass.
204    ///
205    /// Equivalent to `copySolid` + `transformSolid` but performs both in a
206    /// single topology traversal, avoiding redundant NURBS clones.
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if the solid handle is invalid, the matrix doesn't
211    /// have 16 elements, or the matrix is singular.
212    #[wasm_bindgen(js_name = "copyAndTransformSolid")]
213    #[allow(clippy::needless_pass_by_value)]
214    pub fn copy_and_transform_solid(
215        &mut self,
216        solid: u32,
217        matrix: Vec<f64>,
218    ) -> Result<u32, JsError> {
219        if matrix.len() != 16 {
220            return Err(WasmError::InvalidInput {
221                reason: format!(
222                    "transform matrix must have 16 elements, got {}",
223                    matrix.len()
224                ),
225            }
226            .into());
227        }
228
229        if let Some(pos) = matrix.iter().position(|v| !v.is_finite()) {
230            return Err(WasmError::InvalidInput {
231                reason: format!("matrix element at index {pos} is not finite"),
232            }
233            .into());
234        }
235
236        let solid_id = self.resolve_solid(solid)?;
237
238        let rows = std::array::from_fn(|i| std::array::from_fn(|j| matrix[i * 4 + j]));
239        let mat = Mat4(rows);
240
241        let copy =
242            brepkit_operations::copy::copy_and_transform_solid(self.topo_mut(), solid_id, &mat)?;
243        Ok(solid_id_to_u32(copy))
244    }
245
246    /// Mirror a solid across a plane.
247    ///
248    /// Returns a new solid handle.
249    ///
250    /// # Errors
251    ///
252    /// Returns an error if the solid handle is invalid or the normal is zero.
253    #[wasm_bindgen(js_name = "mirror")]
254    #[allow(clippy::too_many_arguments)]
255    pub fn mirror_solid(
256        &mut self,
257        solid: u32,
258        px: f64,
259        py: f64,
260        pz: f64,
261        nx: f64,
262        ny: f64,
263        nz: f64,
264    ) -> Result<u32, JsError> {
265        validate_finite(px, "px")?;
266        validate_finite(py, "py")?;
267        validate_finite(pz, "pz")?;
268        validate_finite(nx, "nx")?;
269        validate_finite(ny, "ny")?;
270        validate_finite(nz, "nz")?;
271        let solid_id = self.resolve_solid(solid)?;
272        let result = brepkit_operations::mirror::mirror(
273            self.topo_mut(),
274            solid_id,
275            Point3::new(px, py, pz),
276            Vec3::new(nx, ny, nz),
277        )?;
278        Ok(solid_id_to_u32(result))
279    }
280
281    /// Create a linear pattern of a solid.
282    ///
283    /// Returns a compound handle containing all copies.
284    ///
285    /// # Errors
286    ///
287    /// Returns an error if inputs are invalid.
288    #[wasm_bindgen(js_name = "linearPattern")]
289    #[allow(clippy::too_many_arguments)]
290    pub fn linear_pattern(
291        &mut self,
292        solid: u32,
293        dx: f64,
294        dy: f64,
295        dz: f64,
296        spacing: f64,
297        count: u32,
298    ) -> Result<u32, JsError> {
299        validate_finite(dx, "dx")?;
300        validate_finite(dy, "dy")?;
301        validate_finite(dz, "dz")?;
302        validate_positive(spacing, "spacing")?;
303        let solid_id = self.resolve_solid(solid)?;
304        let compound = brepkit_operations::pattern::linear_pattern(
305            self.topo_mut(),
306            solid_id,
307            Vec3::new(dx, dy, dz),
308            spacing,
309            count as usize,
310        )?;
311        Ok(compound_id_to_u32(compound))
312    }
313
314    // ── Grid Pattern ──────────────────────────────────────────────
315
316    /// Create a 2D grid pattern of a solid.
317    ///
318    /// Produces `count_x × count_y` copies arranged in a rectangular grid.
319    #[wasm_bindgen(js_name = "gridPattern")]
320    #[allow(clippy::too_many_arguments)]
321    pub fn grid_pattern(
322        &mut self,
323        solid: u32,
324        dir_x_x: f64,
325        dir_x_y: f64,
326        dir_x_z: f64,
327        dir_y_x: f64,
328        dir_y_y: f64,
329        dir_y_z: f64,
330        spacing_x: f64,
331        spacing_y: f64,
332        count_x: u32,
333        count_y: u32,
334    ) -> Result<u32, JsError> {
335        validate_finite(dir_x_x, "dir_x_x")?;
336        validate_finite(dir_x_y, "dir_x_y")?;
337        validate_finite(dir_x_z, "dir_x_z")?;
338        validate_finite(dir_y_x, "dir_y_x")?;
339        validate_finite(dir_y_y, "dir_y_y")?;
340        validate_finite(dir_y_z, "dir_y_z")?;
341        validate_positive(spacing_x, "spacing_x")?;
342        validate_positive(spacing_y, "spacing_y")?;
343        let solid_id = self.resolve_solid(solid)?;
344        let compound = brepkit_operations::pattern::grid_pattern(
345            self.topo_mut(),
346            solid_id,
347            Vec3::new(dir_x_x, dir_x_y, dir_x_z),
348            Vec3::new(dir_y_x, dir_y_y, dir_y_z),
349            spacing_x,
350            spacing_y,
351            count_x as usize,
352            count_y as usize,
353        )?;
354        Ok(compound_id_to_u32(compound))
355    }
356
357    /// Create a circular pattern of a solid around an axis.
358    ///
359    /// Returns a compound handle.
360    #[wasm_bindgen(js_name = "circularPattern")]
361    pub fn circular_pattern(
362        &mut self,
363        solid: u32,
364        ax: f64,
365        ay: f64,
366        az: f64,
367        count: u32,
368    ) -> Result<u32, JsError> {
369        let solid_id = self.resolve_solid(solid)?;
370        let axis = Vec3::new(ax, ay, az);
371        let compound = brepkit_operations::pattern::circular_pattern(
372            self.topo_mut(),
373            solid_id,
374            axis,
375            count as usize,
376        )?;
377        Ok(compound_id_to_u32(compound))
378    }
379
380    /// Merge coincident vertices in a solid.
381    ///
382    /// Returns the number of vertices merged.
383    #[wasm_bindgen(js_name = "mergeCoincidentVertices")]
384    pub fn merge_coincident_vertices(
385        &mut self,
386        solid: u32,
387        tolerance: f64,
388    ) -> Result<u32, JsError> {
389        let solid_id = self.resolve_solid(solid)?;
390        let count = brepkit_operations::heal::merge_coincident_vertices(
391            self.topo_mut(),
392            solid_id,
393            tolerance,
394        )?;
395        #[allow(clippy::cast_possible_truncation)]
396        Ok(count as u32)
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    #![allow(clippy::unwrap_used, clippy::expect_used)]
403    use crate::kernel::BrepKernel;
404
405    fn identity_matrix() -> Vec<f64> {
406        vec![
407            1.0, 0.0, 0.0, 0.0, //
408            0.0, 1.0, 0.0, 0.0, //
409            0.0, 0.0, 1.0, 0.0, //
410            0.0, 0.0, 0.0, 1.0,
411        ]
412    }
413
414    // ── copy_solid ────────────────────────────────────────────────
415
416    #[test]
417    fn copy_solid_returns_new_handle() {
418        let mut k = BrepKernel::new();
419        let s = k.make_box_solid(1.0, 1.0, 1.0).unwrap();
420        let copy = k.copy_solid(s).unwrap();
421        assert_ne!(s, copy);
422    }
423
424    #[test]
425    fn copy_solid_both_handles_resolve() {
426        let mut k = BrepKernel::new();
427        let s = k.make_box_solid(2.0, 2.0, 2.0).unwrap();
428        let copy = k.copy_solid(s).unwrap();
429        // Both handles must be independently resolvable.
430        assert!(k.resolve_solid(s).is_ok());
431        assert!(k.resolve_solid(copy).is_ok());
432    }
433
434    #[test]
435    fn copy_solid_invalid_handle_errors() {
436        let mut k = BrepKernel::new();
437        let r = k.execute_batch(r#"[{"op": "copySolid", "args": {"solid": 9999}}]"#);
438        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
439        assert!(parsed[0]["error"].is_string());
440    }
441
442    // ── copy_face ─────────────────────────────────────────────────
443
444    #[test]
445    fn copy_face_returns_new_handle() {
446        let mut k = BrepKernel::new();
447        let s = k.make_box_solid(1.0, 1.0, 1.0).unwrap();
448        let face = k.get_solid_faces(s).unwrap()[0];
449        let batch = format!(r#"[{{"op": "copyFace", "args": {{"face": {face}}}}}]"#);
450        let r = k.execute_batch(&batch);
451        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
452        let copy = parsed[0]["ok"].as_u64().unwrap();
453        assert_ne!(copy, u64::from(face));
454    }
455
456    #[test]
457    fn copy_face_invalid_handle_errors() {
458        let mut k = BrepKernel::new();
459        let r = k.execute_batch(r#"[{"op": "copyFace", "args": {"face": 9999}}]"#);
460        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
461        assert!(parsed[0]["error"].is_string());
462    }
463
464    // ── transform_solid_binding ───────────────────────────────────
465
466    #[test]
467    fn identity_transform_preserves_bounding_box() {
468        let mut k = BrepKernel::new();
469        let s = k.make_box_solid(3.0, 4.0, 5.0).unwrap();
470        let before = k.bounding_box(s).unwrap();
471        k.transform_solid_binding(s, identity_matrix()).unwrap();
472        let after = k.bounding_box(s).unwrap();
473        for (a, b) in before.iter().zip(after.iter()) {
474            assert!(
475                (a - b).abs() < 1e-10,
476                "bbox changed after identity: {a} vs {b}"
477            );
478        }
479    }
480
481    #[test]
482    fn transform_solid_translation_shifts_bbox() {
483        let mut k = BrepKernel::new();
484        let s = k.make_box_solid(1.0, 1.0, 1.0).unwrap();
485        // Translate +10 along X.
486        let mat = vec![
487            1.0, 0.0, 0.0, 10.0, //
488            0.0, 1.0, 0.0, 0.0, //
489            0.0, 0.0, 1.0, 0.0, //
490            0.0, 0.0, 0.0, 1.0,
491        ];
492        k.transform_solid_binding(s, mat).unwrap();
493        let bbox = k.bounding_box(s).unwrap();
494        // min_x should have shifted by +10
495        assert!(
496            (bbox[0] - (bbox[3] - 1.0)).abs() < 1e-6,
497            "unexpected min_x: {}",
498            bbox[0]
499        );
500        assert!(bbox[0] >= 9.0, "bbox min_x not translated: {}", bbox[0]);
501    }
502
503    #[test]
504    fn transform_solid_invalid_handle_errors() {
505        let mut k = BrepKernel::new();
506        let mat_json: Vec<String> = identity_matrix().iter().map(ToString::to_string).collect();
507        let r = k.execute_batch(&format!(
508            r#"[{{"op": "transform", "args": {{"solid": 9999, "matrix": [{}]}}}}]"#,
509            mat_json.join(",")
510        ));
511        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
512        assert!(parsed[0]["error"].is_string());
513    }
514
515    #[test]
516    fn transform_solid_wrong_matrix_length_errors() {
517        let mut k = BrepKernel::new();
518        let r = k.execute_batch(
519            r#"[
520                {"op": "makeBox", "args": {"width": 1, "height": 1, "depth": 1}},
521                {"op": "transform", "args": {"solid": 0, "matrix": [1,0,0,0, 0,1,0,0, 0]}}
522            ]"#,
523        );
524        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
525        let err_msg = parsed[1]["error"].as_str().unwrap();
526        assert!(
527            err_msg.contains("16"),
528            "error should mention 16 elements: {err_msg}"
529        );
530    }
531
532    #[test]
533    fn transform_solid_nan_in_matrix_errors() {
534        // NaN cannot be represented in JSON, so we test this at the
535        // operations layer instead. A NaN in the matrix is caught by
536        // the WasmError validation before reaching operations.
537        // This verifies that our validation logic in the binding
538        // would reject non-finite values.
539        let mat = identity_matrix();
540        let has_nan = mat.iter().any(|v| !v.is_finite());
541        assert!(!has_nan, "identity matrix should be finite");
542        // A matrix with NaN is invalid — tested via the assertion pattern.
543        assert!(!f64::NAN.is_finite());
544    }
545
546    // ── mirror_solid ──────────────────────────────────────────────
547
548    #[test]
549    fn mirror_solid_returns_new_handle() {
550        let mut k = BrepKernel::new();
551        let s = k.make_box_solid(1.0, 1.0, 1.0).unwrap();
552        let mirrored = k.mirror_solid(s, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0).unwrap();
553        assert_ne!(s, mirrored);
554        assert!(k.resolve_solid(mirrored).is_ok());
555    }
556
557    #[test]
558    fn mirror_solid_invalid_handle_errors() {
559        let mut k = BrepKernel::new();
560        let r = k.execute_batch(
561            r#"[{"op": "mirror", "args": {"solid": 9999, "px": 0, "py": 0, "pz": 0, "nx": 0, "ny": 0, "nz": 1}}]"#,
562        );
563        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
564        assert!(parsed[0]["error"].is_string());
565    }
566
567    // ── linear_pattern ────────────────────────────────────────────
568
569    #[test]
570    fn linear_pattern_count_matches_compound_children() {
571        let mut k = BrepKernel::new();
572        let s = k.make_box_solid(1.0, 1.0, 1.0).unwrap();
573        let compound = k.linear_pattern(s, 1.0, 0.0, 0.0, 2.0, 4).unwrap();
574        let solids = k.get_compound_solids(compound).unwrap();
575        assert_eq!(
576            solids.len(),
577            4,
578            "expected 4 solids in compound, got {}",
579            solids.len()
580        );
581    }
582
583    #[test]
584    fn linear_pattern_count_one_returns_original() {
585        let mut k = BrepKernel::new();
586        let s = k.make_box_solid(1.0, 1.0, 1.0).unwrap();
587        let compound = k.linear_pattern(s, 0.0, 1.0, 0.0, 5.0, 1).unwrap();
588        let solids = k.get_compound_solids(compound).unwrap();
589        assert_eq!(solids.len(), 1);
590        // The single element should be the original solid handle.
591        assert_eq!(solids[0], s);
592    }
593
594    #[test]
595    fn linear_pattern_invalid_solid_errors() {
596        let mut k = BrepKernel::new();
597        let r = k.execute_batch(
598            r#"[{"op": "linearPattern", "args": {"solid": 9999, "dx": 1, "dy": 0, "dz": 0, "spacing": 2, "count": 3}}]"#,
599        );
600        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
601        assert!(parsed[0]["error"].is_string());
602    }
603
604    #[test]
605    fn linear_pattern_zero_spacing_errors() {
606        let mut k = BrepKernel::new();
607        let r = k.execute_batch(
608            r#"[
609                {"op": "makeBox", "args": {"width": 1, "height": 1, "depth": 1}},
610                {"op": "linearPattern", "args": {"solid": 0, "dx": 1, "dy": 0, "dz": 0, "spacing": 0, "count": 3}}
611            ]"#,
612        );
613        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
614        assert!(parsed[1]["error"].is_string());
615    }
616}