Skip to main content

axiolid_construct/
offset.rs

1//! Constant-distance offset and shelling of planar-faced solids (#78).
2//!
3//! # Which offset this is
4//!
5//! Offsetting by a sphere rounds every convex edge, which introduces curved
6//! faces and leaves the planar family this crate refuses to leave. What is
7//! implemented is the MITER offset: each face plane is pushed along its
8//! normal by the distance, and the faces are extended until they meet again.
9//! A cube offset outward by `d` becomes a cube of edge `a + 2d`, which is
10//! the identity #78 verifies against.
11//!
12//! # Why vertices, not faces
13//!
14//! Pushing each face and re-deriving the solid works for convex operands and
15//! silently fails for concave ones: at a reflex edge the pushed neighbours
16//! overlap instead of meeting. Offsetting the VERTICES instead -- each moved
17//! to the common point of its own incident pushed planes -- gives the miter
18//! point in both cases, and preserves topology exactly, so the result has the
19//! same face structure as its input and needs no re-assembly.
20
21use crate::boolean_exact::unsupported;
22use crate::polyhedron::Polyhedron;
23use axiolid_contracts::{GeomError, GeomResult};
24use axiolid_core::{Point3, Vec3};
25use std::collections::BTreeMap;
26
27/// Which way an offset moves the boundary.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum OffsetDirection {
30    /// Grow the solid.
31    Outward,
32    /// Shrink the solid. Collapses if the distance exceeds the local
33    /// half-thickness, which is refused rather than emitted.
34    Inward,
35}
36
37/// A face's supporting plane, as a unit normal and an offset along it.
38#[derive(Debug, Clone, Copy)]
39struct Plane {
40    normal: Vec3,
41    distance: f64,
42}
43
44/// Offset a planar-faced solid by a constant distance.
45///
46/// Every vertex moves to the intersection of its own incident face planes,
47/// each pushed by `distance`. Topology is preserved, so the result has the
48/// same faces as the input with new coordinates.
49///
50/// # Errors
51///
52/// Refuses a non-positive or non-finite distance, a vertex whose incident
53/// planes do not meet in a single point, and any inward offset that collapses
54/// the solid.
55pub fn offset_solid(
56    solid: &Polyhedron,
57    distance: f64,
58    direction: OffsetDirection,
59) -> GeomResult<Polyhedron> {
60    if !distance.is_finite() || distance <= 0.0 {
61        return Err(GeomError::InvalidInput(format!(
62            "offset distance {distance} must be positive and finite"
63        )));
64    }
65    let signed = match direction {
66        OffsetDirection::Outward => distance,
67        OffsetDirection::Inward => -distance,
68    };
69
70    let planes = face_planes(solid)?;
71    let incidence = vertex_incidence(solid);
72
73    let mut moved: BTreeMap<VertexKey, Point3> = BTreeMap::new();
74    for (key, faces) in &incidence {
75        let point = miter_point(&planes, faces, signed, key)?;
76        moved.insert(*key, point);
77    }
78
79    let faces = solid
80        .faces()
81        .iter()
82        .map(|face| {
83            face.iter()
84                .map(|&v| moved[&VertexKey::of(v)])
85                .collect::<Vec<_>>()
86        })
87        .collect();
88
89    let result = Polyhedron::new(faces)?;
90    reject_collapse(solid, &result, &planes, signed)?;
91    Ok(result)
92}
93
94/// Exact-coordinate key so vertices shared between faces are recognised.
95///
96/// Faces arrive as independent rings, so the same corner appears once per
97/// incident face. Bit keying is correct because those copies come from the
98/// same input literal or the same boolean split, never from separate
99/// arithmetic, so no welding tolerance is invented here.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
101struct VertexKey([u64; 3]);
102
103impl VertexKey {
104    fn of(p: Point3) -> Self {
105        Self([p.x.to_bits(), p.y.to_bits(), p.z.to_bits()])
106    }
107}
108
109/// Unit normal and plane offset for each face, in face order.
110fn face_planes(solid: &Polyhedron) -> GeomResult<Vec<Plane>> {
111    solid
112        .faces()
113        .iter()
114        .map(|face| {
115            let raw = (face[1] - face[0]).cross(face[2] - face[0]);
116            let length = raw.length();
117            if length == 0.0 {
118                return Err(unsupported("degenerate face has no offset direction"));
119            }
120            let normal = raw / length;
121            Ok(Plane {
122                normal,
123                distance: normal.dot(face[0] - Point3::new(0.0, 0.0, 0.0)),
124            })
125        })
126        .collect()
127}
128
129/// Which faces touch each vertex.
130fn vertex_incidence(solid: &Polyhedron) -> BTreeMap<VertexKey, Vec<usize>> {
131    let mut map: BTreeMap<VertexKey, Vec<usize>> = BTreeMap::new();
132    for (index, face) in solid.faces().iter().enumerate() {
133        for &v in face {
134            let entry = map.entry(VertexKey::of(v)).or_default();
135            if !entry.contains(&index) {
136                entry.push(index);
137            }
138        }
139    }
140    map
141}
142
143/// Where a vertex lands when its incident planes are all pushed by `signed`.
144///
145/// Three independent planes meet in one point, which is the miter vertex.
146/// This is what makes concave edges work: at a reflex edge the incident
147/// planes still meet, and their common point is the correct inside corner,
148/// whereas pushing faces and re-deriving the solid would leave them
149/// overlapping.
150///
151/// A vertex with more than three incident faces is over-determined: the
152/// planes only stay concurrent for special geometry, and if they do not, the
153/// offset genuinely has no single answer there. Three are solved and the rest
154/// verified, so a disagreement is refused rather than silently resolved by
155/// picking the first three.
156fn miter_point(
157    planes: &[Plane],
158    faces: &[usize],
159    signed: f64,
160    key: &VertexKey,
161) -> GeomResult<Point3> {
162    if faces.len() < 3 {
163        return Err(unsupported(
164            "a vertex needs 3 incident faces to determine an offset corner",
165        ));
166    }
167    let (a, b, c) = (planes[faces[0]], planes[faces[1]], planes[faces[2]]);
168    let point = intersect_three(a, b, c, signed)
169        .ok_or_else(|| unsupported("incident face planes are parallel; offset corner undefined"))?;
170
171    for &extra in &faces[3..] {
172        let plane = planes[extra];
173        let residual =
174            plane.normal.dot(point - Point3::new(0.0, 0.0, 0.0)) - (plane.distance + signed);
175        // Scale-relative: an absolute epsilon would reject large models and
176        // accept tiny ones.
177        let scale = point.x.abs().max(point.y.abs()).max(point.z.abs()).max(1.0);
178        if residual.abs() > 1e-9 * scale {
179            let _ = key;
180            return Err(unsupported(
181                "over-determined vertex: incident planes do not meet in one offset point",
182            ));
183        }
184    }
185    Ok(point)
186}
187
188/// Common point of three pushed planes, by Cramer's rule.
189///
190/// `None` when the planes are parallel or share a line, which has no unique
191/// offset corner.
192fn intersect_three(a: Plane, b: Plane, c: Plane, signed: f64) -> Option<Point3> {
193    let bc = b.normal.cross(c.normal);
194    let determinant = a.normal.dot(bc);
195    // Scale-relative singularity test: the normals are unit vectors, so the
196    // determinant is the parallelepiped volume they span and is dimensionless.
197    if determinant.abs() < 1e-12 {
198        return None;
199    }
200    let (da, db, dc) = (
201        a.distance + signed,
202        b.distance + signed,
203        c.distance + signed,
204    );
205    let numerator = bc * da + c.normal.cross(a.normal) * db + a.normal.cross(b.normal) * dc;
206    Some(Point3::new(0.0, 0.0, 0.0) + numerator / determinant)
207}
208
209/// Refuse an offset that turned the solid inside out.
210///
211/// An inward offset deeper than the local half-thickness makes opposing walls
212/// cross. The signature is local and unmistakable: a face whose normal
213/// reverses has been pushed past its opposite neighbour, so the boundary now
214/// passes through itself.
215///
216/// Checking normals rather than volume is deliberate. A volume test needs the
217/// result triangulated, and fanning a NON-CONVEX face emits triangles outside
218/// the footprint, so the measurement would be wrong exactly on the operands
219/// this offset exists to support. Normal preservation is a per-face invariant
220/// that holds regardless of face convexity.
221fn reject_collapse(
222    input: &Polyhedron,
223    result: &Polyhedron,
224    planes: &[Plane],
225    signed: f64,
226) -> GeomResult<()> {
227    for (before, after) in input.faces().iter().zip(result.faces()) {
228        let n0 = (before[1] - before[0]).cross(before[2] - before[0]);
229        let n1 = (after[1] - after[0]).cross(after[2] - after[0]);
230        if n1.length() == 0.0 {
231            return Err(unsupported(
232                "offset collapsed a face to zero area; distance exceeds the local half-thickness",
233            ));
234        }
235        if n0.dot(n1) <= 0.0 {
236            return Err(unsupported(
237                "offset reversed a face normal; the boundary passes through itself",
238            ));
239        }
240    }
241
242    // Normals alone are not enough, and neither is travel distance. Offset a
243    // 2-cube inward by 1.5: the z=2 face lands at z=0.5 and the z=0 face at
244    // z=1.5. Each kept its normal and each travelled exactly the requested
245    // 1.5 -- yet they have SWAPPED SIDES and the solid is inside out.
246    //
247    // The collapse mode is opposing walls crossing, which is precisely the
248    // "local half-thickness" the operation is bounded by. Two anti-parallel
249    // faces bound a slab whose width is the sum of their plane offsets; an
250    // inward offset narrows that slab by twice the distance, and a width that
251    // reaches zero or goes negative means the walls have met or passed
252    // through each other.
253    //
254    // Only anti-parallel pairs are tested. A containment or half-space test
255    // would also catch this, but rejects non-convex operands: the L-prism's
256    // reflex corner correctly moves OUTSIDE the offset planes of the faces
257    // forming its notch, and that is the case this offset exists to support.
258    for (i, a) in planes.iter().enumerate() {
259        for b in &planes[i + 1..] {
260            // Anti-parallel: normals opposed to within rounding of exactly
261            // -1, which is the configuration that forms a bounded slab.
262            if a.normal.dot(b.normal) > -1.0 + 1e-12 {
263                continue;
264            }
265            let width_before = a.distance + b.distance;
266            let width_after = width_before + 2.0 * signed;
267            if width_before > 0.0 && width_after <= 1e-12 * width_before.max(1.0) {
268                return Err(unsupported(
269                    "offset closed the gap between opposing walls; \
270                     distance exceeds the local half-thickness",
271                ));
272            }
273        }
274    }
275    Ok(())
276}
277
278/// Hollow a solid to a stated wall thickness.
279///
280/// The shell is the difference between the solid and its inward offset, which
281/// is why this needs #77's general boolean: the cavity of a non-convex solid
282/// is not expressible any other way.
283///
284/// # Errors
285///
286/// Refuses a thickness that collapses the cavity, propagating the offset's
287/// own refusal rather than returning a degenerate shell.
288pub fn shell_solid(solid: &Polyhedron, thickness: f64) -> GeomResult<Polyhedron> {
289    let cavity = offset_solid(solid, thickness, OffsetDirection::Inward)?;
290    crate::polyhedron::boolean_polyhedra_exact(
291        solid,
292        &cavity,
293        crate::polyhedron::BooleanOp::Difference,
294    )
295}