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            for k in 0..N {
649                points[j][k] = points[j - 1][k] * (1.0 - alpha) + points[j][k] * alpha;
650            }
651            weights[j] = weights[j - 1] * (1.0 - alpha) + weights[j] * alpha;
652        }
653    }
654}
655
656// --- de Boor ----------------------------------------------------------------
657
658/// Shared setup: validated flat knots, degree, and the knot span for `t`.
659fn spline_span<P>(b: &BSplineCurve<P>, t: Scalar) -> GeomResult<(Vec<Scalar>, usize, usize)> {
660    let axis = SplineAxis::new(
661        &b.knots,
662        &b.multiplicities,
663        b.degree,
664        b.control_points.len(),
665        "B-spline curve",
666    )?;
667    if let Some(weights) = &b.weights {
668        if weights.len() != b.control_points.len() {
669            return Err(GeomError::InvalidInput(format!(
670                "B-spline has {} weights for {} control points",
671                weights.len(),
672                b.control_points.len()
673            )));
674        }
675        if weights
676            .iter()
677            .any(|weight| !weight.is_finite() || *weight <= 0.0)
678        {
679            return Err(GeomError::InvalidInput(
680                "B-spline weights must be finite and strictly positive".to_owned(),
681            ));
682        }
683    }
684    let t = axis.clamp(t);
685    let span = span_in(&axis.knots, axis.count, axis.degree, t);
686    Ok((axis.knots, span, axis.degree))
687}
688
689/// Convert and validate every control point before selecting a knot span.
690/// Imported NaN/Inf coordinates must not be hidden in currently uninfluential
691/// spans and surface later when the parameter changes.
692fn finite_control_points<P, const N: usize, F>(
693    control_points: &[P],
694    to: &F,
695) -> GeomResult<Vec<[Scalar; N]>>
696where
697    F: Fn(&P) -> [Scalar; N],
698{
699    let points: Vec<[Scalar; N]> = control_points.iter().map(to).collect();
700    if points
701        .iter()
702        .flatten()
703        .any(|coordinate| !coordinate.is_finite())
704    {
705        return Err(GeomError::InvalidInput(
706            "B-spline control points must be finite".to_owned(),
707        ));
708    }
709    Ok(points)
710}
711
712/// Position via de Boor's algorithm.
713///
714/// `to` and `from` convert between the point type and a fixed-size coordinate
715/// array so 2D and 3D share one implementation. Rational curves are evaluated
716/// in homogeneous coordinates `(w*x, w*y, [w*z], w)` and projected at the end.
717fn de_boor<P, const N: usize, F, G, Q>(
718    b: &BSplineCurve<P>,
719    t: Scalar,
720    to: F,
721    from: G,
722) -> GeomResult<Q>
723where
724    F: Fn(&P) -> [Scalar; N],
725    G: Fn([Scalar; N]) -> Q,
726{
727    let (knots, span, d) = spline_span(b, t)?;
728    let control_points = finite_control_points(&b.control_points, &to)?;
729    let u = t.clamp(knots[d], knots[b.control_points.len()]);
730
731    // Working set: the d+1 control points influencing this span, in homogeneous
732    // form. The trailing slot holds the weight (1.0 for polynomial curves).
733    let mut work: Vec<[Scalar; N]> = Vec::with_capacity(d + 1);
734    let mut weights: Vec<Scalar> = Vec::with_capacity(d + 1);
735    for j in 0..=d {
736        let idx = span - d + j;
737        let w = b.weights.as_ref().map_or(1.0, |ws| ws[idx]);
738        let c = control_points[idx];
739        // Premultiply by w: interpolating in homogeneous space is what makes
740        // rational curves correct. Projecting first would be plain averaging.
741        let homogeneous = core::array::from_fn(|k| c[k] * w);
742        if homogeneous.iter().any(|value| !value.is_finite()) {
743            return Err(GeomError::Degenerate(
744                "B-spline homogeneous control point overflowed".to_owned(),
745            ));
746        }
747        work.push(homogeneous);
748        weights.push(w);
749    }
750
751    // A repeated knot makes an interval empty; the shared recurrence treats
752    // that as alpha = 0, which is the correct limit.
753    de_boor_recurrence(&knots, span, d, u, &mut work, &mut weights);
754
755    let w = weights[d];
756    if !w.is_finite() || w == 0.0 {
757        return Err(GeomError::Degenerate(
758            "B-spline weight collapsed to zero".to_owned(),
759        ));
760    }
761    Ok(from(core::array::from_fn(|k| work[d][k] / w)))
762}
763
764/// First derivative via the hodograph, with the quotient rule for rationals.
765///
766/// The derivative of a degree-`d` B-spline is a degree-`(d-1)` B-spline over
767/// the same knots minus their outermost entries, with control points
768/// `d * (P[i+1] - P[i]) / (knots[i+d+1] - knots[i+1])`.
769///
770/// For a rational curve `C = A/w`, both `A` and `w` are differentiated in
771/// homogeneous space and combined as `(A' - C * w') / w`.
772fn de_boor_derivative<P, const N: usize, F, G, Q>(
773    b: &BSplineCurve<P>,
774    t: Scalar,
775    to: F,
776    from: G,
777) -> GeomResult<Q>
778where
779    F: Fn(&P) -> [Scalar; N],
780    G: Fn([Scalar; N]) -> Q,
781{
782    let (knots, _, d) = spline_span(b, t)?;
783    let control_points = finite_control_points(&b.control_points, &to)?;
784    let n = b.control_points.len();
785    let u = t.clamp(knots[d], knots[n]);
786
787    // Homogeneous control points, weight in a parallel array.
788    let hom: Vec<[Scalar; N]> = (0..n)
789        .map(|i| {
790            let w = b.weights.as_ref().map_or(1.0, |ws| ws[i]);
791            let c = control_points[i];
792            core::array::from_fn(|k| c[k] * w)
793        })
794        .collect();
795    if hom.iter().flatten().any(|value| !value.is_finite()) {
796        return Err(GeomError::Degenerate(
797            "B-spline homogeneous control point overflowed".to_owned(),
798        ));
799    }
800    let hw: Vec<Scalar> = (0..n)
801        .map(|i| b.weights.as_ref().map_or(1.0, |ws| ws[i]))
802        .collect();
803
804    // Hodograph control points.
805    let mut dhom: Vec<[Scalar; N]> = Vec::with_capacity(n - 1);
806    let mut dhw: Vec<Scalar> = Vec::with_capacity(n - 1);
807    for i in 0..n - 1 {
808        let denom = knots[i + d + 1] - knots[i + 1];
809        let f = if denom.abs() > 0.0 {
810            d as Scalar / denom
811        } else {
812            0.0
813        };
814        dhom.push(core::array::from_fn(|k| (hom[i + 1][k] - hom[i][k]) * f));
815        dhw.push((hw[i + 1] - hw[i]) * f);
816    }
817
818    // Evaluate the hodograph at u with degree d-1 over the trimmed knots.
819    let dknots = &knots[1..knots.len() - 1];
820    let (da, dw) = eval_homogeneous(dknots, d - 1, &dhom, &dhw, u);
821    // Evaluate the curve itself for the quotient rule.
822    let (a, w) = eval_homogeneous(&knots, d, &hom, &hw, u);
823
824    if !w.is_finite() || w == 0.0 {
825        return Err(GeomError::Degenerate(
826            "B-spline weight collapsed to zero".to_owned(),
827        ));
828    }
829    // C = A/w  =>  C' = (A' - (A/w) * w') / w
830    Ok(from(core::array::from_fn(|k| {
831        (da[k] - (a[k] / w) * dw) / w
832    })))
833}
834
835/// Second derivative via two homogeneous hodograph constructions.
836///
837/// For `C = A / w`, the rational recurrence is
838/// `C'' = (A'' - 2 w' C' - w'' C) / w`.
839fn de_boor_second_derivative<P, const N: usize, F, G, Q>(
840    b: &BSplineCurve<P>,
841    t: Scalar,
842    to: F,
843    from: G,
844) -> GeomResult<Q>
845where
846    F: Fn(&P) -> [Scalar; N],
847    G: Fn([Scalar; N]) -> Q,
848{
849    let (knots, _, degree) = spline_span(b, t)?;
850    let control_points = finite_control_points(&b.control_points, &to)?;
851    let count = b.control_points.len();
852    let u = t.clamp(knots[degree], knots[count]);
853
854    let points: Vec<[Scalar; N]> = (0..count)
855        .map(|i| {
856            let weight = b.weights.as_ref().map_or(1.0, |weights| weights[i]);
857            core::array::from_fn(|axis| control_points[i][axis] * weight)
858        })
859        .collect();
860    if points.iter().flatten().any(|value| !value.is_finite()) {
861        return Err(GeomError::Degenerate(
862            "B-spline homogeneous control point overflowed".to_owned(),
863        ));
864    }
865    let weights: Vec<Scalar> = (0..count)
866        .map(|i| b.weights.as_ref().map_or(1.0, |values| values[i]))
867        .collect();
868
869    let (point, weight) = eval_homogeneous(&knots, degree, &points, &weights, u);
870    if !weight.is_finite() || weight == 0.0 {
871        return Err(GeomError::Degenerate(
872            "B-spline weight collapsed to zero".to_owned(),
873        ));
874    }
875
876    let (first_points, first_weights) = derivative_controls(&points, &weights, &knots, degree);
877    let first_knots = &knots[1..knots.len() - 1];
878    let (first, first_weight) =
879        eval_homogeneous(first_knots, degree - 1, &first_points, &first_weights, u);
880    let position: [Scalar; N] = core::array::from_fn(|axis| point[axis] / weight);
881    let first_projected: [Scalar; N] =
882        core::array::from_fn(|axis| (first[axis] - position[axis] * first_weight) / weight);
883
884    let (second, second_weight) = if degree == 1 {
885        ([0.0; N], 0.0)
886    } else {
887        let (second_points, second_weights) =
888            derivative_controls(&first_points, &first_weights, first_knots, degree - 1);
889        let second_knots = &first_knots[1..first_knots.len() - 1];
890        eval_homogeneous(second_knots, degree - 2, &second_points, &second_weights, u)
891    };
892    Ok(from(core::array::from_fn(|axis| {
893        (second[axis] - 2.0 * first_weight * first_projected[axis] - second_weight * position[axis])
894            / weight
895    })))
896}
897
898/// Derivative control polygon for one homogeneous B-spline axis.
899fn derivative_controls<const N: usize>(
900    points: &[[Scalar; N]],
901    weights: &[Scalar],
902    knots: &[Scalar],
903    degree: usize,
904) -> (Vec<[Scalar; N]>, Vec<Scalar>) {
905    let mut derivative_points = Vec::with_capacity(points.len() - 1);
906    let mut derivative_weights = Vec::with_capacity(weights.len() - 1);
907    for i in 0..points.len() - 1 {
908        let denominator = knots[i + degree + 1] - knots[i + 1];
909        let factor = if denominator.abs() > 0.0 {
910            degree as Scalar / denominator
911        } else {
912            0.0
913        };
914        derivative_points.push(core::array::from_fn(|axis| {
915            (points[i + 1][axis] - points[i][axis]) * factor
916        }));
917        derivative_weights.push((weights[i + 1] - weights[i]) * factor);
918    }
919    (derivative_points, derivative_weights)
920}
921
922/// de Boor over explicit homogeneous arrays; returns `(numerator, weight)`.
923pub(crate) fn eval_homogeneous<const N: usize>(
924    knots: &[Scalar],
925    d: usize,
926    hom: &[[Scalar; N]],
927    hw: &[Scalar],
928    u: Scalar,
929) -> ([Scalar; N], Scalar) {
930    let n = hom.len();
931    if d == 0 {
932        // Degree zero: piecewise constant, pick the containing span.
933        let mut idx = 0;
934        for (k, knot) in knots.iter().enumerate().take(n) {
935            if *knot <= u {
936                idx = k;
937            }
938        }
939        return (hom[idx.min(n - 1)], hw[idx.min(n - 1)]);
940    }
941    let mut span = d;
942    for (k, knot) in knots.iter().enumerate().take(n).skip(d) {
943        if *knot <= u {
944            span = k;
945        } else {
946            break;
947        }
948    }
949    let mut work: Vec<[Scalar; N]> = (0..=d).map(|j| hom[span - d + j]).collect();
950    let mut weights: Vec<Scalar> = (0..=d).map(|j| hw[span - d + j]).collect();
951    for r in 1..=d {
952        for j in (r..=d).rev() {
953            let i = span - d + j;
954            let denom = knots[i + d + 1 - r] - knots[i];
955            let alpha = if denom.abs() > 0.0 {
956                (u - knots[i]) / denom
957            } else {
958                0.0
959            };
960            for k in 0..N {
961                work[j][k] = work[j - 1][k] * (1.0 - alpha) + work[j][k] * alpha;
962            }
963            weights[j] = weights[j - 1] * (1.0 - alpha) + weights[j] * alpha;
964        }
965    }
966    (work[d], weights[d])
967}
968
969// --- adaptive flattening ----------------------------------------------------
970
971/// Flatten a 2D curve over `domain` so the chord never deviates from the true
972/// curve by more than `chord_tolerance`.
973///
974/// For the families [`crate::bound::certifies_flattening2`] names this is a
975/// guarantee: every span is also accepted on a certified chord bound, and a
976/// B-spline is cut at its corner knots first (#232). Other families are
977/// accepted on their midpoint sagitta alone.
978///
979/// # Why bisection rather than a closed-form segment count
980///
981/// A count derived from radius and tolerance only works for circles. Bisecting
982/// on measured sagitta works for every family, including rational splines whose
983/// curvature varies along the span, and it degrades gracefully on the
984/// degenerate inputs imported data actually contains.
985///
986/// The returned polyline includes both endpoints and is ordered along
987/// increasing parameter. `max_depth` bounds the work: a caller gets a
988/// deterministic result rather than an unbounded subdivision on a pathological
989/// curve.
990pub fn flatten2(
991    curve: &Curve2,
992    domain: Interval,
993    chord_tolerance: Scalar,
994    max_depth: u32,
995) -> GeomResult<Vec<Point2>> {
996    // A depth bound alone is not a resource bound: depth `d` permits `2^d`
997    // segments. Cap the total point count too, so a curve that cannot meet
998    // the tolerance fails fast instead of exhausting memory.
999    const MAX_POINTS: usize = 1 << 16;
1000    if !(chord_tolerance.is_finite()
1001        && chord_tolerance.is_sign_positive()
1002        && chord_tolerance != 0.0)
1003    {
1004        return Err(GeomError::InvalidInput(format!(
1005            "chord tolerance must be positive and finite, got {chord_tolerance}"
1006        )));
1007    }
1008    // A line and a polyline are already exact between their breakpoints:
1009    // subdividing them adds vertices that carry no information.
1010    if let Curve2::Line(_) = curve {
1011        return Ok(vec![
1012            evaluate2(curve, domain.start)?,
1013            evaluate2(curve, domain.end)?,
1014        ]);
1015    }
1016    if let Curve2::Polyline(p) = curve {
1017        // A polyline's parameter is one unit per segment, so a caller passing
1018        // a normalized `(0, 1)` domain would silently collapse an n-vertex
1019        // ring to its first edge. That is data loss disguised as success, so
1020        // it is refused: a domain narrower than one segment can only be
1021        // intentional for a genuinely 1-segment polyline.
1022        let natural = polyline_domain(p.points.len(), p.closed);
1023        let requested = (domain.end - domain.start).abs();
1024        if natural.end > 1.0 && requested <= 1.0 {
1025            return Err(GeomError::InvalidInput(format!(
1026                "polyline domain {:?} spans {requested} of {} segments; a \
1027                 polyline parameter is one unit per segment, so this would \
1028                 discard {} vertices",
1029                domain,
1030                natural.end,
1031                p.points.len().saturating_sub(2)
1032            )));
1033        }
1034        return polyline_flatten(&p.points, p.closed, domain, |t| evaluate2(curve, t));
1035    }
1036
1037    let mut out = vec![evaluate2(curve, domain.start)?];
1038    let eval = |t| evaluate2(curve, t);
1039    let bound = |a, b| crate::bound::chord_bound2(curve, a, b);
1040    for (a, b) in smooth_pieces(domain, &crate::bound::continuity_breaks2(curve, 1)) {
1041        subdivide(
1042            &eval,
1043            &bound,
1044            a,
1045            b,
1046            chord_tolerance,
1047            max_depth.min(MAX_DEPTH_CEILING),
1048            MAX_POINTS,
1049            &mut out,
1050        )?;
1051        out.push(evaluate2(curve, b)?);
1052    }
1053    Ok(out)
1054}
1055
1056/// `domain` cut at the breaks strictly inside it, in its own direction.
1057///
1058/// A chord across a knot where the curve is not `C^1` cannot be certified
1059/// (#232): the curve may turn a corner there. Cutting at the knot keeps the
1060/// corner as an exact vertex and leaves smooth pieces to bound.
1061fn smooth_pieces(domain: Interval, breaks: &[Scalar]) -> Vec<(Scalar, Scalar)> {
1062    let (lo, hi) = if domain.start <= domain.end {
1063        (domain.start, domain.end)
1064    } else {
1065        (domain.end, domain.start)
1066    };
1067    let mut cuts: Vec<Scalar> = breaks
1068        .iter()
1069        .copied()
1070        .filter(|&t| t > lo && t < hi)
1071        .collect();
1072    cuts.sort_by(Scalar::total_cmp);
1073    cuts.dedup();
1074    if domain.start > domain.end {
1075        cuts.reverse();
1076    }
1077    let mut out = Vec::with_capacity(cuts.len() + 1);
1078    let mut from = domain.start;
1079    for cut in cuts {
1080        out.push((from, cut));
1081        from = cut;
1082    }
1083    out.push((from, domain.end));
1084    out
1085}
1086
1087/// Hard ceiling on recursion depth regardless of what a caller asks for.
1088///
1089/// 2^20 segments is already far past any usable tolerance; beyond this a
1090/// request is a bug, not a quality setting.
1091const MAX_DEPTH_CEILING: u32 = 20;
1092
1093/// Emit interior points of `(a, b)` that are needed to meet the tolerance.
1094///
1095/// `budget` bounds total emitted points. Exceeding it is an error rather than
1096/// a truncation: silently returning a coarser polyline than the caller asked
1097/// for would violate the tolerance contract this function exists to honour.
1098/// The vector operations chord subdivision needs, in either dimension.
1099///
1100/// `Point2` and `Point3` are both glam vectors with the same surface, and
1101/// the subdivision below is genuinely dimension-independent: it measures a
1102/// sagitta and bisects a parameter interval, neither of which mentions a
1103/// coordinate count. This trait states that once instead of maintaining
1104/// two copies that can drift apart.
1105trait ChordPoint: Copy {
1106    fn sub(self, other: Self) -> Self;
1107    fn add_scaled(self, direction: Self, scale: Scalar) -> Self;
1108    fn dot(self, other: Self) -> Scalar;
1109    fn length(self) -> Scalar;
1110    fn length_squared(self) -> Scalar;
1111}
1112
1113impl ChordPoint for Point2 {
1114    fn sub(self, other: Self) -> Self {
1115        self - other
1116    }
1117    fn add_scaled(self, direction: Self, scale: Scalar) -> Self {
1118        self + direction * scale
1119    }
1120    fn dot(self, other: Self) -> Scalar {
1121        Point2::dot(self, other)
1122    }
1123    fn length(self) -> Scalar {
1124        Point2::length(self)
1125    }
1126    fn length_squared(self) -> Scalar {
1127        Point2::length_squared(self)
1128    }
1129}
1130
1131impl ChordPoint for Point3 {
1132    fn sub(self, other: Self) -> Self {
1133        self - other
1134    }
1135    fn add_scaled(self, direction: Self, scale: Scalar) -> Self {
1136        self + direction * scale
1137    }
1138    fn dot(self, other: Self) -> Scalar {
1139        Point3::dot(self, other)
1140    }
1141    fn length(self) -> Scalar {
1142        Point3::length(self)
1143    }
1144    fn length_squared(self) -> Scalar {
1145        Point3::length_squared(self)
1146    }
1147}
1148
1149/// Perpendicular distance from `m` to the chord `a`-`b`.
1150fn sagitta<P: ChordPoint>(a: P, b: P, m: P) -> Scalar {
1151    let ab = b.sub(a);
1152    let len2 = ab.length_squared();
1153    if len2 <= 0.0 {
1154        // Degenerate chord: fall back to point distance so a closed curve
1155        // whose endpoints coincide still subdivides.
1156        return m.sub(a).length();
1157    }
1158    let t = (m.sub(a).dot(ab) / len2).clamp(0.0, 1.0);
1159    m.sub(a.add_scaled(ab, t)).length()
1160}
1161
1162/// Emit the interior points of `(a, b)` needed to meet `tol`.
1163///
1164/// Shared by both dimensions; the caller supplies the evaluator. Depth
1165/// exhaustion is an error rather than a truncation, matching the 2D
1166/// contract: silently returning a coarser polyline than asked for would
1167/// break the tolerance guarantee the caller is relying on.
1168///
1169/// A span is accepted when its midpoint sagitta is within `tol` AND, for a
1170/// family [`crate::bound`] can bound, its certified chord bound is too
1171/// (#232). The midpoint alone is a measurement: an ellipse or a spline can
1172/// cross its chord at the midpoint and bulge either side of it. A family
1173/// with no bound (`bound` returns `None`) keeps the sagitta test alone,
1174/// which is why [`crate::bound::certifies_flattening2`] names the families
1175/// whose output is certified.
1176#[allow(clippy::too_many_arguments)]
1177fn subdivide<P, F, B>(
1178    eval: &F,
1179    bound: &B,
1180    a: Scalar,
1181    b: Scalar,
1182    tol: Scalar,
1183    depth: u32,
1184    budget: usize,
1185    out: &mut Vec<P>,
1186) -> GeomResult<()>
1187where
1188    P: ChordPoint,
1189    F: Fn(Scalar) -> GeomResult<P>,
1190    B: Fn(Scalar, Scalar) -> Option<Scalar>,
1191{
1192    if out.len() >= budget {
1193        return Err(GeomError::Degenerate(format!(
1194            "curve flattening exceeded {budget} points before meeting the \
1195             chord tolerance {tol}; the curve may be degenerate"
1196        )));
1197    }
1198    let mid = 0.5 * (a + b);
1199    let pa = eval(a)?;
1200    let pb = eval(b)?;
1201    // A parameter interval too small to bisect cannot be refined further:
1202    // `mid` equals `a` or `b` in floating point. Returning the chord anyway
1203    // would hand back an unverified approximation, so this fails closed --
1204    // unless the chord already meets the tolerance, in which case there was
1205    // nothing left to verify.
1206    if !(mid > a && mid < b) {
1207        if sagitta(pa, pb, pa) <= tol && (pb.sub(pa)).length() <= tol {
1208            return Ok(());
1209        }
1210        return Err(GeomError::Degenerate(format!(
1211            "curve parameter interval ({a}, {b}) is too small to bisect but \
1212             its chord still exceeds the tolerance {tol}"
1213        )));
1214    }
1215    let pm = eval(mid)?;
1216    if sagitta(pa, pb, pm) <= tol && bound(a, b).is_none_or(|certified| certified <= tol) {
1217        // Within tolerance: the chord a->b stands, no interior point.
1218        return Ok(());
1219    }
1220    if depth == 0 {
1221        return Err(GeomError::BudgetExceeded {
1222            resource: "curve flattening depth",
1223        });
1224    }
1225    subdivide(eval, bound, a, mid, tol, depth - 1, budget, out)?;
1226    out.push(pm);
1227    subdivide(eval, bound, mid, b, tol, depth - 1, budget, out)?;
1228    Ok(())
1229}
1230
1231/// Polylines flatten to their own breakpoints, restricted to `domain`.
1232fn polyline_flatten<P, F>(
1233    points: &[P],
1234    closed: bool,
1235    domain: Interval,
1236    eval: F,
1237) -> GeomResult<Vec<P>>
1238where
1239    P: Copy,
1240    F: Fn(Scalar) -> GeomResult<P>,
1241{
1242    let segments = if closed {
1243        points.len()
1244    } else {
1245        points.len().saturating_sub(1)
1246    };
1247    if segments == 0 {
1248        return Err(GeomError::Degenerate(
1249            "polyline has no evaluable segment".to_owned(),
1250        ));
1251    }
1252    let lo = domain.start.min(domain.end);
1253    let hi = domain.start.max(domain.end);
1254    let mut out = vec![eval(lo)?];
1255    // Interior breakpoints are the integer parameters strictly inside.
1256    let first = lo.floor() as i64 + 1;
1257    let last = hi.ceil() as i64 - 1;
1258    for k in first..=last {
1259        let t = k as Scalar;
1260        if t > lo && t < hi {
1261            out.push(eval(t)?);
1262        }
1263    }
1264    out.push(eval(hi)?);
1265    Ok(out)
1266}
1267
1268// --- trait wiring -----------------------------------------------------------
1269
1270impl CurveEvaluator<Curve2> for ScalarCurve {
1271    type Point = Point2;
1272    type Derivative = Vec2;
1273    type Error = GeomError;
1274
1275    fn domain(&self, curve: &Curve2) -> Interval {
1276        domain2(curve)
1277    }
1278
1279    fn evaluate(
1280        &self,
1281        curve: &Curve2,
1282        t: Scalar,
1283        _tolerance: Tolerance,
1284    ) -> Result<Self::Point, Self::Error> {
1285        evaluate2(curve, t)
1286    }
1287
1288    fn derivative(
1289        &self,
1290        curve: &Curve2,
1291        t: Scalar,
1292        _tolerance: Tolerance,
1293    ) -> Result<Self::Derivative, Self::Error> {
1294        derivative2(curve, t)
1295    }
1296}
1297
1298impl CurveEvaluator<Curve3> for ScalarCurve {
1299    type Point = Point3;
1300    type Derivative = Vec3;
1301    type Error = GeomError;
1302
1303    fn domain(&self, curve: &Curve3) -> Interval {
1304        domain3(curve)
1305    }
1306
1307    fn evaluate(
1308        &self,
1309        curve: &Curve3,
1310        t: Scalar,
1311        _tolerance: Tolerance,
1312    ) -> Result<Self::Point, Self::Error> {
1313        evaluate3(curve, t)
1314    }
1315
1316    fn derivative(
1317        &self,
1318        curve: &Curve3,
1319        t: Scalar,
1320        _tolerance: Tolerance,
1321    ) -> Result<Self::Derivative, Self::Error> {
1322        derivative3(curve, t)
1323    }
1324}
1325
1326// Silence unused-import warnings for types only named in signatures.
1327#[allow(unused)]
1328fn _type_anchors(_: Circle2, _: Circle3, _: Ellipse2, _: Ellipse3, _: Line2, _: Line3) {}
1329#[allow(unused)]
1330fn _poly_anchors(_: Polyline2, _: Polyline3) {}
1331
1332/// Flatten a 3D curve to a polyline within `chord_tolerance`.
1333///
1334/// The 3D twin of [`flatten2`], sharing its subdivision, its resource
1335/// bounds and its polyline contract. Sampling is adaptive: a curve is
1336/// bisected only where the chord actually departs from it, so a gentle
1337/// arc costs few points and a tight one costs many, and neither is
1338/// decided by a fixed count chosen in advance.
1339pub fn flatten3(
1340    curve: &Curve3,
1341    domain: Interval,
1342    chord_tolerance: Scalar,
1343    max_depth: u32,
1344) -> GeomResult<Vec<Point3>> {
1345    // A depth bound alone is not a resource bound: depth `d` permits `2^d`
1346    // segments. Cap the total point count too, so a curve that cannot meet
1347    // the tolerance fails fast instead of exhausting memory.
1348    const MAX_POINTS: usize = 1 << 16;
1349    if !(chord_tolerance.is_finite()
1350        && chord_tolerance.is_sign_positive()
1351        && chord_tolerance != 0.0)
1352    {
1353        return Err(GeomError::InvalidInput(format!(
1354            "chord tolerance must be positive and finite, got {chord_tolerance}"
1355        )));
1356    }
1357    // A line is exact between its endpoints: subdividing adds vertices that
1358    // carry no information.
1359    if let Curve3::Line(_) = curve {
1360        return Ok(vec![
1361            evaluate3(curve, domain.start)?,
1362            evaluate3(curve, domain.end)?,
1363        ]);
1364    }
1365    if let Curve3::Polyline(p) = curve {
1366        // A polyline's parameter is one unit per segment, so a caller passing
1367        // a normalized `(0, 1)` domain would silently collapse an n-vertex
1368        // path to its first edge. That is data loss disguised as success.
1369        let natural = polyline_domain(p.points.len(), p.closed);
1370        let requested = (domain.end - domain.start).abs();
1371        if natural.end > 1.0 && requested <= 1.0 {
1372            return Err(GeomError::InvalidInput(format!(
1373                "polyline domain {:?} spans {requested} of {} segments; a \
1374                 polyline parameter is one unit per segment, so this would \
1375                 discard {} vertices",
1376                domain,
1377                natural.end,
1378                p.points.len().saturating_sub(2)
1379            )));
1380        }
1381        return polyline_flatten(&p.points, p.closed, domain, |t| evaluate3(curve, t));
1382    }
1383
1384    let eval = |t| evaluate3(curve, t);
1385    let bound = |a, b| crate::bound::chord_bound3(curve, a, b);
1386    let mut out = vec![eval(domain.start)?];
1387    for (a, b) in smooth_pieces(domain, &crate::bound::continuity_breaks3(curve, 1)) {
1388        subdivide(
1389            &eval,
1390            &bound,
1391            a,
1392            b,
1393            chord_tolerance,
1394            max_depth.min(MAX_DEPTH_CEILING),
1395            MAX_POINTS,
1396            &mut out,
1397        )?;
1398        out.push(eval(b)?);
1399    }
1400    Ok(out)
1401}
1402
1403/// Refuse a family that has no closed-form inversion.
1404///
1405/// Named separately from `unsupported_family` so the message can say WHY:
1406/// the family is understood, its inversion simply is not algebraic.
1407fn no_closed_form_inversion() -> GeomError {
1408    GeomError::InvalidInput(
1409        "curve family has no closed form inversion; a point trim on this basis \
1410         would require iteration, which trim resolution does not perform"
1411            .to_owned(),
1412    )
1413}
1414
1415/// The point is not on the curve, so no parameter names it.
1416fn point_not_on_curve(distance: Scalar, tolerance: Scalar) -> GeomError {
1417    GeomError::InvalidInput(format!(
1418        "point is {distance} from the curve, outside the {tolerance} tolerance; \
1419         refusing rather than projecting it onto the nearest parameter"
1420    ))
1421}
1422
1423/// Parameter of `point` on `line`, or a refusal when it is off the line.
1424///
1425/// The direction need not be unit length, so the projection is normalised by
1426/// its squared length: that is what makes the returned value a parameter of
1427/// THIS line rather than an arc length.
1428fn invert_line(
1429    origin: impl Into<[Scalar; 3]>,
1430    direction: [Scalar; 3],
1431    point: [Scalar; 3],
1432    tolerance: Scalar,
1433) -> GeomResult<Scalar> {
1434    let origin = origin.into();
1435    let dd = direction.iter().map(|c| c * c).sum::<Scalar>();
1436    if !dd.is_finite() || dd <= Scalar::EPSILON {
1437        return Err(GeomError::InvalidInput(
1438            "line direction is degenerate, so no parameter names a point".to_owned(),
1439        ));
1440    }
1441    let offset = [
1442        point[0] - origin[0],
1443        point[1] - origin[1],
1444        point[2] - origin[2],
1445    ];
1446    let t = offset
1447        .iter()
1448        .zip(direction.iter())
1449        .map(|(o, d)| o * d)
1450        .sum::<Scalar>()
1451        / dd;
1452    // Verify rather than assume: the projection always yields a parameter, but
1453    // only a point actually ON the line is named by it.
1454    let residual = [
1455        offset[0] - direction[0] * t,
1456        offset[1] - direction[1] * t,
1457        offset[2] - direction[2] * t,
1458    ];
1459    let distance = residual.iter().map(|c| c * c).sum::<Scalar>().sqrt();
1460    if distance > tolerance {
1461        return Err(point_not_on_curve(distance, tolerance));
1462    }
1463    Ok(t)
1464}
1465
1466/// Parametric angle of `point` about a conic frame, verified against the curve.
1467///
1468/// For a circle the parametric angle is the polar angle; for an ellipse it is
1469/// not, so the local coordinates are divided by their semi-axes BEFORE the
1470/// arctangent. Taking the polar angle directly would be wrong off-axis.
1471fn invert_conic(
1472    local_x: Scalar,
1473    local_y: Scalar,
1474    semi_x: Scalar,
1475    semi_y: Scalar,
1476) -> GeomResult<Scalar> {
1477    if !(semi_x.is_finite() && semi_y.is_finite()) || semi_x <= 0.0 || semi_y <= 0.0 {
1478        return Err(GeomError::InvalidInput(
1479            "conic semi-axes must be finite and positive to invert a point".to_owned(),
1480        ));
1481    }
1482    let angle = (local_y / semi_y).atan2(local_x / semi_x);
1483    if !angle.is_finite() {
1484        return Err(GeomError::InvalidInput(
1485            "conic inversion produced a non-finite angle".to_owned(),
1486        ));
1487    }
1488    // Report on the same domain evaluation uses, so invert then evaluate is a
1489    // round trip rather than an off-by-one-turn surprise.
1490    Ok(angle.rem_euclid(std::f64::consts::TAU))
1491}
1492
1493/// Parameter naming `point` on a 2D curve, or a refusal.
1494///
1495/// Exact for the families whose inversion is algebraic. Anything else is
1496/// refused by name: introducing iteration here would put a tolerance and a
1497/// convergence failure mode into every consumer of a point trim, and the
1498/// certified iterative path belongs to a caller that can carry its evidence.
1499///
1500/// # Errors
1501///
1502/// Refuses a point further than `tolerance` from the curve rather than
1503/// projecting it, and refuses families with no closed-form inversion.
1504pub fn invert2(curve: &Curve2, point: Point2, tolerance: Tolerance) -> GeomResult<Scalar> {
1505    finite2(point, "inversion point")?;
1506    let linear = tolerance.linear();
1507    match curve {
1508        Curve2::Line(l) => invert_line(
1509            [l.origin.x, l.origin.y, 0.0],
1510            [l.direction.x, l.direction.y, 0.0],
1511            [point.x, point.y, 0.0],
1512            linear,
1513        ),
1514        Curve2::Circle(c) => {
1515            let t = invert_conic_in_frame2(&c.frame, point, c.radius, c.radius)?;
1516            verify2(curve, t, point, linear)
1517        }
1518        Curve2::Ellipse(e) => {
1519            let t = invert_conic_in_frame2(&e.frame, point, e.semi_axis_x, e.semi_axis_y)?;
1520            verify2(curve, t, point, linear)
1521        }
1522        // A graph over its parameter: the parameter of a point IS its first
1523        // coordinate, then the height is checked.
1524        Curve2::Sinusoid(_) | Curve2::QuadraticGraph(_) => verify2(curve, point.x, point, linear),
1525        // A graph over its second coordinate; the angle it returns lies in
1526        // `(-pi, pi]`, so a point given a whole turn away is read there.
1527        Curve2::AngleGraph(g) => {
1528            let u = g.angle(point.y).ok_or_else(|| outside_graph(point.y))?;
1529            let turns = ((point.x - u) / std::f64::consts::TAU).round();
1530            let shifted = Point2::new(point.x - turns * std::f64::consts::TAU, point.y);
1531            verify2(curve, point.y, shifted, linear)
1532        }
1533        // The space curve's parameter of the point lifted onto the carrier.
1534        Curve2::Lifted(l) => {
1535            let lifted = l.carrier.jet(point.x, point.y).point;
1536            let t = invert3(&l.curve, lifted, tolerance)?;
1537            verify2(curve, t, point, linear)
1538        }
1539        // The cell whose box holds the point; its free value places it.
1540        Curve2::Implicit(c) => {
1541            let t = c
1542                .parameter_of(point)
1543                .ok_or_else(|| point_not_on_curve(Scalar::INFINITY, linear))?;
1544            verify2(curve, t, point, linear)
1545        }
1546        _ => Err(no_closed_form_inversion()),
1547    }
1548}
1549
1550/// Project a point into a 2D conic frame and invert it there.
1551fn invert_conic_in_frame2(
1552    frame: &axiolid_core::Frame2,
1553    point: Point2,
1554    semi_x: Scalar,
1555    semi_y: Scalar,
1556) -> GeomResult<Scalar> {
1557    let offset = point - frame.origin;
1558    invert_conic(offset.dot(frame.x), offset.dot(frame.y), semi_x, semi_y)
1559}
1560
1561/// Confirm the recovered parameter actually reproduces the point.
1562///
1563/// The algebra above assumes an orthonormal frame. Imported frames are not
1564/// always orthonormal, and this crate deliberately keeps dirty frames
1565/// representable, so the claim is checked against the real evaluator instead
1566/// of trusted.
1567fn verify2(curve: &Curve2, t: Scalar, point: Point2, tolerance: Scalar) -> GeomResult<Scalar> {
1568    let found = evaluate2(curve, t)?;
1569    let distance = (found - point).length();
1570    if distance > tolerance {
1571        return Err(point_not_on_curve(distance, tolerance));
1572    }
1573    Ok(t)
1574}
1575
1576/// Parameter naming `point` on a 3D curve, or a refusal.
1577///
1578/// See [`invert2`] for the exactness policy.
1579///
1580/// # Errors
1581///
1582/// Refuses an off-curve point and any family without a closed-form inversion.
1583pub fn invert3(curve: &Curve3, point: Point3, tolerance: Tolerance) -> GeomResult<Scalar> {
1584    finite3(point, "inversion point")?;
1585    let linear = tolerance.linear();
1586    match curve {
1587        Curve3::Line(l) => invert_line(
1588            [l.origin.x, l.origin.y, l.origin.z],
1589            [l.direction.x, l.direction.y, l.direction.z],
1590            [point.x, point.y, point.z],
1591            linear,
1592        ),
1593        Curve3::Circle(c) => {
1594            let t = invert_conic_in_frame3(&c.frame, point, c.radius, c.radius)?;
1595            verify3(curve, t, point, linear)
1596        }
1597        Curve3::Ellipse(e) => {
1598            let t = invert_conic_in_frame3(&e.frame, point, e.semi_axis_x, e.semi_axis_y)?;
1599            verify3(curve, t, point, linear)
1600        }
1601        // Graphs over the carrier angle: the parameter is that angle, read
1602        // off the point, up to whole turns (ADR 0076).
1603        Curve3::RuledSection(r) => {
1604            let carrier = axiolid_curve::Carrier::Ruled(r.carrier);
1605            let (u, _) = carrier.parameters(point);
1606            first_on_curve(curve, &turns_of(u), point, linear)
1607        }
1608        Curve3::TorusSection(r) => {
1609            let carrier = axiolid_curve::Carrier::Torus(r.torus);
1610            let (_, v) = carrier.parameters(point);
1611            first_on_curve(curve, &turns_of(v), point, linear)
1612        }
1613        // The nearest chord, then the curve itself (ADR 0077).
1614        Curve3::PairSection(r) => {
1615            let t = r
1616                .parameter_of(point)
1617                .ok_or_else(|| point_not_on_curve(Scalar::INFINITY, linear))?;
1618            verify3(curve, t, point, linear)
1619        }
1620        // The carrier's parameters of the point, over whole turns, located
1621        // in the curve's cells (ADR 0077).
1622        Curve3::ImplicitSection(r) => {
1623            // A B-spline carrier has no closed-form inverse: the surface is
1624            // inverted, then the cell located.
1625            if let axiolid_curve::Carrier::Spline(b) = &r.carrier {
1626                let surface = axiolid_surface::Surface::BSpline((**b).clone());
1627                let (u, v) = crate::surface::locate(&surface, point, tolerance)?;
1628                let t = r
1629                    .curve
1630                    .parameter_of(Point2::new(u, v))
1631                    .ok_or_else(|| point_not_on_curve(Scalar::INFINITY, linear))?;
1632                return verify3(curve, t, point, linear);
1633            }
1634            let (u, v) = r.carrier.parameters(point);
1635            let (pu, pv) = r.carrier.periodic();
1636            let turns = |periodic: bool| -> &'static [Scalar] {
1637                if periodic {
1638                    &[0.0, 1.0, -1.0, 2.0, -2.0]
1639                } else {
1640                    &[0.0]
1641                }
1642            };
1643            let mut candidates = Vec::new();
1644            for &ku in turns(pu) {
1645                for &kv in turns(pv) {
1646                    let tau = std::f64::consts::TAU;
1647                    if let Some(t) = r
1648                        .curve
1649                        .parameter_of(Point2::new(u + ku * tau, v + kv * tau))
1650                    {
1651                        candidates.push(t);
1652                    }
1653                }
1654            }
1655            first_on_curve(curve, &candidates, point, linear)
1656        }
1657        _ => Err(no_closed_form_inversion()),
1658    }
1659}
1660
1661/// An angle and its neighbours a whole turn or two away.
1662fn turns_of(angle: Scalar) -> Vec<Scalar> {
1663    let tau = std::f64::consts::TAU;
1664    vec![
1665        angle,
1666        angle + tau,
1667        angle - tau,
1668        angle + 2.0 * tau,
1669        angle - 2.0 * tau,
1670    ]
1671}
1672
1673/// The first candidate parameter that reproduces the point.
1674fn first_on_curve(
1675    curve: &Curve3,
1676    candidates: &[Scalar],
1677    point: Point3,
1678    tolerance: Scalar,
1679) -> GeomResult<Scalar> {
1680    let mut nearest = Scalar::INFINITY;
1681    for &t in candidates {
1682        if let Ok(found) = evaluate3(curve, t) {
1683            let distance = (found - point).length();
1684            if distance <= tolerance {
1685                return Ok(t);
1686            }
1687            nearest = nearest.min(distance);
1688        }
1689    }
1690    Err(point_not_on_curve(nearest, tolerance))
1691}
1692
1693/// Parameter of a point on a 3D curve, iterating where no closed form
1694/// exists: [`invert3`] first, and for a B-spline the nearest of 64 samples
1695/// per span refined by Newton on `|C(t) - p|^2`. The answer must reproduce
1696/// the point within `tolerance`, as [`invert3`]'s must.
1697///
1698/// # Errors
1699///
1700/// The point is not on the curve, or the family cannot be evaluated.
1701pub fn locate3(curve: &Curve3, point: Point3, tolerance: Tolerance) -> GeomResult<Scalar> {
1702    match invert3(curve, point, tolerance) {
1703        Ok(t) => Ok(t),
1704        Err(error) => match curve {
1705            Curve3::BSpline(b) => {
1706                let domain = spline_domain(b);
1707                let t = nearest_parameter(
1708                    domain,
1709                    b.control_points.len().max(2) * 64,
1710                    |t| evaluate3(curve, t).map(|p| (p - point).length()),
1711                    |t| {
1712                        let (p, d, dd) = (
1713                            evaluate3(curve, t)?,
1714                            derivative3(curve, t)?,
1715                            second_derivative3(curve, t)?,
1716                        );
1717                        let r = p - point;
1718                        Ok((r.dot(d), d.dot(d) + r.dot(dd)))
1719                    },
1720                )?;
1721                verify3(curve, t, point, tolerance.linear())
1722            }
1723            _ => Err(error),
1724        },
1725    }
1726}
1727
1728/// Parameter of a point on a 2D curve, iterating where no closed form
1729/// exists. See [`locate3`].
1730///
1731/// # Errors
1732///
1733/// The point is not on the curve, or the family cannot be evaluated.
1734pub fn locate2(curve: &Curve2, point: Point2, tolerance: Tolerance) -> GeomResult<Scalar> {
1735    match invert2(curve, point, tolerance) {
1736        Ok(t) => Ok(t),
1737        Err(error) => match curve {
1738            Curve2::BSpline(b) => {
1739                let domain = spline_domain(b);
1740                let t = nearest_parameter(
1741                    domain,
1742                    b.control_points.len().max(2) * 64,
1743                    |t| evaluate2(curve, t).map(|p| (p - point).length()),
1744                    |t| {
1745                        let (p, d, dd) = (
1746                            evaluate2(curve, t)?,
1747                            derivative2(curve, t)?,
1748                            second_derivative2(curve, t)?,
1749                        );
1750                        let r = p - point;
1751                        Ok((r.dot(d), d.dot(d) + r.dot(dd)))
1752                    },
1753                )?;
1754                verify2(curve, t, point, tolerance.linear())
1755            }
1756            _ => Err(error),
1757        },
1758    }
1759}
1760
1761/// The parameter in `domain` nearest the target: the best of `samples`
1762/// evenly spaced values, then Newton on the distance's derivative
1763/// (`gradient` returns it and its derivative), kept inside the domain.
1764fn nearest_parameter(
1765    domain: Interval,
1766    samples: usize,
1767    distance: impl Fn(Scalar) -> GeomResult<Scalar>,
1768    gradient: impl Fn(Scalar) -> GeomResult<(Scalar, Scalar)>,
1769) -> GeomResult<Scalar> {
1770    let (lo, hi) = (domain.start.min(domain.end), domain.start.max(domain.end));
1771    let mut best = (Scalar::INFINITY, lo);
1772    for i in 0..=samples {
1773        let t = lo + (hi - lo) * i as Scalar / samples as Scalar;
1774        let d = distance(t)?;
1775        if d < best.0 {
1776            best = (d, t);
1777        }
1778    }
1779    let mut t = best.1;
1780    for _ in 0..60 {
1781        let (g, h) = gradient(t)?;
1782        if h <= 0.0 || !h.is_finite() {
1783            break;
1784        }
1785        let next = (t - g / h).clamp(lo, hi);
1786        let moved = (next - t).abs();
1787        t = next;
1788        if moved <= 4.0 * Scalar::EPSILON * (1.0 + t.abs()) {
1789            break;
1790        }
1791    }
1792    Ok(t)
1793}
1794
1795/// Project a point into a 3D conic frame and invert it there.
1796fn invert_conic_in_frame3(
1797    frame: &axiolid_core::Frame3,
1798    point: Point3,
1799    semi_x: Scalar,
1800    semi_y: Scalar,
1801) -> GeomResult<Scalar> {
1802    let offset = point - frame.origin;
1803    invert_conic(offset.dot(frame.x), offset.dot(frame.y), semi_x, semi_y)
1804}
1805
1806/// Confirm the recovered parameter reproduces the point. See [`verify2`].
1807fn verify3(curve: &Curve3, t: Scalar, point: Point3, tolerance: Scalar) -> GeomResult<Scalar> {
1808    let found = evaluate3(curve, t)?;
1809    let distance = (found - point).length();
1810    if distance > tolerance {
1811        return Err(point_not_on_curve(distance, tolerance));
1812    }
1813    Ok(t)
1814}