use axiolid_contracts::{GeomError, GeomResult};
use axiolid_core::{Point2, Point3, Scalar, Tolerance, Vec3};
use axiolid_mesh::TriMesh;
use crate::loft::{self, Frame, Station};
use crate::profile::Rings;
fn blend_rings(a: &Rings, b: &Rings, t: Scalar) -> GeomResult<Rings> {
if a.outer.len() != b.outer.len() || a.holes.len() != b.holes.len() {
return Err(GeomError::InvalidInput(
"tapered sweep profiles must share their ring structure".to_owned(),
));
}
let mut holes = Vec::with_capacity(a.holes.len());
for (ha, hb) in a.holes.iter().zip(&b.holes) {
if ha.len() != hb.len() {
return Err(GeomError::InvalidInput(
"tapered sweep holes must share their point count".to_owned(),
));
}
holes.push(
ha.iter()
.zip(hb)
.map(|(p, q)| loft::blend(*p, *q, t))
.collect(),
);
}
Ok(Rings {
outer: a
.outer
.iter()
.zip(&b.outer)
.map(|(p, q)| loft::blend(*p, *q, t))
.collect(),
holes,
})
}
pub fn tapered_extrude(
start: &Rings,
end: &Rings,
direction: Vec3,
depth: Scalar,
) -> GeomResult<TriMesh> {
if !depth.is_finite() || depth <= 0.0 {
return Err(GeomError::InvalidInput(format!(
"extrusion depth must be positive and finite, got {depth}"
)));
}
let d = direction.normalize_or_zero();
if d == Vec3::ZERO {
return Err(GeomError::InvalidInput(
"extrusion direction must be a non-zero vector".to_owned(),
));
}
let far = blend_rings(start, end, 1.0)?;
let s0 = loft::place(start, |p| Point3::new(p.x, p.y, 0.0));
let s1 = loft::place(&far, |p| Point3::new(p.x, p.y, 0.0) + d * depth);
loft::loft_tapered(start, &far, &[s0, s1])
}
pub fn tapered_revolve(
start: &Rings,
end: &Rings,
axis_origin: Point3,
axis_direction: Vec3,
angle: Scalar,
tolerance: Tolerance,
) -> GeomResult<TriMesh> {
if !angle.is_finite() || angle == 0.0 {
return Err(GeomError::InvalidInput(format!(
"revolution angle must be finite and non-zero, got {angle}"
)));
}
let dir = axis_direction.normalize_or_zero();
if dir == Vec3::ZERO {
return Err(GeomError::InvalidInput(
"revolution axis must be a finite non-zero direction".to_owned(),
));
}
let ends = Rings {
outer: start.outer.clone(),
holes: core::iter::once(&end.outer)
.chain(&start.holes)
.chain(&end.holes)
.cloned()
.collect(),
};
if crate::revolve::check_axis_side(&ends, axis_origin, axis_direction, tolerance, true)?
.contains(&true)
{
return Err(crate::revolve::touching_unsupported());
}
let far = blend_rings(start, end, 1.0)?;
let mut max_r: Scalar = 0.0;
let starts = start.outer.iter().chain(start.holes.iter().flatten());
let ends = far.outer.iter().chain(far.holes.iter().flatten());
let mut travel: Scalar = 0.0;
for (p, q) in starts.zip(ends) {
for p in [p, q] {
let v = Point3::new(p.x, p.y, 0.0) - axis_origin;
max_r = max_r.max((v - dir * dir.dot(v)).length());
}
travel = travel.max((*q - *p).length());
}
let budget = tolerance.linear();
let curvature = angle * angle * max_r + 2.0 * angle.abs() * travel;
let mut n = crate::revolve::steps(max_r, angle, budget)?;
let stations = loop {
let mut stations = Vec::with_capacity(n + 1);
for s in (0..=n).rev() {
let t = (s as Scalar) / (n as Scalar);
let ring = blend_rings(start, end, t)?;
let a = angle * t;
stations.push(loft::place(&ring, |p| {
crate::revolve::rotate(Point3::new(p.x, p.y, 0.0), axis_origin, dir, a)
}));
}
let chord = curvature / (8.0 * (n * n) as Scalar);
let twist = stations
.windows(2)
.map(|w| loft::span_deviation(&w[0], &w[1]))
.fold(0.0, Scalar::max);
if chord + twist <= budget {
break stations;
}
n = crate::revolve::grow(n)?;
};
let mut mesh = loft::loft_tapered(&far, start, &stations)?;
if matches!(crate::extrude::outward_orientation(&mesh), Some(false)) {
for triangle in mesh.indices.as_chunks_mut::<3>().0 {
triangle.swap(1, 2);
}
}
Ok(mesh)
}
pub fn fixed_reference_sweep(
rings: &Rings,
path: &[Point3],
reference: Vec3,
) -> GeomResult<TriMesh> {
let frames = frames_along(path, None, |_| reference)?;
let stations: Vec<Station> = frames
.iter()
.map(|f| loft::place(rings, |p| loft::at(f, p)))
.collect();
loft::loft(rings, &stations, false)
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SampledPath {
pub points: Vec<Point3>,
pub end_tangents: Option<[Vec3; 2]>,
}
const MAX_REFINEMENTS: usize = 16;
fn bounded_stations(
rings: &Rings,
budget: Scalar,
mut frames_at: impl FnMut(Scalar) -> GeomResult<(Vec<Frame>, bool)>,
) -> GeomResult<Vec<Station>> {
if !(budget.is_finite() && budget > 0.0) {
return Err(GeomError::InvalidInput(format!(
"sweep chord budget must be positive and finite, got {budget}"
)));
}
let reach = rings
.outer
.iter()
.chain(rings.holes.iter().flatten())
.map(|p| p.x.hypot(p.y))
.fold(0.0, Scalar::max);
let mut b = budget;
for _ in 0..MAX_REFINEMENTS {
let (frames, smooth) = frames_at(b)?;
let stations: Vec<Station> = frames
.iter()
.map(|f| loft::place(rings, |p| loft::at(f, p)))
.collect();
if !smooth {
return Ok(stations);
}
let worst = frames
.windows(2)
.zip(stations.windows(2))
.map(|(f, s)| span_bound(&f[0], &f[1], b, reach) + loft::span_deviation(&s[0], &s[1]))
.fold(0.0, Scalar::max);
if worst <= budget {
return Ok(stations);
}
b *= 0.5;
}
Err(GeomError::BudgetExceeded {
resource: "sweep directrix refinement",
})
}
pub(crate) fn span_bound(from: &Frame, to: &Frame, budget: Scalar, reach: Scalar) -> Scalar {
let (t0, t1) = (from.x.cross(from.y), to.x.cross(to.y));
let turn = t0.dot(t1).clamp(-1.0, 1.0).acos();
let chord = (to.origin - from.origin).length();
let directrix = budget.min(0.5 * chord * (0.25 * turn).tan());
let trace = from.x.dot(to.x) + from.y.dot(to.y) + t0.dot(t1);
let rotation = (0.5 * (trace - 1.0)).clamp(-1.0, 1.0).acos();
directrix + reach * (1.0 - (0.5 * rotation).cos())
}
pub fn fixed_reference_sweep_within(
rings: &Rings,
mut sample: impl FnMut(Scalar) -> GeomResult<SampledPath>,
reference: Vec3,
budget: Scalar,
) -> GeomResult<TriMesh> {
let stations = bounded_stations(rings, budget, |b| {
let path = sample(b)?;
let frames = frames_along(&path.points, path.end_tangents, |_| reference)?;
Ok((frames, path.end_tangents.is_some()))
})?;
loft::loft(rings, &stations, false)
}
pub fn surface_curve_sweep_within(
rings: &Rings,
mut sample: impl FnMut(Scalar) -> GeomResult<(SampledPath, Vec<Vec3>)>,
budget: Scalar,
) -> GeomResult<TriMesh> {
let stations = bounded_stations(rings, budget, |b| {
let (path, normals) = sample(b)?;
if normals.len() != path.points.len() {
return Err(GeomError::InvalidInput(
"a surface curve sweep needs one surface normal per directrix point".to_owned(),
));
}
let frames = frames_along(&path.points, path.end_tangents, |i| normals[i])?;
Ok((frames, path.end_tangents.is_some()))
})?;
loft::loft(rings, &stations, false)
}
fn frames_along(
path: &[Point3],
ends: Option<[Vec3; 2]>,
up: impl Fn(usize) -> Vec3,
) -> GeomResult<Vec<Frame>> {
let tangents = tangents_along(path, ends)?;
let mut frames = Vec::with_capacity(path.len());
for (i, (point, tangent)) in path.iter().zip(tangents).enumerate() {
frames.push(Frame::from_reference(*point, tangent, up(i))?);
}
Ok(frames)
}
pub fn linear_extrusion_normals(path: &[Point3], direction: Vec3) -> GeomResult<Vec<Vec3>> {
if path.len() < 2 {
return Err(GeomError::InvalidInput(
"a linear-extrusion surface needs at least two directrix points".into(),
));
}
let direction = direction.normalize_or_zero();
if direction == Vec3::ZERO {
return Err(GeomError::InvalidInput(
"linear-extrusion direction must be finite and non-zero".into(),
));
}
let mut normals = Vec::with_capacity(path.len());
for i in 0..path.len() {
let tangent = if i == 0 {
path[1] - path[0]
} else if i + 1 == path.len() {
path[i] - path[i - 1]
} else {
(path[i] - path[i - 1]).normalize_or_zero()
+ (path[i + 1] - path[i]).normalize_or_zero()
};
let normal = tangent.cross(direction).normalize_or_zero();
if normal == Vec3::ZERO {
return Err(GeomError::Degenerate(
"directrix tangent is parallel to linear-extrusion direction".into(),
));
}
normals.push(normal);
}
Ok(normals)
}
pub fn surface_curve_sweep(
rings: &Rings,
path: &[Point3],
normals: &[Vec3],
) -> GeomResult<TriMesh> {
if normals.len() != path.len() {
return Err(GeomError::InvalidInput(
"a surface curve sweep needs one surface normal per directrix point".to_owned(),
));
}
let frames = frames_along(path, None, |i| normals[i])?;
let stations: Vec<Station> = frames
.iter()
.map(|f| loft::place(rings, |p| loft::at(f, p)))
.collect();
loft::loft(rings, &stations, false)
}
pub fn swept_disk(
path: &[Point3],
radius: Scalar,
inner_radius: Option<Scalar>,
fillet_radius: Option<Scalar>,
tolerance: Tolerance,
) -> GeomResult<TriMesh> {
check_disk(radius, inner_radius, fillet_radius)?;
let rings = disk_rings(radius, inner_radius, tolerance.linear())?;
let frames = rotation_minimising_frames(path, None)?;
let stations: Vec<Station> = frames
.iter()
.map(|f| loft::place(&rings, |p| loft::at(f, p)))
.collect();
loft::loft(&rings, &stations, false)
}
pub fn swept_disk_within(
mut sample: impl FnMut(Scalar) -> GeomResult<SampledPath>,
radius: Scalar,
inner_radius: Option<Scalar>,
fillet_radius: Option<Scalar>,
chord: Scalar,
) -> GeomResult<TriMesh> {
check_disk(radius, inner_radius, fillet_radius)?;
let rings = disk_rings(radius, inner_radius, 0.5 * chord)?;
let stations = bounded_stations(&rings, 0.5 * chord, |b| {
let path = sample(b)?;
let frames = rotation_minimising_frames(&path.points, path.end_tangents)?;
Ok((frames, path.end_tangents.is_some()))
})?;
loft::loft(&rings, &stations, false)
}
fn check_disk(
radius: Scalar,
inner_radius: Option<Scalar>,
fillet_radius: Option<Scalar>,
) -> GeomResult<()> {
if fillet_radius.is_some() {
return Err(GeomError::Unsupported {
backend: crate::BACKEND_ID,
operation: axiolid_contracts::Operation::Sweep,
});
}
check_radii(radius, inner_radius)
}
pub(crate) fn check_radii(radius: Scalar, inner_radius: Option<Scalar>) -> GeomResult<()> {
if !radius.is_finite() || radius <= 0.0 {
return Err(GeomError::InvalidInput(format!(
"swept disk radius must be positive and finite, got {radius}"
)));
}
if let Some(inner) = inner_radius {
if !inner.is_finite() || inner <= 0.0 || inner >= radius {
return Err(GeomError::InvalidInput(format!(
"swept disk inner radius must be positive and below {radius}, got {inner}"
)));
}
}
Ok(())
}
fn rotation_minimising_frames(path: &[Point3], ends: Option<[Vec3; 2]>) -> GeomResult<Vec<Frame>> {
let seed = seed_reference(path)?;
let tangents = tangents_along(path, ends)?;
frames_carried(path, &tangents, seed)
}
pub(crate) fn frames_carried(
path: &[Point3],
tangents: &[Vec3],
seed: Vec3,
) -> GeomResult<Vec<Frame>> {
let mut reference = seed;
let mut frames = Vec::with_capacity(path.len());
for i in 0..path.len() {
frames.push(Frame::from_reference(path[i], tangents[i], reference)?);
let Some(next) = path.get(i + 1) else { break };
let chord = *next - path[i];
let c1 = chord.dot(chord);
if c1 == 0.0 {
continue;
}
let reflected_ref = reference - chord * (2.0 / c1 * chord.dot(reference));
let reflected_tan = tangents[i] - chord * (2.0 / c1 * chord.dot(tangents[i]));
let fix = tangents[i + 1] - reflected_tan;
let c2 = fix.dot(fix);
reference = if c2 == 0.0 {
reflected_ref
} else {
reflected_ref - fix * (2.0 / c2 * fix.dot(reflected_ref))
};
}
Ok(frames)
}
fn tangents_along(path: &[Point3], ends: Option<[Vec3; 2]>) -> GeomResult<Vec<Vec3>> {
if path.len() < 2 {
return Err(GeomError::InvalidInput(format!(
"a sweep directrix needs at least two points, got {}",
path.len()
)));
}
(0..path.len())
.map(|i| {
let raw = if i == 0 {
ends.map_or(path[1] - path[0], |[start, _]| start)
} else if i + 1 == path.len() {
ends.map_or(path[i] - path[i - 1], |[_, end]| end)
} else {
(path[i] - path[i - 1]).normalize_or_zero()
+ (path[i + 1] - path[i]).normalize_or_zero()
};
let tangent = raw.normalize_or_zero();
if tangent == Vec3::ZERO {
Err(GeomError::InvalidInput(
"sweep tangent must be a non-zero direction".to_owned(),
))
} else {
Ok(tangent)
}
})
.collect()
}
pub(crate) fn disk_rings(
radius: Scalar,
inner: Option<Scalar>,
chord: Scalar,
) -> GeomResult<Rings> {
let circle = |r: Scalar, reverse: bool| -> GeomResult<Vec<Point2>> {
let n = crate::revolve::steps(r, core::f64::consts::TAU, chord)?;
let mut pts: Vec<Point2> = (0..n)
.map(|k| {
let a = core::f64::consts::TAU * (k as Scalar) / (n as Scalar);
Point2::new(r * a.cos(), r * a.sin())
})
.collect();
if reverse {
pts.reverse();
}
Ok(pts)
};
Ok(Rings {
outer: circle(radius, false)?,
holes: match inner {
Some(r) => vec![circle(r, true)?],
None => Vec::new(),
},
})
}
fn seed_reference(path: &[Point3]) -> GeomResult<Vec3> {
if path.len() < 2 {
return Err(GeomError::InvalidInput(
"a swept disk directrix needs at least two points".to_owned(),
));
}
let t = (path[1] - path[0]).normalize_or_zero();
if t == Vec3::ZERO {
return Err(GeomError::InvalidInput(
"a swept disk directrix must not start with a zero-length segment".to_owned(),
));
}
let candidate = if t.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
Ok(candidate - t * t.dot(candidate))
}
pub fn sectioned_spine(sections: &[(Rings, Vec<Point3>)]) -> GeomResult<TriMesh> {
if sections.len() < 2 {
return Err(GeomError::InvalidInput(format!(
"a sectioned spine needs at least two sections, got {}",
sections.len()
)));
}
let stations: Vec<Station> = sections
.iter()
.map(|(rings, placed)| {
let mut it = placed.iter().copied();
let mut loops = Vec::with_capacity(1 + rings.holes.len());
loops.push((0..rings.outer.len()).filter_map(|_| it.next()).collect());
for hole in &rings.holes {
loops.push((0..hole.len()).filter_map(|_| it.next()).collect());
}
Station { loops }
})
.collect();
loft::loft(§ions[0].0, &stations, false)
}