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}