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 and the two are joined.
174///
175/// # Errors
176///
177/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
178/// settings are unusable, or the start does not have the condition's own
179/// number of unknowns.
180pub fn follow<C: Condition + ?Sized>(
181    condition: &C,
182    start: &[f64],
183    options: Marching,
184    tol: Tolerances,
185) -> OgeomResult<Walked> {
186    options.validate()?;
187    if start.len() != condition.unknowns() {
188        ogeom_core::ogeom_bail!(
189            Construction,
190            "the condition is posed in {} unknowns and the start has {}",
191            condition.unknowns(),
192            start.len()
193        );
194    }
195    let ahead = walk_one_way(condition, start, 1.0, options, tol)?;
196    if ahead.stopped == Stopped::Closed {
197        return Ok(ahead);
198    }
199    let behind = walk_one_way(condition, start, -1.0, options, tol)?;
200
201    let mut states = behind.states;
202    let mut points = behind.points;
203    states.reverse();
204    points.reverse();
205    states.pop();
206    points.pop();
207    states.extend(ahead.states);
208    points.extend(ahead.points);
209
210    // The worse of the two reasons: a curve truncated at either end is
211    // truncated.
212    let stopped = if ahead.stopped == Stopped::RanOut || behind.stopped == Stopped::RanOut {
213        Stopped::RanOut
214    } else if ahead.stopped == Stopped::Stalled || behind.stopped == Stopped::Stalled {
215        Stopped::Stalled
216    } else {
217        Stopped::LeftTheDomain
218    };
219    Ok(Walked {
220        states,
221        points,
222        stopped,
223    })
224}
225
226/// Walk one way from a start.
227///
228/// # Errors
229///
230/// Only through the progress sink; a walk that goes nowhere reports why in
231/// [`Walked::stopped`] rather than failing.
232pub fn walk_one_way<C: Condition + ?Sized>(
233    condition: &C,
234    start: &[f64],
235    sense: f64,
236    options: Marching,
237    tol: Tolerances,
238) -> OgeomResult<Walked> {
239    let mut at: Vec<f64> = start.to_vec();
240    condition.clamp(&mut at);
241    let Some(from) = condition.position(&at, tol) else {
242        return Ok(Walked {
243            states: vec![at],
244            points: Vec::new(),
245            stopped: Stopped::Stalled,
246        });
247    };
248    let mut states = vec![at.clone()];
249    let mut points = vec![from];
250    let mut stopped = Stopped::RanOut;
251
252    // The step is set by how far the chord may sag from the arc, and the sag
253    // is measured rather than assumed: about `h · turn / 8`, where `turn` is
254    // the angle between successive tangents. So the step that just meets the
255    // tolerance is found by control rather than by a constant.
256    let reach = condition.extent();
257    let ceiling = reach / 8.0;
258    let mut step = (options.chord * reach)
259        .sqrt()
260        .clamp(tol.confusion(), ceiling);
261    // The null-space tangent's sign is arbitrary from point to point, so the
262    // walk carries the direction it is going and keeps to it.
263    let mut heading: Option<Vector> = None;
264    // The tangent at the point just accepted, measured there to judge the
265    // step's turn: the same question the next step opens with, asked with
266    // the same heading, so it is answered once.
267    let mut ahead: Option<Option<Vector>> = None;
268
269    while points.len() < options.max_points {
270        ogeom_core::progress::checkpoint()?;
271        let direction = match ahead.take() {
272            Some(known) => known,
273            None => oriented(condition, &at, heading, sense, tol),
274        };
275        let Some(direction) = direction else {
276            stopped = Stopped::Stalled;
277            break;
278        };
279        let here = points[points.len() - 1];
280
281        let mut taken = None;
282        for _ in 0..40 {
283            let Some((state, point, evaluated)) =
284                correct(condition, &at, (here, direction, step), tol)
285            else {
286                step *= 0.5;
287                if step <= tol.confusion() {
288                    break;
289                }
290                continue;
291            };
292            let next = (state, point);
293            // Like against like: the *travel* direction at the next point,
294            // sensed the same way, or a backward walk would read every step
295            // as a half turn and crawl to a halt.
296            let there = match evaluated {
297                Some((jacobian, gradient)) => orient(
298                    condition,
299                    condition.tangent_from(&next.0, &jacobian, &gradient, tol),
300                    Some(direction),
301                    sense,
302                ),
303                None => oriented(condition, &next.0, Some(direction), sense, tol),
304            };
305            let turn = there.map_or(0.0, |t| direction.dot(t).clamp(-1.0, 1.0).acos());
306            let sag = step * turn / 8.0;
307            if sag <= options.chord || step <= tol.confusion() * 8.0 {
308                // Aim the next step at exactly the tolerance. Sag grows with
309                // the square of the step, so the correction is a square root,
310                // damped so one tight corner does not make the rest of the
311                // curve expensive nor one straight stretch overshoot.
312                let scale = if sag > 0.0 {
313                    (options.chord / sag).sqrt().clamp(0.5, 2.0)
314                } else {
315                    2.0
316                };
317                taken = Some((next, (step * scale).clamp(tol.confusion(), ceiling), there));
318                break;
319            }
320            step *= (options.chord / sag).sqrt().clamp(0.25, 0.9);
321        }
322        let Some(((next_state, next_point), following, there)) = taken else {
323            // A stall right at a domain edge is the edge, not a singularity:
324            // the walk converges on the boundary from inside and the
325            // correction starts failing when the step would cross it, so the
326            // last accepted point sits a fraction of a step short.
327            stopped = if condition.near_edge(&at) {
328                Stopped::LeftTheDomain
329            } else {
330                Stopped::Stalled
331            };
332            break;
333        };
334
335        // Back at the start: a closed loop. Only checked once the walk has
336        // gone far enough to have left, or every curve would close at once.
337        if points.len() > 3 && next_point.distance(from) <= step {
338            states.push(states[0].clone());
339            points.push(from);
340            stopped = Stopped::Closed;
341            break;
342        }
343        if condition.outside(&next_state, tol) {
344            stopped = Stopped::LeftTheDomain;
345            break;
346        }
347
348        heading = Some(direction);
349        states.push(next_state);
350        points.push(next_point);
351        at.clone_from(&states[states.len() - 1]);
352        step = following;
353        ahead = Some(there);
354    }
355
356    Ok(Walked {
357        states,
358        points,
359        stopped,
360    })
361}
362
363/// The tangent, turned to keep going the way the walk is going.
364fn oriented<C: Condition + ?Sized>(
365    condition: &C,
366    at: &[f64],
367    heading: Option<Vector>,
368    sense: f64,
369    tol: Tolerances,
370) -> Option<Vector> {
371    orient(condition, condition.tangent(at, tol), heading, sense)
372}
373
374/// A tangent turned to keep going the way the walk is going.
375fn orient<C: Condition + ?Sized>(
376    condition: &C,
377    direction: Option<Vector>,
378    heading: Option<Vector>,
379    sense: f64,
380) -> Option<Vector> {
381    let direction = direction?;
382    if condition.tangent_is_oriented() {
383        // The condition's own sign, kept exactly, including where it flips.
384        return Some(direction * sense);
385    }
386    let along = match heading {
387        // A null vector's sign is whatever the arithmetic gave it; what the
388        // walk means by "onward" is the way it was already going.
389        Some(previous) if direction.dot(previous) < 0.0 => -direction,
390        _ => direction,
391    };
392    Some(if heading.is_none() {
393        along * sense
394    } else {
395        along
396    })
397}
398
399/// Where a correction landed: the state, its point, and the condition's
400/// own Jacobian rows and position gradient there when the correction's last
401/// evaluation was at that state.
402type Landed = (Vec<f64>, Point, Option<(Vec<Vec<f64>>, Vec<Vector>)>);
403
404/// Bring a guess onto the condition, landing a stated distance along.
405///
406/// The condition's own `n − 1` equations say *on the curve*; the walker's one
407/// more says *this far along it*. Without that row the system would be
408/// underdetermined and Newton would wander along the curve instead of
409/// converging to a point on it.
410///
411/// The sizes walked here (two to five unknowns) solve on the stack.
412fn correct<C: Condition + ?Sized>(
413    condition: &C,
414    from: &[f64],
415    travel: (Point, Vector, f64),
416    tol: Tolerances,
417) -> Option<Landed> {
418    match condition.unknowns() {
419        2 => correct_fixed::<C, 2>(condition, from, travel, tol),
420        3 => correct_fixed::<C, 3>(condition, from, travel, tol),
421        4 => correct_fixed::<C, 4>(condition, from, travel, tol),
422        5 => correct_fixed::<C, 5>(condition, from, travel, tol),
423        _ => correct_any(condition, from, travel, tol),
424    }
425}
426
427/// The correction's stopping rule: on the curve to a hundredth of the
428/// confusion, or steps under the parametric tolerance.
429fn correction_criteria(tol: Tolerances) -> solve::Criteria {
430    solve::Criteria {
431        residual: tol.confusion() * 0.01,
432        step: tol.parametric(),
433        max_iterations: 40,
434    }
435}
436
437/// One evaluation of a correction: the clamped state, its point, the
438/// condition's Jacobian rows and the position gradient.
439type Evaluated<const N: usize> = ([f64; N], Point, Vec<Vec<f64>>, Vec<Vector>);
440
441/// [`correct`] for `N` unknowns, allocation-free in the solve, keeping the
442/// last evaluation so the landing hands back its Jacobian.
443fn correct_fixed<C: Condition + ?Sized, const N: usize>(
444    condition: &C,
445    from: &[f64],
446    (anchor, along, reach): (Point, Vector, f64),
447    tol: Tolerances,
448) -> Option<Landed> {
449    let start: [f64; N] = from.try_into().ok()?;
450    let mut last: Option<Evaluated<N>> = None;
451    let system = |x: &[f64; N]| {
452        let mut at = *x;
453        condition.clamp(&mut at);
454        last = None;
455        // Where the condition cannot be evaluated the residual is infinite,
456        // so the damped step backs off; a zero there would read as a root.
457        let mut residual = [f64::INFINITY; N];
458        let mut jacobian = [[0.0; N]; N];
459        let Some(((rows, matrix), point, gradient)) = condition.system_at(&at, tol) else {
460            return (residual, jacobian);
461        };
462        if rows.len() + 1 != N
463            || matrix.len() + 1 != N
464            || gradient.len() != N
465            || matrix.iter().any(|row| row.len() != N)
466        {
467            return (residual, jacobian);
468        }
469        residual[..N - 1].copy_from_slice(&rows);
470        for (to, row) in jacobian.iter_mut().zip(&matrix) {
471            to.copy_from_slice(row);
472        }
473        residual[N - 1] = (point - anchor).dot(along) - reach;
474        for (entry, g) in jacobian[N - 1].iter_mut().zip(&gradient) {
475            *entry = g.dot(along);
476        }
477        last = Some((at, point, matrix, gradient));
478        (residual, jacobian)
479    };
480    let (found, norm, _, _) =
481        solve::newton_system_fixed(system, start, correction_criteria(tol)).ok()?;
482    if norm > tol.confusion() {
483        return None;
484    }
485    let mut at = found;
486    condition.clamp(&mut at);
487    if let Some((evaluated, point, matrix, gradient)) = last
488        && evaluated == at
489    {
490        return Some((at.to_vec(), point, Some((matrix, gradient))));
491    }
492    let point = condition.position(&at, tol)?;
493    Some((at.to_vec(), point, None))
494}
495
496/// [`correct`] for any number of unknowns, on the general solver.
497fn correct_any<C: Condition + ?Sized>(
498    condition: &C,
499    from: &[f64],
500    (anchor, along, reach): (Point, Vector, f64),
501    tol: Tolerances,
502) -> Option<Landed> {
503    let n = condition.unknowns();
504    let system = |x: &[f64]| {
505        let mut at = x.to_vec();
506        condition.clamp(&mut at);
507        let Some(((mut residual, mut jacobian), point, gradient)) = condition.system_at(&at, tol)
508        else {
509            return (vec![f64::INFINITY; n], vec![vec![0.0; n]; n]);
510        };
511        residual.push((point - anchor).dot(along) - reach);
512        jacobian.push(gradient.iter().map(|g| g.dot(along)).collect());
513        (residual, jacobian)
514    };
515    let found = solve::newton_system(system, from, correction_criteria(tol)).ok()?;
516    if found.residual > tol.confusion() {
517        return None;
518    }
519    let mut at = found.value;
520    condition.clamp(&mut at);
521    let point = condition.position(&at, tol)?;
522    Some((at, point, None))
523}
524
525/// The null vector of an `(n − 1) × n` matrix: the generalized cross product.
526///
527/// Component `i` is the determinant of the matrix with column `i` struck out,
528/// signed by `(−1)^i`, which is exactly the cross product for `n = 3` and the
529/// perpendicular for `n = 2`, and is the direction the curve runs for any `n`.
530/// `None` where the matrix has full rank, which means the "curve" is a point
531/// and there is nothing to follow.
532fn null_vector(jacobian: &[Vec<f64>], n: usize) -> Option<Vec<f64>> {
533    if n == 0 || jacobian.len() + 1 != n || jacobian.iter().any(|row| row.len() < n) {
534        return None;
535    }
536    let all: Columns = (0..n).collect();
537    let mut out = Vec::with_capacity(n);
538    for column in 0..n {
539        let kept = without(&all, column);
540        let sign = if column % 2 == 0 { 1.0 } else { -1.0 };
541        out.push(sign * determinant(jacobian, &kept));
542    }
543    let length = out.iter().map(|v| v * v).sum::<f64>().sqrt();
544    if length <= f64::MIN_POSITIVE {
545        return None;
546    }
547    for v in &mut out {
548        *v /= length;
549    }
550    Some(out)
551}
552
553/// Column indices of a minor, on the stack for the sizes walked here.
554type Columns = SmallVec<[usize; 8]>;
555
556/// `columns` with the entry at `position` struck out.
557fn without(columns: &[usize], position: usize) -> Columns {
558    columns
559        .iter()
560        .enumerate()
561        .filter(|(k, _)| *k != position)
562        .map(|(_, c)| *c)
563        .collect()
564}
565
566/// The determinant of the square minor of `rows` (from the last
567/// `columns.len()` rows up) on `columns`, by expansion along its first row.
568/// The sizes here are at most five.
569fn determinant(rows: &[Vec<f64>], columns: &[usize]) -> f64 {
570    let rows = &rows[rows.len() - columns.len()..];
571    match columns.len() {
572        0 => 1.0,
573        1 => rows[0][columns[0]],
574        2 => {
575            let (a, b) = (&rows[0], &rows[1]);
576            a[columns[0]].mul_add(b[columns[1]], -(a[columns[1]] * b[columns[0]]))
577        }
578        n => {
579            let mut total = 0.0;
580            for position in 0..n {
581                let sign = if position % 2 == 0 { 1.0 } else { -1.0 };
582                total += sign
583                    * rows[0][columns[position]]
584                    * determinant(&rows[1..], &without(columns, position));
585            }
586            total
587        }
588    }
589}
590
591#[cfg(test)]
592#[allow(clippy::unwrap_used, clippy::expect_used)]
593mod tests {
594    use super::*;
595
596    const T: Tolerances = Tolerances::millimetres();
597
598    /// The null vector by cofactors, computed on owned minors: what the
599    /// stack version must reproduce bit for bit.
600    fn cofactor_null(jacobian: &[Vec<f64>], n: usize) -> Vec<f64> {
601        fn det(m: &[Vec<f64>]) -> f64 {
602            match m.len() {
603                0 => 1.0,
604                1 => m[0][0],
605                2 => m[0][0].mul_add(m[1][1], -(m[0][1] * m[1][0])),
606                n => (0..n)
607                    .map(|c| {
608                        let minor: Vec<Vec<f64>> = m[1..]
609                            .iter()
610                            .map(|r| (0..n).filter(|&k| k != c).map(|k| r[k]).collect())
611                            .collect();
612                        let sign = if c % 2 == 0 { 1.0 } else { -1.0 };
613                        sign * m[0][c] * det(&minor)
614                    })
615                    .fold(0.0, |a, b| a + b),
616            }
617        }
618        let mut out: Vec<f64> = (0..n)
619            .map(|c| {
620                let minor: Vec<Vec<f64>> = jacobian
621                    .iter()
622                    .map(|r| (0..n).filter(|&k| k != c).map(|k| r[k]).collect())
623                    .collect();
624                let sign = if c % 2 == 0 { 1.0 } else { -1.0 };
625                sign * det(&minor)
626            })
627            .collect();
628        let length = out.iter().map(|v| v * v).sum::<f64>().sqrt();
629        for v in &mut out {
630            *v /= length;
631        }
632        out
633    }
634
635    #[test]
636    fn the_null_vector_matches_plain_cofactors_exactly() {
637        let mut seed = 0x2545_f491_4f6c_dd1d_u64;
638        let mut next = || {
639            seed ^= seed << 13;
640            seed ^= seed >> 7;
641            seed ^= seed << 17;
642            // The top 32 bits, exact in an `f64`, in [-0.5, 0.5).
643            f64::from((seed >> 32) as u32) / f64::from(u32::MAX) - 0.5
644        };
645        for n in 2..=5 {
646            for _ in 0..50 {
647                let jacobian: Vec<Vec<f64>> = (0..n - 1)
648                    .map(|_| (0..n).map(|_| next()).collect())
649                    .collect();
650                let got = null_vector(&jacobian, n).unwrap();
651                let want = cofactor_null(&jacobian, n);
652                assert_eq!(
653                    got.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
654                    want.iter().map(|v| v.to_bits()).collect::<Vec<_>>()
655                );
656            }
657        }
658    }
659
660    /// A circle of radius `r` about the origin in the `z = h` plane, posed in
661    /// three unknowns (the point's own coordinates) with two equations. A
662    /// deliberately silly condition, chosen because its answer is known
663    /// exactly and its Jacobian has nothing in common with a surface pair's.
664    struct CircleAt {
665        radius: f64,
666        height: f64,
667    }
668
669    impl Condition for CircleAt {
670        fn unknowns(&self) -> usize {
671            3
672        }
673        fn position(&self, x: &[f64], _tol: Tolerances) -> Option<Point> {
674            Some(Point::new(x[0], x[1], x[2]))
675        }
676        fn position_gradient(&self, _x: &[f64], _tol: Tolerances) -> Option<Vec<Vector>> {
677            Some(vec![Vector::X, Vector::Y, Vector::Z])
678        }
679        fn system(&self, x: &[f64], _tol: Tolerances) -> Option<(Vec<f64>, Vec<Vec<f64>>)> {
680            Some((
681                vec![
682                    x[0].mul_add(x[0], x[1] * x[1]) - self.radius * self.radius,
683                    x[2] - self.height,
684                ],
685                vec![vec![2.0 * x[0], 2.0 * x[1], 0.0], vec![0.0, 0.0, 1.0]],
686            ))
687        }
688        fn clamp(&self, _x: &mut [f64]) {}
689        fn outside(&self, _x: &[f64], _tol: Tolerances) -> bool {
690            false
691        }
692        fn near_edge(&self, _x: &[f64]) -> bool {
693            false
694        }
695        fn extent(&self) -> f64 {
696            self.radius * 4.0
697        }
698    }
699
700    /// The walker follows a condition it has never heard of, closes the loop,
701    /// and lands on the circle to the chord it was given, the tangent coming
702    /// from the null space alone, since this condition supplies no formula.
703    #[test]
704    fn a_condition_the_walker_knows_nothing_about_is_followed_to_its_chord() {
705        let circle = CircleAt {
706            radius: 3.0,
707            height: 1.5,
708        };
709        let options = Marching {
710            chord: 1e-5,
711            ..Marching::default()
712        };
713        let walked = follow(&circle, &[3.0, 0.0, 1.5], options, T).unwrap();
714        assert_eq!(walked.stopped, Stopped::Closed, "a circle closes");
715        assert!(walked.points.len() > 20, "{} points", walked.points.len());
716
717        for p in &walked.points {
718            assert!((p.x.hypot(p.y) - 3.0).abs() < 1e-9, "on the circle: {p:?}");
719            assert!((p.z - 1.5).abs() < 1e-9, "in its plane: {p:?}");
720        }
721        // The polyline's length is the circumference, to the chord's own sag.
722        let length: f64 = walked.points.windows(2).map(|w| w[0].distance(w[1])).sum();
723        let circumference = 2.0 * core::f64::consts::PI * 3.0;
724        assert!(
725            length <= circumference && length > circumference * (1.0 - 1e-4),
726            "the inscribed polygon: {length} against {circumference}"
727        );
728    }
729
730    /// The null vector is the direction the curve runs, for the shapes a
731    /// condition actually has.
732    #[test]
733    fn the_null_vector_is_the_generalized_cross_product() {
734        // Two unknowns, one equation: the perpendicular.
735        let null = null_vector(&[vec![3.0, 4.0]], 2).unwrap();
736        assert!((null[0] - 0.8).abs() < 1e-12 && (null[1] + 0.6).abs() < 1e-12);
737        // Three unknowns, two equations: the cross product of the rows.
738        let null = null_vector(&[vec![1.0, 0.0, 0.0], vec![0.0, 1.0, 0.0]], 3).unwrap();
739        assert!(null[0].abs() < 1e-12 && null[1].abs() < 1e-12 && null[2].abs() - 1.0 < 1e-12);
740        // A matrix whose rows are dependent has no curve to follow.
741        assert!(null_vector(&[vec![1.0, 2.0, 3.0], vec![2.0, 4.0, 6.0]], 3).is_none());
742    }
743}