Skip to main content

axiolid_evaluate/
curve.rs

1//! Scalar reference implementation of curve evaluation (ADR 0012).
2//!
3//! # What this closes
4//!
5//! `axiolid-curve` declares `Curve2`/`Curve3` and a `CurveEvaluator` trait. Until
6//! now nothing in the workspace implemented that trait, so every declared curve
7//! family was inert data. `axiolid-mesh-compile` worked around this with its own
8//! private circle flattener and refused ellipses and B-splines outright.
9//!
10//! # Design
11//!
12//! Evaluation is analytic per family, never a generic subdivision fallback:
13//!
14//! - `Line`     -- `origin + t * direction`, exact.
15//! - `Circle`   -- `origin + r*(cos t * x + sin t * y)`, `t` in radians.
16//! - `Ellipse`  -- same with independent semi-axes. Note `t` is the
17//!   *parametric* angle, not the polar angle; they differ except on axis.
18//! - `Polyline` -- `t` in `[0, n)`, integer part selects the segment. Chosen
19//!   over arc-length parameterization because it is exact and stable under
20//!   degenerate (zero-length) segments, which imported data contains.
21//! - `BSpline`  -- de Boor. Rational curves evaluate in homogeneous space and
22//!   project, which is the only way to get correct rational derivatives.
23//!
24//! Derivatives are closed-form. A finite-difference derivative would make the
25//! curvature oracle in `tests/curve.rs` self-referential: it would be checking
26//! a difference quotient against a difference quotient.
27//!
28//! # Frames are used as given
29//!
30//! Imported frames may be non-orthonormal. Evaluation applies the frame axes as
31//! written rather than orthonormalizing, so a caller sees the geometry its
32//! source actually declared. Validation is a separate concern (`axiolid-heal`).
33
34use axiolid_contracts::{GeomError, GeomResult};
35use axiolid_core::{Frame2, Frame3, Interval, Point2, Point3, Scalar, Tolerance, Vec2, Vec3};
36use axiolid_curve::{
37    BSplineCurve, BSplineCurve2, BSplineCurve3, Circle2, Circle3, Curve2, Curve3, CurveEvaluator,
38    Ellipse2, Ellipse3, Line2, Line3, Polyline2, Polyline3,
39};
40
41use crate::nurbs::SplineAxis;
42
43/// Portable scalar curve evaluator.
44///
45/// Stateless: every method is a pure function of its arguments, so one instance
46/// is freely shareable across threads.
47#[derive(Debug, Clone, Copy, Default)]
48pub struct ScalarCurve;
49
50impl ScalarCurve {
51    /// Construct the evaluator.
52    #[must_use]
53    pub const fn new() -> Self {
54        Self
55    }
56}
57
58/// Position and the first two parameter derivatives of a curve.
59///
60/// Keeping the derivatives with the point prevents callers from accidentally
61/// mixing results evaluated at different parameters. All derivatives use the
62/// curve's native parameter, not arc length.
63#[derive(Debug, Clone, Copy, PartialEq)]
64pub struct CurveJet<P, D> {
65    /// Position at the requested parameter.
66    pub point: P,
67    /// First derivative with respect to the native parameter.
68    pub first: D,
69    /// Second derivative with respect to the native parameter.
70    pub second: D,
71}
72
73// --- parameter domains ------------------------------------------------------
74
75/// Domain of a 2D curve.
76#[must_use]
77pub fn domain2(curve: &Curve2) -> Interval {
78    match curve {
79        // A line is infinite; the unit interval is the conventional finite
80        // window. Bounded use always arrives via `ProfileSegment::domain`.
81        Curve2::Line(_) => Interval::UNIT,
82        Curve2::Circle(_) | Curve2::Ellipse(_) => full_turn(),
83        Curve2::Polyline(p) => polyline_domain(p.points.len(), p.closed),
84        Curve2::BSpline(b) => spline_domain(b),
85        // Parameterised by ARC LENGTH, not by a unit parameter: the domain is
86        // the span the law is declared over. A non-finite or non-positive
87        // length claims no domain rather than a guessed one.
88        Curve2::Intrinsic(i) if i.length.is_finite() && i.length > 0.0 => Interval {
89            start: 0.0,
90            end: i.length,
91        },
92        // Periodic in the angle it is parameterised by, like a circle.
93        Curve2::Sinusoid(_) => full_turn(),
94        // Arc length from the chain start to its end.
95        Curve2::Chain(c) => c.length().map_or(
96            Interval {
97                start: 0.0,
98                end: 0.0,
99            },
100            |end| Interval { start: 0.0, end },
101        ),
102        // Unknown family: no domain is knowable, so claim none.
103        _ => Interval {
104            start: 0.0,
105            end: 0.0,
106        },
107    }
108}
109
110/// Domain of a 3D curve.
111#[must_use]
112pub fn domain3(curve: &Curve3) -> Interval {
113    match curve {
114        Curve3::Line(_) => Interval::UNIT,
115        Curve3::Circle(_) | Curve3::Ellipse(_) => full_turn(),
116        Curve3::Polyline(p) => polyline_domain(p.points.len(), p.closed),
117        Curve3::BSpline(b) => spline_domain(b),
118        // Parameterised by ARC LENGTH, not by a unit parameter: the domain is
119        // the span the laws are declared over. A non-finite or non-positive
120        // length claims no domain rather than a guessed one.
121        Curve3::Intrinsic(i) if i.length.is_finite() && i.length > 0.0 => Interval {
122            start: 0.0,
123            end: i.length,
124        },
125        // Parameterised by PLAN distance over the cant law's span: no
126        // station outside it has a section (ADR 0081).
127        Curve3::Banked(b) if b.cant.is_well_formed() => Interval {
128            start: 0.0,
129            end: b.span(),
130        },
131        // Parameterised by PLAN distance over the plan's length (#252,
132        // ADR 0082): unbounded for a line plan, one turn for a circle.
133        Curve3::Elevated(e) => crate::elevated::elevated_domain(e),
134        _ => Interval {
135            start: 0.0,
136            end: 0.0,
137        },
138    }
139}
140
141fn full_turn() -> Interval {
142    Interval {
143        start: 0.0,
144        end: core::f64::consts::TAU,
145    }
146}
147
148/// Polyline parameter runs `[0, segment_count]`.
149fn polyline_domain(count: usize, closed: bool) -> Interval {
150    let segments = if closed {
151        count
152    } else {
153        count.saturating_sub(1)
154    };
155    Interval {
156        start: 0.0,
157        end: segments as Scalar,
158    }
159}
160
161/// Domain of a validated B-spline axis. Invalid imported data reports an empty
162/// domain through the infallible evaluator trait and is rejected by evaluation.
163fn spline_domain<P>(b: &BSplineCurve<P>) -> Interval {
164    SplineAxis::new(
165        &b.knots,
166        &b.multiplicities,
167        b.degree,
168        b.control_points.len(),
169        "B-spline curve",
170    )
171    .map_or(
172        Interval {
173            start: 0.0,
174            end: 0.0,
175        },
176        |axis| {
177            let (start, end) = axis.domain();
178            Interval { start, end }
179        },
180    )
181}
182
183// --- 2D evaluation ----------------------------------------------------------
184
185/// Position on a 2D curve.
186pub fn evaluate2(curve: &Curve2, t: Scalar) -> GeomResult<Point2> {
187    finite(t)?;
188    let value = match curve {
189        Curve2::Line(l) => Ok(line_point(l.origin, l.direction, t)),
190        Curve2::Circle(c) => Ok(conic_point2(&c.frame, c.radius, c.radius, t)),
191        Curve2::Ellipse(e) => Ok(conic_point2(&e.frame, e.semi_axis_x, e.semi_axis_y, t)),
192        Curve2::Polyline(p) => polyline_point(&p.points, p.closed, t),
193        Curve2::BSpline(b) => de_boor(b, t, |p| [p.x, p.y], |c| Point2::new(c[0], c[1])),
194        // Position has no elementary closed form for a general curvature law
195        // (a clothoid needs Fresnel integrals), so it is quadrature over the
196        // exact heading rather than a parametric formula.
197        Curve2::Intrinsic(i) => crate::arc_length::intrinsic_point(i, t),
198        // Arc length too: each piece placed at the end of the one before.
199        Curve2::Chain(c) => crate::chain::chain_point(c, t),
200        // The parameter is the first coordinate; closed form, no sampling.
201        Curve2::Sinusoid(w) => Ok(Point2::new(t, w.height(t))),
202        // Likewise, one root of a quadratic in the height (ADR 0076).
203        Curve2::QuadraticGraph(g) => g
204            .height(t)
205            .map(|v| Point2::new(t, v))
206            .ok_or_else(|| outside_graph(t)),
207        // The angle is the first coordinate, the parameter the second.
208        Curve2::AngleGraph(g) => g
209            .angle(t)
210            .map(|u| Point2::new(u, t))
211            .ok_or_else(|| outside_graph(t)),
212        // The field's unique zero in the cell holding `t` (ADR 0077).
213        Curve2::Implicit(c) => c.point(t).ok_or_else(|| outside_graph(t)),
214        // The space curve's point, read on the carrier.
215        Curve2::Lifted(l) => {
216            // A section of two B-splines carries its parameters on both.
217            if let Some((section, first)) = pair_side(l) {
218                let (a, b, _) = section.solve(t).ok_or_else(|| outside_graph(t))?;
219                return finite2(if first { a } else { b }, "curve point");
220            }
221            let p = evaluate3(&l.curve, t)?;
222            l.unwrap_at(t, p).ok_or_else(|| outside_graph(t))
223        }
224        // `Curve*` is #[non_exhaustive]. An unknown family is refused by name
225        // rather than approximated by whichever arm happens to be nearest.
226        _ => Err(GeomError::Unsupported {
227            backend: axiolid_contracts::BackendId::new("axiolid-reference"),
228            operation: axiolid_contracts::Operation::CurveEvaluation,
229        }),
230    }?;
231    finite2(value, "curve point")
232}
233
234/// First derivative of a 2D curve.
235pub fn derivative2(curve: &Curve2, t: Scalar) -> GeomResult<Vec2> {
236    finite(t)?;
237    let value = match curve {
238        Curve2::Line(l) => Ok(l.direction),
239        Curve2::Circle(c) => Ok(conic_tangent2(&c.frame, c.radius, c.radius, t)),
240        Curve2::Ellipse(e) => Ok(conic_tangent2(&e.frame, e.semi_axis_x, e.semi_axis_y, t)),
241        Curve2::Polyline(p) => polyline_tangent(&p.points, p.closed, t),
242        Curve2::BSpline(b) => de_boor_derivative(b, t, |p| [p.x, p.y], |c| Vec2::new(c[0], c[1])),
243        // Arc-length parameterised, so the derivative is the UNIT tangent, and
244        // the heading it is built from is exact -- only position needs
245        // quadrature, never the tangent.
246        Curve2::Intrinsic(i) => crate::arc_length::intrinsic_tangent(i, t),
247        Curve2::Chain(c) => crate::chain::chain_tangent(c, t),
248        Curve2::Sinusoid(w) => {
249            let (sin, cos) = t.sin_cos();
250            Ok(Vec2::new(1.0, -w.cosine * sin + w.sine * cos))
251        }
252        Curve2::QuadraticGraph(g) => g
253            .slope(t)
254            .map(|slope| Vec2::new(1.0, slope))
255            .ok_or_else(|| outside_graph(t)),
256        Curve2::AngleGraph(g) => g
257            .slope(t)
258            .map(|slope| Vec2::new(slope, 1.0))
259            .ok_or_else(|| outside_graph(t)),
260        Curve2::Implicit(c) => c.derivative(t).ok_or_else(|| outside_graph(t)),
261        Curve2::Lifted(l) => lifted_rates(l, t).map(|(d, _)| d),
262        // `Curve*` is #[non_exhaustive]. An unknown family is refused by name
263        // rather than approximated by whichever arm happens to be nearest.
264        _ => Err(GeomError::Unsupported {
265            backend: axiolid_contracts::BackendId::new("axiolid-reference"),
266            operation: axiolid_contracts::Operation::CurveEvaluation,
267        }),
268    }?;
269    finite2(value, "curve derivative")
270}
271
272/// Second derivative of a 2D curve with respect to its native parameter.
273pub fn second_derivative2(curve: &Curve2, t: Scalar) -> GeomResult<Vec2> {
274    finite(t)?;
275    let value = match curve {
276        Curve2::Line(_) | Curve2::Polyline(_) => Ok(Vec2::ZERO),
277        Curve2::Circle(c) => Ok(conic_second2(&c.frame, c.radius, c.radius, t)),
278        Curve2::Ellipse(e) => Ok(conic_second2(&e.frame, e.semi_axis_x, e.semi_axis_y, t)),
279        Curve2::BSpline(b) => {
280            de_boor_second_derivative(b, t, |p| [p.x, p.y], |c| Vec2::new(c[0], c[1]))
281        }
282        Curve2::Sinusoid(w) => {
283            let (sin, cos) = t.sin_cos();
284            Ok(Vec2::new(0.0, -w.cosine * cos - w.sine * sin))
285        }
286        Curve2::QuadraticGraph(g) => g
287            .bend(t)
288            .map(|bend| Vec2::new(0.0, bend))
289            .ok_or_else(|| outside_graph(t)),
290        Curve2::AngleGraph(g) => g
291            .bend(t)
292            .map(|bend| Vec2::new(bend, 0.0))
293            .ok_or_else(|| outside_graph(t)),
294        Curve2::Implicit(c) => c.second_derivative(t).ok_or_else(|| outside_graph(t)),
295        Curve2::Lifted(l) => lifted_rates(l, t).map(|(_, dd)| dd),
296        _ => Err(GeomError::Unsupported {
297            backend: axiolid_contracts::BackendId::new("axiolid-reference"),
298            operation: axiolid_contracts::Operation::CurveEvaluation,
299        }),
300    }?;
301    finite2(value, "curve second derivative")
302}
303
304/// Second-order differential jet of a 2D curve.
305pub fn bspline_jet2(curve: &BSplineCurve2, t: Scalar) -> GeomResult<CurveJet<Point2, Vec2>> {
306    Ok(CurveJet {
307        point: de_boor(curve, t, |p| [p.x, p.y], |c| Point2::new(c[0], c[1]))?,
308        first: de_boor_derivative(curve, t, |p| [p.x, p.y], |c| Vec2::new(c[0], c[1]))?,
309        second: de_boor_second_derivative(curve, t, |p| [p.x, p.y], |c| Vec2::new(c[0], c[1]))?,
310    })
311}
312
313/// Second-order differential jet of a 2D curve.
314pub fn jet2(curve: &Curve2, t: Scalar) -> GeomResult<CurveJet<Point2, Vec2>> {
315    Ok(CurveJet {
316        point: evaluate2(curve, t)?,
317        first: derivative2(curve, t)?,
318        second: second_derivative2(curve, t)?,
319    })
320}
321
322/// First and second parameter rates of a lifted pcurve: `C' = S_u u' + S_v
323/// v'`, and `C'' = S_uu u'^2 + 2 S_uv u' v' + S_vv v'^2 + S_u u'' + S_v v''`,
324/// each solved in the least-squares sense on the tangent plane.
325fn lifted_rates(l: &axiolid_curve::LiftedCurve2, t: Scalar) -> GeomResult<(Vec2, Vec2)> {
326    if let Some((section, first)) = pair_side(l) {
327        let (a, b, _) = section.rates(t).ok_or_else(|| outside_graph(t))?;
328        let (aa, bb, _) = section.second_rates(t).ok_or_else(|| outside_graph(t))?;
329        let pick = |x: Point2, y: Point2| if first { x } else { y };
330        let (d, dd) = (pick(a, b), pick(aa, bb));
331        return Ok((Vec2::new(d.x, d.y), Vec2::new(dd.x, dd.y)));
332    }
333    let p = evaluate3(&l.curve, t)?;
334    let uv = l.unwrap_at(t, p).ok_or_else(|| outside_graph(t))?;
335    let jet = l.carrier.jet(uv.x, uv.y);
336    let (a, b, c) = (jet.u.dot(jet.u), jet.u.dot(jet.v), jet.v.dot(jet.v));
337    let det = a * c - b * b;
338    if det == 0.0 || !det.is_finite() {
339        return Err(outside_graph(t));
340    }
341    let solve = |w: Vec3| {
342        let (g0, g1) = (jet.u.dot(w), jet.v.dot(w));
343        Vec2::new((c * g0 - b * g1) / det, (a * g1 - b * g0) / det)
344    };
345    let d = solve(derivative3(&l.curve, t)?);
346    let rest = second_derivative3(&l.curve, t)?
347        - (jet.uu * (d.x * d.x) + jet.uv * (2.0 * d.x * d.y) + jet.vv * (d.y * d.y));
348    Ok((d, solve(rest)))
349}
350
351/// The section of two B-splines a lifted pcurve reads, when it reads one on
352/// either of its own surfaces, and whether that is the first.
353fn pair_side(l: &axiolid_curve::LiftedCurve2) -> Option<(&axiolid_curve::PairSection3, bool)> {
354    match l.curve.as_ref() {
355        Curve3::PairSection(section) => section.side(&l.carrier).map(|first| (section, first)),
356        _ => None,
357    }
358}
359
360/// A quadratic-graph parameter where its root does not exist, diverges, or
361/// has a vertical tangent: outside the spans the curve was built for.
362fn outside_graph(t: Scalar) -> GeomError {
363    GeomError::Degenerate(format!(
364        "quadratic section graph has no regular point at t = {t}"
365    ))
366}
367
368// --- 3D evaluation ----------------------------------------------------------
369
370/// Position on a 3D curve.
371pub fn evaluate3(curve: &Curve3, t: Scalar) -> GeomResult<Point3> {
372    finite(t)?;
373    let value = match curve {
374        Curve3::Line(l) => Ok(line_point(l.origin, l.direction, t)),
375        Curve3::Circle(c) => Ok(conic_point3(&c.frame, c.radius, c.radius, t)),
376        Curve3::Ellipse(e) => Ok(conic_point3(&e.frame, e.semi_axis_x, e.semi_axis_y, t)),
377        Curve3::Polyline(p) => polyline_point(&p.points, p.closed, t),
378        Curve3::BSpline(b) => de_boor(b, t, |p| [p.x, p.y, p.z], |c| Point3::new(c[0], c[1], c[2])),
379        // Natural equations: `t` is ARC LENGTH, and the point comes from the
380        // Frenet integrator (ADR 0061). Dispatching here is what lets the
381        // graph's existing relation machinery -- trim, composite, sweep
382        // directrix -- work on a torsion curve without special-casing it.
383        Curve3::Intrinsic(i) => crate::frenet::frenet_point(i, t),
384        // The carrier along its section graph (ADR 0076).
385        Curve3::RuledSection(r) => r.point(t).ok_or_else(|| outside_graph(t)),
386        Curve3::TorusSection(r) => r.point(t).ok_or_else(|| outside_graph(t)),
387        Curve3::ImplicitSection(r) => r.point(t).ok_or_else(|| outside_graph(t)),
388        Curve3::PairSection(r) => r.point(t).ok_or_else(|| outside_graph(t)),
389        // `t` is PLAN distance, the centreline's own parameter (ADR 0081).
390        Curve3::Banked(b) => crate::banked::banked_point(b, t),
391        // `t` is PLAN distance, the parameter both halves are written
392        // against (#252, ADR 0082).
393        Curve3::Elevated(e) => crate::arc_length::elevated_point(e, t),
394        // `Curve*` is #[non_exhaustive]. An unknown family is refused by name
395        // rather than approximated by whichever arm happens to be nearest.
396        _ => Err(GeomError::Unsupported {
397            backend: axiolid_contracts::BackendId::new("axiolid-reference"),
398            operation: axiolid_contracts::Operation::CurveEvaluation,
399        }),
400    }?;
401    finite3(value, "curve point")
402}
403
404/// First derivative of a 3D curve.
405pub fn derivative3(curve: &Curve3, t: Scalar) -> GeomResult<Vec3> {
406    finite(t)?;
407    let value = match curve {
408        Curve3::Line(l) => Ok(l.direction),
409        Curve3::Circle(c) => Ok(conic_tangent3(&c.frame, c.radius, c.radius, t)),
410        Curve3::Ellipse(e) => Ok(conic_tangent3(&e.frame, e.semi_axis_x, e.semi_axis_y, t)),
411        Curve3::Polyline(p) => polyline_tangent(&p.points, p.closed, t),
412        Curve3::BSpline(b) => {
413            de_boor_derivative(b, t, |p| [p.x, p.y, p.z], |c| Vec3::new(c[0], c[1], c[2]))
414        }
415        // Arc-length parameterised, so the derivative is the UNIT tangent.
416        Curve3::Intrinsic(i) => crate::frenet::frenet_tangent(i, t),
417        Curve3::RuledSection(r) => r.tangent(t).ok_or_else(|| outside_graph(t)),
418        Curve3::TorusSection(r) => r.tangent(t).ok_or_else(|| outside_graph(t)),
419        Curve3::ImplicitSection(r) => r.tangent(t).ok_or_else(|| outside_graph(t)),
420        Curve3::PairSection(r) => r.tangent(t).ok_or_else(|| outside_graph(t)),
421        // In plan distance, so not unit: `(p, grade + pivot rate)`.
422        Curve3::Banked(b) => crate::banked::banked_derivative(b, t),
423        // In plan distance, so not unit: `(p', grade)` (#252).
424        Curve3::Elevated(e) => crate::elevated::elevated_derivative(e, t),
425        // `Curve*` is #[non_exhaustive]. An unknown family is refused by name
426        // rather than approximated by whichever arm happens to be nearest.
427        _ => Err(GeomError::Unsupported {
428            backend: axiolid_contracts::BackendId::new("axiolid-reference"),
429            operation: axiolid_contracts::Operation::CurveEvaluation,
430        }),
431    }?;
432    finite3(value, "curve derivative")
433}
434
435/// Second derivative of a 3D curve with respect to its native parameter.
436pub fn second_derivative3(curve: &Curve3, t: Scalar) -> GeomResult<Vec3> {
437    finite(t)?;
438    let value = match curve {
439        Curve3::Line(_) | Curve3::Polyline(_) => Ok(Vec3::ZERO),
440        Curve3::Circle(c) => Ok(conic_second3(&c.frame, c.radius, c.radius, t)),
441        Curve3::Ellipse(e) => Ok(conic_second3(&e.frame, e.semi_axis_x, e.semi_axis_y, t)),
442        Curve3::BSpline(b) => {
443            de_boor_second_derivative(b, t, |p| [p.x, p.y, p.z], |c| Vec3::new(c[0], c[1], c[2]))
444        }
445        Curve3::RuledSection(r) => r.bend(t).ok_or_else(|| outside_graph(t)),
446        Curve3::TorusSection(r) => r.bend(t).ok_or_else(|| outside_graph(t)),
447        Curve3::ImplicitSection(r) => r.bend(t).ok_or_else(|| outside_graph(t)),
448        Curve3::PairSection(r) => r.bend(t).ok_or_else(|| outside_graph(t)),
449        // In plan distance: `(k n, z'')`, plus the pivot's `e''` (#252).
450        Curve3::Elevated(e) => crate::elevated::elevated_second_derivative(e, t),
451        Curve3::Banked(b) => crate::banked::banked_second_derivative(b, t),
452        _ => Err(GeomError::Unsupported {
453            backend: axiolid_contracts::BackendId::new("axiolid-reference"),
454            operation: axiolid_contracts::Operation::CurveEvaluation,
455        }),
456    }?;
457    finite3(value, "curve second derivative")
458}
459
460/// Second-order differential jet of a 3D curve.
461pub fn bspline_jet3(curve: &BSplineCurve3, t: Scalar) -> GeomResult<CurveJet<Point3, Vec3>> {
462    Ok(CurveJet {
463        point: de_boor(
464            curve,
465            t,
466            |p| [p.x, p.y, p.z],
467            |c| Point3::new(c[0], c[1], c[2]),
468        )?,
469        first: de_boor_derivative(
470            curve,
471            t,
472            |p| [p.x, p.y, p.z],
473            |c| Vec3::new(c[0], c[1], c[2]),
474        )?,
475        second: de_boor_second_derivative(
476            curve,
477            t,
478            |p| [p.x, p.y, p.z],
479            |c| Vec3::new(c[0], c[1], c[2]),
480        )?,
481    })
482}
483
484/// Second-order differential jet of a 3D curve.
485pub fn jet3(curve: &Curve3, t: Scalar) -> GeomResult<CurveJet<Point3, Vec3>> {
486    Ok(CurveJet {
487        point: evaluate3(curve, t)?,
488        first: derivative3(curve, t)?,
489        second: second_derivative3(curve, t)?,
490    })
491}
492
493// --- family kernels ---------------------------------------------------------
494
495fn finite(t: Scalar) -> GeomResult<()> {
496    if t.is_finite() {
497        Ok(())
498    } else {
499        Err(GeomError::InvalidInput(format!(
500            "curve parameter must be finite, got {t}"
501        )))
502    }
503}
504
505fn finite2(value: Vec2, what: &str) -> GeomResult<Vec2> {
506    if value.is_finite() {
507        Ok(value)
508    } else {
509        Err(GeomError::Degenerate(format!("{what} is non-finite")))
510    }
511}
512
513fn finite3(value: Vec3, what: &str) -> GeomResult<Vec3> {
514    if value.is_finite() {
515        Ok(value)
516    } else {
517        Err(GeomError::Degenerate(format!("{what} is non-finite")))
518    }
519}
520
521fn line_point<P>(origin: P, direction: P, t: Scalar) -> P
522where
523    P: core::ops::Add<Output = P> + core::ops::Mul<Scalar, Output = P>,
524{
525    origin + direction * t
526}
527
528fn conic_point2(frame: &Frame2, rx: Scalar, ry: Scalar, t: Scalar) -> Point2 {
529    frame.origin + frame.x * (rx * t.cos()) + frame.y * (ry * t.sin())
530}
531
532fn conic_tangent2(frame: &Frame2, rx: Scalar, ry: Scalar, t: Scalar) -> Vec2 {
533    frame.x * (-rx * t.sin()) + frame.y * (ry * t.cos())
534}
535
536fn conic_second2(frame: &Frame2, rx: Scalar, ry: Scalar, t: Scalar) -> Vec2 {
537    frame.x * (-rx * t.cos()) + frame.y * (-ry * t.sin())
538}
539
540fn conic_point3(frame: &Frame3, rx: Scalar, ry: Scalar, t: Scalar) -> Point3 {
541    frame.origin + frame.x * (rx * t.cos()) + frame.y * (ry * t.sin())
542}
543
544fn conic_tangent3(frame: &Frame3, rx: Scalar, ry: Scalar, t: Scalar) -> Vec3 {
545    frame.x * (-rx * t.sin()) + frame.y * (ry * t.cos())
546}
547
548fn conic_second3(frame: &Frame3, rx: Scalar, ry: Scalar, t: Scalar) -> Vec3 {
549    frame.x * (-rx * t.cos()) + frame.y * (-ry * t.sin())
550}
551
552/// Segment index and local fraction for a polyline parameter.
553///
554/// Returns `None` when the polyline cannot be evaluated at all.
555fn polyline_span(count: usize, closed: bool, t: Scalar) -> Option<(usize, usize, Scalar)> {
556    let segments = if closed {
557        count
558    } else {
559        count.saturating_sub(1)
560    };
561    if count < 2 || segments == 0 {
562        return None;
563    }
564    // Clamp into range: the endpoint t == segments is the final vertex, which
565    // would otherwise index one past the last segment.
566    let clamped = t.clamp(0.0, segments as Scalar);
567    let mut index = clamped.floor() as usize;
568    if index >= segments {
569        index = segments - 1;
570    }
571    let local = clamped - index as Scalar;
572    let next = (index + 1) % count;
573    Some((index, next, local))
574}
575
576fn polyline_point<P>(points: &[P], closed: bool, t: Scalar) -> GeomResult<P>
577where
578    P: Copy
579        + core::ops::Add<Output = P>
580        + core::ops::Sub<Output = P>
581        + core::ops::Mul<Scalar, Output = P>,
582{
583    let (i, j, local) = polyline_span(points.len(), closed, t).ok_or_else(|| {
584        GeomError::Degenerate(format!(
585            "polyline with {} points has no evaluable segment",
586            points.len()
587        ))
588    })?;
589    Ok(points[i] + (points[j] - points[i]) * local)
590}
591
592fn polyline_tangent<P>(points: &[P], closed: bool, t: Scalar) -> GeomResult<P>
593where
594    P: Copy + core::ops::Sub<Output = P>,
595{
596    let (i, j, _) = polyline_span(points.len(), closed, t).ok_or_else(|| {
597        GeomError::Degenerate(format!(
598            "polyline with {} points has no evaluable segment",
599            points.len()
600        ))
601    })?;
602    // Derivative w.r.t. the unit-per-segment parameter is the full edge vector.
603    Ok(points[j] - points[i])
604}
605
606// --- reusable de Boor core (shared with `crate::surface`) -------------------
607
608/// Locate the knot span for `u` in a validated flat knot vector.
609///
610/// Extracted from [`spline_span`] so a tensor-product surface can reuse the
611/// exact same span logic per axis. `n` is the control-point count, `d` the
612/// degree; the caller has already checked `knots.len() == n + d + 1`.
613pub(crate) fn span_in(knots: &[Scalar], n: usize, d: usize, u: Scalar) -> usize {
614    let mut span = d;
615    for (k, knot) in knots.iter().enumerate().take(n).skip(d) {
616        if *knot <= u {
617            span = k;
618        } else {
619            break;
620        }
621    }
622    span
623}
624
625/// One de Boor recurrence over homogeneous coordinates.
626///
627/// `points` holds the `d+1` premultiplied control points influencing `span`,
628/// `weights` their weights. Both are consumed in place. This is the numerical
629/// heart shared by curve and surface evaluation: keeping one copy means a fix
630/// to the recurrence cannot land in one and not the other.
631pub(crate) fn de_boor_recurrence<const N: usize>(
632    knots: &[Scalar],
633    span: usize,
634    d: usize,
635    u: Scalar,
636    points: &mut [[Scalar; N]],
637    weights: &mut [Scalar],
638) {
639    for r in 1..=d {
640        for j in (r..=d).rev() {
641            let i = span - d + j;
642            let denom = knots[i + d + 1 - r] - knots[i];
643            let alpha = if denom.abs() > 0.0 {
644                (u - knots[i]) / denom
645            } else {
646                0.0
647            };
648            let (prev, cur) = (points[j - 1], points[j]);
649            points[j] = core::array::from_fn(|k| prev[k] * (1.0 - alpha) + cur[k] * alpha);
650            weights[j] = weights[j - 1] * (1.0 - alpha) + weights[j] * alpha;
651        }
652    }
653}
654
655// --- de Boor ----------------------------------------------------------------
656
657/// Shared setup: validated flat knots, degree, and the knot span for `t`.
658fn spline_span<P>(b: &BSplineCurve<P>, t: Scalar) -> GeomResult<(Vec<Scalar>, usize, usize)> {
659    let axis = SplineAxis::new(
660        &b.knots,
661        &b.multiplicities,
662        b.degree,
663        b.control_points.len(),
664        "B-spline curve",
665    )?;
666    if let Some(weights) = &b.weights {
667        if weights.len() != b.control_points.len() {
668            return Err(GeomError::InvalidInput(format!(
669                "B-spline has {} weights for {} control points",
670                weights.len(),
671                b.control_points.len()
672            )));
673        }
674        if weights
675            .iter()
676            .any(|weight| !weight.is_finite() || *weight <= 0.0)
677        {
678            return Err(GeomError::InvalidInput(
679                "B-spline weights must be finite and strictly positive".to_owned(),
680            ));
681        }
682    }
683    let t = axis.clamp(t);
684    let span = span_in(&axis.knots, axis.count, axis.degree, t);
685    Ok((axis.knots, span, axis.degree))
686}
687
688/// Convert and validate every control point before selecting a knot span.
689/// Imported NaN/Inf coordinates must not be hidden in currently uninfluential
690/// spans and surface later when the parameter changes.
691fn finite_control_points<P, const N: usize, F>(
692    control_points: &[P],
693    to: &F,
694) -> GeomResult<Vec<[Scalar; N]>>
695where
696    F: Fn(&P) -> [Scalar; N],
697{
698    let points: Vec<[Scalar; N]> = control_points.iter().map(to).collect();
699    if points
700        .iter()
701        .flatten()
702        .any(|coordinate| !coordinate.is_finite())
703    {
704        return Err(GeomError::InvalidInput(
705            "B-spline control points must be finite".to_owned(),
706        ));
707    }
708    Ok(points)
709}
710
711/// Position via de Boor's algorithm.
712///
713/// `to` and `from` convert between the point type and a fixed-size coordinate
714/// array so 2D and 3D share one implementation. Rational curves are evaluated
715/// in homogeneous coordinates `(w*x, w*y, [w*z], w)` and projected at the end.
716fn de_boor<P, const N: usize, F, G, Q>(
717    b: &BSplineCurve<P>,
718    t: Scalar,
719    to: F,
720    from: G,
721) -> GeomResult<Q>
722where
723    F: Fn(&P) -> [Scalar; N],
724    G: Fn([Scalar; N]) -> Q,
725{
726    let (knots, span, d) = spline_span(b, t)?;
727    let control_points = finite_control_points(&b.control_points, &to)?;
728    let u = t.clamp(knots[d], knots[b.control_points.len()]);
729
730    // Working set: the d+1 control points influencing this span, in homogeneous
731    // form. The trailing slot holds the weight (1.0 for polynomial curves).
732    let mut work: Vec<[Scalar; N]> = Vec::with_capacity(d + 1);
733    let mut weights: Vec<Scalar> = Vec::with_capacity(d + 1);
734    for j in 0..=d {
735        let idx = span - d + j;
736        let w = b.weights.as_ref().map_or(1.0, |ws| ws[idx]);
737        let c = control_points[idx];
738        // Premultiply by w: interpolating in homogeneous space is what makes
739        // rational curves correct. Projecting first would be plain averaging.
740        let homogeneous = core::array::from_fn(|k| c[k] * w);
741        if homogeneous.iter().any(|value| !value.is_finite()) {
742            return Err(GeomError::Degenerate(
743                "B-spline homogeneous control point overflowed".to_owned(),
744            ));
745        }
746        work.push(homogeneous);
747        weights.push(w);
748    }
749
750    // A repeated knot makes an interval empty; the shared recurrence treats
751    // that as alpha = 0, which is the correct limit.
752    de_boor_recurrence(&knots, span, d, u, &mut work, &mut weights);
753
754    let w = weights[d];
755    if !w.is_finite() || w == 0.0 {
756        return Err(GeomError::Degenerate(
757            "B-spline weight collapsed to zero".to_owned(),
758        ));
759    }
760    Ok(from(core::array::from_fn(|k| work[d][k] / w)))
761}
762
763/// First derivative via the hodograph, with the quotient rule for rationals.
764///
765/// The derivative of a degree-`d` B-spline is a degree-`(d-1)` B-spline over
766/// the same knots minus their outermost entries, with control points
767/// `d * (P[i+1] - P[i]) / (knots[i+d+1] - knots[i+1])`.
768///
769/// For a rational curve `C = A/w`, both `A` and `w` are differentiated in
770/// homogeneous space and combined as `(A' - C * w') / w`.
771fn de_boor_derivative<P, const N: usize, F, G, Q>(
772    b: &BSplineCurve<P>,
773    t: Scalar,
774    to: F,
775    from: G,
776) -> GeomResult<Q>
777where
778    F: Fn(&P) -> [Scalar; N],
779    G: Fn([Scalar; N]) -> Q,
780{
781    let (knots, _, d) = spline_span(b, t)?;
782    let control_points = finite_control_points(&b.control_points, &to)?;
783    let n = b.control_points.len();
784    let u = t.clamp(knots[d], knots[n]);
785
786    // Homogeneous control points, weight in a parallel array.
787    let hom: Vec<[Scalar; N]> = (0..n)
788        .map(|i| {
789            let w = b.weights.as_ref().map_or(1.0, |ws| ws[i]);
790            let c = control_points[i];
791            core::array::from_fn(|k| c[k] * w)
792        })
793        .collect();
794    if hom.iter().flatten().any(|value| !value.is_finite()) {
795        return Err(GeomError::Degenerate(
796            "B-spline homogeneous control point overflowed".to_owned(),
797        ));
798    }
799    let hw: Vec<Scalar> = (0..n)
800        .map(|i| b.weights.as_ref().map_or(1.0, |ws| ws[i]))
801        .collect();
802
803    // Hodograph control points.
804    let mut dhom: Vec<[Scalar; N]> = Vec::with_capacity(n - 1);
805    let mut dhw: Vec<Scalar> = Vec::with_capacity(n - 1);
806    for i in 0..n - 1 {
807        let denom = knots[i + d + 1] - knots[i + 1];
808        let f = if denom.abs() > 0.0 {
809            d as Scalar / denom
810        } else {
811            0.0
812        };
813        dhom.push(core::array::from_fn(|k| (hom[i + 1][k] - hom[i][k]) * f));
814        dhw.push((hw[i + 1] - hw[i]) * f);
815    }
816
817    // Evaluate the hodograph at u with degree d-1 over the trimmed knots.
818    let dknots = &knots[1..knots.len() - 1];
819    let (da, dw) = eval_homogeneous(dknots, d - 1, &dhom, &dhw, u);
820    // Evaluate the curve itself for the quotient rule.
821    let (a, w) = eval_homogeneous(&knots, d, &hom, &hw, u);
822
823    if !w.is_finite() || w == 0.0 {
824        return Err(GeomError::Degenerate(
825            "B-spline weight collapsed to zero".to_owned(),
826        ));
827    }
828    // C = A/w  =>  C' = (A' - (A/w) * w') / w
829    Ok(from(core::array::from_fn(|k| {
830        (da[k] - (a[k] / w) * dw) / w
831    })))
832}
833
834/// Second derivative via two homogeneous hodograph constructions.
835///
836/// For `C = A / w`, the rational recurrence is
837/// `C'' = (A'' - 2 w' C' - w'' C) / w`.
838fn de_boor_second_derivative<P, const N: usize, F, G, Q>(
839    b: &BSplineCurve<P>,
840    t: Scalar,
841    to: F,
842    from: G,
843) -> GeomResult<Q>
844where
845    F: Fn(&P) -> [Scalar; N],
846    G: Fn([Scalar; N]) -> Q,
847{
848    let (knots, _, degree) = spline_span(b, t)?;
849    let control_points = finite_control_points(&b.control_points, &to)?;
850    let count = b.control_points.len();
851    let u = t.clamp(knots[degree], knots[count]);
852
853    let points: Vec<[Scalar; N]> = (0..count)
854        .map(|i| {
855            let weight = b.weights.as_ref().map_or(1.0, |weights| weights[i]);
856            core::array::from_fn(|axis| control_points[i][axis] * weight)
857        })
858        .collect();
859    if points.iter().flatten().any(|value| !value.is_finite()) {
860        return Err(GeomError::Degenerate(
861            "B-spline homogeneous control point overflowed".to_owned(),
862        ));
863    }
864    let weights: Vec<Scalar> = (0..count)
865        .map(|i| b.weights.as_ref().map_or(1.0, |values| values[i]))
866        .collect();
867
868    let (point, weight) = eval_homogeneous(&knots, degree, &points, &weights, u);
869    if !weight.is_finite() || weight == 0.0 {
870        return Err(GeomError::Degenerate(
871            "B-spline weight collapsed to zero".to_owned(),
872        ));
873    }
874
875    let (first_points, first_weights) = derivative_controls(&points, &weights, &knots, degree);
876    let first_knots = &knots[1..knots.len() - 1];
877    let (first, first_weight) =
878        eval_homogeneous(first_knots, degree - 1, &first_points, &first_weights, u);
879    let position: [Scalar; N] = core::array::from_fn(|axis| point[axis] / weight);
880    let first_projected: [Scalar; N] =
881        core::array::from_fn(|axis| (first[axis] - position[axis] * first_weight) / weight);
882
883    let (second, second_weight) = if degree == 1 {
884        ([0.0; N], 0.0)
885    } else {
886        let (second_points, second_weights) =
887            derivative_controls(&first_points, &first_weights, first_knots, degree - 1);
888        let second_knots = &first_knots[1..first_knots.len() - 1];
889        eval_homogeneous(second_knots, degree - 2, &second_points, &second_weights, u)
890    };
891    Ok(from(core::array::from_fn(|axis| {
892        (second[axis] - 2.0 * first_weight * first_projected[axis] - second_weight * position[axis])
893            / weight
894    })))
895}
896
897/// Derivative control polygon for one homogeneous B-spline axis.
898fn derivative_controls<const N: usize>(
899    points: &[[Scalar; N]],
900    weights: &[Scalar],
901    knots: &[Scalar],
902    degree: usize,
903) -> (Vec<[Scalar; N]>, Vec<Scalar>) {
904    let mut derivative_points = Vec::with_capacity(points.len() - 1);
905    let mut derivative_weights = Vec::with_capacity(weights.len() - 1);
906    for i in 0..points.len() - 1 {
907        let denominator = knots[i + degree + 1] - knots[i + 1];
908        let factor = if denominator.abs() > 0.0 {
909            degree as Scalar / denominator
910        } else {
911            0.0
912        };
913        derivative_points.push(core::array::from_fn(|axis| {
914            (points[i + 1][axis] - points[i][axis]) * factor
915        }));
916        derivative_weights.push((weights[i + 1] - weights[i]) * factor);
917    }
918    (derivative_points, derivative_weights)
919}
920
921/// de Boor over explicit homogeneous arrays; returns `(numerator, weight)`.
922pub(crate) fn eval_homogeneous<const N: usize>(
923    knots: &[Scalar],
924    d: usize,
925    hom: &[[Scalar; N]],
926    hw: &[Scalar],
927    u: Scalar,
928) -> ([Scalar; N], Scalar) {
929    let n = hom.len();
930    if d == 0 {
931        // Degree zero: piecewise constant, pick the containing span.
932        let mut idx = 0;
933        for (k, knot) in knots.iter().enumerate().take(n) {
934            if *knot <= u {
935                idx = k;
936            }
937        }
938        return (hom[idx.min(n - 1)], hw[idx.min(n - 1)]);
939    }
940    let mut span = d;
941    for (k, knot) in knots.iter().enumerate().take(n).skip(d) {
942        if *knot <= u {
943            span = k;
944        } else {
945            break;
946        }
947    }
948    let mut work: Vec<[Scalar; N]> = (0..=d).map(|j| hom[span - d + j]).collect();
949    let mut weights: Vec<Scalar> = (0..=d).map(|j| hw[span - d + j]).collect();
950    for r in 1..=d {
951        for j in (r..=d).rev() {
952            let i = span - d + j;
953            let denom = knots[i + d + 1 - r] - knots[i];
954            let alpha = if denom.abs() > 0.0 {
955                (u - knots[i]) / denom
956            } else {
957                0.0
958            };
959            let (prev, cur) = (work[j - 1], work[j]);
960            work[j] = core::array::from_fn(|k| prev[k] * (1.0 - alpha) + cur[k] * alpha);
961            weights[j] = weights[j - 1] * (1.0 - alpha) + weights[j] * alpha;
962        }
963    }
964    (work[d], weights[d])
965}
966
967// --- adaptive flattening ----------------------------------------------------
968
969/// Flatten a 2D curve over `domain` so the chord never deviates from the true
970/// curve by more than `chord_tolerance`.
971///
972/// For the families [`crate::bound::certifies_flattening2`] names this is a
973/// guarantee: every span is also accepted on a certified chord bound, and a
974/// B-spline is cut at its corner knots first (#232). Other families are
975/// accepted on their midpoint sagitta alone.
976///
977/// # Why bisection rather than a closed-form segment count
978///
979/// A count derived from radius and tolerance only works for circles. Bisecting
980/// on measured sagitta works for every family, including rational splines whose
981/// curvature varies along the span, and it degrades gracefully on the
982/// degenerate inputs imported data actually contains.
983///
984/// The returned polyline includes both endpoints and is ordered along
985/// increasing parameter. `max_depth` bounds the work: a caller gets a
986/// deterministic result rather than an unbounded subdivision on a pathological
987/// curve.
988pub fn flatten2(
989    curve: &Curve2,
990    domain: Interval,
991    chord_tolerance: Scalar,
992    max_depth: u32,
993) -> GeomResult<Vec<Point2>> {
994    // A depth bound alone is not a resource bound: depth `d` permits `2^d`
995    // segments. Cap the total point count too, so a curve that cannot meet
996    // the tolerance fails fast instead of exhausting memory.
997    const MAX_POINTS: usize = 1 << 16;
998    if !(chord_tolerance.is_finite()
999        && chord_tolerance.is_sign_positive()
1000        && chord_tolerance != 0.0)
1001    {
1002        return Err(GeomError::InvalidInput(format!(
1003            "chord tolerance must be positive and finite, got {chord_tolerance}"
1004        )));
1005    }
1006    // A line and a polyline are already exact between their breakpoints:
1007    // subdividing them adds vertices that carry no information.
1008    if let Curve2::Line(_) = curve {
1009        return Ok(vec![
1010            evaluate2(curve, domain.start)?,
1011            evaluate2(curve, domain.end)?,
1012        ]);
1013    }
1014    if let Curve2::Polyline(p) = curve {
1015        // A polyline's parameter is one unit per segment, so a caller passing
1016        // a normalized `(0, 1)` domain would silently collapse an n-vertex
1017        // ring to its first edge. That is data loss disguised as success, so
1018        // it is refused: a domain narrower than one segment can only be
1019        // intentional for a genuinely 1-segment polyline.
1020        let natural = polyline_domain(p.points.len(), p.closed);
1021        let requested = (domain.end - domain.start).abs();
1022        if natural.end > 1.0 && requested <= 1.0 {
1023            return Err(GeomError::InvalidInput(format!(
1024                "polyline domain {:?} spans {requested} of {} segments; a \
1025                 polyline parameter is one unit per segment, so this would \
1026                 discard {} vertices",
1027                domain,
1028                natural.end,
1029                p.points.len().saturating_sub(2)
1030            )));
1031        }
1032        return polyline_flatten(&p.points, p.closed, domain, |t| evaluate2(curve, t));
1033    }
1034
1035    let mut out = vec![evaluate2(curve, domain.start)?];
1036    let eval = |t| evaluate2(curve, t);
1037    let bound = |a, b| crate::bound::chord_bound2(curve, a, b);
1038    for (a, b) in smooth_pieces(domain, &crate::bound::continuity_breaks2(curve, 1)) {
1039        subdivide(
1040            &eval,
1041            &bound,
1042            a,
1043            b,
1044            chord_tolerance,
1045            max_depth.min(MAX_DEPTH_CEILING),
1046            MAX_POINTS,
1047            &mut out,
1048        )?;
1049        out.push(evaluate2(curve, b)?);
1050    }
1051    Ok(out)
1052}
1053
1054/// `domain` cut at the breaks strictly inside it, in its own direction.
1055///
1056/// A chord across a knot where the curve is not `C^1` cannot be certified
1057/// (#232): the curve may turn a corner there. Cutting at the knot keeps the
1058/// corner as an exact vertex and leaves smooth pieces to bound.
1059fn smooth_pieces(domain: Interval, breaks: &[Scalar]) -> Vec<(Scalar, Scalar)> {
1060    let (lo, hi) = if domain.start <= domain.end {
1061        (domain.start, domain.end)
1062    } else {
1063        (domain.end, domain.start)
1064    };
1065    let mut cuts: Vec<Scalar> = breaks
1066        .iter()
1067        .copied()
1068        .filter(|&t| t > lo && t < hi)
1069        .collect();
1070    cuts.sort_by(Scalar::total_cmp);
1071    cuts.dedup();
1072    if domain.start > domain.end {
1073        cuts.reverse();
1074    }
1075    let mut out = Vec::with_capacity(cuts.len() + 1);
1076    let mut from = domain.start;
1077    for cut in cuts {
1078        out.push((from, cut));
1079        from = cut;
1080    }
1081    out.push((from, domain.end));
1082    out
1083}
1084
1085/// Hard ceiling on recursion depth regardless of what a caller asks for.
1086///
1087/// 2^20 segments is already far past any usable tolerance; beyond this a
1088/// request is a bug, not a quality setting.
1089const MAX_DEPTH_CEILING: u32 = 20;
1090
1091/// Emit interior points of `(a, b)` that are needed to meet the tolerance.
1092///
1093/// `budget` bounds total emitted points. Exceeding it is an error rather than
1094/// a truncation: silently returning a coarser polyline than the caller asked
1095/// for would violate the tolerance contract this function exists to honour.
1096/// The vector operations chord subdivision needs, in either dimension.
1097///
1098/// `Point2` and `Point3` are both glam vectors with the same surface, and
1099/// the subdivision below is genuinely dimension-independent: it measures a
1100/// sagitta and bisects a parameter interval, neither of which mentions a
1101/// coordinate count. This trait states that once instead of maintaining
1102/// two copies that can drift apart.
1103trait ChordPoint: Copy {
1104    fn sub(self, other: Self) -> Self;
1105    fn add_scaled(self, direction: Self, scale: Scalar) -> Self;
1106    fn dot(self, other: Self) -> Scalar;
1107    fn length(self) -> Scalar;
1108    fn length_squared(self) -> Scalar;
1109}
1110
1111impl ChordPoint for Point2 {
1112    fn sub(self, other: Self) -> Self {
1113        self - other
1114    }
1115    fn add_scaled(self, direction: Self, scale: Scalar) -> Self {
1116        self + direction * scale
1117    }
1118    fn dot(self, other: Self) -> Scalar {
1119        Point2::dot(self, other)
1120    }
1121    fn length(self) -> Scalar {
1122        Point2::length(self)
1123    }
1124    fn length_squared(self) -> Scalar {
1125        Point2::length_squared(self)
1126    }
1127}
1128
1129impl ChordPoint for Point3 {
1130    fn sub(self, other: Self) -> Self {
1131        self - other
1132    }
1133    fn add_scaled(self, direction: Self, scale: Scalar) -> Self {
1134        self + direction * scale
1135    }
1136    fn dot(self, other: Self) -> Scalar {
1137        Point3::dot(self, other)
1138    }
1139    fn length(self) -> Scalar {
1140        Point3::length(self)
1141    }
1142    fn length_squared(self) -> Scalar {
1143        Point3::length_squared(self)
1144    }
1145}
1146
1147/// Perpendicular distance from `m` to the chord `a`-`b`.
1148fn sagitta<P: ChordPoint>(a: P, b: P, m: P) -> Scalar {
1149    let ab = b.sub(a);
1150    let len2 = ab.length_squared();
1151    if len2 <= 0.0 {
1152        // Degenerate chord: fall back to point distance so a closed curve
1153        // whose endpoints coincide still subdivides.
1154        return m.sub(a).length();
1155    }
1156    let t = (m.sub(a).dot(ab) / len2).clamp(0.0, 1.0);
1157    m.sub(a.add_scaled(ab, t)).length()
1158}
1159
1160/// Emit the interior points of `(a, b)` needed to meet `tol`.
1161///
1162/// Shared by both dimensions; the caller supplies the evaluator. Depth
1163/// exhaustion is an error rather than a truncation, matching the 2D
1164/// contract: silently returning a coarser polyline than asked for would
1165/// break the tolerance guarantee the caller is relying on.
1166///
1167/// A span is accepted when its midpoint sagitta is within `tol` AND, for a
1168/// family [`crate::bound`] can bound, its certified chord bound is too
1169/// (#232). The midpoint alone is a measurement: an ellipse or a spline can
1170/// cross its chord at the midpoint and bulge either side of it. A family
1171/// with no bound (`bound` returns `None`) keeps the sagitta test alone,
1172/// which is why [`crate::bound::certifies_flattening2`] names the families
1173/// whose output is certified.
1174#[allow(clippy::too_many_arguments)]
1175fn subdivide<P, F, B>(
1176    eval: &F,
1177    bound: &B,
1178    a: Scalar,
1179    b: Scalar,
1180    tol: Scalar,
1181    depth: u32,
1182    budget: usize,
1183    out: &mut Vec<P>,
1184) -> GeomResult<()>
1185where
1186    P: ChordPoint,
1187    F: Fn(Scalar) -> GeomResult<P>,
1188    B: Fn(Scalar, Scalar) -> Option<Scalar>,
1189{
1190    if out.len() >= budget {
1191        return Err(GeomError::Degenerate(format!(
1192            "curve flattening exceeded {budget} points before meeting the \
1193             chord tolerance {tol}; the curve may be degenerate"
1194        )));
1195    }
1196    let mid = 0.5 * (a + b);
1197    let pa = eval(a)?;
1198    let pb = eval(b)?;
1199    // A parameter interval too small to bisect cannot be refined further:
1200    // `mid` equals `a` or `b` in floating point. Returning the chord anyway
1201    // would hand back an unverified approximation, so this fails closed --
1202    // unless the chord already meets the tolerance, in which case there was
1203    // nothing left to verify.
1204    if !(mid > a && mid < b) {
1205        if sagitta(pa, pb, pa) <= tol && (pb.sub(pa)).length() <= tol {
1206            return Ok(());
1207        }
1208        return Err(GeomError::Degenerate(format!(
1209            "curve parameter interval ({a}, {b}) is too small to bisect but \
1210             its chord still exceeds the tolerance {tol}"
1211        )));
1212    }
1213    let pm = eval(mid)?;
1214    if sagitta(pa, pb, pm) <= tol && bound(a, b).is_none_or(|certified| certified <= tol) {
1215        // Within tolerance: the chord a->b stands, no interior point.
1216        return Ok(());
1217    }
1218    if depth == 0 {
1219        return Err(GeomError::BudgetExceeded {
1220            resource: "curve flattening depth",
1221        });
1222    }
1223    subdivide(eval, bound, a, mid, tol, depth - 1, budget, out)?;
1224    out.push(pm);
1225    subdivide(eval, bound, mid, b, tol, depth - 1, budget, out)?;
1226    Ok(())
1227}
1228
1229/// Polylines flatten to their own breakpoints, restricted to `domain`.
1230fn polyline_flatten<P, F>(
1231    points: &[P],
1232    closed: bool,
1233    domain: Interval,
1234    eval: F,
1235) -> GeomResult<Vec<P>>
1236where
1237    P: Copy,
1238    F: Fn(Scalar) -> GeomResult<P>,
1239{
1240    let segments = if closed {
1241        points.len()
1242    } else {
1243        points.len().saturating_sub(1)
1244    };
1245    if segments == 0 {
1246        return Err(GeomError::Degenerate(
1247            "polyline has no evaluable segment".to_owned(),
1248        ));
1249    }
1250    let lo = domain.start.min(domain.end);
1251    let hi = domain.start.max(domain.end);
1252    let mut out = vec![eval(lo)?];
1253    // Interior breakpoints are the integer parameters strictly inside.
1254    let first = lo.floor() as i64 + 1;
1255    let last = hi.ceil() as i64 - 1;
1256    for k in first..=last {
1257        let t = k as Scalar;
1258        if t > lo && t < hi {
1259            out.push(eval(t)?);
1260        }
1261    }
1262    out.push(eval(hi)?);
1263    Ok(out)
1264}
1265
1266// --- trait wiring -----------------------------------------------------------
1267
1268impl CurveEvaluator<Curve2> for ScalarCurve {
1269    type Point = Point2;
1270    type Derivative = Vec2;
1271    type Error = GeomError;
1272
1273    fn domain(&self, curve: &Curve2) -> Interval {
1274        domain2(curve)
1275    }
1276
1277    fn evaluate(
1278        &self,
1279        curve: &Curve2,
1280        t: Scalar,
1281        _tolerance: Tolerance,
1282    ) -> Result<Self::Point, Self::Error> {
1283        evaluate2(curve, t)
1284    }
1285
1286    fn derivative(
1287        &self,
1288        curve: &Curve2,
1289        t: Scalar,
1290        _tolerance: Tolerance,
1291    ) -> Result<Self::Derivative, Self::Error> {
1292        derivative2(curve, t)
1293    }
1294}
1295
1296impl CurveEvaluator<Curve3> for ScalarCurve {
1297    type Point = Point3;
1298    type Derivative = Vec3;
1299    type Error = GeomError;
1300
1301    fn domain(&self, curve: &Curve3) -> Interval {
1302        domain3(curve)
1303    }
1304
1305    fn evaluate(
1306        &self,
1307        curve: &Curve3,
1308        t: Scalar,
1309        _tolerance: Tolerance,
1310    ) -> Result<Self::Point, Self::Error> {
1311        evaluate3(curve, t)
1312    }
1313
1314    fn derivative(
1315        &self,
1316        curve: &Curve3,
1317        t: Scalar,
1318        _tolerance: Tolerance,
1319    ) -> Result<Self::Derivative, Self::Error> {
1320        derivative3(curve, t)
1321    }
1322}
1323
1324// Silence unused-import warnings for types only named in signatures.
1325#[allow(unused)]
1326fn _type_anchors(_: Circle2, _: Circle3, _: Ellipse2, _: Ellipse3, _: Line2, _: Line3) {}
1327#[allow(unused)]
1328fn _poly_anchors(_: Polyline2, _: Polyline3) {}
1329
1330/// Flatten a 3D curve to a polyline within `chord_tolerance`.
1331///
1332/// The 3D twin of [`flatten2`], sharing its subdivision, its resource
1333/// bounds and its polyline contract. Sampling is adaptive: a curve is
1334/// bisected only where the chord actually departs from it, so a gentle
1335/// arc costs few points and a tight one costs many, and neither is
1336/// decided by a fixed count chosen in advance.
1337pub fn flatten3(
1338    curve: &Curve3,
1339    domain: Interval,
1340    chord_tolerance: Scalar,
1341    max_depth: u32,
1342) -> GeomResult<Vec<Point3>> {
1343    // A depth bound alone is not a resource bound: depth `d` permits `2^d`
1344    // segments. Cap the total point count too, so a curve that cannot meet
1345    // the tolerance fails fast instead of exhausting memory.
1346    const MAX_POINTS: usize = 1 << 16;
1347    if !(chord_tolerance.is_finite()
1348        && chord_tolerance.is_sign_positive()
1349        && chord_tolerance != 0.0)
1350    {
1351        return Err(GeomError::InvalidInput(format!(
1352            "chord tolerance must be positive and finite, got {chord_tolerance}"
1353        )));
1354    }
1355    // A line is exact between its endpoints: subdividing adds vertices that
1356    // carry no information.
1357    if let Curve3::Line(_) = curve {
1358        return Ok(vec![
1359            evaluate3(curve, domain.start)?,
1360            evaluate3(curve, domain.end)?,
1361        ]);
1362    }
1363    if let Curve3::Polyline(p) = curve {
1364        // A polyline's parameter is one unit per segment, so a caller passing
1365        // a normalized `(0, 1)` domain would silently collapse an n-vertex
1366        // path to its first edge. That is data loss disguised as success.
1367        let natural = polyline_domain(p.points.len(), p.closed);
1368        let requested = (domain.end - domain.start).abs();
1369        if natural.end > 1.0 && requested <= 1.0 {
1370            return Err(GeomError::InvalidInput(format!(
1371                "polyline domain {:?} spans {requested} of {} segments; a \
1372                 polyline parameter is one unit per segment, so this would \
1373                 discard {} vertices",
1374                domain,
1375                natural.end,
1376                p.points.len().saturating_sub(2)
1377            )));
1378        }
1379        return polyline_flatten(&p.points, p.closed, domain, |t| evaluate3(curve, t));
1380    }
1381
1382    let eval = |t| evaluate3(curve, t);
1383    let bound = |a, b| crate::bound::chord_bound3(curve, a, b);
1384    let mut out = vec![eval(domain.start)?];
1385    for (a, b) in smooth_pieces(domain, &crate::bound::continuity_breaks3(curve, 1)) {
1386        subdivide(
1387            &eval,
1388            &bound,
1389            a,
1390            b,
1391            chord_tolerance,
1392            max_depth.min(MAX_DEPTH_CEILING),
1393            MAX_POINTS,
1394            &mut out,
1395        )?;
1396        out.push(eval(b)?);
1397    }
1398    Ok(out)
1399}
1400
1401/// Refuse a family that has no closed-form inversion.
1402///
1403/// Named separately from `unsupported_family` so the message can say WHY:
1404/// the family is understood, its inversion simply is not algebraic.
1405fn no_closed_form_inversion() -> GeomError {
1406    GeomError::InvalidInput(
1407        "curve family has no closed form inversion; a point trim on this basis \
1408         would require iteration, which trim resolution does not perform"
1409            .to_owned(),
1410    )
1411}
1412
1413/// The point is not on the curve, so no parameter names it.
1414fn point_not_on_curve(distance: Scalar, tolerance: Scalar) -> GeomError {
1415    GeomError::InvalidInput(format!(
1416        "point is {distance} from the curve, outside the {tolerance} tolerance; \
1417         refusing rather than projecting it onto the nearest parameter"
1418    ))
1419}
1420
1421/// Parameter of `point` on `line`, or a refusal when it is off the line.
1422///
1423/// The direction need not be unit length, so the projection is normalised by
1424/// its squared length: that is what makes the returned value a parameter of
1425/// THIS line rather than an arc length.
1426fn invert_line(
1427    origin: impl Into<[Scalar; 3]>,
1428    direction: [Scalar; 3],
1429    point: [Scalar; 3],
1430    tolerance: Scalar,
1431) -> GeomResult<Scalar> {
1432    let origin = origin.into();
1433    let dd = direction.iter().map(|c| c * c).sum::<Scalar>();
1434    if !dd.is_finite() || dd <= Scalar::EPSILON {
1435        return Err(GeomError::InvalidInput(
1436            "line direction is degenerate, so no parameter names a point".to_owned(),
1437        ));
1438    }
1439    let offset = [
1440        point[0] - origin[0],
1441        point[1] - origin[1],
1442        point[2] - origin[2],
1443    ];
1444    let t = offset
1445        .iter()
1446        .zip(direction.iter())
1447        .map(|(o, d)| o * d)
1448        .sum::<Scalar>()
1449        / dd;
1450    // Verify rather than assume: the projection always yields a parameter, but
1451    // only a point actually ON the line is named by it.
1452    let residual = [
1453        offset[0] - direction[0] * t,
1454        offset[1] - direction[1] * t,
1455        offset[2] - direction[2] * t,
1456    ];
1457    let distance = residual.iter().map(|c| c * c).sum::<Scalar>().sqrt();
1458    if distance > tolerance {
1459        return Err(point_not_on_curve(distance, tolerance));
1460    }
1461    Ok(t)
1462}
1463
1464/// Parametric angle of `point` about a conic frame, verified against the curve.
1465///
1466/// For a circle the parametric angle is the polar angle; for an ellipse it is
1467/// not, so the local coordinates are divided by their semi-axes BEFORE the
1468/// arctangent. Taking the polar angle directly would be wrong off-axis.
1469fn invert_conic(
1470    local_x: Scalar,
1471    local_y: Scalar,
1472    semi_x: Scalar,
1473    semi_y: Scalar,
1474) -> GeomResult<Scalar> {
1475    if !(semi_x.is_finite() && semi_y.is_finite()) || semi_x <= 0.0 || semi_y <= 0.0 {
1476        return Err(GeomError::InvalidInput(
1477            "conic semi-axes must be finite and positive to invert a point".to_owned(),
1478        ));
1479    }
1480    let angle = (local_y / semi_y).atan2(local_x / semi_x);
1481    if !angle.is_finite() {
1482        return Err(GeomError::InvalidInput(
1483            "conic inversion produced a non-finite angle".to_owned(),
1484        ));
1485    }
1486    // Report on the same domain evaluation uses, so invert then evaluate is a
1487    // round trip rather than an off-by-one-turn surprise.
1488    Ok(angle.rem_euclid(std::f64::consts::TAU))
1489}
1490
1491/// Parameter naming `point` on a 2D curve, or a refusal.
1492///
1493/// Exact for the families whose inversion is algebraic. Anything else is
1494/// refused by name: introducing iteration here would put a tolerance and a
1495/// convergence failure mode into every consumer of a point trim, and the
1496/// certified iterative path belongs to a caller that can carry its evidence.
1497///
1498/// # Errors
1499///
1500/// Refuses a point further than `tolerance` from the curve rather than
1501/// projecting it, and refuses families with no closed-form inversion.
1502pub fn invert2(curve: &Curve2, point: Point2, tolerance: Tolerance) -> GeomResult<Scalar> {
1503    finite2(point, "inversion point")?;
1504    let linear = tolerance.linear();
1505    match curve {
1506        Curve2::Line(l) => invert_line(
1507            [l.origin.x, l.origin.y, 0.0],
1508            [l.direction.x, l.direction.y, 0.0],
1509            [point.x, point.y, 0.0],
1510            linear,
1511        ),
1512        Curve2::Circle(c) => {
1513            let t = invert_conic_in_frame2(&c.frame, point, c.radius, c.radius)?;
1514            verify2(curve, t, point, linear)
1515        }
1516        Curve2::Ellipse(e) => {
1517            let t = invert_conic_in_frame2(&e.frame, point, e.semi_axis_x, e.semi_axis_y)?;
1518            verify2(curve, t, point, linear)
1519        }
1520        // A graph over its parameter: the parameter of a point IS its first
1521        // coordinate, then the height is checked.
1522        Curve2::Sinusoid(_) | Curve2::QuadraticGraph(_) => verify2(curve, point.x, point, linear),
1523        // A graph over its second coordinate; the angle it returns lies in
1524        // `(-pi, pi]`, so a point given a whole turn away is read there.
1525        Curve2::AngleGraph(g) => {
1526            let u = g.angle(point.y).ok_or_else(|| outside_graph(point.y))?;
1527            let turns = ((point.x - u) / std::f64::consts::TAU).round();
1528            let shifted = Point2::new(point.x - turns * std::f64::consts::TAU, point.y);
1529            verify2(curve, point.y, shifted, linear)
1530        }
1531        // The space curve's parameter of the point lifted onto the carrier.
1532        Curve2::Lifted(l) => {
1533            let lifted = l.carrier.jet(point.x, point.y).point;
1534            let t = invert3(&l.curve, lifted, tolerance)?;
1535            verify2(curve, t, point, linear)
1536        }
1537        // The cell whose box holds the point; its free value places it.
1538        Curve2::Implicit(c) => {
1539            let t = c
1540                .parameter_of(point)
1541                .ok_or_else(|| point_not_on_curve(Scalar::INFINITY, linear))?;
1542            verify2(curve, t, point, linear)
1543        }
1544        _ => Err(no_closed_form_inversion()),
1545    }
1546}
1547
1548/// Project a point into a 2D conic frame and invert it there.
1549fn invert_conic_in_frame2(
1550    frame: &axiolid_core::Frame2,
1551    point: Point2,
1552    semi_x: Scalar,
1553    semi_y: Scalar,
1554) -> GeomResult<Scalar> {
1555    let offset = point - frame.origin;
1556    invert_conic(offset.dot(frame.x), offset.dot(frame.y), semi_x, semi_y)
1557}
1558
1559/// Confirm the recovered parameter actually reproduces the point.
1560///
1561/// The algebra above assumes an orthonormal frame. Imported frames are not
1562/// always orthonormal, and this crate deliberately keeps dirty frames
1563/// representable, so the claim is checked against the real evaluator instead
1564/// of trusted.
1565fn verify2(curve: &Curve2, t: Scalar, point: Point2, tolerance: Scalar) -> GeomResult<Scalar> {
1566    let found = evaluate2(curve, t)?;
1567    let distance = (found - point).length();
1568    if distance > tolerance {
1569        return Err(point_not_on_curve(distance, tolerance));
1570    }
1571    Ok(t)
1572}
1573
1574/// Parameter naming `point` on a 3D curve, or a refusal.
1575///
1576/// See [`invert2`] for the exactness policy.
1577///
1578/// # Errors
1579///
1580/// Refuses an off-curve point and any family without a closed-form inversion.
1581pub fn invert3(curve: &Curve3, point: Point3, tolerance: Tolerance) -> GeomResult<Scalar> {
1582    finite3(point, "inversion point")?;
1583    let linear = tolerance.linear();
1584    match curve {
1585        Curve3::Line(l) => invert_line(
1586            [l.origin.x, l.origin.y, l.origin.z],
1587            [l.direction.x, l.direction.y, l.direction.z],
1588            [point.x, point.y, point.z],
1589            linear,
1590        ),
1591        Curve3::Circle(c) => {
1592            let t = invert_conic_in_frame3(&c.frame, point, c.radius, c.radius)?;
1593            verify3(curve, t, point, linear)
1594        }
1595        Curve3::Ellipse(e) => {
1596            let t = invert_conic_in_frame3(&e.frame, point, e.semi_axis_x, e.semi_axis_y)?;
1597            verify3(curve, t, point, linear)
1598        }
1599        // Graphs over the carrier angle: the parameter is that angle, read
1600        // off the point, up to whole turns (ADR 0076).
1601        Curve3::RuledSection(r) => {
1602            let carrier = axiolid_curve::Carrier::Ruled(r.carrier);
1603            let (u, _) = carrier.parameters(point);
1604            first_on_curve(curve, &turns_of(u), point, linear)
1605        }
1606        Curve3::TorusSection(r) => {
1607            let carrier = axiolid_curve::Carrier::Torus(r.torus);
1608            let (_, v) = carrier.parameters(point);
1609            first_on_curve(curve, &turns_of(v), point, linear)
1610        }
1611        // The nearest chord, then the curve itself (ADR 0077).
1612        Curve3::PairSection(r) => {
1613            let t = r
1614                .parameter_of(point)
1615                .ok_or_else(|| point_not_on_curve(Scalar::INFINITY, linear))?;
1616            verify3(curve, t, point, linear)
1617        }
1618        // The carrier's parameters of the point, over whole turns, located
1619        // in the curve's cells (ADR 0077).
1620        Curve3::ImplicitSection(r) => {
1621            // A B-spline carrier has no closed-form inverse: the surface is
1622            // inverted, then the cell located.
1623            if let axiolid_curve::Carrier::Spline(b) = &r.carrier {
1624                let surface = axiolid_surface::Surface::BSpline((**b).clone());
1625                let (u, v) = crate::surface::locate(&surface, point, tolerance)?;
1626                let t = r
1627                    .curve
1628                    .parameter_of(Point2::new(u, v))
1629                    .ok_or_else(|| point_not_on_curve(Scalar::INFINITY, linear))?;
1630                return verify3(curve, t, point, linear);
1631            }
1632            let (u, v) = r.carrier.parameters(point);
1633            let (pu, pv) = r.carrier.periodic();
1634            let turns = |periodic: bool| -> &'static [Scalar] {
1635                if periodic {
1636                    &[0.0, 1.0, -1.0, 2.0, -2.0]
1637                } else {
1638                    &[0.0]
1639                }
1640            };
1641            let mut candidates = Vec::new();
1642            for &ku in turns(pu) {
1643                for &kv in turns(pv) {
1644                    let tau = std::f64::consts::TAU;
1645                    if let Some(t) = r
1646                        .curve
1647                        .parameter_of(Point2::new(u + ku * tau, v + kv * tau))
1648                    {
1649                        candidates.push(t);
1650                    }
1651                }
1652            }
1653            first_on_curve(curve, &candidates, point, linear)
1654        }
1655        _ => Err(no_closed_form_inversion()),
1656    }
1657}
1658
1659/// An angle and its neighbours a whole turn or two away.
1660fn turns_of(angle: Scalar) -> Vec<Scalar> {
1661    let tau = std::f64::consts::TAU;
1662    vec![
1663        angle,
1664        angle + tau,
1665        angle - tau,
1666        angle + 2.0 * tau,
1667        angle - 2.0 * tau,
1668    ]
1669}
1670
1671/// The first candidate parameter that reproduces the point.
1672fn first_on_curve(
1673    curve: &Curve3,
1674    candidates: &[Scalar],
1675    point: Point3,
1676    tolerance: Scalar,
1677) -> GeomResult<Scalar> {
1678    let mut nearest = Scalar::INFINITY;
1679    for &t in candidates {
1680        if let Ok(found) = evaluate3(curve, t) {
1681            let distance = (found - point).length();
1682            if distance <= tolerance {
1683                return Ok(t);
1684            }
1685            nearest = nearest.min(distance);
1686        }
1687    }
1688    Err(point_not_on_curve(nearest, tolerance))
1689}
1690
1691/// Parameter of a point on a 3D curve, iterating where no closed form
1692/// exists: [`invert3`] first, and for a B-spline the nearest of 64 samples
1693/// per span refined by Newton on `|C(t) - p|^2`. The answer must reproduce
1694/// the point within `tolerance`, as [`invert3`]'s must.
1695///
1696/// # Errors
1697///
1698/// The point is not on the curve, or the family cannot be evaluated.
1699pub fn locate3(curve: &Curve3, point: Point3, tolerance: Tolerance) -> GeomResult<Scalar> {
1700    match invert3(curve, point, tolerance) {
1701        Ok(t) => Ok(t),
1702        Err(error) => match curve {
1703            Curve3::BSpline(b) => {
1704                let domain = spline_domain(b);
1705                let t = nearest_parameter(
1706                    domain,
1707                    b.control_points.len().max(2) * 64,
1708                    |t| evaluate3(curve, t).map(|p| (p - point).length()),
1709                    |t| {
1710                        let (p, d, dd) = (
1711                            evaluate3(curve, t)?,
1712                            derivative3(curve, t)?,
1713                            second_derivative3(curve, t)?,
1714                        );
1715                        let r = p - point;
1716                        Ok((r.dot(d), d.dot(d) + r.dot(dd)))
1717                    },
1718                )?;
1719                verify3(curve, t, point, tolerance.linear())
1720            }
1721            _ => Err(error),
1722        },
1723    }
1724}
1725
1726/// Parameter of a point on a 2D curve, iterating where no closed form
1727/// exists. See [`locate3`].
1728///
1729/// # Errors
1730///
1731/// The point is not on the curve, or the family cannot be evaluated.
1732pub fn locate2(curve: &Curve2, point: Point2, tolerance: Tolerance) -> GeomResult<Scalar> {
1733    match invert2(curve, point, tolerance) {
1734        Ok(t) => Ok(t),
1735        Err(error) => match curve {
1736            Curve2::BSpline(b) => {
1737                let domain = spline_domain(b);
1738                let t = nearest_parameter(
1739                    domain,
1740                    b.control_points.len().max(2) * 64,
1741                    |t| evaluate2(curve, t).map(|p| (p - point).length()),
1742                    |t| {
1743                        let (p, d, dd) = (
1744                            evaluate2(curve, t)?,
1745                            derivative2(curve, t)?,
1746                            second_derivative2(curve, t)?,
1747                        );
1748                        let r = p - point;
1749                        Ok((r.dot(d), d.dot(d) + r.dot(dd)))
1750                    },
1751                )?;
1752                verify2(curve, t, point, tolerance.linear())
1753            }
1754            _ => Err(error),
1755        },
1756    }
1757}
1758
1759/// The parameter in `domain` nearest the target: the best of `samples`
1760/// evenly spaced values, then Newton on the distance's derivative
1761/// (`gradient` returns it and its derivative), kept inside the domain.
1762fn nearest_parameter(
1763    domain: Interval,
1764    samples: usize,
1765    distance: impl Fn(Scalar) -> GeomResult<Scalar>,
1766    gradient: impl Fn(Scalar) -> GeomResult<(Scalar, Scalar)>,
1767) -> GeomResult<Scalar> {
1768    let (lo, hi) = (domain.start.min(domain.end), domain.start.max(domain.end));
1769    let mut best = (Scalar::INFINITY, lo);
1770    for i in 0..=samples {
1771        let t = lo + (hi - lo) * i as Scalar / samples as Scalar;
1772        let d = distance(t)?;
1773        if d < best.0 {
1774            best = (d, t);
1775        }
1776    }
1777    let mut t = best.1;
1778    for _ in 0..60 {
1779        let (g, h) = gradient(t)?;
1780        if h <= 0.0 || !h.is_finite() {
1781            break;
1782        }
1783        let next = (t - g / h).clamp(lo, hi);
1784        let moved = (next - t).abs();
1785        t = next;
1786        if moved <= 4.0 * Scalar::EPSILON * (1.0 + t.abs()) {
1787            break;
1788        }
1789    }
1790    Ok(t)
1791}
1792
1793/// Project a point into a 3D conic frame and invert it there.
1794fn invert_conic_in_frame3(
1795    frame: &axiolid_core::Frame3,
1796    point: Point3,
1797    semi_x: Scalar,
1798    semi_y: Scalar,
1799) -> GeomResult<Scalar> {
1800    let offset = point - frame.origin;
1801    invert_conic(offset.dot(frame.x), offset.dot(frame.y), semi_x, semi_y)
1802}
1803
1804/// Confirm the recovered parameter reproduces the point. See [`verify2`].
1805fn verify3(curve: &Curve3, t: Scalar, point: Point3, tolerance: Scalar) -> GeomResult<Scalar> {
1806    let found = evaluate3(curve, t)?;
1807    let distance = (found - point).length();
1808    if distance > tolerance {
1809        return Err(point_not_on_curve(distance, tolerance));
1810    }
1811    Ok(t)
1812}