Skip to main content

brepkit_wasm/bindings/
nurbs.rs

1//! NURBS curve and surface manipulation bindings.
2
3#![allow(clippy::missing_errors_doc, clippy::too_many_arguments)]
4
5use wasm_bindgen::prelude::*;
6
7use brepkit_math::nurbs::curve::NurbsCurve;
8use brepkit_math::nurbs::surface::NurbsSurface;
9use brepkit_math::vec::Point3;
10use brepkit_topology::edge::{Edge, EdgeCurve};
11use brepkit_topology::vertex::Vertex;
12
13use crate::error::WasmError;
14use crate::handles::{edge_id_to_u32, face_id_to_u32};
15use crate::helpers::{TOL, parse_point_grid, parse_points};
16use crate::kernel::BrepKernel;
17
18/// Weights within this absolute distance of `1.0` are treated as unit.
19const WEIGHT_UNIT_TOL: f64 = 1e-12;
20
21/// Squared linear distance below which control rows/columns are coincident.
22const CLOSURE_TOL_SQ: f64 = 1e-14;
23
24/// Split a flat (repeated) knot vector into distinct values + multiplicities.
25fn compress_knots(knots: &[f64]) -> (Vec<f64>, Vec<u32>) {
26    let mut distinct: Vec<f64> = Vec::new();
27    let mut mult: Vec<u32> = Vec::new();
28    for &k in knots {
29        match distinct.last() {
30            Some(&last) if (k - last).abs() <= f64::EPSILON => {
31                if let Some(m) = mult.last_mut() {
32                    *m += 1;
33                }
34            }
35            _ => {
36                distinct.push(k);
37                mult.push(1);
38            }
39        }
40    }
41    (distinct, mult)
42}
43
44/// Serialize a `NurbsCurve` to the read-only extraction object.
45#[allow(clippy::redundant_pub_crate)]
46pub(crate) fn curve_data_json(curve: &NurbsCurve) -> serde_json::Value {
47    let cps: Vec<[f64; 3]> = curve
48        .control_points()
49        .iter()
50        .map(|p| [p.x(), p.y(), p.z()])
51        .collect();
52    let weights = curve.weights();
53    let rational = weights.iter().any(|&w| (w - 1.0).abs() > WEIGHT_UNIT_TOL);
54    let (a, b) = curve.domain();
55    let (distinct, mult) = compress_knots(curve.knots());
56
57    let closed = curve
58        .control_points()
59        .first()
60        .zip(curve.control_points().last())
61        .is_some_and(|(first, last)| (*last - *first).length_squared() < CLOSURE_TOL_SQ);
62
63    serde_json::json!({
64        "degree": curve.degree(),
65        "controlPoints": cps,
66        "weights": weights,
67        "knots": curve.knots(),
68        "distinctKnots": distinct,
69        "multiplicities": mult,
70        "rational": rational,
71        "closed": closed,
72        "periodic": closed,
73        "domain": [a, b],
74    })
75}
76
77/// Serialize a `NurbsSurface` to the read-only extraction object.
78#[allow(clippy::redundant_pub_crate)]
79pub(crate) fn surface_data_json(surface: &NurbsSurface) -> serde_json::Value {
80    let cps: Vec<Vec<[f64; 3]>> = surface
81        .control_points()
82        .iter()
83        .map(|row| row.iter().map(|p| [p.x(), p.y(), p.z()]).collect())
84        .collect();
85    let weights = surface.weights();
86    let rational = weights
87        .iter()
88        .flatten()
89        .any(|&w| (w - 1.0).abs() > WEIGHT_UNIT_TOL);
90
91    let rows = surface.control_points();
92    let periodic_u = rows
93        .first()
94        .zip(rows.last())
95        .is_some_and(|(first, last)| rows_coincide(first, last));
96    let periodic_v = rows.iter().all(|row| {
97        row.first()
98            .zip(row.last())
99            .is_some_and(|(a, b)| (*b - *a).length_squared() < CLOSURE_TOL_SQ)
100    }) && rows.first().is_some_and(|r| r.len() >= 2);
101
102    let (ua, ub) = surface.domain_u();
103    let (va, vb) = surface.domain_v();
104    let (distinct_u, mult_u) = compress_knots(surface.knots_u());
105    let (distinct_v, mult_v) = compress_knots(surface.knots_v());
106
107    serde_json::json!({
108        "degreeU": surface.degree_u(),
109        "degreeV": surface.degree_v(),
110        "controlPoints": cps,
111        "weights": weights,
112        "knotsU": surface.knots_u(),
113        "knotsV": surface.knots_v(),
114        "distinctKnotsU": distinct_u,
115        "multiplicitiesU": mult_u,
116        "distinctKnotsV": distinct_v,
117        "multiplicitiesV": mult_v,
118        "rational": rational,
119        "periodicU": periodic_u,
120        "periodicV": periodic_v,
121        "domainU": [ua, ub],
122        "domainV": [va, vb],
123    })
124}
125
126/// Serialize a `NurbsSurface` to the parity extraction object.
127///
128/// The parity shape uses `poles`/`nbPolesU`/`nbPolesV`, distinct `knotsU`/`knotsV`
129/// paired with `multiplicitiesU`/`multiplicitiesV`, and `isPeriodicU`/`isPeriodicV`/
130/// `isRational`. Distinct knots are emitted (not the flat vector); a consumer
131/// rebuilds the flat vector by repeating each value by its multiplicity.
132#[allow(clippy::redundant_pub_crate)]
133pub(crate) fn surface_data_parity_json(surface: &NurbsSurface) -> serde_json::Value {
134    let poles: Vec<Vec<[f64; 3]>> = surface
135        .control_points()
136        .iter()
137        .map(|row| row.iter().map(|p| [p.x(), p.y(), p.z()]).collect())
138        .collect();
139    let weights = surface.weights();
140    let rational = weights
141        .iter()
142        .flatten()
143        .any(|&w| (w - 1.0).abs() > WEIGHT_UNIT_TOL);
144
145    let nb_poles_u = poles.len();
146    let nb_poles_v = poles.first().map_or(0, Vec::len);
147
148    let (distinct_u, mult_u) = compress_knots(surface.knots_u());
149    let (distinct_v, mult_v) = compress_knots(surface.knots_v());
150
151    serde_json::json!({
152        "degreeU": surface.degree_u(),
153        "degreeV": surface.degree_v(),
154        "nbPolesU": nb_poles_u,
155        "nbPolesV": nb_poles_v,
156        "poles": poles,
157        "weights": weights,
158        "knotsU": distinct_u,
159        "knotsV": distinct_v,
160        "multiplicitiesU": mult_u,
161        "multiplicitiesV": mult_v,
162        "isPeriodicU": surface.is_periodic_u(),
163        "isPeriodicV": surface.is_periodic_v(),
164        "isRational": rational,
165    })
166}
167
168/// Two control rows coincide pointwise within the closure tolerance.
169fn rows_coincide(a: &[Point3], b: &[Point3]) -> bool {
170    a.len() == b.len()
171        && !a.is_empty()
172        && a.iter()
173            .zip(b)
174            .all(|(p, q)| (*q - *p).length_squared() < CLOSURE_TOL_SQ)
175}
176
177#[wasm_bindgen]
178impl BrepKernel {
179    /// Interpolate a NURBS curve through points and create an edge.
180    ///
181    /// Uses chord-length parameterization with the given degree.
182    /// Returns an edge handle (`u32`).
183    #[wasm_bindgen(js_name = "interpolatePoints")]
184    #[allow(clippy::needless_pass_by_value)]
185    pub fn interpolate_points(&mut self, coords: Vec<f64>, degree: u32) -> Result<u32, JsError> {
186        if !coords.len().is_multiple_of(3) {
187            return Err(WasmError::InvalidInput {
188                reason: format!(
189                    "coordinate array length must be a multiple of 3, got {}",
190                    coords.len()
191                ),
192            }
193            .into());
194        }
195        let points: Vec<Point3> = coords
196            .chunks_exact(3)
197            .map(|c| Point3::new(c[0], c[1], c[2]))
198            .collect();
199        if points.len() < 2 {
200            return Err(WasmError::InvalidInput {
201                reason: format!("need at least 2 points, got {}", points.len()),
202            }
203            .into());
204        }
205
206        let deg = std::cmp::min(degree as usize, points.len() - 1);
207        let curve = brepkit_math::nurbs::fitting::interpolate(&points, deg)?;
208
209        let start = points[0];
210        let end = points[points.len() - 1];
211        let v_start = self.topo_mut().add_vertex(Vertex::new(start, TOL));
212        let v_end = self.topo_mut().add_vertex(Vertex::new(end, TOL));
213        let eid = self
214            .topo_mut()
215            .add_edge(Edge::new(v_start, v_end, EdgeCurve::NurbsCurve(curve)));
216        Ok(edge_id_to_u32(eid))
217    }
218
219    /// Approximate a curve through points (least-squares).
220    ///
221    /// Returns an edge handle.
222    #[wasm_bindgen(js_name = "approximateCurve")]
223    #[allow(clippy::needless_pass_by_value)]
224    pub fn approximate_curve(
225        &mut self,
226        coords: Vec<f64>,
227        degree: u32,
228        num_control_points: u32,
229    ) -> Result<u32, JsError> {
230        let points = parse_points(&coords)?;
231        if points.len() < 2 {
232            return Err(WasmError::InvalidInput {
233                reason: format!("need at least 2 points, got {}", points.len()),
234            }
235            .into());
236        }
237        let deg = std::cmp::min(degree as usize, points.len() - 1);
238        let curve =
239            brepkit_math::nurbs::fitting::approximate(&points, deg, num_control_points as usize)?;
240        Ok(edge_id_to_u32(self.nurbs_curve_to_edge(&points, curve)))
241    }
242
243    /// Approximate a curve through points using LSPIA (progressive iteration).
244    ///
245    /// Returns an edge handle.
246    #[wasm_bindgen(js_name = "approximateCurveLspia")]
247    #[allow(clippy::needless_pass_by_value)]
248    pub fn approximate_curve_lspia(
249        &mut self,
250        coords: Vec<f64>,
251        degree: u32,
252        num_control_points: u32,
253        tolerance: f64,
254        max_iterations: u32,
255    ) -> Result<u32, JsError> {
256        let points = parse_points(&coords)?;
257        if points.len() < 2 {
258            return Err(WasmError::InvalidInput {
259                reason: format!("need at least 2 points, got {}", points.len()),
260            }
261            .into());
262        }
263        let deg = std::cmp::min(degree as usize, points.len() - 1);
264        let curve = brepkit_math::nurbs::fitting::approximate_lspia(
265            &points,
266            deg,
267            num_control_points as usize,
268            tolerance,
269            max_iterations as usize,
270        )?;
271        Ok(edge_id_to_u32(self.nurbs_curve_to_edge(&points, curve)))
272    }
273
274    /// Interpolate a grid of points into a NURBS surface.
275    ///
276    /// `coords` is a flat array `[x,y,z, ...]` of `rows * cols` points.
277    /// Returns a face handle.
278    #[wasm_bindgen(js_name = "interpolateSurface")]
279    #[allow(clippy::needless_pass_by_value)]
280    pub fn interpolate_surface(
281        &mut self,
282        coords: Vec<f64>,
283        rows: u32,
284        cols: u32,
285        degree_u: u32,
286        degree_v: u32,
287    ) -> Result<u32, JsError> {
288        let grid = parse_point_grid(&coords, rows as usize, cols as usize)?;
289        let surface = brepkit_math::nurbs::surface_fitting::interpolate_surface(
290            &grid,
291            degree_u as usize,
292            degree_v as usize,
293        )?;
294        Ok(face_id_to_u32(self.nurbs_surface_to_face(surface)?))
295    }
296
297    /// Approximate a grid of points into a NURBS surface using LSPIA.
298    ///
299    /// Returns a face handle.
300    #[wasm_bindgen(js_name = "approximateSurfaceLspia")]
301    #[allow(clippy::needless_pass_by_value)]
302    pub fn approximate_surface_lspia(
303        &mut self,
304        coords: Vec<f64>,
305        rows: u32,
306        cols: u32,
307        degree_u: u32,
308        degree_v: u32,
309        num_cps_u: u32,
310        num_cps_v: u32,
311        tolerance: f64,
312        max_iterations: u32,
313    ) -> Result<u32, JsError> {
314        let grid = parse_point_grid(&coords, rows as usize, cols as usize)?;
315        let surface = brepkit_math::nurbs::surface_fitting::approximate_surface_lspia(
316            &grid,
317            degree_u as usize,
318            degree_v as usize,
319            num_cps_u as usize,
320            num_cps_v as usize,
321            tolerance,
322            max_iterations as usize,
323        )?;
324        Ok(face_id_to_u32(self.nurbs_surface_to_face(surface)?))
325    }
326
327    /// Insert a knot into an edge's NURBS curve.
328    ///
329    /// Returns a new edge handle with the refined curve.
330    #[wasm_bindgen(js_name = "curveKnotInsert")]
331    pub fn curve_knot_insert(&mut self, edge: u32, knot: f64, times: u32) -> Result<u32, JsError> {
332        let curve = self.extract_nurbs_curve(edge)?;
333        let refined =
334            brepkit_math::nurbs::knot_ops::curve_knot_insert(&curve, knot, times as usize)?;
335        Ok(edge_id_to_u32(
336            self.nurbs_curve_to_edge_from_curve(&refined),
337        ))
338    }
339
340    /// Remove a knot from an edge's NURBS curve.
341    ///
342    /// Returns a new edge handle with the simplified curve.
343    #[wasm_bindgen(js_name = "curveKnotRemove")]
344    pub fn curve_knot_remove(
345        &mut self,
346        edge: u32,
347        knot: f64,
348        tolerance: f64,
349    ) -> Result<u32, JsError> {
350        let curve = self.extract_nurbs_curve(edge)?;
351        let simplified = brepkit_math::nurbs::knot_ops::curve_knot_remove(&curve, knot, tolerance)?;
352        Ok(edge_id_to_u32(
353            self.nurbs_curve_to_edge_from_curve(&simplified),
354        ))
355    }
356
357    /// Split an edge's NURBS curve at a parameter value.
358    ///
359    /// Returns two edge handles as `[u32; 2]`.
360    #[wasm_bindgen(js_name = "curveSplit")]
361    pub fn curve_split(&mut self, edge: u32, u: f64) -> Result<Vec<u32>, JsError> {
362        let curve = self.extract_nurbs_curve(edge)?;
363        let (left, right) = brepkit_math::nurbs::knot_ops::curve_split(&curve, u)?;
364        let e1 = self.nurbs_curve_to_edge_from_curve(&left);
365        let e2 = self.nurbs_curve_to_edge_from_curve(&right);
366        Ok(vec![edge_id_to_u32(e1), edge_id_to_u32(e2)])
367    }
368
369    /// Elevate the degree of an edge's NURBS curve.
370    ///
371    /// Returns a new edge handle.
372    #[wasm_bindgen(js_name = "curveDegreeElevate")]
373    pub fn curve_degree_elevate(&mut self, edge: u32, elevate_by: u32) -> Result<u32, JsError> {
374        let curve = self.extract_nurbs_curve(edge)?;
375        let elevated =
376            brepkit_math::nurbs::decompose::curve_degree_elevate(&curve, elevate_by as usize)?;
377        Ok(edge_id_to_u32(
378            self.nurbs_curve_to_edge_from_curve(&elevated),
379        ))
380    }
381
382    /// Read-only canonical NURBS data for the curve underlying an edge.
383    ///
384    /// Analytic curves (line, circle, ellipse) are converted to their exact
385    /// NURBS form. Returns a JSON string with `degree`, `controlPoints`,
386    /// `weights`, the flat `knots` vector, compressed `distinctKnots` /
387    /// `multiplicities`, `rational`, `closed` / `periodic`, and `domain`.
388    #[wasm_bindgen(js_name = "getNurbsCurveData")]
389    pub fn get_nurbs_curve_data(&self, edge: u32) -> Result<String, JsError> {
390        let curve = self.extract_nurbs_curve(edge)?;
391        Ok(curve_data_json(&curve).to_string())
392    }
393
394    /// Read-only canonical NURBS data for the surface underlying a face.
395    ///
396    /// Analytic surfaces are converted to NURBS (planes/cylinders exact;
397    /// cones/spheres/tori via the exact rational forms). Returns a JSON
398    /// string with `degreeU`/`degreeV`, the row-major `controlPoints` grid,
399    /// the matching `weights` grid, flat `knotsU`/`knotsV`, compressed
400    /// distinct-knots/multiplicities per direction, `rational`,
401    /// `periodicU`/`periodicV`, and `domainU`/`domainV`.
402    #[wasm_bindgen(js_name = "getNurbsSurfaceData")]
403    pub fn get_nurbs_surface_data(&self, face: u32) -> Result<String, JsError> {
404        let surface = self.extract_nurbs_surface(face)?;
405        Ok(surface_data_json(&surface).to_string())
406    }
407
408    /// Type-gated read-only B-Spline/NURBS surface data for a face.
409    ///
410    /// Unlike `getNurbsSurfaceData`, this never converts analytic surfaces:
411    /// faces backed by a plane, cylinder, cone, sphere, or torus return the
412    /// JSON literal `null`. Only intrinsically free-form (B-Spline/NURBS) faces
413    /// yield a record with `degreeU`/`degreeV`, `nbPolesU`/`nbPolesV`, the
414    /// row-major `poles` grid (u-major, v-minor) with the matching `weights`
415    /// grid, distinct `knotsU`/`knotsV` paired with `multiplicitiesU`/
416    /// `multiplicitiesV`, `isPeriodicU`/`isPeriodicV`, and `isRational`.
417    #[wasm_bindgen(js_name = "getNurbsSurfaceDataParity")]
418    pub fn get_nurbs_surface_data_parity(&self, face: u32) -> Result<String, JsError> {
419        Ok(self.free_form_surface_data_parity(face)?.to_string())
420    }
421}
422
423impl BrepKernel {
424    /// Return parity surface data only for intrinsically free-form faces,
425    /// else JSON `null`. Pure: resolves the face without touching the arena.
426    pub(crate) fn free_form_surface_data_parity(
427        &self,
428        face: u32,
429    ) -> Result<serde_json::Value, WasmError> {
430        use brepkit_topology::face::FaceSurface;
431
432        let face_id = self.resolve_face(face)?;
433        let face_data = self.topo.face(face_id)?;
434        match face_data.surface() {
435            FaceSurface::Nurbs(s) => Ok(surface_data_parity_json(s)),
436            FaceSurface::Plane { .. }
437            | FaceSurface::Cylinder(_)
438            | FaceSurface::Cone(_)
439            | FaceSurface::Sphere(_)
440            | FaceSurface::Torus(_) => Ok(serde_json::Value::Null),
441        }
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::cast_precision_loss)]
448
449    use brepkit_math::nurbs::surface::NurbsSurface;
450    use brepkit_math::nurbs::surface_fitting::interpolate_surface;
451    use brepkit_math::vec::{Point3, Vec3};
452    use brepkit_topology::builder::{make_nurbs_edge_from_curve, make_nurbs_face};
453
454    use crate::handles::{edge_id_to_u32, face_id_to_u32};
455    use crate::helpers::TOL;
456    use crate::kernel::BrepKernel;
457
458    use super::*;
459
460    fn dispatch_ok(k: &mut BrepKernel, op: &str, args: serde_json::Value) -> serde_json::Value {
461        let batch = serde_json::json!([{ "op": op, "args": args }]);
462        let out = k.execute_batch(&batch.to_string());
463        let parsed: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap();
464        let entry = &parsed[0];
465        assert!(
466            entry.get("error").is_none(),
467            "dispatch {op} errored: {entry}"
468        );
469        entry["ok"].clone()
470    }
471
472    fn knot_distance_curve(degree: usize, cp: usize) -> usize {
473        cp + degree + 1
474    }
475
476    fn entity_counts(k: &BrepKernel) -> (usize, usize, usize) {
477        (
478            k.topo.num_faces(),
479            k.topo.num_edges(),
480            k.topo.num_vertices(),
481        )
482    }
483
484    #[test]
485    fn curve_data_round_trips_cubic_nurbs() {
486        let mut k = BrepKernel::new();
487        let cps = vec![
488            Point3::new(0.0, 0.0, 0.0),
489            Point3::new(1.0, 2.0, 0.0),
490            Point3::new(3.0, 2.0, 1.0),
491            Point3::new(4.0, 0.0, 0.0),
492            Point3::new(6.0, -1.0, 2.0),
493        ];
494        let knots = vec![0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0];
495        let weights = vec![1.0; 5];
496        let curve = NurbsCurve::new(3, knots.clone(), cps.clone(), weights.clone()).unwrap();
497        let eid = make_nurbs_edge_from_curve(k.topo_mut(), &curve, TOL);
498        let handle = edge_id_to_u32(eid);
499
500        let before = entity_counts(&k);
501        let data = dispatch_ok(
502            &mut k,
503            "getNurbsCurveData",
504            serde_json::json!({"edge": handle}),
505        );
506        assert_eq!(before, entity_counts(&k), "query must not mutate topology");
507
508        assert_eq!(data["degree"].as_u64().unwrap(), 3);
509        assert!(!data["rational"].as_bool().unwrap());
510        let out_knots: Vec<f64> = serde_json::from_value(data["knots"].clone()).unwrap();
511        assert_eq!(out_knots.len(), knot_distance_curve(3, 5));
512        for (a, b) in out_knots.iter().zip(&knots) {
513            assert!((a - b).abs() < 1e-12);
514        }
515        let out_cps: Vec<[f64; 3]> = serde_json::from_value(data["controlPoints"].clone()).unwrap();
516        assert_eq!(out_cps.len(), 5);
517        for (a, b) in out_cps.iter().zip(&cps) {
518            assert!((a[0] - b.x()).abs() < 1e-12);
519            assert!((a[1] - b.y()).abs() < 1e-12);
520            assert!((a[2] - b.z()).abs() < 1e-12);
521        }
522        let out_w: Vec<f64> = serde_json::from_value(data["weights"].clone()).unwrap();
523        assert_eq!(out_w, weights);
524
525        // Domain matches [knot[degree], knot[len-degree-1]].
526        let domain: [f64; 2] = serde_json::from_value(data["domain"].clone()).unwrap();
527        assert!(domain[1] > domain[0]);
528
529        // Compressed knots expand to the flat vector.
530        let dk: Vec<f64> = serde_json::from_value(data["distinctKnots"].clone()).unwrap();
531        let mult: Vec<u32> = serde_json::from_value(data["multiplicities"].clone()).unwrap();
532        let expanded: Vec<f64> = dk
533            .iter()
534            .zip(&mult)
535            .flat_map(|(&v, &m)| std::iter::repeat_n(v, m as usize))
536            .collect();
537        assert_eq!(expanded, out_knots);
538    }
539
540    fn assert_complete_bspline(data: &serde_json::Value, expected_degree: u64) {
541        let degree = data["degree"].as_u64().expect("degree present");
542        assert_eq!(degree, expected_degree);
543
544        let cps: Vec<[f64; 3]> = serde_json::from_value(data["controlPoints"].clone()).unwrap();
545        let n = cps.len();
546        assert!(n > expected_degree as usize, "n {n} >= degree+1");
547
548        let knots: Vec<f64> = serde_json::from_value(data["knots"].clone()).unwrap();
549        assert_eq!(knots.len(), n + degree as usize + 1);
550        assert!(knots.windows(2).all(|w| w[1] >= w[0]), "non-decreasing");
551
552        let weights: Vec<f64> = serde_json::from_value(data["weights"].clone()).unwrap();
553        assert_eq!(weights.len(), n);
554
555        let dk: Vec<f64> = serde_json::from_value(data["distinctKnots"].clone()).unwrap();
556        let mult: Vec<u32> = serde_json::from_value(data["multiplicities"].clone()).unwrap();
557        let expanded: Vec<f64> = dk
558            .iter()
559            .zip(&mult)
560            .flat_map(|(&v, &m)| std::iter::repeat_n(v, m as usize))
561            .collect();
562        assert_eq!(expanded, knots, "compressed knots round-trip");
563        assert_eq!(mult.iter().sum::<u32>() as usize, n + degree as usize + 1);
564
565        let domain: [f64; 2] = serde_json::from_value(data["domain"].clone()).unwrap();
566        assert!(domain[1] > domain[0], "domain {domain:?}");
567    }
568
569    fn rebuild_curve(data: &serde_json::Value) -> NurbsCurve {
570        let degree = data["degree"].as_u64().unwrap() as usize;
571        let knots: Vec<f64> = serde_json::from_value(data["knots"].clone()).unwrap();
572        let weights: Vec<f64> = serde_json::from_value(data["weights"].clone()).unwrap();
573        let cps: Vec<Point3> =
574            serde_json::from_value::<Vec<[f64; 3]>>(data["controlPoints"].clone())
575                .unwrap()
576                .into_iter()
577                .map(|c| Point3::new(c[0], c[1], c[2]))
578                .collect();
579        NurbsCurve::new(degree, knots, cps, weights).unwrap()
580    }
581
582    fn extract(k: &mut BrepKernel, handle: u32) -> serde_json::Value {
583        let before = entity_counts(k);
584        let data = dispatch_ok(k, "getNurbsCurveData", serde_json::json!({"edge": handle}));
585        assert_eq!(before, entity_counts(k), "query must not mutate topology");
586        data
587    }
588
589    #[test]
590    fn interpolated_curve_data_never_null() {
591        let mut k = BrepKernel::new();
592        let points = vec![
593            Point3::new(0.0, 0.0, 0.0),
594            Point3::new(1.0, 2.0, 0.5),
595            Point3::new(3.0, 1.5, 1.0),
596            Point3::new(5.0, 0.0, 0.0),
597        ];
598        let curve = brepkit_math::nurbs::fitting::interpolate(&points, 3).unwrap();
599        let eid = make_nurbs_edge_from_curve(k.topo_mut(), &curve, TOL);
600        let handle = edge_id_to_u32(eid);
601
602        let data = extract(&mut k, handle);
603        assert_complete_bspline(&data, 3);
604        assert!(!data["rational"].as_bool().unwrap(), "plain interpolation");
605
606        let rebuilt = rebuild_curve(&data);
607        let (a, b) = rebuilt.domain();
608        for i in 0..=16 {
609            let t = a + (b - a) * (f64::from(i) / 16.0);
610            let d = (rebuilt.evaluate(t) - curve.evaluate(t)).length();
611            assert!(d < 1e-9, "round-trip mismatch {d} at t={t}");
612        }
613    }
614
615    #[test]
616    fn approximated_curve_data_never_null() {
617        let mut k = BrepKernel::new();
618        let points: Vec<Point3> = (0..8)
619            .map(|i| {
620                let t = f64::from(i) / 7.0;
621                Point3::new(t * 6.0, (t * std::f64::consts::PI).sin() * 2.0, t)
622            })
623            .collect();
624        let curve = brepkit_math::nurbs::fitting::approximate(&points, 3, 5).unwrap();
625        let eid = make_nurbs_edge_from_curve(k.topo_mut(), &curve, TOL);
626        let handle = edge_id_to_u32(eid);
627
628        let data = extract(&mut k, handle);
629        assert_complete_bspline(&data, 3);
630        let cps: Vec<[f64; 3]> = serde_json::from_value(data["controlPoints"].clone()).unwrap();
631        assert_eq!(cps.len(), 5, "poles match requested control-point count");
632    }
633
634    #[test]
635    fn lspia_curve_data_never_null() {
636        let mut k = BrepKernel::new();
637        let points: Vec<Point3> = (0..10)
638            .map(|i| {
639                let t = f64::from(i) / 9.0;
640                Point3::new(t * 4.0, (t * 6.0).cos(), t * t)
641            })
642            .collect();
643        let curve =
644            brepkit_math::nurbs::fitting::approximate_lspia(&points, 3, 6, 1e-6, 50).unwrap();
645        let eid = make_nurbs_edge_from_curve(k.topo_mut(), &curve, TOL);
646        let handle = edge_id_to_u32(eid);
647
648        let data = extract(&mut k, handle);
649        assert_complete_bspline(&data, 3);
650    }
651
652    #[test]
653    fn two_point_interpolation_degree_clamped() {
654        let mut k = BrepKernel::new();
655        let points = vec![Point3::new(0.0, 0.0, 0.0), Point3::new(2.0, 3.0, 1.0)];
656        let curve = brepkit_math::nurbs::fitting::interpolate(&points, 3).unwrap();
657        let eid = make_nurbs_edge_from_curve(k.topo_mut(), &curve, TOL);
658        let handle = edge_id_to_u32(eid);
659
660        let data = extract(&mut k, handle);
661        assert_complete_bspline(&data, 1);
662        let cps: Vec<[f64; 3]> = serde_json::from_value(data["controlPoints"].clone()).unwrap();
663        assert_eq!(cps.len(), 2);
664        let knots: Vec<f64> = serde_json::from_value(data["knots"].clone()).unwrap();
665        assert_eq!(knots, vec![0.0, 0.0, 1.0, 1.0]);
666    }
667
668    #[test]
669    fn fitted_straight_curve_keeps_explicit_poles() {
670        let mut k = BrepKernel::new();
671        let points: Vec<Point3> = (0..5)
672            .map(|i| {
673                let t = f64::from(i) / 4.0;
674                Point3::new(t * 10.0, t * 10.0, 0.0)
675            })
676            .collect();
677        let curve = brepkit_math::nurbs::fitting::interpolate(&points, 3).unwrap();
678        let eid = make_nurbs_edge_from_curve(k.topo_mut(), &curve, TOL);
679        let handle = edge_id_to_u32(eid);
680
681        let data = extract(&mut k, handle);
682        assert_complete_bspline(&data, 3);
683        let cps: Vec<[f64; 3]> = serde_json::from_value(data["controlPoints"].clone()).unwrap();
684        assert!(
685            cps.len() >= 2,
686            "fitted curve not collapsed to analytic form"
687        );
688    }
689
690    #[test]
691    fn circle_edge_extracts_rational_quadratic() {
692        let mut k = BrepKernel::new();
693        let radius = 2.5;
694        let circle = brepkit_math::curves::Circle3D::new(
695            Point3::new(0.0, 0.0, 0.0),
696            Vec3::new(0.0, 0.0, 1.0),
697            radius,
698        )
699        .unwrap();
700        let start = Point3::new(radius, 0.0, 0.0);
701        let v = k.topo_mut().add_vertex(Vertex::new(start, TOL));
702        let eid = k.topo_mut().add_edge(Edge::new(
703            v,
704            v,
705            brepkit_topology::edge::EdgeCurve::Circle(circle),
706        ));
707        let handle = edge_id_to_u32(eid);
708
709        let data = dispatch_ok(
710            &mut k,
711            "getNurbsCurveData",
712            serde_json::json!({"edge": handle}),
713        );
714        assert_eq!(data["degree"].as_u64().unwrap(), 2);
715        assert!(data["rational"].as_bool().unwrap());
716
717        // Reconstruct and sample: every point at distance `radius` from center.
718        let out_cps: Vec<Point3> =
719            serde_json::from_value::<Vec<[f64; 3]>>(data["controlPoints"].clone())
720                .unwrap()
721                .into_iter()
722                .map(|c| Point3::new(c[0], c[1], c[2]))
723                .collect();
724        let out_knots: Vec<f64> = serde_json::from_value(data["knots"].clone()).unwrap();
725        let out_w: Vec<f64> = serde_json::from_value(data["weights"].clone()).unwrap();
726        let degree = data["degree"].as_u64().unwrap() as usize;
727        let rebuilt = NurbsCurve::new(degree, out_knots, out_cps, out_w).unwrap();
728        let (a, b) = rebuilt.domain();
729        for i in 0..=16 {
730            let t = a + (b - a) * (i as f64 / 16.0);
731            let p = rebuilt.evaluate(t);
732            let r = (p.x() * p.x() + p.y() * p.y()).sqrt();
733            assert!((r - radius).abs() < 1e-9, "sample r={r} at t={t}");
734        }
735    }
736
737    #[test]
738    fn surface_data_round_trips_nurbs_face() {
739        let mut k = BrepKernel::new();
740        let mut grid = Vec::new();
741        for i in 0..4 {
742            let mut row = Vec::new();
743            for j in 0..4 {
744                let x = i as f64;
745                let y = j as f64;
746                let z = (x * y).sin() * 0.3;
747                row.push(Point3::new(x, y, z));
748            }
749            grid.push(row);
750        }
751        let surface = interpolate_surface(&grid, 3, 3).unwrap();
752        let fid = make_nurbs_face(k.topo_mut(), surface.clone(), TOL).unwrap();
753        let handle = face_id_to_u32(fid);
754
755        let data = dispatch_ok(
756            &mut k,
757            "getNurbsSurfaceData",
758            serde_json::json!({"face": handle}),
759        );
760
761        let du = data["degreeU"].as_u64().unwrap() as usize;
762        let dv = data["degreeV"].as_u64().unwrap() as usize;
763        let knots_u: Vec<f64> = serde_json::from_value(data["knotsU"].clone()).unwrap();
764        let knots_v: Vec<f64> = serde_json::from_value(data["knotsV"].clone()).unwrap();
765        let cps: Vec<Vec<[f64; 3]>> =
766            serde_json::from_value(data["controlPoints"].clone()).unwrap();
767        let weights: Vec<Vec<f64>> = serde_json::from_value(data["weights"].clone()).unwrap();
768
769        let nu = cps.len();
770        let nv = cps[0].len();
771        assert!(cps.iter().all(|row| row.len() == nv), "grid is rectangular");
772        assert_eq!(knots_u.len(), nu + du + 1);
773        assert_eq!(knots_v.len(), nv + dv + 1);
774        assert_eq!(weights.len(), nu);
775        assert!(weights.iter().all(|r| r.len() == nv));
776        assert!(knots_u.windows(2).all(|w| w[1] >= w[0]));
777        assert!(knots_v.windows(2).all(|w| w[1] >= w[0]));
778
779        // Round-trip evaluation against the source surface.
780        let rebuilt = NurbsSurface::new(
781            du,
782            dv,
783            knots_u,
784            knots_v,
785            cps.iter()
786                .map(|row| row.iter().map(|c| Point3::new(c[0], c[1], c[2])).collect())
787                .collect(),
788            weights,
789        )
790        .unwrap();
791        let (ua, ub) = surface.domain_u();
792        let (va, vb) = surface.domain_v();
793        for i in 0..=4 {
794            for j in 0..=4 {
795                let u = ua + (ub - ua) * (i as f64 / 4.0);
796                let v = va + (vb - va) * (j as f64 / 4.0);
797                let p0 = surface.evaluate(u, v);
798                let p1 = rebuilt.evaluate(u, v);
799                let d = (p1 - p0).length();
800                assert!(d < 1e-9, "round-trip mismatch {d} at ({u},{v})");
801            }
802        }
803    }
804
805    #[test]
806    fn planar_face_extracts_unit_degree1_grid() {
807        let mut k = BrepKernel::new();
808        let solid = brepkit_operations::primitives::make_box(k.topo_mut(), 4.0, 6.0, 2.0).unwrap();
809        let faces = brepkit_topology::explorer::solid_faces(&k.topo, solid).unwrap();
810        let handle = face_id_to_u32(faces[0]);
811
812        let data = dispatch_ok(
813            &mut k,
814            "getNurbsSurfaceData",
815            serde_json::json!({"face": handle}),
816        );
817        assert_eq!(data["degreeU"].as_u64().unwrap(), 1);
818        assert_eq!(data["degreeV"].as_u64().unwrap(), 1);
819        assert!(!data["rational"].as_bool().unwrap());
820        let cps: Vec<Vec<[f64; 3]>> =
821            serde_json::from_value(data["controlPoints"].clone()).unwrap();
822        assert_eq!(cps.len(), 2);
823        assert!(cps.iter().all(|row| row.len() == 2));
824        let weights: Vec<Vec<f64>> = serde_json::from_value(data["weights"].clone()).unwrap();
825        assert!(weights.iter().flatten().all(|&w| (w - 1.0).abs() < 1e-12));
826    }
827
828    #[test]
829    fn cylinder_cap_face_extracts_unit_degree1_grid() {
830        let mut k = BrepKernel::new();
831        let radius = 3.0;
832        let height = 5.0;
833        let solid =
834            brepkit_operations::primitives::make_cylinder(k.topo_mut(), radius, height).unwrap();
835        let faces = brepkit_topology::explorer::solid_faces(&k.topo, solid).unwrap();
836        let cap = faces
837            .iter()
838            .copied()
839            .find(|&f| {
840                matches!(
841                    k.topo.face(f).unwrap().surface(),
842                    brepkit_topology::face::FaceSurface::Plane { .. }
843                )
844            })
845            .unwrap();
846        let handle = face_id_to_u32(cap);
847
848        let data = dispatch_ok(
849            &mut k,
850            "getNurbsSurfaceData",
851            serde_json::json!({"face": handle}),
852        );
853        assert_eq!(data["degreeU"].as_u64().unwrap(), 1);
854        assert_eq!(data["degreeV"].as_u64().unwrap(), 1);
855        assert!(!data["rational"].as_bool().unwrap());
856
857        let cps: Vec<Vec<[f64; 3]>> =
858            serde_json::from_value(data["controlPoints"].clone()).unwrap();
859        assert_eq!(cps.len(), 2);
860        assert!(cps.iter().all(|row| row.len() == 2));
861
862        // The 2x2 corner grid must enclose the cap disk: the half-diagonal
863        // extent in plane must be at least the radius in each direction.
864        let xs: Vec<f64> = cps.iter().flatten().map(|c| c[0]).collect();
865        let ys: Vec<f64> = cps.iter().flatten().map(|c| c[1]).collect();
866        let dx = xs.iter().copied().fold(f64::NEG_INFINITY, f64::max)
867            - xs.iter().copied().fold(f64::INFINITY, f64::min);
868        let dy = ys.iter().copied().fold(f64::NEG_INFINITY, f64::max)
869            - ys.iter().copied().fold(f64::INFINITY, f64::min);
870        assert!(dx >= 2.0 * radius - 1e-9, "cap u-extent {dx}");
871        assert!(dy >= 2.0 * radius - 1e-9, "cap v-extent {dy}");
872    }
873
874    fn first_analytic_face(k: &BrepKernel, solid: brepkit_topology::solid::SolidId) -> u32 {
875        let faces = brepkit_topology::explorer::solid_faces(&k.topo, solid).unwrap();
876        face_id_to_u32(faces[0])
877    }
878
879    #[test]
880    fn parity_planar_face_returns_null() {
881        let mut k = BrepKernel::new();
882        let solid = brepkit_operations::primitives::make_box(k.topo_mut(), 4.0, 6.0, 2.0).unwrap();
883        let handle = first_analytic_face(&k, solid);
884
885        let before = entity_counts(&k);
886        let data = dispatch_ok(
887            &mut k,
888            "getNurbsSurfaceDataParity",
889            serde_json::json!({"face": handle}),
890        );
891        assert_eq!(before, entity_counts(&k), "query must not mutate topology");
892        assert!(data.is_null(), "planar face must yield null, got {data}");
893    }
894
895    #[test]
896    fn parity_analytic_faces_return_null() {
897        let mut k = BrepKernel::new();
898        let cyl = brepkit_operations::primitives::make_cylinder(k.topo_mut(), 3.0, 5.0).unwrap();
899        let cone = brepkit_operations::primitives::make_cone(k.topo_mut(), 3.0, 1.0, 5.0).unwrap();
900        let sphere = brepkit_operations::primitives::make_sphere(k.topo_mut(), 2.0, 16).unwrap();
901        let torus = brepkit_operations::primitives::make_torus(k.topo_mut(), 4.0, 1.0, 16).unwrap();
902
903        for solid in [cyl, cone, sphere, torus] {
904            let faces = brepkit_topology::explorer::solid_faces(&k.topo, solid).unwrap();
905            for fid in faces {
906                let handle = face_id_to_u32(fid);
907                let data = dispatch_ok(
908                    &mut k,
909                    "getNurbsSurfaceDataParity",
910                    serde_json::json!({"face": handle}),
911                );
912                assert!(
913                    data.is_null(),
914                    "analytic face {handle} must yield null, got {data}"
915                );
916            }
917        }
918    }
919
920    #[test]
921    fn parity_nurbs_face_extracts_full_data() {
922        let mut k = BrepKernel::new();
923        let mut grid = Vec::new();
924        for i in 0..4 {
925            let mut row = Vec::new();
926            for j in 0..4 {
927                let x = i as f64;
928                let y = j as f64;
929                let z = (x * y).sin() * 0.3;
930                row.push(Point3::new(x, y, z));
931            }
932            grid.push(row);
933        }
934        let surface = interpolate_surface(&grid, 3, 3).unwrap();
935        let fid = make_nurbs_face(k.topo_mut(), surface.clone(), TOL).unwrap();
936        let handle = face_id_to_u32(fid);
937
938        let before = entity_counts(&k);
939        let data = dispatch_ok(
940            &mut k,
941            "getNurbsSurfaceDataParity",
942            serde_json::json!({"face": handle}),
943        );
944        assert_eq!(before, entity_counts(&k), "query must not mutate topology");
945        assert!(!data.is_null());
946
947        let du = data["degreeU"].as_u64().unwrap() as usize;
948        let dv = data["degreeV"].as_u64().unwrap() as usize;
949        let nb_u = data["nbPolesU"].as_u64().unwrap() as usize;
950        let nb_v = data["nbPolesV"].as_u64().unwrap() as usize;
951        let poles: Vec<Vec<[f64; 3]>> = serde_json::from_value(data["poles"].clone()).unwrap();
952        let weights: Vec<Vec<f64>> = serde_json::from_value(data["weights"].clone()).unwrap();
953        let knots_u: Vec<f64> = serde_json::from_value(data["knotsU"].clone()).unwrap();
954        let knots_v: Vec<f64> = serde_json::from_value(data["knotsV"].clone()).unwrap();
955        let mult_u: Vec<u32> = serde_json::from_value(data["multiplicitiesU"].clone()).unwrap();
956        let mult_v: Vec<u32> = serde_json::from_value(data["multiplicitiesV"].clone()).unwrap();
957
958        assert_eq!(poles.len(), nb_u);
959        assert!(poles.iter().all(|r| r.len() == nb_v), "rectangular grid");
960        assert_eq!(weights.len(), nb_u);
961        assert!(weights.iter().all(|r| r.len() == nb_v));
962        assert!(!data["isRational"].as_bool().unwrap());
963
964        assert_eq!(knots_u.len(), mult_u.len());
965        assert_eq!(knots_v.len(), mult_v.len());
966        assert!(
967            knots_u.windows(2).all(|w| w[1] > w[0]),
968            "strictly increasing"
969        );
970        assert!(
971            knots_v.windows(2).all(|w| w[1] > w[0]),
972            "strictly increasing"
973        );
974        assert_eq!(mult_u.iter().sum::<u32>() as usize, nb_u + du + 1);
975        assert_eq!(mult_v.iter().sum::<u32>() as usize, nb_v + dv + 1);
976
977        let flat_u: Vec<f64> = knots_u
978            .iter()
979            .zip(&mult_u)
980            .flat_map(|(&v, &m)| std::iter::repeat_n(v, m as usize))
981            .collect();
982        let flat_v: Vec<f64> = knots_v
983            .iter()
984            .zip(&mult_v)
985            .flat_map(|(&v, &m)| std::iter::repeat_n(v, m as usize))
986            .collect();
987
988        let rebuilt = NurbsSurface::new(
989            du,
990            dv,
991            flat_u,
992            flat_v,
993            poles
994                .iter()
995                .map(|row| row.iter().map(|c| Point3::new(c[0], c[1], c[2])).collect())
996                .collect(),
997            weights,
998        )
999        .unwrap();
1000        let (ua, ub) = surface.domain_u();
1001        let (va, vb) = surface.domain_v();
1002        for i in 0..=4 {
1003            for j in 0..=4 {
1004                let u = ua + (ub - ua) * (i as f64 / 4.0);
1005                let v = va + (vb - va) * (j as f64 / 4.0);
1006                let d = (rebuilt.evaluate(u, v) - surface.evaluate(u, v)).length();
1007                assert!(d < 1e-9, "round-trip mismatch {d} at ({u},{v})");
1008            }
1009        }
1010    }
1011
1012    #[test]
1013    fn cylinder_side_face_extracts_periodic_rational() {
1014        let mut k = BrepKernel::new();
1015        let radius = 3.0;
1016        let height = 5.0;
1017        let solid =
1018            brepkit_operations::primitives::make_cylinder(k.topo_mut(), radius, height).unwrap();
1019        let faces = brepkit_topology::explorer::solid_faces(&k.topo, solid).unwrap();
1020        let side = faces
1021            .iter()
1022            .copied()
1023            .find(|&f| {
1024                matches!(
1025                    k.topo.face(f).unwrap().surface(),
1026                    brepkit_topology::face::FaceSurface::Cylinder(_)
1027                )
1028            })
1029            .unwrap();
1030        let handle = face_id_to_u32(side);
1031
1032        let data = dispatch_ok(
1033            &mut k,
1034            "getNurbsSurfaceData",
1035            serde_json::json!({"face": handle}),
1036        );
1037        assert_eq!(data["degreeU"].as_u64().unwrap(), 2);
1038        assert_eq!(data["degreeV"].as_u64().unwrap(), 1);
1039        assert!(data["rational"].as_bool().unwrap());
1040        assert!(data["periodicU"].as_bool().unwrap());
1041
1042        let cps: Vec<Vec<[f64; 3]>> =
1043            serde_json::from_value(data["controlPoints"].clone()).unwrap();
1044
1045        // Control points span the face's axial extent [0, height] in z even
1046        // though the v-knot domain is normalized to [0, 1].
1047        let z_min = cps
1048            .iter()
1049            .flatten()
1050            .map(|c| c[2])
1051            .fold(f64::INFINITY, f64::min);
1052        let z_max = cps
1053            .iter()
1054            .flatten()
1055            .map(|c| c[2])
1056            .fold(f64::NEG_INFINITY, f64::max);
1057        assert!((z_min - 0.0).abs() < 1e-9, "axial start z {z_min}");
1058        assert!((z_max - height).abs() < 1e-9, "axial end z {z_max}");
1059
1060        let knots_u: Vec<f64> = serde_json::from_value(data["knotsU"].clone()).unwrap();
1061        let knots_v: Vec<f64> = serde_json::from_value(data["knotsV"].clone()).unwrap();
1062        let weights: Vec<Vec<f64>> = serde_json::from_value(data["weights"].clone()).unwrap();
1063        let rebuilt = NurbsSurface::new(
1064            2,
1065            1,
1066            knots_u,
1067            knots_v,
1068            cps.iter()
1069                .map(|row| row.iter().map(|c| Point3::new(c[0], c[1], c[2])).collect())
1070                .collect(),
1071            weights,
1072        )
1073        .unwrap();
1074        let (ua, ub) = rebuilt.domain_u();
1075        let (va, vb) = rebuilt.domain_v();
1076        for i in 0..=8 {
1077            for j in 0..=2 {
1078                let u = ua + (ub - ua) * (i as f64 / 8.0);
1079                let v = va + (vb - va) * (j as f64 / 2.0);
1080                let p = rebuilt.evaluate(u, v);
1081                let r = (p.x() * p.x() + p.y() * p.y()).sqrt();
1082                assert!((r - radius).abs() < 1e-9, "cyl r={r}");
1083            }
1084        }
1085    }
1086}