use crate::march::{Marching, Stopped};
use ogeom_core::{OgeomResult, Tolerances};
use ogeom_math::{Point, Vector, solve};
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>>)>;
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;
while points.len() < options.max_points {
ogeom_core::progress::checkpoint()?;
let Some(direction) = oriented(condition, &at, heading, sense, tol) 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 turn = oriented(condition, &next.0, Some(direction), sense, tol)
.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)));
break;
}
step *= (options.chord / sag).sqrt().clamp(0.25, 0.9);
}
let Some(((next_state, next_point), following)) = 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.clone());
points.push(next_point);
at = next_state;
step = following;
}
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 (mut residual, mut jacobian) = condition
.system(&at, tol)
.unwrap_or_else(|| (vec![0.0; n - 1], vec![vec![0.0; n]; n - 1]));
let point = condition.position(&at, tol).unwrap_or(Point::ORIGIN);
let gradient = condition
.position_gradient(&at, tol)
.unwrap_or_else(|| vec![Vector::ZERO; 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 {
return None;
}
let mut out = Vec::with_capacity(n);
for column in 0..n {
let minor: Vec<Vec<f64>> = jacobian
.iter()
.map(|row| {
row.iter()
.enumerate()
.filter(|(k, _)| *k != column)
.map(|(_, v)| *v)
.collect()
})
.collect();
let sign = if column % 2 == 0 { 1.0 } else { -1.0 };
out.push(sign * determinant(&minor));
}
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)
}
fn determinant(matrix: &[Vec<f64>]) -> f64 {
match matrix.len() {
0 => 1.0,
1 => matrix[0][0],
2 => matrix[0][0].mul_add(matrix[1][1], -(matrix[0][1] * matrix[1][0])),
n => {
let mut total = 0.0;
for column in 0..n {
let minor: Vec<Vec<f64>> = matrix[1..]
.iter()
.map(|row| {
row.iter()
.enumerate()
.filter(|(k, _)| *k != column)
.map(|(_, v)| *v)
.collect()
})
.collect();
let sign = if column % 2 == 0 { 1.0 } else { -1.0 };
total += sign * matrix[0][column] * determinant(&minor);
}
total
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
const T: Tolerances = Tolerances::millimetres();
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());
}
}