Skip to main content

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}