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//! which matters, because they are the parts that took the longest to get
16//! right and would be the easiest to get subtly wrong a second time.
17//!
18//! # What a condition owes the walker
19//!
20//! `n` unknowns and `n − 1` equations. That shortfall is not an oversight: the
21//! solution set of `n − 1` equations in `n` unknowns *is* a curve, which is
22//! what there is to follow. The walker supplies the missing equation itself
23//! (a plane across the direction of travel, saying how far along to land), and
24//! that is what turns "somewhere on the curve" into "the next point".
25//!
26//! The direction of travel comes free. The curve's tangent in parameter space
27//! is the null vector of the condition's own Jacobian, and a condition that
28//! has a cheaper or more careful formula for it (the intersector does, and
29//! uses it to refuse a crossing too shallow to trust) says so by overriding
30//! [`Condition::tangent`].
31
32use crate::march::{Marching, Stopped};
33use ogeom_core::{OgeomResult, Tolerances};
34use ogeom_math::{Point, Vector, solve};
35
36/// A curve stated as what it satisfies, and everything needed to follow it.
37///
38/// The parameter vector is whatever the condition is posed in: four numbers
39/// for a surface pair, two for a silhouette, five for a blend section marching
40/// a guide. The walker never interprets them.
41pub trait Condition {
42    /// How many unknowns the condition is posed in.
43    fn unknowns(&self) -> usize;
44
45    /// Where a parameter vector puts the curve in space.
46    fn position(&self, x: &[f64], tol: Tolerances) -> Option<Point>;
47
48    /// How the position moves with each unknown: one vector per unknown.
49    ///
50    /// The walker needs this to write its own travel equation, which is a
51    /// statement about where the *point* goes rather than about the
52    /// parameters.
53    fn position_gradient(&self, x: &[f64], tol: Tolerances) -> Option<Vec<Vector>>;
54
55    /// The condition itself: `n − 1` residuals, and the Jacobian of them.
56    ///
57    /// `None` where the condition cannot be evaluated there at all, which the
58    /// walker reads as a stall rather than as a zero.
59    fn system(&self, x: &[f64], tol: Tolerances) -> Option<(Vec<f64>, Vec<Vec<f64>>)>;
60
61    /// Bring a parameter vector back into the region the condition is posed
62    /// on. Called before every evaluation, so a condition may assume it.
63    fn clamp(&self, x: &mut [f64]);
64
65    /// Whether a parameter vector has left that region.
66    fn outside(&self, x: &[f64], tol: Tolerances) -> bool;
67
68    /// Whether it is at the edge of it, which is how a stall at a boundary is
69    /// told apart from a stall at a singularity.
70    fn near_edge(&self, x: &[f64]) -> bool;
71
72    /// A length scale for the step control: how big the thing being walked is.
73    fn extent(&self) -> f64;
74
75    /// Whether [`Condition::tangent`]'s *sign* is its own, continuous along
76    /// the curve, or arbitrary from point to point.
77    ///
78    /// A null vector's sign is whatever the arithmetic gave it, so the default
79    /// answer is no and the walker keeps its own heading. Saying yes is a
80    /// claim, and a load-bearing one: where two surfaces touch, the cross
81    /// product of their normals swaps sides, and a walker that quietly turned
82    /// it back round would march from one branch onto the other straight
83    /// through the tangency: two thin curves through two touching points
84    /// coming back as one confident loop that is on neither of them. The flip
85    /// is the signal, not noise.
86    fn tangent_is_oriented(&self) -> bool {
87        false
88    }
89
90    /// The direction the curve runs, as a unit vector in space.
91    ///
92    /// The default derives it from the condition's own Jacobian: the tangent
93    /// in parameter space is that matrix's null vector, and the space tangent
94    /// is the position gradient applied to it. A condition with a cheaper or
95    /// more careful formula overrides this, and "more careful" is not
96    /// hypothetical, since the null vector says nothing about whether the
97    /// direction it found is real or is the residual's own noise.
98    fn tangent(&self, x: &[f64], tol: Tolerances) -> Option<Vector> {
99        let (_, jacobian) = self.system(x, tol)?;
100        let null = null_vector(&jacobian, self.unknowns())?;
101        let gradient = self.position_gradient(x, tol)?;
102        let mut out = Vector::ZERO;
103        for (g, n) in gradient.iter().zip(&null) {
104            out += *g * *n;
105        }
106        let length = out.magnitude();
107        if length <= tol.confusion() {
108            return None;
109        }
110        Some(out / length)
111    }
112}
113
114/// One walked curve.
115#[derive(Debug, Clone, PartialEq)]
116pub struct Walked {
117    /// The parameter vector at each point, in order.
118    pub states: Vec<Vec<f64>>,
119    /// Where each is in space.
120    pub points: Vec<Point>,
121    /// Why it stopped.
122    pub stopped: Stopped,
123}
124
125/// Follow a condition's curve both ways from a starting point.
126///
127/// Forwards first; if that closes, the curve is a loop and there is nothing
128/// behind. Otherwise the backward half is walked and the two are joined.
129///
130/// # Errors
131///
132/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
133/// settings are unusable, or the start does not have the condition's own
134/// number of unknowns.
135pub fn follow<C: Condition + ?Sized>(
136    condition: &C,
137    start: &[f64],
138    options: Marching,
139    tol: Tolerances,
140) -> OgeomResult<Walked> {
141    options.validate()?;
142    if start.len() != condition.unknowns() {
143        ogeom_core::ogeom_bail!(
144            Construction,
145            "the condition is posed in {} unknowns and the start has {}",
146            condition.unknowns(),
147            start.len()
148        );
149    }
150    let ahead = walk_one_way(condition, start, 1.0, options, tol)?;
151    if ahead.stopped == Stopped::Closed {
152        return Ok(ahead);
153    }
154    let behind = walk_one_way(condition, start, -1.0, options, tol)?;
155
156    let mut states = behind.states;
157    let mut points = behind.points;
158    states.reverse();
159    points.reverse();
160    states.pop();
161    points.pop();
162    states.extend(ahead.states);
163    points.extend(ahead.points);
164
165    // The worse of the two reasons: a curve truncated at either end is
166    // truncated.
167    let stopped = if ahead.stopped == Stopped::RanOut || behind.stopped == Stopped::RanOut {
168        Stopped::RanOut
169    } else if ahead.stopped == Stopped::Stalled || behind.stopped == Stopped::Stalled {
170        Stopped::Stalled
171    } else {
172        Stopped::LeftTheDomain
173    };
174    Ok(Walked {
175        states,
176        points,
177        stopped,
178    })
179}
180
181/// Walk one way from a start.
182///
183/// # Errors
184///
185/// Only through the progress sink; a walk that goes nowhere reports why in
186/// [`Walked::stopped`] rather than failing.
187pub fn walk_one_way<C: Condition + ?Sized>(
188    condition: &C,
189    start: &[f64],
190    sense: f64,
191    options: Marching,
192    tol: Tolerances,
193) -> OgeomResult<Walked> {
194    let mut at: Vec<f64> = start.to_vec();
195    condition.clamp(&mut at);
196    let Some(from) = condition.position(&at, tol) else {
197        return Ok(Walked {
198            states: vec![at],
199            points: Vec::new(),
200            stopped: Stopped::Stalled,
201        });
202    };
203    let mut states = vec![at.clone()];
204    let mut points = vec![from];
205    let mut stopped = Stopped::RanOut;
206
207    // The step is set by how far the chord may sag from the arc, and the sag
208    // is measured rather than assumed: about `h · turn / 8`, where `turn` is
209    // the angle between successive tangents. So the step that just meets the
210    // tolerance is found by control rather than by a constant.
211    let reach = condition.extent();
212    let ceiling = reach / 8.0;
213    let mut step = (options.chord * reach)
214        .sqrt()
215        .clamp(tol.confusion(), ceiling);
216    // The null-space tangent's sign is arbitrary from point to point, so the
217    // walk carries the direction it is going and keeps to it.
218    let mut heading: Option<Vector> = None;
219
220    while points.len() < options.max_points {
221        ogeom_core::progress::checkpoint()?;
222        let Some(direction) = oriented(condition, &at, heading, sense, tol) else {
223            stopped = Stopped::Stalled;
224            break;
225        };
226        let here = points[points.len() - 1];
227
228        let mut taken = None;
229        for _ in 0..40 {
230            let Some(next) = correct(condition, &at, (here, direction, step), tol) else {
231                step *= 0.5;
232                if step <= tol.confusion() {
233                    break;
234                }
235                continue;
236            };
237            // Like against like: the *travel* direction at the next point,
238            // sensed the same way, or a backward walk would read every step
239            // as a half turn and crawl to a halt.
240            let turn = oriented(condition, &next.0, Some(direction), sense, tol)
241                .map_or(0.0, |t| direction.dot(t).clamp(-1.0, 1.0).acos());
242            let sag = step * turn / 8.0;
243            if sag <= options.chord || step <= tol.confusion() * 8.0 {
244                // Aim the next step at exactly the tolerance. Sag grows with
245                // the square of the step, so the correction is a square root,
246                // damped so one tight corner does not make the rest of the
247                // curve expensive nor one straight stretch overshoot.
248                let scale = if sag > 0.0 {
249                    (options.chord / sag).sqrt().clamp(0.5, 2.0)
250                } else {
251                    2.0
252                };
253                taken = Some((next, (step * scale).clamp(tol.confusion(), ceiling)));
254                break;
255            }
256            step *= (options.chord / sag).sqrt().clamp(0.25, 0.9);
257        }
258        let Some(((next_state, next_point), following)) = taken else {
259            // A stall right at a domain edge is the edge, not a singularity:
260            // the walk converges on the boundary from inside and the
261            // correction starts failing when the step would cross it, so the
262            // last accepted point sits a fraction of a step short.
263            stopped = if condition.near_edge(&at) {
264                Stopped::LeftTheDomain
265            } else {
266                Stopped::Stalled
267            };
268            break;
269        };
270
271        // Back where we started: a closed loop. Only checked once the walk has
272        // gone far enough to have left, or every curve would close at once.
273        if points.len() > 3 && next_point.distance(from) <= step {
274            states.push(states[0].clone());
275            points.push(from);
276            stopped = Stopped::Closed;
277            break;
278        }
279        if condition.outside(&next_state, tol) {
280            stopped = Stopped::LeftTheDomain;
281            break;
282        }
283
284        heading = Some(direction);
285        states.push(next_state.clone());
286        points.push(next_point);
287        at = next_state;
288        step = following;
289    }
290
291    Ok(Walked {
292        states,
293        points,
294        stopped,
295    })
296}
297
298/// The tangent, turned to keep going the way the walk is going.
299fn oriented<C: Condition + ?Sized>(
300    condition: &C,
301    at: &[f64],
302    heading: Option<Vector>,
303    sense: f64,
304    tol: Tolerances,
305) -> Option<Vector> {
306    let direction = condition.tangent(at, tol)?;
307    if condition.tangent_is_oriented() {
308        // The condition's own sign, kept exactly, including where it flips.
309        return Some(direction * sense);
310    }
311    let along = match heading {
312        // A null vector's sign is whatever the arithmetic gave it; what the
313        // walk means by "onward" is the way it was already going.
314        Some(previous) if direction.dot(previous) < 0.0 => -direction,
315        _ => direction,
316    };
317    Some(if heading.is_none() {
318        along * sense
319    } else {
320        along
321    })
322}
323
324/// Bring a guess onto the condition, landing a stated distance along.
325///
326/// The condition's own `n − 1` equations say *on the curve*; the walker's one
327/// more says *this far along it*. Without that row the system would be
328/// underdetermined and Newton would wander along the curve instead of
329/// converging to a point on it.
330fn correct<C: Condition + ?Sized>(
331    condition: &C,
332    from: &[f64],
333    (anchor, along, reach): (Point, Vector, f64),
334    tol: Tolerances,
335) -> Option<(Vec<f64>, Point)> {
336    let n = condition.unknowns();
337    let system = |x: &[f64]| {
338        let mut at = x.to_vec();
339        condition.clamp(&mut at);
340        let (mut residual, mut jacobian) = condition
341            .system(&at, tol)
342            .unwrap_or_else(|| (vec![0.0; n - 1], vec![vec![0.0; n]; n - 1]));
343        let point = condition.position(&at, tol).unwrap_or(Point::ORIGIN);
344        let gradient = condition
345            .position_gradient(&at, tol)
346            .unwrap_or_else(|| vec![Vector::ZERO; n]);
347        residual.push((point - anchor).dot(along) - reach);
348        jacobian.push(gradient.iter().map(|g| g.dot(along)).collect());
349        (residual, jacobian)
350    };
351    let criteria = solve::Criteria {
352        residual: tol.confusion() * 0.01,
353        step: tol.parametric(),
354        max_iterations: 40,
355    };
356    let found = solve::newton_system(system, from, criteria).ok()?;
357    if found.residual > tol.confusion() {
358        return None;
359    }
360    let mut at = found.value;
361    condition.clamp(&mut at);
362    let point = condition.position(&at, tol)?;
363    Some((at, point))
364}
365
366/// The null vector of an `(n − 1) × n` matrix: the generalized cross product.
367///
368/// Component `i` is the determinant of the matrix with column `i` struck out,
369/// signed by `(−1)^i`, which is exactly the cross product for `n = 3` and the
370/// perpendicular for `n = 2`, and is the direction the curve runs for any `n`.
371/// `None` where the matrix has full rank, which means the "curve" is a point
372/// and there is nothing to follow.
373fn null_vector(jacobian: &[Vec<f64>], n: usize) -> Option<Vec<f64>> {
374    if n == 0 || jacobian.len() + 1 != n {
375        return None;
376    }
377    let mut out = Vec::with_capacity(n);
378    for column in 0..n {
379        let minor: Vec<Vec<f64>> = jacobian
380            .iter()
381            .map(|row| {
382                row.iter()
383                    .enumerate()
384                    .filter(|(k, _)| *k != column)
385                    .map(|(_, v)| *v)
386                    .collect()
387            })
388            .collect();
389        let sign = if column % 2 == 0 { 1.0 } else { -1.0 };
390        out.push(sign * determinant(&minor));
391    }
392    let length = out.iter().map(|v| v * v).sum::<f64>().sqrt();
393    if length <= f64::MIN_POSITIVE {
394        return None;
395    }
396    for v in &mut out {
397        *v /= length;
398    }
399    Some(out)
400}
401
402/// A small square determinant, by expansion. The sizes here are at most four.
403fn determinant(matrix: &[Vec<f64>]) -> f64 {
404    match matrix.len() {
405        0 => 1.0,
406        1 => matrix[0][0],
407        2 => matrix[0][0].mul_add(matrix[1][1], -(matrix[0][1] * matrix[1][0])),
408        n => {
409            let mut total = 0.0;
410            for column in 0..n {
411                let minor: Vec<Vec<f64>> = matrix[1..]
412                    .iter()
413                    .map(|row| {
414                        row.iter()
415                            .enumerate()
416                            .filter(|(k, _)| *k != column)
417                            .map(|(_, v)| *v)
418                            .collect()
419                    })
420                    .collect();
421                let sign = if column % 2 == 0 { 1.0 } else { -1.0 };
422                total += sign * matrix[0][column] * determinant(&minor);
423            }
424            total
425        }
426    }
427}
428
429#[cfg(test)]
430#[allow(clippy::unwrap_used, clippy::expect_used)]
431mod tests {
432    use super::*;
433
434    const T: Tolerances = Tolerances::millimetres();
435
436    /// A circle of radius `r` about the origin in the `z = h` plane, posed in
437    /// three unknowns (the point's own coordinates) with two equations. A
438    /// deliberately silly condition, chosen because its answer is known
439    /// exactly and its Jacobian has nothing in common with a surface pair's.
440    struct CircleAt {
441        radius: f64,
442        height: f64,
443    }
444
445    impl Condition for CircleAt {
446        fn unknowns(&self) -> usize {
447            3
448        }
449        fn position(&self, x: &[f64], _tol: Tolerances) -> Option<Point> {
450            Some(Point::new(x[0], x[1], x[2]))
451        }
452        fn position_gradient(&self, _x: &[f64], _tol: Tolerances) -> Option<Vec<Vector>> {
453            Some(vec![Vector::X, Vector::Y, Vector::Z])
454        }
455        fn system(&self, x: &[f64], _tol: Tolerances) -> Option<(Vec<f64>, Vec<Vec<f64>>)> {
456            Some((
457                vec![
458                    x[0].mul_add(x[0], x[1] * x[1]) - self.radius * self.radius,
459                    x[2] - self.height,
460                ],
461                vec![vec![2.0 * x[0], 2.0 * x[1], 0.0], vec![0.0, 0.0, 1.0]],
462            ))
463        }
464        fn clamp(&self, _x: &mut [f64]) {}
465        fn outside(&self, _x: &[f64], _tol: Tolerances) -> bool {
466            false
467        }
468        fn near_edge(&self, _x: &[f64]) -> bool {
469            false
470        }
471        fn extent(&self) -> f64 {
472            self.radius * 4.0
473        }
474    }
475
476    /// The walker follows a condition it has never heard of, closes the loop,
477    /// and lands on the circle to the chord it was given, the tangent coming
478    /// from the null space alone, since this condition supplies no formula.
479    #[test]
480    fn a_condition_the_walker_knows_nothing_about_is_followed_to_its_chord() {
481        let circle = CircleAt {
482            radius: 3.0,
483            height: 1.5,
484        };
485        let options = Marching {
486            chord: 1e-5,
487            ..Marching::default()
488        };
489        let walked = follow(&circle, &[3.0, 0.0, 1.5], options, T).unwrap();
490        assert_eq!(walked.stopped, Stopped::Closed, "a circle closes");
491        assert!(walked.points.len() > 20, "{} points", walked.points.len());
492
493        for p in &walked.points {
494            assert!((p.x.hypot(p.y) - 3.0).abs() < 1e-9, "on the circle: {p:?}");
495            assert!((p.z - 1.5).abs() < 1e-9, "in its plane: {p:?}");
496        }
497        // The polyline's length is the circumference, to the chord's own sag.
498        let length: f64 = walked.points.windows(2).map(|w| w[0].distance(w[1])).sum();
499        let circumference = 2.0 * core::f64::consts::PI * 3.0;
500        assert!(
501            length <= circumference && length > circumference * (1.0 - 1e-4),
502            "the inscribed polygon: {length} against {circumference}"
503        );
504    }
505
506    /// The null vector is the direction the curve runs, for the shapes a
507    /// condition actually has.
508    #[test]
509    fn the_null_vector_is_the_generalized_cross_product() {
510        // Two unknowns, one equation: the perpendicular.
511        let null = null_vector(&[vec![3.0, 4.0]], 2).unwrap();
512        assert!((null[0] - 0.8).abs() < 1e-12 && (null[1] + 0.6).abs() < 1e-12);
513        // Three unknowns, two equations: the cross product of the rows.
514        let null = null_vector(&[vec![1.0, 0.0, 0.0], vec![0.0, 1.0, 0.0]], 3).unwrap();
515        assert!(null[0].abs() < 1e-12 && null[1].abs() < 1e-12 && null[2].abs() - 1.0 < 1e-12);
516        // A matrix whose rows are dependent has no curve to follow.
517        assert!(null_vector(&[vec![1.0, 2.0, 3.0], vec![2.0, 4.0, 6.0]], 3).is_none());
518    }
519}