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.
353pub(crate) fn pair_side(
354    l: &axiolid_curve::LiftedCurve2,
355) -> Option<(&axiolid_curve::PairSection3, bool)> {
356    match l.curve.as_ref() {
357        Curve3::PairSection(section) => section.side(&l.carrier).map(|first| (section, first)),
358        _ => None,
359    }
360}
361
362/// A quadratic-graph parameter where its root does not exist, diverges, or
363/// has a vertical tangent: outside the spans the curve was built for.
364fn outside_graph(t: Scalar) -> GeomError {
365    GeomError::Degenerate(format!(
366        "quadratic section graph has no regular point at t = {t}"
367    ))
368}
369
370// --- 3D evaluation ----------------------------------------------------------
371
372/// Position on a 3D curve.
373pub fn evaluate3(curve: &Curve3, t: Scalar) -> GeomResult<Point3> {
374    finite(t)?;
375    let value = match curve {
376        Curve3::Line(l) => Ok(line_point(l.origin, l.direction, t)),
377        Curve3::Circle(c) => Ok(conic_point3(&c.frame, c.radius, c.radius, t)),
378        Curve3::Ellipse(e) => Ok(conic_point3(&e.frame, e.semi_axis_x, e.semi_axis_y, t)),
379        Curve3::Polyline(p) => polyline_point(&p.points, p.closed, t),
380        Curve3::BSpline(b) => de_boor(b, t, |p| [p.x, p.y, p.z], |c| Point3::new(c[0], c[1], c[2])),
381        // Natural equations: `t` is ARC LENGTH, and the point comes from the
382        // Frenet integrator (ADR 0061). Dispatching here is what lets the
383        // graph's existing relation machinery -- trim, composite, sweep
384        // directrix -- work on a torsion curve without special-casing it.
385        Curve3::Intrinsic(i) => crate::frenet::frenet_point(i, t),
386        // The carrier along its section graph (ADR 0076).
387        Curve3::RuledSection(r) => r.point(t).ok_or_else(|| outside_graph(t)),
388        Curve3::TorusSection(r) => r.point(t).ok_or_else(|| outside_graph(t)),
389        Curve3::ImplicitSection(r) => r.point(t).ok_or_else(|| outside_graph(t)),
390        Curve3::PairSection(r) => r.point(t).ok_or_else(|| outside_graph(t)),
391        // `t` is PLAN distance, the centreline's own parameter (ADR 0081).
392        Curve3::Banked(b) => crate::banked::banked_point(b, t),
393        // `t` is PLAN distance, the parameter both halves are written
394        // against (#252, ADR 0082).
395        Curve3::Elevated(e) => crate::arc_length::elevated_point(e, t),
396        // `Curve*` is #[non_exhaustive]. An unknown family is refused by name
397        // rather than approximated by whichever arm happens to be nearest.
398        _ => Err(GeomError::Unsupported {
399            backend: axiolid_contracts::BackendId::new("axiolid-reference"),
400            operation: axiolid_contracts::Operation::CurveEvaluation,
401        }),
402    }?;
403    finite3(value, "curve point")
404}
405
406/// First derivative of a 3D curve.
407pub fn derivative3(curve: &Curve3, t: Scalar) -> GeomResult<Vec3> {
408    finite(t)?;
409    let value = match curve {
410        Curve3::Line(l) => Ok(l.direction),
411        Curve3::Circle(c) => Ok(conic_tangent3(&c.frame, c.radius, c.radius, t)),
412        Curve3::Ellipse(e) => Ok(conic_tangent3(&e.frame, e.semi_axis_x, e.semi_axis_y, t)),
413        Curve3::Polyline(p) => polyline_tangent(&p.points, p.closed, t),
414        Curve3::BSpline(b) => {
415            de_boor_derivative(b, t, |p| [p.x, p.y, p.z], |c| Vec3::new(c[0], c[1], c[2]))
416        }
417        // Arc-length parameterised, so the derivative is the UNIT tangent.
418        Curve3::Intrinsic(i) => crate::frenet::frenet_tangent(i, t),
419        Curve3::RuledSection(r) => r.tangent(t).ok_or_else(|| outside_graph(t)),
420        Curve3::TorusSection(r) => r.tangent(t).ok_or_else(|| outside_graph(t)),
421        Curve3::ImplicitSection(r) => r.tangent(t).ok_or_else(|| outside_graph(t)),
422        Curve3::PairSection(r) => r.tangent(t).ok_or_else(|| outside_graph(t)),
423        // In plan distance, so not unit: `(p, grade + pivot rate)`.
424        Curve3::Banked(b) => crate::banked::banked_derivative(b, t),
425        // In plan distance, so not unit: `(p', grade)` (#252).
426        Curve3::Elevated(e) => crate::elevated::elevated_derivative(e, t),
427        // `Curve*` is #[non_exhaustive]. An unknown family is refused by name
428        // rather than approximated by whichever arm happens to be nearest.
429        _ => Err(GeomError::Unsupported {
430            backend: axiolid_contracts::BackendId::new("axiolid-reference"),
431            operation: axiolid_contracts::Operation::CurveEvaluation,
432        }),
433    }?;
434    finite3(value, "curve derivative")
435}
436
437/// Second derivative of a 3D curve with respect to its native parameter.
438pub fn second_derivative3(curve: &Curve3, t: Scalar) -> GeomResult<Vec3> {
439    finite(t)?;
440    let value = match curve {
441        Curve3::Line(_) | Curve3::Polyline(_) => Ok(Vec3::ZERO),
442        Curve3::Circle(c) => Ok(conic_second3(&c.frame, c.radius, c.radius, t)),
443        Curve3::Ellipse(e) => Ok(conic_second3(&e.frame, e.semi_axis_x, e.semi_axis_y, t)),
444        Curve3::BSpline(b) => {
445            de_boor_second_derivative(b, t, |p| [p.x, p.y, p.z], |c| Vec3::new(c[0], c[1], c[2]))
446        }
447        Curve3::RuledSection(r) => r.bend(t).ok_or_else(|| outside_graph(t)),
448        Curve3::TorusSection(r) => r.bend(t).ok_or_else(|| outside_graph(t)),
449        Curve3::ImplicitSection(r) => r.bend(t).ok_or_else(|| outside_graph(t)),
450        Curve3::PairSection(r) => r.bend(t).ok_or_else(|| outside_graph(t)),
451        // In plan distance: `(k n, z'')`, plus the pivot's `e''` (#252).
452        Curve3::Elevated(e) => crate::elevated::elevated_second_derivative(e, t),
453        Curve3::Banked(b) => crate::banked::banked_second_derivative(b, t),
454        _ => Err(GeomError::Unsupported {
455            backend: axiolid_contracts::BackendId::new("axiolid-reference"),
456            operation: axiolid_contracts::Operation::CurveEvaluation,
457        }),
458    }?;
459    finite3(value, "curve second derivative")
460}
461
462/// Second-order differential jet of a 3D curve.
463pub fn bspline_jet3(curve: &BSplineCurve3, t: Scalar) -> GeomResult<CurveJet<Point3, Vec3>> {
464    Ok(CurveJet {
465        point: de_boor(
466            curve,
467            t,
468            |p| [p.x, p.y, p.z],
469            |c| Point3::new(c[0], c[1], c[2]),
470        )?,
471        first: de_boor_derivative(
472            curve,
473            t,
474            |p| [p.x, p.y, p.z],
475            |c| Vec3::new(c[0], c[1], c[2]),
476        )?,
477        second: de_boor_second_derivative(
478            curve,
479            t,
480            |p| [p.x, p.y, p.z],
481            |c| Vec3::new(c[0], c[1], c[2]),
482        )?,
483    })
484}
485
486/// Second-order differential jet of a 3D curve.
487pub fn jet3(curve: &Curve3, t: Scalar) -> GeomResult<CurveJet<Point3, Vec3>> {
488    Ok(CurveJet {
489        point: evaluate3(curve, t)?,
490        first: derivative3(curve, t)?,
491        second: second_derivative3(curve, t)?,
492    })
493}
494
495// --- family kernels ---------------------------------------------------------
496
497fn finite(t: Scalar) -> GeomResult<()> {
498    if t.is_finite() {
499        Ok(())
500    } else {
501        Err(GeomError::InvalidInput(format!(
502            "curve parameter must be finite, got {t}"
503        )))
504    }
505}
506
507fn finite2(value: Vec2, what: &str) -> GeomResult<Vec2> {
508    if value.is_finite() {
509        Ok(value)
510    } else {
511        Err(GeomError::Degenerate(format!("{what} is non-finite")))
512    }
513}
514
515fn finite3(value: Vec3, what: &str) -> GeomResult<Vec3> {
516    if value.is_finite() {
517        Ok(value)
518    } else {
519        Err(GeomError::Degenerate(format!("{what} is non-finite")))
520    }
521}
522
523fn line_point<P>(origin: P, direction: P, t: Scalar) -> P
524where
525    P: core::ops::Add<Output = P> + core::ops::Mul<Scalar, Output = P>,
526{
527    origin + direction * t
528}
529
530fn conic_point2(frame: &Frame2, rx: Scalar, ry: Scalar, t: Scalar) -> Point2 {
531    frame.origin + frame.x * (rx * t.cos()) + frame.y * (ry * t.sin())
532}
533
534fn conic_tangent2(frame: &Frame2, rx: Scalar, ry: Scalar, t: Scalar) -> Vec2 {
535    frame.x * (-rx * t.sin()) + frame.y * (ry * t.cos())
536}
537
538fn conic_second2(frame: &Frame2, rx: Scalar, ry: Scalar, t: Scalar) -> Vec2 {
539    frame.x * (-rx * t.cos()) + frame.y * (-ry * t.sin())
540}
541
542fn conic_point3(frame: &Frame3, rx: Scalar, ry: Scalar, t: Scalar) -> Point3 {
543    frame.origin + frame.x * (rx * t.cos()) + frame.y * (ry * t.sin())
544}
545
546fn conic_tangent3(frame: &Frame3, rx: Scalar, ry: Scalar, t: Scalar) -> Vec3 {
547    frame.x * (-rx * t.sin()) + frame.y * (ry * t.cos())
548}
549
550fn conic_second3(frame: &Frame3, rx: Scalar, ry: Scalar, t: Scalar) -> Vec3 {
551    frame.x * (-rx * t.cos()) + frame.y * (-ry * t.sin())
552}
553
554/// Segment index and local fraction for a polyline parameter.
555///
556/// Returns `None` when the polyline cannot be evaluated at all.
557fn polyline_span(count: usize, closed: bool, t: Scalar) -> Option<(usize, usize, Scalar)> {
558    let segments = if closed {
559        count
560    } else {
561        count.saturating_sub(1)
562    };
563    if count < 2 || segments == 0 {
564        return None;
565    }
566    // Clamp into range: the endpoint t == segments is the final vertex, which
567    // would otherwise index one past the last segment.
568    let clamped = t.clamp(0.0, segments as Scalar);
569    let mut index = clamped.floor() as usize;
570    if index >= segments {
571        index = segments - 1;
572    }
573    let local = clamped - index as Scalar;
574    let next = (index + 1) % count;
575    Some((index, next, local))
576}
577
578fn polyline_point<P>(points: &[P], closed: bool, t: Scalar) -> GeomResult<P>
579where
580    P: Copy
581        + core::ops::Add<Output = P>
582        + core::ops::Sub<Output = P>
583        + core::ops::Mul<Scalar, Output = P>,
584{
585    let (i, j, local) = polyline_span(points.len(), closed, t).ok_or_else(|| {
586        GeomError::Degenerate(format!(
587            "polyline with {} points has no evaluable segment",
588            points.len()
589        ))
590    })?;
591    Ok(points[i] + (points[j] - points[i]) * local)
592}
593
594fn polyline_tangent<P>(points: &[P], closed: bool, t: Scalar) -> GeomResult<P>
595where
596    P: Copy + core::ops::Sub<Output = P>,
597{
598    let (i, j, _) = polyline_span(points.len(), closed, t).ok_or_else(|| {
599        GeomError::Degenerate(format!(
600            "polyline with {} points has no evaluable segment",
601            points.len()
602        ))
603    })?;
604    // Derivative w.r.t. the unit-per-segment parameter is the full edge vector.
605    Ok(points[j] - points[i])
606}
607
608// --- reusable de Boor core (shared with `crate::surface`) -------------------
609
610/// Locate the knot span for `u` in a validated flat knot vector.
611///
612/// Extracted from [`spline_span`] so a tensor-product surface can reuse the
613/// exact same span logic per axis. `n` is the control-point count, `d` the
614/// degree; the caller has already checked `knots.len() == n + d + 1`.
615pub(crate) fn span_in(knots: &[Scalar], n: usize, d: usize, u: Scalar) -> usize {
616    let mut span = d;
617    for (k, knot) in knots.iter().enumerate().take(n).skip(d) {
618        if *knot <= u {
619            span = k;
620        } else {
621            break;
622        }
623    }
624    span
625}
626
627/// One de Boor recurrence over homogeneous coordinates.
628///
629/// `points` holds the `d+1` premultiplied control points influencing `span`,
630/// `weights` their weights. Both are consumed in place. This is the numerical
631/// heart shared by curve and surface evaluation: keeping one copy means a fix
632/// to the recurrence cannot land in one and not the other.
633pub(crate) fn de_boor_recurrence<const N: usize>(
634    knots: &[Scalar],
635    span: usize,
636    d: usize,
637    u: Scalar,
638    points: &mut [[Scalar; N]],
639    weights: &mut [Scalar],
640) {
641    for r in 1..=d {
642        for j in (r..=d).rev() {
643            let i = span - d + j;
644            let denom = knots[i + d + 1 - r] - knots[i];
645            let alpha = if denom.abs() > 0.0 {
646                (u - knots[i]) / denom
647            } else {
648                0.0
649            };
650            let (prev, cur) = (points[j - 1], points[j]);
651            points[j] = core::array::from_fn(|k| prev[k] * (1.0 - alpha) + cur[k] * alpha);
652            weights[j] = weights[j - 1] * (1.0 - alpha) + weights[j] * alpha;
653        }
654    }
655}
656
657// --- de Boor ----------------------------------------------------------------
658
659/// Shared setup: validated flat knots, degree, and the knot span for `t`.
660fn spline_span<P>(b: &BSplineCurve<P>, t: Scalar) -> GeomResult<(Vec<Scalar>, usize, usize)> {
661    let axis = SplineAxis::new(
662        &b.knots,
663        &b.multiplicities,
664        b.degree,
665        b.control_points.len(),
666        "B-spline curve",
667    )?;
668    if let Some(weights) = &b.weights {
669        if weights.len() != b.control_points.len() {
670            return Err(GeomError::InvalidInput(format!(
671                "B-spline has {} weights for {} control points",
672                weights.len(),
673                b.control_points.len()
674            )));
675        }
676        if weights
677            .iter()
678            .any(|weight| !weight.is_finite() || *weight <= 0.0)
679        {
680            return Err(GeomError::InvalidInput(
681                "B-spline weights must be finite and strictly positive".to_owned(),
682            ));
683        }
684    }
685    let t = axis.clamp(t);
686    let span = span_in(&axis.knots, axis.count, axis.degree, t);
687    Ok((axis.knots, span, axis.degree))
688}
689
690/// Convert and validate every control point before selecting a knot span.
691/// Imported NaN/Inf coordinates must not be hidden in currently uninfluential
692/// spans and surface later when the parameter changes.
693fn finite_control_points<P, const N: usize, F>(
694    control_points: &[P],
695    to: &F,
696) -> GeomResult<Vec<[Scalar; N]>>
697where
698    F: Fn(&P) -> [Scalar; N],
699{
700    let points: Vec<[Scalar; N]> = control_points.iter().map(to).collect();
701    if points
702        .iter()
703        .flatten()
704        .any(|coordinate| !coordinate.is_finite())
705    {
706        return Err(GeomError::InvalidInput(
707            "B-spline control points must be finite".to_owned(),
708        ));
709    }
710    Ok(points)
711}
712
713/// Position via de Boor's algorithm.
714///
715/// `to` and `from` convert between the point type and a fixed-size coordinate
716/// array so 2D and 3D share one implementation. Rational curves are evaluated
717/// in homogeneous coordinates `(w*x, w*y, [w*z], w)` and projected at the end.
718fn de_boor<P, const N: usize, F, G, Q>(
719    b: &BSplineCurve<P>,
720    t: Scalar,
721    to: F,
722    from: G,
723) -> GeomResult<Q>
724where
725    F: Fn(&P) -> [Scalar; N],
726    G: Fn([Scalar; N]) -> Q,
727{
728    let (knots, span, d) = spline_span(b, t)?;
729    let control_points = finite_control_points(&b.control_points, &to)?;
730    let u = t.clamp(knots[d], knots[b.control_points.len()]);
731
732    // Working set: the d+1 control points influencing this span, in homogeneous
733    // form. The trailing slot holds the weight (1.0 for polynomial curves).
734    let mut work: Vec<[Scalar; N]> = Vec::with_capacity(d + 1);
735    let mut weights: Vec<Scalar> = Vec::with_capacity(d + 1);
736    for j in 0..=d {
737        let idx = span - d + j;
738        let w = b.weights.as_ref().map_or(1.0, |ws| ws[idx]);
739        let c = control_points[idx];
740        // Premultiply by w: interpolating in homogeneous space is what makes
741        // rational curves correct. Projecting first would be plain averaging.
742        let homogeneous = core::array::from_fn(|k| c[k] * w);
743        if homogeneous.iter().any(|value| !value.is_finite()) {
744            return Err(GeomError::Degenerate(
745                "B-spline homogeneous control point overflowed".to_owned(),
746            ));
747        }
748        work.push(homogeneous);
749        weights.push(w);
750    }
751
752    // A repeated knot makes an interval empty; the shared recurrence treats
753    // that as alpha = 0, which is the correct limit.
754    de_boor_recurrence(&knots, span, d, u, &mut work, &mut weights);
755
756    let w = weights[d];
757    if !w.is_finite() || w == 0.0 {
758        return Err(GeomError::Degenerate(
759            "B-spline weight collapsed to zero".to_owned(),
760        ));
761    }
762    Ok(from(core::array::from_fn(|k| work[d][k] / w)))
763}
764
765/// First derivative via the hodograph, with the quotient rule for rationals.
766///
767/// The derivative of a degree-`d` B-spline is a degree-`(d-1)` B-spline over
768/// the same knots minus their outermost entries, with control points
769/// `d * (P[i+1] - P[i]) / (knots[i+d+1] - knots[i+1])`.
770///
771/// For a rational curve `C = A/w`, both `A` and `w` are differentiated in
772/// homogeneous space and combined as `(A' - C * w') / w`.
773fn de_boor_derivative<P, const N: usize, F, G, Q>(
774    b: &BSplineCurve<P>,
775    t: Scalar,
776    to: F,
777    from: G,
778) -> GeomResult<Q>
779where
780    F: Fn(&P) -> [Scalar; N],
781    G: Fn([Scalar; N]) -> Q,
782{
783    let (knots, _, d) = spline_span(b, t)?;
784    let control_points = finite_control_points(&b.control_points, &to)?;
785    let n = b.control_points.len();
786    let u = t.clamp(knots[d], knots[n]);
787
788    // Homogeneous control points, weight in a parallel array.
789    let hom: Vec<[Scalar; N]> = (0..n)
790        .map(|i| {
791            let w = b.weights.as_ref().map_or(1.0, |ws| ws[i]);
792            let c = control_points[i];
793            core::array::from_fn(|k| c[k] * w)
794        })
795        .collect();
796    if hom.iter().flatten().any(|value| !value.is_finite()) {
797        return Err(GeomError::Degenerate(
798            "B-spline homogeneous control point overflowed".to_owned(),
799        ));
800    }
801    let hw: Vec<Scalar> = (0..n)
802        .map(|i| b.weights.as_ref().map_or(1.0, |ws| ws[i]))
803        .collect();
804
805    // Hodograph control points.
806    let mut dhom: Vec<[Scalar; N]> = Vec::with_capacity(n - 1);
807    let mut dhw: Vec<Scalar> = Vec::with_capacity(n - 1);
808    for i in 0..n - 1 {
809        let denom = knots[i + d + 1] - knots[i + 1];
810        let f = if denom.abs() > 0.0 {
811            d as Scalar / denom
812        } else {
813            0.0
814        };
815        dhom.push(core::array::from_fn(|k| (hom[i + 1][k] - hom[i][k]) * f));
816        dhw.push((hw[i + 1] - hw[i]) * f);
817    }
818
819    // Evaluate the hodograph at u with degree d-1 over the trimmed knots.
820    let dknots = &knots[1..knots.len() - 1];
821    let (da, dw) = eval_homogeneous(dknots, d - 1, &dhom, &dhw, u);
822    // Evaluate the curve itself for the quotient rule.
823    let (a, w) = eval_homogeneous(&knots, d, &hom, &hw, u);
824
825    if !w.is_finite() || w == 0.0 {
826        return Err(GeomError::Degenerate(
827            "B-spline weight collapsed to zero".to_owned(),
828        ));
829    }
830    // C = A/w  =>  C' = (A' - (A/w) * w') / w
831    Ok(from(core::array::from_fn(|k| {
832        (da[k] - (a[k] / w) * dw) / w
833    })))
834}
835
836/// Second derivative via two homogeneous hodograph constructions.
837///
838/// For `C = A / w`, the rational recurrence is
839/// `C'' = (A'' - 2 w' C' - w'' C) / w`.
840fn de_boor_second_derivative<P, const N: usize, F, G, Q>(
841    b: &BSplineCurve<P>,
842    t: Scalar,
843    to: F,
844    from: G,
845) -> GeomResult<Q>
846where
847    F: Fn(&P) -> [Scalar; N],
848    G: Fn([Scalar; N]) -> Q,
849{
850    let (knots, _, degree) = spline_span(b, t)?;
851    let control_points = finite_control_points(&b.control_points, &to)?;
852    let count = b.control_points.len();
853    let u = t.clamp(knots[degree], knots[count]);
854
855    let points: Vec<[Scalar; N]> = (0..count)
856        .map(|i| {
857            let weight = b.weights.as_ref().map_or(1.0, |weights| weights[i]);
858            core::array::from_fn(|axis| control_points[i][axis] * weight)
859        })
860        .collect();
861    if points.iter().flatten().any(|value| !value.is_finite()) {
862        return Err(GeomError::Degenerate(
863            "B-spline homogeneous control point overflowed".to_owned(),
864        ));
865    }
866    let weights: Vec<Scalar> = (0..count)
867        .map(|i| b.weights.as_ref().map_or(1.0, |values| values[i]))
868        .collect();
869
870    let (point, weight) = eval_homogeneous(&knots, degree, &points, &weights, u);
871    if !weight.is_finite() || weight == 0.0 {
872        return Err(GeomError::Degenerate(
873            "B-spline weight collapsed to zero".to_owned(),
874        ));
875    }
876
877    let (first_points, first_weights) = derivative_controls(&points, &weights, &knots, degree);
878    let first_knots = &knots[1..knots.len() - 1];
879    let (first, first_weight) =
880        eval_homogeneous(first_knots, degree - 1, &first_points, &first_weights, u);
881    let position: [Scalar; N] = core::array::from_fn(|axis| point[axis] / weight);
882    let first_projected: [Scalar; N] =
883        core::array::from_fn(|axis| (first[axis] - position[axis] * first_weight) / weight);
884
885    let (second, second_weight) = if degree == 1 {
886        ([0.0; N], 0.0)
887    } else {
888        let (second_points, second_weights) =
889            derivative_controls(&first_points, &first_weights, first_knots, degree - 1);
890        let second_knots = &first_knots[1..first_knots.len() - 1];
891        eval_homogeneous(second_knots, degree - 2, &second_points, &second_weights, u)
892    };
893    Ok(from(core::array::from_fn(|axis| {
894        (second[axis] - 2.0 * first_weight * first_projected[axis] - second_weight * position[axis])
895            / weight
896    })))
897}
898
899/// Derivative control polygon for one homogeneous B-spline axis.
900fn derivative_controls<const N: usize>(
901    points: &[[Scalar; N]],
902    weights: &[Scalar],
903    knots: &[Scalar],
904    degree: usize,
905) -> (Vec<[Scalar; N]>, Vec<Scalar>) {
906    let mut derivative_points = Vec::with_capacity(points.len() - 1);
907    let mut derivative_weights = Vec::with_capacity(weights.len() - 1);
908    for i in 0..points.len() - 1 {
909        let denominator = knots[i + degree + 1] - knots[i + 1];
910        let factor = if denominator.abs() > 0.0 {
911            degree as Scalar / denominator
912        } else {
913            0.0
914        };
915        derivative_points.push(core::array::from_fn(|axis| {
916            (points[i + 1][axis] - points[i][axis]) * factor
917        }));
918        derivative_weights.push((weights[i + 1] - weights[i]) * factor);
919    }
920    (derivative_points, derivative_weights)
921}
922
923/// de Boor over explicit homogeneous arrays; returns `(numerator, weight)`.
924pub(crate) fn eval_homogeneous<const N: usize>(
925    knots: &[Scalar],
926    d: usize,
927    hom: &[[Scalar; N]],
928    hw: &[Scalar],
929    u: Scalar,
930) -> ([Scalar; N], Scalar) {
931    let n = hom.len();
932    if d == 0 {
933        // Degree zero: piecewise constant, pick the containing span.
934        let mut idx = 0;
935        for (k, knot) in knots.iter().enumerate().take(n) {
936            if *knot <= u {
937                idx = k;
938            }
939        }
940        return (hom[idx.min(n - 1)], hw[idx.min(n - 1)]);
941    }
942    let mut span = d;
943    for (k, knot) in knots.iter().enumerate().take(n).skip(d) {
944        if *knot <= u {
945            span = k;
946        } else {
947            break;
948        }
949    }
950    let mut work: Vec<[Scalar; N]> = (0..=d).map(|j| hom[span - d + j]).collect();
951    let mut weights: Vec<Scalar> = (0..=d).map(|j| hw[span - d + j]).collect();
952    for r in 1..=d {
953        for j in (r..=d).rev() {
954            let i = span - d + j;
955            let denom = knots[i + d + 1 - r] - knots[i];
956            let alpha = if denom.abs() > 0.0 {
957                (u - knots[i]) / denom
958            } else {
959                0.0
960            };
961            let (prev, cur) = (work[j - 1], work[j]);
962            work[j] = core::array::from_fn(|k| prev[k] * (1.0 - alpha) + cur[k] * alpha);
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            crate::bound::certifies_flattening2(curve),
1050            &mut out,
1051        )?;
1052        out.push(evaluate2(curve, b)?);
1053    }
1054    Ok(out)
1055}
1056
1057/// `domain` cut at the breaks strictly inside it, in its own direction.
1058///
1059/// A chord across a knot where the curve is not `C^1` cannot be certified
1060/// (#232): the curve may turn a corner there. Cutting at the knot keeps the
1061/// corner as an exact vertex and leaves smooth pieces to bound.
1062fn smooth_pieces(domain: Interval, breaks: &[Scalar]) -> Vec<(Scalar, Scalar)> {
1063    let (lo, hi) = if domain.start <= domain.end {
1064        (domain.start, domain.end)
1065    } else {
1066        (domain.end, domain.start)
1067    };
1068    let mut cuts: Vec<Scalar> = breaks
1069        .iter()
1070        .copied()
1071        .filter(|&t| t > lo && t < hi)
1072        .collect();
1073    cuts.sort_by(Scalar::total_cmp);
1074    cuts.dedup();
1075    if domain.start > domain.end {
1076        cuts.reverse();
1077    }
1078    let mut out = Vec::with_capacity(cuts.len() + 1);
1079    let mut from = domain.start;
1080    for cut in cuts {
1081        out.push((from, cut));
1082        from = cut;
1083    }
1084    out.push((from, domain.end));
1085    out
1086}
1087
1088/// Hard ceiling on recursion depth regardless of what a caller asks for.
1089///
1090/// 2^20 segments is already far past any usable tolerance; beyond this a
1091/// request is a bug, not a quality setting.
1092const MAX_DEPTH_CEILING: u32 = 20;
1093
1094/// Emit interior points of `(a, b)` that are needed to meet the tolerance.
1095///
1096/// `budget` bounds total emitted points. Exceeding it is an error rather than
1097/// a truncation: silently returning a coarser polyline than the caller asked
1098/// for would violate the tolerance contract this function exists to honour.
1099/// The vector operations chord subdivision needs, in either dimension.
1100///
1101/// `Point2` and `Point3` are both glam vectors with the same surface, and
1102/// the subdivision below is genuinely dimension-independent: it measures a
1103/// sagitta and bisects a parameter interval, neither of which mentions a
1104/// coordinate count. This trait states that once instead of maintaining
1105/// two copies that can drift apart.
1106trait ChordPoint: Copy {
1107    fn sub(self, other: Self) -> Self;
1108    fn add_scaled(self, direction: Self, scale: Scalar) -> Self;
1109    fn dot(self, other: Self) -> Scalar;
1110    fn length(self) -> Scalar;
1111    fn length_squared(self) -> Scalar;
1112}
1113
1114impl ChordPoint for Point2 {
1115    fn sub(self, other: Self) -> Self {
1116        self - other
1117    }
1118    fn add_scaled(self, direction: Self, scale: Scalar) -> Self {
1119        self + direction * scale
1120    }
1121    fn dot(self, other: Self) -> Scalar {
1122        Point2::dot(self, other)
1123    }
1124    fn length(self) -> Scalar {
1125        Point2::length(self)
1126    }
1127    fn length_squared(self) -> Scalar {
1128        Point2::length_squared(self)
1129    }
1130}
1131
1132impl ChordPoint for Point3 {
1133    fn sub(self, other: Self) -> Self {
1134        self - other
1135    }
1136    fn add_scaled(self, direction: Self, scale: Scalar) -> Self {
1137        self + direction * scale
1138    }
1139    fn dot(self, other: Self) -> Scalar {
1140        Point3::dot(self, other)
1141    }
1142    fn length(self) -> Scalar {
1143        Point3::length(self)
1144    }
1145    fn length_squared(self) -> Scalar {
1146        Point3::length_squared(self)
1147    }
1148}
1149
1150/// Perpendicular distance from `m` to the chord `a`-`b`.
1151fn sagitta<P: ChordPoint>(a: P, b: P, m: P) -> Scalar {
1152    let ab = b.sub(a);
1153    let len2 = ab.length_squared();
1154    if len2 <= 0.0 {
1155        // Degenerate chord: fall back to point distance so a closed curve
1156        // whose endpoints coincide still subdivides.
1157        return m.sub(a).length();
1158    }
1159    let t = (m.sub(a).dot(ab) / len2).clamp(0.0, 1.0);
1160    m.sub(a.add_scaled(ab, t)).length()
1161}
1162
1163/// Emit the interior points of `(a, b)` needed to meet `tol`.
1164///
1165/// Shared by both dimensions; the caller supplies the evaluator. Depth
1166/// exhaustion is an error rather than a truncation, matching the 2D
1167/// contract: silently returning a coarser polyline than asked for would
1168/// break the tolerance guarantee the caller is relying on.
1169///
1170/// A span is accepted when its midpoint sagitta is within `tol` AND, for a
1171/// family [`crate::bound`] can bound, its certified chord bound is too
1172/// (#232). The midpoint alone is a measurement: an ellipse or a spline can
1173/// cross its chord at the midpoint and bulge either side of it. A family
1174/// with no bound (`bound` returns `None`) keeps the sagitta test alone,
1175/// which is why [`crate::bound::certifies_flattening2`] names the families
1176/// whose output is certified.
1177#[allow(clippy::too_many_arguments)]
1178fn subdivide<P, F, B>(
1179    eval: &F,
1180    bound: &B,
1181    a: Scalar,
1182    b: Scalar,
1183    tol: Scalar,
1184    depth: u32,
1185    budget: usize,
1186    claimed: bool,
1187    out: &mut Vec<P>,
1188) -> GeomResult<()>
1189where
1190    P: ChordPoint,
1191    F: Fn(Scalar) -> GeomResult<P>,
1192    B: Fn(Scalar, Scalar) -> Option<Scalar>,
1193{
1194    if out.len() >= budget {
1195        return Err(GeomError::Degenerate(format!(
1196            "curve flattening exceeded {budget} points before meeting the \
1197             chord tolerance {tol}; the curve may be degenerate"
1198        )));
1199    }
1200    let mid = 0.5 * (a + b);
1201    let pa = eval(a)?;
1202    let pb = eval(b)?;
1203    // A parameter interval too small to bisect cannot be refined further:
1204    // `mid` equals `a` or `b` in floating point. Returning the chord anyway
1205    // would hand back an unverified approximation, so this fails closed --
1206    // unless the chord already meets the tolerance, in which case there was
1207    // nothing left to verify. A claimed family also needs its bound there:
1208    // the evaluated points of a point span can be far off the exact ones
1209    // (#438), and a missing bound is not a verified chord.
1210    if !(mid > a && mid < b) {
1211        let certified = !claimed || bound(a, b).is_some_and(|d| d <= tol);
1212        if sagitta(pa, pb, pa) <= tol && (pb.sub(pa)).length() <= tol && certified {
1213            return Ok(());
1214        }
1215        return Err(GeomError::Degenerate(format!(
1216            "curve parameter interval ({a}, {b}) is too small to bisect but \
1217             its chord still exceeds the tolerance {tol}"
1218        )));
1219    }
1220    let pm = eval(mid)?;
1221    // A span of a claimed family whose bound is `None` is not accepted on
1222    // its sagitta: a claim means the bound exists wherever the curve
1223    // evaluates, so a missing one is a span to refine, and at the depth
1224    // limit a refusal (#438).
1225    let certified = match bound(a, b) {
1226        Some(certified) => certified <= tol,
1227        None => !claimed,
1228    };
1229    if sagitta(pa, pb, pm) <= tol && certified {
1230        // Within tolerance: the chord a->b stands, no interior point.
1231        return Ok(());
1232    }
1233    if depth == 0 {
1234        return Err(GeomError::BudgetExceeded {
1235            resource: "curve flattening depth",
1236        });
1237    }
1238    subdivide(eval, bound, a, mid, tol, depth - 1, budget, claimed, out)?;
1239    out.push(pm);
1240    subdivide(eval, bound, mid, b, tol, depth - 1, budget, claimed, out)?;
1241    Ok(())
1242}
1243
1244/// Polylines flatten to their own breakpoints, restricted to `domain`.
1245fn polyline_flatten<P, F>(
1246    points: &[P],
1247    closed: bool,
1248    domain: Interval,
1249    eval: F,
1250) -> GeomResult<Vec<P>>
1251where
1252    P: Copy,
1253    F: Fn(Scalar) -> GeomResult<P>,
1254{
1255    let segments = if closed {
1256        points.len()
1257    } else {
1258        points.len().saturating_sub(1)
1259    };
1260    if segments == 0 {
1261        return Err(GeomError::Degenerate(
1262            "polyline has no evaluable segment".to_owned(),
1263        ));
1264    }
1265    let lo = domain.start.min(domain.end);
1266    let hi = domain.start.max(domain.end);
1267    let mut out = vec![eval(lo)?];
1268    // Interior breakpoints are the integer parameters strictly inside.
1269    let first = lo.floor() as i64 + 1;
1270    let last = hi.ceil() as i64 - 1;
1271    for k in first..=last {
1272        let t = k as Scalar;
1273        if t > lo && t < hi {
1274            out.push(eval(t)?);
1275        }
1276    }
1277    out.push(eval(hi)?);
1278    Ok(out)
1279}
1280
1281// --- trait wiring -----------------------------------------------------------
1282
1283impl CurveEvaluator<Curve2> for ScalarCurve {
1284    type Point = Point2;
1285    type Derivative = Vec2;
1286    type Error = GeomError;
1287
1288    fn domain(&self, curve: &Curve2) -> Interval {
1289        domain2(curve)
1290    }
1291
1292    fn evaluate(
1293        &self,
1294        curve: &Curve2,
1295        t: Scalar,
1296        _tolerance: Tolerance,
1297    ) -> Result<Self::Point, Self::Error> {
1298        evaluate2(curve, t)
1299    }
1300
1301    fn derivative(
1302        &self,
1303        curve: &Curve2,
1304        t: Scalar,
1305        _tolerance: Tolerance,
1306    ) -> Result<Self::Derivative, Self::Error> {
1307        derivative2(curve, t)
1308    }
1309}
1310
1311impl CurveEvaluator<Curve3> for ScalarCurve {
1312    type Point = Point3;
1313    type Derivative = Vec3;
1314    type Error = GeomError;
1315
1316    fn domain(&self, curve: &Curve3) -> Interval {
1317        domain3(curve)
1318    }
1319
1320    fn evaluate(
1321        &self,
1322        curve: &Curve3,
1323        t: Scalar,
1324        _tolerance: Tolerance,
1325    ) -> Result<Self::Point, Self::Error> {
1326        evaluate3(curve, t)
1327    }
1328
1329    fn derivative(
1330        &self,
1331        curve: &Curve3,
1332        t: Scalar,
1333        _tolerance: Tolerance,
1334    ) -> Result<Self::Derivative, Self::Error> {
1335        derivative3(curve, t)
1336    }
1337}
1338
1339// Silence unused-import warnings for types only named in signatures.
1340#[allow(unused)]
1341fn _type_anchors(_: Circle2, _: Circle3, _: Ellipse2, _: Ellipse3, _: Line2, _: Line3) {}
1342#[allow(unused)]
1343fn _poly_anchors(_: Polyline2, _: Polyline3) {}
1344
1345/// Flatten a 3D curve to a polyline within `chord_tolerance`.
1346///
1347/// The 3D twin of [`flatten2`], sharing its subdivision, its resource
1348/// bounds and its polyline contract. Sampling is adaptive: a curve is
1349/// bisected only where the chord actually departs from it, so a gentle
1350/// arc costs few points and a tight one costs many, and neither is
1351/// decided by a fixed count chosen in advance.
1352pub fn flatten3(
1353    curve: &Curve3,
1354    domain: Interval,
1355    chord_tolerance: Scalar,
1356    max_depth: u32,
1357) -> GeomResult<Vec<Point3>> {
1358    // A depth bound alone is not a resource bound: depth `d` permits `2^d`
1359    // segments. Cap the total point count too, so a curve that cannot meet
1360    // the tolerance fails fast instead of exhausting memory.
1361    const MAX_POINTS: usize = 1 << 16;
1362    if !(chord_tolerance.is_finite()
1363        && chord_tolerance.is_sign_positive()
1364        && chord_tolerance != 0.0)
1365    {
1366        return Err(GeomError::InvalidInput(format!(
1367            "chord tolerance must be positive and finite, got {chord_tolerance}"
1368        )));
1369    }
1370    // A line is exact between its endpoints: subdividing adds vertices that
1371    // carry no information.
1372    if let Curve3::Line(_) = curve {
1373        return Ok(vec![
1374            evaluate3(curve, domain.start)?,
1375            evaluate3(curve, domain.end)?,
1376        ]);
1377    }
1378    if let Curve3::Polyline(p) = curve {
1379        // A polyline's parameter is one unit per segment, so a caller passing
1380        // a normalized `(0, 1)` domain would silently collapse an n-vertex
1381        // path to its first edge. That is data loss disguised as success.
1382        let natural = polyline_domain(p.points.len(), p.closed);
1383        let requested = (domain.end - domain.start).abs();
1384        if natural.end > 1.0 && requested <= 1.0 {
1385            return Err(GeomError::InvalidInput(format!(
1386                "polyline domain {:?} spans {requested} of {} segments; a \
1387                 polyline parameter is one unit per segment, so this would \
1388                 discard {} vertices",
1389                domain,
1390                natural.end,
1391                p.points.len().saturating_sub(2)
1392            )));
1393        }
1394        return polyline_flatten(&p.points, p.closed, domain, |t| evaluate3(curve, t));
1395    }
1396
1397    let eval = |t| evaluate3(curve, t);
1398    let bound = |a, b| crate::bound::chord_bound3(curve, a, b);
1399    let mut out = vec![eval(domain.start)?];
1400    for (a, b) in smooth_pieces(domain, &crate::bound::continuity_breaks3(curve, 1)) {
1401        subdivide(
1402            &eval,
1403            &bound,
1404            a,
1405            b,
1406            chord_tolerance,
1407            max_depth.min(MAX_DEPTH_CEILING),
1408            MAX_POINTS,
1409            crate::bound::certifies_flattening3(curve),
1410            &mut out,
1411        )?;
1412        out.push(eval(b)?);
1413    }
1414    Ok(out)
1415}
1416
1417/// Refuse a family that has no closed-form inversion.
1418///
1419/// Named separately from `unsupported_family` so the message can say WHY:
1420/// the family is understood, its inversion simply is not algebraic.
1421fn no_closed_form_inversion() -> GeomError {
1422    GeomError::InvalidInput(
1423        "curve family has no closed form inversion; a point trim on this basis \
1424         would require iteration, which trim resolution does not perform"
1425            .to_owned(),
1426    )
1427}
1428
1429/// The point is not on the curve, so no parameter names it.
1430fn point_not_on_curve(distance: Scalar, tolerance: Scalar) -> GeomError {
1431    GeomError::InvalidInput(format!(
1432        "point is {distance} from the curve, outside the {tolerance} tolerance; \
1433         refusing rather than projecting it onto the nearest parameter"
1434    ))
1435}
1436
1437/// Parameter of `point` on `line`, or a refusal when it is off the line.
1438///
1439/// The direction need not be unit length, so the projection is normalised by
1440/// its squared length: that is what makes the returned value a parameter of
1441/// THIS line rather than an arc length.
1442fn invert_line(
1443    origin: impl Into<[Scalar; 3]>,
1444    direction: [Scalar; 3],
1445    point: [Scalar; 3],
1446    tolerance: Scalar,
1447) -> GeomResult<Scalar> {
1448    let origin = origin.into();
1449    let dd = direction.iter().map(|c| c * c).sum::<Scalar>();
1450    if !dd.is_finite() || dd <= Scalar::EPSILON {
1451        return Err(GeomError::InvalidInput(
1452            "line direction is degenerate, so no parameter names a point".to_owned(),
1453        ));
1454    }
1455    let offset = [
1456        point[0] - origin[0],
1457        point[1] - origin[1],
1458        point[2] - origin[2],
1459    ];
1460    let t = offset
1461        .iter()
1462        .zip(direction.iter())
1463        .map(|(o, d)| o * d)
1464        .sum::<Scalar>()
1465        / dd;
1466    // Verify rather than assume: the projection always yields a parameter, but
1467    // only a point actually ON the line is named by it.
1468    let residual = [
1469        offset[0] - direction[0] * t,
1470        offset[1] - direction[1] * t,
1471        offset[2] - direction[2] * t,
1472    ];
1473    let distance = residual.iter().map(|c| c * c).sum::<Scalar>().sqrt();
1474    if distance > tolerance {
1475        return Err(point_not_on_curve(distance, tolerance));
1476    }
1477    Ok(t)
1478}
1479
1480/// Parametric angle of `point` about a conic frame, verified against the curve.
1481///
1482/// For a circle the parametric angle is the polar angle; for an ellipse it is
1483/// not, so the local coordinates are divided by their semi-axes BEFORE the
1484/// arctangent. Taking the polar angle directly would be wrong off-axis.
1485fn invert_conic(
1486    local_x: Scalar,
1487    local_y: Scalar,
1488    semi_x: Scalar,
1489    semi_y: Scalar,
1490) -> GeomResult<Scalar> {
1491    if !(semi_x.is_finite() && semi_y.is_finite()) || semi_x <= 0.0 || semi_y <= 0.0 {
1492        return Err(GeomError::InvalidInput(
1493            "conic semi-axes must be finite and positive to invert a point".to_owned(),
1494        ));
1495    }
1496    let angle = (local_y / semi_y).atan2(local_x / semi_x);
1497    if !angle.is_finite() {
1498        return Err(GeomError::InvalidInput(
1499            "conic inversion produced a non-finite angle".to_owned(),
1500        ));
1501    }
1502    // Report on the same domain evaluation uses, so invert then evaluate is a
1503    // round trip rather than an off-by-one-turn surprise.
1504    Ok(angle.rem_euclid(std::f64::consts::TAU))
1505}
1506
1507/// Parameter naming `point` on a 2D curve, or a refusal.
1508///
1509/// Exact for the families whose inversion is algebraic. Anything else is
1510/// refused by name: introducing iteration here would put a tolerance and a
1511/// convergence failure mode into every consumer of a point trim, and the
1512/// certified iterative path belongs to a caller that can carry its evidence.
1513///
1514/// # Errors
1515///
1516/// Refuses a point further than `tolerance` from the curve rather than
1517/// projecting it, and refuses families with no closed-form inversion.
1518pub fn invert2(curve: &Curve2, point: Point2, tolerance: Tolerance) -> GeomResult<Scalar> {
1519    finite2(point, "inversion point")?;
1520    let linear = tolerance.linear();
1521    match curve {
1522        Curve2::Line(l) => invert_line(
1523            [l.origin.x, l.origin.y, 0.0],
1524            [l.direction.x, l.direction.y, 0.0],
1525            [point.x, point.y, 0.0],
1526            linear,
1527        ),
1528        Curve2::Circle(c) => {
1529            let t = invert_conic_in_frame2(&c.frame, point, c.radius, c.radius)?;
1530            verify2(curve, t, point, linear)
1531        }
1532        Curve2::Ellipse(e) => {
1533            let t = invert_conic_in_frame2(&e.frame, point, e.semi_axis_x, e.semi_axis_y)?;
1534            verify2(curve, t, point, linear)
1535        }
1536        // A graph over its parameter: the parameter of a point IS its first
1537        // coordinate, then the height is checked.
1538        Curve2::Sinusoid(_) | Curve2::QuadraticGraph(_) => verify2(curve, point.x, point, linear),
1539        // A graph over its second coordinate; the angle it returns lies in
1540        // `(-pi, pi]`, so a point given a whole turn away is read there.
1541        Curve2::AngleGraph(g) => {
1542            let u = g.angle(point.y).ok_or_else(|| outside_graph(point.y))?;
1543            let turns = ((point.x - u) / std::f64::consts::TAU).round();
1544            let shifted = Point2::new(point.x - turns * std::f64::consts::TAU, point.y);
1545            verify2(curve, point.y, shifted, linear)
1546        }
1547        // The space curve's parameter of the point lifted onto the carrier.
1548        Curve2::Lifted(l) => {
1549            let lifted = l.carrier.jet(point.x, point.y).point;
1550            let t = invert3(&l.curve, lifted, tolerance)?;
1551            verify2(curve, t, point, linear)
1552        }
1553        // The cell whose box holds the point; its free value places it.
1554        Curve2::Implicit(c) => {
1555            let t = c
1556                .parameter_of(point)
1557                .ok_or_else(|| point_not_on_curve(Scalar::INFINITY, linear))?;
1558            verify2(curve, t, point, linear)
1559        }
1560        _ => Err(no_closed_form_inversion()),
1561    }
1562}
1563
1564/// Project a point into a 2D conic frame and invert it there.
1565fn invert_conic_in_frame2(
1566    frame: &axiolid_core::Frame2,
1567    point: Point2,
1568    semi_x: Scalar,
1569    semi_y: Scalar,
1570) -> GeomResult<Scalar> {
1571    let offset = point - frame.origin;
1572    invert_conic(offset.dot(frame.x), offset.dot(frame.y), semi_x, semi_y)
1573}
1574
1575/// Confirm the recovered parameter actually reproduces the point.
1576///
1577/// The algebra above assumes an orthonormal frame. Imported frames are not
1578/// always orthonormal, and this crate deliberately keeps dirty frames
1579/// representable, so the claim is checked against the real evaluator instead
1580/// of trusted.
1581fn verify2(curve: &Curve2, t: Scalar, point: Point2, tolerance: Scalar) -> GeomResult<Scalar> {
1582    let found = evaluate2(curve, t)?;
1583    let distance = (found - point).length();
1584    if distance > tolerance {
1585        return Err(point_not_on_curve(distance, tolerance));
1586    }
1587    Ok(t)
1588}
1589
1590/// Parameter naming `point` on a 3D curve, or a refusal.
1591///
1592/// See [`invert2`] for the exactness policy.
1593///
1594/// # Errors
1595///
1596/// Refuses an off-curve point and any family without a closed-form inversion.
1597pub fn invert3(curve: &Curve3, point: Point3, tolerance: Tolerance) -> GeomResult<Scalar> {
1598    finite3(point, "inversion point")?;
1599    let linear = tolerance.linear();
1600    match curve {
1601        Curve3::Line(l) => invert_line(
1602            [l.origin.x, l.origin.y, l.origin.z],
1603            [l.direction.x, l.direction.y, l.direction.z],
1604            [point.x, point.y, point.z],
1605            linear,
1606        ),
1607        Curve3::Circle(c) => {
1608            let t = invert_conic_in_frame3(&c.frame, point, c.radius, c.radius)?;
1609            verify3(curve, t, point, linear)
1610        }
1611        Curve3::Ellipse(e) => {
1612            let t = invert_conic_in_frame3(&e.frame, point, e.semi_axis_x, e.semi_axis_y)?;
1613            verify3(curve, t, point, linear)
1614        }
1615        // Graphs over the carrier angle: the parameter is that angle, read
1616        // off the point, up to whole turns (ADR 0076).
1617        Curve3::RuledSection(r) => {
1618            let carrier = axiolid_curve::Carrier::Ruled(r.carrier);
1619            let (u, _) = carrier.parameters(point);
1620            first_on_curve(curve, &turns_of(u), point, linear)
1621        }
1622        Curve3::TorusSection(r) => {
1623            let carrier = axiolid_curve::Carrier::Torus(r.torus);
1624            let (_, v) = carrier.parameters(point);
1625            first_on_curve(curve, &turns_of(v), point, linear)
1626        }
1627        // The nearest chord, then the curve itself (ADR 0077).
1628        Curve3::PairSection(r) => {
1629            let t = r
1630                .parameter_of(point)
1631                .ok_or_else(|| point_not_on_curve(Scalar::INFINITY, linear))?;
1632            verify3(curve, t, point, linear)
1633        }
1634        // The carrier's parameters of the point, over whole turns, located
1635        // in the curve's cells (ADR 0077).
1636        Curve3::ImplicitSection(r) => {
1637            // A B-spline carrier has no closed-form inverse: the surface is
1638            // inverted, then the cell located.
1639            if let axiolid_curve::Carrier::Spline(b) = &r.carrier {
1640                let surface = axiolid_surface::Surface::BSpline((**b).clone());
1641                let (u, v) = crate::surface::locate(&surface, point, tolerance)?;
1642                let t = r
1643                    .curve
1644                    .parameter_of(Point2::new(u, v))
1645                    .ok_or_else(|| point_not_on_curve(Scalar::INFINITY, linear))?;
1646                return verify3(curve, t, point, linear);
1647            }
1648            let (u, v) = r.carrier.parameters(point);
1649            let (pu, pv) = r.carrier.periodic();
1650            let turns = |periodic: bool| -> &'static [Scalar] {
1651                if periodic {
1652                    &[0.0, 1.0, -1.0, 2.0, -2.0]
1653                } else {
1654                    &[0.0]
1655                }
1656            };
1657            let mut candidates = Vec::new();
1658            for &ku in turns(pu) {
1659                for &kv in turns(pv) {
1660                    let tau = std::f64::consts::TAU;
1661                    if let Some(t) = r
1662                        .curve
1663                        .parameter_of(Point2::new(u + ku * tau, v + kv * tau))
1664                    {
1665                        candidates.push(t);
1666                    }
1667                }
1668            }
1669            first_on_curve(curve, &candidates, point, linear)
1670        }
1671        _ => Err(no_closed_form_inversion()),
1672    }
1673}
1674
1675/// An angle and its neighbours a whole turn or two away.
1676fn turns_of(angle: Scalar) -> Vec<Scalar> {
1677    let tau = std::f64::consts::TAU;
1678    vec![
1679        angle,
1680        angle + tau,
1681        angle - tau,
1682        angle + 2.0 * tau,
1683        angle - 2.0 * tau,
1684    ]
1685}
1686
1687/// The first candidate parameter that reproduces the point.
1688fn first_on_curve(
1689    curve: &Curve3,
1690    candidates: &[Scalar],
1691    point: Point3,
1692    tolerance: Scalar,
1693) -> GeomResult<Scalar> {
1694    let mut nearest = Scalar::INFINITY;
1695    for &t in candidates {
1696        if let Ok(found) = evaluate3(curve, t) {
1697            let distance = (found - point).length();
1698            if distance <= tolerance {
1699                return Ok(t);
1700            }
1701            nearest = nearest.min(distance);
1702        }
1703    }
1704    Err(point_not_on_curve(nearest, tolerance))
1705}
1706
1707/// Parameter of a point on a 3D curve, iterating where no closed form
1708/// exists: [`invert3`] first, and for a B-spline the nearest of 64 samples
1709/// per span refined by Newton on `|C(t) - p|^2`. The answer must reproduce
1710/// the point within `tolerance`, as [`invert3`]'s must.
1711///
1712/// # Errors
1713///
1714/// The point is not on the curve, or the family cannot be evaluated.
1715pub fn locate3(curve: &Curve3, point: Point3, tolerance: Tolerance) -> GeomResult<Scalar> {
1716    match invert3(curve, point, tolerance) {
1717        Ok(t) => Ok(t),
1718        Err(error) => match curve {
1719            Curve3::BSpline(b) => {
1720                let domain = spline_domain(b);
1721                let t = nearest_parameter(
1722                    domain,
1723                    b.control_points.len().max(2) * 64,
1724                    |t| evaluate3(curve, t).map(|p| (p - point).length()),
1725                    |t| {
1726                        let (p, d, dd) = (
1727                            evaluate3(curve, t)?,
1728                            derivative3(curve, t)?,
1729                            second_derivative3(curve, t)?,
1730                        );
1731                        let r = p - point;
1732                        Ok((r.dot(d), d.dot(d) + r.dot(dd)))
1733                    },
1734                )?;
1735                verify3(curve, t, point, tolerance.linear())
1736            }
1737            _ => Err(error),
1738        },
1739    }
1740}
1741
1742/// Parameter of a point on a 2D curve, iterating where no closed form
1743/// exists. See [`locate3`].
1744///
1745/// # Errors
1746///
1747/// The point is not on the curve, or the family cannot be evaluated.
1748pub fn locate2(curve: &Curve2, point: Point2, tolerance: Tolerance) -> GeomResult<Scalar> {
1749    match invert2(curve, point, tolerance) {
1750        Ok(t) => Ok(t),
1751        Err(error) => match curve {
1752            Curve2::BSpline(b) => {
1753                let domain = spline_domain(b);
1754                let t = nearest_parameter(
1755                    domain,
1756                    b.control_points.len().max(2) * 64,
1757                    |t| evaluate2(curve, t).map(|p| (p - point).length()),
1758                    |t| {
1759                        let (p, d, dd) = (
1760                            evaluate2(curve, t)?,
1761                            derivative2(curve, t)?,
1762                            second_derivative2(curve, t)?,
1763                        );
1764                        let r = p - point;
1765                        Ok((r.dot(d), d.dot(d) + r.dot(dd)))
1766                    },
1767                )?;
1768                verify2(curve, t, point, tolerance.linear())
1769            }
1770            _ => Err(error),
1771        },
1772    }
1773}
1774
1775/// The parameter in `domain` nearest the target: the best of `samples`
1776/// evenly spaced values, then Newton on the distance's derivative
1777/// (`gradient` returns it and its derivative), kept inside the domain.
1778fn nearest_parameter(
1779    domain: Interval,
1780    samples: usize,
1781    distance: impl Fn(Scalar) -> GeomResult<Scalar>,
1782    gradient: impl Fn(Scalar) -> GeomResult<(Scalar, Scalar)>,
1783) -> GeomResult<Scalar> {
1784    let (lo, hi) = (domain.start.min(domain.end), domain.start.max(domain.end));
1785    let mut best = (Scalar::INFINITY, lo);
1786    for i in 0..=samples {
1787        let t = lo + (hi - lo) * i as Scalar / samples as Scalar;
1788        let d = distance(t)?;
1789        if d < best.0 {
1790            best = (d, t);
1791        }
1792    }
1793    let mut t = best.1;
1794    for _ in 0..60 {
1795        let (g, h) = gradient(t)?;
1796        if h <= 0.0 || !h.is_finite() {
1797            break;
1798        }
1799        let next = (t - g / h).clamp(lo, hi);
1800        let moved = (next - t).abs();
1801        t = next;
1802        if moved <= 4.0 * Scalar::EPSILON * (1.0 + t.abs()) {
1803            break;
1804        }
1805    }
1806    Ok(t)
1807}
1808
1809/// Project a point into a 3D conic frame and invert it there.
1810fn invert_conic_in_frame3(
1811    frame: &axiolid_core::Frame3,
1812    point: Point3,
1813    semi_x: Scalar,
1814    semi_y: Scalar,
1815) -> GeomResult<Scalar> {
1816    let offset = point - frame.origin;
1817    invert_conic(offset.dot(frame.x), offset.dot(frame.y), semi_x, semi_y)
1818}
1819
1820/// Confirm the recovered parameter reproduces the point. See [`verify2`].
1821fn verify3(curve: &Curve3, t: Scalar, point: Point3, tolerance: Scalar) -> GeomResult<Scalar> {
1822    let found = evaluate3(curve, t)?;
1823    let distance = (found - point).length();
1824    if distance > tolerance {
1825        return Err(point_not_on_curve(distance, tolerance));
1826    }
1827    Ok(t)
1828}
1829
1830#[cfg(test)]
1831mod tests {
1832    use super::*;
1833
1834    /// A span with no chord bound passes on its sagitta alone for a family
1835    /// that makes no claim, and is refined, then refused at the depth
1836    /// limit, for a claimed one (#438).
1837    #[test]
1838    fn a_claimed_family_never_passes_a_span_without_a_bound() {
1839        let eval = |t: Scalar| -> GeomResult<Point2> { Ok(Point2::new(t, 0.0)) };
1840        let none = |_: Scalar, _: Scalar| None;
1841        let mut out = Vec::new();
1842        subdivide(&eval, &none, 0.0, 1.0, 1e-3, 6, 1 << 10, false, &mut out).unwrap();
1843        assert!(out.is_empty());
1844        let refused = subdivide(&eval, &none, 0.0, 1.0, 1e-3, 6, 1 << 10, true, &mut out);
1845        assert!(matches!(refused, Err(GeomError::BudgetExceeded { .. })));
1846        // A bound that exists decides, claimed or not.
1847        let some = |_: Scalar, _: Scalar| Some(1e-4);
1848        subdivide(&eval, &some, 0.0, 1.0, 1e-3, 6, 1 << 10, true, &mut out).unwrap();
1849        let loose = |_: Scalar, _: Scalar| Some(1.0);
1850        let refused = subdivide(&eval, &loose, 0.0, 1.0, 1e-3, 6, 1 << 10, true, &mut out);
1851        assert!(matches!(refused, Err(GeomError::BudgetExceeded { .. })));
1852        // A span too small to bisect is not accepted without its bound
1853        // either, when the family is claimed.
1854        let mut point = Vec::new();
1855        subdivide(&eval, &none, 1.0, 1.0, 1e-3, 6, 1 << 10, false, &mut point).unwrap();
1856        let refused = subdivide(&eval, &none, 1.0, 1.0, 1e-3, 6, 1 << 10, true, &mut point);
1857        assert!(matches!(refused, Err(GeomError::Degenerate(_))));
1858        let refused = subdivide(&eval, &loose, 1.0, 1.0, 1e-3, 6, 1 << 10, true, &mut point);
1859        assert!(matches!(refused, Err(GeomError::Degenerate(_))));
1860        subdivide(&eval, &some, 1.0, 1.0, 1e-3, 6, 1 << 10, true, &mut point).unwrap();
1861    }
1862}