Skip to main content

oxiphysics_geometry/parametric/
functions.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5use super::types::BezierCurve;
6
7#[inline]
8pub(super) fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
9    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
10}
11#[inline]
12pub(super) fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
13    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
14}
15#[inline]
16pub(super) fn scale3(a: [f64; 3], s: f64) -> [f64; 3] {
17    [a[0] * s, a[1] * s, a[2] * s]
18}
19#[inline]
20pub(super) fn lerp3(a: [f64; 3], b: [f64; 3], t: f64) -> [f64; 3] {
21    add3(scale3(a, 1.0 - t), scale3(b, t))
22}
23#[inline]
24pub(super) fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
25    [
26        a[1] * b[2] - a[2] * b[1],
27        a[2] * b[0] - a[0] * b[2],
28        a[0] * b[1] - a[1] * b[0],
29    ]
30}
31#[inline]
32pub(super) fn normalize3(a: [f64; 3]) -> [f64; 3] {
33    let len = (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt();
34    if len < 1e-12 {
35        [0.0, 0.0, 0.0]
36    } else {
37        scale3(a, 1.0 / len)
38    }
39}
40#[inline]
41pub(super) fn dist3(a: [f64; 3], b: [f64; 3]) -> f64 {
42    let d = sub3(a, b);
43    (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
44}
45#[inline]
46pub(super) fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
47    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
48}
49/// Returns a unit vector perpendicular to `v`.
50pub(super) fn arbitrary_perp(v: [f64; 3]) -> [f64; 3] {
51    let candidates: [[f64; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
52    for &c in &candidates {
53        let proj = dot3(v, c);
54        let perp = sub3(c, scale3(v, proj));
55        let len = dot3(perp, perp).sqrt();
56        if len > 1e-8 {
57            return scale3(perp, 1.0 / len);
58        }
59    }
60    [1.0, 0.0, 0.0]
61}
62/// Approximate a circular arc as a sequence of quadratic Bezier segments.
63///
64/// Returns the control points for `n_segments` quadratic Bezier arcs,
65/// laid out as `[P0, P1, P2, P1', P2', ...]` (each segment shares its last point).
66pub fn bezier_arc_approximation(
67    center: [f64; 3],
68    radius: f64,
69    start_angle: f64,
70    end_angle: f64,
71    n_segments: usize,
72) -> Vec<[f64; 3]> {
73    if n_segments == 0 {
74        return Vec::new();
75    }
76    let total_angle = end_angle - start_angle;
77    let seg_angle = total_angle / n_segments as f64;
78    let mut pts = Vec::with_capacity(n_segments * 2 + 1);
79    for k in 0..n_segments {
80        let a0 = start_angle + k as f64 * seg_angle;
81        let a1 = a0 + seg_angle;
82        let a_mid = (a0 + a1) / 2.0;
83        let p0 = [
84            center[0] + radius * a0.cos(),
85            center[1] + radius * a0.sin(),
86            center[2],
87        ];
88        let r_mid = radius / a_mid.cos().max(1e-12) * (seg_angle / 2.0).cos();
89        let _ = r_mid;
90        let t = (seg_angle / 2.0).tan();
91        let p1 = [
92            center[0] + radius * a_mid.cos() - radius * t * a_mid.sin(),
93            center[1] + radius * a_mid.sin() + radius * t * a_mid.cos(),
94            center[2],
95        ];
96        if k == 0 {
97            pts.push(p0);
98        }
99        pts.push(p1);
100        let p2 = [
101            center[0] + radius * a1.cos(),
102            center[1] + radius * a1.sin(),
103            center[2],
104        ];
105        pts.push(p2);
106    }
107    pts
108}
109/// Sample a curve uniformly in parameter space, returning `n` points.
110///
111/// `eval` maps parameter `t ∈ [0, 1]` to a 3-D point.
112/// Returns `n` points at parameters `0, 1/(n-1), ..., 1` (arc-length
113/// parameterization is approximated by uniform parameter sampling).
114pub fn sample_curve_uniform(eval: &dyn Fn(f64) -> [f64; 3], n: usize) -> Vec<[f64; 3]> {
115    if n == 0 {
116        return Vec::new();
117    }
118    if n == 1 {
119        return vec![eval(0.0)];
120    }
121    (0..n).map(|i| eval(i as f64 / (n - 1) as f64)).collect()
122}
123/// Approximate the curvature κ of a `BezierCurve` at parameter `t`.
124///
125/// Uses the formula κ = |C'(t) × C''(t)| / |C'(t)|³.
126pub fn bezier_curvature(curve: &BezierCurve, t: f64) -> f64 {
127    let eps = 1e-5_f64;
128    let d1 = curve.derivative(t);
129    let t0 = (t - eps).max(0.0);
130    let t1 = (t + eps).min(1.0);
131    let d1_0 = curve.derivative(t0);
132    let d1_1 = curve.derivative(t1);
133    let d2 = scale3(sub3(d1_1, d1_0), 1.0 / (t1 - t0));
134    let cross_mag = {
135        let c = cross3(d1, d2);
136        (c[0] * c[0] + c[1] * c[1] + c[2] * c[2]).sqrt()
137    };
138    let d1_mag = (d1[0] * d1[0] + d1[1] * d1[1] + d1[2] * d1[2]).sqrt();
139    if d1_mag < 1e-12 {
140        return 0.0;
141    }
142    cross_mag / (d1_mag * d1_mag * d1_mag)
143}
144/// Approximate the torsion Ï„ of a `BezierCurve` at parameter `t`.
145///
146/// Uses the scalar triple product of C', C'', C''' divided by |C' × C''|².
147pub fn bezier_torsion(curve: &BezierCurve, t: f64) -> f64 {
148    let eps = 1e-5_f64;
149    let d1 = curve.derivative(t);
150    let t0 = (t - eps).max(0.0);
151    let t1 = (t + eps).min(1.0);
152    let d1_0 = curve.derivative(t0);
153    let d1_1 = curve.derivative(t1);
154    let d2 = scale3(sub3(d1_1, d1_0), 1.0 / (t1 - t0));
155    let t00 = (t - 2.0 * eps).max(0.0);
156    let t11 = (t + 2.0 * eps).min(1.0);
157    let d1_00 = curve.derivative(t00);
158    let d1_11 = curve.derivative(t11);
159    let d2_0 = scale3(sub3(d1, d1_00), 1.0 / eps);
160    let d2_1 = scale3(sub3(d1_11, d1), 1.0 / eps);
161    let d3 = scale3(sub3(d2_1, d2_0), 1.0 / (2.0 * eps));
162    let cross = cross3(d1, d2);
163    let cross_mag2 = cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2];
164    if cross_mag2 < 1e-20 {
165        return 0.0;
166    }
167    let dot_val = cross[0] * d3[0] + cross[1] * d3[1] + cross[2] * d3[2];
168    dot_val / cross_mag2
169}
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::bspline::BsplineSurface;
174    use crate::bspline::KnotVector;
175    use crate::bspline::NurbsCurve;
176    use crate::bspline::NurbsSurface;
177    use crate::parametric::Arc;
178    use crate::parametric::BSpline;
179    use crate::parametric::BezierSurface;
180    use crate::parametric::CatmullRomSpline;
181    use crate::parametric::FrenetFrame;
182    use crate::parametric::HermiteCurve;
183    use crate::parametric::LoftSurface;
184    use crate::parametric::RevolutionSurface;
185    use crate::parametric::SweptSurface;
186    use crate::parametric::TangentFrame;
187    use crate::parametric::TubeGeometry;
188    #[test]
189    fn bezier_degree1_endpoints() {
190        let p0 = [0.0, 0.0, 0.0];
191        let p1 = [4.0, 2.0, 0.0];
192        let curve = BezierCurve::new(vec![p0, p1]);
193        let at0 = curve.evaluate(0.0);
194        let at1 = curve.evaluate(1.0);
195        let at_half = curve.evaluate(0.5);
196        for k in 0..3 {
197            assert!((at0[k] - p0[k]).abs() < 1e-10);
198            assert!((at1[k] - p1[k]).abs() < 1e-10);
199            assert!((at_half[k] - (p0[k] + p1[k]) / 2.0).abs() < 1e-10);
200        }
201    }
202    #[test]
203    fn bezier_degree2_midpoint() {
204        let p0 = [0.0, 0.0, 0.0];
205        let p1 = [1.0, 2.0, 0.0];
206        let p2 = [2.0, 0.0, 0.0];
207        let curve = BezierCurve::new(vec![p0, p1, p2]);
208        let expected = [1.0, 1.0, 0.0];
209        let result = curve.evaluate(0.5);
210        for k in 0..3 {
211            assert!(
212                (result[k] - expected[k]).abs() < 1e-10,
213                "component {k}: got {}",
214                result[k]
215            );
216        }
217    }
218    #[test]
219    fn bezier_arc_length_line() {
220        let curve = BezierCurve::new(vec![[0.0, 0.0, 0.0], [3.0, 0.0, 0.0]]);
221        let len = curve.arc_length(1000);
222        assert!((len - 3.0).abs() < 1e-6, "expected ≈3.0, got {len}");
223    }
224    #[test]
225    fn bezier_surface_flat_normal() {
226        let grid: Vec<Vec<[f64; 3]>> = (0..4)
227            .map(|i| {
228                (0..4)
229                    .map(|j| [i as f64 / 3.0, j as f64 / 3.0, 0.0])
230                    .collect()
231            })
232            .collect();
233        let surf = BezierSurface::new_bicubic(grid);
234        let n = surf.normal(0.5, 0.5);
235        assert!(n[0].abs() < 1e-4, "nx={}", n[0]);
236        assert!(n[1].abs() < 1e-4, "ny={}", n[1]);
237        assert!((n[2].abs() - 1.0).abs() < 1e-4, "nz={}", n[2]);
238    }
239    #[test]
240    fn bspline_clamped_endpoints() {
241        let pts = vec![
242            [0.0, 0.0, 0.0],
243            [1.0, 2.0, 0.0],
244            [3.0, 0.0, 1.0],
245            [4.0, 1.0, 0.0],
246        ];
247        let spline = BSpline::clamped_uniform(3, pts.clone());
248        let at0 = spline.eval(0.0);
249        let at1 = spline.eval(1.0);
250        for k in 0..3 {
251            assert!(
252                (at0[k] - pts[0][k]).abs() < 1e-10,
253                "start: axis {k}: {}",
254                at0[k]
255            );
256            assert!(
257                (at1[k] - pts[pts.len() - 1][k]).abs() < 1e-10,
258                "end: axis {k}: {}",
259                at1[k]
260            );
261        }
262    }
263    #[test]
264    fn bspline_knot_insert_preserves_curve() {
265        let pts = vec![
266            [0.0, 0.0, 0.0],
267            [1.0, 1.0, 0.0],
268            [2.0, 0.0, 0.0],
269            [3.0, 1.0, 0.0],
270        ];
271        let spline = BSpline::clamped_uniform(2, pts);
272        let new_spline = spline.insert_knot(0.5);
273        for &t in &[0.0, 0.25, 0.5, 0.75, 1.0] {
274            let p_orig = spline.eval(t);
275            let p_new = new_spline.eval(t);
276            for k in 0..3 {
277                assert!(
278                    (p_orig[k] - p_new[k]).abs() < 1e-9,
279                    "t={t} axis {k}: orig={} new={}",
280                    p_orig[k],
281                    p_new[k]
282                );
283            }
284        }
285    }
286    #[test]
287    fn bspline_degree1_is_polyline() {
288        let pts = vec![
289            [0.0, 0.0, 0.0],
290            [1.0, 0.0, 0.0],
291            [1.0, 1.0, 0.0],
292            [2.0, 1.0, 0.0],
293        ];
294        let spline = BSpline::clamped_uniform(1, pts.clone());
295        let p0 = spline.eval(0.0);
296        let p1 = spline.eval(1.0);
297        for k in 0..3 {
298            assert!((p0[k] - pts[0][k]).abs() < 1e-9);
299            assert!((p1[k] - pts[pts.len() - 1][k]).abs() < 1e-9);
300        }
301    }
302    #[test]
303    fn hermite_interpolates_endpoints() {
304        let pts = vec![[0.0, 0.0, 0.0], [2.0, 3.0, 0.0], [4.0, 0.0, 0.0]];
305        let tgts = vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]];
306        let curve = HermiteCurve::new(pts.clone(), tgts);
307        for (i, &pt) in pts.iter().enumerate() {
308            let evaled = curve.eval(i as f64);
309            for k in 0..3 {
310                assert!(
311                    (evaled[k] - pt[k]).abs() < 1e-10,
312                    "seg {i} axis {k}: {}",
313                    evaled[k]
314                );
315            }
316        }
317    }
318    #[test]
319    fn hermite_c0_continuity() {
320        let pts = vec![
321            [0.0, 0.0, 0.0],
322            [1.0, 1.0, 0.0],
323            [2.0, 0.0, 0.0],
324            [3.0, 1.0, 0.0],
325        ];
326        let tgts = vec![
327            [0.5, 0.5, 0.0],
328            [0.5, -0.5, 0.0],
329            [0.5, 0.5, 0.0],
330            [0.5, -0.5, 0.0],
331        ];
332        let curve = HermiteCurve::new(pts, tgts);
333        for knot in [1.0, 2.0] {
334            let eps = 1e-8;
335            let left = curve.eval(knot - eps);
336            let right = curve.eval(knot + eps);
337            for k in 0..3 {
338                assert!((left[k] - right[k]).abs() < 1e-5, "C0 at {knot} axis {k}");
339            }
340        }
341    }
342    #[test]
343    fn hermite_derivative_at_knot() {
344        let pts = vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]];
345        let tgts = vec![[3.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
346        let curve = HermiteCurve::new(pts, tgts.clone());
347        let deriv = curve.derivative(0.0);
348        for k in 0..3 {
349            assert!(
350                (deriv[k] - tgts[0][k]).abs() < 1e-10,
351                "axis {k}: {}",
352                deriv[k]
353            );
354        }
355    }
356    #[test]
357    fn catmull_rom_interpolates_interior() {
358        let pts = vec![
359            [0.0, 0.0, 0.0],
360            [1.0, 1.0, 0.0],
361            [2.0, 0.0, 0.0],
362            [3.0, 1.0, 0.0],
363        ];
364        let spline = CatmullRomSpline::new(pts.clone());
365        for (i, &pt) in pts.iter().enumerate() {
366            let t = spline.knots[i];
367            let evaled = spline.eval(t);
368            for k in 0..3 {
369                assert!(
370                    (evaled[k] - pt[k]).abs() < 1e-6,
371                    "point {i} axis {k}: got {} expected {}",
372                    evaled[k],
373                    pt[k]
374                );
375            }
376        }
377    }
378    #[test]
379    fn catmull_rom_range_clamp() {
380        let pts = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]];
381        let spline = CatmullRomSpline::new(pts);
382        let p_neg = spline.eval(-0.1);
383        let p_over = spline.eval(1.5);
384        for k in 0..3 {
385            assert!(p_neg[k].is_finite());
386            assert!(p_over[k].is_finite());
387        }
388    }
389    #[test]
390    fn frenet_tangent_is_unit() {
391        let circle_pts: Vec<[f64; 3]> = (0..=20)
392            .map(|i| {
393                let t = i as f64 / 20.0 * std::f64::consts::TAU;
394                [t.cos(), t.sin(), 0.0]
395            })
396            .collect();
397        let curve = BezierCurve::new(circle_pts.clone());
398        let frame = FrenetFrame::from_bezier(&curve, 0.5);
399        let len =
400            (frame.tangent[0].powi(2) + frame.tangent[1].powi(2) + frame.tangent[2].powi(2)).sqrt();
401        assert!((len - 1.0).abs() < 1e-6, "tangent not unit: len={len}");
402    }
403    #[test]
404    fn frenet_triad_orthonormal() {
405        let pts = vec![
406            [0.0, 0.0, 0.0],
407            [1.0, 1.0, 0.0],
408            [2.0, 0.0, 1.0],
409            [3.0, 0.0, 0.0],
410        ];
411        let curve = BezierCurve::new(pts);
412        let frame = FrenetFrame::from_bezier(&curve, 0.5);
413        let t = frame.tangent;
414        let n = frame.normal;
415        let b = frame.binormal;
416        assert!(dot3(t, n).abs() < 1e-6, "T·N = {}", dot3(t, n));
417        assert!(dot3(t, b).abs() < 1e-6, "T·B = {}", dot3(t, b));
418        assert!(dot3(n, b).abs() < 1e-6, "N·B = {}", dot3(n, b));
419        for (name, v) in [("T", t), ("N", n), ("B", b)] {
420            let len = (v[0].powi(2) + v[1].powi(2) + v[2].powi(2)).sqrt();
421            assert!((len - 1.0).abs() < 1e-6, "{name} not unit: {len}");
422        }
423    }
424    #[test]
425    fn tube_vertex_count() {
426        let curve = BezierCurve::new(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]]);
427        let tube = TubeGeometry::from_bezier(&curve, 0.1, 8, 12);
428        assert_eq!(tube.vertices.len(), 8 * 12, "vertex count");
429        assert_eq!(tube.triangles.len(), (8 - 1) * 12 * 2, "triangle count");
430    }
431    #[test]
432    fn tube_vertices_near_spine() {
433        let pts = vec![[0.0, 0.0, 0.0], [3.0, 0.0, 0.0]];
434        let curve = BezierCurve::new(pts);
435        let radius = 0.5;
436        let tube = TubeGeometry::from_bezier(&curve, radius, 10, 8);
437        for v in &tube.vertices {
438            let r = (v[1].powi(2) + v[2].powi(2)).sqrt();
439            assert!(
440                (r - radius).abs() < 1e-8,
441                "vertex [{},{},{}] radius {r} != {radius}",
442                v[0],
443                v[1],
444                v[2]
445            );
446        }
447    }
448    #[test]
449    fn bezier_derivative_line() {
450        let curve = BezierCurve::new(vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]]);
451        for &t in &[0.0, 0.5, 1.0] {
452            let d = curve.derivative(t);
453            assert!((d[0] - 2.0).abs() < 1e-10, "dx at t={t}: {}", d[0]);
454            assert!(d[1].abs() < 1e-10);
455            assert!(d[2].abs() < 1e-10);
456        }
457    }
458    #[test]
459    fn bezier_eval_at_t0_returns_first_control_point() {
460        let p0 = [1.0, 2.0, 3.0];
461        let p1 = [4.0, 5.0, 6.0];
462        let p2 = [7.0, 8.0, 9.0];
463        let curve = BezierCurve::new(vec![p0, p1, p2]);
464        let result = curve.eval(0.0);
465        for k in 0..3 {
466            assert!(
467                (result[k] - p0[k]).abs() < 1e-10,
468                "axis {k}: {} != {}",
469                result[k],
470                p0[k]
471            );
472        }
473    }
474    #[test]
475    fn bezier_eval_at_t1_returns_last_control_point() {
476        let p0 = [1.0, 2.0, 3.0];
477        let p1 = [4.0, 5.0, 6.0];
478        let p2 = [7.0, 8.0, 9.0];
479        let curve = BezierCurve::new(vec![p0, p1, p2]);
480        let result = curve.eval(1.0);
481        for k in 0..3 {
482            assert!(
483                (result[k] - p2[k]).abs() < 1e-10,
484                "axis {k}: {} != {}",
485                result[k],
486                p2[k]
487            );
488        }
489    }
490    #[test]
491    fn bspline_order_clamped() {
492        let pts = vec![
493            [0.0, 0.0, 0.0],
494            [1.0, 2.0, 0.0],
495            [3.0, 0.0, 1.0],
496            [4.0, 1.0, 0.0],
497        ];
498        let spline = BSpline::clamped_uniform(3, pts.clone());
499        let start = spline.eval(0.0);
500        let end = spline.eval(1.0);
501        for k in 0..3 {
502            assert!(
503                (start[k] - pts[0][k]).abs() < 1e-9,
504                "start axis {k}: got {} expected {}",
505                start[k],
506                pts[0][k]
507            );
508            assert!(
509                (end[k] - pts[pts.len() - 1][k]).abs() < 1e-9,
510                "end axis {k}: got {} expected {}",
511                end[k],
512                pts[pts.len() - 1][k]
513            );
514        }
515    }
516    #[test]
517    fn nurbs_surface_eval_non_nan() {
518        let mut control_points: Vec<Vec<[f64; 3]>> = Vec::new();
519        let mut weights: Vec<Vec<f64>> = Vec::new();
520        for i in 0..3 {
521            let mut cp_row = Vec::new();
522            let mut w_row = Vec::new();
523            for j in 0..3 {
524                cp_row.push([i as f64, j as f64, 0.0]);
525                w_row.push(1.0);
526            }
527            control_points.push(cp_row);
528            weights.push(w_row);
529        }
530        let knots = vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
531        let surf = NurbsSurface::new(
532            2,
533            2,
534            KnotVector::new(knots.clone()),
535            KnotVector::new(knots),
536            control_points,
537            weights,
538        );
539        for &u in &[0.0, 0.5, 1.0] {
540            for &v in &[0.0, 0.5, 1.0] {
541                let p = surf.eval(u, v);
542                for &val in &p {
543                    assert!(val.is_finite(), "NurbsSurface eval({u},{v}) returned NaN");
544                }
545            }
546        }
547    }
548    #[test]
549    fn bezier_arc_approximation_points_on_circle() {
550        let center = [0.0, 0.0, 0.0];
551        let radius = 2.0;
552        let pts = bezier_arc_approximation(center, radius, 0.0, std::f64::consts::PI, 4);
553        assert!(!pts.is_empty(), "arc approximation should return points");
554        let first = pts[0];
555        let last = *pts.last().unwrap();
556        let r0 = (first[0].powi(2) + first[1].powi(2)).sqrt();
557        let r1 = (last[0].powi(2) + last[1].powi(2)).sqrt();
558        assert!(
559            (r0 - radius).abs() < 1e-9,
560            "first point not on circle: r={r0}"
561        );
562        assert!(
563            (r1 - radius).abs() < 1e-9,
564            "last point not on circle: r={r1}"
565        );
566    }
567    #[test]
568    fn sample_curve_uniform_count() {
569        let n = 10usize;
570        let pts = sample_curve_uniform(&|t| [t, t * 2.0, 0.0], n);
571        assert_eq!(pts.len(), n);
572        assert!((pts[0][0] - 0.0).abs() < 1e-10);
573        assert!((pts[n - 1][0] - 1.0).abs() < 1e-10);
574    }
575    #[test]
576    fn nurbs_basis_partition_of_unity() {
577        let degree = 3_usize;
578        let knots = vec![0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0];
579        let n_ctrl = 5_usize;
580        for &t in &[0.0, 0.25, 0.5, 0.75] {
581            let sum: f64 = (0..n_ctrl)
582                .map(|i| NurbsCurve::b_spline_basis(i, degree, t, &knots))
583                .sum();
584            assert!(
585                (sum - 1.0).abs() < 1e-10,
586                "partition of unity failed at t={t}: sum={sum}"
587            );
588        }
589        let sum_at_1: f64 = (0..n_ctrl)
590            .map(|i| NurbsCurve::b_spline_basis(i, degree, 1.0, &knots))
591            .sum();
592        assert!(
593            (sum_at_1 - 1.0).abs() < 1e-10,
594            "partition of unity failed at t=1.0: sum={sum_at_1}"
595        );
596    }
597    #[test]
598    fn arc_endpoints_on_circle() {
599        let center = [1.0, 2.0, 0.0];
600        let radius = 3.0;
601        let arc = Arc::in_xy_plane(center, radius, 0.0, std::f64::consts::PI);
602        let p0 = arc.evaluate(0.0);
603        let p1 = arc.evaluate(1.0);
604        let r0 = dist3(p0, center);
605        let r1 = dist3(p1, center);
606        assert!((r0 - radius).abs() < 1e-9, "start not on circle: r={r0}");
607        assert!((r1 - radius).abs() < 1e-9, "end not on circle: r={r1}");
608    }
609    #[test]
610    fn arc_length_half_circle() {
611        let radius = 2.0;
612        let arc = Arc::in_xy_plane([0.0; 3], radius, 0.0, std::f64::consts::PI);
613        let expected = std::f64::consts::PI * radius;
614        let len = arc.arc_length();
615        assert!(
616            (len - expected).abs() < 1e-9,
617            "expected {expected}, got {len}"
618        );
619    }
620    #[test]
621    fn arc_sample_count() {
622        let arc = Arc::in_xy_plane([0.0; 3], 1.0, 0.0, std::f64::consts::TAU);
623        let pts = arc.sample(10);
624        assert_eq!(pts.len(), 10);
625    }
626    #[test]
627    fn arc_tangent_perpendicular_to_radius() {
628        let center = [0.0; 3];
629        let arc = Arc::in_xy_plane(center, 1.0, 0.0, std::f64::consts::TAU);
630        for i in 0..4 {
631            let t = i as f64 / 4.0;
632            let p = arc.evaluate(t);
633            let tan = arc.tangent(t);
634            let radial = sub3(p, center);
635            let d = dot3(radial, tan);
636            assert!(
637                d.abs() < 1e-6,
638                "tangent not perpendicular to radius at t={t}: dot={d}"
639            );
640        }
641    }
642    #[test]
643    fn loft_surface_at_v0_is_curve_a() {
644        let ca = BezierCurve::new(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]);
645        let cb = BezierCurve::new(vec![[0.0, 0.0, 1.0], [1.0, 0.0, 1.0]]);
646        let loft = LoftSurface::new(ca, cb);
647        for &u in &[0.0, 0.5, 1.0] {
648            let p = loft.evaluate(u, 0.0);
649            let expected = loft.curve_a.evaluate(u);
650            for k in 0..3 {
651                assert!(
652                    (p[k] - expected[k]).abs() < 1e-9,
653                    "v=0 loft should equal curve_a"
654                );
655            }
656        }
657    }
658    #[test]
659    fn loft_surface_at_v1_is_curve_b() {
660        let ca = BezierCurve::new(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]);
661        let cb = BezierCurve::new(vec![[0.0, 1.0, 0.0], [1.0, 1.0, 0.0]]);
662        let loft = LoftSurface::new(ca, cb);
663        for &u in &[0.0, 0.5, 1.0] {
664            let p = loft.evaluate(u, 1.0);
665            let expected = loft.curve_b.evaluate(u);
666            for k in 0..3 {
667                assert!(
668                    (p[k] - expected[k]).abs() < 1e-9,
669                    "v=1 loft should equal curve_b"
670                );
671            }
672        }
673    }
674    #[test]
675    fn loft_surface_normal_finite() {
676        let ca = BezierCurve::new(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]);
677        let cb = BezierCurve::new(vec![[0.0, 0.0, 1.0], [1.0, 0.0, 1.0]]);
678        let loft = LoftSurface::new(ca, cb);
679        let n = loft.normal(0.5, 0.5);
680        for &v in &n {
681            assert!(v.is_finite(), "loft normal must be finite: {v}");
682        }
683    }
684    #[test]
685    fn loft_sample_grid_dimensions() {
686        let ca = BezierCurve::new(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]);
687        let cb = BezierCurve::new(vec![[0.0, 0.0, 1.0], [1.0, 0.0, 1.0]]);
688        let loft = LoftSurface::new(ca, cb);
689        let grid = loft.sample_grid(4, 5);
690        assert_eq!(grid.len(), 4);
691        assert_eq!(grid[0].len(), 5);
692    }
693    #[test]
694    fn swept_surface_at_u0_is_at_spine_start() {
695        let spine = BezierCurve::new(vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]]);
696        let profile = BezierCurve::new(vec![[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]]);
697        let swept = SweptSurface::new(spine, profile);
698        let p00 = swept.evaluate(0.0, 0.0);
699        for (k, &p00k) in p00.iter().enumerate() {
700            assert!(p00k.abs() < 1e-9, "p00[{k}]={}", p00k);
701        }
702    }
703    #[test]
704    fn swept_surface_normal_is_unit() {
705        let spine = BezierCurve::new(vec![[0.0, 0.0, 0.0], [3.0, 0.0, 0.0]]);
706        let profile = BezierCurve::new(vec![[0.0, 0.0, 0.0], [0.0, 0.0, 1.0]]);
707        let swept = SweptSurface::new(spine, profile);
708        let n = swept.normal(0.5, 0.5);
709        let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
710        assert!(
711            (len - 1.0).abs() < 1e-5 || len < 1e-9,
712            "normal not unit: len={len}"
713        );
714    }
715    #[test]
716    fn revolution_surface_full_circle_radius() {
717        let profile = BezierCurve::new(vec![[2.0, 0.0, 0.0], [2.0, 0.0, 0.0]]);
718        let rev = RevolutionSurface::new(profile);
719        for i in 0..8 {
720            let u = i as f64 / 8.0;
721            let p = rev.evaluate(u, 0.5);
722            let r = (p[0] * p[0] + p[1] * p[1]).sqrt();
723            assert!((r - 2.0).abs() < 1e-9, "radius at u={u}: {r}");
724        }
725    }
726    #[test]
727    fn revolution_surface_sample_grid_dimensions() {
728        let profile = BezierCurve::new(vec![[1.0, 0.0, 0.0], [1.0, 0.0, 2.0]]);
729        let rev = RevolutionSurface::new(profile);
730        let grid = rev.sample_grid(6, 4);
731        assert_eq!(grid.len(), 6);
732        assert_eq!(grid[0].len(), 4);
733    }
734    #[test]
735    fn tangent_frame_bezier_surface_normal_unit() {
736        let grid: Vec<Vec<[f64; 3]>> = (0..4)
737            .map(|i| {
738                (0..4)
739                    .map(|j| [i as f64 / 3.0, j as f64 / 3.0, 0.0])
740                    .collect()
741            })
742            .collect();
743        let surf = BezierSurface::new_bicubic(grid);
744        let frame = TangentFrame::from_bezier_surface(&surf, 0.5, 0.5);
745        let n_len =
746            (frame.normal[0].powi(2) + frame.normal[1].powi(2) + frame.normal[2].powi(2)).sqrt();
747        assert!((n_len - 1.0).abs() < 1e-5, "normal not unit: len={n_len}");
748    }
749    #[test]
750    fn tangent_frame_loft_orthogonality() {
751        let ca = BezierCurve::new(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]);
752        let cb = BezierCurve::new(vec![[0.0, 0.0, 1.0], [1.0, 0.0, 1.0]]);
753        let loft = LoftSurface::new(ca, cb);
754        let frame = TangentFrame::from_loft_surface(&loft, 0.5, 0.5);
755        let d = dot3(frame.tangent_u, frame.tangent_v);
756        assert!(d.abs() < 1e-5, "tangent_u . tangent_v = {d} (should be ~0)");
757    }
758    #[test]
759    fn bspline_derivative_finite() {
760        let pts = vec![
761            [0.0, 0.0, 0.0],
762            [1.0, 1.0, 0.0],
763            [2.0, 0.0, 0.0],
764            [3.0, 1.0, 0.0],
765        ];
766        let spline = BSpline::clamped_uniform(3, pts);
767        for &t in &[0.0, 0.25, 0.5, 0.75, 1.0] {
768            let d = spline.derivative(t);
769            for (k, &dk) in d.iter().enumerate() {
770                assert!(
771                    dk.is_finite(),
772                    "derivative[{k}] not finite at t={t}: {}",
773                    dk
774                );
775            }
776        }
777    }
778    #[test]
779    fn bspline_arc_length_line_approx() {
780        let pts = vec![
781            [0.0, 0.0, 0.0],
782            [1.0, 0.0, 0.0],
783            [2.0, 0.0, 0.0],
784            [4.0, 0.0, 0.0],
785        ];
786        let spline = BSpline::clamped_uniform(1, pts);
787        let len = spline.arc_length(100);
788        assert!(
789            (len - 4.0).abs() < 1e-3,
790            "arc length should be ~4.0, got {len}"
791        );
792    }
793    #[test]
794    fn bezier_curvature_straight_line_is_zero() {
795        let curve = BezierCurve::new(vec![[0.0, 0.0, 0.0], [5.0, 0.0, 0.0]]);
796        let kappa = bezier_curvature(&curve, 0.5);
797        assert!(
798            kappa < 1e-6,
799            "straight line curvature should be 0, got {kappa}"
800        );
801    }
802    #[test]
803    fn bezier_curvature_circle_approx_positive() {
804        let circle_pts: Vec<[f64; 3]> = (0..=8)
805            .map(|i| {
806                let t = i as f64 / 8.0 * std::f64::consts::TAU;
807                [t.cos(), t.sin(), 0.0]
808            })
809            .collect();
810        let curve = BezierCurve::new(circle_pts);
811        let kappa = bezier_curvature(&curve, 0.5);
812        assert!(
813            kappa.is_finite() && kappa >= 0.0,
814            "curvature should be non-negative: {kappa}"
815        );
816    }
817    #[test]
818    fn bezier_torsion_planar_curve_near_zero() {
819        let pts = vec![
820            [0.0, 0.0, 0.0],
821            [1.0, 1.0, 0.0],
822            [2.0, 0.0, 0.0],
823            [3.0, 1.0, 0.0],
824        ];
825        let curve = BezierCurve::new(pts);
826        let tau = bezier_torsion(&curve, 0.5);
827        assert!(tau.is_finite(), "torsion must be finite: {tau}");
828        assert!(
829            tau.abs() < 1.0,
830            "planar curve torsion should be small: {tau}"
831        );
832    }
833    fn flat_bspline_surface() -> BsplineSurface {
834        let net: Vec<Vec<[f64; 3]>> = (0..3)
835            .map(|i| (0..3).map(|j| [i as f64, j as f64, 0.0]).collect())
836            .collect();
837        let knots = vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
838        BsplineSurface::new(
839            2,
840            2,
841            KnotVector::new(knots.clone()),
842            KnotVector::new(knots),
843            net,
844        )
845    }
846    #[test]
847    fn bspline_surface_eval_finite() {
848        let surf = flat_bspline_surface();
849        for &u in &[0.0, 0.25, 0.5, 0.75, 1.0] {
850            for &v in &[0.0, 0.25, 0.5, 0.75, 1.0] {
851                let p = surf.eval(u, v);
852                for (k, &pk) in p.iter().enumerate() {
853                    assert!(pk.is_finite(), "eval({u},{v})[{k}] not finite: {}", pk);
854                }
855            }
856        }
857    }
858    #[test]
859    fn bspline_surface_flat_gaussian_curvature_near_zero() {
860        let surf = flat_bspline_surface();
861        let (k_gauss, _k_mean) = surf.compute_curvature(0.5, 0.5);
862        assert!(
863            k_gauss.abs() < 1e-3,
864            "flat surface Gaussian curvature should be ~0, got {k_gauss}"
865        );
866    }
867    #[test]
868    fn bspline_surface_flat_mean_curvature_near_zero() {
869        let surf = flat_bspline_surface();
870        let (_k_gauss, k_mean) = surf.compute_curvature(0.5, 0.5);
871        assert!(
872            k_mean.abs() < 1e-3,
873            "flat surface mean curvature should be ~0, got {k_mean}"
874        );
875    }
876    #[test]
877    fn bspline_surface_curvature_returns_finite() {
878        let surf = flat_bspline_surface();
879        let (k_g, k_h) = surf.compute_curvature(0.3, 0.7);
880        assert!(
881            k_g.is_finite(),
882            "Gaussian curvature should be finite: {k_g}"
883        );
884        assert!(k_h.is_finite(), "mean curvature should be finite: {k_h}");
885    }
886    #[test]
887    fn bspline_surface_refine_knots_increases_control_points() {
888        let surf = flat_bspline_surface();
889        let refined = surf.refine_knots(&[0.5], &[0.5]);
890        let n_u_before = surf.control_points.len();
891        let n_u_after = refined.control_points.len();
892        assert!(
893            n_u_after >= n_u_before,
894            "refine_knots should not reduce control point count in u: {n_u_after} < {n_u_before}"
895        );
896    }
897    #[test]
898    fn bspline_surface_refine_knots_preserves_geometry() {
899        let surf = flat_bspline_surface();
900        let refined = surf.refine_knots(&[0.5], &[0.5]);
901        for &(u, v) in &[(0.25, 0.25), (0.5, 0.5), (0.75, 0.75)] {
902            let p_orig = surf.eval(u, v);
903            let p_refined = refined.eval(u, v);
904            for k in 0..3 {
905                assert!(
906                    (p_orig[k] - p_refined[k]).abs() < 1e-6,
907                    "eval changed after refinement at ({u},{v})[{k}]: {} vs {}",
908                    p_orig[k],
909                    p_refined[k]
910                );
911            }
912        }
913    }
914    fn simple_nurbs_line() -> NurbsCurve {
915        NurbsCurve::new(
916            1,
917            KnotVector::new(vec![0.0, 0.0, 1.0, 1.0]),
918            vec![[0.0, 0.0, 0.0], [4.0, 0.0, 0.0]],
919            vec![1.0, 1.0],
920        )
921    }
922    #[test]
923    fn nurbs_arc_length_line() {
924        let curve = simple_nurbs_line();
925        let len = curve.arc_length(0.0, 1.0, 200);
926        assert!(
927            (len - 4.0).abs() < 1e-3,
928            "NURBS line arc length should be ~4.0, got {len}"
929        );
930    }
931    #[test]
932    fn nurbs_arc_length_reparametrize_count() {
933        let curve = simple_nurbs_line();
934        let pts = curve.compute_arc_length_reparametrize(200, 10);
935        assert_eq!(
936            pts.len(),
937            10,
938            "should return exactly 10 reparametrized points"
939        );
940    }
941    #[test]
942    fn nurbs_arc_length_reparametrize_uniform_spacing() {
943        let curve = simple_nurbs_line();
944        let pts = curve.compute_arc_length_reparametrize(500, 5);
945        assert_eq!(pts.len(), 5);
946        for i in 1..pts.len() {
947            let d = dist3(pts[i - 1], pts[i]);
948            assert!(
949                (d - 1.0).abs() < 5e-2,
950                "inter-point distance should be ~1.0, got {d} between pts[{}] and pts[{}]",
951                i - 1,
952                i
953            );
954        }
955    }
956    #[test]
957    fn nurbs_arc_length_table_monotone() {
958        let curve = simple_nurbs_line();
959        let (arc_lengths, _params) = curve.arc_length_table(100);
960        for w in arc_lengths.windows(2) {
961            assert!(
962                w[1] >= w[0] - 1e-12,
963                "arc length table must be non-decreasing"
964            );
965        }
966    }
967}