Skip to main content

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