use crate::march::{Marching, Stopped};
use ogeom_core::{OgeomResult, Tolerances};
use ogeom_math::{Point, Vector, solve};
use smallvec::SmallVec;
pub trait Condition {
fn unknowns(&self) -> usize;
fn position(&self, x: &[f64], tol: Tolerances) -> Option<Point>;
fn position_gradient(&self, x: &[f64], tol: Tolerances) -> Option<Vec<Vector>>;
fn system(&self, x: &[f64], tol: Tolerances) -> Option<(Vec<f64>, Vec<Vec<f64>>)>;
#[allow(clippy::type_complexity, reason = "the three answers, together")]
fn system_at(
&self,
x: &[f64],
tol: Tolerances,
) -> Option<((Vec<f64>, Vec<Vec<f64>>), Point, Vec<Vector>)> {
Some((
self.system(x, tol)?,
self.position(x, tol)?,
self.position_gradient(x, tol)?,
))
}
fn clamp(&self, x: &mut [f64]);
fn outside(&self, x: &[f64], tol: Tolerances) -> bool;
fn near_edge(&self, x: &[f64]) -> bool;
fn extent(&self) -> f64;
fn tangent_is_oriented(&self) -> bool {
false
}
fn tangent(&self, x: &[f64], tol: Tolerances) -> Option<Vector> {
let (_, jacobian) = self.system(x, tol)?;
let null = null_vector(&jacobian, self.unknowns())?;
let gradient = self.position_gradient(x, tol)?;
let mut out = Vector::ZERO;
for (g, n) in gradient.iter().zip(&null) {
out += *g * *n;
}
let length = out.magnitude();
if length <= tol.confusion() {
return None;
}
Some(out / length)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Walked {
pub states: Vec<Vec<f64>>,
pub points: Vec<Point>,
pub stopped: Stopped,
}
pub fn follow<C: Condition + ?Sized>(
condition: &C,
start: &[f64],
options: Marching,
tol: Tolerances,
) -> OgeomResult<Walked> {
options.validate()?;
if start.len() != condition.unknowns() {
ogeom_core::ogeom_bail!(
Construction,
"the condition is posed in {} unknowns and the start has {}",
condition.unknowns(),
start.len()
);
}
let ahead = walk_one_way(condition, start, 1.0, options, tol)?;
if ahead.stopped == Stopped::Closed {
return Ok(ahead);
}
let behind = walk_one_way(condition, start, -1.0, options, tol)?;
let mut states = behind.states;
let mut points = behind.points;
states.reverse();
points.reverse();
states.pop();
points.pop();
states.extend(ahead.states);
points.extend(ahead.points);
let stopped = if ahead.stopped == Stopped::RanOut || behind.stopped == Stopped::RanOut {
Stopped::RanOut
} else if ahead.stopped == Stopped::Stalled || behind.stopped == Stopped::Stalled {
Stopped::Stalled
} else {
Stopped::LeftTheDomain
};
Ok(Walked {
states,
points,
stopped,
})
}
pub fn walk_one_way<C: Condition + ?Sized>(
condition: &C,
start: &[f64],
sense: f64,
options: Marching,
tol: Tolerances,
) -> OgeomResult<Walked> {
let mut at: Vec<f64> = start.to_vec();
condition.clamp(&mut at);
let Some(from) = condition.position(&at, tol) else {
return Ok(Walked {
states: vec![at],
points: Vec::new(),
stopped: Stopped::Stalled,
});
};
let mut states = vec![at.clone()];
let mut points = vec![from];
let mut stopped = Stopped::RanOut;
let reach = condition.extent();
let ceiling = reach / 8.0;
let mut step = (options.chord * reach)
.sqrt()
.clamp(tol.confusion(), ceiling);
let mut heading: Option<Vector> = None;
let mut ahead: Option<Option<Vector>> = None;
while points.len() < options.max_points {
ogeom_core::progress::checkpoint()?;
let direction = match ahead.take() {
Some(known) => known,
None => oriented(condition, &at, heading, sense, tol),
};
let Some(direction) = direction else {
stopped = Stopped::Stalled;
break;
};
let here = points[points.len() - 1];
let mut taken = None;
for _ in 0..40 {
let Some(next) = correct(condition, &at, (here, direction, step), tol) else {
step *= 0.5;
if step <= tol.confusion() {
break;
}
continue;
};
let there = oriented(condition, &next.0, Some(direction), sense, tol);
let turn = there.map_or(0.0, |t| direction.dot(t).clamp(-1.0, 1.0).acos());
let sag = step * turn / 8.0;
if sag <= options.chord || step <= tol.confusion() * 8.0 {
let scale = if sag > 0.0 {
(options.chord / sag).sqrt().clamp(0.5, 2.0)
} else {
2.0
};
taken = Some((next, (step * scale).clamp(tol.confusion(), ceiling), there));
break;
}
step *= (options.chord / sag).sqrt().clamp(0.25, 0.9);
}
let Some(((next_state, next_point), following, there)) = taken else {
stopped = if condition.near_edge(&at) {
Stopped::LeftTheDomain
} else {
Stopped::Stalled
};
break;
};
if points.len() > 3 && next_point.distance(from) <= step {
states.push(states[0].clone());
points.push(from);
stopped = Stopped::Closed;
break;
}
if condition.outside(&next_state, tol) {
stopped = Stopped::LeftTheDomain;
break;
}
heading = Some(direction);
states.push(next_state);
points.push(next_point);
at.clone_from(&states[states.len() - 1]);
step = following;
ahead = Some(there);
}
Ok(Walked {
states,
points,
stopped,
})
}
fn oriented<C: Condition + ?Sized>(
condition: &C,
at: &[f64],
heading: Option<Vector>,
sense: f64,
tol: Tolerances,
) -> Option<Vector> {
let direction = condition.tangent(at, tol)?;
if condition.tangent_is_oriented() {
return Some(direction * sense);
}
let along = match heading {
Some(previous) if direction.dot(previous) < 0.0 => -direction,
_ => direction,
};
Some(if heading.is_none() {
along * sense
} else {
along
})
}
fn correct<C: Condition + ?Sized>(
condition: &C,
from: &[f64],
(anchor, along, reach): (Point, Vector, f64),
tol: Tolerances,
) -> Option<(Vec<f64>, Point)> {
let n = condition.unknowns();
let system = |x: &[f64]| {
let mut at = x.to_vec();
condition.clamp(&mut at);
let Some(((mut residual, mut jacobian), point, gradient)) = condition.system_at(&at, tol)
else {
return (vec![f64::INFINITY; n], vec![vec![0.0; n]; n]);
};
residual.push((point - anchor).dot(along) - reach);
jacobian.push(gradient.iter().map(|g| g.dot(along)).collect());
(residual, jacobian)
};
let criteria = solve::Criteria {
residual: tol.confusion() * 0.01,
step: tol.parametric(),
max_iterations: 40,
};
let found = solve::newton_system(system, from, criteria).ok()?;
if found.residual > tol.confusion() {
return None;
}
let mut at = found.value;
condition.clamp(&mut at);
let point = condition.position(&at, tol)?;
Some((at, point))
}
fn null_vector(jacobian: &[Vec<f64>], n: usize) -> Option<Vec<f64>> {
if n == 0 || jacobian.len() + 1 != n || jacobian.iter().any(|row| row.len() < n) {
return None;
}
let all: Columns = (0..n).collect();
let mut out = Vec::with_capacity(n);
for column in 0..n {
let kept = without(&all, column);
let sign = if column % 2 == 0 { 1.0 } else { -1.0 };
out.push(sign * determinant(jacobian, &kept));
}
let length = out.iter().map(|v| v * v).sum::<f64>().sqrt();
if length <= f64::MIN_POSITIVE {
return None;
}
for v in &mut out {
*v /= length;
}
Some(out)
}
type Columns = SmallVec<[usize; 8]>;
fn without(columns: &[usize], position: usize) -> Columns {
columns
.iter()
.enumerate()
.filter(|(k, _)| *k != position)
.map(|(_, c)| *c)
.collect()
}
fn determinant(rows: &[Vec<f64>], columns: &[usize]) -> f64 {
let rows = &rows[rows.len() - columns.len()..];
match columns.len() {
0 => 1.0,
1 => rows[0][columns[0]],
2 => {
let (a, b) = (&rows[0], &rows[1]);
a[columns[0]].mul_add(b[columns[1]], -(a[columns[1]] * b[columns[0]]))
}
n => {
let mut total = 0.0;
for position in 0..n {
let sign = if position % 2 == 0 { 1.0 } else { -1.0 };
total += sign
* rows[0][columns[position]]
* determinant(&rows[1..], &without(columns, position));
}
total
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
const T: Tolerances = Tolerances::millimetres();
fn cofactor_null(jacobian: &[Vec<f64>], n: usize) -> Vec<f64> {
fn det(m: &[Vec<f64>]) -> f64 {
match m.len() {
0 => 1.0,
1 => m[0][0],
2 => m[0][0].mul_add(m[1][1], -(m[0][1] * m[1][0])),
n => (0..n)
.map(|c| {
let minor: Vec<Vec<f64>> = m[1..]
.iter()
.map(|r| (0..n).filter(|&k| k != c).map(|k| r[k]).collect())
.collect();
let sign = if c % 2 == 0 { 1.0 } else { -1.0 };
sign * m[0][c] * det(&minor)
})
.fold(0.0, |a, b| a + b),
}
}
let mut out: Vec<f64> = (0..n)
.map(|c| {
let minor: Vec<Vec<f64>> = jacobian
.iter()
.map(|r| (0..n).filter(|&k| k != c).map(|k| r[k]).collect())
.collect();
let sign = if c % 2 == 0 { 1.0 } else { -1.0 };
sign * det(&minor)
})
.collect();
let length = out.iter().map(|v| v * v).sum::<f64>().sqrt();
for v in &mut out {
*v /= length;
}
out
}
#[test]
fn the_null_vector_matches_plain_cofactors_exactly() {
let mut seed = 0x2545_f491_4f6c_dd1d_u64;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
f64::from((seed >> 32) as u32) / f64::from(u32::MAX) - 0.5
};
for n in 2..=5 {
for _ in 0..50 {
let jacobian: Vec<Vec<f64>> = (0..n - 1)
.map(|_| (0..n).map(|_| next()).collect())
.collect();
let got = null_vector(&jacobian, n).unwrap();
let want = cofactor_null(&jacobian, n);
assert_eq!(
got.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
want.iter().map(|v| v.to_bits()).collect::<Vec<_>>()
);
}
}
}
struct CircleAt {
radius: f64,
height: f64,
}
impl Condition for CircleAt {
fn unknowns(&self) -> usize {
3
}
fn position(&self, x: &[f64], _tol: Tolerances) -> Option<Point> {
Some(Point::new(x[0], x[1], x[2]))
}
fn position_gradient(&self, _x: &[f64], _tol: Tolerances) -> Option<Vec<Vector>> {
Some(vec![Vector::X, Vector::Y, Vector::Z])
}
fn system(&self, x: &[f64], _tol: Tolerances) -> Option<(Vec<f64>, Vec<Vec<f64>>)> {
Some((
vec![
x[0].mul_add(x[0], x[1] * x[1]) - self.radius * self.radius,
x[2] - self.height,
],
vec![vec![2.0 * x[0], 2.0 * x[1], 0.0], vec![0.0, 0.0, 1.0]],
))
}
fn clamp(&self, _x: &mut [f64]) {}
fn outside(&self, _x: &[f64], _tol: Tolerances) -> bool {
false
}
fn near_edge(&self, _x: &[f64]) -> bool {
false
}
fn extent(&self) -> f64 {
self.radius * 4.0
}
}
#[test]
fn a_condition_the_walker_knows_nothing_about_is_followed_to_its_chord() {
let circle = CircleAt {
radius: 3.0,
height: 1.5,
};
let options = Marching {
chord: 1e-5,
..Marching::default()
};
let walked = follow(&circle, &[3.0, 0.0, 1.5], options, T).unwrap();
assert_eq!(walked.stopped, Stopped::Closed, "a circle closes");
assert!(walked.points.len() > 20, "{} points", walked.points.len());
for p in &walked.points {
assert!((p.x.hypot(p.y) - 3.0).abs() < 1e-9, "on the circle: {p:?}");
assert!((p.z - 1.5).abs() < 1e-9, "in its plane: {p:?}");
}
let length: f64 = walked.points.windows(2).map(|w| w[0].distance(w[1])).sum();
let circumference = 2.0 * core::f64::consts::PI * 3.0;
assert!(
length <= circumference && length > circumference * (1.0 - 1e-4),
"the inscribed polygon: {length} against {circumference}"
);
}
#[test]
fn the_null_vector_is_the_generalized_cross_product() {
let null = null_vector(&[vec![3.0, 4.0]], 2).unwrap();
assert!((null[0] - 0.8).abs() < 1e-12 && (null[1] + 0.6).abs() < 1e-12);
let null = null_vector(&[vec![1.0, 0.0, 0.0], vec![0.0, 1.0, 0.0]], 3).unwrap();
assert!(null[0].abs() < 1e-12 && null[1].abs() < 1e-12 && null[2].abs() - 1.0 < 1e-12);
assert!(null_vector(&[vec![1.0, 2.0, 3.0], vec![2.0, 4.0, 6.0]], 3).is_none());
}
}