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 far = blend_rings(start, end, 1.0)?;
let mut max_r: Scalar = 0.0;
for p in start.outer.iter().chain(far.outer.iter()) {
let v = Point3::new(p.x, p.y, 0.0) - axis_origin;
max_r = max_r.max((v - dir * dir.dot(v)).length());
}
let n = crate::revolve::steps(max_r, angle, tolerance.linear());
let mut stations = Vec::with_capacity(n + 1);
for s in 0..=n {
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 stations: Vec<_> = stations.into_iter().rev().collect();
loft::loft_tapered(&far, start, &stations)
}
pub fn fixed_reference_sweep(
rings: &Rings,
path: &[Point3],
reference: Vec3,
) -> GeomResult<TriMesh> {
let frames = frames_along(path, |_| reference)?;
let stations: Vec<Station> = frames
.iter()
.map(|f| loft::place(rings, |p| loft::at(f, p)))
.collect();
loft::loft(rings, &stations, false)
}
fn frames_along(path: &[Point3], up: impl Fn(usize) -> Vec3) -> GeomResult<Vec<Frame>> {
if path.len() < 2 {
return Err(GeomError::InvalidInput(format!(
"a sweep directrix needs at least two points, got {}",
path.len()
)));
}
let mut frames = 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()
};
frames.push(Frame::from_reference(path[i], 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, |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> {
if fillet_radius.is_some() {
return Err(GeomError::Unsupported {
backend: crate::BACKEND_ID,
operation: axiolid_contracts::Operation::Sweep,
});
}
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}"
)));
}
}
let rings = disk_rings(radius, inner_radius, tolerance)?;
let seed = seed_reference(path)?;
let frames = frames_along(path, |_| seed)?;
let stations: Vec<Station> = frames
.iter()
.map(|f| loft::place(&rings, |p| loft::at(f, p)))
.collect();
loft::loft(&rings, &stations, false)
}
fn disk_rings(radius: Scalar, inner: Option<Scalar>, tolerance: Tolerance) -> GeomResult<Rings> {
let circle = |r: Scalar, reverse: bool| -> Vec<Point2> {
let n = crate::revolve::steps(r, core::f64::consts::TAU, tolerance.linear());
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();
}
pts
};
Ok(Rings {
outer: circle(radius, false),
holes: inner.map(|r| vec![circle(r, true)]).unwrap_or_default(),
})
}
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)
}