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}