Skip to main content

axiolid_reference/
surface.rs

1//! Scalar reference implementation of surface evaluation (ADR 0012).
2//!
3//! # What this closes
4//!
5//! `axiolid-surface` declared six surface families and a `SurfaceEvaluator`
6//! trait. Nothing implemented it, so a B-rep face on any curved surface could
7//! not be tessellated, which is most faces in a real building model. This
8//! reference that makes the declaration executable.
9//!
10//! # Parameterisation
11//!
12//! Each family uses the conventional parameterisation, chosen so `u` is the
13//! angular direction wherever one exists (matching the curve module, where a
14//! full turn is `[0, tau]`):
15//!
16//! | family   | `u`                  | `v`                     |
17//! |----------|----------------------|-------------------------|
18//! | Plane    | local x offset       | local y offset          |
19//! | Cylinder | angle about z        | height along z          |
20//! | Cone     | angle about z        | height along z          |
21//! | Sphere   | azimuth about z      | polar, `-pi/2 .. pi/2`  |
22//! | Torus    | angle about z        | angle around the tube   |
23//! | BSpline  | first knot axis      | second knot axis        |
24//!
25//! Normals point outward for closed families (away from the axis for a
26//! cylinder, away from the centre for a sphere, away from the tube centre for
27//! a torus). A caller that needs the opposite convention negates; the kernel
28//! does not guess.
29//!
30//! # What it does not do
31//!
32//! No surface-surface intersection, no trimming, no blending. Those are the
33//! parts of a NURBS kernel this crate deliberately does not attempt: for a
34//! tessellate-and-check pipeline the useful operation is evaluation, and the
35//! boolean stack works on meshes.
36
37use axiolid_contracts::BackendId;
38use axiolid_contracts::{GeomError, GeomResult};
39use axiolid_core::{Frame3, Point3, Scalar, Vec3};
40use axiolid_surface::{BSplineSurface, Cone, Cylinder, Plane, Sphere, Surface, Torus};
41
42use crate::curve::{de_boor_recurrence, eval_homogeneous, span_in};
43use crate::nurbs::SplineAxis;
44
45/// A finite parameter rectangle for tessellation.
46///
47/// Elementary surfaces are infinite (a plane, a cylinder) or only periodic in
48/// one direction, so a caller must say which patch it wants. Returning a
49/// default would invent geometry the source never declared.
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub struct Patch {
52    /// Start of the `u` interval.
53    pub u_start: Scalar,
54    /// End of the `u` interval.
55    pub u_end: Scalar,
56    /// Start of the `v` interval.
57    pub v_start: Scalar,
58    /// End of the `v` interval.
59    pub v_end: Scalar,
60}
61
62/// Position and all first/second partial derivatives of a surface.
63#[derive(Debug, Clone, Copy, PartialEq)]
64pub struct SurfaceJet {
65    /// Position at `(u, v)`.
66    pub point: Point3,
67    /// First partial with respect to `u`.
68    pub du: Vec3,
69    /// First partial with respect to `v`.
70    pub dv: Vec3,
71    /// Second partial with respect to `u` twice.
72    pub duu: Vec3,
73    /// Mixed second partial.
74    pub duv: Vec3,
75    /// Second partial with respect to `v` twice.
76    pub dvv: Vec3,
77}
78
79impl Patch {
80    /// Construct a patch, rejecting an empty or non-finite rectangle.
81    pub fn new(u_start: Scalar, u_end: Scalar, v_start: Scalar, v_end: Scalar) -> GeomResult<Self> {
82        let all = [u_start, u_end, v_start, v_end];
83        if !all.iter().all(|value| value.is_finite()) {
84            return Err(GeomError::InvalidInput(format!(
85                "patch bounds must be finite, got {all:?}"
86            )));
87        }
88        if !(u_end > u_start && v_end > v_start) {
89            return Err(GeomError::Degenerate(format!(
90                "patch must have positive extent, got u {u_start}..{u_end}, v {v_start}..{v_end}"
91            )));
92        }
93        Ok(Self {
94            u_start,
95            u_end,
96            v_start,
97            v_end,
98        })
99    }
100
101    /// The full closed patch for a family that is periodic in `u`.
102    pub fn full_turn(v_start: Scalar, v_end: Scalar) -> GeomResult<Self> {
103        Self::new(0.0, core::f64::consts::TAU, v_start, v_end)
104    }
105}
106
107/// Map a local-frame point into world coordinates.
108fn place(frame: &Frame3, local: Vec3) -> Point3 {
109    frame.origin + frame.x * local.x + frame.y * local.y + frame.z * local.z
110}
111
112/// Map a local-frame direction into world coordinates (no translation).
113fn direct(frame: &Frame3, local: Vec3) -> Vec3 {
114    frame.x * local.x + frame.y * local.y + frame.z * local.z
115}
116
117fn finite(value: Scalar, what: &str) -> GeomResult<()> {
118    if value.is_finite() {
119        Ok(())
120    } else {
121        Err(GeomError::InvalidInput(format!(
122            "{what} must be finite, got {value}"
123        )))
124    }
125}
126
127fn positive(value: Scalar, what: &str) -> GeomResult<()> {
128    finite(value, what)?;
129    if value > 0.0 {
130        Ok(())
131    } else {
132        Err(GeomError::InvalidInput(format!(
133            "{what} must be positive, got {value}"
134        )))
135    }
136}
137
138fn finite_surface_frame(surface: &Surface) -> GeomResult<()> {
139    let frame = match surface {
140        Surface::Plane(value) => Some(&value.frame),
141        Surface::Cylinder(value) => Some(&value.frame),
142        Surface::Cone(value) => Some(&value.frame),
143        Surface::Sphere(value) => Some(&value.frame),
144        Surface::Torus(value) => Some(&value.frame),
145        _ => None,
146    };
147    if frame.is_none_or(|frame| {
148        frame.origin.is_finite()
149            && frame.x.is_finite()
150            && frame.y.is_finite()
151            && frame.z.is_finite()
152    }) {
153        Ok(())
154    } else {
155        Err(GeomError::InvalidInput(
156            "surface frame must be finite".to_owned(),
157        ))
158    }
159}
160
161/// Position on a surface at `(u, v)`.
162pub fn evaluate(surface: &Surface, u: Scalar, v: Scalar) -> GeomResult<Point3> {
163    finite(u, "surface parameter u")?;
164    finite(v, "surface parameter v")?;
165    finite_surface_frame(surface)?;
166    let point = match surface {
167        Surface::Plane(p) => Ok(plane_point(p, u, v)),
168        Surface::Cylinder(c) => cylinder_point(c, u, v),
169        Surface::Cone(c) => cone_point(c, u, v),
170        Surface::Sphere(s) => sphere_point(s, u, v),
171        Surface::Torus(t) => torus_point(t, u, v),
172        Surface::BSpline(b) => bspline_point(b, u, v),
173        _ => Err(GeomError::Unsupported {
174            backend: ScalarSurface::ID,
175            operation: axiolid_contracts::Operation::SurfaceEvaluation,
176        }),
177    }?;
178    if point.is_finite() {
179        Ok(point)
180    } else {
181        Err(GeomError::Degenerate(
182            "surface point is non-finite".to_owned(),
183        ))
184    }
185}
186
187/// Analytic first partial derivatives `(∂S/∂u, ∂S/∂v)` at `(u, v)`.
188///
189/// Rational B-spline derivatives are evaluated in homogeneous space and
190/// projected with the quotient rule. This remains stable when valid imported
191/// knot domains have large offsets that make finite-difference steps vanish.
192pub fn partials(surface: &Surface, u: Scalar, v: Scalar) -> GeomResult<(Vec3, Vec3)> {
193    finite(u, "surface parameter u")?;
194    finite(v, "surface parameter v")?;
195    finite_surface_frame(surface)?;
196    let value = match surface {
197        Surface::Plane(p) => Ok((p.frame.x, p.frame.y)),
198        Surface::Cylinder(c) => {
199            positive(c.radius, "cylinder radius")?;
200            let (s, co) = u.sin_cos();
201            Ok((
202                direct(&c.frame, Vec3::new(-c.radius * s, c.radius * co, 0.0)),
203                c.frame.z,
204            ))
205        }
206        Surface::Cone(c) => {
207            finite(c.radius, "cone radius")?;
208            finite(c.semi_angle, "cone semi-angle")?;
209            let slope = c.semi_angle.tan();
210            let radius = c.radius + v * slope;
211            if radius < 0.0 {
212                return Err(GeomError::Degenerate(format!(
213                    "cone radius is negative at v = {v}: the patch crosses the apex"
214                )));
215            }
216            let (s, co) = u.sin_cos();
217            Ok((
218                direct(&c.frame, Vec3::new(-radius * s, radius * co, 0.0)),
219                direct(&c.frame, Vec3::new(slope * co, slope * s, 1.0)),
220            ))
221        }
222        Surface::Sphere(sphere) => {
223            positive(sphere.radius, "sphere radius")?;
224            let (su, cu) = u.sin_cos();
225            let (sv, cv) = v.sin_cos();
226            Ok((
227                direct(
228                    &sphere.frame,
229                    Vec3::new(-sphere.radius * cv * su, sphere.radius * cv * cu, 0.0),
230                ),
231                direct(
232                    &sphere.frame,
233                    Vec3::new(
234                        -sphere.radius * sv * cu,
235                        -sphere.radius * sv * su,
236                        sphere.radius * cv,
237                    ),
238                ),
239            ))
240        }
241        Surface::Torus(torus) => {
242            positive(torus.major_radius, "torus major radius")?;
243            positive(torus.minor_radius, "torus minor radius")?;
244            let (su, cu) = u.sin_cos();
245            let (sv, cv) = v.sin_cos();
246            let ring = torus.major_radius + torus.minor_radius * cv;
247            Ok((
248                direct(&torus.frame, Vec3::new(-ring * su, ring * cu, 0.0)),
249                direct(
250                    &torus.frame,
251                    Vec3::new(
252                        -torus.minor_radius * sv * cu,
253                        -torus.minor_radius * sv * su,
254                        torus.minor_radius * cv,
255                    ),
256                ),
257            ))
258        }
259        Surface::BSpline(b) => bspline_partials(b, u, v),
260        _ => Err(GeomError::Unsupported {
261            backend: ScalarSurface::ID,
262            operation: axiolid_contracts::Operation::SurfaceEvaluation,
263        }),
264    }?;
265    if value.0.is_finite() && value.1.is_finite() {
266        Ok(value)
267    } else {
268        Err(GeomError::Degenerate(
269            "surface partial is non-finite".to_owned(),
270        ))
271    }
272}
273
274/// Second-order differential jet at `(u, v)`.
275///
276/// All values are analytic in the surface's native parameterisation. Rational
277/// B-splines are differentiated in homogeneous space before projection.
278pub fn jet(surface: &Surface, u: Scalar, v: Scalar) -> GeomResult<SurfaceJet> {
279    finite(u, "surface parameter u")?;
280    finite(v, "surface parameter v")?;
281    finite_surface_frame(surface)?;
282    let value = match surface {
283        Surface::Plane(p) => SurfaceJet {
284            point: plane_point(p, u, v),
285            du: p.frame.x,
286            dv: p.frame.y,
287            duu: Vec3::ZERO,
288            duv: Vec3::ZERO,
289            dvv: Vec3::ZERO,
290        },
291        Surface::Cylinder(c) => {
292            positive(c.radius, "cylinder radius")?;
293            let (s, co) = u.sin_cos();
294            SurfaceJet {
295                point: cylinder_point(c, u, v)?,
296                du: direct(&c.frame, Vec3::new(-c.radius * s, c.radius * co, 0.0)),
297                dv: c.frame.z,
298                duu: direct(&c.frame, Vec3::new(-c.radius * co, -c.radius * s, 0.0)),
299                duv: Vec3::ZERO,
300                dvv: Vec3::ZERO,
301            }
302        }
303        Surface::Cone(c) => {
304            finite(c.radius, "cone radius")?;
305            finite(c.semi_angle, "cone semi-angle")?;
306            let slope = c.semi_angle.tan();
307            let radius = c.radius + v * slope;
308            if radius < 0.0 {
309                return Err(GeomError::Degenerate(format!(
310                    "cone radius is negative at v = {v}: the patch crosses the apex"
311                )));
312            }
313            let (s, co) = u.sin_cos();
314            SurfaceJet {
315                point: cone_point(c, u, v)?,
316                du: direct(&c.frame, Vec3::new(-radius * s, radius * co, 0.0)),
317                dv: direct(&c.frame, Vec3::new(slope * co, slope * s, 1.0)),
318                duu: direct(&c.frame, Vec3::new(-radius * co, -radius * s, 0.0)),
319                duv: direct(&c.frame, Vec3::new(-slope * s, slope * co, 0.0)),
320                dvv: Vec3::ZERO,
321            }
322        }
323        Surface::Sphere(sphere) => {
324            positive(sphere.radius, "sphere radius")?;
325            let r = sphere.radius;
326            let (su, cu) = u.sin_cos();
327            let (sv, cv) = v.sin_cos();
328            SurfaceJet {
329                point: sphere_point(sphere, u, v)?,
330                du: direct(&sphere.frame, Vec3::new(-r * cv * su, r * cv * cu, 0.0)),
331                dv: direct(&sphere.frame, Vec3::new(-r * sv * cu, -r * sv * su, r * cv)),
332                duu: direct(&sphere.frame, Vec3::new(-r * cv * cu, -r * cv * su, 0.0)),
333                duv: direct(&sphere.frame, Vec3::new(r * sv * su, -r * sv * cu, 0.0)),
334                dvv: direct(
335                    &sphere.frame,
336                    Vec3::new(-r * cv * cu, -r * cv * su, -r * sv),
337                ),
338            }
339        }
340        Surface::Torus(torus) => {
341            positive(torus.major_radius, "torus major radius")?;
342            positive(torus.minor_radius, "torus minor radius")?;
343            let r = torus.minor_radius;
344            let (su, cu) = u.sin_cos();
345            let (sv, cv) = v.sin_cos();
346            let ring = torus.major_radius + r * cv;
347            SurfaceJet {
348                point: torus_point(torus, u, v)?,
349                du: direct(&torus.frame, Vec3::new(-ring * su, ring * cu, 0.0)),
350                dv: direct(&torus.frame, Vec3::new(-r * sv * cu, -r * sv * su, r * cv)),
351                duu: direct(&torus.frame, Vec3::new(-ring * cu, -ring * su, 0.0)),
352                duv: direct(&torus.frame, Vec3::new(r * sv * su, -r * sv * cu, 0.0)),
353                dvv: direct(&torus.frame, Vec3::new(-r * cv * cu, -r * cv * su, -r * sv)),
354            }
355        }
356        Surface::BSpline(b) => bspline_jet(b, u, v)?,
357        _ => {
358            return Err(GeomError::Unsupported {
359                backend: ScalarSurface::ID,
360                operation: axiolid_contracts::Operation::SurfaceEvaluation,
361            })
362        }
363    };
364    if [
365        value.point,
366        value.du,
367        value.dv,
368        value.duu,
369        value.duv,
370        value.dvv,
371    ]
372    .iter()
373    .all(|vector| vector.is_finite())
374    {
375        Ok(value)
376    } else {
377        Err(GeomError::Degenerate(
378            "surface differential jet is non-finite".to_owned(),
379        ))
380    }
381}
382
383/// Unit normal at `(u, v)`.
384///
385/// Computed from the exact analytic partial derivatives rather than by
386/// differencing evaluated points: a finite difference loses precision exactly
387/// where it matters most, at high curvature.
388pub fn normal(surface: &Surface, u: Scalar, v: Scalar) -> GeomResult<Vec3> {
389    finite(u, "surface parameter u")?;
390    finite(v, "surface parameter v")?;
391    finite_surface_frame(surface)?;
392    let n = match surface {
393        Surface::Plane(p) => p.frame.z,
394        Surface::Cylinder(c) => {
395            positive(c.radius, "cylinder radius")?;
396            let (s, co) = u.sin_cos();
397            direct(&c.frame, Vec3::new(co, s, 0.0))
398        }
399        Surface::Cone(c) => cone_normal(c, u)?,
400        Surface::Sphere(s) => {
401            positive(s.radius, "sphere radius")?;
402            let (su, cu) = u.sin_cos();
403            let (sv, cv) = v.sin_cos();
404            direct(&s.frame, Vec3::new(cv * cu, cv * su, sv))
405        }
406        Surface::Torus(t) => {
407            positive(t.minor_radius, "torus minor radius")?;
408            let (su, cu) = u.sin_cos();
409            let (sv, cv) = v.sin_cos();
410            direct(&t.frame, Vec3::new(cv * cu, cv * su, sv))
411        }
412        Surface::BSpline(b) => bspline_normal(b, u, v)?,
413        _ => {
414            return Err(GeomError::Unsupported {
415                backend: ScalarSurface::ID,
416                operation: axiolid_contracts::Operation::SurfaceEvaluation,
417            })
418        }
419    };
420    let length = n.length();
421    if !(length > 0.0 && n.is_finite()) {
422        return Err(GeomError::Degenerate(format!(
423            "surface normal is not orientable at ({u}, {v})"
424        )));
425    }
426    Ok(n / length)
427}
428
429fn plane_point(p: &Plane, u: Scalar, v: Scalar) -> Point3 {
430    place(&p.frame, Vec3::new(u, v, 0.0))
431}
432
433fn cylinder_point(c: &Cylinder, u: Scalar, v: Scalar) -> GeomResult<Point3> {
434    positive(c.radius, "cylinder radius")?;
435    let (s, co) = u.sin_cos();
436    Ok(place(&c.frame, Vec3::new(c.radius * co, c.radius * s, v)))
437}
438
439fn cone_point(c: &Cone, u: Scalar, v: Scalar) -> GeomResult<Point3> {
440    finite(c.radius, "cone radius")?;
441    finite(c.semi_angle, "cone semi-angle")?;
442    // Radius shrinks with height at the semi-angle; a negative radius means
443    // the surface has passed through the apex, which is not a valid patch.
444    let r = c.radius + v * c.semi_angle.tan();
445    if r < 0.0 {
446        return Err(GeomError::Degenerate(format!(
447            "cone radius is negative at v = {v}: the patch crosses the apex"
448        )));
449    }
450    let (s, co) = u.sin_cos();
451    Ok(place(&c.frame, Vec3::new(r * co, r * s, v)))
452}
453
454fn cone_normal(c: &Cone, u: Scalar) -> GeomResult<Vec3> {
455    finite(c.semi_angle, "cone semi-angle")?;
456    let (s, co) = u.sin_cos();
457    // Outward radial component, tilted by the semi-angle: the normal leans
458    // toward the axis as the cone narrows.
459    let (sa, ca) = c.semi_angle.sin_cos();
460    Ok(direct(&c.frame, Vec3::new(ca * co, ca * s, -sa)))
461}
462
463fn sphere_point(s: &Sphere, u: Scalar, v: Scalar) -> GeomResult<Point3> {
464    positive(s.radius, "sphere radius")?;
465    let (su, cu) = u.sin_cos();
466    let (sv, cv) = v.sin_cos();
467    Ok(place(
468        &s.frame,
469        Vec3::new(s.radius * cv * cu, s.radius * cv * su, s.radius * sv),
470    ))
471}
472
473fn torus_point(t: &Torus, u: Scalar, v: Scalar) -> GeomResult<Point3> {
474    positive(t.major_radius, "torus major radius")?;
475    positive(t.minor_radius, "torus minor radius")?;
476    let (su, cu) = u.sin_cos();
477    let (sv, cv) = v.sin_cos();
478    let ring = t.major_radius + t.minor_radius * cv;
479    Ok(place(
480        &t.frame,
481        Vec3::new(ring * cu, ring * su, t.minor_radius * sv),
482    ))
483}
484
485// --- tensor-product B-spline ------------------------------------------------
486
487type Axis = SplineAxis;
488
489/// Validate the control net and both axes together.
490fn bspline_axes(b: &BSplineSurface) -> GeomResult<(Axis, Axis)> {
491    let rows = b.control_points.len();
492    if rows == 0 {
493        return Err(GeomError::InvalidInput(
494            "B-spline surface has no control points".to_owned(),
495        ));
496    }
497    let cols = b.control_points[0].len();
498    if cols == 0 {
499        return Err(GeomError::InvalidInput(
500            "B-spline surface control net has an empty row".to_owned(),
501        ));
502    }
503    // A ragged net is a data error, not something to paper over: evaluating it
504    // would silently read a different surface than the source declared.
505    if b.control_points.iter().any(|row| row.len() != cols) {
506        return Err(GeomError::InvalidInput(
507            "B-spline surface control net is ragged".to_owned(),
508        ));
509    }
510    if b.control_points
511        .iter()
512        .flatten()
513        .any(|point| !point.is_finite())
514    {
515        return Err(GeomError::InvalidInput(
516            "B-spline surface control points must be finite".to_owned(),
517        ));
518    }
519    if let Some(w) = &b.weights {
520        if w.len() != rows || w.iter().any(|row| row.len() != cols) {
521            return Err(GeomError::InvalidInput(
522                "B-spline surface weight net does not match the control net".to_owned(),
523            ));
524        }
525        if w.iter()
526            .flatten()
527            .any(|weight| !weight.is_finite() || *weight <= 0.0)
528        {
529            return Err(GeomError::InvalidInput(
530                "B-spline surface weights must be finite and strictly positive".to_owned(),
531            ));
532        }
533    }
534    let u = Axis::new(&b.u_knots, &b.u_multiplicities, b.u_degree, rows, "u")?;
535    let v = Axis::new(&b.v_knots, &b.v_multiplicities, b.v_degree, cols, "v")?;
536    Ok((u, v))
537}
538
539/// Tensor-product de Boor: evaluate along `v` per influencing row, then along
540/// `u` through those results.
541///
542/// Rational surfaces interpolate in homogeneous space throughout; projecting
543/// per row and averaging afterwards is the classic wrong answer.
544fn bspline_point(b: &BSplineSurface, u: Scalar, v: Scalar) -> GeomResult<Point3> {
545    let (ua, va) = bspline_axes(b)?;
546    let (uc, vc) = (ua.clamp(u), va.clamp(v));
547    let uspan = span_in(&ua.knots, ua.count, ua.degree, uc);
548    let vspan = span_in(&va.knots, va.count, va.degree, vc);
549
550    // Stage one: collapse each influencing row along v, staying homogeneous.
551    let mut row_points: Vec<[Scalar; 3]> = Vec::with_capacity(ua.degree + 1);
552    let mut row_weights: Vec<Scalar> = Vec::with_capacity(ua.degree + 1);
553    for i in 0..=ua.degree {
554        let row = uspan - ua.degree + i;
555        let mut pts: Vec<[Scalar; 3]> = Vec::with_capacity(va.degree + 1);
556        let mut wts: Vec<Scalar> = Vec::with_capacity(va.degree + 1);
557        for j in 0..=va.degree {
558            let col = vspan - va.degree + j;
559            let w = b.weights.as_ref().map_or(1.0, |ws| ws[row][col]);
560            let p = b.control_points[row][col];
561            let homogeneous = [p.x * w, p.y * w, p.z * w];
562            if homogeneous.iter().any(|value| !value.is_finite()) {
563                return Err(GeomError::Degenerate(
564                    "B-spline surface homogeneous control point overflowed".to_owned(),
565                ));
566            }
567            pts.push(homogeneous);
568            wts.push(w);
569        }
570        de_boor_recurrence(&va.knots, vspan, va.degree, vc, &mut pts, &mut wts);
571        row_points.push(pts[va.degree]);
572        row_weights.push(wts[va.degree]);
573    }
574
575    // Stage two: collapse the row results along u.
576    de_boor_recurrence(
577        &ua.knots,
578        uspan,
579        ua.degree,
580        uc,
581        &mut row_points,
582        &mut row_weights,
583    );
584
585    let w = row_weights[ua.degree];
586    if !w.is_finite() || w == 0.0 {
587        return Err(GeomError::Degenerate(
588            "B-spline surface weight collapsed to zero".to_owned(),
589        ));
590    }
591    let p = row_points[ua.degree];
592    Ok(Point3::new(p[0] / w, p[1] / w, p[2] / w))
593}
594
595/// Borrowed knot axes for one homogeneous tensor-product evaluation.
596#[derive(Clone, Copy)]
597struct HomogeneousAxes<'a> {
598    u_knots: &'a [Scalar],
599    u_degree: usize,
600    v_knots: &'a [Scalar],
601    v_degree: usize,
602}
603
604/// Evaluate one homogeneous tensor-product control net without projecting.
605fn eval_tensor_homogeneous(
606    axes: HomogeneousAxes<'_>,
607    points: &[Vec<[Scalar; 3]>],
608    weights: &[Vec<Scalar>],
609    u: Scalar,
610    v: Scalar,
611) -> ([Scalar; 3], Scalar) {
612    let mut row_points = Vec::with_capacity(points.len());
613    let mut row_weights = Vec::with_capacity(points.len());
614    for (row_points_h, row_weights_h) in points.iter().zip(weights) {
615        let (point, weight) =
616            eval_homogeneous(axes.v_knots, axes.v_degree, row_points_h, row_weights_h, v);
617        row_points.push(point);
618        row_weights.push(weight);
619    }
620    eval_homogeneous(axes.u_knots, axes.u_degree, &row_points, &row_weights, u)
621}
622
623type HomogeneousPointNet = Vec<Vec<[Scalar; 3]>>;
624type HomogeneousWeightNet = Vec<Vec<Scalar>>;
625
626/// Homogeneous point and weight control nets for a validated surface.
627fn homogeneous_control_net(
628    b: &BSplineSurface,
629) -> GeomResult<(HomogeneousPointNet, HomogeneousWeightNet)> {
630    let mut points = Vec::with_capacity(b.control_points.len());
631    let mut weights = Vec::with_capacity(b.control_points.len());
632    for (i, row) in b.control_points.iter().enumerate() {
633        let mut point_row = Vec::with_capacity(row.len());
634        let mut weight_row = Vec::with_capacity(row.len());
635        for (j, point) in row.iter().enumerate() {
636            let weight = b.weights.as_ref().map_or(1.0, |net| net[i][j]);
637            let homogeneous = [point.x * weight, point.y * weight, point.z * weight];
638            if homogeneous.iter().any(|value| !value.is_finite()) {
639                return Err(GeomError::Degenerate(
640                    "B-spline surface homogeneous control point overflowed".to_owned(),
641                ));
642            }
643            point_row.push(homogeneous);
644            weight_row.push(weight);
645        }
646        points.push(point_row);
647        weights.push(weight_row);
648    }
649    Ok((points, weights))
650}
651
652/// Differentiate a homogeneous control net along `u`.
653fn derivative_net_u(
654    points: &[Vec<[Scalar; 3]>],
655    weights: &[Vec<Scalar>],
656    knots: &[Scalar],
657    degree: usize,
658) -> (Vec<Vec<[Scalar; 3]>>, Vec<Vec<Scalar>>) {
659    let rows = points.len() - 1;
660    let cols = points[0].len();
661    let mut derivative_points = Vec::with_capacity(rows);
662    let mut derivative_weights = Vec::with_capacity(rows);
663    for i in 0..rows {
664        let denominator = knots[i + degree + 1] - knots[i + 1];
665        let factor = if denominator.abs() > 0.0 {
666            degree as Scalar / denominator
667        } else {
668            0.0
669        };
670        let mut point_row = Vec::with_capacity(cols);
671        let mut weight_row = Vec::with_capacity(cols);
672        for j in 0..cols {
673            point_row.push(core::array::from_fn(|k| {
674                factor * (points[i + 1][j][k] - points[i][j][k])
675            }));
676            weight_row.push(factor * (weights[i + 1][j] - weights[i][j]));
677        }
678        derivative_points.push(point_row);
679        derivative_weights.push(weight_row);
680    }
681    (derivative_points, derivative_weights)
682}
683
684/// Differentiate a homogeneous control net along `v`.
685fn derivative_net_v(
686    points: &[Vec<[Scalar; 3]>],
687    weights: &[Vec<Scalar>],
688    knots: &[Scalar],
689    degree: usize,
690) -> (Vec<Vec<[Scalar; 3]>>, Vec<Vec<Scalar>>) {
691    let rows = points.len();
692    let cols = points[0].len() - 1;
693    let mut derivative_points = Vec::with_capacity(rows);
694    let mut derivative_weights = Vec::with_capacity(rows);
695    for i in 0..rows {
696        let mut point_row = Vec::with_capacity(cols);
697        let mut weight_row = Vec::with_capacity(cols);
698        for j in 0..cols {
699            let denominator = knots[j + degree + 1] - knots[j + 1];
700            let factor = if denominator.abs() > 0.0 {
701                degree as Scalar / denominator
702            } else {
703                0.0
704            };
705            point_row.push(core::array::from_fn(|k| {
706                factor * (points[i][j + 1][k] - points[i][j][k])
707            }));
708            weight_row.push(factor * (weights[i][j + 1] - weights[i][j]));
709        }
710        derivative_points.push(point_row);
711        derivative_weights.push(weight_row);
712    }
713    (derivative_points, derivative_weights)
714}
715
716/// Project one homogeneous derivative with the rational quotient rule.
717fn project_derivative(
718    point: [Scalar; 3],
719    weight: Scalar,
720    derivative: [Scalar; 3],
721    derivative_weight: Scalar,
722    axis: &str,
723) -> GeomResult<Vec3> {
724    if !weight.is_finite() || weight == 0.0 {
725        return Err(GeomError::Degenerate(
726            "B-spline surface weight collapsed to a non-finite or zero value".to_owned(),
727        ));
728    }
729    let value = Vec3::new(
730        (derivative[0] - point[0] * derivative_weight / weight) / weight,
731        (derivative[1] - point[1] * derivative_weight / weight) / weight,
732        (derivative[2] - point[2] * derivative_weight / weight) / weight,
733    );
734    if !value.is_finite() {
735        return Err(GeomError::Degenerate(format!(
736            "B-spline surface {axis} derivative is non-finite"
737        )));
738    }
739    Ok(value)
740}
741
742/// Exact first partials of a rational tensor-product B-spline.
743fn bspline_partials(b: &BSplineSurface, u: Scalar, v: Scalar) -> GeomResult<(Vec3, Vec3)> {
744    let (ua, va) = bspline_axes(b)?;
745    let (uc, vc) = (ua.clamp(u), va.clamp(v));
746    let (points, weights) = homogeneous_control_net(b)?;
747    let (point, weight) = eval_tensor_homogeneous(
748        HomogeneousAxes {
749            u_knots: &ua.knots,
750            u_degree: ua.degree,
751            v_knots: &va.knots,
752            v_degree: va.degree,
753        },
754        &points,
755        &weights,
756        uc,
757        vc,
758    );
759
760    let (u_points, u_weights) = derivative_net_u(&points, &weights, &ua.knots, ua.degree);
761    let (du, du_weight) = eval_tensor_homogeneous(
762        HomogeneousAxes {
763            u_knots: &ua.knots[1..ua.knots.len() - 1],
764            u_degree: ua.degree - 1,
765            v_knots: &va.knots,
766            v_degree: va.degree,
767        },
768        &u_points,
769        &u_weights,
770        uc,
771        vc,
772    );
773
774    let (v_points, v_weights) = derivative_net_v(&points, &weights, &va.knots, va.degree);
775    let (dv, dv_weight) = eval_tensor_homogeneous(
776        HomogeneousAxes {
777            u_knots: &ua.knots,
778            u_degree: ua.degree,
779            v_knots: &va.knots[1..va.knots.len() - 1],
780            v_degree: va.degree - 1,
781        },
782        &v_points,
783        &v_weights,
784        uc,
785        vc,
786    );
787
788    Ok((
789        project_derivative(point, weight, du, du_weight, "u")?,
790        project_derivative(point, weight, dv, dv_weight, "v")?,
791    ))
792}
793
794/// Full second-order jet of a rational tensor-product B-spline.
795/// Second-order differential jet of a B-spline surface without enum wrapping.
796pub fn bspline_jet(b: &BSplineSurface, u: Scalar, v: Scalar) -> GeomResult<SurfaceJet> {
797    let (ua, va) = bspline_axes(b)?;
798    let (uc, vc) = (ua.clamp(u), va.clamp(v));
799    let (points, weights) = homogeneous_control_net(b)?;
800    let base_axes = HomogeneousAxes {
801        u_knots: &ua.knots,
802        u_degree: ua.degree,
803        v_knots: &va.knots,
804        v_degree: va.degree,
805    };
806    let (point, weight) = eval_tensor_homogeneous(base_axes, &points, &weights, uc, vc);
807    if !weight.is_finite() || weight == 0.0 {
808        return Err(GeomError::Degenerate(
809            "B-spline surface weight collapsed to a non-finite or zero value".to_owned(),
810        ));
811    }
812    let position = Point3::new(point[0] / weight, point[1] / weight, point[2] / weight);
813
814    let (u_points, u_weights) = derivative_net_u(&points, &weights, &ua.knots, ua.degree);
815    let u_knots = &ua.knots[1..ua.knots.len() - 1];
816    let (du_h, du_weight) = eval_tensor_homogeneous(
817        HomogeneousAxes {
818            u_knots,
819            u_degree: ua.degree - 1,
820            v_knots: &va.knots,
821            v_degree: va.degree,
822        },
823        &u_points,
824        &u_weights,
825        uc,
826        vc,
827    );
828    let du = project_derivative(point, weight, du_h, du_weight, "u")?;
829
830    let (v_points, v_weights) = derivative_net_v(&points, &weights, &va.knots, va.degree);
831    let v_knots = &va.knots[1..va.knots.len() - 1];
832    let (dv_h, dv_weight) = eval_tensor_homogeneous(
833        HomogeneousAxes {
834            u_knots: &ua.knots,
835            u_degree: ua.degree,
836            v_knots,
837            v_degree: va.degree - 1,
838        },
839        &v_points,
840        &v_weights,
841        uc,
842        vc,
843    );
844    let dv = project_derivative(point, weight, dv_h, dv_weight, "v")?;
845
846    let (duu_h, duu_weight) = if ua.degree >= 2 {
847        let (net, net_weights) = derivative_net_u(&u_points, &u_weights, u_knots, ua.degree - 1);
848        eval_tensor_homogeneous(
849            HomogeneousAxes {
850                u_knots: &u_knots[1..u_knots.len() - 1],
851                u_degree: ua.degree - 2,
852                v_knots: &va.knots,
853                v_degree: va.degree,
854            },
855            &net,
856            &net_weights,
857            uc,
858            vc,
859        )
860    } else {
861        ([0.0; 3], 0.0)
862    };
863    let duu = project_second(point, weight, du, duu_h, du_weight, duu_weight, "uu")?;
864
865    let (dvv_h, dvv_weight) = if va.degree >= 2 {
866        let (net, net_weights) = derivative_net_v(&v_points, &v_weights, v_knots, va.degree - 1);
867        eval_tensor_homogeneous(
868            HomogeneousAxes {
869                u_knots: &ua.knots,
870                u_degree: ua.degree,
871                v_knots: &v_knots[1..v_knots.len() - 1],
872                v_degree: va.degree - 2,
873            },
874            &net,
875            &net_weights,
876            uc,
877            vc,
878        )
879    } else {
880        ([0.0; 3], 0.0)
881    };
882    let dvv = project_second(point, weight, dv, dvv_h, dv_weight, dvv_weight, "vv")?;
883
884    let (uv_points, uv_weights) = derivative_net_v(&u_points, &u_weights, &va.knots, va.degree);
885    let (duv_h, duv_weight) = eval_tensor_homogeneous(
886        HomogeneousAxes {
887            u_knots,
888            u_degree: ua.degree - 1,
889            v_knots,
890            v_degree: va.degree - 1,
891        },
892        &uv_points,
893        &uv_weights,
894        uc,
895        vc,
896    );
897    let duv = project_mixed(
898        point, weight, du, du_weight, dv, dv_weight, duv_h, duv_weight,
899    )?;
900
901    Ok(SurfaceJet {
902        point: position,
903        du,
904        dv,
905        duu,
906        duv,
907        dvv,
908    })
909}
910
911fn project_second(
912    point: [Scalar; 3],
913    weight: Scalar,
914    first: Vec3,
915    second: [Scalar; 3],
916    first_weight: Scalar,
917    second_weight: Scalar,
918    axis: &str,
919) -> GeomResult<Vec3> {
920    let position = Vec3::new(point[0], point[1], point[2]) / weight;
921    let value = (Vec3::new(second[0], second[1], second[2])
922        - 2.0 * first_weight * first
923        - second_weight * position)
924        / weight;
925    if value.is_finite() {
926        Ok(value)
927    } else {
928        Err(GeomError::Degenerate(format!(
929            "B-spline surface {axis} second derivative is non-finite"
930        )))
931    }
932}
933
934#[allow(clippy::too_many_arguments)]
935fn project_mixed(
936    point: [Scalar; 3],
937    weight: Scalar,
938    du: Vec3,
939    du_weight: Scalar,
940    dv: Vec3,
941    dv_weight: Scalar,
942    mixed: [Scalar; 3],
943    mixed_weight: Scalar,
944) -> GeomResult<Vec3> {
945    let position = Vec3::new(point[0], point[1], point[2]) / weight;
946    let value = (Vec3::new(mixed[0], mixed[1], mixed[2])
947        - du_weight * dv
948        - dv_weight * du
949        - mixed_weight * position)
950        / weight;
951    if value.is_finite() {
952        Ok(value)
953    } else {
954        Err(GeomError::Degenerate(
955            "B-spline surface uv mixed derivative is non-finite".to_owned(),
956        ))
957    }
958}
959
960/// Normal from analytic rational tensor-product partial derivatives.
961fn bspline_normal(b: &BSplineSurface, u: Scalar, v: Scalar) -> GeomResult<Vec3> {
962    let (du, dv) = bspline_partials(b, u, v)?;
963    Ok(du.cross(dv))
964}
965
966/// The [`axiolid_surface::SurfaceEvaluator`] implementation, so a caller can dispatch through
967/// the trait rather than the free functions.
968#[derive(Debug, Default, Clone, Copy)]
969pub struct ScalarSurface;
970
971impl ScalarSurface {
972    /// Identity reported in structured errors, matching `ScalarBoolean`.
973    pub const ID: BackendId = BackendId::new("scalar-reference");
974}
975
976impl axiolid_surface::SurfaceEvaluator<Surface> for ScalarSurface {
977    type Error = GeomError;
978
979    fn evaluate(
980        &self,
981        surface: &Surface,
982        u: Scalar,
983        v: Scalar,
984        _tolerance: axiolid_core::Tolerance,
985    ) -> Result<Point3, Self::Error> {
986        evaluate(surface, u, v)
987    }
988
989    fn normal(
990        &self,
991        surface: &Surface,
992        u: Scalar,
993        v: Scalar,
994        _tolerance: axiolid_core::Tolerance,
995    ) -> Result<Vec3, Self::Error> {
996        normal(surface, u, v)
997    }
998}
999
1000/// Surface parameters `(u, v)` whose evaluation reproduces `point`.
1001///
1002/// This is the exact inverse of [`evaluate`] for the analytic surfaces,
1003/// derived from each parameterisation rather than found by iteration, so
1004/// it neither needs a seed nor converges to a nearby-but-wrong branch.
1005///
1006/// The point must already lie ON the surface: this answers "which
1007/// parameters name this point", not "which point is nearest". A sweep
1008/// directrix that has drifted off its reference surface is a modelling
1009/// error, and silently projecting it would tilt every section frame by an
1010/// amount nothing downstream can detect. The residual is therefore checked
1011/// against `tolerance` and a miss is reported rather than absorbed.
1012///
1013/// Parameters that no unique answer exists for are refused, not guessed:
1014/// at a cone apex or a sphere pole the whole `u` circle maps to one point,
1015/// so any choice would be arbitrary and would rotate the swept section.
1016pub fn invert(
1017    surface: &Surface,
1018    point: Point3,
1019    tolerance: axiolid_core::Tolerance,
1020) -> GeomResult<(Scalar, Scalar)> {
1021    let (u, v) = match surface {
1022        Surface::Plane(p) => {
1023            let local = to_local(&p.frame, point)?;
1024            (local.x, local.y)
1025        }
1026        Surface::Cylinder(c) => {
1027            positive(c.radius, "cylinder radius")?;
1028            let local = to_local(&c.frame, point)?;
1029            (angle_about_axis(local, "cylinder")?, local.z)
1030        }
1031        Surface::Cone(c) => {
1032            finite(c.radius, "cone radius")?;
1033            finite(c.semi_angle, "cone semi-angle")?;
1034            let local = to_local(&c.frame, point)?;
1035            // At the apex the radius vanishes and every u names the same
1036            // point, so the angle is unrecoverable rather than merely
1037            // imprecise.
1038            (angle_about_axis(local, "cone")?, local.z)
1039        }
1040        Surface::Sphere(s) => {
1041            positive(s.radius, "sphere radius")?;
1042            let local = to_local(&s.frame, point)?;
1043            // Latitude first: it is well defined even at the poles, which
1044            // the angle lookup then rejects.
1045            let sin_v = (local.z / s.radius).clamp(-1.0, 1.0);
1046            (angle_about_axis(local, "sphere")?, sin_v.asin())
1047        }
1048        Surface::Torus(t) => {
1049            positive(t.major_radius, "torus major radius")?;
1050            positive(t.minor_radius, "torus minor radius")?;
1051            let local = to_local(&t.frame, point)?;
1052            let ring = (local.x * local.x + local.y * local.y).sqrt();
1053            (
1054                angle_about_axis(local, "torus")?,
1055                (local.z).atan2(ring - t.major_radius),
1056            )
1057        }
1058        // A B-spline has no closed-form inverse; recovering parameters
1059        // needs iterative closest-point with its own seeding and
1060        // convergence contract. `Surface` is non-exhaustive, so any
1061        // future variant lands here too and is refused by name rather
1062        // than silently taking an analytic branch that does not fit it.
1063        _ => {
1064            return Err(GeomError::Unsupported {
1065                backend: ScalarSurface::ID,
1066                operation: axiolid_contracts::Operation::SurfaceEvaluation,
1067            });
1068        }
1069    };
1070    // The parameters are only meaningful if they reproduce the point.
1071    // This is what turns a silent mis-parameterisation into an error.
1072    let round_trip = evaluate(surface, u, v)?;
1073    let residual = (round_trip - point).length();
1074    if residual > tolerance.linear() {
1075        return Err(GeomError::Degenerate(format!(
1076            "point is {residual} from the surface, beyond the {} tolerance: \
1077             inversion names a point ON the surface and does not project",
1078            tolerance.linear()
1079        )));
1080    }
1081    Ok((u, v))
1082}
1083
1084/// Express a world point in a frame's local coordinates.
1085///
1086/// `place` maps local to world by scaling the frame axes, so the inverse
1087/// is a projection onto those axes -- but ONLY when they are orthonormal.
1088/// `Frame3` stores three free vectors and the core documents that
1089/// algorithms validate orthonormality explicitly, so this checks rather
1090/// than assumes: on a skewed or scaled frame a dot-product projection is
1091/// silently wrong, and every parameter derived from it would be wrong by
1092/// an amount that still round-trips through the same bad frame.
1093fn to_local(frame: &Frame3, point: Point3) -> GeomResult<Vec3> {
1094    let axes = [frame.x, frame.y, frame.z];
1095    for (axis, name) in axes.iter().zip(["x", "y", "z"]) {
1096        let length = axis.length();
1097        if (length - 1.0).abs() > 1e-9 {
1098            return Err(GeomError::Degenerate(format!(
1099                "surface frame {name} axis has length {length}, expected 1"
1100            )));
1101        }
1102    }
1103    for (a, b, pair) in [
1104        (frame.x, frame.y, "x/y"),
1105        (frame.y, frame.z, "y/z"),
1106        (frame.z, frame.x, "z/x"),
1107    ] {
1108        let dot = a.dot(b);
1109        if dot.abs() > 1e-9 {
1110            return Err(GeomError::Degenerate(format!(
1111                "surface frame {pair} axes are not perpendicular: dot {dot}"
1112            )));
1113        }
1114    }
1115    let offset = point - frame.origin;
1116    Ok(Vec3::new(
1117        offset.dot(frame.x),
1118        offset.dot(frame.y),
1119        offset.dot(frame.z),
1120    ))
1121}
1122
1123/// Angle of a local point about the frame's z axis.
1124///
1125/// Refuses points ON the axis. There the whole `u` circle collapses to a
1126/// single location -- a cone apex, a sphere pole -- so no angle is more
1127/// correct than any other. Returning zero would look successful and would
1128/// rotate a swept section arbitrarily about its own path.
1129fn angle_about_axis(local: Vec3, surface: &str) -> GeomResult<Scalar> {
1130    let radial = (local.x * local.x + local.y * local.y).sqrt();
1131    if radial <= 1e-12 {
1132        return Err(GeomError::Degenerate(format!(
1133            "{surface} point lies on the axis, where every u names it: \
1134             the angular parameter is not recoverable"
1135        )));
1136    }
1137    Ok(local.y.atan2(local.x))
1138}