use axiolid_contracts::{GeomError, GeomResult};
use axiolid_core::{Point3, Scalar, Vec3};
use axiolid_mesh::TriMesh;
use crate::loft::{self, Frame, Station};
use crate::revolve::rotate;
use crate::sweep;
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum PathPiece {
Segment {
start: Point3,
end: Point3,
},
Arc {
centre: Point3,
axis: Vec3,
start: Point3,
angle: Scalar,
},
}
impl PathPiece {
#[must_use]
pub fn start_point(&self) -> Point3 {
match *self {
Self::Segment { start, .. } | Self::Arc { start, .. } => start,
}
}
#[must_use]
pub fn end_point(&self) -> Point3 {
self.point(1.0)
}
#[must_use]
pub fn point(&self, s: Scalar) -> Point3 {
match *self {
Self::Segment { start, end } => start + (end - start) * s,
Self::Arc {
centre,
axis,
start,
angle,
} => rotate(start, centre, axis, angle * s),
}
}
fn tangent_at(&self, point: Point3) -> Vec3 {
match *self {
Self::Segment { start, end } => (end - start).normalize_or_zero(),
Self::Arc {
centre,
axis,
angle,
..
} => (axis.cross(point - centre) * angle.signum()).normalize_or_zero(),
}
}
#[must_use]
pub fn start_tangent(&self) -> Vec3 {
self.tangent_at(self.start_point())
}
#[must_use]
pub fn end_tangent(&self) -> Vec3 {
self.tangent_at(self.end_point())
}
#[must_use]
pub fn length(&self) -> Scalar {
match *self {
Self::Segment { start, end } => (end - start).length(),
Self::Arc {
centre,
start,
angle,
..
} => (start - centre).length() * angle.abs(),
}
}
#[must_use]
pub fn reversed(&self) -> Self {
match *self {
Self::Segment { start, end } => Self::Segment {
start: end,
end: start,
},
Self::Arc {
centre,
axis,
angle,
..
} => Self::Arc {
centre,
axis,
start: self.end_point(),
angle: -angle,
},
}
}
#[must_use]
pub fn between(&self, from: Scalar, to: Scalar) -> Self {
match *self {
Self::Segment { .. } => Self::Segment {
start: self.point(from),
end: self.point(to),
},
Self::Arc {
centre,
axis,
angle,
..
} => Self::Arc {
centre,
axis,
start: self.point(from),
angle: angle * (to - from),
},
}
}
fn check(&self, radius: Scalar) -> GeomResult<()> {
let finite = |p: Point3| p.is_finite();
match *self {
Self::Segment { start, end } => {
if !(finite(start) && finite(end)) {
return Err(GeomError::InvalidInput(
"swept disk directrix segment must be finite".to_owned(),
));
}
if start == end {
return Err(GeomError::Degenerate(
"swept disk directrix segment has no length".to_owned(),
));
}
}
Self::Arc {
centre,
axis,
start,
angle,
} => {
if !(finite(centre) && finite(axis) && finite(start) && angle.is_finite()) {
return Err(GeomError::InvalidInput(
"swept disk directrix arc must be finite".to_owned(),
));
}
let bend = (start - centre).length();
if (axis.length() - 1.0).abs() > 1e-9
|| bend == 0.0
|| (start - centre).dot(axis).abs() > 1e-9 * bend
{
return Err(GeomError::InvalidInput(
"swept disk directrix arc needs a unit axis normal to a non-zero \
radius"
.to_owned(),
));
}
if angle == 0.0 || angle.abs() > core::f64::consts::TAU * (1.0 + 1e-12) {
return Err(GeomError::InvalidInput(format!(
"swept disk directrix arc must turn by a non-zero angle of at most \
a full turn, got {angle}"
)));
}
if bend <= radius {
return Err(GeomError::InvalidInput(format!(
"swept disk radius {radius} is not below the bend radius {bend}: \
the tube would fold through the bend's axis"
)));
}
}
}
Ok(())
}
fn sample(&self, budget: Scalar) -> GeomResult<(Vec<Point3>, Vec<Vec3>)> {
let n = match *self {
Self::Segment { .. } => 1,
Self::Arc {
centre,
start,
angle,
..
} => crate::revolve::steps((start - centre).length(), angle, budget).map_err(|_| {
GeomError::BudgetExceeded {
resource: "swept disk bend steps",
}
})?,
};
let points: Vec<Point3> = (0..=n)
.map(|k| self.point(k as Scalar / n as Scalar))
.collect();
let tangents = points.iter().map(|p| self.tangent_at(*p)).collect();
Ok((points, tangents))
}
}
#[must_use]
pub fn joint_tolerance(chord: Scalar, radius: Scalar) -> Scalar {
2.0 * (chord / (8.0 * radius)).min(1.0).asin()
}
fn turn(a: Vec3, b: Vec3) -> Scalar {
a.cross(b).length().atan2(a.dot(b))
}
pub fn swept_disk_along_pieces(
pieces: &[PathPiece],
radius: Scalar,
inner_radius: Option<Scalar>,
fillet_radius: Option<Scalar>,
chord: Scalar,
) -> GeomResult<TriMesh> {
sweep::check_radii(radius, inner_radius)?;
if !(chord.is_finite() && chord > 0.0) {
return Err(GeomError::InvalidInput(format!(
"swept disk chord budget must be positive and finite, got {chord}"
)));
}
if let Some(f) = fillet_radius {
if !(f.is_finite() && f > 0.0) {
return Err(GeomError::InvalidInput(format!(
"swept disk fillet radius must be positive and finite, got {f}"
)));
}
}
if pieces.is_empty() {
return Err(GeomError::InvalidInput(
"a swept disk directrix needs at least one piece".to_owned(),
));
}
let tolerance = joint_tolerance(chord, radius);
let path = match fillet_radius {
Some(f) => fillet(pieces, f, radius, tolerance)?,
None => pieces.to_vec(),
};
for piece in &path {
piece.check(radius)?;
}
for (k, pair) in path.windows(2).enumerate() {
let theta = turn(pair[0].end_tangent(), pair[1].start_tangent());
if theta > tolerance {
let advice = if fillet_radius.is_some() {
"only corners between two straight segments are filleted"
} else {
"a swept disk along it is undefined; give a fillet radius or a \
tangent-continuous directrix"
};
return Err(GeomError::InvalidInput(format!(
"swept disk directrix turns a corner of {theta} rad at joint {k}, above \
the {tolerance} rad a tangent-continuous joint may turn at this chord \
budget: {advice}"
)));
}
let gap = (pair[1].start_point() - pair[0].end_point()).length();
if gap > 0.25 * chord {
return Err(GeomError::InvalidInput(format!(
"swept disk directrix pieces leave a {gap} gap at joint {k}, more than a \
quarter of the {chord} chord budget"
)));
}
}
let rings = sweep::disk_rings(radius, inner_radius, 0.5 * chord)?;
let share = 0.5 * chord;
let mut stations: Vec<Station> = Vec::new();
let mut last: Option<Frame> = None;
for piece in &path {
let tangent = piece.start_tangent();
let seed = match &last {
None => seed_reference(tangent),
Some(frame) => least_rotation(frame.x, frame.x.cross(frame.y), tangent),
};
let joint = stations.last();
let (frames, placed) = bounded_piece(piece, &rings, seed, radius, share, joint)?;
let skip = usize::from(joint.is_some());
stations.extend(placed.into_iter().skip(skip));
last = frames.into_iter().last();
}
let mut mesh = loft::loft(&rings, &stations, false)?;
if matches!(crate::extrude::outward_orientation(&mesh), Some(false)) {
for triangle in mesh.indices.chunks_exact_mut(3) {
triangle.swap(1, 2);
}
}
Ok(mesh)
}
const MAX_REFINEMENTS: usize = 16;
fn bounded_piece(
piece: &PathPiece,
rings: &crate::profile::Rings,
seed: Vec3,
reach: Scalar,
share: Scalar,
joint: Option<&Station>,
) -> GeomResult<(Vec<Frame>, Vec<Station>)> {
let mut b = share;
for _ in 0..MAX_REFINEMENTS {
let (points, tangents) = piece.sample(b)?;
let frames = sweep::frames_carried(&points, &tangents, seed)?;
let placed: Vec<Station> = frames
.iter()
.map(|f| loft::place(rings, |p| loft::at(f, p)))
.collect();
let shift = joint.map_or(0.0, |j| displacement(j, &placed[0]));
let worst = frames
.windows(2)
.zip(placed.windows(2))
.enumerate()
.map(|(k, (f, s))| {
let moved = if k == 0 { shift } else { 0.0 };
sweep::span_bound(&f[0], &f[1], b, reach)
+ loft::span_deviation(&s[0], &s[1])
+ moved
})
.fold(0.0, Scalar::max);
if worst <= share {
return Ok((frames, placed));
}
b *= 0.5;
}
Err(GeomError::BudgetExceeded {
resource: "swept disk directrix refinement",
})
}
fn displacement(a: &Station, b: &Station) -> Scalar {
a.loops
.iter()
.zip(&b.loops)
.flat_map(|(p, q)| p.iter().zip(q).map(|(u, v)| (*u - *v).length()))
.fold(0.0, Scalar::max)
}
fn least_rotation(v: Vec3, from: Vec3, to: Vec3) -> Vec3 {
let axis = from.cross(to);
let sin = axis.length();
if sin == 0.0 {
return v;
}
rotate(v, Point3::ZERO, axis / sin, turn(from, to))
}
fn seed_reference(t: Vec3) -> Vec3 {
let candidate = if t.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
candidate - t * t.dot(candidate)
}
fn fillet(
pieces: &[PathPiece],
fillet: Scalar,
radius: Scalar,
tolerance: Scalar,
) -> GeomResult<Vec<PathPiece>> {
struct Leg {
piece: PathPiece,
dir: Vec3,
trim_start: Scalar,
trim_end: Scalar,
}
let mut legs: Vec<Leg> = Vec::with_capacity(pieces.len());
let mut arcs: Vec<Option<PathPiece>> = Vec::with_capacity(pieces.len());
for piece in pieces {
piece.check(radius)?;
legs.push(Leg {
piece: *piece,
dir: piece.start_tangent(),
trim_start: 0.0,
trim_end: 0.0,
});
}
for k in 1..legs.len() {
let (PathPiece::Segment { .. }, PathPiece::Segment { start: corner, .. }) =
(legs[k - 1].piece, legs[k].piece)
else {
arcs.push(None);
continue;
};
let (u, v) = (legs[k - 1].dir, legs[k].dir);
let theta = turn(u, v);
if theta <= tolerance {
arcs.push(None);
continue;
}
if radius >= fillet {
return Err(GeomError::InvalidInput(format!(
"swept disk radius {radius} is not below the fillet radius {fillet}: the \
tube would fold through the bend's axis"
)));
}
let normal = u.cross(v);
if normal.length() <= 1e-12 {
return Err(GeomError::InvalidInput(format!(
"swept disk directrix reverses at corner {k}; no fillet rounds it"
)));
}
let cut = fillet * (0.5 * theta).tan();
legs[k - 1].trim_end = cut;
legs[k].trim_start = cut;
let tangent_point = corner - u * cut;
let inward = (v - u * u.dot(v)).normalize();
arcs.push(Some(PathPiece::Arc {
centre: tangent_point + inward * fillet,
axis: normal.normalize(),
start: tangent_point,
angle: theta,
}));
}
let mut out = Vec::with_capacity(2 * legs.len());
for (k, leg) in legs.iter().enumerate() {
if k > 0 {
if let Some(arc) = arcs[k - 1] {
out.push(arc);
}
}
let length = leg.piece.length();
let needed = leg.trim_start + leg.trim_end;
if needed == 0.0 {
out.push(leg.piece);
continue;
}
let slack = 1e-9 * length;
if needed > length + slack {
return Err(GeomError::InvalidInput(format!(
"swept disk fillet radius {fillet} does not fit: segment {k} is {length} \
long but its fillets need {needed} of it"
)));
}
if needed < length - slack {
out.push(
leg.piece
.between(leg.trim_start / length, 1.0 - leg.trim_end / length),
);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fillet_is_tangent_to_both_segments() {
let corner = Point3::new(1.0, 0.0, 0.0);
let pieces = [
PathPiece::Segment {
start: Point3::ZERO,
end: corner,
},
PathPiece::Segment {
start: corner,
end: Point3::new(1.0, 1.0, 1.0),
},
];
let path = fillet(&pieces, 0.2, 0.05, 1e-6).unwrap();
assert_eq!(path.len(), 3);
for pair in path.windows(2) {
assert!((pair[0].end_point() - pair[1].start_point()).length() < 1e-12);
assert!(turn(pair[0].end_tangent(), pair[1].start_tangent()) < 1e-12);
}
}
#[test]
fn an_arc_walked_backwards_retraces_itself() {
let arc = PathPiece::Arc {
centre: Point3::new(0.0, 1.0, 0.0),
axis: Vec3::Z,
start: Point3::ZERO,
angle: 1.0,
};
let back = arc.reversed();
assert!((back.end_point() - arc.start_point()).length() < 1e-12);
assert!((back.start_tangent() + arc.end_tangent()).length() < 1e-12);
let part = arc.between(0.25, 0.75);
assert!((part.length() - 0.5).abs() < 1e-12);
assert!((part.start_point() - arc.point(0.25)).length() < 1e-12);
}
}