axiolid_construct/extrude.rs
1//! Linear extrusion of a triangulated profile into a closed solid.
2//!
3//! The result must be watertight and outward-oriented, because that is exactly
4//! what `axiolid-mesh-boolean-boolmesh` demands of its inputs. Getting the winding wrong here
5//! produces a mesh that looks valid and computes wrong booleans -- the failure
6//! mode that has already cost two debugging sessions.
7//!
8//! # A direction in the profile plane (#281)
9//!
10//! An offset with no component along the profile normal bounds no volume:
11//! its "solid" is a sliver, or flat at exactly `z = 0`. The exact path
12//! refuses an offset within tolerance of the plane, `|o.z| <= tolerance`,
13//! as `"extrusion direction in the profile plane"`, and so does
14//! [`extrude_profile`], by the same typed refusal, so the two paths agree
15//! on what they build. [`extrude`] carries no tolerance, so it refuses
16//! only an offset exactly in the plane, by the same name.
17
18use axiolid_contracts::{GeomError, GeomResult, Operation, Sign};
19use axiolid_core::{Point2, Point3, Scalar, Vec3};
20use axiolid_mesh::TriMesh;
21use axiolid_reference::arithmetic::{
22 expansion_sign, expansion_sum, grow_expansion, scale_expansion,
23};
24use axiolid_reference::expansion::two_product;
25
26pub use crate::extrude_exact::extrude_profile_exact;
27
28/// Extrude a triangulated 2D profile along `direction` by `depth`.
29///
30/// The profile lies in the local z = 0 plane. Caps use the triangulation;
31/// sides are quads split into two triangles per boundary edge.
32///
33/// `boundary` lists the closed loops of the profile as index ranges into
34/// `points`: each loop is a contiguous run, matching `profile::Rings` layout.
35///
36/// An offset exactly in the profile plane (`o.z == 0`) bounds no volume and
37/// is refused as `"extrusion direction in the profile plane"`; with no
38/// tolerance here, a sliver just off the plane is built.
39/// [`extrude_profile`] refuses within its tolerance (see the module docs).
40pub fn extrude(
41 points: &[Point2],
42 triangles: &[[u32; 3]],
43 loops: &[core::ops::Range<usize>],
44 direction: Vec3,
45 depth: Scalar,
46) -> GeomResult<TriMesh> {
47 let offset = mesh_offset(direction, depth)?;
48 if offset.z == 0.0 {
49 return Err(in_profile_plane());
50 }
51
52 let n = points.len();
53 let mut positions = Vec::with_capacity(n * 2);
54 // Base ring first, then the offset ring: vertex i has its twin at i + n.
55 positions.extend(points.iter().map(|p| Point3::new(p.x, p.y, 0.0)));
56 positions.extend(points.iter().map(|p| Point3::new(p.x, p.y, 0.0) + offset));
57
58 let mut indices: Vec<u32> = Vec::with_capacity(triangles.len() * 6 + n * 6);
59 let top = n as u32;
60
61 // Caps. The profile triangulation is counter-clockwise seen from +z, which
62 // is outward for the TOP cap and inward for the bottom, so the bottom is
63 // emitted reversed.
64 for t in triangles {
65 indices.extend_from_slice(&[t[0] + top, t[1] + top, t[2] + top]);
66 indices.extend_from_slice(&[t[0], t[2], t[1]]);
67 }
68
69 // Sides. Each boundary edge (a -> b) becomes the quad a, b, b', a'.
70 for range in loops {
71 let len = range.len();
72 if len < 3 {
73 return Err(GeomError::InvalidInput(format!(
74 "extrusion loop needs at least 3 vertices, got {len}"
75 )));
76 }
77 for k in 0..len {
78 let a = (range.start + k) as u32;
79 let b = (range.start + (k + 1) % len) as u32;
80 indices.extend_from_slice(&[a, b, b + top]);
81 indices.extend_from_slice(&[a, b + top, a + top]);
82 }
83 }
84
85 // Every winding above assumes the offset leaves the profile plane towards
86 // +z. When it points below the plane the solid is the mirror image of that
87 // case, so every triangle is inside-out (signed volume -area*depth) and a
88 // boolean would refuse or silently invert it. Flipping each triangle once
89 // restores outward orientation for caps and walls, outer and hole loops
90 // alike. An offset IN the plane bounds no volume either way and was
91 // refused above.
92 if offset.z < 0.0 {
93 for triangle in indices.as_chunks_mut::<3>().0 {
94 triangle.swap(1, 2);
95 }
96 }
97
98 Ok(TriMesh::new(positions, indices))
99}
100
101/// Triangulate rings and extrude them in one step.
102///
103/// The loop layout must match `triangulate`'s vertex order exactly, so the
104/// two are derived from the same `Rings` value here rather than by a caller
105/// reconstructing the ranges.
106///
107/// A direction within `tolerance` of the profile plane (`|o.z| <=
108/// tolerance` for the offset `o`) is refused as `"extrusion direction in
109/// the profile plane"`, exactly as [`extrude_profile_exact`] refuses it.
110pub fn extrude_profile(
111 rings: &crate::profile::Rings,
112 direction: Vec3,
113 depth: Scalar,
114 tolerance: axiolid_core::Tolerance,
115) -> GeomResult<TriMesh> {
116 // Refused before triangulating, by the exact path's rule (#281).
117 if mesh_offset(direction, depth)?.z.abs() <= tolerance.linear() {
118 return Err(in_profile_plane());
119 }
120 let (points, triangles) = crate::profile::triangulate(rings)?;
121 let mut loops = Vec::with_capacity(1 + rings.holes.len());
122 let mut start = 0usize;
123 loops.push(start..rings.outer.len());
124 start += rings.outer.len();
125 for hole in &rings.holes {
126 loops.push(start..start + hole.len());
127 start += hole.len();
128 }
129 extrude(&points, &triangles, &loops, direction, depth)
130}
131
132/// The offset `direction / |direction| * depth`, validated.
133fn mesh_offset(direction: Vec3, depth: Scalar) -> GeomResult<Vec3> {
134 if !depth.is_finite() || depth <= 0.0 {
135 return Err(GeomError::InvalidInput(format!(
136 "extrusion depth must be positive and finite, got {depth}"
137 )));
138 }
139 if !direction.is_finite() || direction.length() <= 0.0 {
140 return Err(GeomError::InvalidInput(
141 "extrusion direction must be a finite non-zero vector".to_owned(),
142 ));
143 }
144 let offset = direction.normalize() * depth;
145 if !offset.is_finite() {
146 return Err(GeomError::Degenerate(
147 "extrusion direction could not be normalised".to_owned(),
148 ));
149 }
150 Ok(offset)
151}
152
153/// The refusal both extrusion paths give an offset in the profile plane.
154fn in_profile_plane() -> GeomError {
155 GeomError::UnsupportedInput {
156 backend: crate::BACKEND_ID,
157 operation: Operation::Sweep,
158 input: "extrusion direction in the profile plane",
159 }
160}
161
162/// Whether a closed mesh is outward-oriented.
163///
164/// A face-counting majority does not work: a hollow section's inner wall
165/// legitimately faces the opposite way from its outer wall, and for a thin
166/// tube the two counts are comparable. Orientation is a property of the
167/// enclosed volume, not of how faces point relative to a centre.
168///
169/// So the volume is summed exactly. Each tetrahedron about the reference point
170/// contributes `a . (b x c)`, and those contributions are accumulated in
171/// expansion arithmetic rather than f64, so the final sign is certified even
172/// when the terms cancel catastrophically -- which is exactly what happens for
173/// a thin plate or a large solid far from the origin.
174///
175/// Returns `None` when the mesh encloses exactly zero volume, which is not an
176/// orientation and must not be reported as one.
177#[must_use]
178pub fn outward_orientation(mesh: &TriMesh) -> Option<bool> {
179 if mesh.indices.len() < 12 {
180 // Fewer than four triangles cannot bound a volume.
181 return None;
182 }
183 let mut total: Vec<f64> = vec![0.0];
184 for corner in mesh.indices.as_chunks::<3>().0 {
185 let a = mesh.positions[corner[0] as usize];
186 let b = mesh.positions[corner[1] as usize];
187 let c = mesh.positions[corner[2] as usize];
188 total = expansion_sum(&total, &triple_product(a, b, c));
189 }
190 match expansion_sign(&total) {
191 Sign::Positive => Some(true),
192 Sign::Negative => Some(false),
193 Sign::Zero => None,
194 // `Sign` is non-exhaustive; an unrecognised variant is not a verdict.
195 _ => None,
196 }
197}
198
199/// Exact `a . (b x c)` as an expansion: six times a tetrahedron's volume.
200#[must_use]
201fn triple_product(a: Point3, b: Point3, c: Point3) -> Vec<f64> {
202 let term = |p: f64, q: f64, r: f64, s: f64, k: f64| {
203 // k * (p*q - r*s), exactly.
204 scale_expansion(&exact_difference_of_products(p, q, r, s), k)
205 };
206 let x = term(b.y, c.z, c.y, b.z, a.x);
207 let y = term(b.z, c.x, c.z, b.x, a.y);
208 let z = term(b.x, c.y, c.x, b.y, a.z);
209 expansion_sum(&expansion_sum(&x, &y), &z)
210}
211
212/// Exact `p*q - r*s` as a four-term expansion.
213#[must_use]
214fn exact_difference_of_products(p: f64, q: f64, r: f64, s: f64) -> Vec<f64> {
215 let (pq, pq_err) = two_product(p, q);
216 let (rs, rs_err) = two_product(r, s);
217 let e = grow_expansion(&[pq_err], -rs_err);
218 let e = grow_expansion(&e, pq);
219 grow_expansion(&e, -rs)
220}