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        // Unknown family: no domain is knowable, so claim none.
93        _ => Interval {
94            start: 0.0,
95            end: 0.0,
96        },
97    }
98}
99
100/// Domain of a 3D curve.
101#[must_use]
102pub fn domain3(curve: &Curve3) -> Interval {
103    match curve {
104        Curve3::Line(_) => Interval::UNIT,
105        Curve3::Circle(_) | Curve3::Ellipse(_) => full_turn(),
106        Curve3::Polyline(p) => polyline_domain(p.points.len(), p.closed),
107        Curve3::BSpline(b) => spline_domain(b),
108        // Parameterised by ARC LENGTH, not by a unit parameter: the domain is
109        // the span the laws are declared over. A non-finite or non-positive
110        // length claims no domain rather than a guessed one.
111        Curve3::Intrinsic(i) if i.length.is_finite() && i.length > 0.0 => Interval {
112            start: 0.0,
113            end: i.length,
114        },
115        _ => Interval {
116            start: 0.0,
117            end: 0.0,
118        },
119    }
120}
121
122fn full_turn() -> Interval {
123    Interval {
124        start: 0.0,
125        end: core::f64::consts::TAU,
126    }
127}
128
129/// Polyline parameter runs `[0, segment_count]`.
130fn polyline_domain(count: usize, closed: bool) -> Interval {
131    let segments = if closed {
132        count
133    } else {
134        count.saturating_sub(1)
135    };
136    Interval {
137        start: 0.0,
138        end: segments as Scalar,
139    }
140}
141
142/// Domain of a validated B-spline axis. Invalid imported data reports an empty
143/// domain through the infallible evaluator trait and is rejected by evaluation.
144fn spline_domain<P>(b: &BSplineCurve<P>) -> Interval {
145    SplineAxis::new(
146        &b.knots,
147        &b.multiplicities,
148        b.degree,
149        b.control_points.len(),
150        "B-spline curve",
151    )
152    .map_or(
153        Interval {
154            start: 0.0,
155            end: 0.0,
156        },
157        |axis| {
158            let (start, end) = axis.domain();
159            Interval { start, end }
160        },
161    )
162}
163
164// --- 2D evaluation ----------------------------------------------------------
165
166/// Position on a 2D curve.
167pub fn evaluate2(curve: &Curve2, t: Scalar) -> GeomResult<Point2> {
168    finite(t)?;
169    let value = match curve {
170        Curve2::Line(l) => Ok(line_point(l.origin, l.direction, t)),
171        Curve2::Circle(c) => Ok(conic_point2(&c.frame, c.radius, c.radius, t)),
172        Curve2::Ellipse(e) => Ok(conic_point2(&e.frame, e.semi_axis_x, e.semi_axis_y, t)),
173        Curve2::Polyline(p) => polyline_point(&p.points, p.closed, t),
174        Curve2::BSpline(b) => de_boor(b, t, |p| [p.x, p.y], |c| Point2::new(c[0], c[1])),
175        // Position has no elementary closed form for a general curvature law
176        // (a clothoid needs Fresnel integrals), so it is quadrature over the
177        // exact heading rather than a parametric formula.
178        Curve2::Intrinsic(i) => crate::arc_length::intrinsic_point(i, t),
179        // `Curve*` is #[non_exhaustive]. An unknown family is refused by name
180        // rather than approximated by whichever arm happens to be nearest.
181        _ => Err(GeomError::Unsupported {
182            backend: axiolid_contracts::BackendId::new("axiolid-reference"),
183            operation: axiolid_contracts::Operation::CurveEvaluation,
184        }),
185    }?;
186    finite2(value, "curve point")
187}
188
189/// First derivative of a 2D curve.
190pub fn derivative2(curve: &Curve2, t: Scalar) -> GeomResult<Vec2> {
191    finite(t)?;
192    let value = match curve {
193        Curve2::Line(l) => Ok(l.direction),
194        Curve2::Circle(c) => Ok(conic_tangent2(&c.frame, c.radius, c.radius, t)),
195        Curve2::Ellipse(e) => Ok(conic_tangent2(&e.frame, e.semi_axis_x, e.semi_axis_y, t)),
196        Curve2::Polyline(p) => polyline_tangent(&p.points, p.closed, t),
197        Curve2::BSpline(b) => de_boor_derivative(b, t, |p| [p.x, p.y], |c| Vec2::new(c[0], c[1])),
198        // Arc-length parameterised, so the derivative is the UNIT tangent, and
199        // the heading it is built from is exact -- only position needs
200        // quadrature, never the tangent.
201        Curve2::Intrinsic(i) => crate::arc_length::intrinsic_tangent(i, t),
202        // `Curve*` is #[non_exhaustive]. An unknown family is refused by name
203        // rather than approximated by whichever arm happens to be nearest.
204        _ => Err(GeomError::Unsupported {
205            backend: axiolid_contracts::BackendId::new("axiolid-reference"),
206            operation: axiolid_contracts::Operation::CurveEvaluation,
207        }),
208    }?;
209    finite2(value, "curve derivative")
210}
211
212/// Second derivative of a 2D curve with respect to its native parameter.
213pub fn second_derivative2(curve: &Curve2, t: Scalar) -> GeomResult<Vec2> {
214    finite(t)?;
215    let value = match curve {
216        Curve2::Line(_) | Curve2::Polyline(_) => Ok(Vec2::ZERO),
217        Curve2::Circle(c) => Ok(conic_second2(&c.frame, c.radius, c.radius, t)),
218        Curve2::Ellipse(e) => Ok(conic_second2(&e.frame, e.semi_axis_x, e.semi_axis_y, t)),
219        Curve2::BSpline(b) => {
220            de_boor_second_derivative(b, t, |p| [p.x, p.y], |c| Vec2::new(c[0], c[1]))
221        }
222        _ => Err(GeomError::Unsupported {
223            backend: axiolid_contracts::BackendId::new("axiolid-reference"),
224            operation: axiolid_contracts::Operation::CurveEvaluation,
225        }),
226    }?;
227    finite2(value, "curve second derivative")
228}
229
230/// Second-order differential jet of a 2D curve.
231pub fn bspline_jet2(curve: &BSplineCurve2, t: Scalar) -> GeomResult<CurveJet<Point2, Vec2>> {
232    Ok(CurveJet {
233        point: de_boor(curve, t, |p| [p.x, p.y], |c| Point2::new(c[0], c[1]))?,
234        first: de_boor_derivative(curve, t, |p| [p.x, p.y], |c| Vec2::new(c[0], c[1]))?,
235        second: de_boor_second_derivative(curve, t, |p| [p.x, p.y], |c| Vec2::new(c[0], c[1]))?,
236    })
237}
238
239/// Second-order differential jet of a 2D curve.
240pub fn jet2(curve: &Curve2, t: Scalar) -> GeomResult<CurveJet<Point2, Vec2>> {
241    Ok(CurveJet {
242        point: evaluate2(curve, t)?,
243        first: derivative2(curve, t)?,
244        second: second_derivative2(curve, t)?,
245    })
246}
247
248// --- 3D evaluation ----------------------------------------------------------
249
250/// Position on a 3D curve.
251pub fn evaluate3(curve: &Curve3, t: Scalar) -> GeomResult<Point3> {
252    finite(t)?;
253    let value = match curve {
254        Curve3::Line(l) => Ok(line_point(l.origin, l.direction, t)),
255        Curve3::Circle(c) => Ok(conic_point3(&c.frame, c.radius, c.radius, t)),
256        Curve3::Ellipse(e) => Ok(conic_point3(&e.frame, e.semi_axis_x, e.semi_axis_y, t)),
257        Curve3::Polyline(p) => polyline_point(&p.points, p.closed, t),
258        Curve3::BSpline(b) => de_boor(b, t, |p| [p.x, p.y, p.z], |c| Point3::new(c[0], c[1], c[2])),
259        // Natural equations: `t` is ARC LENGTH, and the point comes from the
260        // Frenet integrator (ADR 0061). Dispatching here is what lets the
261        // graph's existing relation machinery -- trim, composite, sweep
262        // directrix -- work on a torsion curve without special-casing it.
263        Curve3::Intrinsic(i) => crate::frenet::frenet_point(i, t),
264        // `Curve*` is #[non_exhaustive]. An unknown family is refused by name
265        // rather than approximated by whichever arm happens to be nearest.
266        _ => Err(GeomError::Unsupported {
267            backend: axiolid_contracts::BackendId::new("axiolid-reference"),
268            operation: axiolid_contracts::Operation::CurveEvaluation,
269        }),
270    }?;
271    finite3(value, "curve point")
272}
273
274/// First derivative of a 3D curve.
275pub fn derivative3(curve: &Curve3, t: Scalar) -> GeomResult<Vec3> {
276    finite(t)?;
277    let value = match curve {
278        Curve3::Line(l) => Ok(l.direction),
279        Curve3::Circle(c) => Ok(conic_tangent3(&c.frame, c.radius, c.radius, t)),
280        Curve3::Ellipse(e) => Ok(conic_tangent3(&e.frame, e.semi_axis_x, e.semi_axis_y, t)),
281        Curve3::Polyline(p) => polyline_tangent(&p.points, p.closed, t),
282        Curve3::BSpline(b) => {
283            de_boor_derivative(b, t, |p| [p.x, p.y, p.z], |c| Vec3::new(c[0], c[1], c[2]))
284        }
285        // Arc-length parameterised, so the derivative is the UNIT tangent.
286        Curve3::Intrinsic(i) => crate::frenet::frenet_tangent(i, t),
287        // `Curve*` is #[non_exhaustive]. An unknown family is refused by name
288        // rather than approximated by whichever arm happens to be nearest.
289        _ => Err(GeomError::Unsupported {
290            backend: axiolid_contracts::BackendId::new("axiolid-reference"),
291            operation: axiolid_contracts::Operation::CurveEvaluation,
292        }),
293    }?;
294    finite3(value, "curve derivative")
295}
296
297/// Second derivative of a 3D curve with respect to its native parameter.
298pub fn second_derivative3(curve: &Curve3, t: Scalar) -> GeomResult<Vec3> {
299    finite(t)?;
300    let value = match curve {
301        Curve3::Line(_) | Curve3::Polyline(_) => Ok(Vec3::ZERO),
302        Curve3::Circle(c) => Ok(conic_second3(&c.frame, c.radius, c.radius, t)),
303        Curve3::Ellipse(e) => Ok(conic_second3(&e.frame, e.semi_axis_x, e.semi_axis_y, t)),
304        Curve3::BSpline(b) => {
305            de_boor_second_derivative(b, t, |p| [p.x, p.y, p.z], |c| Vec3::new(c[0], c[1], c[2]))
306        }
307        _ => Err(GeomError::Unsupported {
308            backend: axiolid_contracts::BackendId::new("axiolid-reference"),
309            operation: axiolid_contracts::Operation::CurveEvaluation,
310        }),
311    }?;
312    finite3(value, "curve second derivative")
313}
314
315/// Second-order differential jet of a 3D curve.
316pub fn bspline_jet3(curve: &BSplineCurve3, t: Scalar) -> GeomResult<CurveJet<Point3, Vec3>> {
317    Ok(CurveJet {
318        point: de_boor(
319            curve,
320            t,
321            |p| [p.x, p.y, p.z],
322            |c| Point3::new(c[0], c[1], c[2]),
323        )?,
324        first: de_boor_derivative(
325            curve,
326            t,
327            |p| [p.x, p.y, p.z],
328            |c| Vec3::new(c[0], c[1], c[2]),
329        )?,
330        second: de_boor_second_derivative(
331            curve,
332            t,
333            |p| [p.x, p.y, p.z],
334            |c| Vec3::new(c[0], c[1], c[2]),
335        )?,
336    })
337}
338
339/// Second-order differential jet of a 3D curve.
340pub fn jet3(curve: &Curve3, t: Scalar) -> GeomResult<CurveJet<Point3, Vec3>> {
341    Ok(CurveJet {
342        point: evaluate3(curve, t)?,
343        first: derivative3(curve, t)?,
344        second: second_derivative3(curve, t)?,
345    })
346}
347
348// --- family kernels ---------------------------------------------------------
349
350fn finite(t: Scalar) -> GeomResult<()> {
351    if t.is_finite() {
352        Ok(())
353    } else {
354        Err(GeomError::InvalidInput(format!(
355            "curve parameter must be finite, got {t}"
356        )))
357    }
358}
359
360fn finite2(value: Vec2, what: &str) -> GeomResult<Vec2> {
361    if value.is_finite() {
362        Ok(value)
363    } else {
364        Err(GeomError::Degenerate(format!("{what} is non-finite")))
365    }
366}
367
368fn finite3(value: Vec3, what: &str) -> GeomResult<Vec3> {
369    if value.is_finite() {
370        Ok(value)
371    } else {
372        Err(GeomError::Degenerate(format!("{what} is non-finite")))
373    }
374}
375
376fn line_point<P>(origin: P, direction: P, t: Scalar) -> P
377where
378    P: core::ops::Add<Output = P> + core::ops::Mul<Scalar, Output = P>,
379{
380    origin + direction * t
381}
382
383fn conic_point2(frame: &Frame2, rx: Scalar, ry: Scalar, t: Scalar) -> Point2 {
384    frame.origin + frame.x * (rx * t.cos()) + frame.y * (ry * t.sin())
385}
386
387fn conic_tangent2(frame: &Frame2, rx: Scalar, ry: Scalar, t: Scalar) -> Vec2 {
388    frame.x * (-rx * t.sin()) + frame.y * (ry * t.cos())
389}
390
391fn conic_second2(frame: &Frame2, rx: Scalar, ry: Scalar, t: Scalar) -> Vec2 {
392    frame.x * (-rx * t.cos()) + frame.y * (-ry * t.sin())
393}
394
395fn conic_point3(frame: &Frame3, rx: Scalar, ry: Scalar, t: Scalar) -> Point3 {
396    frame.origin + frame.x * (rx * t.cos()) + frame.y * (ry * t.sin())
397}
398
399fn conic_tangent3(frame: &Frame3, rx: Scalar, ry: Scalar, t: Scalar) -> Vec3 {
400    frame.x * (-rx * t.sin()) + frame.y * (ry * t.cos())
401}
402
403fn conic_second3(frame: &Frame3, rx: Scalar, ry: Scalar, t: Scalar) -> Vec3 {
404    frame.x * (-rx * t.cos()) + frame.y * (-ry * t.sin())
405}
406
407/// Segment index and local fraction for a polyline parameter.
408///
409/// Returns `None` when the polyline cannot be evaluated at all.
410fn polyline_span(count: usize, closed: bool, t: Scalar) -> Option<(usize, usize, Scalar)> {
411    let segments = if closed {
412        count
413    } else {
414        count.saturating_sub(1)
415    };
416    if count < 2 || segments == 0 {
417        return None;
418    }
419    // Clamp into range: the endpoint t == segments is the final vertex, which
420    // would otherwise index one past the last segment.
421    let clamped = t.clamp(0.0, segments as Scalar);
422    let mut index = clamped.floor() as usize;
423    if index >= segments {
424        index = segments - 1;
425    }
426    let local = clamped - index as Scalar;
427    let next = (index + 1) % count;
428    Some((index, next, local))
429}
430
431fn polyline_point<P>(points: &[P], closed: bool, t: Scalar) -> GeomResult<P>
432where
433    P: Copy
434        + core::ops::Add<Output = P>
435        + core::ops::Sub<Output = P>
436        + core::ops::Mul<Scalar, Output = P>,
437{
438    let (i, j, local) = polyline_span(points.len(), closed, t).ok_or_else(|| {
439        GeomError::Degenerate(format!(
440            "polyline with {} points has no evaluable segment",
441            points.len()
442        ))
443    })?;
444    Ok(points[i] + (points[j] - points[i]) * local)
445}
446
447fn polyline_tangent<P>(points: &[P], closed: bool, t: Scalar) -> GeomResult<P>
448where
449    P: Copy + core::ops::Sub<Output = P>,
450{
451    let (i, j, _) = polyline_span(points.len(), closed, t).ok_or_else(|| {
452        GeomError::Degenerate(format!(
453            "polyline with {} points has no evaluable segment",
454            points.len()
455        ))
456    })?;
457    // Derivative w.r.t. the unit-per-segment parameter is the full edge vector.
458    Ok(points[j] - points[i])
459}
460
461// --- reusable de Boor core (shared with `crate::surface`) -------------------
462
463/// Locate the knot span for `u` in a validated flat knot vector.
464///
465/// Extracted from [`spline_span`] so a tensor-product surface can reuse the
466/// exact same span logic per axis. `n` is the control-point count, `d` the
467/// degree; the caller has already checked `knots.len() == n + d + 1`.
468pub(crate) fn span_in(knots: &[Scalar], n: usize, d: usize, u: Scalar) -> usize {
469    let mut span = d;
470    for (k, knot) in knots.iter().enumerate().take(n).skip(d) {
471        if *knot <= u {
472            span = k;
473        } else {
474            break;
475        }
476    }
477    span
478}
479
480/// One de Boor recurrence over homogeneous coordinates.
481///
482/// `points` holds the `d+1` premultiplied control points influencing `span`,
483/// `weights` their weights. Both are consumed in place. This is the numerical
484/// heart shared by curve and surface evaluation: keeping one copy means a fix
485/// to the recurrence cannot land in one and not the other.
486pub(crate) fn de_boor_recurrence<const N: usize>(
487    knots: &[Scalar],
488    span: usize,
489    d: usize,
490    u: Scalar,
491    points: &mut [[Scalar; N]],
492    weights: &mut [Scalar],
493) {
494    for r in 1..=d {
495        for j in (r..=d).rev() {
496            let i = span - d + j;
497            let denom = knots[i + d + 1 - r] - knots[i];
498            let alpha = if denom.abs() > 0.0 {
499                (u - knots[i]) / denom
500            } else {
501                0.0
502            };
503            for k in 0..N {
504                points[j][k] = points[j - 1][k] * (1.0 - alpha) + points[j][k] * alpha;
505            }
506            weights[j] = weights[j - 1] * (1.0 - alpha) + weights[j] * alpha;
507        }
508    }
509}
510
511// --- de Boor ----------------------------------------------------------------
512
513/// Shared setup: validated flat knots, degree, and the knot span for `t`.
514fn spline_span<P>(b: &BSplineCurve<P>, t: Scalar) -> GeomResult<(Vec<Scalar>, usize, usize)> {
515    let axis = SplineAxis::new(
516        &b.knots,
517        &b.multiplicities,
518        b.degree,
519        b.control_points.len(),
520        "B-spline curve",
521    )?;
522    if let Some(weights) = &b.weights {
523        if weights.len() != b.control_points.len() {
524            return Err(GeomError::InvalidInput(format!(
525                "B-spline has {} weights for {} control points",
526                weights.len(),
527                b.control_points.len()
528            )));
529        }
530        if weights
531            .iter()
532            .any(|weight| !weight.is_finite() || *weight <= 0.0)
533        {
534            return Err(GeomError::InvalidInput(
535                "B-spline weights must be finite and strictly positive".to_owned(),
536            ));
537        }
538    }
539    let t = axis.clamp(t);
540    let span = span_in(&axis.knots, axis.count, axis.degree, t);
541    Ok((axis.knots, span, axis.degree))
542}
543
544/// Convert and validate every control point before selecting a knot span.
545/// Imported NaN/Inf coordinates must not be hidden in currently uninfluential
546/// spans and surface later when the parameter changes.
547fn finite_control_points<P, const N: usize, F>(
548    control_points: &[P],
549    to: &F,
550) -> GeomResult<Vec<[Scalar; N]>>
551where
552    F: Fn(&P) -> [Scalar; N],
553{
554    let points: Vec<[Scalar; N]> = control_points.iter().map(to).collect();
555    if points
556        .iter()
557        .flatten()
558        .any(|coordinate| !coordinate.is_finite())
559    {
560        return Err(GeomError::InvalidInput(
561            "B-spline control points must be finite".to_owned(),
562        ));
563    }
564    Ok(points)
565}
566
567/// Position via de Boor's algorithm.
568///
569/// `to` and `from` convert between the point type and a fixed-size coordinate
570/// array so 2D and 3D share one implementation. Rational curves are evaluated
571/// in homogeneous coordinates `(w*x, w*y, [w*z], w)` and projected at the end.
572fn de_boor<P, const N: usize, F, G, Q>(
573    b: &BSplineCurve<P>,
574    t: Scalar,
575    to: F,
576    from: G,
577) -> GeomResult<Q>
578where
579    F: Fn(&P) -> [Scalar; N],
580    G: Fn([Scalar; N]) -> Q,
581{
582    let (knots, span, d) = spline_span(b, t)?;
583    let control_points = finite_control_points(&b.control_points, &to)?;
584    let u = t.clamp(knots[d], knots[b.control_points.len()]);
585
586    // Working set: the d+1 control points influencing this span, in homogeneous
587    // form. The trailing slot holds the weight (1.0 for polynomial curves).
588    let mut work: Vec<[Scalar; N]> = Vec::with_capacity(d + 1);
589    let mut weights: Vec<Scalar> = Vec::with_capacity(d + 1);
590    for j in 0..=d {
591        let idx = span - d + j;
592        let w = b.weights.as_ref().map_or(1.0, |ws| ws[idx]);
593        let c = control_points[idx];
594        // Premultiply by w: interpolating in homogeneous space is what makes
595        // rational curves correct. Projecting first would be plain averaging.
596        let homogeneous = core::array::from_fn(|k| c[k] * w);
597        if homogeneous.iter().any(|value| !value.is_finite()) {
598            return Err(GeomError::Degenerate(
599                "B-spline homogeneous control point overflowed".to_owned(),
600            ));
601        }
602        work.push(homogeneous);
603        weights.push(w);
604    }
605
606    // A repeated knot makes an interval empty; the shared recurrence treats
607    // that as alpha = 0, which is the correct limit.
608    de_boor_recurrence(&knots, span, d, u, &mut work, &mut weights);
609
610    let w = weights[d];
611    if !w.is_finite() || w == 0.0 {
612        return Err(GeomError::Degenerate(
613            "B-spline weight collapsed to zero".to_owned(),
614        ));
615    }
616    Ok(from(core::array::from_fn(|k| work[d][k] / w)))
617}
618
619/// First derivative via the hodograph, with the quotient rule for rationals.
620///
621/// The derivative of a degree-`d` B-spline is a degree-`(d-1)` B-spline over
622/// the same knots minus their outermost entries, with control points
623/// `d * (P[i+1] - P[i]) / (knots[i+d+1] - knots[i+1])`.
624///
625/// For a rational curve `C = A/w`, both `A` and `w` are differentiated in
626/// homogeneous space and combined as `(A' - C * w') / w`.
627fn de_boor_derivative<P, const N: usize, F, G, Q>(
628    b: &BSplineCurve<P>,
629    t: Scalar,
630    to: F,
631    from: G,
632) -> GeomResult<Q>
633where
634    F: Fn(&P) -> [Scalar; N],
635    G: Fn([Scalar; N]) -> Q,
636{
637    let (knots, _, d) = spline_span(b, t)?;
638    let control_points = finite_control_points(&b.control_points, &to)?;
639    let n = b.control_points.len();
640    let u = t.clamp(knots[d], knots[n]);
641
642    // Homogeneous control points, weight in a parallel array.
643    let hom: Vec<[Scalar; N]> = (0..n)
644        .map(|i| {
645            let w = b.weights.as_ref().map_or(1.0, |ws| ws[i]);
646            let c = control_points[i];
647            core::array::from_fn(|k| c[k] * w)
648        })
649        .collect();
650    if hom.iter().flatten().any(|value| !value.is_finite()) {
651        return Err(GeomError::Degenerate(
652            "B-spline homogeneous control point overflowed".to_owned(),
653        ));
654    }
655    let hw: Vec<Scalar> = (0..n)
656        .map(|i| b.weights.as_ref().map_or(1.0, |ws| ws[i]))
657        .collect();
658
659    // Hodograph control points.
660    let mut dhom: Vec<[Scalar; N]> = Vec::with_capacity(n - 1);
661    let mut dhw: Vec<Scalar> = Vec::with_capacity(n - 1);
662    for i in 0..n - 1 {
663        let denom = knots[i + d + 1] - knots[i + 1];
664        let f = if denom.abs() > 0.0 {
665            d as Scalar / denom
666        } else {
667            0.0
668        };
669        dhom.push(core::array::from_fn(|k| (hom[i + 1][k] - hom[i][k]) * f));
670        dhw.push((hw[i + 1] - hw[i]) * f);
671    }
672
673    // Evaluate the hodograph at u with degree d-1 over the trimmed knots.
674    let dknots = &knots[1..knots.len() - 1];
675    let (da, dw) = eval_homogeneous(dknots, d - 1, &dhom, &dhw, u);
676    // Evaluate the curve itself for the quotient rule.
677    let (a, w) = eval_homogeneous(&knots, d, &hom, &hw, u);
678
679    if !w.is_finite() || w == 0.0 {
680        return Err(GeomError::Degenerate(
681            "B-spline weight collapsed to zero".to_owned(),
682        ));
683    }
684    // C = A/w  =>  C' = (A' - (A/w) * w') / w
685    Ok(from(core::array::from_fn(|k| {
686        (da[k] - (a[k] / w) * dw) / w
687    })))
688}
689
690/// Second derivative via two homogeneous hodograph constructions.
691///
692/// For `C = A / w`, the rational recurrence is
693/// `C'' = (A'' - 2 w' C' - w'' C) / w`.
694fn de_boor_second_derivative<P, const N: usize, F, G, Q>(
695    b: &BSplineCurve<P>,
696    t: Scalar,
697    to: F,
698    from: G,
699) -> GeomResult<Q>
700where
701    F: Fn(&P) -> [Scalar; N],
702    G: Fn([Scalar; N]) -> Q,
703{
704    let (knots, _, degree) = spline_span(b, t)?;
705    let control_points = finite_control_points(&b.control_points, &to)?;
706    let count = b.control_points.len();
707    let u = t.clamp(knots[degree], knots[count]);
708
709    let points: Vec<[Scalar; N]> = (0..count)
710        .map(|i| {
711            let weight = b.weights.as_ref().map_or(1.0, |weights| weights[i]);
712            core::array::from_fn(|axis| control_points[i][axis] * weight)
713        })
714        .collect();
715    if points.iter().flatten().any(|value| !value.is_finite()) {
716        return Err(GeomError::Degenerate(
717            "B-spline homogeneous control point overflowed".to_owned(),
718        ));
719    }
720    let weights: Vec<Scalar> = (0..count)
721        .map(|i| b.weights.as_ref().map_or(1.0, |values| values[i]))
722        .collect();
723
724    let (point, weight) = eval_homogeneous(&knots, degree, &points, &weights, u);
725    if !weight.is_finite() || weight == 0.0 {
726        return Err(GeomError::Degenerate(
727            "B-spline weight collapsed to zero".to_owned(),
728        ));
729    }
730
731    let (first_points, first_weights) = derivative_controls(&points, &weights, &knots, degree);
732    let first_knots = &knots[1..knots.len() - 1];
733    let (first, first_weight) =
734        eval_homogeneous(first_knots, degree - 1, &first_points, &first_weights, u);
735    let position: [Scalar; N] = core::array::from_fn(|axis| point[axis] / weight);
736    let first_projected: [Scalar; N] =
737        core::array::from_fn(|axis| (first[axis] - position[axis] * first_weight) / weight);
738
739    let (second, second_weight) = if degree == 1 {
740        ([0.0; N], 0.0)
741    } else {
742        let (second_points, second_weights) =
743            derivative_controls(&first_points, &first_weights, first_knots, degree - 1);
744        let second_knots = &first_knots[1..first_knots.len() - 1];
745        eval_homogeneous(second_knots, degree - 2, &second_points, &second_weights, u)
746    };
747    Ok(from(core::array::from_fn(|axis| {
748        (second[axis] - 2.0 * first_weight * first_projected[axis] - second_weight * position[axis])
749            / weight
750    })))
751}
752
753/// Derivative control polygon for one homogeneous B-spline axis.
754fn derivative_controls<const N: usize>(
755    points: &[[Scalar; N]],
756    weights: &[Scalar],
757    knots: &[Scalar],
758    degree: usize,
759) -> (Vec<[Scalar; N]>, Vec<Scalar>) {
760    let mut derivative_points = Vec::with_capacity(points.len() - 1);
761    let mut derivative_weights = Vec::with_capacity(weights.len() - 1);
762    for i in 0..points.len() - 1 {
763        let denominator = knots[i + degree + 1] - knots[i + 1];
764        let factor = if denominator.abs() > 0.0 {
765            degree as Scalar / denominator
766        } else {
767            0.0
768        };
769        derivative_points.push(core::array::from_fn(|axis| {
770            (points[i + 1][axis] - points[i][axis]) * factor
771        }));
772        derivative_weights.push((weights[i + 1] - weights[i]) * factor);
773    }
774    (derivative_points, derivative_weights)
775}
776
777/// de Boor over explicit homogeneous arrays; returns `(numerator, weight)`.
778pub(crate) fn eval_homogeneous<const N: usize>(
779    knots: &[Scalar],
780    d: usize,
781    hom: &[[Scalar; N]],
782    hw: &[Scalar],
783    u: Scalar,
784) -> ([Scalar; N], Scalar) {
785    let n = hom.len();
786    if d == 0 {
787        // Degree zero: piecewise constant, pick the containing span.
788        let mut idx = 0;
789        for (k, knot) in knots.iter().enumerate().take(n) {
790            if *knot <= u {
791                idx = k;
792            }
793        }
794        return (hom[idx.min(n - 1)], hw[idx.min(n - 1)]);
795    }
796    let mut span = d;
797    for (k, knot) in knots.iter().enumerate().take(n).skip(d) {
798        if *knot <= u {
799            span = k;
800        } else {
801            break;
802        }
803    }
804    let mut work: Vec<[Scalar; N]> = (0..=d).map(|j| hom[span - d + j]).collect();
805    let mut weights: Vec<Scalar> = (0..=d).map(|j| hw[span - d + j]).collect();
806    for r in 1..=d {
807        for j in (r..=d).rev() {
808            let i = span - d + j;
809            let denom = knots[i + d + 1 - r] - knots[i];
810            let alpha = if denom.abs() > 0.0 {
811                (u - knots[i]) / denom
812            } else {
813                0.0
814            };
815            for k in 0..N {
816                work[j][k] = work[j - 1][k] * (1.0 - alpha) + work[j][k] * alpha;
817            }
818            weights[j] = weights[j - 1] * (1.0 - alpha) + weights[j] * alpha;
819        }
820    }
821    (work[d], weights[d])
822}
823
824// --- adaptive flattening ----------------------------------------------------
825
826/// Flatten a 2D curve over `domain` so the chord never deviates from the true
827/// curve by more than `chord_tolerance`.
828///
829/// # Why bisection rather than a closed-form segment count
830///
831/// A count derived from radius and tolerance only works for circles. Bisecting
832/// on measured sagitta works for every family, including rational splines whose
833/// curvature varies along the span, and it degrades gracefully on the
834/// degenerate inputs imported data actually contains.
835///
836/// The returned polyline includes both endpoints and is ordered along
837/// increasing parameter. `max_depth` bounds the work: a caller gets a
838/// deterministic result rather than an unbounded subdivision on a pathological
839/// curve.
840pub fn flatten2(
841    curve: &Curve2,
842    domain: Interval,
843    chord_tolerance: Scalar,
844    max_depth: u32,
845) -> GeomResult<Vec<Point2>> {
846    // A depth bound alone is not a resource bound: depth `d` permits `2^d`
847    // segments. Cap the total point count too, so a curve that cannot meet
848    // the tolerance fails fast instead of exhausting memory.
849    const MAX_POINTS: usize = 1 << 16;
850    if !(chord_tolerance.is_finite()
851        && chord_tolerance.is_sign_positive()
852        && chord_tolerance != 0.0)
853    {
854        return Err(GeomError::InvalidInput(format!(
855            "chord tolerance must be positive and finite, got {chord_tolerance}"
856        )));
857    }
858    // A line and a polyline are already exact between their breakpoints:
859    // subdividing them adds vertices that carry no information.
860    if let Curve2::Line(_) = curve {
861        return Ok(vec![
862            evaluate2(curve, domain.start)?,
863            evaluate2(curve, domain.end)?,
864        ]);
865    }
866    if let Curve2::Polyline(p) = curve {
867        // A polyline's parameter is one unit per segment, so a caller passing
868        // a normalized `(0, 1)` domain would silently collapse an n-vertex
869        // ring to its first edge. That is data loss disguised as success, so
870        // it is refused: a domain narrower than one segment can only be
871        // intentional for a genuinely 1-segment polyline.
872        let natural = polyline_domain(p.points.len(), p.closed);
873        let requested = (domain.end - domain.start).abs();
874        if natural.end > 1.0 && requested <= 1.0 {
875            return Err(GeomError::InvalidInput(format!(
876                "polyline domain {:?} spans {requested} of {} segments; a \
877                 polyline parameter is one unit per segment, so this would \
878                 discard {} vertices",
879                domain,
880                natural.end,
881                p.points.len().saturating_sub(2)
882            )));
883        }
884        return polyline_flatten(&p.points, p.closed, domain, |t| evaluate2(curve, t));
885    }
886
887    let mut out = vec![evaluate2(curve, domain.start)?];
888    let eval = |t| evaluate2(curve, t);
889    subdivide(
890        &eval,
891        domain.start,
892        domain.end,
893        chord_tolerance,
894        max_depth.min(MAX_DEPTH_CEILING),
895        MAX_POINTS,
896        &mut out,
897    )?;
898    out.push(evaluate2(curve, domain.end)?);
899    Ok(out)
900}
901
902/// Hard ceiling on recursion depth regardless of what a caller asks for.
903///
904/// 2^20 segments is already far past any usable tolerance; beyond this a
905/// request is a bug, not a quality setting.
906const MAX_DEPTH_CEILING: u32 = 20;
907
908/// Emit interior points of `(a, b)` that are needed to meet the tolerance.
909///
910/// `budget` bounds total emitted points. Exceeding it is an error rather than
911/// a truncation: silently returning a coarser polyline than the caller asked
912/// for would violate the tolerance contract this function exists to honour.
913/// The vector operations chord subdivision needs, in either dimension.
914///
915/// `Point2` and `Point3` are both glam vectors with the same surface, and
916/// the subdivision below is genuinely dimension-independent: it measures a
917/// sagitta and bisects a parameter interval, neither of which mentions a
918/// coordinate count. This trait states that once instead of maintaining
919/// two copies that can drift apart.
920trait ChordPoint: Copy {
921    fn sub(self, other: Self) -> Self;
922    fn add_scaled(self, direction: Self, scale: Scalar) -> Self;
923    fn dot(self, other: Self) -> Scalar;
924    fn length(self) -> Scalar;
925    fn length_squared(self) -> Scalar;
926}
927
928impl ChordPoint for Point2 {
929    fn sub(self, other: Self) -> Self {
930        self - other
931    }
932    fn add_scaled(self, direction: Self, scale: Scalar) -> Self {
933        self + direction * scale
934    }
935    fn dot(self, other: Self) -> Scalar {
936        Point2::dot(self, other)
937    }
938    fn length(self) -> Scalar {
939        Point2::length(self)
940    }
941    fn length_squared(self) -> Scalar {
942        Point2::length_squared(self)
943    }
944}
945
946impl ChordPoint for Point3 {
947    fn sub(self, other: Self) -> Self {
948        self - other
949    }
950    fn add_scaled(self, direction: Self, scale: Scalar) -> Self {
951        self + direction * scale
952    }
953    fn dot(self, other: Self) -> Scalar {
954        Point3::dot(self, other)
955    }
956    fn length(self) -> Scalar {
957        Point3::length(self)
958    }
959    fn length_squared(self) -> Scalar {
960        Point3::length_squared(self)
961    }
962}
963
964/// Perpendicular distance from `m` to the chord `a`-`b`.
965fn sagitta<P: ChordPoint>(a: P, b: P, m: P) -> Scalar {
966    let ab = b.sub(a);
967    let len2 = ab.length_squared();
968    if len2 <= 0.0 {
969        // Degenerate chord: fall back to point distance so a closed curve
970        // whose endpoints coincide still subdivides.
971        return m.sub(a).length();
972    }
973    let t = (m.sub(a).dot(ab) / len2).clamp(0.0, 1.0);
974    m.sub(a.add_scaled(ab, t)).length()
975}
976
977/// Emit the interior points of `(a, b)` needed to meet `tol`.
978///
979/// Shared by both dimensions; the caller supplies the evaluator. Depth
980/// exhaustion is an error rather than a truncation, matching the 2D
981/// contract: silently returning a coarser polyline than asked for would
982/// break the tolerance guarantee the caller is relying on.
983fn subdivide<P, F>(
984    eval: &F,
985    a: Scalar,
986    b: Scalar,
987    tol: Scalar,
988    depth: u32,
989    budget: usize,
990    out: &mut Vec<P>,
991) -> GeomResult<()>
992where
993    P: ChordPoint,
994    F: Fn(Scalar) -> GeomResult<P>,
995{
996    if out.len() >= budget {
997        return Err(GeomError::Degenerate(format!(
998            "curve flattening exceeded {budget} points before meeting the \
999             chord tolerance {tol}; the curve may be degenerate"
1000        )));
1001    }
1002    let mid = 0.5 * (a + b);
1003    let pa = eval(a)?;
1004    let pb = eval(b)?;
1005    // A parameter interval too small to bisect cannot be refined further:
1006    // `mid` equals `a` or `b` in floating point. Returning the chord anyway
1007    // would hand back an unverified approximation, so this fails closed --
1008    // unless the chord already meets the tolerance, in which case there was
1009    // nothing left to verify.
1010    if !(mid > a && mid < b) {
1011        if sagitta(pa, pb, pa) <= tol && (pb.sub(pa)).length() <= tol {
1012            return Ok(());
1013        }
1014        return Err(GeomError::Degenerate(format!(
1015            "curve parameter interval ({a}, {b}) is too small to bisect but \
1016             its chord still exceeds the tolerance {tol}"
1017        )));
1018    }
1019    let pm = eval(mid)?;
1020    if sagitta(pa, pb, pm) <= tol {
1021        // Within tolerance: the chord a->b stands, no interior point.
1022        return Ok(());
1023    }
1024    if depth == 0 {
1025        return Err(GeomError::BudgetExceeded {
1026            resource: "curve flattening depth",
1027        });
1028    }
1029    subdivide(eval, a, mid, tol, depth - 1, budget, out)?;
1030    out.push(pm);
1031    subdivide(eval, mid, b, tol, depth - 1, budget, out)?;
1032    Ok(())
1033}
1034
1035/// Polylines flatten to their own breakpoints, restricted to `domain`.
1036fn polyline_flatten<P, F>(
1037    points: &[P],
1038    closed: bool,
1039    domain: Interval,
1040    eval: F,
1041) -> GeomResult<Vec<P>>
1042where
1043    P: Copy,
1044    F: Fn(Scalar) -> GeomResult<P>,
1045{
1046    let segments = if closed {
1047        points.len()
1048    } else {
1049        points.len().saturating_sub(1)
1050    };
1051    if segments == 0 {
1052        return Err(GeomError::Degenerate(
1053            "polyline has no evaluable segment".to_owned(),
1054        ));
1055    }
1056    let lo = domain.start.min(domain.end);
1057    let hi = domain.start.max(domain.end);
1058    let mut out = vec![eval(lo)?];
1059    // Interior breakpoints are the integer parameters strictly inside.
1060    let first = lo.floor() as i64 + 1;
1061    let last = hi.ceil() as i64 - 1;
1062    for k in first..=last {
1063        let t = k as Scalar;
1064        if t > lo && t < hi {
1065            out.push(eval(t)?);
1066        }
1067    }
1068    out.push(eval(hi)?);
1069    Ok(out)
1070}
1071
1072// --- trait wiring -----------------------------------------------------------
1073
1074impl CurveEvaluator<Curve2> for ScalarCurve {
1075    type Point = Point2;
1076    type Derivative = Vec2;
1077    type Error = GeomError;
1078
1079    fn domain(&self, curve: &Curve2) -> Interval {
1080        domain2(curve)
1081    }
1082
1083    fn evaluate(
1084        &self,
1085        curve: &Curve2,
1086        t: Scalar,
1087        _tolerance: Tolerance,
1088    ) -> Result<Self::Point, Self::Error> {
1089        evaluate2(curve, t)
1090    }
1091
1092    fn derivative(
1093        &self,
1094        curve: &Curve2,
1095        t: Scalar,
1096        _tolerance: Tolerance,
1097    ) -> Result<Self::Derivative, Self::Error> {
1098        derivative2(curve, t)
1099    }
1100}
1101
1102impl CurveEvaluator<Curve3> for ScalarCurve {
1103    type Point = Point3;
1104    type Derivative = Vec3;
1105    type Error = GeomError;
1106
1107    fn domain(&self, curve: &Curve3) -> Interval {
1108        domain3(curve)
1109    }
1110
1111    fn evaluate(
1112        &self,
1113        curve: &Curve3,
1114        t: Scalar,
1115        _tolerance: Tolerance,
1116    ) -> Result<Self::Point, Self::Error> {
1117        evaluate3(curve, t)
1118    }
1119
1120    fn derivative(
1121        &self,
1122        curve: &Curve3,
1123        t: Scalar,
1124        _tolerance: Tolerance,
1125    ) -> Result<Self::Derivative, Self::Error> {
1126        derivative3(curve, t)
1127    }
1128}
1129
1130// Silence unused-import warnings for types only named in signatures.
1131#[allow(unused)]
1132fn _type_anchors(_: Circle2, _: Circle3, _: Ellipse2, _: Ellipse3, _: Line2, _: Line3) {}
1133#[allow(unused)]
1134fn _poly_anchors(_: Polyline2, _: Polyline3) {}
1135
1136/// Flatten a 3D curve to a polyline within `chord_tolerance`.
1137///
1138/// The 3D twin of [`flatten2`], sharing its subdivision, its resource
1139/// bounds and its polyline contract. Sampling is adaptive: a curve is
1140/// bisected only where the chord actually departs from it, so a gentle
1141/// arc costs few points and a tight one costs many, and neither is
1142/// decided by a fixed count chosen in advance.
1143pub fn flatten3(
1144    curve: &Curve3,
1145    domain: Interval,
1146    chord_tolerance: Scalar,
1147    max_depth: u32,
1148) -> GeomResult<Vec<Point3>> {
1149    // A depth bound alone is not a resource bound: depth `d` permits `2^d`
1150    // segments. Cap the total point count too, so a curve that cannot meet
1151    // the tolerance fails fast instead of exhausting memory.
1152    const MAX_POINTS: usize = 1 << 16;
1153    if !(chord_tolerance.is_finite()
1154        && chord_tolerance.is_sign_positive()
1155        && chord_tolerance != 0.0)
1156    {
1157        return Err(GeomError::InvalidInput(format!(
1158            "chord tolerance must be positive and finite, got {chord_tolerance}"
1159        )));
1160    }
1161    // A line is exact between its endpoints: subdividing adds vertices that
1162    // carry no information.
1163    if let Curve3::Line(_) = curve {
1164        return Ok(vec![
1165            evaluate3(curve, domain.start)?,
1166            evaluate3(curve, domain.end)?,
1167        ]);
1168    }
1169    if let Curve3::Polyline(p) = curve {
1170        // A polyline's parameter is one unit per segment, so a caller passing
1171        // a normalized `(0, 1)` domain would silently collapse an n-vertex
1172        // path to its first edge. That is data loss disguised as success.
1173        let natural = polyline_domain(p.points.len(), p.closed);
1174        let requested = (domain.end - domain.start).abs();
1175        if natural.end > 1.0 && requested <= 1.0 {
1176            return Err(GeomError::InvalidInput(format!(
1177                "polyline domain {:?} spans {requested} of {} segments; a \
1178                 polyline parameter is one unit per segment, so this would \
1179                 discard {} vertices",
1180                domain,
1181                natural.end,
1182                p.points.len().saturating_sub(2)
1183            )));
1184        }
1185        return polyline_flatten(&p.points, p.closed, domain, |t| evaluate3(curve, t));
1186    }
1187
1188    let eval = |t| evaluate3(curve, t);
1189    let mut out = vec![eval(domain.start)?];
1190    subdivide(
1191        &eval,
1192        domain.start,
1193        domain.end,
1194        chord_tolerance,
1195        max_depth.min(MAX_DEPTH_CEILING),
1196        MAX_POINTS,
1197        &mut out,
1198    )?;
1199    out.push(eval(domain.end)?);
1200    Ok(out)
1201}
1202
1203/// Refuse a family that has no closed-form inversion.
1204///
1205/// Named separately from `unsupported_family` so the message can say WHY:
1206/// the family is understood, its inversion simply is not algebraic.
1207fn no_closed_form_inversion() -> GeomError {
1208    GeomError::InvalidInput(
1209        "curve family has no closed form inversion; a point trim on this basis \
1210         would require iteration, which trim resolution does not perform"
1211            .to_owned(),
1212    )
1213}
1214
1215/// The point is not on the curve, so no parameter names it.
1216fn point_not_on_curve(distance: Scalar, tolerance: Scalar) -> GeomError {
1217    GeomError::InvalidInput(format!(
1218        "point is {distance} from the curve, outside the {tolerance} tolerance; \
1219         refusing rather than projecting it onto the nearest parameter"
1220    ))
1221}
1222
1223/// Parameter of `point` on `line`, or a refusal when it is off the line.
1224///
1225/// The direction need not be unit length, so the projection is normalised by
1226/// its squared length: that is what makes the returned value a parameter of
1227/// THIS line rather than an arc length.
1228fn invert_line(
1229    origin: impl Into<[Scalar; 3]>,
1230    direction: [Scalar; 3],
1231    point: [Scalar; 3],
1232    tolerance: Scalar,
1233) -> GeomResult<Scalar> {
1234    let origin = origin.into();
1235    let dd = direction.iter().map(|c| c * c).sum::<Scalar>();
1236    if !dd.is_finite() || dd <= Scalar::EPSILON {
1237        return Err(GeomError::InvalidInput(
1238            "line direction is degenerate, so no parameter names a point".to_owned(),
1239        ));
1240    }
1241    let offset = [
1242        point[0] - origin[0],
1243        point[1] - origin[1],
1244        point[2] - origin[2],
1245    ];
1246    let t = offset
1247        .iter()
1248        .zip(direction.iter())
1249        .map(|(o, d)| o * d)
1250        .sum::<Scalar>()
1251        / dd;
1252    // Verify rather than assume: the projection always yields a parameter, but
1253    // only a point actually ON the line is named by it.
1254    let residual = [
1255        offset[0] - direction[0] * t,
1256        offset[1] - direction[1] * t,
1257        offset[2] - direction[2] * t,
1258    ];
1259    let distance = residual.iter().map(|c| c * c).sum::<Scalar>().sqrt();
1260    if distance > tolerance {
1261        return Err(point_not_on_curve(distance, tolerance));
1262    }
1263    Ok(t)
1264}
1265
1266/// Parametric angle of `point` about a conic frame, verified against the curve.
1267///
1268/// For a circle the parametric angle is the polar angle; for an ellipse it is
1269/// not, so the local coordinates are divided by their semi-axes BEFORE the
1270/// arctangent. Taking the polar angle directly would be wrong off-axis.
1271fn invert_conic(
1272    local_x: Scalar,
1273    local_y: Scalar,
1274    semi_x: Scalar,
1275    semi_y: Scalar,
1276) -> GeomResult<Scalar> {
1277    if !(semi_x.is_finite() && semi_y.is_finite()) || semi_x <= 0.0 || semi_y <= 0.0 {
1278        return Err(GeomError::InvalidInput(
1279            "conic semi-axes must be finite and positive to invert a point".to_owned(),
1280        ));
1281    }
1282    let angle = (local_y / semi_y).atan2(local_x / semi_x);
1283    if !angle.is_finite() {
1284        return Err(GeomError::InvalidInput(
1285            "conic inversion produced a non-finite angle".to_owned(),
1286        ));
1287    }
1288    // Report on the same domain evaluation uses, so invert then evaluate is a
1289    // round trip rather than an off-by-one-turn surprise.
1290    Ok(angle.rem_euclid(std::f64::consts::TAU))
1291}
1292
1293/// Parameter naming `point` on a 2D curve, or a refusal.
1294///
1295/// Exact for the families whose inversion is algebraic. Anything else is
1296/// refused by name: introducing iteration here would put a tolerance and a
1297/// convergence failure mode into every consumer of a point trim, and the
1298/// certified iterative path belongs to a caller that can carry its evidence.
1299///
1300/// # Errors
1301///
1302/// Refuses a point further than `tolerance` from the curve rather than
1303/// projecting it, and refuses families with no closed-form inversion.
1304pub fn invert2(curve: &Curve2, point: Point2, tolerance: Tolerance) -> GeomResult<Scalar> {
1305    finite2(point, "inversion point")?;
1306    let linear = tolerance.linear();
1307    match curve {
1308        Curve2::Line(l) => invert_line(
1309            [l.origin.x, l.origin.y, 0.0],
1310            [l.direction.x, l.direction.y, 0.0],
1311            [point.x, point.y, 0.0],
1312            linear,
1313        ),
1314        Curve2::Circle(c) => {
1315            let t = invert_conic_in_frame2(&c.frame, point, c.radius, c.radius)?;
1316            verify2(curve, t, point, linear)
1317        }
1318        Curve2::Ellipse(e) => {
1319            let t = invert_conic_in_frame2(&e.frame, point, e.semi_axis_x, e.semi_axis_y)?;
1320            verify2(curve, t, point, linear)
1321        }
1322        _ => Err(no_closed_form_inversion()),
1323    }
1324}
1325
1326/// Project a point into a 2D conic frame and invert it there.
1327fn invert_conic_in_frame2(
1328    frame: &axiolid_core::Frame2,
1329    point: Point2,
1330    semi_x: Scalar,
1331    semi_y: Scalar,
1332) -> GeomResult<Scalar> {
1333    let offset = point - frame.origin;
1334    invert_conic(offset.dot(frame.x), offset.dot(frame.y), semi_x, semi_y)
1335}
1336
1337/// Confirm the recovered parameter actually reproduces the point.
1338///
1339/// The algebra above assumes an orthonormal frame. Imported frames are not
1340/// always orthonormal, and this crate deliberately keeps dirty frames
1341/// representable, so the claim is checked against the real evaluator instead
1342/// of trusted.
1343fn verify2(curve: &Curve2, t: Scalar, point: Point2, tolerance: Scalar) -> GeomResult<Scalar> {
1344    let found = evaluate2(curve, t)?;
1345    let distance = (found - point).length();
1346    if distance > tolerance {
1347        return Err(point_not_on_curve(distance, tolerance));
1348    }
1349    Ok(t)
1350}
1351
1352/// Parameter naming `point` on a 3D curve, or a refusal.
1353///
1354/// See [`invert2`] for the exactness policy.
1355///
1356/// # Errors
1357///
1358/// Refuses an off-curve point and any family without a closed-form inversion.
1359pub fn invert3(curve: &Curve3, point: Point3, tolerance: Tolerance) -> GeomResult<Scalar> {
1360    finite3(point, "inversion point")?;
1361    let linear = tolerance.linear();
1362    match curve {
1363        Curve3::Line(l) => invert_line(
1364            [l.origin.x, l.origin.y, l.origin.z],
1365            [l.direction.x, l.direction.y, l.direction.z],
1366            [point.x, point.y, point.z],
1367            linear,
1368        ),
1369        Curve3::Circle(c) => {
1370            let t = invert_conic_in_frame3(&c.frame, point, c.radius, c.radius)?;
1371            verify3(curve, t, point, linear)
1372        }
1373        Curve3::Ellipse(e) => {
1374            let t = invert_conic_in_frame3(&e.frame, point, e.semi_axis_x, e.semi_axis_y)?;
1375            verify3(curve, t, point, linear)
1376        }
1377        _ => Err(no_closed_form_inversion()),
1378    }
1379}
1380
1381/// Project a point into a 3D conic frame and invert it there.
1382fn invert_conic_in_frame3(
1383    frame: &axiolid_core::Frame3,
1384    point: Point3,
1385    semi_x: Scalar,
1386    semi_y: Scalar,
1387) -> GeomResult<Scalar> {
1388    let offset = point - frame.origin;
1389    invert_conic(offset.dot(frame.x), offset.dot(frame.y), semi_x, semi_y)
1390}
1391
1392/// Confirm the recovered parameter reproduces the point. See [`verify2`].
1393fn verify3(curve: &Curve3, t: Scalar, point: Point3, tolerance: Scalar) -> GeomResult<Scalar> {
1394    let found = evaluate3(curve, t)?;
1395    let distance = (found - point).length();
1396    if distance > tolerance {
1397        return Err(point_not_on_curve(distance, tolerance));
1398    }
1399    Ok(t)
1400}