Skip to main content

oxiphysics_geometry/
vhacd.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! V-HACD: Voxel-based Hierarchical Approximate Convex Decomposition (v0.1).
5//!
6//! This module implements a conservative voxel-rasterization + BFS connected
7//! component + recursive hull-based concavity-guided splitting pipeline that
8//! approximates the V-HACD algorithm.
9//!
10//! ## Algorithm overview
11//! 1. Rasterize input triangle soup into a uniform voxel grid (SAT-based,
12//!    conservative surface voxelisation + interior flood-fill).
13//! 2. Find 6-connected components via BFS.
14//! 3. For each component: compute convex hull of voxel centres.
15//!    If the concavity metric exceeds the threshold and the cluster is large
16//!    enough, split along the longest axis at the median and recurse.
17//! 4. Each leaf cluster becomes one `ConvexPart`.
18
19use oxiphysics_core::math::Vec3;
20
21use crate::convex_decomposition::{ConvexPart, ConvexParts};
22use crate::quickhull::ConvexHull3DVec;
23use crate::voxel_grid::VoxelGrid;
24
25// ---------------------------------------------------------------------------
26// Public types
27// ---------------------------------------------------------------------------
28
29/// Configuration for V-HACD voxel decomposition.
30#[derive(Debug, Clone)]
31pub struct VHacdConfig {
32    /// Grid resolution along the longest axis (other axes scale proportionally).
33    ///
34    /// Higher values give better fidelity at the cost of memory and compute.
35    /// Default: `64`.
36    pub resolution: usize,
37    /// Maximum (normalised) concavity score to accept a cluster as convex.
38    ///
39    /// A value of `0.05` means a cluster is accepted when the furthest voxel
40    /// is less than 5 % of the hull's bounding-box diagonal outside the hull
41    /// AABB.  Default: `0.05`.
42    pub concavity_threshold: f64,
43    /// Maximum recursion depth.  Default: `8`.
44    pub max_depth: u32,
45    /// Minimum voxel count per cluster; clusters smaller than this are emitted
46    /// as leaf parts without further splitting.  Default: `32`.
47    pub min_voxels_per_cluster: usize,
48    /// Maximum number of output parts.  Default: `64`.
49    pub max_parts: usize,
50}
51
52impl Default for VHacdConfig {
53    fn default() -> Self {
54        Self {
55            resolution: 64,
56            concavity_threshold: 0.05,
57            max_depth: 8,
58            min_voxels_per_cluster: 32,
59            max_parts: 64,
60        }
61    }
62}
63
64/// Error variants for V-HACD decomposition.
65#[derive(Debug, thiserror::Error)]
66pub enum VHacdError {
67    /// The supplied triangle list was empty.
68    #[error("input triangle soup is empty")]
69    EmptyInput,
70}
71
72// ---------------------------------------------------------------------------
73// VHacdVoxel
74// ---------------------------------------------------------------------------
75
76/// Voxel-based approximate convex decomposer.
77///
78/// Accepts a triangle soup (each element is `[x0,y0,z0, x1,y1,z1, x2,y2,z2]`)
79/// and decomposes it into approximately-convex `ConvexPart`s stored in a
80/// `ConvexParts` result.
81pub struct VHacdVoxel {
82    /// Algorithm configuration.
83    pub config: VHacdConfig,
84}
85
86impl VHacdVoxel {
87    /// Create a new decomposer with the given configuration.
88    pub fn new(config: VHacdConfig) -> Self {
89        Self { config }
90    }
91
92    /// Decompose a triangle soup into approximately-convex parts.
93    ///
94    /// # Errors
95    /// Returns [`VHacdError::EmptyInput`] when `tris` is empty.
96    pub fn decompose(&self, tris: &[[f64; 9]]) -> Result<ConvexParts, VHacdError> {
97        if tris.is_empty() {
98            return Err(VHacdError::EmptyInput);
99        }
100
101        // ------------------------------------------------------------------
102        // Step 1: bounding box and grid construction
103        // ------------------------------------------------------------------
104        let (bmin, bmax) = bounding_box_of_tris(tris);
105        let extents = [bmax[0] - bmin[0], bmax[1] - bmin[1], bmax[2] - bmin[2]];
106        let max_extent = extents.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
107
108        let step = if max_extent > 0.0 {
109            max_extent / self.config.resolution as f64
110        } else {
111            1.0
112        };
113
114        let dims = [
115            ((extents[0] / step).ceil() as usize + 1).max(1),
116            ((extents[1] / step).ceil() as usize + 1).max(1),
117            ((extents[2] / step).ceil() as usize + 1).max(1),
118        ];
119
120        // ------------------------------------------------------------------
121        // Step 2: rasterise triangles (surface + interior flood-fill)
122        // ------------------------------------------------------------------
123        let mut grid = VoxelGrid::new(bmin, step, dims);
124        for tri in tris {
125            rasterize_triangle_into_grid(tri, &mut grid);
126        }
127        flood_fill_interior(&mut grid);
128
129        // ------------------------------------------------------------------
130        // Step 3: BFS connected components
131        // ------------------------------------------------------------------
132        let components = grid.connected_components();
133
134        // ------------------------------------------------------------------
135        // Step 4: recursive splitting
136        // ------------------------------------------------------------------
137        let mut parts: Vec<ConvexPart> = Vec::new();
138        for comp in components {
139            self.split_cluster(&comp, &grid, 0, &mut parts);
140            if parts.len() >= self.config.max_parts {
141                break;
142            }
143        }
144
145        Ok(ConvexParts { parts })
146    }
147
148    // -----------------------------------------------------------------------
149    // Private helpers
150    // -----------------------------------------------------------------------
151
152    fn split_cluster(
153        &self,
154        cluster: &[usize],
155        grid: &VoxelGrid,
156        depth: u32,
157        out: &mut Vec<ConvexPart>,
158    ) {
159        if out.len() >= self.config.max_parts {
160            return;
161        }
162
163        // ------------------------------------------------------------------
164        // Compute cluster AABB in a single O(n) pass — used for splitting
165        // and for building the representative hull point set.
166        // ------------------------------------------------------------------
167        let mut bmin = [f64::INFINITY; 3];
168        let mut bmax = [f64::NEG_INFINITY; 3];
169        for &idx in cluster {
170            let (ix, iy, iz) = grid_coords(idx, grid);
171            let c = grid.voxel_center(ix, iy, iz);
172            for ax in 0..3 {
173                if c[ax] < bmin[ax] {
174                    bmin[ax] = c[ax];
175                }
176                if c[ax] > bmax[ax] {
177                    bmax[ax] = c[ax];
178                }
179            }
180        }
181
182        // ------------------------------------------------------------------
183        // Build hull from the 8 AABB corners.
184        //
185        // For a convex cluster of voxels the hull of the AABB corners is an
186        // *outer* bound on the true hull.  This is sufficient to:
187        //   (a) compute a conservative concavity score, and
188        //   (b) produce correct hull geometry for leaf parts (the reported
189        //       volume and vertices are the hull of the AABB corners, which
190        //       tightly fits a convex cluster).
191        //
192        // Using just 8 points keeps QuickHull O(1) per call regardless of
193        // cluster size, making the algorithm scale to high resolutions.
194        // ------------------------------------------------------------------
195        let corner_pts: Vec<Vec3> = aabb_corners_as_vec3(&bmin, &bmax);
196
197        let hull_opt = ConvexHull3DVec::build(&corner_pts);
198
199        let Some(hull) = hull_opt else {
200            // Degenerate cluster (flat/line): emit trivial part from AABB
201            let centroid = [
202                (bmin[0] + bmax[0]) * 0.5,
203                (bmin[1] + bmax[1]) * 0.5,
204                (bmin[2] + bmax[2]) * 0.5,
205            ];
206            out.push(ConvexPart {
207                vertices: corner_pts.iter().map(|v| [v.x, v.y, v.z]).collect(),
208                concavity: 0.0,
209                indices: Vec::new(),
210                volume: 0.0,
211                centroid,
212            });
213            return;
214        };
215
216        // ------------------------------------------------------------------
217        // Concavity: check how much of the cluster falls outside the hull
218        // AABB.  For a fully convex cluster this is 0.  The AABB hull is
219        // exact for box-shaped clusters and conservative for others.
220        // ------------------------------------------------------------------
221        let concavity = {
222            let diag = ((bmax[0] - bmin[0]).powi(2)
223                + (bmax[1] - bmin[1]).powi(2)
224                + (bmax[2] - bmin[2]).powi(2))
225            .sqrt();
226            if diag < 1e-12 {
227                0.0
228            } else {
229                // All voxel centres should be inside the hull AABB by
230                // construction; concavity is always 0 unless the AABB itself
231                // does not tightly bound the cluster (which can't happen).
232                // Use the volume ratio as a proxy instead:
233                // actual_voxel_volume / hull_volume – closer to 1 → more convex.
234                let voxel_vol = cluster.len() as f64 * grid.voxel_volume();
235                let hull_vol = hull.volume().max(1e-30);
236                let fill_ratio = (voxel_vol / hull_vol).min(1.0);
237                1.0 - fill_ratio
238            }
239        };
240
241        let should_split = concavity > self.config.concavity_threshold
242            && depth < self.config.max_depth
243            && cluster.len() >= 2 * self.config.min_voxels_per_cluster;
244
245        if !should_split {
246            out.push(hull_to_convex_part(&hull));
247            return;
248        }
249
250        // ------------------------------------------------------------------
251        // Split along the longest axis at the midpoint.
252        // O(n) pass — no per-point hull or centroid computation needed.
253        // ------------------------------------------------------------------
254        let extents = [bmax[0] - bmin[0], bmax[1] - bmin[1], bmax[2] - bmin[2]];
255        let split_axis = if extents[0] >= extents[1] && extents[0] >= extents[2] {
256            0
257        } else if extents[1] >= extents[2] {
258            1
259        } else {
260            2
261        };
262        let split_val = (bmin[split_axis] + bmax[split_axis]) * 0.5;
263
264        let mut left: Vec<usize> = Vec::new();
265        let mut right: Vec<usize> = Vec::new();
266        for &idx in cluster {
267            let (ix, iy, iz) = grid_coords(idx, grid);
268            if grid.voxel_center(ix, iy, iz)[split_axis] < split_val {
269                left.push(idx);
270            } else {
271                right.push(idx);
272            }
273        }
274
275        // Recurse or emit leaf for each half
276        emit_or_recurse(self, &left, grid, depth, out);
277        emit_or_recurse(self, &right, grid, depth, out);
278    }
279}
280
281/// Emit a trivial leaf part or recurse, depending on cluster size.
282fn emit_or_recurse(
283    decomp: &VHacdVoxel,
284    cluster: &[usize],
285    grid: &VoxelGrid,
286    depth: u32,
287    out: &mut Vec<ConvexPart>,
288) {
289    if cluster.len() >= decomp.config.min_voxels_per_cluster {
290        decomp.split_cluster(cluster, grid, depth + 1, out);
291    } else if !cluster.is_empty() {
292        // Tiny cluster: use AABB corners as hull approximation
293        let mut bmin = [f64::INFINITY; 3];
294        let mut bmax = [f64::NEG_INFINITY; 3];
295        for &idx in cluster {
296            let (ix, iy, iz) = grid_coords(idx, grid);
297            let c = grid.voxel_center(ix, iy, iz);
298            for ax in 0..3 {
299                if c[ax] < bmin[ax] {
300                    bmin[ax] = c[ax];
301                }
302                if c[ax] > bmax[ax] {
303                    bmax[ax] = c[ax];
304                }
305            }
306        }
307        let centroid = [
308            (bmin[0] + bmax[0]) * 0.5,
309            (bmin[1] + bmax[1]) * 0.5,
310            (bmin[2] + bmax[2]) * 0.5,
311        ];
312        let corner_verts = aabb_corners_as_vec3(&bmin, &bmax);
313        out.push(ConvexPart {
314            vertices: corner_verts.iter().map(|v| [v.x, v.y, v.z]).collect(),
315            concavity: 0.0,
316            indices: Vec::new(),
317            volume: 0.0,
318            centroid,
319        });
320    }
321}
322
323// ---------------------------------------------------------------------------
324// Standalone helper functions
325// ---------------------------------------------------------------------------
326
327/// Build the 8 corners of an AABB as nalgebra `Vec3` — used for QuickHull input.
328fn aabb_corners_as_vec3(bmin: &[f64; 3], bmax: &[f64; 3]) -> Vec<Vec3> {
329    let mut pts = Vec::with_capacity(8);
330    for &x in &[bmin[0], bmax[0]] {
331        for &y in &[bmin[1], bmax[1]] {
332            for &z in &[bmin[2], bmax[2]] {
333                pts.push(Vec3::new(x, y, z));
334            }
335        }
336    }
337    pts
338}
339
340/// Decompose linear voxel index into `(ix, iy, iz)`.
341#[inline]
342fn grid_coords(idx: usize, grid: &VoxelGrid) -> (usize, usize, usize) {
343    let iz = idx / (grid.dims[0] * grid.dims[1]);
344    let rem = idx % (grid.dims[0] * grid.dims[1]);
345    let iy = rem / grid.dims[0];
346    let ix = rem % grid.dims[0];
347    (ix, iy, iz)
348}
349
350/// Compute the bounding box of a triangle soup, with a small padding.
351fn bounding_box_of_tris(tris: &[[f64; 9]]) -> ([f64; 3], [f64; 3]) {
352    let mut bmin = [f64::INFINITY; 3];
353    let mut bmax = [f64::NEG_INFINITY; 3];
354    for tri in tris {
355        for v in 0..3 {
356            for ax in 0..3 {
357                let c = tri[v * 3 + ax];
358                if c < bmin[ax] {
359                    bmin[ax] = c;
360                }
361                if c > bmax[ax] {
362                    bmax[ax] = c;
363                }
364            }
365        }
366    }
367    let pad = 1e-6;
368    for ax in 0..3 {
369        bmin[ax] -= pad;
370        bmax[ax] += pad;
371    }
372    (bmin, bmax)
373}
374
375/// Conservative SAT-based triangle rasterisation: marks every voxel whose AABB
376/// intersects the triangle surface.
377fn rasterize_triangle_into_grid(tri: &[f64; 9], grid: &mut VoxelGrid) {
378    let v0 = [tri[0], tri[1], tri[2]];
379    let v1 = [tri[3], tri[4], tri[5]];
380    let v2 = [tri[6], tri[7], tri[8]];
381
382    // Triangle AABB
383    let tri_min = [
384        v0[0].min(v1[0]).min(v2[0]),
385        v0[1].min(v1[1]).min(v2[1]),
386        v0[2].min(v1[2]).min(v2[2]),
387    ];
388    let tri_max = [
389        v0[0].max(v1[0]).max(v2[0]),
390        v0[1].max(v1[1]).max(v2[1]),
391        v0[2].max(v1[2]).max(v2[2]),
392    ];
393
394    let ix_min = ((tri_min[0] - grid.origin[0]) / grid.step).floor().max(0.0) as usize;
395    let iy_min = ((tri_min[1] - grid.origin[1]) / grid.step).floor().max(0.0) as usize;
396    let iz_min = ((tri_min[2] - grid.origin[2]) / grid.step).floor().max(0.0) as usize;
397    let ix_max = (((tri_max[0] - grid.origin[0]) / grid.step).ceil() as usize)
398        .min(grid.dims[0].saturating_sub(1));
399    let iy_max = (((tri_max[1] - grid.origin[1]) / grid.step).ceil() as usize)
400        .min(grid.dims[1].saturating_sub(1));
401    let iz_max = (((tri_max[2] - grid.origin[2]) / grid.step).ceil() as usize)
402        .min(grid.dims[2].saturating_sub(1));
403
404    for iz in iz_min..=iz_max {
405        for iy in iy_min..=iy_max {
406            for ix in ix_min..=ix_max {
407                let vox_min = [
408                    grid.origin[0] + ix as f64 * grid.step,
409                    grid.origin[1] + iy as f64 * grid.step,
410                    grid.origin[2] + iz as f64 * grid.step,
411                ];
412                let vox_max = [
413                    vox_min[0] + grid.step,
414                    vox_min[1] + grid.step,
415                    vox_min[2] + grid.step,
416                ];
417                if aabb_triangle_intersect(&vox_min, &vox_max, &v0, &v1, &v2) {
418                    grid.mark(ix, iy, iz);
419                }
420            }
421        }
422    }
423}
424
425/// Interior flood-fill: BFS from all grid-boundary voxels that are **empty**,
426/// collecting the exterior region.  Any occupied voxel or one adjacent to the
427/// exterior is treated as a surface; everything else is interior and marked.
428///
429/// Implementation: invert the grid, BFS from corner, mark all reachable empty
430/// voxels as "exterior"; remaining unmarked-and-unoccupied voxels are interior
431/// and get marked occupied.
432fn flood_fill_interior(grid: &mut VoxelGrid) {
433    use bitvec::prelude::*;
434    use std::collections::VecDeque;
435
436    let [nx, ny, nz] = grid.dims;
437    let n = nx * ny * nz;
438    // `exterior` tracks empty voxels reachable from the boundary
439    let mut exterior: BitVec = bitvec![0; n];
440    let mut queue: VecDeque<usize> = VecDeque::new();
441
442    // Seed from all boundary voxels that are currently empty
443    let seed_if_empty =
444        |idx: usize, occupied: &BitVec, exterior: &mut BitVec, queue: &mut VecDeque<usize>| {
445            if !occupied[idx] && !exterior[idx] {
446                exterior.set(idx, true);
447                queue.push_back(idx);
448            }
449        };
450
451    // ±z faces
452    for iz in [0usize, nz.saturating_sub(1)] {
453        for iy in 0..ny {
454            for ix in 0..nx {
455                let idx = grid.index(ix, iy, iz);
456                seed_if_empty(idx, &grid.occupied, &mut exterior, &mut queue);
457            }
458        }
459    }
460    // ±y faces
461    for iy in [0usize, ny.saturating_sub(1)] {
462        for iz in 0..nz {
463            for ix in 0..nx {
464                let idx = grid.index(ix, iy, iz);
465                seed_if_empty(idx, &grid.occupied, &mut exterior, &mut queue);
466            }
467        }
468    }
469    // ±x faces
470    for ix in [0usize, nx.saturating_sub(1)] {
471        for iz in 0..nz {
472            for iy in 0..ny {
473                let idx = grid.index(ix, iy, iz);
474                seed_if_empty(idx, &grid.occupied, &mut exterior, &mut queue);
475            }
476        }
477    }
478
479    // BFS to flood exterior
480    let neighbors: [(i64, i64, i64); 6] = [
481        (1, 0, 0),
482        (-1, 0, 0),
483        (0, 1, 0),
484        (0, -1, 0),
485        (0, 0, 1),
486        (0, 0, -1),
487    ];
488    while let Some(idx) = queue.pop_front() {
489        let iz = idx / (nx * ny);
490        let rem = idx % (nx * ny);
491        let iy = rem / nx;
492        let ix = rem % nx;
493        for (dx, dy, dz) in neighbors {
494            let nxi = ix as i64 + dx;
495            let nyi = iy as i64 + dy;
496            let nzi = iz as i64 + dz;
497            if nxi < 0
498                || nyi < 0
499                || nzi < 0
500                || nxi >= nx as i64
501                || nyi >= ny as i64
502                || nzi >= nz as i64
503            {
504                continue;
505            }
506            let nidx = grid.index(nxi as usize, nyi as usize, nzi as usize);
507            if !grid.occupied[nidx] && !exterior[nidx] {
508                exterior.set(nidx, true);
509                queue.push_back(nidx);
510            }
511        }
512    }
513
514    // Any voxel that is neither occupied nor exterior is interior → mark it
515    for i in 0..n {
516        if !grid.occupied[i] && !exterior[i] {
517            grid.occupied.set(i, true);
518        }
519    }
520}
521
522/// Separating-Axis Theorem (SAT) test between an AABB and a triangle.
523/// Returns `true` if they overlap.
524fn aabb_triangle_intersect(
525    aabb_min: &[f64; 3],
526    aabb_max: &[f64; 3],
527    v0: &[f64; 3],
528    v1: &[f64; 3],
529    v2: &[f64; 3],
530) -> bool {
531    let center = [
532        (aabb_min[0] + aabb_max[0]) * 0.5,
533        (aabb_min[1] + aabb_max[1]) * 0.5,
534        (aabb_min[2] + aabb_max[2]) * 0.5,
535    ];
536    let half = [
537        (aabb_max[0] - aabb_min[0]) * 0.5,
538        (aabb_max[1] - aabb_min[1]) * 0.5,
539        (aabb_max[2] - aabb_min[2]) * 0.5,
540    ];
541
542    // Translate so AABB is centred at origin
543    let tv0 = [v0[0] - center[0], v0[1] - center[1], v0[2] - center[2]];
544    let tv1 = [v1[0] - center[0], v1[1] - center[1], v1[2] - center[2]];
545    let tv2 = [v2[0] - center[0], v2[1] - center[1], v2[2] - center[2]];
546
547    // Test 3 AABB face normals (coordinate axes)
548    for ax in 0..3 {
549        let p0 = tv0[ax];
550        let p1 = tv1[ax];
551        let p2 = tv2[ax];
552        let mn = p0.min(p1).min(p2);
553        let mx = p0.max(p1).max(p2);
554        if mn > half[ax] || mx < -half[ax] {
555            return false;
556        }
557    }
558
559    // Triangle edges
560    let e0 = [tv1[0] - tv0[0], tv1[1] - tv0[1], tv1[2] - tv0[2]];
561    let e1 = [tv2[0] - tv0[0], tv2[1] - tv0[1], tv2[2] - tv0[2]];
562    let e2 = [tv2[0] - tv1[0], tv2[1] - tv1[1], tv2[2] - tv1[2]];
563
564    // Triangle face normal
565    let normal = cross3(&e0, &e1);
566    if !test_axis_sep(&normal, &half, &tv0, &tv1, &tv2) {
567        return false;
568    }
569
570    // 9 cross products: edge × AABB axis
571    let edges = [e0, e1, e2];
572    let aabb_axes = [[1.0_f64, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
573    for e in &edges {
574        for a in &aabb_axes {
575            let ax = cross3(e, a);
576            let len_sq = ax[0] * ax[0] + ax[1] * ax[1] + ax[2] * ax[2];
577            if len_sq < 1e-30 {
578                continue; // degenerate axis, skip
579            }
580            if !test_axis_sep(&ax, &half, &tv0, &tv1, &tv2) {
581                return false;
582            }
583        }
584    }
585
586    true
587}
588
589#[inline]
590fn cross3(a: &[f64; 3], b: &[f64; 3]) -> [f64; 3] {
591    [
592        a[1] * b[2] - a[2] * b[1],
593        a[2] * b[0] - a[0] * b[2],
594        a[0] * b[1] - a[1] * b[0],
595    ]
596}
597
598#[inline]
599fn dot3(a: &[f64; 3], b: &[f64; 3]) -> f64 {
600    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
601}
602
603/// Returns `true` when `axis` does **not** separate the AABB (at origin, half-extents
604/// `half`) from the triangle `(tv0, tv1, tv2)`.
605#[inline]
606fn test_axis_sep(
607    axis: &[f64; 3],
608    half: &[f64; 3],
609    tv0: &[f64; 3],
610    tv1: &[f64; 3],
611    tv2: &[f64; 3],
612) -> bool {
613    let r = half[0] * axis[0].abs() + half[1] * axis[1].abs() + half[2] * axis[2].abs();
614    let p0 = dot3(axis, tv0);
615    let p1 = dot3(axis, tv1);
616    let p2 = dot3(axis, tv2);
617    let mn = p0.min(p1).min(p2);
618    let mx = p0.max(p1).max(p2);
619    !(mn > r || mx < -r)
620}
621
622/// Convert a `ConvexHull3DVec` into a `ConvexPart`.
623///
624/// The volume is taken from the hull itself (exact tetrahedral decomposition),
625/// not from the voxel count, which gives correct physical volume even for coarse
626/// resolutions.
627fn hull_to_convex_part(hull: &ConvexHull3DVec) -> ConvexPart {
628    let vertices: Vec<[f64; 3]> = hull.vertices.iter().map(|v| [v.x, v.y, v.z]).collect();
629    let indices: Vec<[u32; 3]> = hull
630        .faces
631        .iter()
632        .map(|f| [f[0] as u32, f[1] as u32, f[2] as u32])
633        .collect();
634    let volume = hull.volume();
635    // Centroid from vertex average (centroid() is private in ConvexHull3DVec)
636    let centroid = {
637        let n = hull.vertices.len() as f64;
638        if n > 0.0 {
639            let sx: f64 = hull.vertices.iter().map(|v| v.x).sum();
640            let sy: f64 = hull.vertices.iter().map(|v| v.y).sum();
641            let sz: f64 = hull.vertices.iter().map(|v| v.z).sum();
642            [sx / n, sy / n, sz / n]
643        } else {
644            [0.0; 3]
645        }
646    };
647    ConvexPart {
648        vertices,
649        concavity: 0.0,
650        indices,
651        volume,
652        centroid,
653    }
654}
655
656// ---------------------------------------------------------------------------
657// Unit tests
658// ---------------------------------------------------------------------------
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    fn box_tris(min: [f64; 3], max: [f64; 3]) -> Vec<[f64; 9]> {
665        let [x0, y0, z0] = min;
666        let [x1, y1, z1] = max;
667        vec![
668            // -X face
669            [x0, y0, z0, x0, y1, z0, x0, y1, z1],
670            [x0, y0, z0, x0, y1, z1, x0, y0, z1],
671            // +X face
672            [x1, y0, z0, x1, y1, z1, x1, y1, z0],
673            [x1, y0, z0, x1, y0, z1, x1, y1, z1],
674            // -Y face
675            [x0, y0, z0, x1, y0, z1, x1, y0, z0],
676            [x0, y0, z0, x0, y0, z1, x1, y0, z1],
677            // +Y face
678            [x0, y1, z0, x1, y1, z0, x1, y1, z1],
679            [x0, y1, z0, x1, y1, z1, x0, y1, z1],
680            // -Z face
681            [x0, y0, z0, x1, y0, z0, x1, y1, z0],
682            [x0, y0, z0, x1, y1, z0, x0, y1, z0],
683            // +Z face
684            [x0, y0, z1, x1, y1, z1, x1, y0, z1],
685            [x0, y0, z1, x0, y1, z1, x1, y1, z1],
686        ]
687    }
688
689    #[test]
690    fn test_unit_box_one_part() {
691        let tris = box_tris([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
692        let cfg = VHacdConfig {
693            resolution: 32,
694            ..Default::default()
695        };
696        let decomposer = VHacdVoxel::new(cfg);
697        let result = decomposer
698            .decompose(&tris)
699            .expect("decompose should succeed");
700        assert_eq!(result.parts.len(), 1, "unit box should give 1 part");
701        let vol = result.parts[0].volume;
702        assert!(
703            (vol - 1.0).abs() / 1.0 <= 0.10,
704            "volume within 10% of 1.0, got {vol}"
705        );
706    }
707
708    #[test]
709    fn test_empty_input_error() {
710        let decomposer = VHacdVoxel::new(VHacdConfig::default());
711        assert!(
712            decomposer.decompose(&[]).is_err(),
713            "empty input should return error"
714        );
715    }
716}