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"
)));
}
if bend == radius {
return Err(horn_torus("bend", radius));
}
}
}
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)?;
}
let mut mitres: Vec<Option<Vec3>> = Vec::with_capacity(path.len());
let mut sharpest: Scalar = 0.0;
for (k, pair) in path.windows(2).enumerate() {
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 (u, v) = (pair[0].end_tangent(), pair[1].start_tangent());
let theta = turn(u, v);
if theta <= tolerance {
mitres.push(None);
continue;
}
if !matches!(
(pair[0], pair[1]),
(PathPiece::Segment { .. }, PathPiece::Segment { .. })
) {
let advice = if fillet_radius.is_some() {
"only corners between two straight segments are filleted"
} else {
"only corners between two straight segments are mitred; the mitre plane \
cuts the tube round an arc in a different curve than the tube on its \
other side"
};
return Err(GeomError::InvalidInput(format!(
"swept disk directrix turns a corner of {theta} rad beside an arc at joint \
{k}, above the {tolerance} rad a tangent-continuous joint may turn at this \
chord budget: {advice}"
)));
}
let bisector = u + v;
if theta >= core::f64::consts::PI - 1e-9 || bisector.length() <= 1e-9 {
return Err(GeomError::InvalidInput(format!(
"swept disk directrix reverses at joint {k}: a mitre at half angle would \
hold both legs"
)));
}
sharpest = sharpest.max(theta);
mitres.push(Some(bisector.normalize()));
}
for (i, piece) in path.iter().enumerate() {
let PathPiece::Segment { start, end } = *piece else {
continue;
};
let ends = mitre_ends(&mitres, i);
if ends == [None, None] {
continue;
}
let u = (end - start).normalize();
let lean = |n: Option<Vec3>| n.map_or(u, |n| n / u.dot(n));
let g = lean(ends[1]) - lean(ends[0]);
let reach = radius * (g - u * u.dot(g)).length();
let length = (end - start).length();
if reach >= length {
return Err(GeomError::InvalidInput(format!(
"swept disk directrix segment {i} is {length} long, but the mitres at its \
ends reach {reach} along it at the disk radius {radius}: the tube would cut \
through itself"
)));
}
}
let rings = sweep::disk_rings(radius, inner_radius, 0.5 * chord * (0.5 * sharpest).cos())?;
let share = 0.5 * chord;
let mut stations: Vec<Station> = Vec::new();
let mut last: Option<Frame> = None;
for (i, piece) in path.iter().enumerate() {
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 ends = mitre_ends(&mitres, i);
let (frames, placed) = bounded_piece(piece, &rings, seed, radius, share, joint, ends)?;
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 mitre_ends(mitres: &[Option<Vec3>], i: usize) -> [Option<Vec3>; 2] {
[
i.checked_sub(1).and_then(|k| mitres[k]),
mitres.get(i).copied().flatten(),
]
}
fn bounded_piece(
piece: &PathPiece,
rings: &crate::profile::Rings,
seed: Vec3,
reach: Scalar,
share: Scalar,
joint: Option<&Station>,
ends: [Option<Vec3>; 2],
) -> 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 mut placed: Vec<Station> = frames
.iter()
.map(|f| loft::place(rings, |p| loft::at(f, p)))
.collect();
let last = placed.len() - 1;
for (k, normal) in [(0, ends[0]), (last, ends[1])] {
if let Some(n) = normal {
placed[k] = mitred(&placed[k], &frames[k], n);
}
}
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 mitred(station: &Station, frame: &Frame, normal: Vec3) -> Station {
let tangent = frame.x.cross(frame.y);
let along = tangent.dot(normal);
Station {
loops: station
.loops
.iter()
.map(|ring| {
ring.iter()
.map(|q| *q - tangent * ((*q - frame.origin).dot(normal) / along))
.collect()
})
.collect(),
}
}
fn horn_torus(what: &str, radius: Scalar) -> GeomError {
GeomError::InvalidInput(format!(
"swept disk radius {radius} equals the {what} radius: the format rule permits a \
fillet radius equal to the disk radius, but the bend is then a horn torus whose \
inner wall degenerates to one point on the bend's axis, a boundary that is not a \
two-manifold and that no closed mesh bounds without meeting itself there"
))
}
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 above the fillet radius {fillet}: the \
tube would fold through the bend's axis"
)));
}
if radius == fillet {
return Err(horn_torus("fillet", radius));
}
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 a_mitre_ring_lies_on_both_cylinders_and_on_the_bisector_plane() {
let corner = Point3::new(1.0, 0.2, -0.3);
let (u, v) = (
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.2, 0.7, -0.4).normalize(),
);
let pieces = [
PathPiece::Segment {
start: corner - u,
end: corner,
},
PathPiece::Segment {
start: corner,
end: corner + v,
},
];
let (r, inner) = (0.05, 0.03);
let mesh = swept_disk_along_pieces(&pieces, r, Some(inner), None, 1e-3).unwrap();
let per = mesh.positions.len() / 3;
let n = (u + v).normalize();
let off = |p: Point3, axis: Vec3| {
let d = p - corner;
(d - axis * d.dot(axis)).length()
};
for p in &mesh.positions[per..2 * per] {
assert!((*p - corner).dot(n).abs() < 1e-12);
let (a, b) = (off(*p, u), off(*p, v));
assert!((a - b).abs() < 1e-12, "{a} {b}");
assert!((a - r).abs() < 1e-12 || (a - inner).abs() < 1e-12, "{a}");
}
}
#[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);
}
}