Skip to main content

ogeom_intersect/
walk.rs

1//! One walker, several conditions.
2//!
3//! Following a curve nobody can write down is the same problem every time.
4//! Surface intersection tracks *on both surfaces*; a silhouette tracks *the
5//! normal is square to the view*; a rolling-ball blend tracks *the ball
6//! touches both supports and its section stands where the guide says*. The
7//! conditions are different and the geometry is different, but the walk is
8//! not: take a step along the curve's own direction, correct back onto the
9//! condition, measure how far the chord sagged, and set the next step from
10//! that.
11//!
12//! So the walk lives here once, over a [`Condition`], and what changes per
13//! problem is the condition's own residual and derivatives. The step control,
14//! the stall reporting and the closure test are written once and inherited.
15//!
16//! # What a condition owes the walker
17//!
18//! `n` unknowns and `n − 1` equations. That shortfall is not an oversight: the
19//! solution set of `n − 1` equations in `n` unknowns *is* a curve, which is
20//! what there is to follow. The walker supplies the missing equation itself
21//! (a plane across the direction of travel, saying how far along to land), and
22//! that is what turns "somewhere on the curve" into "the next point".
23//!
24//! The direction of travel comes free. The curve's tangent in parameter space
25//! is the null vector of the condition's own Jacobian, and a condition that
26//! has a cheaper or more careful formula for it (the intersector does, and
27//! uses it to refuse a crossing too shallow to trust) says so by overriding
28//! [`Condition::tangent`].
29
30use crate::march::{Marching, Stopped};
31use ogeom_core::{OgeomResult, Tolerances};
32use ogeom_math::{Point, Vector, solve};
33use smallvec::SmallVec;
34
35/// A curve stated as what it satisfies, and everything needed to follow it.
36///
37/// The parameter vector is whatever the condition is posed in: four numbers
38/// for a surface pair, two for a silhouette, five for a blend section marching
39/// a guide. The walker never interprets them.
40pub trait Condition {
41    /// How many unknowns the condition is posed in.
42    fn unknowns(&self) -> usize;
43
44    /// Where a parameter vector puts the curve in space.
45    fn position(&self, x: &[f64], tol: Tolerances) -> Option<Point>;
46
47    /// How the position moves with each unknown: one vector per unknown.
48    ///
49    /// The walker needs this to write its own travel equation, which is a
50    /// statement about where the *point* goes rather than about the
51    /// parameters.
52    fn position_gradient(&self, x: &[f64], tol: Tolerances) -> Option<Vec<Vector>>;
53
54    /// The condition itself: `n − 1` residuals, and the Jacobian of them.
55    ///
56    /// `None` where the condition cannot be evaluated there at all, which the
57    /// walker reads as a stall rather than as a zero.
58    fn system(&self, x: &[f64], tol: Tolerances) -> Option<(Vec<f64>, Vec<Vec<f64>>)>;
59
60    /// [`Condition::system`], [`Condition::position`] and
61    /// [`Condition::position_gradient`] at one parameter vector, as the
62    /// walker's correction asks for all three at every step. The default asks
63    /// each; a condition whose position is one of the points its system
64    /// already evaluates overrides this to evaluate it once.
65    #[allow(clippy::type_complexity, reason = "the three answers, together")]
66    fn system_at(
67        &self,
68        x: &[f64],
69        tol: Tolerances,
70    ) -> Option<((Vec<f64>, Vec<Vec<f64>>), Point, Vec<Vector>)> {
71        Some((
72            self.system(x, tol)?,
73            self.position(x, tol)?,
74            self.position_gradient(x, tol)?,
75        ))
76    }
77
78    /// Bring a parameter vector back into the region the condition is posed
79    /// on. Called before every evaluation, so a condition may assume it.
80    fn clamp(&self, x: &mut [f64]);
81
82    /// Whether a parameter vector has left that region.
83    fn outside(&self, x: &[f64], tol: Tolerances) -> bool;
84
85    /// Whether it is at the edge of it, which is how a stall at a boundary is
86    /// told apart from a stall at a singularity.
87    fn near_edge(&self, x: &[f64]) -> bool;
88
89    /// A length scale for the step control: how big the thing being walked is.
90    fn extent(&self) -> f64;
91
92    /// Whether [`Condition::tangent`]'s *sign* is its own, continuous along
93    /// the curve, or arbitrary from point to point.
94    ///
95    /// A null vector's sign is whatever the arithmetic gave it, so the default
96    /// answer is no and the walker keeps its own heading. Saying yes is a
97    /// claim, and a load-bearing one: where two surfaces touch, the cross
98    /// product of their normals swaps sides, and a walker that quietly turned
99    /// it back round would march from one branch onto the other straight
100    /// through the tangency: two thin curves through two touching points
101    /// coming back as one confident loop that is on neither of them. The flip
102    /// is the signal, not noise.
103    fn tangent_is_oriented(&self) -> bool {
104        false
105    }
106
107    /// The direction the curve runs, as a unit vector in space.
108    ///
109    /// The default derives it from the condition's own Jacobian: the tangent
110    /// in parameter space is that matrix's null vector, and the space tangent
111    /// is the position gradient applied to it. A condition with a cheaper or
112    /// more careful formula overrides this, and "more careful" is not
113    /// hypothetical, since the null vector says nothing about whether the
114    /// direction it found is real or is the residual's own noise.
115    fn tangent(&self, x: &[f64], tol: Tolerances) -> Option<Vector> {
116        let (_, jacobian) = self.system(x, tol)?;
117        let gradient = self.position_gradient(x, tol)?;
118        null_tangent(&jacobian, &gradient, tol)
119    }
120
121    /// [`Condition::tangent`] at `x`, given the system's Jacobian and the
122    /// position gradient already evaluated there: the walker's correction
123    /// ends on them, so the tangent at the point it lands on need not
124    /// evaluate the condition again.
125    ///
126    /// The default asks [`Condition::tangent`], which is right for any
127    /// condition with its own formula; one that keeps the null-space default
128    /// answers with [`null_tangent`] over what it is given.
129    fn tangent_from(
130        &self,
131        x: &[f64],
132        jacobian: &[Vec<f64>],
133        gradient: &[Vector],
134        tol: Tolerances,
135    ) -> Option<Vector> {
136        let _ = (jacobian, gradient);
137        self.tangent(x, tol)
138    }
139}
140
141/// The unit space tangent of a condition's curve from its Jacobian (the
142/// `n − 1` rows of its equations) and its position gradient: the Jacobian's
143/// null vector carried into space. What [`Condition::tangent`] answers by
144/// default. `None` where the Jacobian has full rank or the tangent vanishes.
145#[must_use]
146pub fn null_tangent(jacobian: &[Vec<f64>], gradient: &[Vector], tol: Tolerances) -> Option<Vector> {
147    let null = null_vector(jacobian, gradient.len())?;
148    let mut out = Vector::ZERO;
149    for (g, n) in gradient.iter().zip(&null) {
150        out += *g * *n;
151    }
152    let length = out.magnitude();
153    if length <= tol.confusion() {
154        return None;
155    }
156    Some(out / length)
157}
158
159/// One walked curve.
160#[derive(Debug, Clone, PartialEq)]
161pub struct Walked {
162    /// The parameter vector at each point, in order.
163    pub states: Vec<Vec<f64>>,
164    /// Where each is in space.
165    pub points: Vec<Point>,
166    /// Why it stopped.
167    pub stopped: Stopped,
168}
169
170/// Follow a condition's curve both ways from a starting point.
171///
172/// Forwards first; if that closes, the curve is a loop and there is nothing
173/// behind. Otherwise the backward half is walked: if that closes, it is the
174/// loop, and otherwise the two are joined.
175///
176/// # Errors
177///
178/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
179/// settings are unusable, or the start does not have the condition's own
180/// number of unknowns.
181pub fn follow<C: Condition + ?Sized>(
182    condition: &C,
183    start: &[f64],
184    options: Marching,
185    tol: Tolerances,
186) -> OgeomResult<Walked> {
187    options.validate()?;
188    if start.len() != condition.unknowns() {
189        ogeom_core::ogeom_bail!(
190            Construction,
191            "the condition is posed in {} unknowns and the start has {}",
192            condition.unknowns(),
193            start.len()
194        );
195    }
196    let ahead = walk_one_way(condition, start, 1.0, options, tol)?;
197    if ahead.stopped == Stopped::Closed {
198        return Ok(ahead);
199    }
200    let behind = walk_one_way(condition, start, -1.0, options, tol)?;
201    // A backward half that closed is the whole loop as well, solved step by
202    // step the other way round: the forward half stopped short of closing
203    // it (out of points, or stalled where the other way passed), and joined
204    // onto it would run past the start a second time. It is turned to run
205    // the way the forward half set off.
206    if behind.stopped == Stopped::Closed {
207        let mut whole = behind;
208        whole.states.reverse();
209        whole.points.reverse();
210        return Ok(whole);
211    }
212
213    let mut states = behind.states;
214    let mut points = behind.points;
215    states.reverse();
216    points.reverse();
217    states.pop();
218    points.pop();
219    states.extend(ahead.states);
220    points.extend(ahead.points);
221
222    // The worse of the two reasons: a curve truncated at either end is
223    // truncated.
224    let stopped = if ahead.stopped == Stopped::RanOut || behind.stopped == Stopped::RanOut {
225        Stopped::RanOut
226    } else if ahead.stopped == Stopped::Stalled || behind.stopped == Stopped::Stalled {
227        Stopped::Stalled
228    } else {
229        Stopped::LeftTheDomain
230    };
231    Ok(Walked {
232        states,
233        points,
234        stopped,
235    })
236}
237
238/// Walk one way from a start.
239///
240/// # Errors
241///
242/// Only through the progress sink; a walk that goes nowhere reports why in
243/// [`Walked::stopped`] rather than failing.
244pub fn walk_one_way<C: Condition + ?Sized>(
245    condition: &C,
246    start: &[f64],
247    sense: f64,
248    options: Marching,
249    tol: Tolerances,
250) -> OgeomResult<Walked> {
251    Ok(walk_one_way_past(condition, start, sense, options, tol)?.0)
252}
253
254/// A step's landing outside the region: its state, held on the region's
255/// edge, and that state's point.
256pub(crate) type Past = (Vec<f64>, Point);
257
258/// [`walk_one_way`], with the step that ended it outside the region where
259/// one did. The step was solved and kept to the chord like any other, and
260/// only where it landed ends the walk.
261pub(crate) fn walk_one_way_past<C: Condition + ?Sized>(
262    condition: &C,
263    start: &[f64],
264    sense: f64,
265    options: Marching,
266    tol: Tolerances,
267) -> OgeomResult<(Walked, Option<Past>)> {
268    let mut at: Vec<f64> = start.to_vec();
269    condition.clamp(&mut at);
270    let Some(from) = condition.position(&at, tol) else {
271        let walked = Walked {
272            states: vec![at],
273            points: Vec::new(),
274            stopped: Stopped::Stalled,
275        };
276        return Ok((walked, None));
277    };
278    let mut past = None;
279    let mut states = vec![at.clone()];
280    let mut points = vec![from];
281    let mut stopped = Stopped::RanOut;
282
283    // The step is set by how far the chord may sag from the arc, and the sag
284    // is measured rather than assumed: about `h · turn / 8`, where `turn` is
285    // the angle between successive tangents. So the step that just meets the
286    // tolerance is found by control rather than by a constant.
287    let reach = condition.extent();
288    let ceiling = reach / 8.0;
289    let mut step = (options.chord * reach)
290        .sqrt()
291        .clamp(tol.confusion(), ceiling);
292    // The null-space tangent's sign is arbitrary from point to point, so the
293    // walk carries the direction it is going and keeps to it.
294    let mut heading: Option<Vector> = None;
295    // The tangent at the point just accepted, measured there to judge the
296    // step's turn: the same question the next step opens with, asked with
297    // the same heading, so it is answered once.
298    let mut ahead: Option<Option<Vector>> = None;
299    // The heading the walk left its start along.
300    let mut start_heading: Option<Vector> = None;
301
302    while points.len() < options.max_points {
303        ogeom_core::progress::checkpoint()?;
304        let direction = match ahead.take() {
305            Some(known) => known,
306            None => oriented(condition, &at, heading, sense, tol),
307        };
308        let Some(direction) = direction else {
309            stopped = Stopped::Stalled;
310            break;
311        };
312        let here = points[points.len() - 1];
313
314        let mut taken = None;
315        for _ in 0..40 {
316            let Some((state, point, evaluated)) =
317                correct(condition, &at, (here, direction, step), options.chord, tol)
318            else {
319                step *= 0.5;
320                if step <= tol.confusion() {
321                    break;
322                }
323                continue;
324            };
325            let next = (state, point);
326            // Like against like: the *travel* direction at the next point,
327            // sensed the same way, or a backward walk would read every step
328            // as a half turn and crawl to a halt.
329            let there = match evaluated {
330                Some((jacobian, gradient)) => orient(
331                    condition,
332                    condition.tangent_from(&next.0, &jacobian, &gradient, tol),
333                    Some(direction),
334                    sense,
335                ),
336                None => oriented(condition, &next.0, Some(direction), sense, tol),
337            };
338            let turn = there.map_or(0.0, |t| direction.dot(t).clamp(-1.0, 1.0).acos());
339            let sag = step * turn / 8.0;
340            if sag <= options.chord || step <= tol.confusion() * 8.0 {
341                // Aim the next step at exactly the tolerance. Sag grows with
342                // the square of the step, so the correction is a square root,
343                // damped so one tight corner does not make the rest of the
344                // curve expensive nor one straight stretch overshoot.
345                let scale = if sag > 0.0 {
346                    (options.chord / sag).sqrt().clamp(0.5, 2.0)
347                } else {
348                    2.0
349                };
350                taken = Some((next, (step * scale).clamp(tol.confusion(), ceiling), there));
351                break;
352            }
353            step *= (options.chord / sag).sqrt().clamp(0.25, 0.9);
354        }
355        let Some(((next_state, next_point), following, there)) = taken else {
356            // A stall right at a domain edge is the edge, not a singularity:
357            // the walk converges on the boundary from inside and the
358            // correction starts failing when the step would cross it, so the
359            // last accepted point sits a fraction of a step short.
360            stopped = if condition.near_edge(&at) {
361                Stopped::LeftTheDomain
362            } else {
363                Stopped::Stalled
364            };
365            break;
366        };
367
368        // Back at the start: a closed loop, if the step onto the start is
369        // one the walk can take. Only asked once the walk has gone far
370        // enough to have left, or every curve would close at once.
371        if points.len() > 3
372            && here.distance(from) <= step * 2.0
373            && closes(
374                condition,
375                &at,
376                (here, direction, step),
377                from,
378                options.chord,
379                tol,
380            )
381        {
382            states.push(states[0].clone());
383            points.push(from);
384            stopped = Stopped::Closed;
385            break;
386        }
387        // Or a step that passes over the start, heading the way the walk
388        // left it: the loop is complete, though the step onto the start
389        // was never one the correction could take (beside a chart's pole
390        // the condition solved onto the start lands anywhere along the
391        // pole's line). Past the start the test above cannot close the
392        // loop again, and the walk would go round once more.
393        if points.len() > 3
394            && start_heading.is_some_and(|h| h.dot(direction) > 0.0)
395            && passes_over(from, here, next_point, options.chord.max(tol.confusion()))
396        {
397            states.push(states[0].clone());
398            points.push(from);
399            stopped = Stopped::Closed;
400            break;
401        }
402        if condition.outside(&next_state, tol) {
403            stopped = Stopped::LeftTheDomain;
404            past = Some((next_state, next_point));
405            break;
406        }
407
408        heading = Some(direction);
409        start_heading.get_or_insert(direction);
410        states.push(next_state);
411        points.push(next_point);
412        at.clone_from(&states[states.len() - 1]);
413        step = following;
414        ahead = Some(there);
415    }
416
417    let walked = Walked {
418        states,
419        points,
420        stopped,
421    };
422    Ok((walked, past))
423}
424
425/// Whether the walk at `at` closes on its start: the step from `here` onto
426/// `from` is solved on the condition and held to the chord like any other.
427///
428/// Coming back near the start is not enough. Across the seam of a closed
429/// but non-periodic surface the curve can break (a spline sphere's
430/// silhouette does by about 0.004, as its normals do not agree there), and
431/// a walk arriving at the seam from one side finds its start a step away
432/// on the other: a loop joined across that gap claims a closed curve that
433/// is not there. So the start must lie within the step the walk is taking,
434/// the condition solved on the plane through it must land on it, and the
435/// condition solved halfway must keep to the chord between them. The step
436/// is never longer than one the walk already accepted, and a start that is
437/// further on is closed from the next point instead.
438fn closes<C: Condition + ?Sized>(
439    condition: &C,
440    at: &[f64],
441    (here, direction, step): (Point, Vector, f64),
442    from: Point,
443    chord: f64,
444    tol: Tolerances,
445) -> bool {
446    let reach = (from - here).dot(direction);
447    if reach <= 0.0 || reach > step {
448        return false;
449    }
450    let near = chord.max(tol.confusion());
451    let Some((_, landed, _)) = correct(condition, at, (here, direction, reach), chord, tol) else {
452        return false;
453    };
454    if landed.distance(from) > near {
455        return false;
456    }
457    let Some((_, middle, _)) = correct(condition, at, (here, direction, reach * 0.5), chord, tol)
458    else {
459        return false;
460    };
461    distance_to_segment(middle, here, from) <= near
462}
463
464/// Whether the segment from `a` to `b` passes `p` within `near`, its
465/// nearest point strictly between the ends.
466fn passes_over(p: Point, a: Point, b: Point, near: f64) -> bool {
467    let along = b - a;
468    let length = along.square_magnitude();
469    if length <= f64::MIN_POSITIVE {
470        return false;
471    }
472    let t = (p - a).dot(along) / length;
473    t > 0.0 && t < 1.0 && p.distance(a + along * t) <= near
474}
475
476/// Distance from a point to a segment.
477fn distance_to_segment(p: Point, a: Point, b: Point) -> f64 {
478    let along = b - a;
479    let length = along.square_magnitude();
480    if length <= f64::MIN_POSITIVE {
481        return p.distance(a);
482    }
483    let t = ((p - a).dot(along) / length).clamp(0.0, 1.0);
484    p.distance(a + along * t)
485}
486
487/// The tangent, turned to keep going the way the walk is going.
488fn oriented<C: Condition + ?Sized>(
489    condition: &C,
490    at: &[f64],
491    heading: Option<Vector>,
492    sense: f64,
493    tol: Tolerances,
494) -> Option<Vector> {
495    orient(condition, condition.tangent(at, tol), heading, sense)
496}
497
498/// A tangent turned to keep going the way the walk is going.
499fn orient<C: Condition + ?Sized>(
500    condition: &C,
501    direction: Option<Vector>,
502    heading: Option<Vector>,
503    sense: f64,
504) -> Option<Vector> {
505    let direction = direction?;
506    if condition.tangent_is_oriented() {
507        // The condition's own sign, kept exactly, including where it flips.
508        return Some(direction * sense);
509    }
510    let along = match heading {
511        // A null vector's sign is whatever the arithmetic gave it; what the
512        // walk means by "onward" is the way it was already going.
513        Some(previous) if direction.dot(previous) < 0.0 => -direction,
514        _ => direction,
515    };
516    Some(if heading.is_none() {
517        along * sense
518    } else {
519        along
520    })
521}
522
523/// Where a correction landed: the state, its point, and the condition's
524/// own Jacobian rows and position gradient there when the correction's last
525/// evaluation was at that state.
526type Landed = (Vec<f64>, Point, Option<(Vec<Vec<f64>>, Vec<Vector>)>);
527
528/// Bring a guess onto the condition, landing a stated distance along.
529///
530/// The condition's own `n − 1` equations say *on the curve*; the walker's one
531/// more says *this far along it*. Without that row the system would be
532/// underdetermined and Newton would wander along the curve instead of
533/// converging to a point on it. The point it lands on stands within
534/// `within` of the curve as far as the solve can tell: see
535/// [`settling_residual`].
536///
537/// The sizes walked here (two to five unknowns) solve on the stack.
538fn correct<C: Condition + ?Sized>(
539    condition: &C,
540    from: &[f64],
541    travel: (Point, Vector, f64),
542    within: f64,
543    tol: Tolerances,
544) -> Option<Landed> {
545    match condition.unknowns() {
546        2 => correct_fixed::<C, 2>(condition, from, travel, within, tol),
547        3 => correct_fixed::<C, 3>(condition, from, travel, within, tol),
548        4 => correct_fixed::<C, 4>(condition, from, travel, within, tol),
549        5 => correct_fixed::<C, 5>(condition, from, travel, within, tol),
550        _ => correct_any(condition, from, travel, tol),
551    }
552}
553
554/// The correction's stopping rule: on the curve to a hundredth of the
555/// confusion, or steps under the parametric tolerance.
556fn correction_criteria(tol: Tolerances) -> solve::Criteria {
557    solve::Criteria {
558        residual: tol.confusion() * 0.01,
559        step: tol.parametric(),
560        max_iterations: 40,
561    }
562}
563
564/// The residual a correction landing at a state settles to: the stopping
565/// rule's own, or less where that would leave the point further than
566/// `within` from the curve.
567///
568/// With the travel row held, a residual `r` in the condition's rows is
569/// what one more Newton step would take out, and that step moves the point
570/// by `G·J⁻¹·r`: `J` the system's Jacobian with the travel row, `G` the
571/// position gradient. So a residual of at most `within` over the Frobenius
572/// norm of `G·J⁻¹`, restricted to the condition's rows, leaves the point
573/// within `within` of where the curve crosses the travel plane. Where the
574/// condition's rows are well conditioned that norm is about one and the
575/// stopping rule's residual is far tighter already. Where they are nearly
576/// dependent it is not: two surfaces crossing at an angle `θ` part by
577/// `d·sin θ` at `d` across the section, so the norm is about `1 / sin θ`,
578/// and a residual of a hundredth of the confusion leaves the point
579/// `ε / (100·sin θ)` across, far outside the chord at a near tangency.
580pub(crate) fn settling_residual<const N: usize>(
581    jacobian: &[[f64; N]; N],
582    gradient: &[Vector],
583    within: f64,
584    stop: f64,
585) -> f64 {
586    let mut spread = 0.0;
587    for row in 0..N - 1 {
588        let mut unit = [0.0; N];
589        unit[row] = 1.0;
590        let Some(column) = solve::linear_fixed(*jacobian, unit) else {
591            return stop;
592        };
593        let moved = gradient
594            .iter()
595            .zip(&column)
596            .fold(Vector::ZERO, |sum, (g, c)| sum + *g * *c);
597        spread += moved.square_magnitude();
598    }
599    let spread = spread.sqrt();
600    if spread.is_finite() && spread * stop > within {
601        within / spread
602    } else {
603        stop
604    }
605}
606
607/// A landing solved further, down to its [`settling_residual`]: the state
608/// the solve settles on, or `None` to keep the landing as it is. A landing
609/// whose next Newton step, `J⁻¹·r` for its residual `r`, would move the
610/// point by no more than `within` is kept without measuring the rest.
611///
612/// The settling residual reads the distance left to the curve off Newton's
613/// linear model at the landing, and that reading holds only as far as the
614/// model does. Kantorovich's condition for Newton's iteration from the
615/// landing to converge to the one root beside it asks that the Jacobian's
616/// change over the move, seen through the landing's inverse, stay within a
617/// half. So the further solve is kept only where `‖J₀⁻¹·(J₁ − J₀)‖ ≤ ½`
618/// over the move it made, in the Frobenius norm, which is never less than
619/// the operator norm. Beside a touch, where two stretches of the curve
620/// cross within the distance a further solve would move, the Jacobian
621/// turns faster than that and the landing is kept.
622pub(crate) fn settle<const N: usize>(
623    mut system: impl FnMut(&[f64; N]) -> ([f64; N], [[f64; N]; N]),
624    (found, residual): ([f64; N], &[f64; N]),
625    (jacobian, gradient): (&[[f64; N]; N], &[Vector]),
626    within: f64,
627    criteria: solve::Criteria,
628) -> Option<[f64; N]> {
629    let step = solve::linear_fixed(*jacobian, *residual)?;
630    let moved = gradient
631        .iter()
632        .zip(&step)
633        .fold(Vector::ZERO, |sum, (g, c)| sum + *g * *c);
634    if moved.magnitude() <= within {
635        return None;
636    }
637    let norm = residual.iter().map(|v| v * v).sum::<f64>().sqrt();
638    let residual = settling_residual(jacobian, gradient, within, criteria.residual);
639    if norm <= residual {
640        return None;
641    }
642    let tighter = solve::Criteria {
643        residual,
644        ..criteria
645    };
646    let (settled, left, _, _) = solve::newton_system_fixed(&mut system, found, tighter).ok()?;
647    if left > norm {
648        return None;
649    }
650    let (_, after) = system(&settled);
651    let mut drift = 0.0;
652    for column in 0..N {
653        let change: [f64; N] =
654            core::array::from_fn(|row| after[row][column] - jacobian[row][column]);
655        let moved = solve::linear_fixed(*jacobian, change)?;
656        drift += moved.iter().map(|v| v * v).sum::<f64>();
657    }
658    (drift.sqrt() <= 0.5).then_some(settled)
659}
660
661/// One evaluation of a correction: the clamped state, its point, the
662/// condition's Jacobian rows, the position gradient, and the whole
663/// system's Jacobian and residual.
664type Evaluated<const N: usize> = (
665    [f64; N],
666    Point,
667    Vec<Vec<f64>>,
668    Vec<Vector>,
669    [[f64; N]; N],
670    [f64; N],
671);
672
673/// [`correct`] for `N` unknowns, allocation-free in the solve, keeping the
674/// last evaluation so the landing hands back its Jacobian.
675fn correct_fixed<C: Condition + ?Sized, const N: usize>(
676    condition: &C,
677    from: &[f64],
678    (anchor, along, reach): (Point, Vector, f64),
679    within: f64,
680    tol: Tolerances,
681) -> Option<Landed> {
682    let start: [f64; N] = from.try_into().ok()?;
683    let last: core::cell::Cell<Option<Evaluated<N>>> = core::cell::Cell::new(None);
684    let system = |x: &[f64; N]| {
685        let mut at = *x;
686        condition.clamp(&mut at);
687        last.set(None);
688        // Where the condition cannot be evaluated the residual is infinite,
689        // so the damped step backs off; a zero there would read as a root.
690        let mut residual = [f64::INFINITY; N];
691        let mut jacobian = [[0.0; N]; N];
692        let Some(((rows, matrix), point, gradient)) = condition.system_at(&at, tol) else {
693            return (residual, jacobian);
694        };
695        if rows.len() + 1 != N
696            || matrix.len() + 1 != N
697            || gradient.len() != N
698            || matrix.iter().any(|row| row.len() != N)
699        {
700            return (residual, jacobian);
701        }
702        residual[..N - 1].copy_from_slice(&rows);
703        for (to, row) in jacobian.iter_mut().zip(&matrix) {
704            to.copy_from_slice(row);
705        }
706        residual[N - 1] = (point - anchor).dot(along) - reach;
707        for (entry, g) in jacobian[N - 1].iter_mut().zip(&gradient) {
708            *entry = g.dot(along);
709        }
710        last.set(Some((at, point, matrix, gradient, jacobian, residual)));
711        (residual, jacobian)
712    };
713    let criteria = correction_criteria(tol);
714    let (mut found, norm, _, _) = solve::newton_system_fixed(system, start, criteria).ok()?;
715    if norm > tol.confusion() {
716        return None;
717    }
718    let landed_at = |found: &[f64; N]| {
719        let mut at = *found;
720        condition.clamp(&mut at);
721        at
722    };
723    let mut evaluated = last.take();
724    if evaluated.as_ref().is_none_or(|e| e.0 != landed_at(&found)) {
725        system(&found);
726        evaluated = last.take();
727    }
728    if let Some((_, _, _, gradient, jacobian, residual)) = &evaluated
729        && let Some(settled) = settle(
730            system,
731            (found, residual),
732            (jacobian, gradient),
733            within,
734            criteria,
735        )
736    {
737        found = settled;
738        evaluated = last.take();
739    }
740    let at = landed_at(&found);
741    if let Some((evaluated, point, matrix, gradient, _, _)) = evaluated
742        && evaluated == at
743    {
744        return Some((at.to_vec(), point, Some((matrix, gradient))));
745    }
746    let point = condition.position(&at, tol)?;
747    Some((at.to_vec(), point, None))
748}
749
750/// [`correct`] for any number of unknowns, on the general solver, to the
751/// stopping rule alone: the conditions walked here are posed in two to five
752/// unknowns and settle on the fixed path.
753fn correct_any<C: Condition + ?Sized>(
754    condition: &C,
755    from: &[f64],
756    (anchor, along, reach): (Point, Vector, f64),
757    tol: Tolerances,
758) -> Option<Landed> {
759    let n = condition.unknowns();
760    let system = |x: &[f64]| {
761        let mut at = x.to_vec();
762        condition.clamp(&mut at);
763        let Some(((mut residual, mut jacobian), point, gradient)) = condition.system_at(&at, tol)
764        else {
765            return (vec![f64::INFINITY; n], vec![vec![0.0; n]; n]);
766        };
767        residual.push((point - anchor).dot(along) - reach);
768        jacobian.push(gradient.iter().map(|g| g.dot(along)).collect());
769        (residual, jacobian)
770    };
771    let found = solve::newton_system(system, from, correction_criteria(tol)).ok()?;
772    if found.residual > tol.confusion() {
773        return None;
774    }
775    let mut at = found.value;
776    condition.clamp(&mut at);
777    let point = condition.position(&at, tol)?;
778    Some((at, point, None))
779}
780
781/// The null vector of an `(n − 1) × n` matrix: the generalized cross product.
782///
783/// Component `i` is the determinant of the matrix with column `i` struck out,
784/// signed by `(−1)^i`, which is exactly the cross product for `n = 3` and the
785/// perpendicular for `n = 2`, and is the direction the curve runs for any `n`.
786/// `None` where the matrix has full rank, which means the "curve" is a point
787/// and there is nothing to follow.
788fn null_vector(jacobian: &[Vec<f64>], n: usize) -> Option<Vec<f64>> {
789    if n == 0 || jacobian.len() + 1 != n || jacobian.iter().any(|row| row.len() < n) {
790        return None;
791    }
792    let all: Columns = (0..n).collect();
793    let mut out = Vec::with_capacity(n);
794    for column in 0..n {
795        let kept = without(&all, column);
796        let sign = if column % 2 == 0 { 1.0 } else { -1.0 };
797        out.push(sign * determinant(jacobian, &kept));
798    }
799    let length = out.iter().map(|v| v * v).sum::<f64>().sqrt();
800    if length <= f64::MIN_POSITIVE {
801        return None;
802    }
803    for v in &mut out {
804        *v /= length;
805    }
806    Some(out)
807}
808
809/// Column indices of a minor, on the stack for the sizes walked here.
810type Columns = SmallVec<[usize; 8]>;
811
812/// `columns` with the entry at `position` struck out.
813fn without(columns: &[usize], position: usize) -> Columns {
814    columns
815        .iter()
816        .enumerate()
817        .filter(|(k, _)| *k != position)
818        .map(|(_, c)| *c)
819        .collect()
820}
821
822/// The determinant of the square minor of `rows` (from the last
823/// `columns.len()` rows up) on `columns`, by expansion along its first row.
824/// The sizes here are at most five.
825fn determinant(rows: &[Vec<f64>], columns: &[usize]) -> f64 {
826    let rows = &rows[rows.len() - columns.len()..];
827    match columns.len() {
828        0 => 1.0,
829        1 => rows[0][columns[0]],
830        2 => {
831            let (a, b) = (&rows[0], &rows[1]);
832            a[columns[0]].mul_add(b[columns[1]], -(a[columns[1]] * b[columns[0]]))
833        }
834        n => {
835            let mut total = 0.0;
836            for position in 0..n {
837                let sign = if position % 2 == 0 { 1.0 } else { -1.0 };
838                total += sign
839                    * rows[0][columns[position]]
840                    * determinant(&rows[1..], &without(columns, position));
841            }
842            total
843        }
844    }
845}
846
847#[cfg(test)]
848#[allow(clippy::unwrap_used, clippy::expect_used)]
849mod tests {
850    use super::*;
851
852    const T: Tolerances = Tolerances::millimetres();
853
854    /// The null vector by cofactors, computed on owned minors: what the
855    /// stack version must reproduce bit for bit.
856    fn cofactor_null(jacobian: &[Vec<f64>], n: usize) -> Vec<f64> {
857        fn det(m: &[Vec<f64>]) -> f64 {
858            match m.len() {
859                0 => 1.0,
860                1 => m[0][0],
861                2 => m[0][0].mul_add(m[1][1], -(m[0][1] * m[1][0])),
862                n => (0..n)
863                    .map(|c| {
864                        let minor: Vec<Vec<f64>> = m[1..]
865                            .iter()
866                            .map(|r| (0..n).filter(|&k| k != c).map(|k| r[k]).collect())
867                            .collect();
868                        let sign = if c % 2 == 0 { 1.0 } else { -1.0 };
869                        sign * m[0][c] * det(&minor)
870                    })
871                    .fold(0.0, |a, b| a + b),
872            }
873        }
874        let mut out: Vec<f64> = (0..n)
875            .map(|c| {
876                let minor: Vec<Vec<f64>> = jacobian
877                    .iter()
878                    .map(|r| (0..n).filter(|&k| k != c).map(|k| r[k]).collect())
879                    .collect();
880                let sign = if c % 2 == 0 { 1.0 } else { -1.0 };
881                sign * det(&minor)
882            })
883            .collect();
884        let length = out.iter().map(|v| v * v).sum::<f64>().sqrt();
885        for v in &mut out {
886            *v /= length;
887        }
888        out
889    }
890
891    #[test]
892    fn the_null_vector_matches_plain_cofactors_exactly() {
893        let mut seed = 0x2545_f491_4f6c_dd1d_u64;
894        let mut next = || {
895            seed ^= seed << 13;
896            seed ^= seed >> 7;
897            seed ^= seed << 17;
898            // The top 32 bits, exact in an `f64`, in [-0.5, 0.5).
899            f64::from((seed >> 32) as u32) / f64::from(u32::MAX) - 0.5
900        };
901        for n in 2..=5 {
902            for _ in 0..50 {
903                let jacobian: Vec<Vec<f64>> = (0..n - 1)
904                    .map(|_| (0..n).map(|_| next()).collect())
905                    .collect();
906                let got = null_vector(&jacobian, n).unwrap();
907                let want = cofactor_null(&jacobian, n);
908                assert_eq!(
909                    got.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
910                    want.iter().map(|v| v.to_bits()).collect::<Vec<_>>()
911                );
912            }
913        }
914    }
915
916    /// A circle of radius `r` about the origin in the `z = h` plane, posed in
917    /// three unknowns (the point's own coordinates) with two equations. A
918    /// deliberately silly condition, chosen because its answer is known
919    /// exactly and its Jacobian has nothing in common with a surface pair's.
920    struct CircleAt {
921        radius: f64,
922        height: f64,
923    }
924
925    impl Condition for CircleAt {
926        fn unknowns(&self) -> usize {
927            3
928        }
929        fn position(&self, x: &[f64], _tol: Tolerances) -> Option<Point> {
930            Some(Point::new(x[0], x[1], x[2]))
931        }
932        fn position_gradient(&self, _x: &[f64], _tol: Tolerances) -> Option<Vec<Vector>> {
933            Some(vec![Vector::X, Vector::Y, Vector::Z])
934        }
935        fn system(&self, x: &[f64], _tol: Tolerances) -> Option<(Vec<f64>, Vec<Vec<f64>>)> {
936            Some((
937                vec![
938                    x[0].mul_add(x[0], x[1] * x[1]) - self.radius * self.radius,
939                    x[2] - self.height,
940                ],
941                vec![vec![2.0 * x[0], 2.0 * x[1], 0.0], vec![0.0, 0.0, 1.0]],
942            ))
943        }
944        fn clamp(&self, _x: &mut [f64]) {}
945        fn outside(&self, _x: &[f64], _tol: Tolerances) -> bool {
946            false
947        }
948        fn near_edge(&self, _x: &[f64]) -> bool {
949            false
950        }
951        fn extent(&self) -> f64 {
952            self.radius * 4.0
953        }
954    }
955
956    /// The walker follows a condition it has never heard of, closes the loop,
957    /// and lands on the circle to the chord it was given, the tangent coming
958    /// from the null space alone, since this condition supplies no formula.
959    #[test]
960    fn a_condition_the_walker_knows_nothing_about_is_followed_to_its_chord() {
961        let circle = CircleAt {
962            radius: 3.0,
963            height: 1.5,
964        };
965        let options = Marching {
966            chord: 1e-5,
967            ..Marching::default()
968        };
969        let walked = follow(&circle, &[3.0, 0.0, 1.5], options, T).unwrap();
970        assert_eq!(walked.stopped, Stopped::Closed, "a circle closes");
971        assert!(walked.points.len() > 20, "{} points", walked.points.len());
972
973        for p in &walked.points {
974            assert!((p.x.hypot(p.y) - 3.0).abs() < 1e-9, "on the circle: {p:?}");
975            assert!((p.z - 1.5).abs() < 1e-9, "in its plane: {p:?}");
976        }
977        // The polyline's length is the circumference, to the chord's own sag.
978        let length: f64 = walked.points.windows(2).map(|w| w[0].distance(w[1])).sum();
979        let circumference = 2.0 * core::f64::consts::PI * 3.0;
980        assert!(
981            length <= circumference && length > circumference * (1.0 - 1e-4),
982            "the inscribed polygon: {length} against {circumference}"
983        );
984    }
985
986    /// An ellipse with semi-axes `a` and `b` about the origin in the
987    /// `z = 0` plane, posed like [`CircleAt`]: its curvature changes along
988    /// it, so a walk round it takes a different number of steps each way.
989    struct Ellipse {
990        a: f64,
991        b: f64,
992    }
993
994    impl Condition for Ellipse {
995        fn unknowns(&self) -> usize {
996            3
997        }
998        fn position(&self, x: &[f64], _tol: Tolerances) -> Option<Point> {
999            Some(Point::new(x[0], x[1], x[2]))
1000        }
1001        fn position_gradient(&self, _x: &[f64], _tol: Tolerances) -> Option<Vec<Vector>> {
1002            Some(vec![Vector::X, Vector::Y, Vector::Z])
1003        }
1004        fn system(&self, x: &[f64], _tol: Tolerances) -> Option<(Vec<f64>, Vec<Vec<f64>>)> {
1005            let (aa, bb) = (self.a * self.a, self.b * self.b);
1006            Some((
1007                vec![
1008                    (x[0] * x[0]).mul_add(1.0 / aa, x[1] * x[1] / bb) - 1.0,
1009                    x[2],
1010                ],
1011                vec![
1012                    vec![2.0 * x[0] / aa, 2.0 * x[1] / bb, 0.0],
1013                    vec![0.0, 0.0, 1.0],
1014                ],
1015            ))
1016        }
1017        fn clamp(&self, _x: &mut [f64]) {}
1018        fn outside(&self, _x: &[f64], _tol: Tolerances) -> bool {
1019            false
1020        }
1021        fn near_edge(&self, _x: &[f64]) -> bool {
1022            false
1023        }
1024        fn extent(&self) -> f64 {
1025            self.a * 4.0
1026        }
1027    }
1028
1029    /// A loop the backward half closes is the loop, whatever stopped the
1030    /// forward half. From a start where going round backwards takes fewer
1031    /// points than forwards, with points enough only for the backward way,
1032    /// the forward half runs out and the backward one closes: joined, the
1033    /// two ran past the start a second time and were reported truncated.
1034    /// What comes back is the backward loop, turned to run forwards.
1035    #[test]
1036    fn a_loop_closed_going_backwards_is_the_loop() {
1037        let ellipse = Ellipse { a: 3.0, b: 1.0 };
1038        let options = Marching {
1039            chord: 1e-5,
1040            ..Marching::default()
1041        };
1042        let (start, ahead, behind) = (0..64)
1043            .map(|k| {
1044                let t = f64::from(k) * 0.1;
1045                let start = [3.0 * t.cos(), t.sin(), 0.0];
1046                let ahead = walk_one_way(&ellipse, &start, 1.0, options, T).unwrap();
1047                let behind = walk_one_way(&ellipse, &start, -1.0, options, T).unwrap();
1048                (start, ahead, behind)
1049            })
1050            .find(|(_, ahead, behind)| {
1051                ahead.stopped == Stopped::Closed
1052                    && behind.stopped == Stopped::Closed
1053                    && behind.points.len() < ahead.points.len()
1054            })
1055            .expect("a start the backward walk closes from in fewer points");
1056
1057        let options = Marching {
1058            max_points: behind.points.len(),
1059            ..options
1060        };
1061        let walked = follow(&ellipse, &start, options, T).unwrap();
1062        assert_eq!(walked.stopped, Stopped::Closed, "the backward half closed");
1063        assert_eq!(walked.points.len(), behind.points.len(), "once round");
1064        let (first, last) = (walked.points[0], walked.points[walked.points.len() - 1]);
1065        assert_eq!(first, last, "closed on its start");
1066        assert!(
1067            (walked.points[1] - first).dot(ahead.points[1] - first) > 0.0,
1068            "it runs the way the forward half set off"
1069        );
1070        for p in &walked.points {
1071            let on = (p.x / 3.0).hypot(p.y) - 1.0;
1072            assert!(
1073                on.abs() < 1e-9 && p.z.abs() < 1e-12,
1074                "on the ellipse: {p:?}"
1075            );
1076        }
1077    }
1078
1079    /// A curve on a closed but non-periodic chart, the cylinder
1080    /// `(5 cos 2πu, 5 sin 2πu, v)` over `u` in `[0, 1]`, climbing `rise` as
1081    /// it goes round: it meets itself across the seam only when `rise` is
1082    /// zero, and otherwise breaks there by `rise`, as a spline sphere's
1083    /// silhouette does where the sphere's normals disagree across its seam.
1084    struct Climbing {
1085        rise: f64,
1086    }
1087
1088    impl Climbing {
1089        /// How far `p` stands from the curve, at its own angle round the
1090        /// axis, the seam's point counted on both sides.
1091        fn miss(&self, p: Point) -> f64 {
1092            let u = p.y.atan2(p.x).rem_euclid(core::f64::consts::TAU) / core::f64::consts::TAU;
1093            [u, u + 1.0]
1094                .into_iter()
1095                .filter(|u| *u <= 1.0)
1096                .map(|u| {
1097                    let a = u * core::f64::consts::TAU;
1098                    p.distance(Point::new(5.0 * a.cos(), 5.0 * a.sin(), self.rise * u))
1099                })
1100                .fold(f64::INFINITY, f64::min)
1101        }
1102    }
1103
1104    impl Condition for Climbing {
1105        fn unknowns(&self) -> usize {
1106            2
1107        }
1108        fn position(&self, x: &[f64], _tol: Tolerances) -> Option<Point> {
1109            let a = x[0] * core::f64::consts::TAU;
1110            Some(Point::new(5.0 * a.cos(), 5.0 * a.sin(), x[1]))
1111        }
1112        fn position_gradient(&self, x: &[f64], _tol: Tolerances) -> Option<Vec<Vector>> {
1113            let a = x[0] * core::f64::consts::TAU;
1114            Some(vec![
1115                Vector::new(-a.sin(), a.cos(), 0.0) * (5.0 * core::f64::consts::TAU),
1116                Vector::Z,
1117            ])
1118        }
1119        fn system(&self, x: &[f64], _tol: Tolerances) -> Option<(Vec<f64>, Vec<Vec<f64>>)> {
1120            Some((
1121                vec![self.rise.mul_add(-x[0], x[1])],
1122                vec![vec![-self.rise, 1.0]],
1123            ))
1124        }
1125        fn clamp(&self, x: &mut [f64]) {
1126            x[0] = x[0].clamp(0.0, 1.0);
1127            x[1] = x[1].clamp(-1.0, 1.0);
1128        }
1129        fn outside(&self, x: &[f64], tol: Tolerances) -> bool {
1130            let band = tol.parametric();
1131            x[0] < -band || x[0] > 1.0 + band || x[1] < -1.0 - band || x[1] > 1.0 + band
1132        }
1133        fn near_edge(&self, x: &[f64]) -> bool {
1134            x[0] <= 1e-6 || x[0] >= 1.0 - 1e-6
1135        }
1136        fn extent(&self) -> f64 {
1137            14.0
1138        }
1139    }
1140
1141    /// A walk closes only through a step it solved. One that comes back
1142    /// beside its start across a seam the curve breaks at, 0.004 here as a
1143    /// spline sphere's silhouette breaks by about that, ends open at the
1144    /// seam rather than claiming a loop: joined straight onto the start,
1145    /// the last chord missed the curve by half the break. Every chord of
1146    /// what comes back keeps to the curve, from a start on either side of
1147    /// the seam or away from it; and where the curve does meet itself
1148    /// across the seam, the loop still closes.
1149    #[test]
1150    fn a_walk_closes_only_through_a_step_it_solved() {
1151        let chord = 1e-5;
1152        let options = Marching {
1153            chord,
1154            ..Marching::default()
1155        };
1156        let broken = Climbing { rise: 0.004 };
1157        for u in [0.0, 1e-4, 0.3, 0.9999, 1.0] {
1158            let walked = follow(&broken, &[u, broken.rise * u], options, T).unwrap();
1159            assert_ne!(
1160                walked.stopped,
1161                Stopped::Closed,
1162                "from {u}: a curve broken at the seam is not a loop"
1163            );
1164            for pair in walked.points.windows(2) {
1165                let middle = pair[0] + (pair[1] - pair[0]) * 0.5;
1166                let miss = broken.miss(middle);
1167                assert!(
1168                    miss <= chord,
1169                    "from {u}: the chord {:?} to {:?} misses the curve by {miss:e}",
1170                    pair[0],
1171                    pair[1]
1172                );
1173            }
1174            let (first, last) = (&walked.states[0], &walked.states[walked.states.len() - 1]);
1175            assert!(
1176                first[0].min(last[0]) <= 1e-6 && first[0].max(last[0]) >= 1.0 - 1e-6,
1177                "from {u}: the whole way round, seam to seam: {first:?} to {last:?}"
1178            );
1179        }
1180
1181        let whole = Climbing { rise: 0.0 };
1182        let walked = follow(&whole, &[0.0, 0.0], options, T).unwrap();
1183        assert_eq!(
1184            walked.stopped,
1185            Stopped::Closed,
1186            "a circle met across the seam"
1187        );
1188    }
1189
1190    /// The null vector is the direction the curve runs, for the shapes a
1191    /// condition actually has.
1192    #[test]
1193    fn the_null_vector_is_the_generalized_cross_product() {
1194        // Two unknowns, one equation: the perpendicular.
1195        let null = null_vector(&[vec![3.0, 4.0]], 2).unwrap();
1196        assert!((null[0] - 0.8).abs() < 1e-12 && (null[1] + 0.6).abs() < 1e-12);
1197        // Three unknowns, two equations: the cross product of the rows.
1198        let null = null_vector(&[vec![1.0, 0.0, 0.0], vec![0.0, 1.0, 0.0]], 3).unwrap();
1199        assert!(null[0].abs() < 1e-12 && null[1].abs() < 1e-12 && null[2].abs() - 1.0 < 1e-12);
1200        // A matrix whose rows are dependent has no curve to follow.
1201        assert!(null_vector(&[vec![1.0, 2.0, 3.0], vec![2.0, 4.0, 6.0]], 3).is_none());
1202    }
1203}