Skip to main content

brepkit_wasm/
kernel.rs

1//! The `BrepKernel` — a WASM-exposed modeling context.
2//!
3//! JavaScript consumers create a single `BrepKernel` instance and call
4//! methods on it to build and query geometry. All topological state is
5//! owned by the kernel; JS only holds opaque `u32` handles.
6
7#![allow(
8    clippy::missing_errors_doc,
9    clippy::too_many_arguments,
10    clippy::redundant_closure,
11    clippy::redundant_closure_for_method_calls,
12    clippy::map_unwrap_or,
13    clippy::expect_used
14)]
15
16use std::rc::Rc;
17
18use brepkit_math::curves::{Circle3D, Ellipse3D};
19use brepkit_math::curves2d::Line2D;
20use brepkit_math::nurbs::curve::NurbsCurve;
21use brepkit_math::nurbs::surface::NurbsSurface;
22use brepkit_math::surfaces::{
23    ConicalSurface, CylindricalSurface, SphericalSurface, ToroidalSurface,
24};
25use brepkit_math::vec::{Point2, Point3, Vec2, Vec3};
26use brepkit_topology::Topology;
27use brepkit_topology::edge::{Edge, EdgeCurve};
28use brepkit_topology::face::{Face, FaceSurface};
29use brepkit_topology::vertex::Vertex;
30use brepkit_topology::wire::{OrientedEdge, Wire};
31use wasm_bindgen::prelude::*;
32
33use crate::error::{WasmError, validate_finite};
34use crate::handles::{edge_id_to_u32, solid_id_to_u32};
35use crate::helpers::TOL;
36use crate::state::{Checkpoint, SketchState};
37
38/// The B-Rep modeling kernel.
39///
40/// Owns all topological state. JavaScript holds this reference and
41/// invokes methods to create, transform, and query geometry.
42#[wasm_bindgen]
43pub struct BrepKernel {
44    pub(crate) topo: Rc<Topology>,
45    pub(crate) assemblies: Vec<brepkit_operations::assembly::Assembly>,
46    pub(crate) sketches: Vec<SketchState>,
47    pub(crate) checkpoints: Vec<Checkpoint>,
48    pub(crate) poisoned: bool,
49}
50
51#[wasm_bindgen]
52impl BrepKernel {
53    /// Create a new, empty kernel.
54    #[wasm_bindgen(constructor)]
55    #[must_use]
56    pub fn new() -> Self {
57        crate::panics::install_hook();
58        Self {
59            topo: Rc::new(Topology::new()),
60            assemblies: Vec::new(),
61            sketches: Vec::new(),
62            checkpoints: Vec::new(),
63            poisoned: false,
64        }
65    }
66}
67
68impl Default for BrepKernel {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74// ── Private helpers ────────────────────────────────────────────────
75
76impl BrepKernel {
77    /// Returns a mutable reference to the topology, cloning if shared
78    /// with any checkpoints (copy-on-write).
79    pub(crate) fn topo_mut(&mut self) -> &mut Topology {
80        Rc::make_mut(&mut self.topo)
81    }
82
83    /// Returns an immutable reference to the topology.
84    pub(crate) fn topo(&self) -> &Topology {
85        &self.topo
86    }
87
88    /// Inner implementation for `make_tangent_arc_3d`.
89    #[allow(clippy::too_many_arguments)]
90    pub(crate) fn make_tangent_arc_3d_impl(
91        &mut self,
92        start_x: f64,
93        start_y: f64,
94        start_z: f64,
95        tangent_x: f64,
96        tangent_y: f64,
97        tangent_z: f64,
98        end_x: f64,
99        end_y: f64,
100        end_z: f64,
101    ) -> Result<u32, WasmError> {
102        for (v, name) in [
103            (start_x, "startX"),
104            (start_y, "startY"),
105            (start_z, "startZ"),
106            (tangent_x, "tangentX"),
107            (tangent_y, "tangentY"),
108            (tangent_z, "tangentZ"),
109            (end_x, "endX"),
110            (end_y, "endY"),
111            (end_z, "endZ"),
112        ] {
113            validate_finite(v, name)?;
114        }
115
116        let start = Point3::new(start_x, start_y, start_z);
117        let end = Point3::new(end_x, end_y, end_z);
118        let tangent = Vec3::new(tangent_x, tangent_y, tangent_z);
119
120        let chord = end - start;
121        if chord.length() < TOL {
122            return Err(WasmError::InvalidInput {
123                reason: "start and end points coincide".into(),
124            });
125        }
126
127        let t_norm = tangent.normalize().map_err(|e| WasmError::InvalidInput {
128            reason: format!("invalid tangent: {e}"),
129        })?;
130
131        // Tangent parallel to chord means the points are collinear.
132        let cross = t_norm.cross(chord);
133        if cross.length() < 1e-10 * chord.length() {
134            let v_start = self.topo_mut().add_vertex(Vertex::new(start, TOL));
135            let v_end = self.topo_mut().add_vertex(Vertex::new(end, TOL));
136            let eid = self
137                .topo_mut()
138                .add_edge(Edge::new(v_start, v_end, EdgeCurve::Line));
139            return Ok(edge_id_to_u32(eid));
140        }
141
142        // Arc geometry: find center and radius from the tangent constraint.
143        let normal = cross.normalize().map_err(|e| WasmError::InvalidInput {
144            reason: format!("degenerate arc plane: {e}"),
145        })?;
146        let perp = normal.cross(t_norm);
147        let half_proj = chord.length_squared() / (2.0 * perp.dot(chord));
148        let center = start + perp * half_proj;
149        let radius = half_proj.abs();
150
151        let u_axis = (start - center)
152            .normalize()
153            .map_err(|e| WasmError::InvalidInput {
154                reason: format!("degenerate u_axis: {e}"),
155            })?;
156        let v_axis = normal.cross(u_axis);
157
158        let circle = Circle3D::with_axes(center, normal, radius, u_axis, v_axis).map_err(|e| {
159            WasmError::InvalidInput {
160                reason: format!("invalid circle: {e}"),
161            }
162        })?;
163
164        let v_start = self.topo_mut().add_vertex(Vertex::new(start, TOL));
165        let v_end = if (start - end).length() < TOL * 100.0 {
166            v_start
167        } else {
168            self.topo_mut().add_vertex(Vertex::new(end, TOL))
169        };
170        let eid = self
171            .topo_mut()
172            .add_edge(Edge::new(v_start, v_end, EdgeCurve::Circle(circle)));
173        Ok(edge_id_to_u32(eid))
174    }
175
176    /// Inner implementation for `lift_curve2d_to_plane`.
177    #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
178    pub(crate) fn lift_curve2d_to_plane_impl(
179        &mut self,
180        curve_type: u32,
181        curve_params: Vec<f64>,
182        origin_x: f64,
183        origin_y: f64,
184        origin_z: f64,
185        x_axis_x: f64,
186        x_axis_y: f64,
187        x_axis_z: f64,
188        normal_x: f64,
189        normal_y: f64,
190        normal_z: f64,
191        t_start: f64,
192        t_end: f64,
193    ) -> Result<u32, WasmError> {
194        validate_finite(origin_x, "originX")?;
195        validate_finite(origin_y, "originY")?;
196        validate_finite(origin_z, "originZ")?;
197        validate_finite(x_axis_x, "xAxisX")?;
198        validate_finite(x_axis_y, "xAxisY")?;
199        validate_finite(x_axis_z, "xAxisZ")?;
200        validate_finite(normal_x, "normalX")?;
201        validate_finite(normal_y, "normalY")?;
202        validate_finite(normal_z, "normalZ")?;
203        validate_finite(t_start, "tStart")?;
204        validate_finite(t_end, "tEnd")?;
205
206        if curve_type > 3 {
207            return Err(WasmError::InvalidInput {
208                reason: format!("curve_type must be 0–3, got {curve_type}"),
209            });
210        }
211
212        for (i, &v) in curve_params.iter().enumerate() {
213            validate_finite(v, &format!("curveParams[{i}]"))?;
214        }
215
216        let normal = Vec3::new(normal_x, normal_y, normal_z)
217            .normalize()
218            .map_err(|e| WasmError::InvalidInput {
219                reason: format!("invalid normal: {e}"),
220            })?;
221        let x_raw = Vec3::new(x_axis_x, x_axis_y, x_axis_z);
222        let x_axis = (x_raw - normal * x_raw.dot(normal))
223            .normalize()
224            .map_err(|e| WasmError::InvalidInput {
225                reason: format!("invalid x_axis (parallel to normal?): {e}"),
226            })?;
227        let y_axis = normal.cross(x_axis);
228        let origin = Point3::new(origin_x, origin_y, origin_z);
229
230        let lift = |x: f64, y: f64| -> Point3 { origin + x_axis * x + y_axis * y };
231
232        match curve_type {
233            0 => {
234                if curve_params.len() != 4 {
235                    return Err(WasmError::InvalidInput {
236                        reason: format!(
237                            "Line2D expects 4 params [ox,oy,dx,dy], got {}",
238                            curve_params.len()
239                        ),
240                    });
241                }
242                let line2d = Line2D::new(
243                    Point2::new(curve_params[0], curve_params[1]),
244                    Vec2::new(curve_params[2], curve_params[3]),
245                )
246                .map_err(|e| WasmError::InvalidInput {
247                    reason: format!("invalid Line2D: {e}"),
248                })?;
249                let p0 = line2d.evaluate(t_start);
250                let p1 = line2d.evaluate(t_end);
251                let start_3d = lift(p0.x(), p0.y());
252                let end_3d = lift(p1.x(), p1.y());
253                if (end_3d - start_3d).length() < TOL {
254                    return Err(WasmError::InvalidInput {
255                        reason: "degenerate line segment (start ≈ end)".into(),
256                    });
257                }
258                let v_start = self.topo_mut().add_vertex(Vertex::new(start_3d, TOL));
259                let v_end = self.topo_mut().add_vertex(Vertex::new(end_3d, TOL));
260                let eid = self
261                    .topo_mut()
262                    .add_edge(Edge::new(v_start, v_end, EdgeCurve::Line));
263                Ok(edge_id_to_u32(eid))
264            }
265            1 => {
266                if curve_params.len() != 3 {
267                    return Err(WasmError::InvalidInput {
268                        reason: format!(
269                            "Circle expects 3 params [cx,cy,r], got {}",
270                            curve_params.len()
271                        ),
272                    });
273                }
274                let center_3d = lift(curve_params[0], curve_params[1]);
275                let radius = curve_params[2];
276                let circle = Circle3D::with_axes(center_3d, normal, radius, x_axis, y_axis)
277                    .map_err(|e| WasmError::InvalidInput {
278                        reason: format!("invalid Circle3D: {e}"),
279                    })?;
280
281                let start_3d = circle.evaluate(t_start);
282                let end_3d = circle.evaluate(t_end);
283
284                let full_circle = (t_end - t_start).abs() >= std::f64::consts::TAU - 1e-10;
285                let v_start = self.topo_mut().add_vertex(Vertex::new(start_3d, TOL));
286                let v_end = if full_circle {
287                    v_start
288                } else {
289                    self.topo_mut().add_vertex(Vertex::new(end_3d, TOL))
290                };
291                let eid =
292                    self.topo_mut()
293                        .add_edge(Edge::new(v_start, v_end, EdgeCurve::Circle(circle)));
294                Ok(edge_id_to_u32(eid))
295            }
296            2 => {
297                if curve_params.len() != 5 {
298                    return Err(WasmError::InvalidInput {
299                        reason: format!(
300                            "Ellipse expects 5 params [cx,cy,a,b,rot], got {}",
301                            curve_params.len()
302                        ),
303                    });
304                }
305                let semi_major = curve_params[2];
306                let semi_minor = curve_params[3];
307                let rotation = curve_params[4];
308
309                let center_3d = lift(curve_params[0], curve_params[1]);
310                let (sin_r, cos_r) = rotation.sin_cos();
311                let u3d = x_axis * cos_r + y_axis * sin_r;
312                let v3d = y_axis * cos_r - x_axis * sin_r;
313                let ellipse =
314                    Ellipse3D::with_axes(center_3d, normal, semi_major, semi_minor, u3d, v3d)
315                        .map_err(|e| WasmError::InvalidInput {
316                            reason: format!("invalid Ellipse3D: {e}"),
317                        })?;
318
319                let start_3d = ellipse.evaluate(t_start);
320                let end_3d = ellipse.evaluate(t_end);
321
322                let full_ellipse = (t_end - t_start).abs() >= std::f64::consts::TAU - 1e-10;
323                let v_start = self.topo_mut().add_vertex(Vertex::new(start_3d, TOL));
324                let v_end = if full_ellipse {
325                    v_start
326                } else {
327                    self.topo_mut().add_vertex(Vertex::new(end_3d, TOL))
328                };
329                let eid = self.topo_mut().add_edge(Edge::new(
330                    v_start,
331                    v_end,
332                    EdgeCurve::Ellipse(ellipse),
333                ));
334                Ok(edge_id_to_u32(eid))
335            }
336            3 => {
337                if curve_params.len() < 2 {
338                    return Err(WasmError::InvalidInput {
339                        reason: "NURBS params too short (need at least degree + n_cp)".into(),
340                    });
341                }
342                let raw_degree = curve_params[0];
343                let raw_n_cp = curve_params[1];
344                if !(1.0..=16.0).contains(&raw_degree) || raw_degree.fract() != 0.0 {
345                    return Err(WasmError::InvalidInput {
346                        reason: format!(
347                            "NURBS degree must be an integer in [1, 16], got {raw_degree}"
348                        ),
349                    });
350                }
351                if !(1.0..=4096.0).contains(&raw_n_cp) || raw_n_cp.fract() != 0.0 {
352                    return Err(WasmError::InvalidInput {
353                        reason: format!(
354                            "NURBS n_cp must be an integer in [1, 4096], got {raw_n_cp}"
355                        ),
356                    });
357                }
358                #[allow(clippy::cast_possible_truncation)]
359                let degree = raw_degree as usize;
360                #[allow(clippy::cast_possible_truncation)]
361                let n_cp = raw_n_cp as usize;
362                let n_knots = n_cp + degree + 1;
363                let expected_len = 2 + n_knots + 3 * n_cp;
364                if curve_params.len() != expected_len {
365                    return Err(WasmError::InvalidInput {
366                        reason: format!(
367                            "NURBS params: expected {expected_len} elements \
368                             (2 + {n_knots} knots + {} coords + {n_cp} weights), got {}",
369                            2 * n_cp,
370                            curve_params.len()
371                        ),
372                    });
373                }
374                let knots = curve_params[2..2 + n_knots].to_vec();
375                let coords_start = 2 + n_knots;
376                let weights_start = coords_start + 2 * n_cp;
377                let control_points_3d: Vec<Point3> = curve_params[coords_start..weights_start]
378                    .chunks_exact(2)
379                    .map(|c| lift(c[0], c[1]))
380                    .collect();
381                let weights = curve_params[weights_start..weights_start + n_cp].to_vec();
382
383                let curve = NurbsCurve::new(degree, knots, control_points_3d, weights)?;
384                let start_3d = curve.evaluate(t_start);
385                let end_3d = curve.evaluate(t_end);
386
387                let v_start = self.topo_mut().add_vertex(Vertex::new(start_3d, TOL));
388                let v_end = if (start_3d - end_3d).length() < TOL * 100.0 {
389                    v_start
390                } else {
391                    self.topo_mut().add_vertex(Vertex::new(end_3d, TOL))
392                };
393                let eid = self.topo_mut().add_edge(Edge::new(
394                    v_start,
395                    v_end,
396                    EdgeCurve::NurbsCurve(curve),
397                ));
398                Ok(edge_id_to_u32(eid))
399            }
400            _ => Err(WasmError::InvalidInput {
401                reason: format!("curve_type must be 0–3, got {curve_type}"),
402            }),
403        }
404    }
405
406    /// Build a closed planar face from an ordered sequence of points.
407    pub(crate) fn make_planar_face(
408        &mut self,
409        points: &[Point3],
410    ) -> Result<brepkit_topology::face::FaceId, WasmError> {
411        Ok(brepkit_topology::builder::make_planar_face(
412            self.topo_mut(),
413            points,
414            TOL,
415        )?)
416    }
417
418    /// Compute a plane surface from the vertices of a wire.
419    ///
420    /// Uses the first three non-collinear vertex positions of the wire's
421    /// edges to derive a plane normal and signed distance `d`.
422    fn compute_plane_from_wire(
423        &self,
424        wire_id: brepkit_topology::wire::WireId,
425    ) -> Result<FaceSurface, WasmError> {
426        let wire = self.topo.wire(wire_id)?;
427        let mut points = Vec::new();
428        for oe in wire.edges() {
429            let edge = self.topo.edge(oe.edge())?;
430            let start_pos = self.topo.vertex(edge.start())?.point();
431            points.push(start_pos);
432        }
433        if points.len() < 3 {
434            return Err(WasmError::InvalidInput {
435                reason: "need at least 3 vertices to compute a plane".into(),
436            });
437        }
438        let e1 = points[1] - points[0];
439        let e2 = points[2] - points[0];
440        let normal = e1.cross(e2).normalize()?;
441        let p0 = points[0];
442        let d = normal
443            .x()
444            .mul_add(p0.x(), normal.y().mul_add(p0.y(), normal.z() * p0.z()));
445        Ok(FaceSurface::Plane { normal, d })
446    }
447
448    /// Parse a JSON array of 3 floats into a `Vec3`.
449    fn parse_vec3(arr: &[serde_json::Value], name: &str) -> Result<Vec3, WasmError> {
450        let x = arr
451            .first()
452            .and_then(|v| v.as_f64())
453            .ok_or_else(|| WasmError::InvalidInput {
454                reason: format!("{name}[0] is not a number"),
455            })?;
456        let y = arr
457            .get(1)
458            .and_then(|v| v.as_f64())
459            .ok_or_else(|| WasmError::InvalidInput {
460                reason: format!("{name}[1] is not a number"),
461            })?;
462        let z = arr
463            .get(2)
464            .and_then(|v| v.as_f64())
465            .ok_or_else(|| WasmError::InvalidInput {
466                reason: format!("{name}[2] is not a number"),
467            })?;
468        Ok(Vec3::new(x, y, z))
469    }
470
471    /// Parse a JSON array of 3 floats into a `Point3`.
472    fn parse_point3(arr: &[serde_json::Value], name: &str) -> Result<Point3, WasmError> {
473        let v = Self::parse_vec3(arr, name)?;
474        Ok(Point3::new(v.x(), v.y(), v.z()))
475    }
476
477    /// Internal implementation of `fromBREP` that returns `WasmError`
478    /// for easier testing in native (non-WASM) contexts.
479    #[allow(clippy::too_many_lines, clippy::wrong_self_convention)]
480    pub(crate) fn from_brep_impl(&mut self, json: &str) -> Result<u32, WasmError> {
481        let parsed: serde_json::Value =
482            serde_json::from_str(json).map_err(|e| WasmError::InvalidInput {
483                reason: format!("invalid BREP JSON: {e}"),
484            })?;
485
486        // 1. Reconstruct vertices
487        let vertices = parsed["vertices"]
488            .as_array()
489            .ok_or_else(|| WasmError::InvalidInput {
490                reason: "missing vertices array".into(),
491            })?;
492        let mut vertex_map: std::collections::HashMap<u32, brepkit_topology::vertex::VertexId> =
493            std::collections::HashMap::new();
494
495        for v in vertices {
496            let id = v["id"].as_u64().ok_or_else(|| WasmError::InvalidInput {
497                reason: "vertex missing id".into(),
498            })? as u32;
499            let pos = v["position"]
500                .as_array()
501                .ok_or_else(|| WasmError::InvalidInput {
502                    reason: "vertex missing position".into(),
503                })?;
504            let x =
505                pos.first()
506                    .and_then(|v| v.as_f64())
507                    .ok_or_else(|| WasmError::InvalidInput {
508                        reason: "invalid vertex x coordinate".into(),
509                    })?;
510            let y = pos
511                .get(1)
512                .and_then(|v| v.as_f64())
513                .ok_or_else(|| WasmError::InvalidInput {
514                    reason: "invalid vertex y coordinate".into(),
515                })?;
516            let z = pos
517                .get(2)
518                .and_then(|v| v.as_f64())
519                .ok_or_else(|| WasmError::InvalidInput {
520                    reason: "invalid vertex z coordinate".into(),
521                })?;
522            let vid = self
523                .topo_mut()
524                .add_vertex(Vertex::new(Point3::new(x, y, z), TOL));
525            vertex_map.insert(id, vid);
526        }
527
528        // 2. Reconstruct edges (line edges from start/end vertices)
529        let edges = parsed["edges"]
530            .as_array()
531            .ok_or_else(|| WasmError::InvalidInput {
532                reason: "missing edges array".into(),
533            })?;
534        let mut edge_map: std::collections::HashMap<u32, brepkit_topology::edge::EdgeId> =
535            std::collections::HashMap::new();
536
537        for e in edges {
538            let id = e["id"].as_u64().ok_or_else(|| WasmError::InvalidInput {
539                reason: "edge missing id".into(),
540            })? as u32;
541            let start_v = e["startVertex"]
542                .as_u64()
543                .ok_or_else(|| WasmError::InvalidInput {
544                    reason: "edge missing startVertex".into(),
545                })? as u32;
546            let end_v = e["endVertex"]
547                .as_u64()
548                .ok_or_else(|| WasmError::InvalidInput {
549                    reason: "edge missing endVertex".into(),
550                })? as u32;
551
552            let start_vid = *vertex_map
553                .get(&start_v)
554                .ok_or_else(|| WasmError::InvalidInput {
555                    reason: format!("edge {id} references unknown start vertex {start_v}"),
556                })?;
557            let end_vid = *vertex_map
558                .get(&end_v)
559                .ok_or_else(|| WasmError::InvalidInput {
560                    reason: format!("edge {id} references unknown end vertex {end_v}"),
561                })?;
562
563            let curve_type = e["curveType"].as_str().unwrap_or("line");
564            let params = e.get("curveParams").and_then(|p| p.as_object());
565            let curve = match curve_type {
566                "line" => EdgeCurve::Line,
567                "circle" => {
568                    let p = params.ok_or_else(|| WasmError::InvalidInput {
569                        reason: format!("edge {id}: circle missing curveParams"),
570                    })?;
571                    let center_arr =
572                        p.get("center").and_then(|v| v.as_array()).ok_or_else(|| {
573                            WasmError::InvalidInput {
574                                reason: format!("edge {id}: circle missing center"),
575                            }
576                        })?;
577                    let axis_arr = p.get("axis").and_then(|v| v.as_array()).ok_or_else(|| {
578                        WasmError::InvalidInput {
579                            reason: format!("edge {id}: circle missing axis"),
580                        }
581                    })?;
582                    let radius = p.get("radius").and_then(|v| v.as_f64()).ok_or_else(|| {
583                        WasmError::InvalidInput {
584                            reason: format!("edge {id}: circle missing radius"),
585                        }
586                    })?;
587                    let center = Self::parse_point3(center_arr, "center")?;
588                    let axis = Self::parse_vec3(axis_arr, "axis")?;
589                    // Use xAxis if available for exact round-trip, else derive from axis
590                    let circle = if let Some(x_axis_arr) = p.get("xAxis").and_then(|v| v.as_array())
591                    {
592                        let x_axis = Self::parse_vec3(x_axis_arr, "xAxis")?;
593                        let v_axis = axis.cross(x_axis);
594                        Circle3D::with_axes(center, axis, radius, x_axis, v_axis)?
595                    } else {
596                        Circle3D::new(center, axis, radius)?
597                    };
598                    EdgeCurve::Circle(circle)
599                }
600                "ellipse" => {
601                    let p = params.ok_or_else(|| WasmError::InvalidInput {
602                        reason: format!("edge {id}: ellipse missing curveParams"),
603                    })?;
604                    let center_arr =
605                        p.get("center").and_then(|v| v.as_array()).ok_or_else(|| {
606                            WasmError::InvalidInput {
607                                reason: format!("edge {id}: ellipse missing center"),
608                            }
609                        })?;
610                    let axis_arr = p.get("axis").and_then(|v| v.as_array()).ok_or_else(|| {
611                        WasmError::InvalidInput {
612                            reason: format!("edge {id}: ellipse missing axis"),
613                        }
614                    })?;
615                    let major_r =
616                        p.get("majorRadius")
617                            .and_then(|v| v.as_f64())
618                            .ok_or_else(|| WasmError::InvalidInput {
619                                reason: format!("edge {id}: ellipse missing majorRadius"),
620                            })?;
621                    let minor_r =
622                        p.get("minorRadius")
623                            .and_then(|v| v.as_f64())
624                            .ok_or_else(|| WasmError::InvalidInput {
625                                reason: format!("edge {id}: ellipse missing minorRadius"),
626                            })?;
627                    let center = Self::parse_point3(center_arr, "center")?;
628                    let axis = Self::parse_vec3(axis_arr, "axis")?;
629                    // Use majorAxis if available for exact round-trip
630                    let ellipse =
631                        if let Some(major_arr) = p.get("majorAxis").and_then(|v| v.as_array()) {
632                            let u_axis = Self::parse_vec3(major_arr, "majorAxis")?;
633                            let v_axis = axis.cross(u_axis);
634                            Ellipse3D::with_axes(center, axis, major_r, minor_r, u_axis, v_axis)?
635                        } else {
636                            Ellipse3D::new(center, axis, major_r, minor_r)?
637                        };
638                    EdgeCurve::Ellipse(ellipse)
639                }
640                "nurbs" => {
641                    let p = params.ok_or_else(|| WasmError::InvalidInput {
642                        reason: format!("edge {id}: nurbs missing curveParams"),
643                    })?;
644                    let degree = p.get("degree").and_then(|v| v.as_u64()).ok_or_else(|| {
645                        WasmError::InvalidInput {
646                            reason: format!("edge {id}: nurbs missing degree"),
647                        }
648                    })? as usize;
649                    let knots_arr = p.get("knots").and_then(|v| v.as_array()).ok_or_else(|| {
650                        WasmError::InvalidInput {
651                            reason: format!("edge {id}: nurbs missing knots"),
652                        }
653                    })?;
654                    let knots: Vec<f64> = knots_arr
655                        .iter()
656                        .enumerate()
657                        .map(|(i, v)| {
658                            v.as_f64().ok_or_else(|| WasmError::InvalidInput {
659                                reason: format!("edge {id}: knot[{i}] is not a number"),
660                            })
661                        })
662                        .collect::<Result<_, _>>()?;
663                    let weights_arr =
664                        p.get("weights").and_then(|v| v.as_array()).ok_or_else(|| {
665                            WasmError::InvalidInput {
666                                reason: format!("edge {id}: nurbs missing weights"),
667                            }
668                        })?;
669                    let weights: Vec<f64> = weights_arr
670                        .iter()
671                        .enumerate()
672                        .map(|(i, v)| {
673                            v.as_f64().ok_or_else(|| WasmError::InvalidInput {
674                                reason: format!("edge {id}: weight[{i}] is not a number"),
675                            })
676                        })
677                        .collect::<Result<_, _>>()?;
678                    let cps_arr = p
679                        .get("controlPoints")
680                        .and_then(|v| v.as_array())
681                        .ok_or_else(|| WasmError::InvalidInput {
682                            reason: format!("edge {id}: nurbs missing controlPoints"),
683                        })?;
684                    let control_points: Vec<Point3> = cps_arr
685                        .iter()
686                        .enumerate()
687                        .map(|(i, cp)| -> Result<Point3, WasmError> {
688                            let arr = cp.as_array().ok_or_else(|| WasmError::InvalidInput {
689                                reason: format!("edge {id}: controlPoints[{i}] is not an array"),
690                            })?;
691                            Self::parse_point3(arr, &format!("controlPoints[{i}]"))
692                        })
693                        .collect::<Result<_, _>>()?;
694                    let nc = NurbsCurve::new(degree, knots, control_points, weights)?;
695                    EdgeCurve::NurbsCurve(nc)
696                }
697                other => {
698                    log::warn!(
699                        "fromBREP: edge {id} has unsupported curve type '{other}', \
700                         approximating as line"
701                    );
702                    EdgeCurve::Line
703                }
704            };
705
706            let eid = self
707                .topo_mut()
708                .add_edge(Edge::new(start_vid, end_vid, curve));
709            edge_map.insert(id, eid);
710        }
711
712        // 3. Reconstruct faces
713        let faces = parsed["faces"]
714            .as_array()
715            .ok_or_else(|| WasmError::InvalidInput {
716                reason: "missing faces array".into(),
717            })?;
718        let mut face_ids: Vec<brepkit_topology::face::FaceId> = Vec::new();
719
720        for f in faces {
721            let outer_edge_ids =
722                f["outerWireEdges"]
723                    .as_array()
724                    .ok_or_else(|| WasmError::InvalidInput {
725                        reason: "face missing outerWireEdges".into(),
726                    })?;
727            let outer_orientations = f
728                .get("outerWireOrientations")
729                .and_then(|v| v.as_array().cloned());
730
731            // Build oriented edges for the outer wire
732            let mut oriented_edges = Vec::new();
733            for (i, eid_val) in outer_edge_ids.iter().enumerate() {
734                let eid = eid_val.as_u64().ok_or_else(|| WasmError::InvalidInput {
735                    reason: "invalid edge id in outerWireEdges".into(),
736                })? as u32;
737                let edge_id = *edge_map.get(&eid).ok_or_else(|| WasmError::InvalidInput {
738                    reason: format!("wire references unknown edge {eid}"),
739                })?;
740                let forward = outer_orientations
741                    .as_ref()
742                    .and_then(|arr| arr.get(i))
743                    .and_then(|v| v.as_bool())
744                    .unwrap_or(true);
745                oriented_edges.push(OrientedEdge::new(edge_id, forward));
746            }
747
748            let wire = Wire::new(oriented_edges, true)?;
749            let wire_id = self.topo_mut().add_wire(wire);
750
751            // Reconstruct surface
752            let surface_type = f["surfaceType"].as_str().unwrap_or("plane");
753            let reversed = f["reversed"].as_bool().unwrap_or(false);
754
755            let surface_params = f.get("surfaceParams").and_then(|p| p.as_object());
756            let surface = match surface_type {
757                "plane" => {
758                    if let Some(params) = surface_params {
759                        if let (Some(normal_arr), Some(d)) = (
760                            params.get("normal").and_then(|n| n.as_array()),
761                            params.get("d").and_then(|d| d.as_f64()),
762                        ) {
763                            let nx = normal_arr.first().and_then(|v| v.as_f64()).unwrap_or(0.0);
764                            let ny = normal_arr.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);
765                            let nz = normal_arr.get(2).and_then(|v| v.as_f64()).unwrap_or(1.0);
766                            FaceSurface::Plane {
767                                normal: Vec3::new(nx, ny, nz),
768                                d,
769                            }
770                        } else {
771                            self.compute_plane_from_wire(wire_id)?
772                        }
773                    } else {
774                        self.compute_plane_from_wire(wire_id)?
775                    }
776                }
777                "cylinder" => {
778                    let p = surface_params.ok_or_else(|| WasmError::InvalidInput {
779                        reason: "cylinder face missing surfaceParams".into(),
780                    })?;
781                    let origin_arr =
782                        p.get("origin").and_then(|v| v.as_array()).ok_or_else(|| {
783                            WasmError::InvalidInput {
784                                reason: "cylinder missing origin".into(),
785                            }
786                        })?;
787                    let axis_arr = p.get("axis").and_then(|v| v.as_array()).ok_or_else(|| {
788                        WasmError::InvalidInput {
789                            reason: "cylinder missing axis".into(),
790                        }
791                    })?;
792                    let radius = p.get("radius").and_then(|v| v.as_f64()).ok_or_else(|| {
793                        WasmError::InvalidInput {
794                            reason: "cylinder missing radius".into(),
795                        }
796                    })?;
797                    let origin = Self::parse_point3(origin_arr, "origin")?;
798                    let axis = Self::parse_vec3(axis_arr, "axis")?;
799                    let cyl = if let Some(ref_arr) = p.get("refDir").and_then(|v| v.as_array()) {
800                        let ref_dir = Self::parse_vec3(ref_arr, "refDir")?;
801                        CylindricalSurface::with_ref_dir(origin, axis, radius, ref_dir)?
802                    } else {
803                        CylindricalSurface::new(origin, axis, radius)?
804                    };
805                    FaceSurface::Cylinder(cyl)
806                }
807                "cone" => {
808                    let p = surface_params.ok_or_else(|| WasmError::InvalidInput {
809                        reason: "cone face missing surfaceParams".into(),
810                    })?;
811                    let apex_arr = p.get("apex").and_then(|v| v.as_array()).ok_or_else(|| {
812                        WasmError::InvalidInput {
813                            reason: "cone missing apex".into(),
814                        }
815                    })?;
816                    let axis_arr = p.get("axis").and_then(|v| v.as_array()).ok_or_else(|| {
817                        WasmError::InvalidInput {
818                            reason: "cone missing axis".into(),
819                        }
820                    })?;
821                    let half_angle =
822                        p.get("halfAngle").and_then(|v| v.as_f64()).ok_or_else(|| {
823                            WasmError::InvalidInput {
824                                reason: "cone missing halfAngle".into(),
825                            }
826                        })?;
827                    let apex = Self::parse_point3(apex_arr, "apex")?;
828                    let axis = Self::parse_vec3(axis_arr, "axis")?;
829                    let cone = if let Some(ref_arr) = p.get("refDir").and_then(|v| v.as_array()) {
830                        let ref_dir = Self::parse_vec3(ref_arr, "refDir")?;
831                        ConicalSurface::with_ref_dir(apex, axis, half_angle, ref_dir)?
832                    } else {
833                        ConicalSurface::new(apex, axis, half_angle)?
834                    };
835                    FaceSurface::Cone(cone)
836                }
837                "sphere" => {
838                    let p = surface_params.ok_or_else(|| WasmError::InvalidInput {
839                        reason: "sphere face missing surfaceParams".into(),
840                    })?;
841                    let center_arr =
842                        p.get("center").and_then(|v| v.as_array()).ok_or_else(|| {
843                            WasmError::InvalidInput {
844                                reason: "sphere missing center".into(),
845                            }
846                        })?;
847                    let radius = p.get("radius").and_then(|v| v.as_f64()).ok_or_else(|| {
848                        WasmError::InvalidInput {
849                            reason: "sphere missing radius".into(),
850                        }
851                    })?;
852                    let center = Self::parse_point3(center_arr, "center")?;
853                    let sphere = SphericalSurface::new(center, radius)?;
854                    FaceSurface::Sphere(sphere)
855                }
856                "torus" => {
857                    let p = surface_params.ok_or_else(|| WasmError::InvalidInput {
858                        reason: "torus face missing surfaceParams".into(),
859                    })?;
860                    let center_arr =
861                        p.get("center").and_then(|v| v.as_array()).ok_or_else(|| {
862                            WasmError::InvalidInput {
863                                reason: "torus missing center".into(),
864                            }
865                        })?;
866                    let axis_arr = p.get("axis").and_then(|v| v.as_array()).ok_or_else(|| {
867                        WasmError::InvalidInput {
868                            reason: "torus missing axis".into(),
869                        }
870                    })?;
871                    let major_r =
872                        p.get("majorRadius")
873                            .and_then(|v| v.as_f64())
874                            .ok_or_else(|| WasmError::InvalidInput {
875                                reason: "torus missing majorRadius".into(),
876                            })?;
877                    let minor_r =
878                        p.get("minorRadius")
879                            .and_then(|v| v.as_f64())
880                            .ok_or_else(|| WasmError::InvalidInput {
881                                reason: "torus missing minorRadius".into(),
882                            })?;
883                    let center = Self::parse_point3(center_arr, "center")?;
884                    let axis = Self::parse_vec3(axis_arr, "axis")?;
885                    let torus = ToroidalSurface::with_axis(center, major_r, minor_r, axis)?;
886                    FaceSurface::Torus(torus)
887                }
888                "nurbs" => {
889                    let p = surface_params.ok_or_else(|| WasmError::InvalidInput {
890                        reason: "nurbs face missing surfaceParams".into(),
891                    })?;
892                    let degree_u = p.get("degreeU").and_then(|v| v.as_u64()).ok_or_else(|| {
893                        WasmError::InvalidInput {
894                            reason: "nurbs surface missing degreeU".into(),
895                        }
896                    })? as usize;
897                    let degree_v = p.get("degreeV").and_then(|v| v.as_u64()).ok_or_else(|| {
898                        WasmError::InvalidInput {
899                            reason: "nurbs surface missing degreeV".into(),
900                        }
901                    })? as usize;
902                    let knots_u_arr =
903                        p.get("knotsU").and_then(|v| v.as_array()).ok_or_else(|| {
904                            WasmError::InvalidInput {
905                                reason: "nurbs surface missing knotsU".into(),
906                            }
907                        })?;
908                    let knots_u: Vec<f64> = knots_u_arr
909                        .iter()
910                        .enumerate()
911                        .map(|(i, v)| {
912                            v.as_f64().ok_or_else(|| WasmError::InvalidInput {
913                                reason: format!("nurbs surface knotsU[{i}] is not a number"),
914                            })
915                        })
916                        .collect::<Result<_, _>>()?;
917                    let knots_v_arr =
918                        p.get("knotsV").and_then(|v| v.as_array()).ok_or_else(|| {
919                            WasmError::InvalidInput {
920                                reason: "nurbs surface missing knotsV".into(),
921                            }
922                        })?;
923                    let knots_v: Vec<f64> = knots_v_arr
924                        .iter()
925                        .enumerate()
926                        .map(|(i, v)| {
927                            v.as_f64().ok_or_else(|| WasmError::InvalidInput {
928                                reason: format!("nurbs surface knotsV[{i}] is not a number"),
929                            })
930                        })
931                        .collect::<Result<_, _>>()?;
932                    let cps_grid = p
933                        .get("controlPoints")
934                        .and_then(|v| v.as_array())
935                        .ok_or_else(|| WasmError::InvalidInput {
936                            reason: "nurbs surface missing controlPoints".into(),
937                        })?;
938                    let control_points: Vec<Vec<Point3>> = cps_grid
939                        .iter()
940                        .enumerate()
941                        .map(|(ri, row)| -> Result<Vec<Point3>, WasmError> {
942                            let row_arr =
943                                row.as_array().ok_or_else(|| WasmError::InvalidInput {
944                                    reason: format!("nurbs controlPoints[{ri}] is not an array"),
945                                })?;
946                            row_arr
947                                .iter()
948                                .enumerate()
949                                .map(|(ci, cp)| -> Result<Point3, WasmError> {
950                                    let arr =
951                                        cp.as_array().ok_or_else(|| WasmError::InvalidInput {
952                                            reason: format!(
953                                                "nurbs controlPoints[{ri}][{ci}] is not an array"
954                                            ),
955                                        })?;
956                                    Self::parse_point3(arr, &format!("controlPoints[{ri}][{ci}]"))
957                                })
958                                .collect()
959                        })
960                        .collect::<Result<_, _>>()?;
961                    let weights_grid =
962                        p.get("weights").and_then(|v| v.as_array()).ok_or_else(|| {
963                            WasmError::InvalidInput {
964                                reason: "nurbs surface missing weights".into(),
965                            }
966                        })?;
967                    let weights: Vec<Vec<f64>> = weights_grid
968                        .iter()
969                        .enumerate()
970                        .map(|(ri, row)| -> Result<Vec<f64>, WasmError> {
971                            let row_arr =
972                                row.as_array().ok_or_else(|| WasmError::InvalidInput {
973                                    reason: format!("nurbs weights[{ri}] is not an array"),
974                                })?;
975                            row_arr
976                                .iter()
977                                .enumerate()
978                                .map(|(ci, v)| {
979                                    v.as_f64().ok_or_else(|| WasmError::InvalidInput {
980                                        reason: format!(
981                                            "nurbs surface weights[{ri}][{ci}] is not a number"
982                                        ),
983                                    })
984                                })
985                                .collect::<Result<_, _>>()
986                        })
987                        .collect::<Result<_, _>>()?;
988                    let ns = NurbsSurface::new(
989                        degree_u,
990                        degree_v,
991                        knots_u,
992                        knots_v,
993                        control_points,
994                        weights,
995                    )?;
996                    FaceSurface::Nurbs(ns)
997                }
998                other => {
999                    log::warn!(
1000                        "fromBREP: face has unsupported surface type '{other}', \
1001                         approximating as plane from vertices"
1002                    );
1003                    self.compute_plane_from_wire(wire_id)?
1004                }
1005            };
1006
1007            // Handle inner wires (holes)
1008            let mut inner_wire_ids = Vec::new();
1009            if let Some(inner_wires) = f["innerWires"].as_array() {
1010                for iw in inner_wires {
1011                    // Support both old format (array of edge IDs) and new format
1012                    // (object with "edges" and "orientations")
1013                    let (edge_arr, orient_arr) = if let Some(obj) = iw.as_object() {
1014                        let e = obj
1015                            .get("edges")
1016                            .and_then(|v| v.as_array())
1017                            .cloned()
1018                            .unwrap_or_default();
1019                        let o = obj.get("orientations").and_then(|v| v.as_array()).cloned();
1020                        (e, o)
1021                    } else if let Some(arr) = iw.as_array() {
1022                        (arr.clone(), None)
1023                    } else {
1024                        continue;
1025                    };
1026
1027                    let mut inner_oriented = Vec::new();
1028                    for (i, eid_val) in edge_arr.iter().enumerate() {
1029                        if let Some(eid) = eid_val.as_u64()
1030                            && let Some(&edge_id) = edge_map.get(&(eid as u32))
1031                        {
1032                            let fwd = orient_arr
1033                                .as_ref()
1034                                .and_then(|arr| arr.get(i))
1035                                .and_then(|v| v.as_bool())
1036                                .unwrap_or(true);
1037                            inner_oriented.push(OrientedEdge::new(edge_id, fwd));
1038                        }
1039                    }
1040                    if !inner_oriented.is_empty() {
1041                        let inner_wire = Wire::new(inner_oriented, true)?;
1042                        inner_wire_ids.push(self.topo_mut().add_wire(inner_wire));
1043                    }
1044                }
1045            }
1046
1047            let mut face = Face::new(wire_id, inner_wire_ids, surface);
1048            if reversed {
1049                face.set_reversed(true);
1050            }
1051
1052            face_ids.push(self.topo_mut().add_face(face));
1053        }
1054
1055        // 4. Build shell and solid
1056        if face_ids.is_empty() {
1057            return Err(WasmError::InvalidInput {
1058                reason: "fromBREP: no faces reconstructed".into(),
1059            });
1060        }
1061
1062        let shell = brepkit_topology::shell::Shell::new(face_ids)?;
1063        let shell_id = self.topo_mut().add_shell(shell);
1064        let solid = brepkit_topology::solid::Solid::new(shell_id, vec![]);
1065        let solid_id = self.topo_mut().add_solid(solid);
1066
1067        Ok(solid_id_to_u32(solid_id))
1068    }
1069}
1070
1071// ── Test fixtures ─────────────────────────────────────────────────
1072
1073#[cfg(test)]
1074pub(crate) mod test_fixtures {
1075    #![allow(clippy::unwrap_used, dead_code)]
1076    use super::*;
1077
1078    pub fn kernel_with_box() -> (BrepKernel, u32) {
1079        let mut k = BrepKernel::new();
1080        let id = brepkit_operations::primitives::make_box(k.topo_mut(), 1.0, 1.0, 1.0).unwrap();
1081        #[allow(clippy::cast_possible_truncation)]
1082        (k, id.index() as u32)
1083    }
1084
1085    pub fn kernel_with_two_boxes() -> (BrepKernel, u32, u32) {
1086        let mut k = BrepKernel::new();
1087        let a = brepkit_operations::primitives::make_box(k.topo_mut(), 2.0, 2.0, 2.0).unwrap();
1088        let b = brepkit_operations::primitives::make_box(k.topo_mut(), 1.0, 1.0, 1.0).unwrap();
1089        #[allow(clippy::cast_possible_truncation)]
1090        (k, a.index() as u32, b.index() as u32)
1091    }
1092
1093    pub fn kernel_with_cylinder() -> (BrepKernel, u32) {
1094        let mut k = BrepKernel::new();
1095        let id = brepkit_operations::primitives::make_cylinder(k.topo_mut(), 1.0, 2.0).unwrap();
1096        #[allow(clippy::cast_possible_truncation)]
1097        (k, id.index() as u32)
1098    }
1099}
1100
1101// ── Tests ─────────────────────────────────────────────────────────
1102
1103#[cfg(test)]
1104mod batch_tests {
1105    #![allow(clippy::unwrap_used, clippy::expect_used)]
1106
1107    use super::*;
1108
1109    #[test]
1110    fn batch_single_op() {
1111        let mut kernel = BrepKernel::new();
1112        let result = kernel
1113            .execute_batch(r#"[{"op": "makeBox", "args": {"width": 1, "height": 1, "depth": 1}}]"#);
1114        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1115        assert!(
1116            parsed[0]["ok"].is_number(),
1117            "expected ok number, got {parsed}"
1118        );
1119    }
1120
1121    #[test]
1122    fn batch_multiple_ops() {
1123        let mut kernel = BrepKernel::new();
1124        let result = kernel.execute_batch(
1125            r#"[
1126                {"op": "makeBox", "args": {"width": 2, "height": 2, "depth": 2}},
1127                {"op": "makeBox", "args": {"width": 1, "height": 1, "depth": 1}},
1128                {"op": "volume", "args": {"solid": 0}}
1129            ]"#,
1130        );
1131        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1132        assert_eq!(parsed.as_array().unwrap().len(), 3);
1133        assert!(parsed[0]["ok"].is_number());
1134        assert!(parsed[1]["ok"].is_number());
1135        assert!(parsed[2]["ok"].is_number());
1136    }
1137
1138    #[test]
1139    fn batch_error_doesnt_stop_rest() {
1140        let mut kernel = BrepKernel::new();
1141        let result = kernel.execute_batch(
1142            r#"[
1143                {"op": "unknownOp", "args": {}},
1144                {"op": "makeBox", "args": {"width": 1, "height": 1, "depth": 1}}
1145            ]"#,
1146        );
1147        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1148        assert!(parsed[0]["error"].is_string());
1149        assert!(parsed[1]["ok"].is_number());
1150    }
1151
1152    #[test]
1153    fn batch_invalid_json() {
1154        let mut kernel = BrepKernel::new();
1155        let result = kernel.execute_batch("not valid json");
1156        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1157        assert!(
1158            parsed[0]["error"]
1159                .as_str()
1160                .unwrap()
1161                .contains("invalid JSON")
1162        );
1163    }
1164
1165    #[test]
1166    fn batch_missing_op_field() {
1167        let mut kernel = BrepKernel::new();
1168        let result = kernel.execute_batch(r#"[{"args": {"width": 1}}]"#);
1169        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1170        assert!(parsed[0]["error"].as_str().unwrap().contains("op"));
1171    }
1172
1173    #[test]
1174    fn batch_boolean_ops() {
1175        let mut kernel = BrepKernel::new();
1176        let result = kernel.execute_batch(
1177            r#"[
1178                {"op": "makeBox", "args": {"width": 2, "height": 2, "depth": 2}},
1179                {"op": "makeBox", "args": {"width": 1, "height": 1, "depth": 1}},
1180                {"op": "fuse", "args": {"solidA": 0, "solidB": 1}}
1181            ]"#,
1182        );
1183        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1184        assert!(parsed[0]["ok"].is_number());
1185        assert!(parsed[1]["ok"].is_number());
1186        assert!(parsed[2]["ok"].is_number());
1187    }
1188
1189    #[test]
1190    fn batch_bounding_box() {
1191        let mut kernel = BrepKernel::new();
1192        let result = kernel.execute_batch(
1193            r#"[
1194                {"op": "makeBox", "args": {"width": 2, "height": 4, "depth": 6}},
1195                {"op": "boundingBox", "args": {"solid": 0}}
1196            ]"#,
1197        );
1198        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1199        assert!(parsed[0]["ok"].is_number());
1200        let bbox = &parsed[1]["ok"];
1201        assert!(bbox.is_array());
1202        assert_eq!(bbox.as_array().unwrap().len(), 6);
1203    }
1204
1205    #[test]
1206    fn batch_copy_solid() {
1207        let mut kernel = BrepKernel::new();
1208        let result = kernel.execute_batch(
1209            r#"[
1210                {"op": "makeBox", "args": {"width": 1, "height": 1, "depth": 1}},
1211                {"op": "copySolid", "args": {"solid": 0}}
1212            ]"#,
1213        );
1214        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1215        assert!(parsed[0]["ok"].is_number());
1216        assert!(parsed[1]["ok"].is_number());
1217        assert_ne!(parsed[0]["ok"].as_u64(), parsed[1]["ok"].as_u64());
1218    }
1219}
1220
1221#[cfg(test)]
1222mod tangent_arc_tests {
1223    #![allow(clippy::unwrap_used, clippy::expect_used)]
1224    use super::*;
1225
1226    fn get_edge(k: &BrepKernel, handle: u32) -> &Edge {
1227        let id = k.resolve_edge(handle).unwrap();
1228        k.topo.edge(id).unwrap()
1229    }
1230
1231    #[test]
1232    fn semicircle() {
1233        let mut k = BrepKernel::new();
1234        let eid = k
1235            .make_tangent_arc_3d_impl(1.0, 0.0, 0.0, 0.0, 1.0, 0.0, -1.0, 0.0, 0.0)
1236            .unwrap();
1237        let edge = get_edge(&k, eid);
1238        assert!(matches!(edge.curve(), EdgeCurve::Circle(_)));
1239        if let EdgeCurve::Circle(c) = edge.curve() {
1240            assert!((c.radius() - 1.0).abs() < 1e-10);
1241            let center = c.center();
1242            assert!(center.x().abs() < 1e-10);
1243            assert!(center.y().abs() < 1e-10);
1244            assert!(center.z().abs() < 1e-10);
1245        }
1246    }
1247
1248    #[test]
1249    fn quarter_circle() {
1250        let mut k = BrepKernel::new();
1251        let eid = k
1252            .make_tangent_arc_3d_impl(1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0)
1253            .unwrap();
1254        let edge = get_edge(&k, eid);
1255        assert!(matches!(edge.curve(), EdgeCurve::Circle(_)));
1256        if let EdgeCurve::Circle(c) = edge.curve() {
1257            assert!((c.radius() - 1.0).abs() < 1e-10);
1258        }
1259        let s = k.topo.vertex(edge.start()).unwrap().point();
1260        let e = k.topo.vertex(edge.end()).unwrap().point();
1261        assert!((s.x() - 1.0).abs() < 1e-10);
1262        assert!((e.y() - 1.0).abs() < 1e-10);
1263    }
1264
1265    #[test]
1266    fn tilted_3d_arc() {
1267        let mut k = BrepKernel::new();
1268        let eid = k
1269            .make_tangent_arc_3d_impl(1.0, 0.0, 1.0, 0.0, 1.0, 0.0, -1.0, 0.0, 1.0)
1270            .unwrap();
1271        let edge = get_edge(&k, eid);
1272        assert!(matches!(edge.curve(), EdgeCurve::Circle(_)));
1273        if let EdgeCurve::Circle(c) = edge.curve() {
1274            assert!((c.radius() - 1.0).abs() < 1e-10);
1275        }
1276    }
1277
1278    #[test]
1279    fn collinear_fallback() {
1280        let mut k = BrepKernel::new();
1281        let eid = k
1282            .make_tangent_arc_3d_impl(0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 5.0, 0.0, 0.0)
1283            .unwrap();
1284        assert!(matches!(get_edge(&k, eid).curve(), EdgeCurve::Line));
1285    }
1286
1287    #[test]
1288    fn large_arc_gt_pi() {
1289        let mut k = BrepKernel::new();
1290        let eid = k
1291            .make_tangent_arc_3d_impl(1.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 1.0, 0.0)
1292            .unwrap();
1293        assert!(matches!(get_edge(&k, eid).curve(), EdgeCurve::Circle(_)));
1294    }
1295
1296    #[test]
1297    fn coincident_points_error() {
1298        let mut k = BrepKernel::new();
1299        let err = k
1300            .make_tangent_arc_3d_impl(1.0, 2.0, 3.0, 0.0, 1.0, 0.0, 1.0, 2.0, 3.0)
1301            .unwrap_err();
1302        assert!(err.to_string().contains("coincide"));
1303    }
1304
1305    #[test]
1306    fn zero_tangent_error() {
1307        let mut k = BrepKernel::new();
1308        let err = k
1309            .make_tangent_arc_3d_impl(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0)
1310            .unwrap_err();
1311        assert!(err.to_string().contains("tangent"));
1312    }
1313}
1314
1315#[cfg(test)]
1316mod lift_curve2d_tests {
1317    #![allow(clippy::unwrap_used, clippy::expect_used)]
1318    use super::*;
1319    use std::f64::consts::{FRAC_PI_2, PI, TAU};
1320
1321    #[test]
1322    fn line2d_on_xy_plane() {
1323        let mut k = BrepKernel::new();
1324        let eid = k
1325            .lift_curve2d_to_plane_impl(
1326                0,
1327                vec![1.0, 0.0, 1.0, 0.0],
1328                0.0,
1329                0.0,
1330                0.0,
1331                1.0,
1332                0.0,
1333                0.0,
1334                0.0,
1335                0.0,
1336                1.0,
1337                0.0,
1338                3.0,
1339            )
1340            .unwrap();
1341        let edge_id = k.resolve_edge(eid).unwrap();
1342        let edge = k.topo.edge(edge_id).unwrap();
1343        let s = k.topo.vertex(edge.start()).unwrap().point();
1344        let e = k.topo.vertex(edge.end()).unwrap().point();
1345        assert!((s.x() - 1.0).abs() < 1e-10);
1346        assert!(s.y().abs() < 1e-10);
1347        assert!((e.x() - 4.0).abs() < 1e-10);
1348        assert!(e.y().abs() < 1e-10);
1349        assert!(matches!(edge.curve(), EdgeCurve::Line));
1350    }
1351
1352    #[test]
1353    fn circle2d_quarter_arc_xy() {
1354        let mut k = BrepKernel::new();
1355        let eid = k
1356            .lift_curve2d_to_plane_impl(
1357                1,
1358                vec![0.0, 0.0, 1.0],
1359                0.0,
1360                0.0,
1361                0.0,
1362                1.0,
1363                0.0,
1364                0.0,
1365                0.0,
1366                0.0,
1367                1.0,
1368                0.0,
1369                FRAC_PI_2,
1370            )
1371            .unwrap();
1372        let edge_id = k.resolve_edge(eid).unwrap();
1373        let edge = k.topo.edge(edge_id).unwrap();
1374        let s = k.topo.vertex(edge.start()).unwrap().point();
1375        let e = k.topo.vertex(edge.end()).unwrap().point();
1376        assert!((s.x() - 1.0).abs() < 1e-10);
1377        assert!(s.y().abs() < 1e-10);
1378        assert!(e.x().abs() < 1e-10);
1379        assert!((e.y() - 1.0).abs() < 1e-10);
1380        assert!(matches!(edge.curve(), EdgeCurve::Circle(_)));
1381    }
1382
1383    #[test]
1384    fn circle2d_on_xz_plane() {
1385        let mut k = BrepKernel::new();
1386        let eid = k
1387            .lift_curve2d_to_plane_impl(
1388                1,
1389                vec![0.0, 0.0, 2.0],
1390                0.0,
1391                0.0,
1392                0.0,
1393                1.0,
1394                0.0,
1395                0.0,
1396                0.0,
1397                1.0,
1398                0.0,
1399                0.0,
1400                FRAC_PI_2,
1401            )
1402            .unwrap();
1403        let edge_id = k.resolve_edge(eid).unwrap();
1404        let edge = k.topo.edge(edge_id).unwrap();
1405        let s = k.topo.vertex(edge.start()).unwrap().point();
1406        let e = k.topo.vertex(edge.end()).unwrap().point();
1407        assert!((s.x() - 2.0).abs() < 1e-10);
1408        assert!(s.y().abs() < 1e-10);
1409        assert!(s.z().abs() < 1e-10);
1410        assert!(e.x().abs() < 1e-10);
1411        assert!(e.y().abs() < 1e-10);
1412        assert!((e.z() + 2.0).abs() < 1e-10);
1413    }
1414
1415    #[test]
1416    fn circle2d_full_circle() {
1417        let mut k = BrepKernel::new();
1418        let eid = k
1419            .lift_curve2d_to_plane_impl(
1420                1,
1421                vec![0.0, 0.0, 1.0],
1422                0.0,
1423                0.0,
1424                0.0,
1425                1.0,
1426                0.0,
1427                0.0,
1428                0.0,
1429                0.0,
1430                1.0,
1431                0.0,
1432                TAU,
1433            )
1434            .unwrap();
1435        let edge_id = k.resolve_edge(eid).unwrap();
1436        let edge = k.topo.edge(edge_id).unwrap();
1437        assert_eq!(edge.start(), edge.end());
1438    }
1439
1440    #[test]
1441    fn ellipse2d_with_rotation() {
1442        let mut k = BrepKernel::new();
1443        let eid = k
1444            .lift_curve2d_to_plane_impl(
1445                2,
1446                vec![0.0, 0.0, 2.0, 1.0, PI / 4.0],
1447                0.0,
1448                0.0,
1449                0.0,
1450                1.0,
1451                0.0,
1452                0.0,
1453                0.0,
1454                0.0,
1455                1.0,
1456                0.0,
1457                FRAC_PI_2,
1458            )
1459            .unwrap();
1460        let edge_id = k.resolve_edge(eid).unwrap();
1461        let edge = k.topo.edge(edge_id).unwrap();
1462        assert!(matches!(edge.curve(), EdgeCurve::Ellipse(_)));
1463        let s = k.topo.vertex(edge.start()).unwrap().point();
1464        let dist = (s.x().powi(2) + s.y().powi(2) + s.z().powi(2)).sqrt();
1465        assert!((dist - 2.0).abs() < 1e-10);
1466    }
1467
1468    #[test]
1469    fn ellipse2d_full() {
1470        let mut k = BrepKernel::new();
1471        let eid = k
1472            .lift_curve2d_to_plane_impl(
1473                2,
1474                vec![0.0, 0.0, 3.0, 1.0, 0.0],
1475                0.0,
1476                0.0,
1477                0.0,
1478                1.0,
1479                0.0,
1480                0.0,
1481                0.0,
1482                0.0,
1483                1.0,
1484                0.0,
1485                TAU,
1486            )
1487            .unwrap();
1488        let edge_id = k.resolve_edge(eid).unwrap();
1489        let edge = k.topo.edge(edge_id).unwrap();
1490        assert_eq!(edge.start(), edge.end());
1491    }
1492
1493    #[test]
1494    fn nurbs2d_degree1_line() {
1495        let mut k = BrepKernel::new();
1496        let eid = k
1497            .lift_curve2d_to_plane_impl(
1498                3,
1499                vec![1.0, 2.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 3.0, 4.0, 1.0, 1.0],
1500                0.0,
1501                0.0,
1502                0.0,
1503                1.0,
1504                0.0,
1505                0.0,
1506                0.0,
1507                0.0,
1508                1.0,
1509                0.0,
1510                1.0,
1511            )
1512            .unwrap();
1513        let edge_id = k.resolve_edge(eid).unwrap();
1514        let edge = k.topo.edge(edge_id).unwrap();
1515        let s = k.topo.vertex(edge.start()).unwrap().point();
1516        let e = k.topo.vertex(edge.end()).unwrap().point();
1517        assert!(s.x().abs() < 1e-10);
1518        assert!(s.y().abs() < 1e-10);
1519        assert!((e.x() - 3.0).abs() < 1e-10);
1520        assert!((e.y() - 4.0).abs() < 1e-10);
1521        assert!(matches!(edge.curve(), EdgeCurve::NurbsCurve(_)));
1522    }
1523
1524    #[test]
1525    fn invalid_curve_type() {
1526        let mut k = BrepKernel::new();
1527        let err = k
1528            .lift_curve2d_to_plane_impl(
1529                5,
1530                vec![],
1531                0.0,
1532                0.0,
1533                0.0,
1534                1.0,
1535                0.0,
1536                0.0,
1537                0.0,
1538                0.0,
1539                1.0,
1540                0.0,
1541                1.0,
1542            )
1543            .unwrap_err();
1544        assert!(err.to_string().contains("curve_type"));
1545    }
1546
1547    #[test]
1548    fn wrong_param_count() {
1549        let mut k = BrepKernel::new();
1550        let err = k
1551            .lift_curve2d_to_plane_impl(
1552                1,
1553                vec![0.0, 0.0],
1554                0.0,
1555                0.0,
1556                0.0,
1557                1.0,
1558                0.0,
1559                0.0,
1560                0.0,
1561                0.0,
1562                1.0,
1563                0.0,
1564                1.0,
1565            )
1566            .unwrap_err();
1567        assert!(err.to_string().contains("Circle expects 3 params"));
1568    }
1569}