Skip to main content

brepkit_wasm/bindings/
batch.rs

1//! Batch execution and dispatch bindings.
2
3#![allow(clippy::missing_errors_doc, clippy::too_many_lines)]
4
5use wasm_bindgen::prelude::*;
6
7use brepkit_math::mat::Mat4;
8use brepkit_math::nurbs::curve::NurbsCurve;
9use brepkit_math::nurbs::surface::NurbsSurface;
10use brepkit_math::vec::{Point3, Vec3};
11use brepkit_operations::boolean::{self, BooleanOp, boolean};
12use brepkit_operations::extrude::extrude;
13use brepkit_operations::measure;
14use brepkit_operations::revolve::revolve;
15use brepkit_operations::sweep::sweep;
16use brepkit_operations::transform::transform_solid;
17use brepkit_topology::edge::EdgeCurve;
18
19use crate::error::WasmError;
20use crate::handles::{
21    compound_id_to_u32, edge_id_to_u32, face_id_to_u32, solid_id_to_u32, wire_id_to_u32,
22};
23use crate::helpers::{TOL, classify_to_string, get_f64, get_u32, panic_message, try_fillet};
24use crate::kernel::BrepKernel;
25
26#[wasm_bindgen]
27impl BrepKernel {
28    // ── Batch execution ──────────────────────────────────────────
29
30    /// Execute a batch of operations, crossing the JS/WASM boundary once.
31    ///
32    /// Accepts a JSON string containing an array of operation objects:
33    /// ```json
34    /// [
35    ///   {"op": "makeBox", "args": {"width": 2.0, "height": 2.0, "depth": 2.0}},
36    ///   {"op": "fuse", "args": {"solidA": 0, "solidB": 1}},
37    ///   {"op": "volume", "args": {"solid": 2, "deflection": 0.1}}
38    /// ]
39    /// ```
40    ///
41    /// Returns a JSON string with an array of results:
42    /// ```json
43    /// [
44    ///   {"ok": 0},
45    ///   {"ok": 2},
46    ///   {"error": "invalid solid id"}
47    /// ]
48    /// ```
49    ///
50    /// Operations are executed sequentially; an error in one does not
51    /// prevent execution of subsequent operations.
52    #[wasm_bindgen(js_name = "executeBatch")]
53    #[allow(clippy::needless_pass_by_value)]
54    pub fn execute_batch(&mut self, json: &str) -> String {
55        let ops: Vec<serde_json::Value> = match serde_json::from_str(json) {
56            Ok(v) => v,
57            Err(e) => {
58                return serde_json::json!([{"error": format!("invalid JSON: {e}")}]).to_string();
59            }
60        };
61
62        let results: Vec<serde_json::Value> = ops
63            .iter()
64            .map(|entry| {
65                let op = match entry["op"].as_str() {
66                    Some(s) => s,
67                    None => return serde_json::json!({"error": "missing or invalid 'op' field"}),
68                };
69                let args = &entry["args"];
70                match self.dispatch_op(op, args) {
71                    Ok(val) => serde_json::json!({"ok": val}),
72                    Err(msg) => serde_json::json!({"error": msg}),
73                }
74            })
75            .collect();
76
77        serde_json::Value::Array(results).to_string()
78    }
79}
80
81/// A `(u_range, v_range)` pair, each `(min, max)`.
82type UvRanges = ((f64, f64), (f64, f64));
83
84/// Build the in-plane axes used by `plane_to_nurbs`.
85///
86/// Must match `brepkit_heal::construct::convert_surface`'s private frame
87/// so projected face corners reconstruct the plane rectangle consistently.
88fn plane_frame_axes(normal: Vec3) -> (Vec3, Vec3) {
89    let seed = if normal.x().abs() < 0.9 {
90        Vec3::new(1.0, 0.0, 0.0)
91    } else {
92        Vec3::new(0.0, 1.0, 0.0)
93    };
94    let u_axis = normal
95        .cross(seed)
96        .normalize()
97        .unwrap_or_else(|_| Vec3::new(1.0, 0.0, 0.0));
98    let v_axis = normal.cross(u_axis);
99    (u_axis, v_axis)
100}
101
102impl BrepKernel {
103    /// Extract a `NurbsCurve` from an edge.
104    ///
105    /// NURBS edges are returned directly. Line, Circle, and Ellipse edges
106    /// are converted to their exact rational NURBS equivalent using the
107    /// edge's bounding vertices (and the curve's analytic params for
108    /// circles/ellipses).
109    pub(crate) fn extract_nurbs_curve(&self, edge: u32) -> Result<NurbsCurve, WasmError> {
110        use brepkit_geometry::convert::{circle_to_nurbs, ellipse_to_nurbs, line_to_nurbs};
111        use std::f64::consts::TAU;
112
113        let edge_id = self.resolve_edge(edge)?;
114        let edge_data = self.topo.edge(edge_id)?;
115        let start_v = edge_data.start();
116        let end_v = edge_data.end();
117        let start_pt = self.topo.vertex(start_v)?.point();
118        let end_pt = self.topo.vertex(end_v)?.point();
119
120        match edge_data.curve() {
121            EdgeCurve::NurbsCurve(c) => Ok(c.clone()),
122            EdgeCurve::Line => {
123                Ok(
124                    line_to_nurbs(start_pt, end_pt).map_err(|e| WasmError::InvalidInput {
125                        reason: format!("line_to_nurbs failed: {e}"),
126                    })?,
127                )
128            }
129            EdgeCurve::Circle(c) => {
130                let (t_start, t_end) = if start_v == end_v {
131                    (0.0, TAU)
132                } else {
133                    let ts = c.project(start_pt);
134                    let mut te = c.project(end_pt);
135                    if te <= ts {
136                        te += TAU;
137                    }
138                    (ts, te)
139                };
140                Ok(
141                    circle_to_nurbs(c, t_start, t_end).map_err(|e| WasmError::InvalidInput {
142                        reason: format!("circle_to_nurbs failed: {e}"),
143                    })?,
144                )
145            }
146            EdgeCurve::Ellipse(e) => {
147                let (t_start, t_end) = if start_v == end_v {
148                    (0.0, TAU)
149                } else {
150                    let ts = e.project(start_pt);
151                    let mut te = e.project(end_pt);
152                    if te <= ts {
153                        te += TAU;
154                    }
155                    (ts, te)
156                };
157                Ok(
158                    ellipse_to_nurbs(e, t_start, t_end).map_err(|err| WasmError::InvalidInput {
159                        reason: format!("ellipse_to_nurbs failed: {err}"),
160                    })?,
161                )
162            }
163        }
164    }
165
166    /// Extract a `NurbsSurface` from a face.
167    ///
168    /// NURBS faces are returned directly. Analytic surfaces are converted to
169    /// their NURBS equivalent: planes and cylinders are geometrically exact;
170    /// cones, spheres, and tori use the exact rational forms from
171    /// `brepkit_heal::construct::convert_surface`. Plane and cone parameter
172    /// ranges are derived from the face's boundary vertices.
173    pub(crate) fn extract_nurbs_surface(&self, face: u32) -> Result<NurbsSurface, WasmError> {
174        use brepkit_heal::construct::convert_surface;
175        use brepkit_topology::face::FaceSurface;
176
177        let face_id = self.resolve_face(face)?;
178        let face_data = self.topo.face(face_id)?;
179
180        let map_err = |context: &str, e: brepkit_heal::HealError| WasmError::InvalidInput {
181            reason: format!("{context}: {e}"),
182        };
183
184        match face_data.surface() {
185            FaceSurface::Nurbs(s) => Ok(s.clone()),
186            FaceSurface::Plane { normal, d } => {
187                let (u_range, v_range) = self.plane_face_uv_bounds(face_id, *normal, *d)?;
188                convert_surface::plane_to_nurbs(*normal, *d, u_range, v_range)
189                    .map_err(|e| map_err("plane_to_nurbs failed", e))
190            }
191            FaceSurface::Cylinder(c) => {
192                let v_range = self.analytic_face_v_bounds(face_id, face_data.surface())?;
193                convert_surface::cylinder_to_nurbs(c, v_range)
194                    .map_err(|e| map_err("cylinder_to_nurbs failed", e))
195            }
196            FaceSurface::Cone(c) => {
197                let v_range = self.analytic_face_v_bounds(face_id, face_data.surface())?;
198                convert_surface::cone_to_nurbs(c, v_range)
199                    .map_err(|e| map_err("cone_to_nurbs failed", e))
200            }
201            FaceSurface::Sphere(s) => convert_surface::sphere_to_nurbs(s)
202                .map_err(|e| map_err("sphere_to_nurbs failed", e)),
203            FaceSurface::Torus(t) => {
204                convert_surface::torus_to_nurbs(t).map_err(|e| map_err("torus_to_nurbs failed", e))
205            }
206        }
207    }
208
209    /// Derive the parametric rectangle of a planar face by sampling its outer
210    /// boundary edges and projecting the samples onto the same local frame
211    /// `plane_to_nurbs` uses.
212    ///
213    /// Sampling the edge curves (not just the bounding vertices) is required for
214    /// circle- and ellipse-bounded faces such as cylinder/cone caps, whose
215    /// outer wire may carry a single seam vertex while the disk spans a finite
216    /// rectangle in the plane frame.
217    #[allow(clippy::cast_precision_loss)]
218    fn plane_face_uv_bounds(
219        &self,
220        face_id: brepkit_topology::face::FaceId,
221        normal: Vec3,
222        d: f64,
223    ) -> Result<UvRanges, WasmError> {
224        const EDGE_SAMPLES: usize = 16;
225
226        let face_data = self.topo.face(face_id)?;
227        let wire = self.topo.wire(face_data.outer_wire())?;
228        let origin = Point3::new(0.0, 0.0, 0.0) + normal * d;
229        let (u_axis, v_axis) = plane_frame_axes(normal);
230
231        let mut u_min = f64::INFINITY;
232        let mut u_max = f64::NEG_INFINITY;
233        let mut v_min = f64::INFINITY;
234        let mut v_max = f64::NEG_INFINITY;
235        for oe in wire.edges() {
236            let edge = self.topo.edge(oe.edge())?;
237            let start = self.topo.vertex(edge.start())?.point();
238            let end = self.topo.vertex(edge.end())?.point();
239            let curve = edge.curve();
240            let (t0, t1) = curve.domain_with_endpoints(start, end);
241            for i in 0..=EDGE_SAMPLES {
242                let t = t0 + (t1 - t0) * (i as f64 / EDGE_SAMPLES as f64);
243                let p = curve.evaluate_with_endpoints(t, start, end);
244                let rel = p - origin;
245                let u = rel.dot(u_axis);
246                let v = rel.dot(v_axis);
247                u_min = u_min.min(u);
248                u_max = u_max.max(u);
249                v_min = v_min.min(v);
250                v_max = v_max.max(v);
251            }
252        }
253        if u_max <= u_min || v_max <= v_min {
254            return Err(WasmError::InvalidInput {
255                reason: "planar face has degenerate parametric extent".to_string(),
256            });
257        }
258        Ok(((u_min, u_max), (v_min, v_max)))
259    }
260
261    /// Derive the axial/generator parameter range of an analytic face by
262    /// projecting its boundary vertices onto the surface.
263    fn analytic_face_v_bounds(
264        &self,
265        face_id: brepkit_topology::face::FaceId,
266        surface: &brepkit_topology::face::FaceSurface,
267    ) -> Result<(f64, f64), WasmError> {
268        let verts = brepkit_topology::explorer::face_vertices(&self.topo, face_id)?;
269        let mut v_min = f64::INFINITY;
270        let mut v_max = f64::NEG_INFINITY;
271        for vid in verts {
272            let p = self.topo.vertex(vid)?.point();
273            if let Some((_, v)) = surface.project_point(p) {
274                v_min = v_min.min(v);
275                v_max = v_max.max(v);
276            }
277        }
278        if v_max <= v_min {
279            return Err(WasmError::InvalidInput {
280                reason: "analytic face has degenerate axial extent".to_string(),
281            });
282        }
283        Ok((v_min, v_max))
284    }
285
286    /// Create an edge from a `NurbsCurve`, using its endpoints.
287    pub(crate) fn nurbs_curve_to_edge(
288        &mut self,
289        points: &[Point3],
290        curve: NurbsCurve,
291    ) -> brepkit_topology::edge::EdgeId {
292        let start = points[0];
293        let end = points[points.len() - 1];
294        brepkit_topology::builder::make_nurbs_edge(self.topo_mut(), start, end, curve, TOL)
295    }
296
297    /// Create an edge from a `NurbsCurve`, evaluating its endpoints.
298    pub(crate) fn nurbs_curve_to_edge_from_curve(
299        &mut self,
300        curve: &NurbsCurve,
301    ) -> brepkit_topology::edge::EdgeId {
302        brepkit_topology::builder::make_nurbs_edge_from_curve(self.topo_mut(), curve, TOL)
303    }
304
305    /// Create a face from a `NurbsSurface` with a rectangular domain wire.
306    pub(crate) fn nurbs_surface_to_face(
307        &mut self,
308        surface: NurbsSurface,
309    ) -> Result<brepkit_topology::face::FaceId, JsError> {
310        Ok(brepkit_topology::builder::make_nurbs_face(
311            self.topo_mut(),
312            surface,
313            TOL,
314        )?)
315    }
316
317    /// Dispatch a single batch operation by name.
318    #[allow(clippy::too_many_lines)]
319    fn dispatch_op(
320        &mut self,
321        op: &str,
322        args: &serde_json::Value,
323    ) -> Result<serde_json::Value, String> {
324        match op {
325            "makeBox" => {
326                let w = get_f64(args, "width")?;
327                let h = get_f64(args, "height")?;
328                let d = get_f64(args, "depth")?;
329                let solid = brepkit_operations::primitives::make_box(self.topo_mut(), w, h, d)
330                    .map_err(|e| e.to_string())?;
331                Ok(serde_json::json!(solid_id_to_u32(solid)))
332            }
333            "makeCylinder" => {
334                let r = get_f64(args, "radius")?;
335                let h = get_f64(args, "height")?;
336                let solid = brepkit_operations::primitives::make_cylinder(self.topo_mut(), r, h)
337                    .map_err(|e| e.to_string())?;
338                Ok(serde_json::json!(solid_id_to_u32(solid)))
339            }
340            "makeSphere" => {
341                let r = get_f64(args, "radius")?;
342                let segments = get_u32(args, "segments").unwrap_or(16);
343                let solid = brepkit_operations::primitives::make_sphere(
344                    self.topo_mut(),
345                    r,
346                    segments as usize,
347                )
348                .map_err(|e| e.to_string())?;
349                Ok(serde_json::json!(solid_id_to_u32(solid)))
350            }
351            "makeCone" => {
352                let br = get_f64(args, "bottomRadius")?;
353                let tr = get_f64(args, "topRadius")?;
354                let h = get_f64(args, "height")?;
355                let solid = brepkit_operations::primitives::make_cone(self.topo_mut(), br, tr, h)
356                    .map_err(|e| e.to_string())?;
357                Ok(serde_json::json!(solid_id_to_u32(solid)))
358            }
359            "makeTorus" => {
360                let major = get_f64(args, "majorRadius")?;
361                let minor = get_f64(args, "minorRadius")?;
362                let segments = get_u32(args, "segments").unwrap_or(16);
363                let solid = brepkit_operations::primitives::make_torus(
364                    self.topo_mut(),
365                    major,
366                    minor,
367                    segments as usize,
368                )
369                .map_err(|e| e.to_string())?;
370                Ok(serde_json::json!(solid_id_to_u32(solid)))
371            }
372            "makeEllipsoid" => {
373                let rx = get_f64(args, "rx")?;
374                let ry = get_f64(args, "ry")?;
375                let rz = get_f64(args, "rz")?;
376                if rx <= 0.0 || ry <= 0.0 || rz <= 0.0 {
377                    return Err("rx, ry, rz must be positive".to_string());
378                }
379                let solid = brepkit_operations::primitives::make_sphere(self.topo_mut(), 1.0, 16)
380                    .map_err(|e| e.to_string())?;
381                let mat = brepkit_math::mat::Mat4::scale(rx, ry, rz);
382                transform_solid(self.topo_mut(), solid, &mat).map_err(|e| e.to_string())?;
383                Ok(serde_json::json!(solid_id_to_u32(solid)))
384            }
385            "fuse" | "cut" | "intersect" => {
386                let bool_op = match op {
387                    "fuse" => BooleanOp::Fuse,
388                    "cut" => BooleanOp::Cut,
389                    _ => BooleanOp::Intersect,
390                };
391                let a = get_u32(args, "solidA")?;
392                let b = get_u32(args, "solidB")?;
393                let a_id = self.resolve_solid(a).map_err(|e| e.to_string())?;
394                let b_id = self.resolve_solid(b).map_err(|e| e.to_string())?;
395                let simplify = args["simplify"].as_bool().unwrap_or(false);
396                let result = if simplify {
397                    let opts = brepkit_operations::boolean::BooleanOptions {
398                        unify_faces: true,
399                        ..Default::default()
400                    };
401                    brepkit_operations::boolean::boolean_with_options(
402                        self.topo_mut(),
403                        bool_op,
404                        a_id,
405                        b_id,
406                        opts,
407                    )
408                } else {
409                    boolean(self.topo_mut(), bool_op, a_id, b_id)
410                }
411                .map_err(|e| e.to_string())?;
412                Ok(serde_json::json!(solid_id_to_u32(result)))
413            }
414            "meshFallbackCount" => {
415                #[allow(clippy::cast_precision_loss)]
416                let count = brepkit_operations::boolean::mesh_fallback_count() as f64;
417                Ok(serde_json::json!(count))
418            }
419            "detectCoincidentFaces" => {
420                let a = get_u32(args, "solidA")?;
421                let b = get_u32(args, "solidB")?;
422                let a_id = self.resolve_solid(a).map_err(|e| e.to_string())?;
423                let b_id = self.resolve_solid(b).map_err(|e| e.to_string())?;
424                let pairs = brepkit_algo::diagnostic::detect_coincident_faces(
425                    self.topo(),
426                    a_id,
427                    b_id,
428                    brepkit_math::tolerance::Tolerance::default(),
429                )
430                .map_err(|e| e.to_string())?;
431                Ok(crate::bindings::booleans::coincident_face_pairs_to_json(
432                    &pairs,
433                ))
434            }
435            "compoundCut" => {
436                let target = get_u32(args, "target")?;
437                let target_id = self.resolve_solid(target).map_err(|e| e.to_string())?;
438                let tool_arr = args["tools"]
439                    .as_array()
440                    .ok_or("missing or invalid 'tools' array")?;
441                let tools: Vec<brepkit_topology::solid::SolidId> = tool_arr
442                    .iter()
443                    .enumerate()
444                    .map(|(i, v)| {
445                        let h = v
446                            .as_u64()
447                            .ok_or_else(|| format!("tools[{i}] is not a number"))
448                            .map(|n| n as u32)?;
449                        self.resolve_solid(h).map_err(|e| e.to_string())
450                    })
451                    .collect::<Result<Vec<_>, String>>()?;
452                let result = boolean::compound_cut(
453                    self.topo_mut(),
454                    target_id,
455                    &tools,
456                    boolean::BooleanOptions::default(),
457                )
458                .map_err(|e| e.to_string())?;
459                Ok(serde_json::json!(solid_id_to_u32(result)))
460            }
461            "fuseAll" => {
462                let solid_arr = args["solids"]
463                    .as_array()
464                    .ok_or("missing or invalid 'solids' array")?;
465                let solids: Vec<brepkit_topology::solid::SolidId> = solid_arr
466                    .iter()
467                    .enumerate()
468                    .map(|(i, v)| {
469                        let h = v
470                            .as_u64()
471                            .ok_or_else(|| format!("solids[{i}] is not a number"))
472                            .map(|n| n as u32)?;
473                        self.resolve_solid(h).map_err(|e| e.to_string())
474                    })
475                    .collect::<Result<Vec<_>, String>>()?;
476                let compound = self
477                    .topo_mut()
478                    .add_compound(brepkit_topology::compound::Compound::new(solids));
479                let result = brepkit_operations::compound_ops::fuse_all(self.topo_mut(), compound)
480                    .map_err(|e| e.to_string())?;
481                Ok(serde_json::json!(solid_id_to_u32(result)))
482            }
483            "transform" => {
484                let s = get_u32(args, "solid")?;
485                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
486                let matrix = args["matrix"]
487                    .as_array()
488                    .ok_or("missing or invalid 'matrix'")?;
489                if matrix.len() != 16 {
490                    return Err(format!(
491                        "matrix must have 16 elements, got {}",
492                        matrix.len()
493                    ));
494                }
495                let elems: Vec<f64> = matrix
496                    .iter()
497                    .enumerate()
498                    .map(|(i, v)| {
499                        v.as_f64()
500                            .ok_or_else(|| format!("matrix[{i}] is not a number"))
501                    })
502                    .collect::<Result<_, _>>()?;
503                let rows = std::array::from_fn(|i| std::array::from_fn(|j| elems[i * 4 + j]));
504                let mat = Mat4(rows);
505                transform_solid(self.topo_mut(), solid_id, &mat).map_err(|e| e.to_string())?;
506                Ok(serde_json::json!(solid_id_to_u32(solid_id)))
507            }
508            "volume" => {
509                let s = get_u32(args, "solid")?;
510                let deflection = get_f64(args, "deflection").unwrap_or(0.1);
511                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
512                let v = measure::solid_volume(&self.topo, solid_id, deflection)
513                    .map_err(|e| e.to_string())?;
514                Ok(serde_json::json!(v))
515            }
516            "surfaceArea" => {
517                let s = get_u32(args, "solid")?;
518                let deflection = get_f64(args, "deflection").unwrap_or(0.1);
519                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
520                let a = measure::solid_surface_area(&self.topo, solid_id, deflection)
521                    .map_err(|e| e.to_string())?;
522                Ok(serde_json::json!(a))
523            }
524            "boundingBox" => {
525                let s = get_u32(args, "solid")?;
526                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
527                let aabb =
528                    measure::solid_bounding_box(&self.topo, solid_id).map_err(|e| e.to_string())?;
529                Ok(serde_json::json!([
530                    aabb.min.x(),
531                    aabb.min.y(),
532                    aabb.min.z(),
533                    aabb.max.x(),
534                    aabb.max.y(),
535                    aabb.max.z()
536                ]))
537            }
538            "centerOfMass" => {
539                let s = get_u32(args, "solid")?;
540                let deflection = get_f64(args, "deflection").unwrap_or(0.1);
541                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
542                let com = measure::solid_center_of_mass(&self.topo, solid_id, deflection)
543                    .map_err(|e| e.to_string())?;
544                Ok(serde_json::json!([com.x(), com.y(), com.z()]))
545            }
546            "solidEdges" => {
547                let s = get_u32(args, "solid")?;
548                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
549                let edges = brepkit_topology::explorer::solid_edges(&self.topo, solid_id)
550                    .map_err(|e| e.to_string())?;
551                let handles: Vec<u32> = edges.iter().map(|&e| edge_id_to_u32(e)).collect();
552                Ok(serde_json::json!(handles))
553            }
554            "solidToSolidDistance" => {
555                let a = get_u32(args, "solidA")?;
556                let b = get_u32(args, "solidB")?;
557                let a_id = self.resolve_solid(a).map_err(|e| e.to_string())?;
558                let b_id = self.resolve_solid(b).map_err(|e| e.to_string())?;
559                let result =
560                    brepkit_operations::distance::solid_to_solid_distance(&self.topo, a_id, b_id)
561                        .map_err(|e| e.to_string())?;
562                Ok(serde_json::json!([
563                    result.distance,
564                    result.point_a.x(),
565                    result.point_a.y(),
566                    result.point_a.z(),
567                    result.point_b.x(),
568                    result.point_b.y(),
569                    result.point_b.z(),
570                ]))
571            }
572            "copySolid" => {
573                let s = get_u32(args, "solid")?;
574                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
575                let copy = brepkit_operations::copy::copy_solid(self.topo_mut(), solid_id)
576                    .map_err(|e| e.to_string())?;
577                Ok(serde_json::json!(solid_id_to_u32(copy)))
578            }
579            "copyAndTransformSolid" => {
580                let s = get_u32(args, "solid")?;
581                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
582                let matrix = args["matrix"]
583                    .as_array()
584                    .ok_or("missing or invalid 'matrix'")?;
585                if matrix.len() != 16 {
586                    return Err(format!(
587                        "matrix must have 16 elements, got {}",
588                        matrix.len()
589                    ));
590                }
591                let elems: Vec<f64> = matrix
592                    .iter()
593                    .enumerate()
594                    .map(|(i, v)| {
595                        v.as_f64()
596                            .ok_or_else(|| format!("matrix[{i}] is not a number"))
597                    })
598                    .collect::<Result<_, _>>()?;
599                let rows = std::array::from_fn(|i| std::array::from_fn(|j| elems[i * 4 + j]));
600                let mat = Mat4(rows);
601                let copy = brepkit_operations::copy::copy_and_transform_solid(
602                    self.topo_mut(),
603                    solid_id,
604                    &mat,
605                )
606                .map_err(|e| e.to_string())?;
607                Ok(serde_json::json!(solid_id_to_u32(copy)))
608            }
609            // ── Batch 8: new batch-dispatched operations ──────────────
610            "extrude" => {
611                let f = get_u32(args, "face")?;
612                let dx = get_f64(args, "dx").unwrap_or(0.0);
613                let dy = get_f64(args, "dy").unwrap_or(0.0);
614                let dz = get_f64(args, "dz").unwrap_or(1.0);
615                let dist = get_f64(args, "distance").unwrap_or(1.0);
616                let face_id = self.resolve_face(f).map_err(|e| e.to_string())?;
617                let dir = Vec3::new(dx, dy, dz);
618                let solid =
619                    extrude(self.topo_mut(), face_id, dir, dist).map_err(|e| e.to_string())?;
620                Ok(serde_json::json!(solid_id_to_u32(solid)))
621            }
622            "revolve" => {
623                let f = get_u32(args, "face")?;
624                let angle_degrees = get_f64(args, "angle")?;
625                let ox = get_f64(args, "originX").unwrap_or(0.0);
626                let oy = get_f64(args, "originY").unwrap_or(0.0);
627                let oz = get_f64(args, "originZ").unwrap_or(0.0);
628                let ax = get_f64(args, "axisX").unwrap_or(0.0);
629                let ay = get_f64(args, "axisY").unwrap_or(0.0);
630                let az = get_f64(args, "axisZ").unwrap_or(1.0);
631                let face_id = self.resolve_face(f).map_err(|e| e.to_string())?;
632                // Convert degrees to radians to match the direct WASM binding.
633                let solid = revolve(
634                    self.topo_mut(),
635                    face_id,
636                    Point3::new(ox, oy, oz),
637                    Vec3::new(ax, ay, az),
638                    angle_degrees.to_radians(),
639                )
640                .map_err(|e| e.to_string())?;
641                Ok(serde_json::json!(solid_id_to_u32(solid)))
642            }
643            "sweep" => {
644                let f = get_u32(args, "face")?;
645                let e = get_u32(args, "pathEdge")?;
646                let face_id = self.resolve_face(f).map_err(|e| e.to_string())?;
647                let edge_id = self.resolve_edge(e).map_err(|e| e.to_string())?;
648                let edge_data = self.topo.edge(edge_id).map_err(|e| e.to_string())?;
649                let curve = match edge_data.curve() {
650                    EdgeCurve::NurbsCurve(c) => c.clone(),
651                    EdgeCurve::Line | EdgeCurve::Circle(_) | EdgeCurve::Ellipse(_) => {
652                        return Err("sweep path must be a NURBS edge".into());
653                    }
654                };
655                let solid = sweep(self.topo_mut(), face_id, &curve).map_err(|e| e.to_string())?;
656                Ok(serde_json::json!(solid_id_to_u32(solid)))
657            }
658            "multiSectionSweep" => {
659                let faces: Vec<u32> = args["faces"]
660                    .as_array()
661                    .map(|a| {
662                        a.iter()
663                            .filter_map(|v| v.as_u64().map(|n| n as u32))
664                            .collect()
665                    })
666                    .unwrap_or_default();
667                let params: Vec<f64> = args["params"]
668                    .as_array()
669                    .map(|a| a.iter().filter_map(serde_json::Value::as_f64).collect())
670                    .unwrap_or_default();
671                if faces.len() != params.len() {
672                    return Err("multiSectionSweep: faces and params length mismatch".into());
673                }
674                let spine_edge = get_u32(args, "spineEdge")?;
675                let edge_id = self.resolve_edge(spine_edge).map_err(|e| e.to_string())?;
676                let edge_data = self.topo.edge(edge_id).map_err(|e| e.to_string())?;
677                let spine = match edge_data.curve() {
678                    EdgeCurve::NurbsCurve(c) => c.clone(),
679                    EdgeCurve::Line | EdgeCurve::Circle(_) | EdgeCurve::Ellipse(_) => {
680                        return Err("multiSectionSweep spine must be a NURBS edge".into());
681                    }
682                };
683                let ruled = args["ruled"].as_bool().unwrap_or(true);
684                let sections: Vec<(brepkit_topology::face::FaceId, f64)> = faces
685                    .iter()
686                    .zip(params.iter())
687                    .map(|(&h, &p)| {
688                        self.resolve_face(h)
689                            .map(|f| (f, p))
690                            .map_err(|e| e.to_string())
691                    })
692                    .collect::<Result<Vec<_>, _>>()?;
693                let solid = brepkit_operations::sweep::multi_section_sweep(
694                    self.topo_mut(),
695                    &spine,
696                    &sections,
697                    ruled,
698                )
699                .map_err(|e| e.to_string())?;
700                Ok(serde_json::json!(solid_id_to_u32(solid)))
701            }
702            "guidedSweep" => {
703                let face_id = self
704                    .resolve_face(get_u32(args, "face")?)
705                    .map_err(|e| e.to_string())?;
706                let nurbs_of =
707                    |this: &Self, edge: u32, label: &str| -> Result<NurbsCurve, String> {
708                        let edge_id = this.resolve_edge(edge).map_err(|e| e.to_string())?;
709                        let edge_data = this.topo.edge(edge_id).map_err(|e| e.to_string())?;
710                        match edge_data.curve() {
711                            EdgeCurve::NurbsCurve(c) => Ok(c.clone()),
712                            EdgeCurve::Line | EdgeCurve::Circle(_) | EdgeCurve::Ellipse(_) => {
713                                Err(format!("guidedSweep {label} must be a NURBS edge"))
714                            }
715                        }
716                    };
717                let spine = nurbs_of(self, get_u32(args, "spineEdge")?, "spineEdge")?;
718                let aux = nurbs_of(self, get_u32(args, "auxEdge")?, "auxEdge")?;
719                let solid =
720                    brepkit_operations::sweep::sweep_guided(self.topo_mut(), face_id, &spine, aux)
721                        .map_err(|e| e.to_string())?;
722                Ok(serde_json::json!(solid_id_to_u32(solid)))
723            }
724            "minkowskiSum" => {
725                let a = self
726                    .resolve_solid(get_u32(args, "solidA")?)
727                    .map_err(|e| e.to_string())?;
728                let b = self
729                    .resolve_solid(get_u32(args, "solidB")?)
730                    .map_err(|e| e.to_string())?;
731                let solid =
732                    brepkit_operations::primitives::make_minkowski_sum(self.topo_mut(), a, b)
733                        .map_err(|e| e.to_string())?;
734                Ok(serde_json::json!(solid_id_to_u32(solid)))
735            }
736            "projectEdges" => {
737                let solid = self
738                    .resolve_solid(get_u32(args, "solid")?)
739                    .map_err(|e| e.to_string())?;
740                let origin = Point3::new(
741                    get_f64(args, "originX")?,
742                    get_f64(args, "originY")?,
743                    get_f64(args, "originZ")?,
744                );
745                let dir = Vec3::new(
746                    get_f64(args, "dirX")?,
747                    get_f64(args, "dirY")?,
748                    get_f64(args, "dirZ")?,
749                );
750                let x_axis = Vec3::new(
751                    get_f64(args, "xAxisX")?,
752                    get_f64(args, "xAxisY")?,
753                    get_f64(args, "xAxisZ")?,
754                );
755                let hidden_lines = args["hiddenLines"].as_bool().unwrap_or(true);
756                let deflection = get_f64(args, "deflection").unwrap_or(0.1);
757                let result = brepkit_operations::projection::project_edges(
758                    &self.topo,
759                    solid,
760                    origin,
761                    dir,
762                    x_axis,
763                    hidden_lines,
764                    deflection,
765                )
766                .map_err(|e| e.to_string())?;
767                let flatten = |polys: &[Vec<brepkit_math::vec::Point2>]| -> Vec<Vec<f64>> {
768                    polys
769                        .iter()
770                        .map(|poly| poly.iter().flat_map(|p| [p.x(), p.y()]).collect())
771                        .collect()
772                };
773                Ok(serde_json::json!({
774                    "visible": flatten(&result.visible),
775                    "hidden": flatten(&result.hidden),
776                }))
777            }
778            "chamfer" => {
779                let s = get_u32(args, "solid")?;
780                let dist = get_f64(args, "distance")?;
781                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
782                let edge_handles: Vec<u32> = args["edges"]
783                    .as_array()
784                    .map(|arr| {
785                        arr.iter()
786                            .filter_map(|v| v.as_u64().map(|n| n as u32))
787                            .collect()
788                    })
789                    .unwrap_or_default();
790                let edge_ids: Vec<_> = edge_handles
791                    .iter()
792                    .map(|&h| self.resolve_edge(h).map_err(|e| e.to_string()))
793                    .collect::<Result<Vec<_>, _>>()?;
794                let result = brepkit_operations::chamfer::chamfer(
795                    self.topo_mut(),
796                    solid_id,
797                    &edge_ids,
798                    dist,
799                )
800                .map_err(|e| e.to_string())?;
801                Ok(serde_json::json!(solid_id_to_u32(result)))
802            }
803            "fillet" => {
804                let s = get_u32(args, "solid")?;
805                let radius = get_f64(args, "radius")?;
806                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
807                let edge_handles: Vec<u32> = args["edges"]
808                    .as_array()
809                    .map(|arr| {
810                        arr.iter()
811                            .filter_map(|v| v.as_u64().map(|n| n as u32))
812                            .collect()
813                    })
814                    .unwrap_or_default();
815                let edge_ids: Vec<_> = edge_handles
816                    .iter()
817                    .map(|&h| self.resolve_edge(h).map_err(|e| e.to_string()))
818                    .collect::<Result<Vec<_>, _>>()?;
819                let fillet_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
820                    try_fillet(self.topo_mut(), solid_id, &edge_ids, radius)
821                }));
822                let result = match fillet_result {
823                    Ok(inner) => inner.map_err(|e| e.to_string())?,
824                    Err(panic_info) => {
825                        return Err(panic_message(&panic_info, "Fillet"));
826                    }
827                };
828                Ok(serde_json::json!(solid_id_to_u32(result)))
829            }
830            "filletVariable" => {
831                let s = get_u32(args, "solid")?;
832                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
833                let specs = args["specs"]
834                    .as_array()
835                    .ok_or_else(|| "missing 'specs' array".to_string())?;
836                let mut edge_laws = Vec::with_capacity(specs.len());
837                for spec in specs {
838                    let edge_handle = spec["edge"]
839                        .as_u64()
840                        .ok_or_else(|| "missing 'edge' in fillet spec".to_string())?
841                        as u32;
842                    let edge_id = self.resolve_edge(edge_handle).map_err(|e| e.to_string())?;
843                    let start_val = spec["start"]
844                        .as_f64()
845                        .or_else(|| spec["startRadius"].as_f64());
846                    let end_val = spec["end"].as_f64().or_else(|| spec["endRadius"].as_f64());
847                    let law_str =
848                        spec["law"]
849                            .as_str()
850                            .unwrap_or_else(|| match (start_val, end_val) {
851                                (Some(sv), Some(ev)) if (sv - ev).abs() > f64::EPSILON => "linear",
852                                _ => "constant",
853                            });
854                    let law = match law_str {
855                        "linear" => brepkit_operations::fillet::FilletRadiusLaw::Linear {
856                            start: start_val.unwrap_or(1.0),
857                            end: end_val.unwrap_or(1.0),
858                        },
859                        "scurve" => brepkit_operations::fillet::FilletRadiusLaw::SCurve {
860                            start: start_val.unwrap_or(1.0),
861                            end: end_val.unwrap_or(1.0),
862                        },
863                        _ => {
864                            let r = spec["radius"].as_f64().or(start_val).unwrap_or(1.0);
865                            brepkit_operations::fillet::FilletRadiusLaw::Constant(r)
866                        }
867                    };
868                    edge_laws.push((edge_id, law));
869                }
870                let result = brepkit_operations::fillet::fillet_variable(
871                    self.topo_mut(),
872                    solid_id,
873                    &edge_laws,
874                )
875                .map_err(|e| e.to_string())?;
876                Ok(serde_json::json!(solid_id_to_u32(result)))
877            }
878            "filletV2" => {
879                let s = get_u32(args, "solid")?;
880                let radius = get_f64(args, "radius")?;
881                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
882                let edge_handles: Vec<u32> = args["edges"]
883                    .as_array()
884                    .map(|arr| {
885                        arr.iter()
886                            .filter_map(|v| v.as_u64().map(|n| n as u32))
887                            .collect()
888                    })
889                    .unwrap_or_default();
890                let edge_ids: Vec<_> = edge_handles
891                    .iter()
892                    .map(|&h| self.resolve_edge(h).map_err(|e| e.to_string()))
893                    .collect::<Result<Vec<_>, _>>()?;
894                let result = brepkit_operations::blend_ops::fillet_v2(
895                    self.topo_mut(),
896                    solid_id,
897                    &edge_ids,
898                    radius,
899                )
900                .map_err(|e| e.to_string())?;
901                Ok(serde_json::json!(solid_id_to_u32(result.solid)))
902            }
903            "chamferV2" => {
904                let s = get_u32(args, "solid")?;
905                let d1 = get_f64(args, "d1")?;
906                let d2 = get_f64(args, "d2")?;
907                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
908                let edge_handles: Vec<u32> = args["edges"]
909                    .as_array()
910                    .map(|arr| {
911                        arr.iter()
912                            .filter_map(|v| v.as_u64().map(|n| n as u32))
913                            .collect()
914                    })
915                    .unwrap_or_default();
916                let edge_ids: Vec<_> = edge_handles
917                    .iter()
918                    .map(|&h| self.resolve_edge(h).map_err(|e| e.to_string()))
919                    .collect::<Result<Vec<_>, _>>()?;
920                let result = brepkit_operations::blend_ops::chamfer_v2(
921                    self.topo_mut(),
922                    solid_id,
923                    &edge_ids,
924                    d1,
925                    d2,
926                )
927                .map_err(|e| e.to_string())?;
928                Ok(serde_json::json!(solid_id_to_u32(result.solid)))
929            }
930            "chamferDistanceAngle" => {
931                let s = get_u32(args, "solid")?;
932                let distance = get_f64(args, "distance")?;
933                let angle = get_f64(args, "angle")?;
934                if angle >= std::f64::consts::FRAC_PI_2 {
935                    return Err("angle must be less than π/2".into());
936                }
937                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
938                let edge_handles: Vec<u32> = args["edges"]
939                    .as_array()
940                    .map(|arr| {
941                        arr.iter()
942                            .filter_map(|v| v.as_u64().map(|n| n as u32))
943                            .collect()
944                    })
945                    .unwrap_or_default();
946                let edge_ids: Vec<_> = edge_handles
947                    .iter()
948                    .map(|&h| self.resolve_edge(h).map_err(|e| e.to_string()))
949                    .collect::<Result<Vec<_>, _>>()?;
950                let result = brepkit_operations::blend_ops::chamfer_distance_angle(
951                    self.topo_mut(),
952                    solid_id,
953                    &edge_ids,
954                    distance,
955                    angle,
956                )
957                .map_err(|e| e.to_string())?;
958                Ok(serde_json::json!(solid_id_to_u32(result.solid)))
959            }
960            "shell" => {
961                let s = get_u32(args, "solid")?;
962                let thickness = get_f64(args, "thickness")?;
963                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
964                let face_handles: Vec<u32> = args["faces"]
965                    .as_array()
966                    .map(|arr| {
967                        arr.iter()
968                            .filter_map(|v| v.as_u64().map(|n| n as u32))
969                            .collect()
970                    })
971                    .unwrap_or_default();
972                let face_ids: Vec<_> = face_handles
973                    .iter()
974                    .map(|&h| self.resolve_face(h).map_err(|e| e.to_string()))
975                    .collect::<Result<Vec<_>, _>>()?;
976                let result = brepkit_operations::shell_op::shell(
977                    self.topo_mut(),
978                    solid_id,
979                    thickness,
980                    &face_ids,
981                )
982                .map_err(|e| e.to_string())?;
983                Ok(serde_json::json!(solid_id_to_u32(result)))
984            }
985            "mirror" => {
986                let s = get_u32(args, "solid")?;
987                let px = get_f64(args, "px").unwrap_or(0.0);
988                let py = get_f64(args, "py").unwrap_or(0.0);
989                let pz = get_f64(args, "pz").unwrap_or(0.0);
990                let nx = get_f64(args, "nx").unwrap_or(1.0);
991                let ny = get_f64(args, "ny").unwrap_or(0.0);
992                let nz = get_f64(args, "nz").unwrap_or(0.0);
993                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
994                let result = brepkit_operations::mirror::mirror(
995                    self.topo_mut(),
996                    solid_id,
997                    Point3::new(px, py, pz),
998                    Vec3::new(nx, ny, nz),
999                )
1000                .map_err(|e| e.to_string())?;
1001                Ok(serde_json::json!(solid_id_to_u32(result)))
1002            }
1003            "unifyFaces" => {
1004                let s = get_u32(args, "solid")?;
1005                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
1006                brepkit_operations::heal::unify_faces(self.topo_mut(), solid_id)
1007                    .map_err(|e| e.to_string())?;
1008                Ok(serde_json::json!(solid_id_to_u32(solid_id)))
1009            }
1010            "convertToBspline" => {
1011                let s = get_u32(args, "solid")?;
1012                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
1013                let count = brepkit_operations::heal::convert_to_bspline(self.topo_mut(), solid_id)
1014                    .map_err(|e| e.to_string())?;
1015                Ok(serde_json::json!({
1016                    "solid": solid_id_to_u32(solid_id),
1017                    "converted": count,
1018                }))
1019            }
1020            "convertToElementary" => {
1021                let s = get_u32(args, "solid")?;
1022                let tol = get_f64(args, "tolerance").unwrap_or(crate::helpers::TOL);
1023                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
1024                let count =
1025                    brepkit_operations::heal::convert_to_elementary(self.topo_mut(), solid_id, tol)
1026                        .map_err(|e| e.to_string())?;
1027                Ok(serde_json::json!({
1028                    "solid": solid_id_to_u32(solid_id),
1029                    "converted": count,
1030                }))
1031            }
1032            "healSolid" => {
1033                let s = get_u32(args, "solid")?;
1034                let tol = get_f64(args, "tolerance").unwrap_or(1e-7);
1035                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
1036                brepkit_operations::heal::heal_solid(self.topo_mut(), solid_id, tol)
1037                    .map_err(|e| e.to_string())?;
1038                Ok(serde_json::json!(solid_id_to_u32(solid_id)))
1039            }
1040            "repairSolid" => {
1041                let s = get_u32(args, "solid")?;
1042                let tol = get_f64(args, "tolerance").unwrap_or(1e-7);
1043                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
1044                let report = brepkit_operations::heal::repair_solid(self.topo_mut(), solid_id, tol)
1045                    .map_err(|e| e.to_string())?;
1046                Ok(serde_json::json!({
1047                    "solid": solid_id_to_u32(solid_id),
1048                    "errorsBefore": report.before.error_count(),
1049                    "errorsAfter": report.after.error_count(),
1050                    "totalRepairs": report.total_repairs(),
1051                }))
1052            }
1053            "classifyPoint" => {
1054                let s = get_u32(args, "solid")?;
1055                let x = get_f64(args, "x")?;
1056                let y = get_f64(args, "y")?;
1057                let z = get_f64(args, "z")?;
1058                let tol = get_f64(args, "tolerance").unwrap_or(1e-7);
1059                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
1060                let pt = Point3::new(x, y, z);
1061                let result = brepkit_operations::classify::classify_point(
1062                    &self.topo, solid_id, pt, 0.1, tol,
1063                )
1064                .map_err(|e| e.to_string())?;
1065                Ok(serde_json::json!(classify_to_string(result)))
1066            }
1067            "loft" => {
1068                let face_handles: Vec<u32> = args["faces"]
1069                    .as_array()
1070                    .map(|arr| {
1071                        arr.iter()
1072                            .filter_map(|v| v.as_u64().map(|n| n as u32))
1073                            .collect()
1074                    })
1075                    .unwrap_or_default();
1076                let face_ids: Vec<_> = face_handles
1077                    .iter()
1078                    .map(|&h| self.resolve_face(h).map_err(|e| e.to_string()))
1079                    .collect::<Result<Vec<_>, _>>()?;
1080                let result = brepkit_operations::loft::loft(self.topo_mut(), &face_ids)
1081                    .map_err(|e| e.to_string())?;
1082                Ok(serde_json::json!(solid_id_to_u32(result)))
1083            }
1084            "loftSmooth" => {
1085                let face_handles: Vec<u32> = args["faces"]
1086                    .as_array()
1087                    .map(|arr| {
1088                        arr.iter()
1089                            .filter_map(|v| v.as_u64().map(|n| n as u32))
1090                            .collect()
1091                    })
1092                    .unwrap_or_default();
1093                let face_ids: Vec<_> = face_handles
1094                    .iter()
1095                    .map(|&h| self.resolve_face(h).map_err(|e| e.to_string()))
1096                    .collect::<Result<Vec<_>, _>>()?;
1097                let result = brepkit_operations::loft::loft_smooth(self.topo_mut(), &face_ids)
1098                    .map_err(|e| e.to_string())?;
1099                Ok(serde_json::json!(solid_id_to_u32(result)))
1100            }
1101            "circularPattern" => {
1102                let s = get_u32(args, "solid")?;
1103                let ax = get_f64(args, "ax").unwrap_or(0.0);
1104                let ay = get_f64(args, "ay").unwrap_or(0.0);
1105                let az = get_f64(args, "az").unwrap_or(1.0);
1106                let count = get_u32(args, "count")?;
1107                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
1108                let axis = Vec3::new(ax, ay, az);
1109                let compound = brepkit_operations::pattern::circular_pattern(
1110                    self.topo_mut(),
1111                    solid_id,
1112                    axis,
1113                    count as usize,
1114                )
1115                .map_err(|e| e.to_string())?;
1116                Ok(serde_json::json!(compound_id_to_u32(compound)))
1117            }
1118            "gridPattern" => {
1119                let s = get_u32(args, "solid")?;
1120                let dxx = get_f64(args, "dirXx").unwrap_or(1.0);
1121                let dxy = get_f64(args, "dirXy").unwrap_or(0.0);
1122                let dxz = get_f64(args, "dirXz").unwrap_or(0.0);
1123                let dyx = get_f64(args, "dirYx").unwrap_or(0.0);
1124                let dyy = get_f64(args, "dirYy").unwrap_or(1.0);
1125                let dyz = get_f64(args, "dirYz").unwrap_or(0.0);
1126                let sx = get_f64(args, "spacingX")?;
1127                let sy = get_f64(args, "spacingY")?;
1128                let cx = get_u32(args, "countX")?;
1129                let cy = get_u32(args, "countY")?;
1130                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
1131                let compound = brepkit_operations::pattern::grid_pattern(
1132                    self.topo_mut(),
1133                    solid_id,
1134                    Vec3::new(dxx, dxy, dxz),
1135                    Vec3::new(dyx, dyy, dyz),
1136                    sx,
1137                    sy,
1138                    cx as usize,
1139                    cy as usize,
1140                )
1141                .map_err(|e| e.to_string())?;
1142                Ok(serde_json::json!(compound_id_to_u32(compound)))
1143            }
1144            "defeature" => {
1145                let s = get_u32(args, "solid")?;
1146                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
1147                let face_handles: Vec<u32> = args["faces"]
1148                    .as_array()
1149                    .map(|arr| {
1150                        arr.iter()
1151                            .filter_map(|v| v.as_u64().map(|n| n as u32))
1152                            .collect()
1153                    })
1154                    .unwrap_or_default();
1155                let face_ids: Vec<_> = face_handles
1156                    .iter()
1157                    .map(|&h| self.resolve_face(h).map_err(|e| e.to_string()))
1158                    .collect::<Result<Vec<_>, _>>()?;
1159                let result =
1160                    brepkit_operations::defeature::defeature(self.topo_mut(), solid_id, &face_ids)
1161                        .map_err(|e| e.to_string())?;
1162                Ok(serde_json::json!(solid_id_to_u32(result)))
1163            }
1164            "copyWire" => {
1165                let w = get_u32(args, "wire")?;
1166                let wire_id = self.resolve_wire(w).map_err(|e| e.to_string())?;
1167                let copy = brepkit_operations::copy::copy_wire(self.topo_mut(), wire_id)
1168                    .map_err(|e| e.to_string())?;
1169                Ok(serde_json::json!(wire_id_to_u32(copy)))
1170            }
1171            "copyFace" => {
1172                let f = get_u32(args, "face")?;
1173                let face_id = self.resolve_face(f).map_err(|e| e.to_string())?;
1174                let copy = brepkit_operations::copy::copy_face(self.topo_mut(), face_id)
1175                    .map_err(|e| e.to_string())?;
1176                Ok(serde_json::json!(face_id_to_u32(copy)))
1177            }
1178            "transformWire" => {
1179                let w = get_u32(args, "wire")?;
1180                let wire_id = self.resolve_wire(w).map_err(|e| e.to_string())?;
1181                let matrix = args["matrix"]
1182                    .as_array()
1183                    .ok_or("missing or invalid 'matrix'")?;
1184                if matrix.len() != 16 {
1185                    return Err(format!(
1186                        "matrix must have 16 elements, got {}",
1187                        matrix.len()
1188                    ));
1189                }
1190                let elems: Vec<f64> = matrix
1191                    .iter()
1192                    .enumerate()
1193                    .map(|(i, v)| {
1194                        v.as_f64()
1195                            .ok_or_else(|| format!("matrix[{i}] is not a number"))
1196                    })
1197                    .collect::<Result<_, _>>()?;
1198                if let Some(pos) = elems.iter().position(|v| !v.is_finite()) {
1199                    return Err(format!("matrix element at index {pos} is not finite"));
1200                }
1201                let rows = std::array::from_fn(|i| std::array::from_fn(|j| elems[i * 4 + j]));
1202                let mat = Mat4(rows);
1203                brepkit_operations::transform::transform_wire(self.topo_mut(), wire_id, &mat)
1204                    .map_err(|e| e.to_string())?;
1205                Ok(serde_json::json!(null))
1206            }
1207            "transformFace" => {
1208                let f = get_u32(args, "face")?;
1209                let face_id = self.resolve_face(f).map_err(|e| e.to_string())?;
1210                let matrix = args["matrix"]
1211                    .as_array()
1212                    .ok_or("missing or invalid 'matrix'")?;
1213                if matrix.len() != 16 {
1214                    return Err(format!(
1215                        "matrix must have 16 elements, got {}",
1216                        matrix.len()
1217                    ));
1218                }
1219                let elems: Vec<f64> = matrix
1220                    .iter()
1221                    .enumerate()
1222                    .map(|(i, v)| {
1223                        v.as_f64()
1224                            .ok_or_else(|| format!("matrix element {i} is not a number"))
1225                    })
1226                    .collect::<Result<_, _>>()?;
1227                if let Some(pos) = elems.iter().position(|v| !v.is_finite()) {
1228                    return Err(format!("matrix element at index {pos} is not finite"));
1229                }
1230                let rows = std::array::from_fn(|i| std::array::from_fn(|j| elems[i * 4 + j]));
1231                let mat = Mat4(rows);
1232                brepkit_operations::transform::transform_face(self.topo_mut(), face_id, &mat)
1233                    .map_err(|e| e.to_string())?;
1234                Ok(serde_json::json!(null))
1235            }
1236            "offsetFace" => {
1237                let f = get_u32(args, "face")?;
1238                let dist = get_f64(args, "distance")?;
1239                let samples = get_u32(args, "samples").unwrap_or(16);
1240                let face_id = self.resolve_face(f).map_err(|e| e.to_string())?;
1241                let result = brepkit_operations::offset_face::offset_face(
1242                    self.topo_mut(),
1243                    face_id,
1244                    dist,
1245                    samples as usize,
1246                )
1247                .map_err(|e| e.to_string())?;
1248                Ok(serde_json::json!(face_id_to_u32(result)))
1249            }
1250            "offsetSolid" => {
1251                let s = get_u32(args, "solid")?;
1252                let dist = get_f64(args, "distance")?;
1253                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
1254                let result =
1255                    brepkit_operations::offset_v2::offset_solid_v2(self.topo_mut(), solid_id, dist)
1256                        .map_err(|e| e.to_string())?;
1257                Ok(serde_json::json!(solid_id_to_u32(result)))
1258            }
1259            "offsetSolidV2" => {
1260                let s = get_u32(args, "solid")?;
1261                let dist = get_f64(args, "distance")?;
1262                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
1263                let result =
1264                    brepkit_operations::offset_v2::offset_solid_v2(self.topo_mut(), solid_id, dist)
1265                        .map_err(|e| e.to_string())?;
1266                Ok(serde_json::json!(solid_id_to_u32(result)))
1267            }
1268            "section" => {
1269                let s = get_u32(args, "solid")?;
1270                let px = get_f64(args, "px").unwrap_or(0.0);
1271                let py = get_f64(args, "py").unwrap_or(0.0);
1272                let pz = get_f64(args, "pz").unwrap_or(0.0);
1273                let nx = get_f64(args, "nx").unwrap_or(0.0);
1274                let ny = get_f64(args, "ny").unwrap_or(0.0);
1275                let nz = get_f64(args, "nz").unwrap_or(1.0);
1276                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
1277                let result = brepkit_operations::section::section(
1278                    self.topo_mut(),
1279                    solid_id,
1280                    Point3::new(px, py, pz),
1281                    Vec3::new(nx, ny, nz),
1282                )
1283                .map_err(|e| e.to_string())?;
1284                let face_ids: Vec<u32> = result.faces.iter().map(|&f| face_id_to_u32(f)).collect();
1285                Ok(serde_json::json!(face_ids))
1286            }
1287            "split" => {
1288                let s = get_u32(args, "solid")?;
1289                let px = get_f64(args, "px").unwrap_or(0.0);
1290                let py = get_f64(args, "py").unwrap_or(0.0);
1291                let pz = get_f64(args, "pz").unwrap_or(0.0);
1292                let nx = get_f64(args, "nx").unwrap_or(0.0);
1293                let ny = get_f64(args, "ny").unwrap_or(0.0);
1294                let nz = get_f64(args, "nz").unwrap_or(1.0);
1295                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
1296                let result = brepkit_operations::split::split(
1297                    self.topo_mut(),
1298                    solid_id,
1299                    Point3::new(px, py, pz),
1300                    Vec3::new(nx, ny, nz),
1301                )
1302                .map_err(|e| e.to_string())?;
1303                Ok(serde_json::json!({
1304                    "positive": solid_id_to_u32(result.positive),
1305                    "negative": solid_id_to_u32(result.negative),
1306                }))
1307            }
1308            "sewFaces" => {
1309                let face_handles: Vec<u32> = args["faces"]
1310                    .as_array()
1311                    .map(|arr| {
1312                        arr.iter()
1313                            .filter_map(|v| v.as_u64().map(|n| n as u32))
1314                            .collect()
1315                    })
1316                    .unwrap_or_default();
1317                let tol = get_f64(args, "tolerance").unwrap_or(1e-6);
1318                let face_ids: Vec<_> = face_handles
1319                    .iter()
1320                    .map(|&h| self.resolve_face(h).map_err(|e| e.to_string()))
1321                    .collect::<Result<Vec<_>, _>>()?;
1322                let solid = brepkit_operations::sew::sew_faces(self.topo_mut(), &face_ids, tol)
1323                    .map_err(|e| e.to_string())?;
1324                Ok(serde_json::json!(solid_id_to_u32(solid)))
1325            }
1326            "thicken" => {
1327                let f = get_u32(args, "face")?;
1328                let thickness = get_f64(args, "thickness")?;
1329                let face_id = self.resolve_face(f).map_err(|e| e.to_string())?;
1330                let result =
1331                    brepkit_operations::thicken::thicken(self.topo_mut(), face_id, thickness)
1332                        .map_err(|e| e.to_string())?;
1333                Ok(serde_json::json!(solid_id_to_u32(result)))
1334            }
1335            "pipe" => {
1336                let f = get_u32(args, "face")?;
1337                let e = get_u32(args, "pathEdge")?;
1338                let face_id = self.resolve_face(f).map_err(|e| e.to_string())?;
1339                let edge_id = self.resolve_edge(e).map_err(|e| e.to_string())?;
1340                let edge_data = self.topo.edge(edge_id).map_err(|e| e.to_string())?;
1341                let curve = match edge_data.curve() {
1342                    EdgeCurve::NurbsCurve(c) => c.clone(),
1343                    EdgeCurve::Line | EdgeCurve::Circle(_) | EdgeCurve::Ellipse(_) => {
1344                        return Err("pipe path must be a NURBS edge".into());
1345                    }
1346                };
1347                let solid = brepkit_operations::pipe::pipe(self.topo_mut(), face_id, &curve, None)
1348                    .map_err(|e| e.to_string())?;
1349                Ok(serde_json::json!(solid_id_to_u32(solid)))
1350            }
1351            "linearPattern" => {
1352                let s = get_u32(args, "solid")?;
1353                let dx = get_f64(args, "dx").unwrap_or(1.0);
1354                let dy = get_f64(args, "dy").unwrap_or(0.0);
1355                let dz = get_f64(args, "dz").unwrap_or(0.0);
1356                let spacing = get_f64(args, "spacing")?;
1357                let count = get_u32(args, "count")?;
1358                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
1359                let compound = brepkit_operations::pattern::linear_pattern(
1360                    self.topo_mut(),
1361                    solid_id,
1362                    Vec3::new(dx, dy, dz),
1363                    spacing,
1364                    count as usize,
1365                )
1366                .map_err(|e| e.to_string())?;
1367                Ok(serde_json::json!(compound_id_to_u32(compound)))
1368            }
1369            "draft" => {
1370                let s = get_u32(args, "solid")?;
1371                let angle = get_f64(args, "angle")?;
1372                let solid_id = self.resolve_solid(s).map_err(|e| e.to_string())?;
1373                let face_handles: Vec<u32> = args["faces"]
1374                    .as_array()
1375                    .map(|arr| {
1376                        arr.iter()
1377                            .filter_map(|v| v.as_u64().map(|n| n as u32))
1378                            .collect()
1379                    })
1380                    .unwrap_or_default();
1381                let face_ids: Vec<_> = face_handles
1382                    .iter()
1383                    .map(|&h| self.resolve_face(h).map_err(|e| e.to_string()))
1384                    .collect::<Result<Vec<_>, _>>()?;
1385                let dx = get_f64(args, "dirX").unwrap_or(0.0);
1386                let dy = get_f64(args, "dirY").unwrap_or(0.0);
1387                let dz = get_f64(args, "dirZ").unwrap_or(1.0);
1388                let npx = get_f64(args, "neutralX").unwrap_or(0.0);
1389                let npy = get_f64(args, "neutralY").unwrap_or(0.0);
1390                let npz = get_f64(args, "neutralZ").unwrap_or(0.0);
1391                let dir = Vec3::new(dx, dy, dz);
1392                let neutral = Point3::new(npx, npy, npz);
1393                let result = brepkit_operations::draft::draft(
1394                    self.topo_mut(),
1395                    solid_id,
1396                    &face_ids,
1397                    dir,
1398                    neutral,
1399                    angle,
1400                )
1401                .map_err(|e| e.to_string())?;
1402                Ok(serde_json::json!(solid_id_to_u32(result)))
1403            }
1404            "makeTangentArc3d" => {
1405                let sx = get_f64(args, "startX")?;
1406                let sy = get_f64(args, "startY")?;
1407                let sz = get_f64(args, "startZ")?;
1408                let tx = get_f64(args, "tangentX")?;
1409                let ty = get_f64(args, "tangentY")?;
1410                let tz = get_f64(args, "tangentZ")?;
1411                let ex = get_f64(args, "endX")?;
1412                let ey = get_f64(args, "endY")?;
1413                let ez = get_f64(args, "endZ")?;
1414                let eid = self
1415                    .make_tangent_arc_3d_impl(sx, sy, sz, tx, ty, tz, ex, ey, ez)
1416                    .map_err(|e| e.to_string())?;
1417                Ok(serde_json::json!(eid))
1418            }
1419            "liftCurve2dToPlane" => {
1420                let ct = get_u32(args, "curveType")?;
1421                let params_arr = args["curveParams"]
1422                    .as_array()
1423                    .ok_or("missing or invalid 'curveParams'")?;
1424                let cp: Vec<f64> = params_arr
1425                    .iter()
1426                    .enumerate()
1427                    .map(|(i, v)| {
1428                        v.as_f64()
1429                            .ok_or_else(|| format!("curveParams[{i}] is not a number"))
1430                    })
1431                    .collect::<Result<_, _>>()?;
1432                let ox = get_f64(args, "originX")?;
1433                let oy = get_f64(args, "originY")?;
1434                let oz = get_f64(args, "originZ")?;
1435                let xx = get_f64(args, "xAxisX")?;
1436                let xy = get_f64(args, "xAxisY")?;
1437                let xz = get_f64(args, "xAxisZ")?;
1438                let nx = get_f64(args, "normalX")?;
1439                let ny = get_f64(args, "normalY")?;
1440                let nz = get_f64(args, "normalZ")?;
1441                let t0 = get_f64(args, "tStart")?;
1442                let t1 = get_f64(args, "tEnd")?;
1443                let eid = self
1444                    .lift_curve2d_to_plane_impl(ct, cp, ox, oy, oz, xx, xy, xz, nx, ny, nz, t0, t1)
1445                    .map_err(|e| e.to_string())?;
1446                Ok(serde_json::json!(eid))
1447            }
1448            "offsetWire" => {
1449                let f = get_u32(args, "face")?;
1450                let dist = get_f64(args, "distance")?;
1451                let face_id = self.resolve_face(f).map_err(|e| e.to_string())?;
1452                let wire_id =
1453                    brepkit_operations::offset_wire::offset_wire(self.topo_mut(), face_id, dist)
1454                        .map_err(|e| e.to_string())?;
1455                Ok(serde_json::json!(wire_id_to_u32(wire_id)))
1456            }
1457            "offsetWireWithJoinType" => {
1458                let f = get_u32(args, "face")?;
1459                let dist = get_f64(args, "distance")?;
1460                let jt_str = args["joinType"]
1461                    .as_str()
1462                    .ok_or("missing or invalid 'joinType' string")?;
1463                let jt =
1464                    super::operations::parse_join_type_str(jt_str).map_err(|e| e.to_string())?;
1465                let face_id = self.resolve_face(f).map_err(|e| e.to_string())?;
1466                let wire_id = brepkit_operations::offset_wire::offset_wire_with_join(
1467                    self.topo_mut(),
1468                    face_id,
1469                    dist,
1470                    jt,
1471                )
1472                .map_err(|e| e.to_string())?;
1473                Ok(serde_json::json!(wire_id_to_u32(wire_id)))
1474            }
1475            "offsetWire2DWithJoin" => {
1476                let w = get_u32(args, "wire")?;
1477                let dist = get_f64(args, "distance")?;
1478                let jt_str = args["joinType"]
1479                    .as_str()
1480                    .ok_or("missing or invalid 'joinType' string")?;
1481                let jt =
1482                    super::operations::parse_join_type_str(jt_str).map_err(|e| e.to_string())?;
1483                let wire_id = self.resolve_wire(w).map_err(|e| e.to_string())?;
1484                let face_id =
1485                    brepkit_topology::builder::make_planar_face_from_wire(self.topo_mut(), wire_id)
1486                        .map_err(|e| e.to_string())?;
1487                let result = brepkit_operations::offset_wire::offset_wire_with_join(
1488                    self.topo_mut(),
1489                    face_id,
1490                    dist,
1491                    jt,
1492                )
1493                .map_err(|e| e.to_string())?;
1494                Ok(serde_json::json!(wire_id_to_u32(result)))
1495            }
1496            "getNurbsCurveData" => {
1497                let edge = get_u32(args, "edge")?;
1498                let curve = self.extract_nurbs_curve(edge).map_err(|e| e.to_string())?;
1499                Ok(super::nurbs::curve_data_json(&curve))
1500            }
1501            "getNurbsSurfaceData" => {
1502                let face = get_u32(args, "face")?;
1503                let surface = self
1504                    .extract_nurbs_surface(face)
1505                    .map_err(|e| e.to_string())?;
1506                Ok(super::nurbs::surface_data_json(&surface))
1507            }
1508            "getNurbsSurfaceDataParity" => {
1509                let face = get_u32(args, "face")?;
1510                self.free_form_surface_data_parity(face)
1511                    .map_err(|e| e.to_string())
1512            }
1513            _ => Err(format!("unknown operation: {op}")),
1514        }
1515    }
1516}