axiolid_evaluate/
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, SpaceFrame, Tolerance, Vec3};
40use axiolid_surface::{
41    BSplineSurface, Cone, Cylinder, EllipticalCylinder, Plane, Sphere, Surface, Torus,
42};
43
44use crate::curve::{de_boor_recurrence, eval_homogeneous, span_in};
45use crate::nurbs::SplineAxis;
46
47/// A finite parameter rectangle for tessellation.
48///
49/// Elementary surfaces are infinite (a plane, a cylinder) or only periodic in
50/// one direction, so a caller must say which patch it wants. Returning a
51/// default would invent geometry the source never declared.
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub struct Patch {
54    /// Start of the `u` interval.
55    pub u_start: Scalar,
56    /// End of the `u` interval.
57    pub u_end: Scalar,
58    /// Start of the `v` interval.
59    pub v_start: Scalar,
60    /// End of the `v` interval.
61    pub v_end: Scalar,
62}
63
64/// Position and all first/second partial derivatives of a surface.
65#[derive(Debug, Clone, Copy, PartialEq)]
66pub struct SurfaceJet {
67    /// Position at `(u, v)`.
68    pub point: Point3,
69    /// First partial with respect to `u`.
70    pub du: Vec3,
71    /// First partial with respect to `v`.
72    pub dv: Vec3,
73    /// Second partial with respect to `u` twice.
74    pub duu: Vec3,
75    /// Mixed second partial.
76    pub duv: Vec3,
77    /// Second partial with respect to `v` twice.
78    pub dvv: Vec3,
79}
80
81impl Patch {
82    /// Construct a patch, rejecting an empty or non-finite rectangle.
83    pub fn new(u_start: Scalar, u_end: Scalar, v_start: Scalar, v_end: Scalar) -> GeomResult<Self> {
84        let all = [u_start, u_end, v_start, v_end];
85        if !all.iter().all(|value| value.is_finite()) {
86            return Err(GeomError::InvalidInput(format!(
87                "patch bounds must be finite, got {all:?}"
88            )));
89        }
90        if !(u_end > u_start && v_end > v_start) {
91            return Err(GeomError::Degenerate(format!(
92                "patch must have positive extent, got u {u_start}..{u_end}, v {v_start}..{v_end}"
93            )));
94        }
95        Ok(Self {
96            u_start,
97            u_end,
98            v_start,
99            v_end,
100        })
101    }
102
103    /// The full closed patch for a family that is periodic in `u`.
104    pub fn full_turn(v_start: Scalar, v_end: Scalar) -> GeomResult<Self> {
105        Self::new(0.0, core::f64::consts::TAU, v_start, v_end)
106    }
107}
108
109/// Map a local-frame point into world coordinates.
110fn place(frame: &Frame3, local: Vec3) -> Point3 {
111    frame.origin + frame.x * local.x + frame.y * local.y + frame.z * local.z
112}
113
114/// Map a local-frame direction into world coordinates (no translation).
115fn direct(frame: &Frame3, local: Vec3) -> Vec3 {
116    frame.x * local.x + frame.y * local.y + frame.z * local.z
117}
118
119fn finite(value: Scalar, what: &str) -> GeomResult<()> {
120    if value.is_finite() {
121        Ok(())
122    } else {
123        Err(GeomError::InvalidInput(format!(
124            "{what} must be finite, got {value}"
125        )))
126    }
127}
128
129fn positive(value: Scalar, what: &str) -> GeomResult<()> {
130    finite(value, what)?;
131    if value > 0.0 {
132        Ok(())
133    } else {
134        Err(GeomError::InvalidInput(format!(
135            "{what} must be positive, got {value}"
136        )))
137    }
138}
139
140fn finite_surface_frame(surface: &Surface) -> GeomResult<()> {
141    let frame = match surface {
142        Surface::Plane(value) => Some(&value.frame),
143        Surface::Cylinder(value) => Some(&value.frame),
144        Surface::Cone(value) => Some(&value.frame),
145        Surface::Sphere(value) => Some(&value.frame),
146        Surface::Torus(value) => Some(&value.frame),
147        Surface::EllipticalCylinder(value) => Some(&value.frame),
148        _ => None,
149    };
150    if frame.is_none_or(|frame| {
151        frame.origin.is_finite()
152            && frame.x.is_finite()
153            && frame.y.is_finite()
154            && frame.z.is_finite()
155    }) {
156        Ok(())
157    } else {
158        Err(GeomError::InvalidInput(
159            "surface frame must be finite".to_owned(),
160        ))
161    }
162}
163
164/// Position on a surface at `(u, v)`.
165pub fn evaluate(surface: &Surface, u: Scalar, v: Scalar) -> GeomResult<Point3> {
166    finite(u, "surface parameter u")?;
167    finite(v, "surface parameter v")?;
168    finite_surface_frame(surface)?;
169    let point = match surface {
170        Surface::Plane(p) => Ok(plane_point(p, u, v)),
171        Surface::Cylinder(c) => cylinder_point(c, u, v),
172        Surface::EllipticalCylinder(c) => elliptical_cylinder_point(c, u, v),
173        Surface::Cone(c) => cone_point(c, u, v),
174        Surface::Sphere(s) => sphere_point(s, u, v),
175        Surface::Torus(t) => torus_point(t, u, v),
176        Surface::BSpline(b) => bspline_point(b, u, v),
177        _ => Err(GeomError::Unsupported {
178            backend: ScalarSurface::ID,
179            operation: axiolid_contracts::Operation::SurfaceEvaluation,
180        }),
181    }?;
182    if point.is_finite() {
183        Ok(point)
184    } else {
185        Err(GeomError::Degenerate(
186            "surface point is non-finite".to_owned(),
187        ))
188    }
189}
190
191/// Analytic first partial derivatives `(∂S/∂u, ∂S/∂v)` at `(u, v)`.
192///
193/// Rational B-spline derivatives are evaluated in homogeneous space and
194/// projected with the quotient rule. This remains stable when valid imported
195/// knot domains have large offsets that make finite-difference steps vanish.
196pub fn partials(surface: &Surface, u: Scalar, v: Scalar) -> GeomResult<(Vec3, Vec3)> {
197    finite(u, "surface parameter u")?;
198    finite(v, "surface parameter v")?;
199    finite_surface_frame(surface)?;
200    let value = match surface {
201        Surface::Plane(p) => Ok((p.frame.x, p.frame.y)),
202        Surface::Cylinder(c) => {
203            positive(c.radius, "cylinder radius")?;
204            let (s, co) = u.sin_cos();
205            Ok((
206                direct(&c.frame, Vec3::new(-c.radius * s, c.radius * co, 0.0)),
207                c.frame.z,
208            ))
209        }
210        Surface::Cone(c) => {
211            finite(c.radius, "cone radius")?;
212            finite(c.semi_angle, "cone semi-angle")?;
213            let slope = c.semi_angle.tan();
214            let radius = c.radius + v * slope;
215            if radius < 0.0 {
216                return Err(GeomError::Degenerate(format!(
217                    "cone radius is negative at v = {v}: the patch crosses the apex"
218                )));
219            }
220            let (s, co) = u.sin_cos();
221            Ok((
222                direct(&c.frame, Vec3::new(-radius * s, radius * co, 0.0)),
223                direct(&c.frame, Vec3::new(slope * co, slope * s, 1.0)),
224            ))
225        }
226        Surface::Sphere(sphere) => {
227            positive(sphere.radius, "sphere radius")?;
228            let (su, cu) = u.sin_cos();
229            let (sv, cv) = v.sin_cos();
230            Ok((
231                direct(
232                    &sphere.frame,
233                    Vec3::new(-sphere.radius * cv * su, sphere.radius * cv * cu, 0.0),
234                ),
235                direct(
236                    &sphere.frame,
237                    Vec3::new(
238                        -sphere.radius * sv * cu,
239                        -sphere.radius * sv * su,
240                        sphere.radius * cv,
241                    ),
242                ),
243            ))
244        }
245        Surface::Torus(torus) => {
246            positive(torus.major_radius, "torus major radius")?;
247            positive(torus.minor_radius, "torus minor radius")?;
248            let (su, cu) = u.sin_cos();
249            let (sv, cv) = v.sin_cos();
250            let ring = torus.major_radius + torus.minor_radius * cv;
251            Ok((
252                direct(&torus.frame, Vec3::new(-ring * su, ring * cu, 0.0)),
253                direct(
254                    &torus.frame,
255                    Vec3::new(
256                        -torus.minor_radius * sv * cu,
257                        -torus.minor_radius * sv * su,
258                        torus.minor_radius * cv,
259                    ),
260                ),
261            ))
262        }
263        Surface::EllipticalCylinder(c) => {
264            positive(c.semi_axis_x, "elliptical cylinder semi-axis x")?;
265            positive(c.semi_axis_y, "elliptical cylinder semi-axis y")?;
266            let (su, cu) = u.sin_cos();
267            // The u-partial is NOT radial: its components carry different
268            // semi-axes, which is exactly why the normal of an elliptical
269            // cylinder is not its radial direction.
270            Ok((
271                direct(
272                    &c.frame,
273                    Vec3::new(-c.semi_axis_x * su, c.semi_axis_y * cu, 0.0),
274                ),
275                direct(&c.frame, Vec3::Z),
276            ))
277        }
278        Surface::BSpline(b) => bspline_partials(b, u, v),
279        _ => Err(GeomError::Unsupported {
280            backend: ScalarSurface::ID,
281            operation: axiolid_contracts::Operation::SurfaceEvaluation,
282        }),
283    }?;
284    if value.0.is_finite() && value.1.is_finite() {
285        Ok(value)
286    } else {
287        Err(GeomError::Degenerate(
288            "surface partial is non-finite".to_owned(),
289        ))
290    }
291}
292
293/// Second-order differential jet at `(u, v)`.
294///
295/// All values are analytic in the surface's native parameterisation. Rational
296/// B-splines are differentiated in homogeneous space before projection.
297pub fn jet(surface: &Surface, u: Scalar, v: Scalar) -> GeomResult<SurfaceJet> {
298    finite(u, "surface parameter u")?;
299    finite(v, "surface parameter v")?;
300    finite_surface_frame(surface)?;
301    let value = match surface {
302        Surface::Plane(p) => SurfaceJet {
303            point: plane_point(p, u, v),
304            du: p.frame.x,
305            dv: p.frame.y,
306            duu: Vec3::ZERO,
307            duv: Vec3::ZERO,
308            dvv: Vec3::ZERO,
309        },
310        Surface::Cylinder(c) => {
311            positive(c.radius, "cylinder radius")?;
312            let (s, co) = u.sin_cos();
313            SurfaceJet {
314                point: cylinder_point(c, u, v)?,
315                du: direct(&c.frame, Vec3::new(-c.radius * s, c.radius * co, 0.0)),
316                dv: c.frame.z,
317                duu: direct(&c.frame, Vec3::new(-c.radius * co, -c.radius * s, 0.0)),
318                duv: Vec3::ZERO,
319                dvv: Vec3::ZERO,
320            }
321        }
322        Surface::Cone(c) => {
323            finite(c.radius, "cone radius")?;
324            finite(c.semi_angle, "cone semi-angle")?;
325            let slope = c.semi_angle.tan();
326            let radius = c.radius + v * slope;
327            if radius < 0.0 {
328                return Err(GeomError::Degenerate(format!(
329                    "cone radius is negative at v = {v}: the patch crosses the apex"
330                )));
331            }
332            let (s, co) = u.sin_cos();
333            SurfaceJet {
334                point: cone_point(c, u, v)?,
335                du: direct(&c.frame, Vec3::new(-radius * s, radius * co, 0.0)),
336                dv: direct(&c.frame, Vec3::new(slope * co, slope * s, 1.0)),
337                duu: direct(&c.frame, Vec3::new(-radius * co, -radius * s, 0.0)),
338                duv: direct(&c.frame, Vec3::new(-slope * s, slope * co, 0.0)),
339                dvv: Vec3::ZERO,
340            }
341        }
342        Surface::Sphere(sphere) => {
343            positive(sphere.radius, "sphere radius")?;
344            let r = sphere.radius;
345            let (su, cu) = u.sin_cos();
346            let (sv, cv) = v.sin_cos();
347            SurfaceJet {
348                point: sphere_point(sphere, u, v)?,
349                du: direct(&sphere.frame, Vec3::new(-r * cv * su, r * cv * cu, 0.0)),
350                dv: direct(&sphere.frame, Vec3::new(-r * sv * cu, -r * sv * su, r * cv)),
351                duu: direct(&sphere.frame, Vec3::new(-r * cv * cu, -r * cv * su, 0.0)),
352                duv: direct(&sphere.frame, Vec3::new(r * sv * su, -r * sv * cu, 0.0)),
353                dvv: direct(
354                    &sphere.frame,
355                    Vec3::new(-r * cv * cu, -r * cv * su, -r * sv),
356                ),
357            }
358        }
359        Surface::Torus(torus) => {
360            positive(torus.major_radius, "torus major radius")?;
361            positive(torus.minor_radius, "torus minor radius")?;
362            let r = torus.minor_radius;
363            let (su, cu) = u.sin_cos();
364            let (sv, cv) = v.sin_cos();
365            let ring = torus.major_radius + r * cv;
366            SurfaceJet {
367                point: torus_point(torus, u, v)?,
368                du: direct(&torus.frame, Vec3::new(-ring * su, ring * cu, 0.0)),
369                dv: direct(&torus.frame, Vec3::new(-r * sv * cu, -r * sv * su, r * cv)),
370                duu: direct(&torus.frame, Vec3::new(-ring * cu, -ring * su, 0.0)),
371                duv: direct(&torus.frame, Vec3::new(r * sv * su, -r * sv * cu, 0.0)),
372                dvv: direct(&torus.frame, Vec3::new(-r * cv * cu, -r * cv * su, -r * sv)),
373            }
374        }
375        Surface::BSpline(b) => bspline_jet(b, u, v)?,
376        _ => {
377            return Err(GeomError::Unsupported {
378                backend: ScalarSurface::ID,
379                operation: axiolid_contracts::Operation::SurfaceEvaluation,
380            })
381        }
382    };
383    if [
384        value.point,
385        value.du,
386        value.dv,
387        value.duu,
388        value.duv,
389        value.dvv,
390    ]
391    .iter()
392    .all(|vector| vector.is_finite())
393    {
394        Ok(value)
395    } else {
396        Err(GeomError::Degenerate(
397            "surface differential jet is non-finite".to_owned(),
398        ))
399    }
400}
401
402/// Unit normal at `(u, v)`.
403///
404/// Computed from the exact analytic partial derivatives rather than by
405/// differencing evaluated points: a finite difference loses precision exactly
406/// where it matters most, at high curvature.
407pub fn normal(surface: &Surface, u: Scalar, v: Scalar) -> GeomResult<Vec3> {
408    finite(u, "surface parameter u")?;
409    finite(v, "surface parameter v")?;
410    finite_surface_frame(surface)?;
411    let n = match surface {
412        Surface::Plane(p) => p.frame.z,
413        Surface::Cylinder(c) => {
414            positive(c.radius, "cylinder radius")?;
415            let (s, co) = u.sin_cos();
416            direct(&c.frame, Vec3::new(co, s, 0.0))
417        }
418        Surface::Cone(c) => cone_normal(c, u)?,
419        Surface::EllipticalCylinder(c) => {
420            positive(c.semi_axis_x, "elliptical cylinder semi-axis x")?;
421            positive(c.semi_axis_y, "elliptical cylinder semi-axis y")?;
422            // NOT the radial direction. For a circular cylinder the two
423            // coincide, but for a 3:1 ellipse they differ by up to 53
424            // degrees, agreeing only at the four axis points. Taking the
425            // cross product of the partials is the definition and is right
426            // everywhere.
427            let (su, cu) = u.sin_cos();
428            let along_u = direct(
429                &c.frame,
430                Vec3::new(-c.semi_axis_x * su, c.semi_axis_y * cu, 0.0),
431            );
432            along_u.cross(c.frame.z)
433        }
434        Surface::Sphere(s) => {
435            positive(s.radius, "sphere radius")?;
436            let (su, cu) = u.sin_cos();
437            let (sv, cv) = v.sin_cos();
438            direct(&s.frame, Vec3::new(cv * cu, cv * su, sv))
439        }
440        Surface::Torus(t) => {
441            positive(t.minor_radius, "torus minor radius")?;
442            let (su, cu) = u.sin_cos();
443            let (sv, cv) = v.sin_cos();
444            direct(&t.frame, Vec3::new(cv * cu, cv * su, sv))
445        }
446        Surface::BSpline(b) => bspline_normal(b, u, v)?,
447        _ => {
448            return Err(GeomError::Unsupported {
449                backend: ScalarSurface::ID,
450                operation: axiolid_contracts::Operation::SurfaceEvaluation,
451            })
452        }
453    };
454    let length = n.length();
455    if !(length > 0.0 && n.is_finite()) {
456        return Err(GeomError::Degenerate(format!(
457            "surface normal is not orientable at ({u}, {v})"
458        )));
459    }
460    Ok(n / length)
461}
462
463fn plane_point(p: &Plane, u: Scalar, v: Scalar) -> Point3 {
464    place(&p.frame, Vec3::new(u, v, 0.0))
465}
466
467fn cylinder_point(c: &Cylinder, u: Scalar, v: Scalar) -> GeomResult<Point3> {
468    positive(c.radius, "cylinder radius")?;
469    let (s, co) = u.sin_cos();
470    Ok(place(&c.frame, Vec3::new(c.radius * co, c.radius * s, v)))
471}
472
473fn elliptical_cylinder_point(c: &EllipticalCylinder, u: Scalar, v: Scalar) -> GeomResult<Point3> {
474    positive(c.semi_axis_x, "elliptical cylinder semi-axis x")?;
475    positive(c.semi_axis_y, "elliptical cylinder semi-axis y")?;
476    let (s, co) = u.sin_cos();
477    Ok(place(
478        &c.frame,
479        Vec3::new(c.semi_axis_x * co, c.semi_axis_y * s, v),
480    ))
481}
482
483fn cone_point(c: &Cone, u: Scalar, v: Scalar) -> GeomResult<Point3> {
484    finite(c.radius, "cone radius")?;
485    finite(c.semi_angle, "cone semi-angle")?;
486    // Radius shrinks with height at the semi-angle; a negative radius means
487    // the surface has passed through the apex, which is not a valid patch.
488    let r = c.radius + v * c.semi_angle.tan();
489    if r < 0.0 {
490        return Err(GeomError::Degenerate(format!(
491            "cone radius is negative at v = {v}: the patch crosses the apex"
492        )));
493    }
494    let (s, co) = u.sin_cos();
495    Ok(place(&c.frame, Vec3::new(r * co, r * s, v)))
496}
497
498fn cone_normal(c: &Cone, u: Scalar) -> GeomResult<Vec3> {
499    finite(c.semi_angle, "cone semi-angle")?;
500    let (s, co) = u.sin_cos();
501    // Outward radial component, tilted by the semi-angle: the normal leans
502    // toward the axis as the cone narrows.
503    let (sa, ca) = c.semi_angle.sin_cos();
504    Ok(direct(&c.frame, Vec3::new(ca * co, ca * s, -sa)))
505}
506
507fn sphere_point(s: &Sphere, u: Scalar, v: Scalar) -> GeomResult<Point3> {
508    positive(s.radius, "sphere radius")?;
509    let (su, cu) = u.sin_cos();
510    let (sv, cv) = v.sin_cos();
511    Ok(place(
512        &s.frame,
513        Vec3::new(s.radius * cv * cu, s.radius * cv * su, s.radius * sv),
514    ))
515}
516
517fn torus_point(t: &Torus, u: Scalar, v: Scalar) -> GeomResult<Point3> {
518    positive(t.major_radius, "torus major radius")?;
519    positive(t.minor_radius, "torus minor radius")?;
520    let (su, cu) = u.sin_cos();
521    let (sv, cv) = v.sin_cos();
522    let ring = t.major_radius + t.minor_radius * cv;
523    Ok(place(
524        &t.frame,
525        Vec3::new(ring * cu, ring * su, t.minor_radius * sv),
526    ))
527}
528
529// --- tensor-product B-spline ------------------------------------------------
530
531type Axis = SplineAxis;
532
533/// Validate the control net and both axes together.
534fn bspline_axes(b: &BSplineSurface) -> GeomResult<(Axis, Axis)> {
535    let rows = b.control_points.len();
536    if rows == 0 {
537        return Err(GeomError::InvalidInput(
538            "B-spline surface has no control points".to_owned(),
539        ));
540    }
541    let cols = b.control_points[0].len();
542    if cols == 0 {
543        return Err(GeomError::InvalidInput(
544            "B-spline surface control net has an empty row".to_owned(),
545        ));
546    }
547    // A ragged net is a data error, not something to paper over: evaluating it
548    // would silently read a different surface than the source declared.
549    if b.control_points.iter().any(|row| row.len() != cols) {
550        return Err(GeomError::InvalidInput(
551            "B-spline surface control net is ragged".to_owned(),
552        ));
553    }
554    if b.control_points
555        .iter()
556        .flatten()
557        .any(|point| !point.is_finite())
558    {
559        return Err(GeomError::InvalidInput(
560            "B-spline surface control points must be finite".to_owned(),
561        ));
562    }
563    if let Some(w) = &b.weights {
564        if w.len() != rows || w.iter().any(|row| row.len() != cols) {
565            return Err(GeomError::InvalidInput(
566                "B-spline surface weight net does not match the control net".to_owned(),
567            ));
568        }
569        if w.iter()
570            .flatten()
571            .any(|weight| !weight.is_finite() || *weight <= 0.0)
572        {
573            return Err(GeomError::InvalidInput(
574                "B-spline surface weights must be finite and strictly positive".to_owned(),
575            ));
576        }
577    }
578    let u = Axis::new(&b.u_knots, &b.u_multiplicities, b.u_degree, rows, "u")?;
579    let v = Axis::new(&b.v_knots, &b.v_multiplicities, b.v_degree, cols, "v")?;
580    Ok((u, v))
581}
582
583/// Tensor-product de Boor: evaluate along `v` per influencing row, then along
584/// `u` through those results.
585///
586/// Rational surfaces interpolate in homogeneous space throughout; projecting
587/// per row and averaging afterwards is the classic wrong answer.
588fn bspline_point(b: &BSplineSurface, u: Scalar, v: Scalar) -> GeomResult<Point3> {
589    let (ua, va) = bspline_axes(b)?;
590    let (uc, vc) = (ua.clamp(u), va.clamp(v));
591    let uspan = span_in(&ua.knots, ua.count, ua.degree, uc);
592    let vspan = span_in(&va.knots, va.count, va.degree, vc);
593
594    // Stage one: collapse each influencing row along v, staying homogeneous.
595    let mut row_points: Vec<[Scalar; 3]> = Vec::with_capacity(ua.degree + 1);
596    let mut row_weights: Vec<Scalar> = Vec::with_capacity(ua.degree + 1);
597    for i in 0..=ua.degree {
598        let row = uspan - ua.degree + i;
599        let mut pts: Vec<[Scalar; 3]> = Vec::with_capacity(va.degree + 1);
600        let mut wts: Vec<Scalar> = Vec::with_capacity(va.degree + 1);
601        for j in 0..=va.degree {
602            let col = vspan - va.degree + j;
603            let w = b.weights.as_ref().map_or(1.0, |ws| ws[row][col]);
604            let p = b.control_points[row][col];
605            let homogeneous = [p.x * w, p.y * w, p.z * w];
606            if homogeneous.iter().any(|value| !value.is_finite()) {
607                return Err(GeomError::Degenerate(
608                    "B-spline surface homogeneous control point overflowed".to_owned(),
609                ));
610            }
611            pts.push(homogeneous);
612            wts.push(w);
613        }
614        de_boor_recurrence(&va.knots, vspan, va.degree, vc, &mut pts, &mut wts);
615        row_points.push(pts[va.degree]);
616        row_weights.push(wts[va.degree]);
617    }
618
619    // Stage two: collapse the row results along u.
620    de_boor_recurrence(
621        &ua.knots,
622        uspan,
623        ua.degree,
624        uc,
625        &mut row_points,
626        &mut row_weights,
627    );
628
629    let w = row_weights[ua.degree];
630    if !w.is_finite() || w == 0.0 {
631        return Err(GeomError::Degenerate(
632            "B-spline surface weight collapsed to zero".to_owned(),
633        ));
634    }
635    let p = row_points[ua.degree];
636    Ok(Point3::new(p[0] / w, p[1] / w, p[2] / w))
637}
638
639/// Borrowed knot axes for one homogeneous tensor-product evaluation.
640#[derive(Clone, Copy)]
641struct HomogeneousAxes<'a> {
642    u_knots: &'a [Scalar],
643    u_degree: usize,
644    v_knots: &'a [Scalar],
645    v_degree: usize,
646}
647
648/// Evaluate one homogeneous tensor-product control net without projecting.
649fn eval_tensor_homogeneous(
650    axes: HomogeneousAxes<'_>,
651    points: &[Vec<[Scalar; 3]>],
652    weights: &[Vec<Scalar>],
653    u: Scalar,
654    v: Scalar,
655) -> ([Scalar; 3], Scalar) {
656    let mut row_points = Vec::with_capacity(points.len());
657    let mut row_weights = Vec::with_capacity(points.len());
658    for (row_points_h, row_weights_h) in points.iter().zip(weights) {
659        let (point, weight) =
660            eval_homogeneous(axes.v_knots, axes.v_degree, row_points_h, row_weights_h, v);
661        row_points.push(point);
662        row_weights.push(weight);
663    }
664    eval_homogeneous(axes.u_knots, axes.u_degree, &row_points, &row_weights, u)
665}
666
667type HomogeneousPointNet = Vec<Vec<[Scalar; 3]>>;
668type HomogeneousWeightNet = Vec<Vec<Scalar>>;
669
670/// Homogeneous point and weight control nets for a validated surface.
671fn homogeneous_control_net(
672    b: &BSplineSurface,
673) -> GeomResult<(HomogeneousPointNet, HomogeneousWeightNet)> {
674    let mut points = Vec::with_capacity(b.control_points.len());
675    let mut weights = Vec::with_capacity(b.control_points.len());
676    for (i, row) in b.control_points.iter().enumerate() {
677        let mut point_row = Vec::with_capacity(row.len());
678        let mut weight_row = Vec::with_capacity(row.len());
679        for (j, point) in row.iter().enumerate() {
680            let weight = b.weights.as_ref().map_or(1.0, |net| net[i][j]);
681            let homogeneous = [point.x * weight, point.y * weight, point.z * weight];
682            if homogeneous.iter().any(|value| !value.is_finite()) {
683                return Err(GeomError::Degenerate(
684                    "B-spline surface homogeneous control point overflowed".to_owned(),
685                ));
686            }
687            point_row.push(homogeneous);
688            weight_row.push(weight);
689        }
690        points.push(point_row);
691        weights.push(weight_row);
692    }
693    Ok((points, weights))
694}
695
696/// Differentiate a homogeneous control net along `u`.
697fn derivative_net_u(
698    points: &[Vec<[Scalar; 3]>],
699    weights: &[Vec<Scalar>],
700    knots: &[Scalar],
701    degree: usize,
702) -> (Vec<Vec<[Scalar; 3]>>, Vec<Vec<Scalar>>) {
703    let rows = points.len() - 1;
704    let cols = points[0].len();
705    let mut derivative_points = Vec::with_capacity(rows);
706    let mut derivative_weights = Vec::with_capacity(rows);
707    for i in 0..rows {
708        let denominator = knots[i + degree + 1] - knots[i + 1];
709        let factor = if denominator.abs() > 0.0 {
710            degree as Scalar / denominator
711        } else {
712            0.0
713        };
714        let mut point_row = Vec::with_capacity(cols);
715        let mut weight_row = Vec::with_capacity(cols);
716        for j in 0..cols {
717            point_row.push(core::array::from_fn(|k| {
718                factor * (points[i + 1][j][k] - points[i][j][k])
719            }));
720            weight_row.push(factor * (weights[i + 1][j] - weights[i][j]));
721        }
722        derivative_points.push(point_row);
723        derivative_weights.push(weight_row);
724    }
725    (derivative_points, derivative_weights)
726}
727
728/// Differentiate a homogeneous control net along `v`.
729fn derivative_net_v(
730    points: &[Vec<[Scalar; 3]>],
731    weights: &[Vec<Scalar>],
732    knots: &[Scalar],
733    degree: usize,
734) -> (Vec<Vec<[Scalar; 3]>>, Vec<Vec<Scalar>>) {
735    let rows = points.len();
736    let cols = points[0].len() - 1;
737    let mut derivative_points = Vec::with_capacity(rows);
738    let mut derivative_weights = Vec::with_capacity(rows);
739    for i in 0..rows {
740        let mut point_row = Vec::with_capacity(cols);
741        let mut weight_row = Vec::with_capacity(cols);
742        for j in 0..cols {
743            let denominator = knots[j + degree + 1] - knots[j + 1];
744            let factor = if denominator.abs() > 0.0 {
745                degree as Scalar / denominator
746            } else {
747                0.0
748            };
749            point_row.push(core::array::from_fn(|k| {
750                factor * (points[i][j + 1][k] - points[i][j][k])
751            }));
752            weight_row.push(factor * (weights[i][j + 1] - weights[i][j]));
753        }
754        derivative_points.push(point_row);
755        derivative_weights.push(weight_row);
756    }
757    (derivative_points, derivative_weights)
758}
759
760/// Project one homogeneous derivative with the rational quotient rule.
761fn project_derivative(
762    point: [Scalar; 3],
763    weight: Scalar,
764    derivative: [Scalar; 3],
765    derivative_weight: Scalar,
766    axis: &str,
767) -> GeomResult<Vec3> {
768    if !weight.is_finite() || weight == 0.0 {
769        return Err(GeomError::Degenerate(
770            "B-spline surface weight collapsed to a non-finite or zero value".to_owned(),
771        ));
772    }
773    let value = Vec3::new(
774        (derivative[0] - point[0] * derivative_weight / weight) / weight,
775        (derivative[1] - point[1] * derivative_weight / weight) / weight,
776        (derivative[2] - point[2] * derivative_weight / weight) / weight,
777    );
778    if !value.is_finite() {
779        return Err(GeomError::Degenerate(format!(
780            "B-spline surface {axis} derivative is non-finite"
781        )));
782    }
783    Ok(value)
784}
785
786/// Exact first partials of a rational tensor-product B-spline.
787fn bspline_partials(b: &BSplineSurface, u: Scalar, v: Scalar) -> GeomResult<(Vec3, Vec3)> {
788    let (ua, va) = bspline_axes(b)?;
789    let (uc, vc) = (ua.clamp(u), va.clamp(v));
790    let (points, weights) = homogeneous_control_net(b)?;
791    let (point, weight) = eval_tensor_homogeneous(
792        HomogeneousAxes {
793            u_knots: &ua.knots,
794            u_degree: ua.degree,
795            v_knots: &va.knots,
796            v_degree: va.degree,
797        },
798        &points,
799        &weights,
800        uc,
801        vc,
802    );
803
804    let (u_points, u_weights) = derivative_net_u(&points, &weights, &ua.knots, ua.degree);
805    let (du, du_weight) = eval_tensor_homogeneous(
806        HomogeneousAxes {
807            u_knots: &ua.knots[1..ua.knots.len() - 1],
808            u_degree: ua.degree - 1,
809            v_knots: &va.knots,
810            v_degree: va.degree,
811        },
812        &u_points,
813        &u_weights,
814        uc,
815        vc,
816    );
817
818    let (v_points, v_weights) = derivative_net_v(&points, &weights, &va.knots, va.degree);
819    let (dv, dv_weight) = eval_tensor_homogeneous(
820        HomogeneousAxes {
821            u_knots: &ua.knots,
822            u_degree: ua.degree,
823            v_knots: &va.knots[1..va.knots.len() - 1],
824            v_degree: va.degree - 1,
825        },
826        &v_points,
827        &v_weights,
828        uc,
829        vc,
830    );
831
832    Ok((
833        project_derivative(point, weight, du, du_weight, "u")?,
834        project_derivative(point, weight, dv, dv_weight, "v")?,
835    ))
836}
837
838/// Full second-order jet of a rational tensor-product B-spline.
839/// Second-order differential jet of a B-spline surface without enum wrapping.
840pub fn bspline_jet(b: &BSplineSurface, u: Scalar, v: Scalar) -> GeomResult<SurfaceJet> {
841    let (ua, va) = bspline_axes(b)?;
842    let (uc, vc) = (ua.clamp(u), va.clamp(v));
843    let (points, weights) = homogeneous_control_net(b)?;
844    let base_axes = HomogeneousAxes {
845        u_knots: &ua.knots,
846        u_degree: ua.degree,
847        v_knots: &va.knots,
848        v_degree: va.degree,
849    };
850    let (point, weight) = eval_tensor_homogeneous(base_axes, &points, &weights, uc, vc);
851    if !weight.is_finite() || weight == 0.0 {
852        return Err(GeomError::Degenerate(
853            "B-spline surface weight collapsed to a non-finite or zero value".to_owned(),
854        ));
855    }
856    let position = Point3::new(point[0] / weight, point[1] / weight, point[2] / weight);
857
858    let (u_points, u_weights) = derivative_net_u(&points, &weights, &ua.knots, ua.degree);
859    let u_knots = &ua.knots[1..ua.knots.len() - 1];
860    let (du_h, du_weight) = eval_tensor_homogeneous(
861        HomogeneousAxes {
862            u_knots,
863            u_degree: ua.degree - 1,
864            v_knots: &va.knots,
865            v_degree: va.degree,
866        },
867        &u_points,
868        &u_weights,
869        uc,
870        vc,
871    );
872    let du = project_derivative(point, weight, du_h, du_weight, "u")?;
873
874    let (v_points, v_weights) = derivative_net_v(&points, &weights, &va.knots, va.degree);
875    let v_knots = &va.knots[1..va.knots.len() - 1];
876    let (dv_h, dv_weight) = eval_tensor_homogeneous(
877        HomogeneousAxes {
878            u_knots: &ua.knots,
879            u_degree: ua.degree,
880            v_knots,
881            v_degree: va.degree - 1,
882        },
883        &v_points,
884        &v_weights,
885        uc,
886        vc,
887    );
888    let dv = project_derivative(point, weight, dv_h, dv_weight, "v")?;
889
890    let (duu_h, duu_weight) = if ua.degree >= 2 {
891        let (net, net_weights) = derivative_net_u(&u_points, &u_weights, u_knots, ua.degree - 1);
892        eval_tensor_homogeneous(
893            HomogeneousAxes {
894                u_knots: &u_knots[1..u_knots.len() - 1],
895                u_degree: ua.degree - 2,
896                v_knots: &va.knots,
897                v_degree: va.degree,
898            },
899            &net,
900            &net_weights,
901            uc,
902            vc,
903        )
904    } else {
905        ([0.0; 3], 0.0)
906    };
907    let duu = project_second(point, weight, du, duu_h, du_weight, duu_weight, "uu")?;
908
909    let (dvv_h, dvv_weight) = if va.degree >= 2 {
910        let (net, net_weights) = derivative_net_v(&v_points, &v_weights, v_knots, va.degree - 1);
911        eval_tensor_homogeneous(
912            HomogeneousAxes {
913                u_knots: &ua.knots,
914                u_degree: ua.degree,
915                v_knots: &v_knots[1..v_knots.len() - 1],
916                v_degree: va.degree - 2,
917            },
918            &net,
919            &net_weights,
920            uc,
921            vc,
922        )
923    } else {
924        ([0.0; 3], 0.0)
925    };
926    let dvv = project_second(point, weight, dv, dvv_h, dv_weight, dvv_weight, "vv")?;
927
928    let (uv_points, uv_weights) = derivative_net_v(&u_points, &u_weights, &va.knots, va.degree);
929    let (duv_h, duv_weight) = eval_tensor_homogeneous(
930        HomogeneousAxes {
931            u_knots,
932            u_degree: ua.degree - 1,
933            v_knots,
934            v_degree: va.degree - 1,
935        },
936        &uv_points,
937        &uv_weights,
938        uc,
939        vc,
940    );
941    let duv = project_mixed(
942        point, weight, du, du_weight, dv, dv_weight, duv_h, duv_weight,
943    )?;
944
945    Ok(SurfaceJet {
946        point: position,
947        du,
948        dv,
949        duu,
950        duv,
951        dvv,
952    })
953}
954
955fn project_second(
956    point: [Scalar; 3],
957    weight: Scalar,
958    first: Vec3,
959    second: [Scalar; 3],
960    first_weight: Scalar,
961    second_weight: Scalar,
962    axis: &str,
963) -> GeomResult<Vec3> {
964    let position = Vec3::new(point[0], point[1], point[2]) / weight;
965    let value = (Vec3::new(second[0], second[1], second[2])
966        - 2.0 * first_weight * first
967        - second_weight * position)
968        / weight;
969    if value.is_finite() {
970        Ok(value)
971    } else {
972        Err(GeomError::Degenerate(format!(
973            "B-spline surface {axis} second derivative is non-finite"
974        )))
975    }
976}
977
978#[allow(clippy::too_many_arguments)]
979fn project_mixed(
980    point: [Scalar; 3],
981    weight: Scalar,
982    du: Vec3,
983    du_weight: Scalar,
984    dv: Vec3,
985    dv_weight: Scalar,
986    mixed: [Scalar; 3],
987    mixed_weight: Scalar,
988) -> GeomResult<Vec3> {
989    let position = Vec3::new(point[0], point[1], point[2]) / weight;
990    let value = (Vec3::new(mixed[0], mixed[1], mixed[2])
991        - du_weight * dv
992        - dv_weight * du
993        - mixed_weight * position)
994        / weight;
995    if value.is_finite() {
996        Ok(value)
997    } else {
998        Err(GeomError::Degenerate(
999            "B-spline surface uv mixed derivative is non-finite".to_owned(),
1000        ))
1001    }
1002}
1003
1004/// Normal from analytic rational tensor-product partial derivatives.
1005fn bspline_normal(b: &BSplineSurface, u: Scalar, v: Scalar) -> GeomResult<Vec3> {
1006    let (du, dv) = bspline_partials(b, u, v)?;
1007    Ok(du.cross(dv))
1008}
1009
1010/// The [`axiolid_surface::SurfaceEvaluator`] implementation, so a caller can dispatch through
1011/// the trait rather than the free functions.
1012#[derive(Debug, Default, Clone, Copy)]
1013pub struct ScalarSurface;
1014
1015impl ScalarSurface {
1016    /// Identity reported in structured errors, matching `ScalarBoolean`.
1017    pub const ID: BackendId = BackendId::new("scalar-reference");
1018}
1019
1020impl axiolid_surface::SurfaceEvaluator<Surface> for ScalarSurface {
1021    type Error = GeomError;
1022
1023    fn evaluate(
1024        &self,
1025        surface: &Surface,
1026        u: Scalar,
1027        v: Scalar,
1028        _tolerance: axiolid_core::Tolerance,
1029    ) -> Result<Point3, Self::Error> {
1030        evaluate(surface, u, v)
1031    }
1032
1033    fn normal(
1034        &self,
1035        surface: &Surface,
1036        u: Scalar,
1037        v: Scalar,
1038        _tolerance: axiolid_core::Tolerance,
1039    ) -> Result<Vec3, Self::Error> {
1040        normal(surface, u, v)
1041    }
1042}
1043
1044/// Surface parameters `(u, v)` whose evaluation reproduces `point`.
1045///
1046/// This is the exact inverse of [`evaluate`] for the analytic surfaces,
1047/// derived from each parameterisation rather than found by iteration, so
1048/// it neither needs a seed nor converges to a nearby-but-wrong branch.
1049///
1050/// The point must already lie ON the surface: this answers "which
1051/// parameters name this point", not "which point is nearest". A sweep
1052/// directrix that has drifted off its reference surface is a modelling
1053/// error, and silently projecting it would tilt every section frame by an
1054/// amount nothing downstream can detect. The residual is therefore checked
1055/// against `tolerance` and a miss is reported rather than absorbed.
1056///
1057/// Parameters that no unique answer exists for are refused, not guessed:
1058/// at a cone apex or a sphere pole the whole `u` circle maps to one point,
1059/// so any choice would be arbitrary and would rotate the swept section.
1060pub fn invert(
1061    surface: &Surface,
1062    point: Point3,
1063    tolerance: axiolid_core::Tolerance,
1064) -> GeomResult<(Scalar, Scalar)> {
1065    let (u, v) = match surface {
1066        Surface::Plane(p) => {
1067            let local = to_local(&p.frame, point, tolerance)?;
1068            (local.x, local.y)
1069        }
1070        Surface::Cylinder(c) => {
1071            positive(c.radius, "cylinder radius")?;
1072            let local = to_local(&c.frame, point, tolerance)?;
1073            (angle_about_axis(local, "cylinder")?, local.z)
1074        }
1075        Surface::Cone(c) => {
1076            finite(c.radius, "cone radius")?;
1077            finite(c.semi_angle, "cone semi-angle")?;
1078            let local = to_local(&c.frame, point, tolerance)?;
1079            // At the apex the radius vanishes and every u names the same
1080            // point, so the angle is unrecoverable rather than merely
1081            // imprecise.
1082            (angle_about_axis(local, "cone")?, local.z)
1083        }
1084        Surface::Sphere(s) => {
1085            positive(s.radius, "sphere radius")?;
1086            let local = to_local(&s.frame, point, tolerance)?;
1087            // Latitude first: it is well defined even at the poles, which
1088            // the angle lookup then rejects.
1089            let sin_v = (local.z / s.radius).clamp(-1.0, 1.0);
1090            (angle_about_axis(local, "sphere")?, sin_v.asin())
1091        }
1092        Surface::Torus(t) => {
1093            positive(t.major_radius, "torus major radius")?;
1094            positive(t.minor_radius, "torus minor radius")?;
1095            let local = to_local(&t.frame, point, tolerance)?;
1096            let ring = (local.x * local.x + local.y * local.y).sqrt();
1097            (
1098                angle_about_axis(local, "torus")?,
1099                (local.z).atan2(ring - t.major_radius),
1100            )
1101        }
1102        // A B-spline has no closed-form inverse; recovering parameters
1103        // needs iterative closest-point with its own seeding and
1104        // convergence contract, which `locate` provides. `Surface` is
1105        // non-exhaustive, so any future variant lands here too and is
1106        // refused by name rather than silently taking an analytic branch
1107        // that does not fit it.
1108        _ => {
1109            return Err(GeomError::Unsupported {
1110                backend: ScalarSurface::ID,
1111                operation: axiolid_contracts::Operation::SurfaceEvaluation,
1112            });
1113        }
1114    };
1115    // The parameters are only meaningful if they reproduce the point.
1116    // This is what turns a silent mis-parameterisation into an error.
1117    let round_trip = evaluate(surface, u, v)?;
1118    let residual = (round_trip - point).length();
1119    if residual > tolerance.linear() {
1120        return Err(GeomError::Degenerate(format!(
1121            "point is {residual} from the surface, beyond the {} tolerance: \
1122             inversion names a point ON the surface and does not project",
1123            tolerance.linear()
1124        )));
1125    }
1126    Ok((u, v))
1127}
1128
1129/// Parameters of the closest point on a surface to an arbitrary point.
1130///
1131/// This is the counterpart to [`invert`], and the distinction matters.
1132/// `invert` names a point that is ALREADY on the surface and refuses one
1133/// that is not. Projection accepts a point anywhere and answers where the
1134/// surface is nearest to it.
1135///
1136/// Refinement needs projection, not inversion: the midpoint of a chord
1137/// across a faceted cylinder lies strictly inside the cylinder, so
1138/// inversion correctly refuses it while projection is exactly the question
1139/// being asked.
1140///
1141/// Every arm here is a closed form, so the result is exact rather than the
1142/// stopping point of an iteration. Where the closest point is genuinely
1143/// ambiguous -- a point on a cylinder's axis is equidistant from every
1144/// point of the surface -- this refuses by name instead of returning one
1145/// arbitrary member of the tie.
1146///
1147/// # Errors
1148///
1149/// Refuses a non-orthonormal frame, a non-finite point, a degenerate
1150/// radius, an ambiguous (equidistant) configuration, and any surface with
1151/// no closed-form projection.
1152pub fn project(
1153    surface: &Surface,
1154    point: Point3,
1155    tolerance: axiolid_core::Tolerance,
1156) -> GeomResult<(Scalar, Scalar)> {
1157    let ambiguous = |what: &str| {
1158        GeomError::Degenerate(format!(
1159            "{what}: the closest point is not unique, so no projection names it"
1160        ))
1161    };
1162    let (u, v) = match surface {
1163        // A plane's closest point is the orthogonal foot, which the local
1164        // frame already gives directly.
1165        Surface::Plane(p) => {
1166            let local = to_local(&p.frame, point, tolerance)?;
1167            (local.x, local.y)
1168        }
1169        // Radial projection: slide along the axis-perpendicular direction
1170        // to the radius. Undefined exactly on the axis.
1171        Surface::Cylinder(c) => {
1172            positive(c.radius, "cylinder radius")?;
1173            let local = to_local(&c.frame, point, tolerance)?;
1174            let ring = (local.x * local.x + local.y * local.y).sqrt();
1175            if ring <= tolerance.linear() {
1176                return Err(ambiguous("point lies on the cylinder axis"));
1177            }
1178            (local.y.atan2(local.x), local.z)
1179        }
1180        // Radial projection from the centre. Undefined exactly at it.
1181        Surface::Sphere(s) => {
1182            positive(s.radius, "sphere radius")?;
1183            let local = to_local(&s.frame, point, tolerance)?;
1184            let distance = (local.x * local.x + local.y * local.y + local.z * local.z).sqrt();
1185            if distance <= tolerance.linear() {
1186                return Err(ambiguous("point lies at the sphere centre"));
1187            }
1188            let ring = (local.x * local.x + local.y * local.y).sqrt();
1189            if ring <= tolerance.linear() {
1190                return Err(ambiguous("point lies on the sphere's polar axis"));
1191            }
1192            let sin_v = (local.z / distance).clamp(-1.0, 1.0);
1193            (local.y.atan2(local.x), sin_v.asin())
1194        }
1195        // The nearest point on a cone is along the SLANT, not the radius:
1196        // the generator is a line in the (rho, z) half-plane, so project
1197        // onto that line rather than onto a circle of constant z.
1198        Surface::Cone(c) => {
1199            finite(c.radius, "cone radius")?;
1200            finite(c.semi_angle, "cone semi-angle")?;
1201            let local = to_local(&c.frame, point, tolerance)?;
1202            let ring = (local.x * local.x + local.y * local.y).sqrt();
1203            if ring <= tolerance.linear() {
1204                return Err(ambiguous("point lies on the cone axis"));
1205            }
1206            let slope = c.semi_angle.tan();
1207            if !slope.is_finite() {
1208                return Err(GeomError::Degenerate(
1209                    "cone semi-angle is a right angle: the surface degenerates to a plane".into(),
1210                ));
1211            }
1212            // Generator through (radius, 0) with direction (slope, 1),
1213            // normalised so the dot product is a true arc position.
1214            let length = (slope * slope + 1.0).sqrt();
1215            let (dr, dz) = (slope / length, 1.0 / length);
1216            let step = (ring - c.radius) * dr + local.z * dz;
1217            let foot_radius = c.radius + step * dr;
1218            // Past the apex the foot crosses onto the mirrored nappe,
1219            // which is a different sheet of the surface.
1220            if foot_radius < 0.0 {
1221                return Err(GeomError::Degenerate(
1222                    "closest point on the cone lies beyond the apex, on the opposite nappe".into(),
1223                ));
1224            }
1225            (local.y.atan2(local.x), step * dz)
1226        }
1227        // Reduce to the tube's cross-section circle: project onto the
1228        // major circle first, then onto the tube around it. Both steps are
1229        // radial, so the composition is closed form.
1230        Surface::Torus(t) => {
1231            positive(t.major_radius, "torus major radius")?;
1232            positive(t.minor_radius, "torus minor radius")?;
1233            let local = to_local(&t.frame, point, tolerance)?;
1234            let ring = (local.x * local.x + local.y * local.y).sqrt();
1235            if ring <= tolerance.linear() {
1236                return Err(ambiguous("point lies on the torus axis"));
1237            }
1238            let planar = ring - t.major_radius;
1239            // On the tube's centre circle every cross-section angle is
1240            // equidistant, the same tie the axis case has one dimension up.
1241            if planar.abs() <= tolerance.linear() && local.z.abs() <= tolerance.linear() {
1242                return Err(ambiguous("point lies on the torus tube centre circle"));
1243            }
1244            (local.y.atan2(local.x), local.z.atan2(planar))
1245        }
1246        // A B-spline needs iterative closest-point with its own seeding and
1247        // convergence contract, which `axiolid-nurbs` provides as a
1248        // certified projection. `Surface` is non-exhaustive, so a future
1249        // variant is refused here by name rather than silently taking an
1250        // analytic branch that does not describe it.
1251        _ => {
1252            return Err(GeomError::Unsupported {
1253                backend: ScalarSurface::ID,
1254                operation: axiolid_contracts::Operation::SurfaceEvaluation,
1255            });
1256        }
1257    };
1258    // Projection promises a point ON the surface, so the parameters must
1259    // evaluate to one. `invert` is the independent check: it refuses a
1260    // point further than tolerance from the surface, so a wrong arm here
1261    // is caught rather than inherited by the caller as bad geometry.
1262    let landed = evaluate(surface, u, v)?;
1263    invert(surface, landed, tolerance).map_err(|_| {
1264        GeomError::Degenerate(
1265            "projection produced parameters that do not name a point on the surface".into(),
1266        )
1267    })?;
1268    Ok((u, v))
1269}
1270
1271/// Express a world point in a frame's local coordinates.
1272///
1273/// `place` maps local to world by scaling the frame axes, so the inverse
1274/// is a projection onto those axes -- but ONLY when they are orthonormal.
1275/// `Frame3` stores three free vectors and the core documents that
1276/// algorithms validate orthonormality explicitly, so this checks rather
1277/// than assumes: on a skewed or scaled frame a dot-product projection is
1278/// silently wrong, and every parameter derived from it would be wrong by
1279/// an amount that still round-trips through the same bad frame.
1280fn to_local(frame: &Frame3, point: Point3, tolerance: Tolerance) -> GeomResult<Vec3> {
1281    // Validation lives in the core SpaceFrame so surface evaluation, sampled
1282    // fields, and sectioning cannot drift apart on what a valid frame is.
1283    // Handedness matters here and was previously unchecked: a mirrored basis
1284    // passes every unit-length and perpendicularity test while reflecting the
1285    // surface it parameterises.
1286    let validated = SpaceFrame::new(frame.origin, frame.x, frame.y, frame.z, tolerance)
1287        .map_err(|error| GeomError::Degenerate(format!("surface frame is invalid: {error}")))?;
1288    Ok(validated.to_local(point))
1289}
1290
1291/// Angle of a local point about the frame's z axis.
1292///
1293/// Refuses points ON the axis. There the whole `u` circle collapses to a
1294/// single location -- a cone apex, a sphere pole -- so no angle is more
1295/// correct than any other. Returning zero would look successful and would
1296/// rotate a swept section arbitrarily about its own path.
1297fn angle_about_axis(local: Vec3, surface: &str) -> GeomResult<Scalar> {
1298    let radial = (local.x * local.x + local.y * local.y).sqrt();
1299    if radial <= 1e-12 {
1300        return Err(GeomError::Degenerate(format!(
1301            "{surface} point lies on the axis, where every u names it: \
1302             the angular parameter is not recoverable"
1303        )));
1304    }
1305    Ok(local.y.atan2(local.x))
1306}
1307
1308/// Parameters of a point on a surface, iterating where no closed form
1309/// exists: [`invert`] first, and for a B-spline the nearest point of a
1310/// sample grid refined by Gauss-Newton. The answer must reproduce the point
1311/// within `tolerance`, as [`invert`]'s must; where a surface overlaps itself
1312/// one of the preimages is returned.
1313///
1314/// # Errors
1315///
1316/// The point is not on the surface, or the surface cannot be evaluated.
1317pub fn locate(
1318    surface: &Surface,
1319    point: Point3,
1320    tolerance: axiolid_core::Tolerance,
1321) -> GeomResult<(Scalar, Scalar)> {
1322    match (invert(surface, point, tolerance), surface) {
1323        (Ok(uv), _) => Ok(uv),
1324        (Err(_), Surface::BSpline(b)) => {
1325            let (u, v) = spline_parameters(b, point)?;
1326            let residual = (evaluate(surface, u, v)? - point).length();
1327            if residual > tolerance.linear() {
1328                return Err(GeomError::Degenerate(format!(
1329                    "point is {residual} from the B-spline surface, beyond the {} tolerance",
1330                    tolerance.linear()
1331                )));
1332            }
1333            Ok((u, v))
1334        }
1335        (Err(error), _) => Err(error),
1336    }
1337}
1338
1339/// Parameters of a point on a B-spline surface: the nearest point of a
1340/// 24 x 24 sample grid over the domain, refined by Gauss-Newton on
1341/// `S(u, v) = p` within the domain.
1342fn spline_parameters(b: &BSplineSurface, point: Point3) -> GeomResult<(Scalar, Scalar)> {
1343    let ((u0, u1), (v0, v1)) = b
1344        .domain()
1345        .ok_or_else(|| GeomError::InvalidInput("malformed B-spline surface".to_owned()))?;
1346    let n = 24;
1347    let mut best = (Scalar::INFINITY, u0, v0);
1348    for i in 0..=n {
1349        for j in 0..=n {
1350            let (u, v) = (
1351                u0 + (u1 - u0) * i as Scalar / n as Scalar,
1352                v0 + (v1 - v0) * j as Scalar / n as Scalar,
1353            );
1354            if let Some(jet) = b.jet(u, v) {
1355                let d = (jet.point - point).length();
1356                if d < best.0 {
1357                    best = (d, u, v);
1358                }
1359            }
1360        }
1361    }
1362    let (_, mut u, mut v) = best;
1363    for _ in 0..60 {
1364        let jet = b
1365            .jet(u, v)
1366            .ok_or_else(|| GeomError::Degenerate("B-spline weight vanished".to_owned()))?;
1367        let r = jet.point - point;
1368        // Normal equations of the 3x2 system [S_u S_v] (du, dv) = -r.
1369        let (a, bb, c) = (jet.u.dot(jet.u), jet.u.dot(jet.v), jet.v.dot(jet.v));
1370        let (g0, g1) = (-jet.u.dot(r), -jet.v.dot(r));
1371        let det = a * c - bb * bb;
1372        if det == 0.0 || !det.is_finite() {
1373            break;
1374        }
1375        let (du, dv) = ((c * g0 - bb * g1) / det, (a * g1 - bb * g0) / det);
1376        let (nu, nv) = ((u + du).clamp(u0, u1), (v + dv).clamp(v0, v1));
1377        let moved = (nu - u).abs() + (nv - v).abs();
1378        (u, v) = (nu, nv);
1379        if moved <= 4.0 * Scalar::EPSILON * (1.0 + u.abs() + v.abs()) {
1380            break;
1381        }
1382    }
1383    Ok((u, v))
1384}