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