Skip to main content

brepkit_wasm/bindings/
measure.rs

1//! Measurement, validation, and distance query bindings.
2
3#![allow(clippy::missing_errors_doc)]
4
5use wasm_bindgen::prelude::*;
6
7use brepkit_math::vec::Point3;
8use brepkit_operations::measure;
9
10use crate::error::validate_positive;
11use crate::kernel::BrepKernel;
12
13#[wasm_bindgen]
14impl BrepKernel {
15    // ── Measurement ───────────────────────────────────────────────
16
17    /// Compute the axis-aligned bounding box of a solid.
18    ///
19    /// Returns `[min_x, min_y, min_z, max_x, max_y, max_z]`.
20    ///
21    /// # Errors
22    ///
23    /// Returns an error if the solid handle is invalid or has no vertices.
24    #[wasm_bindgen(js_name = "boundingBox")]
25    pub fn bounding_box(&self, solid: u32) -> Result<Vec<f64>, JsError> {
26        let solid_id = self.resolve_solid(solid)?;
27        let aabb = measure::solid_bounding_box(&self.topo, solid_id)?;
28        Ok(vec![
29            aabb.min.x(),
30            aabb.min.y(),
31            aabb.min.z(),
32            aabb.max.x(),
33            aabb.max.y(),
34            aabb.max.z(),
35        ])
36    }
37
38    /// Compute the volume of a solid.
39    ///
40    /// # Errors
41    ///
42    /// Returns an error if the solid handle is invalid or tessellation fails.
43    #[wasm_bindgen(js_name = "volume")]
44    pub fn volume(&self, solid: u32, deflection: f64) -> Result<f64, JsError> {
45        validate_positive(deflection, "deflection")?;
46        let solid_id = self.resolve_solid(solid)?;
47        Ok(measure::solid_volume(&self.topo, solid_id, deflection)?)
48    }
49
50    /// Compute the total surface area of a solid.
51    ///
52    /// # Errors
53    ///
54    /// Returns an error if the solid handle is invalid or tessellation fails.
55    #[wasm_bindgen(js_name = "surfaceArea")]
56    pub fn surface_area(&self, solid: u32, deflection: f64) -> Result<f64, JsError> {
57        validate_positive(deflection, "deflection")?;
58        let solid_id = self.resolve_solid(solid)?;
59        Ok(measure::solid_surface_area(
60            &self.topo, solid_id, deflection,
61        )?)
62    }
63
64    /// Compute the area of a single face.
65    ///
66    /// # Errors
67    ///
68    /// Returns an error if the face handle is invalid or tessellation fails.
69    #[wasm_bindgen(js_name = "faceArea")]
70    pub fn face_area(&self, face: u32, deflection: f64) -> Result<f64, JsError> {
71        validate_positive(deflection, "deflection")?;
72        let face_id = self.resolve_face(face)?;
73        Ok(measure::face_area(&self.topo, face_id, deflection)?)
74    }
75
76    /// Compute the center of mass of a solid (uniform density).
77    ///
78    /// Returns `[x, y, z]`.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if the solid has zero volume or tessellation fails.
83    #[wasm_bindgen(js_name = "centerOfMass")]
84    pub fn center_of_mass(&self, solid: u32, deflection: f64) -> Result<Vec<f64>, JsError> {
85        validate_positive(deflection, "deflection")?;
86        let solid_id = self.resolve_solid(solid)?;
87        let com = measure::solid_center_of_mass(&self.topo, solid_id, deflection)?;
88        Ok(vec![com.x(), com.y(), com.z()])
89    }
90
91    /// Classify a point relative to a solid: inside, outside, or on boundary.
92    ///
93    /// Returns `"inside"`, `"outside"`, or `"boundary"`.
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if the solid handle is invalid.
98    #[wasm_bindgen(js_name = "classifyPoint")]
99    pub fn classify_point(
100        &self,
101        solid: u32,
102        x: f64,
103        y: f64,
104        z: f64,
105        tolerance: f64,
106    ) -> Result<String, JsError> {
107        let solid_id = self.resolve_solid(solid)?;
108        let point = brepkit_math::vec::Point3::new(x, y, z);
109        let result = brepkit_operations::classify::classify_point(
110            &self.topo, solid_id, point, 0.1, tolerance,
111        )?;
112        Ok(match result {
113            brepkit_operations::classify::PointClassification::Inside => "inside".into(),
114            brepkit_operations::classify::PointClassification::Outside => "outside".into(),
115            brepkit_operations::classify::PointClassification::OnBoundary => "boundary".into(),
116        })
117    }
118
119    /// Compute the length of an edge.
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if the edge handle is invalid.
124    #[wasm_bindgen(js_name = "edgeLength")]
125    pub fn edge_length(&self, edge: u32) -> Result<f64, JsError> {
126        let edge_id = self.resolve_edge(edge)?;
127        Ok(measure::edge_length(&self.topo, edge_id)?)
128    }
129
130    /// Compute the perimeter of a face.
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if the face handle is invalid.
135    #[wasm_bindgen(js_name = "facePerimeter")]
136    pub fn face_perimeter(&self, face: u32) -> Result<f64, JsError> {
137        let face_id = self.resolve_face(face)?;
138        Ok(measure::face_perimeter(&self.topo, face_id)?)
139    }
140
141    /// Validate a solid, returning the number of errors found.
142    ///
143    /// Returns 0 if the solid is valid.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if the solid handle is invalid.
148    #[wasm_bindgen(js_name = "validateSolid")]
149    pub fn validate_solid(&self, solid: u32) -> Result<u32, JsError> {
150        let solid_id = self.resolve_solid(solid)?;
151        let report = brepkit_operations::validate::validate_solid(&self.topo, solid_id)?;
152        #[allow(clippy::cast_possible_truncation)]
153        Ok(report.error_count() as u32)
154    }
155
156    /// Validate a solid with relaxed checks suitable for assembled geometry.
157    ///
158    /// Operations like boolean, fillet, and shell produce geometrically
159    /// correct shapes that may not have fully manifold topology (faces
160    /// from different operations may not share edges). This validation
161    /// skips Euler characteristic, boundary edge, non-manifold edge, and
162    /// shell connectivity checks.
163    ///
164    /// Returns 0 if the solid passes all structural checks.
165    ///
166    /// # Errors
167    ///
168    /// Returns an error if the solid handle is invalid.
169    #[wasm_bindgen(js_name = "validateSolidRelaxed")]
170    pub fn validate_solid_relaxed(&self, solid: u32) -> Result<u32, JsError> {
171        let solid_id = self.resolve_solid(solid)?;
172        let report = brepkit_operations::validate::validate_solid_relaxed(&self.topo, solid_id)?;
173        #[allow(clippy::cast_possible_truncation)]
174        Ok(report.error_count() as u32)
175    }
176
177    /// Validate a solid with configurable tolerance scaling.
178    ///
179    /// `tolerance_scale` multiplies geometric tolerances used for the
180    /// face-normal and face-area checks. Use `10.0` to reduce false
181    /// positives on NURBS faces from fillet/shell operations.
182    ///
183    /// Returns 0 if the solid is valid.
184    ///
185    /// # Errors
186    ///
187    /// Returns an error if the solid handle is invalid.
188    #[wasm_bindgen(js_name = "validateSolidWithOptions")]
189    pub fn validate_solid_with_options(
190        &self,
191        solid: u32,
192        tolerance_scale: f64,
193    ) -> Result<u32, JsError> {
194        let solid_id = self.resolve_solid(solid)?;
195        let options = brepkit_operations::validate::ValidationOptions {
196            tolerance_scale,
197            ..Default::default()
198        };
199        let report = brepkit_operations::validate::validate_solid_with_options(
200            &self.topo, solid_id, &options,
201        )?;
202        #[allow(clippy::cast_possible_truncation)]
203        Ok(report.error_count() as u32)
204    }
205
206    // ── Distance ──────────────────────────────────────────────────
207
208    /// Compute minimum distance from a point to a solid.
209    ///
210    /// Returns `[distance, closest_x, closest_y, closest_z]`.
211    ///
212    /// # Errors
213    ///
214    /// Returns an error if the solid handle is invalid.
215    #[wasm_bindgen(js_name = "pointToSolidDistance")]
216    pub fn point_to_solid_distance(
217        &self,
218        px: f64,
219        py: f64,
220        pz: f64,
221        solid: u32,
222    ) -> Result<Vec<f64>, JsError> {
223        let solid_id = self.resolve_solid(solid)?;
224        let result = brepkit_operations::distance::point_to_solid_distance(
225            &self.topo,
226            Point3::new(px, py, pz),
227            solid_id,
228        )?;
229        Ok(vec![
230            result.distance,
231            result.point_b.x(),
232            result.point_b.y(),
233            result.point_b.z(),
234        ])
235    }
236
237    /// Compute minimum distance between two solids.
238    ///
239    /// Returns `[distance, point_a_x, point_a_y, point_a_z, point_b_x, point_b_y, point_b_z]`.
240    ///
241    /// # Errors
242    ///
243    /// Returns an error if either solid handle is invalid.
244    #[wasm_bindgen(js_name = "solidToSolidDistance")]
245    pub fn solid_to_solid_distance(&self, a: u32, b: u32) -> Result<Vec<f64>, JsError> {
246        let a_id = self.resolve_solid(a)?;
247        let b_id = self.resolve_solid(b)?;
248        let result = brepkit_operations::distance::solid_to_solid_distance(&self.topo, a_id, b_id)?;
249        Ok(vec![
250            result.distance,
251            result.point_a.x(),
252            result.point_a.y(),
253            result.point_a.z(),
254            result.point_b.x(),
255            result.point_b.y(),
256            result.point_b.z(),
257        ])
258    }
259
260    /// Compute minimum distance from a point to a face.
261    ///
262    /// Returns `[distance, closest_x, closest_y, closest_z]`.
263    ///
264    /// # Errors
265    ///
266    /// Returns an error if the face handle is invalid.
267    #[wasm_bindgen(js_name = "pointToFaceDistance")]
268    pub fn point_to_face_distance(
269        &self,
270        px: f64,
271        py: f64,
272        pz: f64,
273        face: u32,
274    ) -> Result<Vec<f64>, JsError> {
275        let face_id = self.resolve_face(face)?;
276        let result = brepkit_operations::distance::point_to_face(
277            &self.topo,
278            Point3::new(px, py, pz),
279            face_id,
280        )?;
281        Ok(vec![
282            result.distance,
283            result.point_b.x(),
284            result.point_b.y(),
285            result.point_b.z(),
286        ])
287    }
288
289    /// Compute minimum distance from a point to an edge.
290    ///
291    /// Returns `[distance, closest_x, closest_y, closest_z]`.
292    ///
293    /// # Errors
294    ///
295    /// Returns an error if the edge handle is invalid.
296    #[wasm_bindgen(js_name = "pointToEdgeDistance")]
297    pub fn point_to_edge_distance(
298        &self,
299        px: f64,
300        py: f64,
301        pz: f64,
302        edge: u32,
303    ) -> Result<Vec<f64>, JsError> {
304        let edge_id = self.resolve_edge(edge)?;
305        let result = brepkit_operations::distance::point_to_edge(
306            &self.topo,
307            Point3::new(px, py, pz),
308            edge_id,
309        )?;
310        Ok(vec![
311            result.distance,
312            result.point_b.x(),
313            result.point_b.y(),
314            result.point_b.z(),
315        ])
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    #![allow(clippy::unwrap_used, clippy::expect_used)]
322
323    use crate::kernel::BrepKernel;
324
325    fn batch_has_error(result: &str, idx: usize) -> bool {
326        let parsed: serde_json::Value = serde_json::from_str(result).unwrap();
327        parsed[idx]["error"].is_string()
328    }
329
330    // ── Volume ─────────────────────────────────────────────────────
331
332    #[test]
333    fn box_volume_matches_w_times_h_times_d() {
334        let mut k = BrepKernel::new();
335        let r = k.execute_batch(
336            r#"[
337                {"op": "makeBox", "args": {"width": 2, "height": 3, "depth": 4}},
338                {"op": "volume", "args": {"solid": 0}}
339            ]"#,
340        );
341        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
342        let v = parsed[1]["ok"].as_f64().unwrap();
343        assert!(
344            (v - 24.0).abs() < 0.1,
345            "2x3x4 box volume should be 24.0, got {v}"
346        );
347    }
348
349    #[test]
350    fn unit_box_volume_is_one() {
351        let mut k = BrepKernel::new();
352        let r = k.execute_batch(
353            r#"[
354                {"op": "makeBox", "args": {"width": 1, "height": 1, "depth": 1}},
355                {"op": "volume", "args": {"solid": 0}}
356            ]"#,
357        );
358        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
359        let v = parsed[1]["ok"].as_f64().unwrap();
360        assert!(
361            (v - 1.0).abs() < 1e-6,
362            "unit box volume should be 1.0, got {v}"
363        );
364    }
365
366    #[test]
367    fn volume_invalid_handle_is_error() {
368        let mut k = BrepKernel::new();
369        let r = k.execute_batch(r#"[{"op": "volume", "args": {"solid": 9999}}]"#);
370        assert!(batch_has_error(&r, 0));
371    }
372
373    // ── Surface area ───────────────────────────────────────────────
374
375    #[test]
376    fn box_surface_area_matches_formula() {
377        let (w, h, d) = (2.0_f64, 3.0_f64, 4.0_f64);
378        let expected = 2.0 * (w * h + w * d + h * d);
379        let mut k = BrepKernel::new();
380        let r = k.execute_batch(
381            r#"[
382                {"op": "makeBox", "args": {"width": 2, "height": 3, "depth": 4}},
383                {"op": "surfaceArea", "args": {"solid": 0}}
384            ]"#,
385        );
386        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
387        let area = parsed[1]["ok"].as_f64().unwrap();
388        assert!(
389            (area - expected).abs() < 0.1,
390            "surface area should be {expected}, got {area}"
391        );
392    }
393
394    #[test]
395    fn surface_area_invalid_handle_is_error() {
396        let mut k = BrepKernel::new();
397        let r = k.execute_batch(r#"[{"op": "surfaceArea", "args": {"solid": 9999}}]"#);
398        assert!(batch_has_error(&r, 0));
399    }
400
401    // ── Bounding box ───────────────────────────────────────────────
402
403    #[test]
404    fn box_bounding_box_corner_at_origin() {
405        let mut k = BrepKernel::new();
406        let r = k.execute_batch(
407            r#"[
408                {"op": "makeBox", "args": {"width": 2, "height": 3, "depth": 5}},
409                {"op": "boundingBox", "args": {"solid": 0}}
410            ]"#,
411        );
412        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
413        let bb = parsed[1]["ok"].as_array().unwrap();
414        assert_eq!(bb.len(), 6, "bounding box must have 6 components");
415        let min_x = bb[0].as_f64().unwrap();
416        let min_y = bb[1].as_f64().unwrap();
417        let min_z = bb[2].as_f64().unwrap();
418        let max_x = bb[3].as_f64().unwrap();
419        let max_y = bb[4].as_f64().unwrap();
420        let max_z = bb[5].as_f64().unwrap();
421        assert!(min_x.abs() < 1e-6, "min_x should be ~0, got {min_x}");
422        assert!(min_y.abs() < 1e-6, "min_y should be ~0, got {min_y}");
423        assert!(min_z.abs() < 1e-6, "min_z should be ~0, got {min_z}");
424        assert!(
425            (max_x - 2.0).abs() < 1e-6,
426            "max_x should be ~2, got {max_x}"
427        );
428        assert!(
429            (max_y - 3.0).abs() < 1e-6,
430            "max_y should be ~3, got {max_y}"
431        );
432        assert!(
433            (max_z - 5.0).abs() < 1e-6,
434            "max_z should be ~5, got {max_z}"
435        );
436    }
437
438    #[test]
439    fn bounding_box_invalid_handle_is_error() {
440        let mut k = BrepKernel::new();
441        let r = k.execute_batch(r#"[{"op": "boundingBox", "args": {"solid": 9999}}]"#);
442        assert!(batch_has_error(&r, 0));
443    }
444
445    // ── Center of mass ─────────────────────────────────────────────
446
447    #[test]
448    fn box_center_of_mass_is_geometric_center() {
449        let (w, h, d) = (4.0_f64, 6.0_f64, 2.0_f64);
450        let mut k = BrepKernel::new();
451        let r = k.execute_batch(
452            r#"[
453                {"op": "makeBox", "args": {"width": 4, "height": 6, "depth": 2}},
454                {"op": "centerOfMass", "args": {"solid": 0}}
455            ]"#,
456        );
457        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
458        let com = parsed[1]["ok"].as_array().unwrap();
459        assert_eq!(com.len(), 3);
460        let cx = com[0].as_f64().unwrap();
461        let cy = com[1].as_f64().unwrap();
462        let cz = com[2].as_f64().unwrap();
463        assert!(
464            (cx - w / 2.0).abs() < 0.1,
465            "CoM x should be ~{}, got {cx}",
466            w / 2.0
467        );
468        assert!(
469            (cy - h / 2.0).abs() < 0.1,
470            "CoM y should be ~{}, got {cy}",
471            h / 2.0
472        );
473        assert!(
474            (cz - d / 2.0).abs() < 0.1,
475            "CoM z should be ~{}, got {cz}",
476            d / 2.0
477        );
478    }
479
480    // ── Edge length ────────────────────────────────────────────────
481    // edge_length happy path works because JsError is only constructed
482    // on error; use internal operations for edge-level queries.
483
484    #[test]
485    fn box_edge_length_via_operations() {
486        // Use operations layer directly to avoid JsError on error paths.
487        let mut topo = brepkit_topology::topology::Topology::new();
488        let solid = brepkit_operations::primitives::make_box(&mut topo, 3.0, 3.0, 3.0).unwrap();
489        let edges = brepkit_topology::explorer::solid_edges(&topo, solid).unwrap();
490        assert_eq!(edges.len(), 12, "box must have 12 edges");
491        for &e in &edges {
492            let len = brepkit_operations::measure::edge_length(&topo, e).unwrap();
493            assert!(
494                (len - 3.0).abs() < 1e-6,
495                "all edges of a 3x3x3 box should have length 3, got {len}"
496            );
497        }
498    }
499
500    #[test]
501    fn edge_length_rectangular_box_has_three_distinct_lengths() {
502        let mut topo = brepkit_topology::topology::Topology::new();
503        let solid = brepkit_operations::primitives::make_box(&mut topo, 1.0, 2.0, 4.0).unwrap();
504        let edges = brepkit_topology::explorer::solid_edges(&topo, solid).unwrap();
505        let mut lengths: Vec<f64> = edges
506            .iter()
507            .map(|&e| brepkit_operations::measure::edge_length(&topo, e).unwrap())
508            .collect();
509        lengths.sort_by(|a, b| a.partial_cmp(b).unwrap());
510        assert!((lengths[0] - 1.0).abs() < 1e-6);
511        assert!((lengths[4] - 2.0).abs() < 1e-6);
512        assert!((lengths[8] - 4.0).abs() < 1e-6);
513    }
514
515    // ── Classify point ─────────────────────────────────────────────
516
517    #[test]
518    fn classify_point_inside_box() {
519        let mut k = BrepKernel::new();
520        let r = k.execute_batch(
521            r#"[
522                {"op": "makeBox", "args": {"width": 4, "height": 4, "depth": 4}},
523                {"op": "classifyPoint", "args": {"solid": 0, "x": 2, "y": 2, "z": 2}}
524            ]"#,
525        );
526        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
527        assert_eq!(parsed[1]["ok"].as_str().unwrap(), "inside");
528    }
529
530    #[test]
531    fn classify_point_outside_box() {
532        let mut k = BrepKernel::new();
533        let r = k.execute_batch(
534            r#"[
535                {"op": "makeBox", "args": {"width": 1, "height": 1, "depth": 1}},
536                {"op": "classifyPoint", "args": {"solid": 0, "x": 5, "y": 5, "z": 5}}
537            ]"#,
538        );
539        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
540        assert_eq!(parsed[1]["ok"].as_str().unwrap(), "outside");
541    }
542
543    #[test]
544    fn classify_point_returns_valid_string() {
545        let mut k = BrepKernel::new();
546        let r = k.execute_batch(
547            r#"[
548                {"op": "makeBox", "args": {"width": 2, "height": 2, "depth": 2}},
549                {"op": "classifyPoint", "args": {"solid": 0, "x": 1, "y": 1, "z": 1}}
550            ]"#,
551        );
552        let parsed: serde_json::Value = serde_json::from_str(&r).unwrap();
553        let result = parsed[1]["ok"].as_str().unwrap();
554        assert!(
555            matches!(result, "inside" | "outside" | "boundary"),
556            "classify_point must return inside/outside/boundary, got {result}"
557        );
558    }
559
560    #[test]
561    fn classify_point_invalid_handle_is_error() {
562        let mut k = BrepKernel::new();
563        let r = k.execute_batch(
564            r#"[{"op": "classifyPoint", "args": {"solid": 9999, "x": 0, "y": 0, "z": 0}}]"#,
565        );
566        assert!(batch_has_error(&r, 0));
567    }
568
569    // ── Face area via operations layer ──────────────────────────────
570
571    #[test]
572    fn face_area_of_box_face_is_correct() {
573        let mut topo = brepkit_topology::topology::Topology::new();
574        let solid = brepkit_operations::primitives::make_box(&mut topo, 2.0, 3.0, 4.0).unwrap();
575        let faces = brepkit_topology::explorer::solid_faces(&topo, solid).unwrap();
576        assert_eq!(faces.len(), 6);
577        let valid_areas = [6.0_f64, 8.0_f64, 12.0_f64];
578        for &f in &faces {
579            let area = brepkit_operations::measure::face_area(&topo, f, 0.01).unwrap();
580            let ok = valid_areas.iter().any(|&a| (area - a).abs() < 0.05);
581            assert!(ok, "face area {area} should be one of {valid_areas:?}");
582        }
583    }
584
585    // ── Distance ───────────────────────────────────────────────────
586
587    #[test]
588    fn point_to_solid_distance_from_outside() {
589        let mut topo = brepkit_topology::topology::Topology::new();
590        let solid = brepkit_operations::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
591        let result = brepkit_operations::distance::point_to_solid_distance(
592            &topo,
593            brepkit_math::vec::Point3::new(3.0, 0.5, 0.5),
594            solid,
595        )
596        .unwrap();
597        assert!(
598            (result.distance - 2.0).abs() < 1e-4,
599            "point (3,0.5,0.5) should be 2.0 from box, got {}",
600            result.distance
601        );
602    }
603}