Skip to main content

brepkit_wasm/bindings/
operations.rs

1//! Modeling operation bindings (extrude, revolve, sweep, loft, fillet, etc.).
2
3#![allow(
4    clippy::missing_errors_doc,
5    clippy::too_many_arguments,
6    clippy::too_many_lines
7)]
8
9use wasm_bindgen::prelude::*;
10
11use brepkit_math::nurbs::curve::NurbsCurve;
12use brepkit_math::vec::{Point3, Vec3};
13use brepkit_topology::edge::Edge;
14use brepkit_topology::face::{Face, FaceSurface};
15
16use crate::error::{WasmError, validate_finite, validate_positive};
17use crate::handles::{edge_id_to_u32, face_id_to_u32, solid_id_to_u32, wire_id_to_u32};
18use brepkit_geometry::extrema::point_to_nurbs_surface;
19
20use crate::helpers::{
21    classify_to_string, create_apex_face, panic_message, parse_points, try_fillet,
22};
23use crate::kernel::BrepKernel;
24
25use brepkit_operations::extrude::extrude;
26use brepkit_operations::offset_wire::JoinType;
27use brepkit_operations::revolve::revolve;
28use brepkit_operations::sweep::sweep;
29
30/// Parse a join type string into a [`JoinType`] enum value.
31///
32/// Used by both the direct WASM binding and the batch dispatcher.
33pub fn parse_join_type_str(s: &str) -> Result<JoinType, WasmError> {
34    match s {
35        "intersection" => Ok(JoinType::Intersection),
36        "arc" => Ok(JoinType::Arc),
37        "chamfer" => Ok(JoinType::Chamfer),
38        _ => Err(WasmError::InvalidInput {
39            reason: format!(
40                "unknown join type '{s}', expected 'intersection', 'arc', or 'chamfer'"
41            ),
42        }),
43    }
44}
45
46#[wasm_bindgen]
47impl BrepKernel {
48    // ── Section ───────────────────────────────────────────────────
49
50    /// Section a solid with a plane, returning cross-section face handles.
51    ///
52    /// Returns an array of face handles (`u32[]`).
53    ///
54    /// # Errors
55    ///
56    /// Returns an error if the solid handle is invalid or the plane doesn't
57    /// intersect the solid.
58    #[wasm_bindgen(js_name = "section")]
59    #[allow(clippy::too_many_arguments)]
60    pub fn section_solid(
61        &mut self,
62        solid: u32,
63        px: f64,
64        py: f64,
65        pz: f64,
66        nx: f64,
67        ny: f64,
68        nz: f64,
69    ) -> Result<Vec<u32>, JsError> {
70        validate_finite(px, "px")?;
71        validate_finite(py, "py")?;
72        validate_finite(pz, "pz")?;
73        validate_finite(nx, "nx")?;
74        validate_finite(ny, "ny")?;
75        validate_finite(nz, "nz")?;
76        let solid_id = self.resolve_solid(solid)?;
77        let result = brepkit_operations::section::section(
78            self.topo_mut(),
79            solid_id,
80            Point3::new(px, py, pz),
81            Vec3::new(nx, ny, nz),
82        )?;
83        #[allow(clippy::cast_possible_truncation)]
84        Ok(result.faces.iter().map(|f| f.index() as u32).collect())
85    }
86
87    // ── Loft ──────────────────────────────────────────────────────
88
89    /// Loft two or more profile faces into a solid.
90    ///
91    /// Takes an array of face handles. Returns a solid handle (`u32`).
92    ///
93    /// # Errors
94    ///
95    /// Returns an error if fewer than 2 faces or profiles have
96    /// different vertex counts.
97    #[wasm_bindgen(js_name = "loft")]
98    #[allow(clippy::needless_pass_by_value)]
99    pub fn loft_faces(&mut self, faces: Vec<u32>) -> Result<u32, JsError> {
100        let face_ids: Vec<brepkit_topology::face::FaceId> = faces
101            .iter()
102            .map(|&h| self.resolve_face(h))
103            .collect::<Result<_, _>>()?;
104        let solid_id = brepkit_operations::loft::loft(self.topo_mut(), &face_ids)?;
105        Ok(solid_id_to_u32(solid_id))
106    }
107
108    /// Loft profiles with smooth NURBS interpolation.
109    ///
110    /// Like `loft()`, but produces smooth NURBS side surfaces for 3+
111    /// profiles instead of piecewise-planar quads. The surfaces
112    /// interpolate through all intermediate profiles with C1+ continuity.
113    ///
114    /// Returns a solid handle (`u32`).
115    ///
116    /// # Errors
117    ///
118    /// Returns an error if fewer than 2 profiles are given, profiles have
119    /// different vertex counts, or surface fitting fails.
120    #[wasm_bindgen(js_name = "loftSmooth")]
121    #[allow(clippy::needless_pass_by_value)]
122    pub fn loft_smooth_faces(&mut self, faces: Vec<u32>) -> Result<u32, JsError> {
123        let face_ids: Vec<brepkit_topology::face::FaceId> = faces
124            .iter()
125            .map(|&h| self.resolve_face(h))
126            .collect::<Result<_, _>>()?;
127        let solid_id = brepkit_operations::loft::loft_smooth(self.topo_mut(), &face_ids)?;
128        Ok(solid_id_to_u32(solid_id))
129    }
130
131    /// Loft profiles with options for start/end points and ruled mode.
132    ///
133    /// `options` is a JSON string with optional fields:
134    /// - `startPoint: [x, y, z]` — apex point before first profile
135    /// - `endPoint: [x, y, z]` — apex point after last profile
136    /// - `ruled: bool` — true for ruled (linear) surfaces (default), false for smooth
137    #[wasm_bindgen(js_name = "loftWithOptions")]
138    #[allow(clippy::needless_pass_by_value)]
139    pub fn loft_with_options(&mut self, faces: Vec<u32>, options: &str) -> Result<u32, JsError> {
140        let opts: serde_json::Value =
141            serde_json::from_str(options).unwrap_or(serde_json::Value::Null);
142
143        let mut face_ids: Vec<brepkit_topology::face::FaceId> = faces
144            .iter()
145            .map(|&h| self.resolve_face(h))
146            .collect::<Result<_, _>>()?;
147
148        // If startPoint is given, create a tiny degenerate triangle face at that point
149        // and prepend it to the profiles.
150        if let Some(sp) = opts.get("startPoint").and_then(|v| v.as_array())
151            && sp.len() >= 3
152        {
153            let x = sp[0].as_f64().unwrap_or(0.0);
154            let y = sp[1].as_f64().unwrap_or(0.0);
155            let z = sp[2].as_f64().unwrap_or(0.0);
156            let apex_face = create_apex_face(self.topo_mut(), Point3::new(x, y, z), &face_ids)?;
157            face_ids.insert(0, apex_face);
158        }
159
160        // If endPoint is given, create a tiny degenerate triangle face and append.
161        if let Some(ep) = opts.get("endPoint").and_then(|v| v.as_array())
162            && ep.len() >= 3
163        {
164            let x = ep[0].as_f64().unwrap_or(0.0);
165            let y = ep[1].as_f64().unwrap_or(0.0);
166            let z = ep[2].as_f64().unwrap_or(0.0);
167            let apex_face = create_apex_face(self.topo_mut(), Point3::new(x, y, z), &face_ids)?;
168            face_ids.push(apex_face);
169        }
170
171        let ruled = opts
172            .get("ruled")
173            .and_then(serde_json::Value::as_bool)
174            .unwrap_or(true);
175
176        let solid_id = if ruled {
177            brepkit_operations::loft::loft(self.topo_mut(), &face_ids)?
178        } else {
179            brepkit_operations::loft::loft_smooth(self.topo_mut(), &face_ids)?
180        };
181        Ok(solid_id_to_u32(solid_id))
182    }
183
184    // ── Shell ─────────────────────────────────────────────────────
185
186    /// Hollow a solid with uniform wall thickness.
187    ///
188    /// `open_faces` is an array of face handles to remove (creating openings).
189    /// Returns a solid handle (`u32`).
190    ///
191    /// # Errors
192    ///
193    /// Returns an error if thickness is non-positive or the solid is invalid.
194    #[wasm_bindgen(js_name = "shell")]
195    #[allow(clippy::needless_pass_by_value)]
196    pub fn shell_solid(
197        &mut self,
198        solid: u32,
199        thickness: f64,
200        open_faces: Vec<u32>,
201    ) -> Result<u32, JsError> {
202        validate_positive(thickness, "thickness")?;
203        let solid_id = self.resolve_solid(solid)?;
204        let open_face_ids: Vec<brepkit_topology::face::FaceId> = open_faces
205            .iter()
206            .map(|&h| self.resolve_face(h))
207            .collect::<Result<_, _>>()?;
208        let result = brepkit_operations::shell_op::shell(
209            self.topo_mut(),
210            solid_id,
211            thickness,
212            &open_face_ids,
213        )?;
214        Ok(solid_id_to_u32(result))
215    }
216
217    // ── Chamfer ───────────────────────────────────────────────────
218
219    /// Chamfer edges of a solid.
220    ///
221    /// `edge_handles` is an array of edge handles. Returns a solid handle.
222    ///
223    /// # Errors
224    ///
225    /// Returns an error if distance is non-positive or edges are invalid.
226    #[wasm_bindgen(js_name = "chamfer")]
227    #[allow(clippy::needless_pass_by_value)]
228    pub fn chamfer_solid(
229        &mut self,
230        solid: u32,
231        edge_handles: Vec<u32>,
232        distance: f64,
233    ) -> Result<u32, JsError> {
234        validate_positive(distance, "distance")?;
235        let solid_id = self.resolve_solid(solid)?;
236        let edge_ids: Vec<brepkit_topology::edge::EdgeId> = edge_handles
237            .iter()
238            .map(|&h| self.resolve_edge(h))
239            .collect::<Result<_, _>>()?;
240        let result =
241            brepkit_operations::chamfer::chamfer(self.topo_mut(), solid_id, &edge_ids, distance)?;
242        Ok(solid_id_to_u32(result))
243    }
244
245    // ── Fillet ────────────────────────────────────────────────────
246
247    /// Fillet (round) edges of a solid.
248    ///
249    /// `edge_handles` is an array of edge handles. Returns a solid handle.
250    ///
251    /// # Errors
252    ///
253    /// Returns an error if radius is non-positive or edges are invalid.
254    #[wasm_bindgen(js_name = "fillet")]
255    #[allow(clippy::needless_pass_by_value)]
256    pub fn fillet_solid(
257        &mut self,
258        solid: u32,
259        edge_handles: Vec<u32>,
260        radius: f64,
261    ) -> Result<u32, JsError> {
262        validate_positive(radius, "radius")?;
263        let solid_id = self.resolve_solid(solid)?;
264        let edge_ids: Vec<brepkit_topology::edge::EdgeId> = edge_handles
265            .iter()
266            .map(|&h| self.resolve_edge(h))
267            .collect::<Result<_, _>>()?;
268        // Use the rolling-ball fillet algorithm for true G1-continuous NURBS
269        // blend surfaces. Falls back to the planar fillet if rolling-ball fails.
270        // If the full set of edges fails (e.g. edges adjacent to NURBS faces from
271        // a prior fillet), filter to edges between two planar faces and retry.
272        //
273        // Wrap in catch_unwind to prevent panics from propagating across the
274        // WASM FFI boundary, which would abort the entire WASM instance.
275        let result =
276            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<u32, JsError> {
277                let solid = if let Ok(s) = try_fillet(self.topo_mut(), solid_id, &edge_ids, radius)
278                {
279                    s
280                } else {
281                    // Filter to edges where both adjacent faces are planar.
282                    let planar_edges = brepkit_operations::query::filter_planar_edges(
283                        &self.topo, solid_id, &edge_ids,
284                    )?;
285                    if planar_edges.is_empty() {
286                        solid_id
287                    } else {
288                        try_fillet(self.topo_mut(), solid_id, &planar_edges, radius)
289                            .map_err(|e| JsError::new(&e.to_string()))?
290                    }
291                };
292                Ok(solid_id_to_u32(solid))
293            }));
294        match result {
295            Ok(inner) => inner,
296            Err(panic_info) => {
297                let msg = panic_message(&panic_info, "Fillet");
298                Err(JsError::new(&msg))
299            }
300        }
301    }
302
303    /// Apply a constant-radius fillet and return face-evolution tracking data.
304    ///
305    /// Returns a JSON string `{"solid": <u32>, "evolution": {modified,
306    /// generated, deleted}}` — the same shape as `fuseWithEvolution`. Blend
307    /// faces appear under `generated` and surviving faces under `modified`.
308    /// Provenance is matched geometrically (face normal + centroid), so it is
309    /// unaffected by how the fillet renumbers faces.
310    ///
311    /// # Errors
312    ///
313    /// Returns an error if a handle is invalid, the radius is non-positive, or
314    /// the fillet fails.
315    #[wasm_bindgen(js_name = "filletWithEvolution")]
316    #[allow(clippy::needless_pass_by_value)]
317    pub fn fillet_with_evolution(
318        &mut self,
319        solid: u32,
320        edge_handles: Vec<u32>,
321        radius: f64,
322    ) -> Result<JsValue, JsError> {
323        validate_positive(radius, "radius")?;
324        let solid_id = self.resolve_solid(solid)?;
325        let edge_ids: Vec<brepkit_topology::edge::EdgeId> = edge_handles
326            .iter()
327            .map(|&h| self.resolve_edge(h))
328            .collect::<Result<_, _>>()?;
329
330        // Wrap in catch_unwind like `fillet` does: a fillet panic must not
331        // abort the whole WASM instance.
332        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(
333            || -> Result<String, JsError> {
334                let input_faces =
335                    brepkit_operations::boolean::collect_face_signatures(&self.topo, solid_id)?;
336                let result = try_fillet(self.topo_mut(), solid_id, &edge_ids, radius)?;
337                let output_faces =
338                    brepkit_operations::boolean::collect_face_signatures(&self.topo, result)?;
339                let evo = brepkit_operations::evolution::build_evolution_by_geometry(
340                    &input_faces,
341                    &output_faces,
342                );
343                Ok(format!(
344                    "{{\"solid\":{},\"evolution\":{}}}",
345                    solid_id_to_u32(result),
346                    evo.to_json()
347                ))
348            },
349        ));
350        match result {
351            Ok(inner) => inner.map(|json| JsValue::from_str(&json)),
352            Err(panic_info) => Err(JsError::new(&panic_message(&panic_info, "Fillet"))),
353        }
354    }
355
356    // ── Operations ─────────────────────────────────────────────────
357
358    /// Extrude a planar face along a direction vector to create a solid.
359    ///
360    /// Returns a solid handle (`u32`).
361    ///
362    /// # Errors
363    ///
364    /// Returns an error if the face handle is invalid or the extrusion fails.
365    #[wasm_bindgen(js_name = "extrude")]
366    pub fn extrude_face(
367        &mut self,
368        face: u32,
369        dir_x: f64,
370        dir_y: f64,
371        dir_z: f64,
372        distance: f64,
373    ) -> Result<u32, JsError> {
374        validate_finite(dir_x, "dir_x")?;
375        validate_finite(dir_y, "dir_y")?;
376        validate_finite(dir_z, "dir_z")?;
377        validate_finite(distance, "distance")?;
378
379        let face_id = self.resolve_face(face)?;
380        let direction = Vec3::new(dir_x, dir_y, dir_z);
381        let solid_id = extrude(self.topo_mut(), face_id, direction, distance)?;
382
383        Ok(solid_id_to_u32(solid_id))
384    }
385
386    /// Revolve a planar face around an axis to create a solid of revolution.
387    ///
388    /// The axis is defined by an origin point `(ox, oy, oz)` and a direction
389    /// `(dx, dy, dz)`. The angle is in degrees and must be in (0, 360].
390    ///
391    /// Returns a solid handle (`u32`).
392    ///
393    /// # Errors
394    ///
395    /// Returns an error if any input is non-finite, the face handle is
396    /// invalid, or the revolve operation fails.
397    #[wasm_bindgen(js_name = "revolve")]
398    #[allow(clippy::too_many_arguments)]
399    pub fn revolve_face(
400        &mut self,
401        face: u32,
402        ox: f64,
403        oy: f64,
404        oz: f64,
405        dx: f64,
406        dy: f64,
407        dz: f64,
408        angle_degrees: f64,
409    ) -> Result<u32, JsError> {
410        validate_finite(ox, "ox")?;
411        validate_finite(oy, "oy")?;
412        validate_finite(oz, "oz")?;
413        validate_finite(dx, "dx")?;
414        validate_finite(dy, "dy")?;
415        validate_finite(dz, "dz")?;
416        validate_finite(angle_degrees, "angle_degrees")?;
417        if angle_degrees <= 0.0 || angle_degrees > 360.0 {
418            return Err(WasmError::InvalidInput {
419                reason: format!("angle_degrees must be in (0, 360], got {angle_degrees}"),
420            }
421            .into());
422        }
423
424        let face_id = self.resolve_face(face)?;
425        let origin = Point3::new(ox, oy, oz);
426        let direction = Vec3::new(dx, dy, dz);
427        let angle_radians = angle_degrees.to_radians();
428
429        let solid_id = revolve(self.topo_mut(), face_id, origin, direction, angle_radians)?;
430
431        Ok(solid_id_to_u32(solid_id))
432    }
433
434    /// Sweep a planar face along a NURBS curve path to create a solid.
435    ///
436    /// The path is specified as flat arrays for JS interop:
437    /// - `path_degree` — polynomial degree of the path curve
438    /// - `path_knots` — knot vector
439    /// - `path_control_points` — flat `[x,y,z, ...]` control point coordinates
440    /// - `path_weights` — per-control-point weights
441    ///
442    /// Returns a solid handle (`u32`).
443    ///
444    /// # Errors
445    ///
446    /// Returns an error if the face handle is invalid, the NURBS arrays have
447    /// inconsistent lengths, or the sweep operation fails.
448    #[wasm_bindgen(js_name = "sweep")]
449    #[allow(clippy::needless_pass_by_value)] // wasm-bindgen requires owned Vec
450    pub fn sweep_face(
451        &mut self,
452        face: u32,
453        path_degree: u32,
454        path_knots: Vec<f64>,
455        path_control_points: Vec<f64>,
456        path_weights: Vec<f64>,
457    ) -> Result<u32, JsError> {
458        // Validate coordinate array length.
459        if !path_control_points.len().is_multiple_of(3) {
460            return Err(WasmError::InvalidInput {
461                reason: format!(
462                    "path_control_points length must be a multiple of 3, got {}",
463                    path_control_points.len()
464                ),
465            }
466            .into());
467        }
468        let num_pts = path_control_points.len() / 3;
469
470        if path_weights.len() != num_pts {
471            return Err(WasmError::InvalidInput {
472                reason: format!(
473                    "path_weights length ({}) must match number of control points ({num_pts})",
474                    path_weights.len()
475                ),
476            }
477            .into());
478        }
479
480        // Validate all values are finite.
481        if let Some(pos) = path_knots.iter().position(|v| !v.is_finite()) {
482            return Err(WasmError::InvalidInput {
483                reason: format!("path_knots[{pos}] is not finite"),
484            }
485            .into());
486        }
487        if let Some(pos) = path_control_points.iter().position(|v| !v.is_finite()) {
488            return Err(WasmError::InvalidInput {
489                reason: format!("path_control_points[{pos}] is not finite"),
490            }
491            .into());
492        }
493        if let Some(pos) = path_weights.iter().position(|v| !v.is_finite()) {
494            return Err(WasmError::InvalidInput {
495                reason: format!("path_weights[{pos}] is not finite"),
496            }
497            .into());
498        }
499
500        let face_id = self.resolve_face(face)?;
501
502        let control_points: Vec<Point3> = path_control_points
503            .chunks_exact(3)
504            .map(|c| Point3::new(c[0], c[1], c[2]))
505            .collect();
506
507        let path_curve = NurbsCurve::new(
508            path_degree as usize,
509            path_knots,
510            control_points,
511            path_weights,
512        )?;
513
514        let solid_id = sweep(self.topo_mut(), face_id, &path_curve)?;
515
516        Ok(solid_id_to_u32(solid_id))
517    }
518
519    /// Sweep through multiple section profiles along a spine, lofting the
520    /// rotation-minimizing-frame-placed profiles.
521    ///
522    /// `face_handles` and `params` are parallel arrays: each planar profile and
523    /// its parameter in `[0, 1]` along the spine (given as raw NURBS data).
524    /// `ruled` selects ruled (planar bands) vs smooth (NURBS) lofted sides.
525    ///
526    /// Returns a solid handle (`u32`).
527    ///
528    /// # Errors
529    ///
530    /// Returns an error for fewer than two sections, mismatched array lengths, a
531    /// non-finite or out-of-range value, a non-planar profile, or loft failure.
532    #[wasm_bindgen(js_name = "multiSectionSweep")]
533    #[allow(clippy::needless_pass_by_value, clippy::too_many_arguments)]
534    pub fn multi_section_sweep(
535        &mut self,
536        face_handles: Vec<u32>,
537        params: Vec<f64>,
538        spine_degree: u32,
539        spine_knots: Vec<f64>,
540        spine_control_points: Vec<f64>,
541        spine_weights: Vec<f64>,
542        ruled: bool,
543    ) -> Result<u32, JsError> {
544        if face_handles.len() != params.len() {
545            return Err(WasmError::InvalidInput {
546                reason: format!(
547                    "face_handles ({}) and params ({}) must have equal length",
548                    face_handles.len(),
549                    params.len()
550                ),
551            }
552            .into());
553        }
554        if !spine_control_points.len().is_multiple_of(3) {
555            return Err(WasmError::InvalidInput {
556                reason: format!(
557                    "spine_control_points length must be a multiple of 3, got {}",
558                    spine_control_points.len()
559                ),
560            }
561            .into());
562        }
563        let num_pts = spine_control_points.len() / 3;
564        if spine_weights.len() != num_pts {
565            return Err(WasmError::InvalidInput {
566                reason: format!(
567                    "spine_weights length ({}) must match control point count ({num_pts})",
568                    spine_weights.len()
569                ),
570            }
571            .into());
572        }
573        for p in &params {
574            validate_finite(*p, "param")?;
575        }
576        for (name, arr) in [
577            ("spine_knots", &spine_knots),
578            ("spine_control_points", &spine_control_points),
579            ("spine_weights", &spine_weights),
580        ] {
581            if let Some(pos) = arr.iter().position(|v| !v.is_finite()) {
582                return Err(WasmError::InvalidInput {
583                    reason: format!("{name}[{pos}] is not finite"),
584                }
585                .into());
586            }
587        }
588
589        let control_points: Vec<Point3> = spine_control_points
590            .chunks_exact(3)
591            .map(|c| Point3::new(c[0], c[1], c[2]))
592            .collect();
593        let spine = NurbsCurve::new(
594            spine_degree as usize,
595            spine_knots,
596            control_points,
597            spine_weights,
598        )?;
599
600        let sections: Vec<(brepkit_topology::face::FaceId, f64)> = face_handles
601            .iter()
602            .zip(params.iter())
603            .map(|(&h, &p)| self.resolve_face(h).map(|f| (f, p)))
604            .collect::<Result<_, _>>()?;
605
606        let solid_id = brepkit_operations::sweep::multi_section_sweep(
607            self.topo_mut(),
608            &spine,
609            &sections,
610            ruled,
611        )?;
612        Ok(solid_id_to_u32(solid_id))
613    }
614
615    /// Sweep a face along a path with smooth NURBS side surfaces.
616    ///
617    /// Like `sweep()`, but produces a single NURBS surface per edge strip
618    /// instead of multiple flat quads, giving smooth geometry that
619    /// tessellates to arbitrary quality.
620    ///
621    /// Returns a solid handle (`u32`).
622    ///
623    /// # Errors
624    ///
625    /// Returns an error if the face or path is invalid, or surface fitting fails.
626    #[wasm_bindgen(js_name = "sweepSmooth")]
627    #[allow(clippy::needless_pass_by_value)]
628    pub fn sweep_smooth_face(
629        &mut self,
630        face: u32,
631        path_degree: u32,
632        path_knots: Vec<f64>,
633        path_control_points: Vec<f64>,
634        path_weights: Vec<f64>,
635    ) -> Result<u32, JsError> {
636        if !path_control_points.len().is_multiple_of(3) {
637            return Err(WasmError::InvalidInput {
638                reason: format!(
639                    "path_control_points length must be a multiple of 3, got {}",
640                    path_control_points.len()
641                ),
642            }
643            .into());
644        }
645
646        let face_id = self.resolve_face(face)?;
647        let n_cp = path_control_points.len() / 3;
648        let control_points: Vec<Point3> = (0..n_cp)
649            .map(|i| {
650                Point3::new(
651                    path_control_points[i * 3],
652                    path_control_points[i * 3 + 1],
653                    path_control_points[i * 3 + 2],
654                )
655            })
656            .collect();
657
658        let weights = if path_weights.is_empty() {
659            vec![1.0; n_cp]
660        } else {
661            path_weights
662        };
663
664        #[allow(clippy::cast_possible_truncation)]
665        let path_curve = brepkit_math::nurbs::curve::NurbsCurve::new(
666            path_degree as usize,
667            path_knots,
668            control_points,
669            weights,
670        )?;
671
672        let solid_id =
673            brepkit_operations::sweep::sweep_smooth(self.topo_mut(), face_id, &path_curve)?;
674        Ok(solid_id_to_u32(solid_id))
675    }
676
677    // ── Offset Face ──────────────────────────────────────────────
678
679    /// Offset a face by a distance along its surface normal.
680    ///
681    /// Returns the new offset face handle.
682    ///
683    /// # Errors
684    ///
685    /// Returns an error if the face handle is invalid or the operation fails.
686    #[wasm_bindgen(js_name = "offsetFace")]
687    pub fn offset_face(&mut self, face: u32, distance: f64, samples: u32) -> Result<u32, JsError> {
688        validate_finite(distance, "distance")?;
689        let face_id = self.resolve_face(face)?;
690        let result = brepkit_operations::offset_face::offset_face(
691            self.topo_mut(),
692            face_id,
693            distance,
694            samples as usize,
695        )?;
696        Ok(face_id_to_u32(result))
697    }
698
699    // ── Helical Sweep ───────────────────────────────────────────
700
701    /// Create a helical sweep of a profile face.
702    ///
703    /// Sweeps the profile along a helix defined by axis, radius, pitch,
704    /// and number of turns. Used for generating thread geometry.
705    ///
706    /// # Errors
707    ///
708    /// Returns an error if parameters are invalid or the sweep fails.
709    #[wasm_bindgen(js_name = "helicalSweep")]
710    #[allow(clippy::too_many_arguments)]
711    pub fn helical_sweep(
712        &mut self,
713        profile: u32,
714        axis_origin_x: f64,
715        axis_origin_y: f64,
716        axis_origin_z: f64,
717        axis_dir_x: f64,
718        axis_dir_y: f64,
719        axis_dir_z: f64,
720        radius: f64,
721        pitch: f64,
722        turns: f64,
723    ) -> Result<u32, JsError> {
724        validate_positive(radius, "radius")?;
725        validate_positive(pitch, "pitch")?;
726        let face_id = self.resolve_face(profile)?;
727
728        let origin = brepkit_math::vec::Point3::new(axis_origin_x, axis_origin_y, axis_origin_z);
729        let axis_dir = brepkit_math::vec::Vec3::new(axis_dir_x, axis_dir_y, axis_dir_z);
730
731        let solid_id = brepkit_operations::helix::helical_sweep(
732            self.topo_mut(),
733            face_id,
734            origin,
735            axis_dir,
736            radius,
737            pitch,
738            turns,
739            8,
740        )?;
741        Ok(solid_id_to_u32(solid_id))
742    }
743
744    // ── Split ─────────────────────────────────────────────────────
745
746    /// Split a solid into two halves along a plane.
747    ///
748    /// Returns `[positive_solid_handle, negative_solid_handle]`.
749    ///
750    /// # Errors
751    ///
752    /// Returns an error if the plane doesn't intersect the solid.
753    #[wasm_bindgen(js_name = "split")]
754    #[allow(clippy::too_many_arguments)]
755    pub fn split_solid(
756        &mut self,
757        solid: u32,
758        px: f64,
759        py: f64,
760        pz: f64,
761        nx: f64,
762        ny: f64,
763        nz: f64,
764    ) -> Result<Vec<u32>, JsError> {
765        validate_finite(px, "px")?;
766        validate_finite(py, "py")?;
767        validate_finite(pz, "pz")?;
768        validate_finite(nx, "nx")?;
769        validate_finite(ny, "ny")?;
770        validate_finite(nz, "nz")?;
771        let solid_id = self.resolve_solid(solid)?;
772        let result = brepkit_operations::split::split(
773            self.topo_mut(),
774            solid_id,
775            Point3::new(px, py, pz),
776            Vec3::new(nx, ny, nz),
777        )?;
778        Ok(vec![
779            solid_id_to_u32(result.positive),
780            solid_id_to_u32(result.negative),
781        ])
782    }
783
784    // ── Draft ─────────────────────────────────────────────────────
785
786    /// Apply draft angle to faces of a solid.
787    ///
788    /// `face_handles` is an array of face handles to draft.
789    /// Returns a solid handle.
790    ///
791    /// # Errors
792    ///
793    /// Returns an error if angle is zero or faces are invalid.
794    #[wasm_bindgen(js_name = "draft")]
795    #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)]
796    pub fn draft_solid(
797        &mut self,
798        solid: u32,
799        face_handles: Vec<u32>,
800        pull_x: f64,
801        pull_y: f64,
802        pull_z: f64,
803        neutral_x: f64,
804        neutral_y: f64,
805        neutral_z: f64,
806        angle_degrees: f64,
807    ) -> Result<u32, JsError> {
808        validate_finite(angle_degrees, "angle_degrees")?;
809        let solid_id = self.resolve_solid(solid)?;
810        let face_ids: Vec<brepkit_topology::face::FaceId> = face_handles
811            .iter()
812            .map(|&h| self.resolve_face(h))
813            .collect::<Result<_, _>>()?;
814        let result = brepkit_operations::draft::draft(
815            self.topo_mut(),
816            solid_id,
817            &face_ids,
818            Vec3::new(pull_x, pull_y, pull_z),
819            Point3::new(neutral_x, neutral_y, neutral_z),
820            angle_degrees.to_radians(),
821        )?;
822        Ok(solid_id_to_u32(result))
823    }
824
825    // ── Pipe ──────────────────────────────────────────────────────
826
827    /// Pipe sweep: sweep a profile along a NURBS path (no guide).
828    ///
829    /// Returns a solid handle.
830    ///
831    /// # Errors
832    ///
833    /// Returns an error if the face or path is invalid.
834    #[wasm_bindgen(js_name = "pipe")]
835    #[allow(clippy::needless_pass_by_value)]
836    pub fn pipe_solid(
837        &mut self,
838        face: u32,
839        path_degree: u32,
840        path_knots: Vec<f64>,
841        path_control_points: Vec<f64>,
842        path_weights: Vec<f64>,
843    ) -> Result<u32, JsError> {
844        if !path_control_points.len().is_multiple_of(3) {
845            return Err(WasmError::InvalidInput {
846                reason: format!(
847                    "path_control_points length must be a multiple of 3, got {}",
848                    path_control_points.len()
849                ),
850            }
851            .into());
852        }
853
854        let face_id = self.resolve_face(face)?;
855        let control_points: Vec<Point3> = path_control_points
856            .chunks_exact(3)
857            .map(|c| Point3::new(c[0], c[1], c[2]))
858            .collect();
859
860        let path_curve = NurbsCurve::new(
861            path_degree as usize,
862            path_knots,
863            control_points,
864            path_weights,
865        )?;
866
867        let solid_id = brepkit_operations::pipe::pipe(self.topo_mut(), face_id, &path_curve, None)?;
868        Ok(solid_id_to_u32(solid_id))
869    }
870
871    // ── Sweep Along Edges ─────────────────────────────────────────
872
873    /// Sweep a face along a path defined by a chain of edges.
874    ///
875    /// A closed planar chain of lines and tangent arcs with an all-line
876    /// perpendicular profile is swept analytically (exact plane / cylinder /
877    /// cone faces); anything else falls back to fitting an interpolating
878    /// NURBS curve through sampled chain points and sweeping along that.
879    ///
880    /// Returns a solid handle (`u32`).
881    ///
882    /// # Errors
883    ///
884    /// Returns an error if fewer than 2 edges or the fit fails.
885    #[wasm_bindgen(js_name = "sweepAlongEdges")]
886    #[allow(clippy::needless_pass_by_value)]
887    pub fn sweep_along_edges(&mut self, face: u32, edge_handles: Vec<u32>) -> Result<u32, JsError> {
888        if edge_handles.is_empty() {
889            return Err(WasmError::InvalidInput {
890                reason: "sweepAlongEdges requires at least one edge".into(),
891            }
892            .into());
893        }
894        let edge_ids: Vec<brepkit_topology::edge::EdgeId> = edge_handles
895            .iter()
896            .map(|&eh| self.resolve_edge(eh))
897            .collect::<Result<_, _>>()?;
898        let face_id = self.resolve_face(face)?;
899        let solid_id =
900            brepkit_operations::sweep::sweep_along_edges(self.topo_mut(), face_id, &edge_ids)?;
901        Ok(solid_id_to_u32(solid_id))
902    }
903
904    // ── Offset Solid ──────────────────────────────────────────────
905
906    /// Offset (shell) a solid by a distance.
907    ///
908    /// Returns a new solid handle.
909    ///
910    /// # Errors
911    ///
912    /// Returns an error if the distance is zero or the solid is invalid.
913    #[wasm_bindgen(js_name = "offsetSolid")]
914    pub fn offset_solid(&mut self, solid: u32, distance: f64) -> Result<u32, JsError> {
915        validate_finite(distance, "distance")?;
916        let solid_id = self.resolve_solid(solid)?;
917        let result =
918            brepkit_operations::offset_v2::offset_solid_v2(self.topo_mut(), solid_id, distance)?;
919        Ok(solid_id_to_u32(result))
920    }
921
922    /// Offset all faces of a solid outward or inward (V2 pipeline).
923    ///
924    /// Uses the new `brepkit-offset` engine with intersection-based joints.
925    ///
926    /// # Errors
927    ///
928    /// Returns an error if the distance is not finite or the solid is invalid.
929    #[wasm_bindgen(js_name = "offsetSolidV2")]
930    pub fn offset_solid_v2(&mut self, solid: u32, distance: f64) -> Result<u32, JsError> {
931        validate_finite(distance, "distance")?;
932        let sid = self.resolve_solid(solid)?;
933        let result =
934            brepkit_operations::offset_v2::offset_solid_v2(self.topo_mut(), sid, distance)?;
935        Ok(solid_id_to_u32(result))
936    }
937
938    /// Thicken a face into a solid by offsetting it by the given distance.
939    ///
940    /// Creates a solid from a face by extruding it along its normal by
941    /// `thickness`. Positive values offset outward, negative inward.
942    ///
943    /// # Errors
944    ///
945    /// Returns an error if the face handle is invalid or thickness is zero.
946    #[wasm_bindgen(js_name = "thicken")]
947    pub fn thicken_face(&mut self, face: u32, thickness: f64) -> Result<u32, JsError> {
948        validate_finite(thickness, "thickness")?;
949        let face_id = self.resolve_face(face)?;
950        let result = brepkit_operations::thicken::thicken(self.topo_mut(), face_id, thickness)?;
951        Ok(solid_id_to_u32(result))
952    }
953
954    // ── Variable Fillet ───────────────────────────────────────────
955
956    /// Apply variable-radius fillets to edges.
957    ///
958    /// `json` is a JSON string: `[{"edge": u32, "law": "constant"|"linear"|"scurve", "start": f64, "end": f64}]`
959    ///
960    /// Also accepts brepjs-style fields: `startRadius`/`endRadius` as aliases for `start`/`end`.
961    /// When `law` is omitted and `startRadius` != `endRadius`, the law auto-detects as `"linear"`.
962    ///
963    /// Returns a new solid handle.
964    #[wasm_bindgen(js_name = "filletVariable")]
965    pub fn fillet_variable(&mut self, solid: u32, json: &str) -> Result<u32, JsError> {
966        let solid_id = self.resolve_solid(solid)?;
967        let specs: Vec<serde_json::Value> =
968            serde_json::from_str(json).map_err(|e| WasmError::InvalidInput {
969                reason: format!("invalid JSON: {e}"),
970            })?;
971        let mut edge_laws = Vec::with_capacity(specs.len());
972        for spec in &specs {
973            let edge_handle = spec["edge"]
974                .as_u64()
975                .ok_or_else(|| WasmError::InvalidInput {
976                    reason: "missing 'edge' in fillet spec".into(),
977                })? as u32;
978            let edge_id = self.resolve_edge(edge_handle)?;
979            // Accept both brepkit-native ("start"/"end") and brepjs ("startRadius"/"endRadius")
980            let start_val = spec["start"]
981                .as_f64()
982                .or_else(|| spec["startRadius"].as_f64());
983            let end_val = spec["end"].as_f64().or_else(|| spec["endRadius"].as_f64());
984
985            // Auto-detect law: if no "law" field but start != end, use "linear"
986            let law_str = spec["law"]
987                .as_str()
988                .unwrap_or_else(|| match (start_val, end_val) {
989                    (Some(s), Some(e)) if (s - e).abs() > f64::EPSILON => "linear",
990                    _ => "constant",
991                });
992            let law = match law_str {
993                "linear" => {
994                    let s = start_val.unwrap_or(1.0);
995                    let e = end_val.unwrap_or(1.0);
996                    brepkit_operations::fillet::FilletRadiusLaw::Linear { start: s, end: e }
997                }
998                "scurve" => {
999                    let s = start_val.unwrap_or(1.0);
1000                    let e = end_val.unwrap_or(1.0);
1001                    brepkit_operations::fillet::FilletRadiusLaw::SCurve { start: s, end: e }
1002                }
1003                _ => {
1004                    let r = spec["radius"].as_f64().or(start_val).unwrap_or(1.0);
1005                    brepkit_operations::fillet::FilletRadiusLaw::Constant(r)
1006                }
1007            };
1008            edge_laws.push((edge_id, law));
1009        }
1010        let result =
1011            brepkit_operations::fillet::fillet_variable(self.topo_mut(), solid_id, &edge_laws)?;
1012        Ok(solid_id_to_u32(result))
1013    }
1014
1015    /// Sweep a face along a NURBS path with advanced options.
1016    ///
1017    /// `contact_mode`: "rmf" (default), "fixed", or "constantNormal:x,y,z"
1018    /// `scale_values`: flat `[t0,s0,t1,s1,...]` pairs for piecewise-linear scale law.
1019    /// `corner_mode`: "smooth" (default), "miter", or "round"
1020    /// Returns a solid handle.
1021    #[wasm_bindgen(js_name = "sweepWithOptions")]
1022    #[allow(clippy::needless_pass_by_value)]
1023    pub fn sweep_with_options(
1024        &mut self,
1025        profile: u32,
1026        path_edge: u32,
1027        contact_mode: &str,
1028        scale_values: Vec<f64>,
1029        segments: u32,
1030        corner_mode: &str,
1031    ) -> Result<u32, JsError> {
1032        use brepkit_operations::sweep::{SweepContactMode, SweepCornerMode, SweepOptions};
1033
1034        let face_id = self.resolve_face(profile)?;
1035        let path_curve = self.extract_nurbs_curve(path_edge)?;
1036
1037        let mode = if contact_mode == "fixed" {
1038            SweepContactMode::Fixed
1039        } else if let Some(rest) = contact_mode.strip_prefix("constantNormal:") {
1040            let parts: Vec<f64> = rest
1041                .split(',')
1042                .filter_map(|s| s.trim().parse().ok())
1043                .collect();
1044            if parts.len() >= 3 {
1045                SweepContactMode::ConstantNormal(Vec3::new(parts[0], parts[1], parts[2]))
1046            } else {
1047                SweepContactMode::RotationMinimizing
1048            }
1049        } else {
1050            SweepContactMode::RotationMinimizing
1051        };
1052
1053        let scale_law: Option<Box<dyn Fn(f64) -> f64 + Send + Sync>> =
1054            if scale_values.len() >= 4 && scale_values.len().is_multiple_of(2) {
1055                let pairs: Vec<(f64, f64)> =
1056                    scale_values.chunks_exact(2).map(|c| (c[0], c[1])).collect();
1057                Some(Box::new(move |t: f64| -> f64 {
1058                    // Piecewise-linear interpolation
1059                    if pairs.is_empty() {
1060                        return 1.0;
1061                    }
1062                    if t <= pairs[0].0 {
1063                        return pairs[0].1;
1064                    }
1065                    if t >= pairs[pairs.len() - 1].0 {
1066                        return pairs[pairs.len() - 1].1;
1067                    }
1068                    for w in pairs.windows(2) {
1069                        if t >= w[0].0 && t <= w[1].0 {
1070                            let frac = (t - w[0].0) / (w[1].0 - w[0].0);
1071                            return w[0].1 + frac * (w[1].1 - w[0].1);
1072                        }
1073                    }
1074                    1.0
1075                }))
1076            } else {
1077                None
1078            };
1079
1080        let cm = match corner_mode {
1081            "miter" => SweepCornerMode::Miter,
1082            "round" => SweepCornerMode::Round,
1083            _ => SweepCornerMode::Smooth,
1084        };
1085
1086        let options = SweepOptions {
1087            contact_mode: mode,
1088            corner_mode: cm,
1089            scale_law,
1090            segments: segments as usize,
1091            aux_spine: None,
1092        };
1093
1094        let result = brepkit_operations::sweep::sweep_with_options(
1095            self.topo_mut(),
1096            face_id,
1097            &path_curve,
1098            &options,
1099        )?;
1100        Ok(solid_id_to_u32(result))
1101    }
1102
1103    /// Guided (two-rail) sweep: sweep `face` along a spine, orienting the
1104    /// profile so its up-vector tracks an auxiliary spine.
1105    ///
1106    /// The spine and auxiliary spine are each passed as raw NURBS data
1107    /// (`degree`, `knots`, flat `control_points`, `weights`). Returns a solid
1108    /// handle (`u32`).
1109    ///
1110    /// # Errors
1111    ///
1112    /// Returns an error for a non-finite or malformed curve, a non-planar
1113    /// profile, or a degenerate path.
1114    #[wasm_bindgen(js_name = "guidedSweep")]
1115    #[allow(clippy::needless_pass_by_value, clippy::too_many_arguments)]
1116    pub fn guided_sweep(
1117        &mut self,
1118        face: u32,
1119        spine_degree: u32,
1120        spine_knots: Vec<f64>,
1121        spine_control_points: Vec<f64>,
1122        spine_weights: Vec<f64>,
1123        aux_degree: u32,
1124        aux_knots: Vec<f64>,
1125        aux_control_points: Vec<f64>,
1126        aux_weights: Vec<f64>,
1127    ) -> Result<u32, JsError> {
1128        let build = |degree: u32,
1129                     knots: Vec<f64>,
1130                     cps: Vec<f64>,
1131                     weights: Vec<f64>,
1132                     label: &str|
1133         -> Result<NurbsCurve, JsError> {
1134            if degree < 1 {
1135                return Err(WasmError::InvalidInput {
1136                    reason: format!("{label}_degree must be at least 1"),
1137                }
1138                .into());
1139            }
1140            if !cps.len().is_multiple_of(3) {
1141                return Err(WasmError::InvalidInput {
1142                    reason: format!("{label}_control_points length must be a multiple of 3"),
1143                }
1144                .into());
1145            }
1146            if weights.len() != cps.len() / 3 {
1147                return Err(WasmError::InvalidInput {
1148                    reason: format!("{label}_weights length must match control point count"),
1149                }
1150                .into());
1151            }
1152            for (name, arr) in [
1153                ("knots", &knots),
1154                ("control_points", &cps),
1155                ("weights", &weights),
1156            ] {
1157                if let Some(pos) = arr.iter().position(|v| !v.is_finite()) {
1158                    return Err(WasmError::InvalidInput {
1159                        reason: format!("{label}_{name}[{pos}] is not finite"),
1160                    }
1161                    .into());
1162                }
1163            }
1164            let control_points: Vec<Point3> = cps
1165                .chunks_exact(3)
1166                .map(|c| Point3::new(c[0], c[1], c[2]))
1167                .collect();
1168            Ok(NurbsCurve::new(
1169                degree as usize,
1170                knots,
1171                control_points,
1172                weights,
1173            )?)
1174        };
1175
1176        let spine = build(
1177            spine_degree,
1178            spine_knots,
1179            spine_control_points,
1180            spine_weights,
1181            "spine",
1182        )?;
1183        let aux = build(
1184            aux_degree,
1185            aux_knots,
1186            aux_control_points,
1187            aux_weights,
1188            "aux",
1189        )?;
1190        let face_id = self.resolve_face(face)?;
1191        let solid = brepkit_operations::sweep::sweep_guided(self.topo_mut(), face_id, &spine, aux)?;
1192        Ok(solid_id_to_u32(solid))
1193    }
1194
1195    /// Convex Minkowski sum of two solids (`A ⊕ B`).
1196    ///
1197    /// Returns the convex hull of all pairwise vertex sums — exact for convex
1198    /// polytopes (boxes, or a tessellated-sphere rolling tool), a convex
1199    /// over-approximation otherwise. Returns a solid handle (`u32`).
1200    ///
1201    /// # Errors
1202    ///
1203    /// Returns an error if either handle is invalid, either solid is empty, or
1204    /// the summed points are degenerate so no hull can be built.
1205    #[wasm_bindgen(js_name = "minkowskiSum")]
1206    pub fn minkowski_sum(&mut self, solid_a: u32, solid_b: u32) -> Result<u32, JsError> {
1207        let a = self.resolve_solid(solid_a)?;
1208        let b = self.resolve_solid(solid_b)?;
1209        let result = brepkit_operations::primitives::make_minkowski_sum(self.topo_mut(), a, b)?;
1210        Ok(solid_id_to_u32(result))
1211    }
1212
1213    /// Project a solid's edges onto a view plane with hidden-line removal.
1214    ///
1215    /// Viewed along `dir` (orthographic) through `origin`, with in-plane x-axis
1216    /// `x_axis`. Returns a JSON string `{"visible": [[x,y,…]], "hidden": [[…]]}`
1217    /// — flat 2D polylines in view coordinates. `hidden_lines = false` drops the
1218    /// hidden set. Occlusion is an exact point-in-solid test.
1219    ///
1220    /// # Errors
1221    ///
1222    /// Returns an error for an invalid handle, a non-positive `deflection`, or a
1223    /// degenerate `dir`/`x_axis`.
1224    #[wasm_bindgen(js_name = "projectEdges")]
1225    #[allow(clippy::too_many_arguments)]
1226    pub fn project_edges(
1227        &self,
1228        solid: u32,
1229        origin_x: f64,
1230        origin_y: f64,
1231        origin_z: f64,
1232        dir_x: f64,
1233        dir_y: f64,
1234        dir_z: f64,
1235        x_axis_x: f64,
1236        x_axis_y: f64,
1237        x_axis_z: f64,
1238        hidden_lines: bool,
1239        deflection: f64,
1240    ) -> Result<JsValue, JsError> {
1241        validate_positive(deflection, "deflection")?;
1242        for (v, name) in [
1243            (origin_x, "origin_x"),
1244            (origin_y, "origin_y"),
1245            (origin_z, "origin_z"),
1246            (dir_x, "dir_x"),
1247            (dir_y, "dir_y"),
1248            (dir_z, "dir_z"),
1249            (x_axis_x, "x_axis_x"),
1250            (x_axis_y, "x_axis_y"),
1251            (x_axis_z, "x_axis_z"),
1252        ] {
1253            validate_finite(v, name)?;
1254        }
1255        let solid_id = self.resolve_solid(solid)?;
1256        let result = brepkit_operations::projection::project_edges(
1257            &self.topo,
1258            solid_id,
1259            Point3::new(origin_x, origin_y, origin_z),
1260            Vec3::new(dir_x, dir_y, dir_z),
1261            Vec3::new(x_axis_x, x_axis_y, x_axis_z),
1262            hidden_lines,
1263            deflection,
1264        )?;
1265        let flatten = |polys: &[Vec<brepkit_math::vec::Point2>]| -> Vec<Vec<f64>> {
1266            polys
1267                .iter()
1268                .map(|poly| poly.iter().flat_map(|p| [p.x(), p.y()]).collect())
1269                .collect()
1270        };
1271        let json = serde_json::json!({
1272            "visible": flatten(&result.visible),
1273            "hidden": flatten(&result.hidden),
1274        });
1275        Ok(JsValue::from_str(&json.to_string()))
1276    }
1277
1278    // ── Point Classification ──────────────────────────────────────
1279
1280    /// Classify a point relative to a solid using generalized winding numbers.
1281    ///
1282    /// Returns "inside", "outside", or "boundary".
1283    #[wasm_bindgen(js_name = "classifyPointWinding")]
1284    pub fn classify_point_winding(
1285        &self,
1286        solid: u32,
1287        x: f64,
1288        y: f64,
1289        z: f64,
1290        tolerance: f64,
1291    ) -> Result<String, JsError> {
1292        let solid_id = self.resolve_solid(solid)?;
1293        let point = Point3::new(x, y, z);
1294        let result = brepkit_operations::classify::classify_point_winding(
1295            &self.topo, solid_id, point, 0.1, tolerance,
1296        )?;
1297        Ok(classify_to_string(result))
1298    }
1299
1300    /// Classify a point using robust dual-method (winding + ray casting).
1301    ///
1302    /// Returns "inside", "outside", or "boundary".
1303    #[wasm_bindgen(js_name = "classifyPointRobust")]
1304    pub fn classify_point_robust(
1305        &self,
1306        solid: u32,
1307        x: f64,
1308        y: f64,
1309        z: f64,
1310        tolerance: f64,
1311    ) -> Result<String, JsError> {
1312        let solid_id = self.resolve_solid(solid)?;
1313        let point = Point3::new(x, y, z);
1314        let result = brepkit_operations::classify::classify_point_robust(
1315            &self.topo, solid_id, point, 0.1, tolerance,
1316        )?;
1317        Ok(classify_to_string(result))
1318    }
1319
1320    // ── Fill / Untrim / Offset Wire ───────────────────────────────
1321
1322    /// Fill a 4-sided boundary with a Coons patch surface.
1323    ///
1324    /// `boundary_coords` is flat `[x,y,z, ...]` for all 4 curves concatenated.
1325    /// `curve_lengths` is `[n0, n1, n2, n3]` — number of points per curve.
1326    /// Returns a face handle.
1327    #[wasm_bindgen(js_name = "fillCoonsPatch")]
1328    #[allow(clippy::needless_pass_by_value)]
1329    pub fn fill_coons_patch(
1330        &mut self,
1331        boundary_coords: Vec<f64>,
1332        curve_lengths: Vec<u32>,
1333    ) -> Result<u32, JsError> {
1334        if curve_lengths.len() != 4 {
1335            return Err(WasmError::InvalidInput {
1336                reason: format!(
1337                    "Coons patch requires exactly 4 boundary curves, got {}",
1338                    curve_lengths.len()
1339                ),
1340            }
1341            .into());
1342        }
1343        let points = parse_points(&boundary_coords)?;
1344        let mut curves: Vec<Vec<Point3>> = Vec::with_capacity(4);
1345        let mut offset = 0usize;
1346        for &len in &curve_lengths {
1347            let l = len as usize;
1348            if offset + l > points.len() {
1349                return Err(WasmError::InvalidInput {
1350                    reason: "curve_lengths exceed total coordinate count".into(),
1351                }
1352                .into());
1353            }
1354            curves.push(points[offset..offset + l].to_vec());
1355            offset += l;
1356        }
1357        let face_id = brepkit_operations::fill_face::fill_coons_patch(self.topo_mut(), &curves)?;
1358        Ok(face_id_to_u32(face_id))
1359    }
1360
1361    /// Untrim a NURBS face by fitting a new surface to the trimmed region.
1362    ///
1363    /// Returns a new face handle.
1364    #[wasm_bindgen(js_name = "untrimFace")]
1365    pub fn untrim_face(
1366        &mut self,
1367        face: u32,
1368        samples_per_curve: u32,
1369        interior_samples: u32,
1370    ) -> Result<u32, JsError> {
1371        let face_id = self.resolve_face(face)?;
1372        let face_data = self.topo.face(face_id)?;
1373        let surface = match face_data.surface() {
1374            FaceSurface::Nurbs(s) => s.clone(),
1375            _ => {
1376                return Err(WasmError::InvalidInput {
1377                    reason: "untrim only works on NURBS faces".into(),
1378                }
1379                .into());
1380            }
1381        };
1382        // Build trim curves from wire edges projected to UV space
1383        let wire_id = face_data.outer_wire();
1384        let wire = self.topo.wire(wire_id)?;
1385        let mut trim_curves = Vec::new();
1386        for oe in wire.edges() {
1387            let edge = self.topo.edge(oe.edge())?;
1388            let v_start = self.topo.vertex(edge.start())?;
1389            let v_end = self.topo.vertex(edge.end())?;
1390            // Project endpoints to UV
1391            let proj_start = point_to_nurbs_surface(v_start.point(), &surface);
1392            let uv_start = brepkit_math::vec::Point2::new(proj_start.u, proj_start.v);
1393            let proj_end = point_to_nurbs_surface(v_end.point(), &surface);
1394            let uv_end = brepkit_math::vec::Point2::new(proj_end.u, proj_end.v);
1395            trim_curves.push(brepkit_operations::untrim::TrimCurve {
1396                curve: vec![uv_start, uv_end],
1397            });
1398        }
1399        let new_surface = brepkit_operations::untrim::untrim_face(
1400            &surface,
1401            &trim_curves,
1402            samples_per_curve as usize,
1403            interior_samples as usize,
1404        )?;
1405        Ok(face_id_to_u32(self.nurbs_surface_to_face(new_surface)?))
1406    }
1407
1408    /// Offset a wire on a planar face.
1409    ///
1410    /// Returns a new wire handle.
1411    #[wasm_bindgen(js_name = "offsetWire")]
1412    pub fn offset_wire(&mut self, face: u32, distance: f64) -> Result<u32, JsError> {
1413        let face_id = self.resolve_face(face)?;
1414        let wire_id =
1415            brepkit_operations::offset_wire::offset_wire(self.topo_mut(), face_id, distance)?;
1416        Ok(wire_id_to_u32(wire_id))
1417    }
1418
1419    /// Offset a wire on a planar face with a specific join type.
1420    ///
1421    /// `join_type` must be one of `"intersection"`, `"arc"`, or `"chamfer"`.
1422    /// Returns a new wire handle.
1423    ///
1424    /// # Errors
1425    ///
1426    /// Returns an error if the face handle is invalid, the join type string
1427    /// is unrecognized, or the offset operation fails.
1428    #[wasm_bindgen(js_name = "offsetWireWithJoinType")]
1429    pub fn offset_wire_with_join_type(
1430        &mut self,
1431        face: u32,
1432        distance: f64,
1433        join_type: &str,
1434    ) -> Result<u32, JsError> {
1435        let face_id = self.resolve_face(face)?;
1436        let jt = parse_join_type_str(join_type)?;
1437        let wire_id = brepkit_operations::offset_wire::offset_wire_with_join(
1438            self.topo_mut(),
1439            face_id,
1440            distance,
1441            jt,
1442        )?;
1443        Ok(wire_id_to_u32(wire_id))
1444    }
1445
1446    /// Offset a planar wire directly by a distance with a specific join type.
1447    ///
1448    /// Builds a planar face from the wire internally, then offsets it with
1449    /// the requested corner join. This is the wire-based counterpart to
1450    /// [`offset_wire_with_join_type`](Self::offset_wire_with_join_type),
1451    /// which requires a face handle. Consumers that only hold a wire (such
1452    /// as 2D sketch offsets) can route a join type through this entry point
1453    /// without first constructing a face.
1454    ///
1455    /// `join_type` must be one of `"intersection"`, `"arc"`, or `"chamfer"`.
1456    /// Returns a new wire handle.
1457    ///
1458    /// # Errors
1459    ///
1460    /// Returns an error if the wire handle is invalid, the wire is not
1461    /// planar, the join type string is unrecognized, or the offset
1462    /// operation fails.
1463    #[wasm_bindgen(js_name = "offsetWire2DWithJoin")]
1464    pub fn offset_wire_2d_with_join(
1465        &mut self,
1466        wire: u32,
1467        distance: f64,
1468        join_type: &str,
1469    ) -> Result<u32, JsError> {
1470        let wire_id = self.resolve_wire(wire)?;
1471        let jt = parse_join_type_str(join_type)?;
1472        let face_id =
1473            brepkit_topology::builder::make_planar_face_from_wire(self.topo_mut(), wire_id)?;
1474        let result = brepkit_operations::offset_wire::offset_wire_with_join(
1475            self.topo_mut(),
1476            face_id,
1477            distance,
1478            jt,
1479        )?;
1480        Ok(wire_id_to_u32(result))
1481    }
1482
1483    // ── Orientation ───────────────────────────────────────────────
1484
1485    /// Get the orientation of a shape.
1486    ///
1487    /// Returns `"forward"` for all faces (brepkit faces don't have an
1488    /// independent orientation flag; the normal direction is canonical).
1489    #[allow(clippy::unused_self)]
1490    #[must_use]
1491    #[wasm_bindgen(js_name = "getShapeOrientation")]
1492    pub fn get_shape_orientation(&self, _id: u32) -> String {
1493        // In brepkit, face normals are always canonical (outward-pointing).
1494        // There is no separate orientation flag.
1495        "forward".to_string()
1496    }
1497
1498    /// Reverse the orientation of a face or edge.
1499    ///
1500    /// For faces: creates a new face with negated plane normal.
1501    /// For edges: creates a new edge with swapped start/end vertices.
1502    /// Returns the handle of the new reversed shape.
1503    ///
1504    /// # Errors
1505    ///
1506    /// Returns an error if the handle is neither a valid face nor edge.
1507    #[wasm_bindgen(js_name = "reverseShape")]
1508    pub fn reverse_shape(&mut self, id: u32) -> Result<u32, JsError> {
1509        // Try as face
1510        if let Ok(face_id) = self.resolve_face(id) {
1511            let face = self.topo.face(face_id)?;
1512            let outer_wire = face.outer_wire();
1513            let inner_wires: Vec<_> = face.inner_wires().to_vec();
1514            let new_surface = match face.surface() {
1515                FaceSurface::Plane { normal, d } => FaceSurface::Plane {
1516                    normal: -*normal,
1517                    d: -*d,
1518                },
1519                other => other.clone(),
1520            };
1521            let new_face = Face::new(outer_wire, inner_wires, new_surface);
1522            let new_fid = self.topo_mut().add_face(new_face);
1523            return Ok(face_id_to_u32(new_fid));
1524        }
1525        // Try as edge
1526        if let Ok(edge_id) = self.resolve_edge(id) {
1527            let edge = self.topo.edge(edge_id)?;
1528            let new_edge = Edge::new(edge.end(), edge.start(), edge.curve().clone());
1529            let new_eid = self.topo_mut().add_edge(new_edge);
1530            return Ok(edge_id_to_u32(new_eid));
1531        }
1532        Err(WasmError::InvalidInput {
1533            reason: "reverseShape requires a face or edge handle".into(),
1534        }
1535        .into())
1536    }
1537
1538    // ── Blend V2 (walking engine) ────────────────────────────────
1539
1540    /// Fillet edges using the v2 walking-based blend engine.
1541    ///
1542    /// Returns a new solid handle.
1543    ///
1544    /// # Errors
1545    ///
1546    /// Returns an error if the solid or edge handles are invalid, or the
1547    /// blend computation fails.
1548    #[wasm_bindgen(js_name = "filletV2")]
1549    #[allow(clippy::needless_pass_by_value)]
1550    pub fn fillet_v2(
1551        &mut self,
1552        solid: u32,
1553        edge_handles: Vec<u32>,
1554        radius: f64,
1555    ) -> Result<u32, JsError> {
1556        validate_positive(radius, "radius")?;
1557        let solid_id = self.resolve_solid(solid)?;
1558        let edge_ids: Vec<_> = edge_handles
1559            .iter()
1560            .map(|&h| self.resolve_edge(h))
1561            .collect::<Result<_, _>>()?;
1562        let result =
1563            brepkit_operations::blend_ops::fillet_v2(self.topo_mut(), solid_id, &edge_ids, radius)?;
1564        Ok(solid_id_to_u32(result.solid))
1565    }
1566
1567    /// Chamfer edges with two distances using the v2 blend engine.
1568    ///
1569    /// Returns a new solid handle.
1570    ///
1571    /// # Errors
1572    ///
1573    /// Returns an error if the solid or edge handles are invalid, or the
1574    /// blend computation fails.
1575    #[wasm_bindgen(js_name = "chamferV2")]
1576    #[allow(clippy::needless_pass_by_value)]
1577    pub fn chamfer_v2(
1578        &mut self,
1579        solid: u32,
1580        edge_handles: Vec<u32>,
1581        d1: f64,
1582        d2: f64,
1583    ) -> Result<u32, JsError> {
1584        validate_positive(d1, "d1")?;
1585        validate_positive(d2, "d2")?;
1586        let solid_id = self.resolve_solid(solid)?;
1587        let edge_ids: Vec<_> = edge_handles
1588            .iter()
1589            .map(|&h| self.resolve_edge(h))
1590            .collect::<Result<_, _>>()?;
1591        let result = brepkit_operations::blend_ops::chamfer_v2(
1592            self.topo_mut(),
1593            solid_id,
1594            &edge_ids,
1595            d1,
1596            d2,
1597        )?;
1598        Ok(solid_id_to_u32(result.solid))
1599    }
1600
1601    /// Chamfer edges with distance and angle using the v2 blend engine.
1602    ///
1603    /// Returns a new solid handle.
1604    ///
1605    /// # Errors
1606    ///
1607    /// Returns an error if the solid or edge handles are invalid, or the
1608    /// blend computation fails.
1609    #[wasm_bindgen(js_name = "chamferDistanceAngle")]
1610    #[allow(clippy::needless_pass_by_value)]
1611    pub fn chamfer_distance_angle(
1612        &mut self,
1613        solid: u32,
1614        edge_handles: Vec<u32>,
1615        distance: f64,
1616        angle: f64,
1617    ) -> Result<u32, JsError> {
1618        validate_positive(distance, "distance")?;
1619        validate_positive(angle, "angle")?;
1620        if angle >= std::f64::consts::FRAC_PI_2 {
1621            return Err(JsError::new("angle must be less than π/2"));
1622        }
1623        let solid_id = self.resolve_solid(solid)?;
1624        let edge_ids: Vec<_> = edge_handles
1625            .iter()
1626            .map(|&h| self.resolve_edge(h))
1627            .collect::<Result<_, _>>()?;
1628        let result = brepkit_operations::blend_ops::chamfer_distance_angle(
1629            self.topo_mut(),
1630            solid_id,
1631            &edge_ids,
1632            distance,
1633            angle,
1634        )?;
1635        Ok(solid_id_to_u32(result.solid))
1636    }
1637}
1638
1639#[cfg(test)]
1640mod tests {
1641    #![allow(clippy::unwrap_used, clippy::expect_used)]
1642
1643    use brepkit_math::vec::Point3;
1644    use brepkit_topology::builder::make_polygon_wire;
1645
1646    use crate::handles::{solid_id_to_u32, wire_id_to_u32};
1647    use crate::helpers::TOL;
1648    use crate::kernel::BrepKernel;
1649
1650    fn square_wire(k: &mut BrepKernel) -> u32 {
1651        let pts = [
1652            Point3::new(0.0, 0.0, 0.0),
1653            Point3::new(10.0, 0.0, 0.0),
1654            Point3::new(10.0, 10.0, 0.0),
1655            Point3::new(0.0, 10.0, 0.0),
1656        ];
1657        let wid = make_polygon_wire(k.topo_mut(), &pts, TOL).unwrap();
1658        wire_id_to_u32(wid)
1659    }
1660
1661    fn dispatch(k: &mut BrepKernel, op: &str, args: serde_json::Value) -> serde_json::Value {
1662        let batch = serde_json::json!([{ "op": op, "args": args }]);
1663        let out = k.execute_batch(&batch.to_string());
1664        let parsed: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap();
1665        parsed[0].clone()
1666    }
1667
1668    fn wire_perimeter(k: &BrepKernel, wire_handle: u32) -> f64 {
1669        let wid = k.resolve_wire(wire_handle).unwrap();
1670        brepkit_operations::measure::wire_length(&k.topo, wid).unwrap()
1671    }
1672
1673    #[test]
1674    fn multi_section_sweep_lofts_circles_along_line() {
1675        let mut k = BrepKernel::new();
1676        let big = k.make_circle_face(10.0, 24).unwrap();
1677        let small = k.make_circle_face(5.0, 24).unwrap();
1678        // Degree-1 line spine from the origin to (0, 0, 50).
1679        let solid = k
1680            .multi_section_sweep(
1681                vec![big, small],
1682                vec![0.0, 1.0],
1683                1,
1684                vec![0.0, 0.0, 1.0, 1.0],
1685                vec![0.0, 0.0, 0.0, 0.0, 0.0, 50.0],
1686                vec![1.0, 1.0],
1687                true,
1688            )
1689            .unwrap();
1690        let vol = k.volume(solid, 0.5).unwrap();
1691        assert!(
1692            vol > 0.0,
1693            "tapered tube should have positive volume, got {vol}"
1694        );
1695        // (Validation paths — <2 sections, length mismatch, out-of-range param —
1696        // are covered by the operations-layer tests; the error path can't be
1697        // exercised here because JsError can't be constructed off-wasm.)
1698    }
1699
1700    #[test]
1701    fn multi_section_sweep_batch_dispatch_lofts_circles() {
1702        let mut k = BrepKernel::new();
1703        let big = k.make_circle_face(10.0, 24).unwrap();
1704        let small = k.make_circle_face(5.0, 24).unwrap();
1705        // A degree-1 NURBS line spine edge (the batch op takes the spine by edge).
1706        let spine = k
1707            .make_nurbs_edge(
1708                0.0,
1709                0.0,
1710                0.0,
1711                0.0,
1712                0.0,
1713                50.0,
1714                1,
1715                vec![0.0, 0.0, 1.0, 1.0],
1716                vec![0.0, 0.0, 0.0, 0.0, 0.0, 50.0],
1717                vec![1.0, 1.0],
1718            )
1719            .unwrap();
1720        let out = dispatch(
1721            &mut k,
1722            "multiSectionSweep",
1723            serde_json::json!({
1724                "faces": [big, small],
1725                "params": [0.0, 1.0],
1726                "spineEdge": spine,
1727                "ruled": true,
1728            }),
1729        );
1730        assert!(
1731            out.get("ok").and_then(serde_json::Value::as_u64).is_some(),
1732            "expected an ok solid handle, got {out}"
1733        );
1734    }
1735
1736    #[test]
1737    fn guided_sweep_produces_solid() {
1738        let mut k = BrepKernel::new();
1739        let profile = k.make_circle_face(2.0, 24).unwrap();
1740        // Spine: line (0,0,0)→(0,0,20). Aux: parallel guide offset +10 in X.
1741        let solid = k
1742            .guided_sweep(
1743                profile,
1744                1,
1745                vec![0.0, 0.0, 1.0, 1.0],
1746                vec![0.0, 0.0, 0.0, 0.0, 0.0, 20.0],
1747                vec![1.0, 1.0],
1748                1,
1749                vec![0.0, 0.0, 1.0, 1.0],
1750                vec![10.0, 0.0, 0.0, 10.0, 0.0, 20.0],
1751                vec![1.0, 1.0],
1752            )
1753            .unwrap();
1754        let vol = k.volume(solid, 0.5).unwrap();
1755        assert!(vol > 0.0, "guided sweep volume, got {vol}");
1756    }
1757
1758    #[test]
1759    fn guided_sweep_batch_dispatch() {
1760        let mut k = BrepKernel::new();
1761        let profile = k.make_circle_face(2.0, 24).unwrap();
1762        let mk_line = |k: &mut BrepKernel, x: f64| {
1763            k.make_nurbs_edge(
1764                x,
1765                0.0,
1766                0.0,
1767                x,
1768                0.0,
1769                20.0,
1770                1,
1771                vec![0.0, 0.0, 1.0, 1.0],
1772                vec![x, 0.0, 0.0, x, 0.0, 20.0],
1773                vec![1.0, 1.0],
1774            )
1775            .unwrap()
1776        };
1777        let spine = mk_line(&mut k, 0.0);
1778        let aux = mk_line(&mut k, 10.0);
1779        let out = dispatch(
1780            &mut k,
1781            "guidedSweep",
1782            serde_json::json!({ "face": profile, "spineEdge": spine, "auxEdge": aux }),
1783        );
1784        assert!(
1785            out.get("ok").and_then(serde_json::Value::as_u64).is_some(),
1786            "expected an ok solid handle, got {out}"
1787        );
1788    }
1789
1790    #[test]
1791    fn minkowski_sum_binding_box10_box2_is_box12() {
1792        let mut k = BrepKernel::new();
1793        let a = brepkit_operations::primitives::make_box(k.topo_mut(), 10.0, 10.0, 10.0).unwrap();
1794        let b = brepkit_operations::primitives::make_box(k.topo_mut(), 2.0, 2.0, 2.0).unwrap();
1795        let sum = k
1796            .minkowski_sum(solid_id_to_u32(a), solid_id_to_u32(b))
1797            .unwrap();
1798        let vol = k.volume(sum, 0.1).unwrap();
1799        assert!(
1800            (vol - 1728.0).abs() < 0.5,
1801            "expected ~1728 (12³), got {vol}"
1802        );
1803    }
1804
1805    #[test]
1806    fn minkowski_sum_batch_dispatch() {
1807        let mut k = BrepKernel::new();
1808        let a = brepkit_operations::primitives::make_box(k.topo_mut(), 4.0, 4.0, 4.0).unwrap();
1809        let b = brepkit_operations::primitives::make_box(k.topo_mut(), 2.0, 2.0, 2.0).unwrap();
1810        let out = dispatch(
1811            &mut k,
1812            "minkowskiSum",
1813            serde_json::json!({ "solidA": solid_id_to_u32(a), "solidB": solid_id_to_u32(b) }),
1814        );
1815        assert!(
1816            out.get("ok").and_then(serde_json::Value::as_u64).is_some(),
1817            "expected an ok solid handle, got {out}"
1818        );
1819    }
1820
1821    #[test]
1822    fn project_edges_batch_dispatch_box_oblique() {
1823        let mut k = BrepKernel::new();
1824        let solid =
1825            brepkit_operations::primitives::make_box(k.topo_mut(), 10.0, 10.0, 10.0).unwrap();
1826        let out = dispatch(
1827            &mut k,
1828            "projectEdges",
1829            serde_json::json!({
1830                "solid": solid_id_to_u32(solid),
1831                "originX": -100.0, "originY": -100.0, "originZ": -100.0,
1832                "dirX": 1.0, "dirY": 1.0, "dirZ": 1.0,
1833                "xAxisX": 1.0, "xAxisY": -1.0, "xAxisZ": 0.0,
1834                "hiddenLines": true, "deflection": 0.1,
1835            }),
1836        );
1837        let ok = out.get("ok").expect("projectEdges batch should return ok");
1838        let nonempty = |key: &str| {
1839            ok.get(key)
1840                .and_then(serde_json::Value::as_array)
1841                .is_some_and(|a| !a.is_empty())
1842        };
1843        assert!(nonempty("visible"), "visible polylines expected, got {out}");
1844        assert!(nonempty("hidden"), "hidden polylines expected, got {out}");
1845    }
1846
1847    #[test]
1848    fn offset_wire_2d_with_join_routes_arc_distinct_from_chamfer() {
1849        let mut k = BrepKernel::new();
1850        let w_int = square_wire(&mut k);
1851        let w_arc = square_wire(&mut k);
1852        let w_chamfer = square_wire(&mut k);
1853
1854        let intersection = dispatch(
1855            &mut k,
1856            "offsetWire2DWithJoin",
1857            serde_json::json!({"wire": w_int, "distance": 2.0, "joinType": "intersection"}),
1858        );
1859        let arc = dispatch(
1860            &mut k,
1861            "offsetWire2DWithJoin",
1862            serde_json::json!({"wire": w_arc, "distance": 2.0, "joinType": "arc"}),
1863        );
1864        let chamfer = dispatch(
1865            &mut k,
1866            "offsetWire2DWithJoin",
1867            serde_json::json!({"wire": w_chamfer, "distance": 2.0, "joinType": "chamfer"}),
1868        );
1869
1870        for (label, entry) in [
1871            ("intersection", &intersection),
1872            ("arc", &arc),
1873            ("chamfer", &chamfer),
1874        ] {
1875            assert!(entry.get("error").is_none(), "{label} errored: {entry}");
1876        }
1877
1878        let int_wire = intersection["ok"].as_u64().unwrap() as u32;
1879        let arc_wire = arc["ok"].as_u64().unwrap() as u32;
1880        let chamfer_wire = chamfer["ok"].as_u64().unwrap() as u32;
1881
1882        let int_len = wire_perimeter(&k, int_wire);
1883        let arc_len = wire_perimeter(&k, arc_wire);
1884        let chamfer_len = wire_perimeter(&k, chamfer_wire);
1885
1886        assert!(int_len > 0.0 && arc_len > 0.0 && chamfer_len > 0.0);
1887        // Arc joins round the four convex corners, so the perimeter must
1888        // differ from the sharp/chamfer join shapes — proving the join type
1889        // is actually threaded through rather than silently ignored.
1890        assert!(
1891            (arc_len - chamfer_len).abs() > 1.0,
1892            "arc ({arc_len}) should differ from chamfer ({chamfer_len})"
1893        );
1894    }
1895
1896    #[test]
1897    fn offset_wire_2d_with_join_rejects_unknown_join_type() {
1898        let mut k = BrepKernel::new();
1899        let w = square_wire(&mut k);
1900        let entry = dispatch(
1901            &mut k,
1902            "offsetWire2DWithJoin",
1903            serde_json::json!({"wire": w, "distance": 2.0, "joinType": "bogus"}),
1904        );
1905        assert!(
1906            entry.get("error").is_some(),
1907            "unknown join type should error: {entry}"
1908        );
1909    }
1910}