use crate::boolean_exact::unsupported;
use crate::polyhedron::Polyhedron;
use axiolid_contracts::{GeomError, GeomResult};
use axiolid_core::{Point3, Vec3};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OffsetDirection {
Outward,
Inward,
}
#[derive(Debug, Clone, Copy)]
struct Plane {
normal: Vec3,
distance: f64,
}
pub fn offset_solid(
solid: &Polyhedron,
distance: f64,
direction: OffsetDirection,
) -> GeomResult<Polyhedron> {
if !distance.is_finite() || distance <= 0.0 {
return Err(GeomError::InvalidInput(format!(
"offset distance {distance} must be positive and finite"
)));
}
let signed = match direction {
OffsetDirection::Outward => distance,
OffsetDirection::Inward => -distance,
};
let planes = face_planes(solid)?;
let incidence = vertex_incidence(solid);
let mut moved: BTreeMap<VertexKey, Point3> = BTreeMap::new();
for (key, faces) in &incidence {
let point = miter_point(&planes, faces, signed, key)?;
moved.insert(*key, point);
}
let faces = solid
.faces()
.iter()
.map(|face| {
face.iter()
.map(|&v| moved[&VertexKey::of(v)])
.collect::<Vec<_>>()
})
.collect();
let result = Polyhedron::new(faces)?;
reject_collapse(solid, &result, &planes, signed)?;
Ok(result)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct VertexKey([u64; 3]);
impl VertexKey {
fn of(p: Point3) -> Self {
Self([p.x.to_bits(), p.y.to_bits(), p.z.to_bits()])
}
}
fn face_planes(solid: &Polyhedron) -> GeomResult<Vec<Plane>> {
solid
.faces()
.iter()
.map(|face| {
let raw = (face[1] - face[0]).cross(face[2] - face[0]);
let length = raw.length();
if length == 0.0 {
return Err(unsupported("degenerate face has no offset direction"));
}
let normal = raw / length;
Ok(Plane {
normal,
distance: normal.dot(face[0] - Point3::new(0.0, 0.0, 0.0)),
})
})
.collect()
}
fn vertex_incidence(solid: &Polyhedron) -> BTreeMap<VertexKey, Vec<usize>> {
let mut map: BTreeMap<VertexKey, Vec<usize>> = BTreeMap::new();
for (index, face) in solid.faces().iter().enumerate() {
for &v in face {
let entry = map.entry(VertexKey::of(v)).or_default();
if !entry.contains(&index) {
entry.push(index);
}
}
}
map
}
fn miter_point(
planes: &[Plane],
faces: &[usize],
signed: f64,
key: &VertexKey,
) -> GeomResult<Point3> {
if faces.len() < 3 {
return Err(unsupported(
"a vertex needs 3 incident faces to determine an offset corner",
));
}
let (a, b, c) = (planes[faces[0]], planes[faces[1]], planes[faces[2]]);
let point = intersect_three(a, b, c, signed)
.ok_or_else(|| unsupported("incident face planes are parallel; offset corner undefined"))?;
for &extra in &faces[3..] {
let plane = planes[extra];
let residual =
plane.normal.dot(point - Point3::new(0.0, 0.0, 0.0)) - (plane.distance + signed);
let scale = point.x.abs().max(point.y.abs()).max(point.z.abs()).max(1.0);
if residual.abs() > 1e-9 * scale {
let _ = key;
return Err(unsupported(
"over-determined vertex: incident planes do not meet in one offset point",
));
}
}
Ok(point)
}
fn intersect_three(a: Plane, b: Plane, c: Plane, signed: f64) -> Option<Point3> {
let bc = b.normal.cross(c.normal);
let determinant = a.normal.dot(bc);
if determinant.abs() < 1e-12 {
return None;
}
let (da, db, dc) = (
a.distance + signed,
b.distance + signed,
c.distance + signed,
);
let numerator = bc * da + c.normal.cross(a.normal) * db + a.normal.cross(b.normal) * dc;
Some(Point3::new(0.0, 0.0, 0.0) + numerator / determinant)
}
fn reject_collapse(
input: &Polyhedron,
result: &Polyhedron,
planes: &[Plane],
signed: f64,
) -> GeomResult<()> {
for (before, after) in input.faces().iter().zip(result.faces()) {
let n0 = (before[1] - before[0]).cross(before[2] - before[0]);
let n1 = (after[1] - after[0]).cross(after[2] - after[0]);
if n1.length() == 0.0 {
return Err(unsupported(
"offset collapsed a face to zero area; distance exceeds the local half-thickness",
));
}
if n0.dot(n1) <= 0.0 {
return Err(unsupported(
"offset reversed a face normal; the boundary passes through itself",
));
}
}
for (i, a) in planes.iter().enumerate() {
for b in &planes[i + 1..] {
if a.normal.dot(b.normal) > -1.0 + 1e-12 {
continue;
}
let width_before = a.distance + b.distance;
let width_after = width_before + 2.0 * signed;
if width_before > 0.0 && width_after <= 1e-12 * width_before.max(1.0) {
return Err(unsupported(
"offset closed the gap between opposing walls; \
distance exceeds the local half-thickness",
));
}
}
}
Ok(())
}
pub fn shell_solid(solid: &Polyhedron, thickness: f64) -> GeomResult<Polyhedron> {
let cavity = offset_solid(solid, thickness, OffsetDirection::Inward)?;
crate::polyhedron::boolean_polyhedra_exact(
solid,
&cavity,
crate::polyhedron::BooleanOp::Difference,
)
}