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        // `+ 0.0` keys -0.0 as 0.0, the same corner (#269).
106        Self([
107            (p.x + 0.0).to_bits(),
108            (p.y + 0.0).to_bits(),
109            (p.z + 0.0).to_bits(),
110        ])
111    }
112}
113
114/// Unit normal and plane offset for each face, in face order.
115fn face_planes(solid: &Polyhedron) -> GeomResult<Vec<Plane>> {
116    solid
117        .faces()
118        .iter()
119        .map(|face| {
120            let raw = (face[1] - face[0]).cross(face[2] - face[0]);
121            let length = raw.length();
122            if length == 0.0 {
123                return Err(unsupported("degenerate face has no offset direction"));
124            }
125            let normal = raw / length;
126            Ok(Plane {
127                normal,
128                distance: normal.dot(face[0] - Point3::new(0.0, 0.0, 0.0)),
129            })
130        })
131        .collect()
132}
133
134/// Which faces touch each vertex.
135fn vertex_incidence(solid: &Polyhedron) -> BTreeMap<VertexKey, Vec<usize>> {
136    let mut map: BTreeMap<VertexKey, Vec<usize>> = BTreeMap::new();
137    for (index, face) in solid.faces().iter().enumerate() {
138        for &v in face {
139            let entry = map.entry(VertexKey::of(v)).or_default();
140            if !entry.contains(&index) {
141                entry.push(index);
142            }
143        }
144    }
145    map
146}
147
148/// Where a vertex lands when its incident planes are all pushed by `signed`.
149///
150/// Three independent planes meet in one point, which is the miter vertex.
151/// This is what makes concave edges work: at a reflex edge the incident
152/// planes still meet, and their common point is the correct inside corner,
153/// whereas pushing faces and re-deriving the solid would leave them
154/// overlapping.
155///
156/// A vertex with more than three incident faces is over-determined: the
157/// planes only stay concurrent for special geometry, and if they do not, the
158/// offset genuinely has no single answer there. Three are solved and the rest
159/// verified, so a disagreement is refused rather than silently resolved by
160/// picking the first three.
161fn miter_point(
162    planes: &[Plane],
163    faces: &[usize],
164    signed: f64,
165    key: &VertexKey,
166) -> GeomResult<Point3> {
167    if faces.len() < 3 {
168        return Err(unsupported(
169            "a vertex needs 3 incident faces to determine an offset corner",
170        ));
171    }
172    let (a, b, c) = (planes[faces[0]], planes[faces[1]], planes[faces[2]]);
173    let point = intersect_three(a, b, c, signed)
174        .ok_or_else(|| unsupported("incident face planes are parallel; offset corner undefined"))?;
175
176    for &extra in &faces[3..] {
177        let plane = planes[extra];
178        let residual =
179            plane.normal.dot(point - Point3::new(0.0, 0.0, 0.0)) - (plane.distance + signed);
180        // Scale-relative: an absolute epsilon would reject large models and
181        // accept tiny ones.
182        let scale = point.x.abs().max(point.y.abs()).max(point.z.abs()).max(1.0);
183        if residual.abs() > 1e-9 * scale {
184            let _ = key;
185            return Err(unsupported(
186                "over-determined vertex: incident planes do not meet in one offset point",
187            ));
188        }
189    }
190    Ok(point)
191}
192
193/// Common point of three pushed planes, by Cramer's rule.
194///
195/// `None` when the planes are parallel or share a line, which has no unique
196/// offset corner.
197fn intersect_three(a: Plane, b: Plane, c: Plane, signed: f64) -> Option<Point3> {
198    let bc = b.normal.cross(c.normal);
199    let determinant = a.normal.dot(bc);
200    // Scale-relative singularity test: the normals are unit vectors, so the
201    // determinant is the parallelepiped volume they span and is dimensionless.
202    if determinant.abs() < 1e-12 {
203        return None;
204    }
205    let (da, db, dc) = (
206        a.distance + signed,
207        b.distance + signed,
208        c.distance + signed,
209    );
210    let numerator = bc * da + c.normal.cross(a.normal) * db + a.normal.cross(b.normal) * dc;
211    Some(Point3::new(0.0, 0.0, 0.0) + numerator / determinant)
212}
213
214/// Refuse an offset that turned the solid inside out.
215///
216/// An inward offset deeper than the local half-thickness makes opposing walls
217/// cross. The signature is local and unmistakable: a face whose normal
218/// reverses has been pushed past its opposite neighbour, so the boundary now
219/// passes through itself.
220///
221/// Checking normals rather than volume is deliberate. A volume test needs the
222/// result triangulated, and fanning a NON-CONVEX face emits triangles outside
223/// the footprint, so the measurement would be wrong exactly on the operands
224/// this offset exists to support. Normal preservation is a per-face invariant
225/// that holds regardless of face convexity.
226fn reject_collapse(
227    input: &Polyhedron,
228    result: &Polyhedron,
229    planes: &[Plane],
230    signed: f64,
231) -> GeomResult<()> {
232    for (before, after) in input.faces().iter().zip(result.faces()) {
233        let n0 = (before[1] - before[0]).cross(before[2] - before[0]);
234        let n1 = (after[1] - after[0]).cross(after[2] - after[0]);
235        if n1.length() == 0.0 {
236            return Err(unsupported(
237                "offset collapsed a face to zero area; distance exceeds the local half-thickness",
238            ));
239        }
240        if n0.dot(n1) <= 0.0 {
241            return Err(unsupported(
242                "offset reversed a face normal; the boundary passes through itself",
243            ));
244        }
245    }
246
247    // Normals alone are not enough, and neither is travel distance. Offset a
248    // 2-cube inward by 1.5: the z=2 face lands at z=0.5 and the z=0 face at
249    // z=1.5. Each kept its normal and each travelled exactly the requested
250    // 1.5 -- yet they have SWAPPED SIDES and the solid is inside out.
251    //
252    // The collapse mode is opposing walls crossing, which is precisely the
253    // "local half-thickness" the operation is bounded by. Two anti-parallel
254    // faces bound a slab whose width is the sum of their plane offsets; an
255    // inward offset narrows that slab by twice the distance, and a width that
256    // reaches zero or goes negative means the walls have met or passed
257    // through each other.
258    //
259    // Only anti-parallel pairs are tested. A containment or half-space test
260    // would also catch this, but rejects non-convex operands: the L-prism's
261    // reflex corner correctly moves OUTSIDE the offset planes of the faces
262    // forming its notch, and that is the case this offset exists to support.
263    for (i, a) in planes.iter().enumerate() {
264        for b in &planes[i + 1..] {
265            // Anti-parallel: normals opposed to within rounding of exactly
266            // -1, which is the configuration that forms a bounded slab.
267            if a.normal.dot(b.normal) > -1.0 + 1e-12 {
268                continue;
269            }
270            let width_before = a.distance + b.distance;
271            let width_after = width_before + 2.0 * signed;
272            if width_before > 0.0 && width_after <= 1e-12 * width_before.max(1.0) {
273                return Err(unsupported(
274                    "offset closed the gap between opposing walls; \
275                     distance exceeds the local half-thickness",
276                ));
277            }
278        }
279    }
280    Ok(())
281}
282
283/// Hollow a solid to a stated wall thickness.
284///
285/// The shell is the difference between the solid and its inward offset, which
286/// is why this needs #77's general boolean: the cavity of a non-convex solid
287/// is not expressible any other way.
288///
289/// # Errors
290///
291/// Refuses a thickness that collapses the cavity, propagating the offset's
292/// own refusal rather than returning a degenerate shell.
293pub fn shell_solid(solid: &Polyhedron, thickness: f64) -> GeomResult<Polyhedron> {
294    let cavity = offset_solid(solid, thickness, OffsetDirection::Inward)?;
295    crate::polyhedron::boolean_polyhedra_exact(
296        solid,
297        &cavity,
298        crate::polyhedron::BooleanOp::Difference,
299    )
300}