use bevy::log::{info, warn};
use bevy::math::Vec3;
use crate::proxy::{FaceKind, ProxyCell};
use crate::soup::{EPS, Plane, Soup, WELD, choose_plane, hash_f32, plane_basis, split_render};
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Bore {
pub from: Vec3,
pub to: Vec3,
pub radius: f32,
pub sides: u32,
pub jaggedness: f32,
pub flare: f32,
pub shatter: u32,
}
impl Bore {
pub fn new(from: Vec3, to: Vec3, radius: f32) -> Self {
Bore { from, to, radius, sides: 8, jaggedness: 0.35, flare: 0.25, shatter: 4 }
}
}
const MIN_RADIUS: f32 = 1.0e-2;
const MAX_SIDES: u32 = 24;
const MAX_SHATTER: u32 = 12;
pub(crate) fn prism(bore: &Bore) -> Option<Vec<Plane>> {
let axis = bore.to - bore.from;
let len = axis.length();
if !len.is_finite() || len <= EPS {
warn!("carnage: bore from {:?} to {:?} has no length; refusing it", bore.from, bore.to);
return None;
}
if !bore.radius.is_finite() || bore.radius < MIN_RADIUS {
warn!(
"carnage: bore radius {} is below MIN_RADIUS {MIN_RADIUS}; a channel that narrow loses \
the skin at its own rim. Refusing it.",
bore.radius
);
return None;
}
if !(3..=MAX_SIDES).contains(&bore.sides) {
warn!("carnage: bore has {} sides; a channel needs 3..={MAX_SIDES}", bore.sides);
return None;
}
let jaggedness = bore.jaggedness.clamp(0.0, 1.0);
let flare = bore.flare.clamp(0.0, 1.0);
let a = axis / len;
let (u, v) = plane_basis(a);
let apothem = (std::f32::consts::PI / bore.sides as f32).cos();
let mut planes: Vec<Plane> = Vec::with_capacity(bore.sides as usize + 2);
for i in 0..bore.sides {
let theta = std::f32::consts::TAU * i as f32 / bore.sides as f32;
let dir = u * theta.cos() + v * theta.sin(); let q = |x: f32| (x / WELD).round() as i64 as u32;
let key = q(bore.from.x)
^ q(bore.from.y).wrapping_mul(0x9E37_79B9)
^ q(bore.from.z).wrapping_mul(2_654_435_761)
^ (i.wrapping_mul(0x85EB_CA6B));
let r0 = bore.radius * apothem * (1.0 - jaggedness * hash_f32(key));
let r1 = r0 * (1.0 + flare);
let p0 = bore.from + dir * r0;
let tangent = a.cross(dir);
let n = tangent.cross((bore.to + dir * r1) - p0).normalize_or_zero();
if n == Vec3::ZERO {
continue;
}
let n = if n.dot(dir) < 0.0 { -n } else { n };
planes.push(Plane { point: p0, normal: n });
}
planes.push(Plane { point: bore.from, normal: -a });
planes.push(Plane { point: bore.to, normal: a });
if planes.len() < 5 {
warn!(
"carnage: bore from {:?} to {:?} collapsed to {} usable planes; that is not a channel. \
Refusing it.",
bore.from,
bore.to,
planes.len()
);
return None;
}
Some(planes)
}
pub(crate) struct Cut {
pub(crate) shards: Vec<ProxyCell>,
pub(crate) plug: ProxyCell,
}
pub(crate) fn subtract(cell: &ProxyCell, prism: &[Plane]) -> Option<Cut> {
let mut shards: Vec<ProxyCell> = Vec::new();
let mut rest = cell.clone();
for plane in prism {
match rest.clip(plane, FaceKind::Bore) {
(Some(outside), Some(inside)) => {
shards.push(outside);
rest = inside;
}
(Some(_), None) => return None,
(None, Some(_)) => {}
(None, None) => return None,
}
}
Some(Cut { shards, plug: rest })
}
pub(crate) fn carve(src: Soup, prisms: &[Vec<Plane>], removed: &mut [Soup]) -> Soup {
let mut skin = src;
for (i, prism) in prisms.iter().enumerate() {
let mut kept = Soup::default();
let mut rest = skin;
for plane in prism {
let mut inside = Soup::default();
split_render(&rest, plane, &mut kept, &mut inside);
rest = inside;
}
if let Some(slot) = removed.get_mut(i) {
for (t, tri) in rest.idx.iter().enumerate() {
slot.push_tri(rest.vtx(tri[0]), rest.vtx(tri[1]), rest.vtx(tri[2]), rest.tri_interior[t]);
}
}
skin = kept;
}
skin
}
pub(crate) struct Plug {
pub(crate) cell: ProxyCell,
pub(crate) prism: usize,
pub(crate) exit: Vec3,
pub(crate) direction: Vec3,
pub(crate) shatter: u32,
}
pub(crate) fn shatter(
cell: ProxyCell,
render: Soup,
want: u32,
seed: u32,
weak_axis: f32,
plane_jitter: f32,
size_spread: f32,
) -> Vec<(ProxyCell, Soup)> {
let mut live: Vec<(ProxyCell, Soup)> = vec![(cell, render)];
let want = want.clamp(1, MAX_SHATTER) as usize;
if want == 1 {
return live;
}
let hard_cap = want as u32 * 4 + 8;
let mut cut = 0u32;
while live.len() < want && cut < hard_cap {
let ranked = |i: usize| -> f32 {
let v = live[i].0.volume();
if size_spread <= 0.0 {
return v;
}
let h = hash_f32(seed ^ (i as u32).wrapping_mul(0x9E37_79B9));
v * (1.0 - size_spread * 0.5 + size_spread * h)
};
let Some(i) = (0..live.len()).max_by(|&a, &b| ranked(a).total_cmp(&ranked(b)).then(b.cmp(&a)))
else {
break;
};
let s = seed
.wrapping_add(cut.wrapping_mul(2_654_435_761))
.wrapping_add(live.len() as u32);
let plane = choose_plane(&live[i].0, s, weak_axis, plane_jitter);
cut += 1;
let (Some(above), Some(below)) = live[i].0.clip(&plane, FaceKind::Cut) else { continue };
let (mut ra, mut rb) = (Soup::default(), Soup::default());
split_render(&live[i].1, &plane, &mut ra, &mut rb);
live[i] = (above, ra);
live.push((below, rb));
}
live
}
pub(crate) fn apply(
cells: &[ProxyCell],
bores: &[Bore],
) -> (Vec<ProxyCell>, Vec<Vec<Plane>>, Vec<Plug>) {
let mut cells: Vec<ProxyCell> = cells.to_vec();
let mut landed: Vec<Vec<Plane>> = Vec::new();
let mut plugs: Vec<Plug> = Vec::new();
let cells_before = cells.len();
let mut ejected = 0.0f32;
let mut consumed = 0usize;
for bore in bores {
let Some(prism) = prism(bore) else { continue }; let axis = (bore.to - bore.from).normalize_or_zero();
let mut next: Vec<ProxyCell> = Vec::with_capacity(cells.len());
let mut mine: Vec<Plug> = Vec::new();
for cell in &cells {
match subtract(cell, &prism) {
None => next.push(cell.clone()),
Some(Cut { shards, plug }) => {
if shards.is_empty() {
consumed += 1;
}
ejected += plug.volume();
next.extend(shards);
mine.push(Plug {
cell: plug,
prism: landed.len(),
exit: bore.to,
direction: axis,
shatter: bore.shatter,
});
}
}
}
if mine.is_empty() {
warn!(
"carnage: a bore from {:?} to {:?} (radius {}) reached no proxy cell; nothing was \
carved",
bore.from, bore.to, bore.radius
);
continue;
}
cells = next;
landed.push(prism);
plugs.append(&mut mine);
}
if !landed.is_empty() {
info!(
"carnage: bored {} channel(s); {cells_before} cells became {}, ejecting {} plug(s) \
holding {ejected} of volume",
landed.len(),
cells.len(),
plugs.len()
);
if consumed > 0 {
info!("carnage: {consumed} of those cells were swallowed whole and left as plugs");
}
}
(cells, landed, plugs)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CutSettings;
use crate::bond::{BondGraph, BondSet};
use crate::mesh::fracture_mesh;
use crate::soup::signed_dist;
use bevy::math::{Mat4, Vec3, primitives::Cuboid};
use bevy::mesh::{Mesh, VertexAttributeValues};
fn cube_parts() -> (Mesh, Vec<ProxyCell>) {
(
Mesh::from(Cuboid::new(1.0, 2.0, 1.0)),
vec![ProxyCell::from_box(Vec3::ZERO, Vec3::new(0.5, 1.0, 0.5))],
)
}
fn through_y(radius: f32, sides: u32, jaggedness: f32, flare: f32) -> Bore {
Bore {
from: Vec3::new(0.0, -1.5, 0.0),
to: Vec3::new(0.0, 1.5, 0.0),
radius,
sides,
jaggedness,
flare,
shatter: 1,
}
}
fn shattered_y(radius: f32, shatter: u32) -> Bore {
Bore { shatter, ..through_y(radius, 8, 0.0, 0.0) }
}
fn bake(cube: &Mesh, proxy: &[ProxyCell], target: usize, bores: Vec<Bore>) -> crate::Fracture {
fracture_mesh(
&[(cube, Mat4::IDENTITY)],
proxy,
&CutSettings { bores, ..CutSettings::new(target, 0.04, 0x5EED) },
)
}
fn inside(p: Vec3, prism: &[Plane]) -> bool {
prism.iter().all(|plane| signed_dist(p, plane) <= EPS)
}
#[test]
fn every_shard_of_a_bored_cell_is_still_a_closed_convex_solid() {
let (cube, proxy) = cube_parts();
for radius in [0.02f32, 0.05, 0.12] {
for sides in [3u32, 8, 24] {
for jaggedness in [0.0f32, 0.35, 1.0] {
for flare in [0.0f32, 0.5] {
for seed in 0..20u32 {
let bore = through_y(radius, sides, jaggedness, flare);
let cut = CutSettings {
bores: vec![bore],
..CutSettings::new(6, 0.04, seed.wrapping_mul(2_654_435_761))
};
let pieces =
fracture_mesh(&[(&cube, Mat4::IDENTITY)], &proxy, &cut).into_leaves();
let what = format!(
"radius {radius}, {sides} sides, jaggedness {jaggedness}, flare \
{flare}, seed {seed}"
);
assert!(!pieces.is_empty(), "{what}: the bored bake produced nothing");
for (i, p) in pieces.iter().enumerate() {
let a = crate::audit::audit_proxy(p).unwrap_or_else(|e| {
panic!("{what}: shard {i} could not be audited: {e}")
});
assert_eq!(a.boundary_edges, 0, "{what}: shard {i} is open: {a:?}");
assert!(a.is_manifold(), "{what}: shard {i} is not a manifold: {a:?}");
assert_eq!(
a.inconsistently_oriented_edges, 0,
"{what}: shard {i} has an inside-out face: {a:?}"
);
assert_eq!(
a.euler_characteristic, 2,
"{what}: shard {i} is not a topological sphere: {a:?}"
);
assert!(
a.supports_inside_outside,
"{what}: shard {i} is not solid enough for a collider: {a:?}"
);
}
}
}
}
}
}
}
#[test]
fn a_bore_removes_the_channel_and_nothing_else() {
let (cube, proxy) = cube_parts();
let r = 0.1f32;
let pieces = bake(&cube, &proxy, 1, vec![through_y(r, 24, 0.0, 0.0)]).into_leaves();
let left: f32 = pieces.iter().map(|p| p.cell.volume()).sum();
let lost = 2.0 - left;
let polygon = 0.5 * 24.0 * r * r * (std::f32::consts::TAU / 24.0).sin() * 2.0;
assert!(
(lost - polygon).abs() < 1.0e-3,
"the bore removed {lost}, but the inscribed 24-gon channel is {polygon}"
);
let cylinder = std::f32::consts::PI * r * r * 2.0;
assert!(
lost < cylinder,
"the bore removed {lost}, more than its circumscribed cylinder {cylinder} — an \
inscribed polygon cannot do that"
);
}
#[test]
fn a_bore_that_misses_the_subject_changes_nothing() {
let (cube, proxy) = cube_parts();
let miss = Bore {
from: Vec3::new(5.0, -1.5, 0.0),
to: Vec3::new(5.0, 1.5, 0.0),
..Bore::new(Vec3::ZERO, Vec3::Y, 0.05)
};
let plain = bake(&cube, &proxy, 8, Vec::new()).into_leaves();
let bored = bake(&cube, &proxy, 8, vec![miss]).into_leaves();
assert_eq!(plain.len(), bored.len(), "a missed bore changed the fragment count");
for (i, (a, b)) in plain.iter().zip(&bored).enumerate() {
let pa: Vec<u32> = a.cell.points().iter().flat_map(|p| p.to_array()).map(f32::to_bits).collect();
let pb: Vec<u32> = b.cell.points().iter().flat_map(|p| p.to_array()).map(f32::to_bits).collect();
assert_eq!(pa, pb, "fragment {i} moved because of a bore that reached nothing");
}
}
#[test]
fn jaggedness_only_bites_inward_so_the_entry_never_exceeds_the_radius() {
let (cube, proxy) = cube_parts();
let lost = |jaggedness: f32| -> f32 {
let pieces =
bake(&cube, &proxy, 1, vec![through_y(0.1, 8, jaggedness, 0.0)]).into_leaves();
2.0 - pieces.iter().map(|p| p.cell.volume()).sum::<f32>()
};
let clean = lost(0.0);
let ragged = lost(1.0);
assert!(clean > 0.0, "the clean bore removed nothing");
assert!(ragged > 0.0, "the ragged bore removed nothing");
assert!(
ragged < clean,
"jaggedness must only ever bite inward: clean removed {clean}, ragged removed {ragged}"
);
}
#[test]
fn flare_widens_the_exit_and_leaves_the_entry_where_it_was() {
let (radius, sides) = (0.1f32, 8u32);
let apothem = radius * (std::f32::consts::PI / sides as f32).cos();
let straight = prism(&through_y(radius, sides, 0.0, 0.0)).expect("a clean bore");
let flared = prism(&through_y(radius, sides, 0.0, 0.6)).expect("a flared bore");
assert_eq!(straight.len(), flared.len(), "flare must not change the plane count");
let radial = |plane: &Plane, h: f32| -> f32 {
let dir = Vec3::new(plane.normal.x, 0.0, plane.normal.z).normalize();
let base = Vec3::new(0.0, -1.5 + h, 0.0);
-signed_dist(base, plane) / dir.dot(plane.normal)
};
for (i, (s, f)) in straight.iter().zip(&flared).take(sides as usize).enumerate() {
for (label, plane) in [("straight", s), ("flared", f)] {
assert!(
(radial(plane, 0.0) - apothem).abs() < 1.0e-6,
"{label} barrel plane {i} opens at {} on entry, not the apothem {apothem}",
radial(plane, 0.0)
);
}
assert!(
(radial(s, 3.0) - apothem).abs() < 1.0e-6,
"an unflared barrel plane {i} must stay parallel: {} at the exit",
radial(s, 3.0)
);
assert!(
(radial(f, 3.0) - apothem * 1.6).abs() < 1.0e-6,
"flared barrel plane {i} reaches {} at the exit, expected {} (1.6 × the apothem)",
radial(f, 3.0),
apothem * 1.6
);
}
}
#[test]
fn a_bored_cell_is_still_one_island() {
let (cube, proxy) = cube_parts();
let baked = bake(&cube, &proxy, 1, vec![through_y(0.1, 8, 0.0, 0.0)]);
let ids = baked.tree.leaves();
assert!(ids.len() > 2, "the bore should have made several shards, got {}", ids.len());
let members: Vec<(crate::FragmentId, &ProxyCell)> = ids
.iter()
.filter_map(|id| baked.solids().get(id.index()).map(|s| (*id, &s.cell)))
.collect();
let graph = BondGraph::of(&members, baked.tree.len());
let found = graph.islands(&ids, &BondSet::new(&graph));
assert_eq!(
found.len(),
1,
"{} shards of one bored cell came back as {} islands, not one",
ids.len(),
found.len()
);
}
#[test]
fn the_skin_opens_exactly_where_the_channel_crosses_it() {
let (cube, proxy) = cube_parts();
let bore = through_y(0.1, 24, 0.0, 0.0);
let prism = prism(&bore).expect("a 0.1-radius 24-gon bore is a valid channel");
let skin_area = |pieces: &[crate::FragmentGeometry]| -> f32 {
pieces.iter().filter_map(|p| p.outer.as_ref()).map(mesh_area).sum()
};
let unsoftened = |bores: Vec<Bore>| -> Vec<crate::FragmentGeometry> {
fracture_mesh(
&[(&cube, Mat4::IDENTITY)],
&proxy,
&CutSettings { bores, soften: 0.0, ..CutSettings::new(1, 0.04, 0x5EED) },
)
.into_leaves()
};
let plain = unsoftened(Vec::new());
let bored = unsoftened(vec![bore]);
for (i, p) in bored.iter().enumerate() {
let Some(mesh) = p.outer.as_ref() else { continue };
for c in mesh_centroids(mesh, p.center_local) {
assert!(
!inside(c, &prism),
"shard {i} kept a skin triangle at {c:?}, inside the channel"
);
}
}
let lost = skin_area(&plain) - skin_area(&bored);
let section = 0.5 * 24.0 * 0.1 * 0.1 * (std::f32::consts::TAU / 24.0).sin();
assert!(
lost > section * 1.5 && lost < section * 2.5,
"the skin lost {lost}; entry plus exit is about {} (2 × {section})",
section * 2.0
);
}
#[test]
fn boring_is_bit_identical_across_runs() {
let (cube, proxy) = cube_parts();
let run = || {
bake(&cube, &proxy, 6, vec![through_y(0.08, 8, 1.0, 0.5)])
.into_leaves()
.into_iter()
.map(|f| {
let pts: Vec<u32> =
f.cell.points().iter().flat_map(|p| p.to_array()).map(f32::to_bits).collect();
(pts, f.center_local.to_array().map(f32::to_bits))
})
.collect::<Vec<_>>()
};
assert_eq!(run(), run(), "two bakes of the same bore disagreed bit for bit");
}
#[test]
fn a_bore_narrower_than_the_assignment_nudge_is_refused() {
let (cube, proxy) = cube_parts();
let too_thin = through_y(1.0e-3, 8, 0.0, 0.0);
assert!(prism(&too_thin).is_none(), "a 1e-3 radius bore must be refused");
let plain = bake(&cube, &proxy, 8, Vec::new()).into_leaves();
let bored = bake(&cube, &proxy, 8, vec![too_thin]).into_leaves();
assert_eq!(plain.len(), bored.len(), "a refused bore changed the fragment count");
for (i, (a, b)) in plain.iter().zip(&bored).enumerate() {
assert_eq!(
a.cell.points().iter().map(|p| p.to_array().map(f32::to_bits)).collect::<Vec<_>>(),
b.cell.points().iter().map(|p| p.to_array().map(f32::to_bits)).collect::<Vec<_>>(),
"fragment {i} moved because of a bore that was refused"
);
}
}
#[test]
fn a_bore_that_swallows_a_cell_removes_it() {
let cells = vec![
ProxyCell::from_box(Vec3::new(-1.0, 0.0, 0.0), Vec3::splat(0.2)),
ProxyCell::from_box(Vec3::new(1.0, 0.0, 0.0), Vec3::splat(0.2)),
];
let swallow = Bore {
from: Vec3::new(-1.0, -2.0, 0.0),
to: Vec3::new(-1.0, 2.0, 0.0),
radius: 0.5,
sides: 8,
jaggedness: 0.0,
flare: 0.0,
shatter: 1,
};
let prism = prism(&swallow).expect("a 0.5-radius bore is a valid channel");
let left = apply(&cells, &[swallow]).0;
assert!(
subtract(&cells[0], &prism).is_some_and(|c| c.shards.is_empty()),
"the first cell should have been consumed whole"
);
assert_eq!(left.len(), 1, "one of the two cells should be gone, got {} left", left.len());
assert_eq!(left[0], cells[1], "the cell the bore missed must come back untouched");
}
#[test]
fn the_shards_and_the_plug_are_the_cell_exactly() {
let cell = ProxyCell::from_box(Vec3::ZERO, Vec3::new(0.5, 1.0, 0.5));
for radius in [0.02f32, 0.05, 0.12] {
for sides in [3u32, 8, 24] {
for jaggedness in [0.0f32, 0.35, 1.0] {
for flare in [0.0f32, 0.5] {
let bore = through_y(radius, sides, jaggedness, flare);
let p = prism(&bore).expect("a valid channel");
let Cut { shards, plug } =
subtract(&cell, &p).expect("the channel crosses the cell");
let what =
format!("radius {radius}, {sides} sides, jag {jaggedness}, flare {flare}");
let sum: f32 =
shards.iter().map(|s| s.volume()).sum::<f32>() + plug.volume();
assert!(
(sum - 2.0).abs() < 1.0e-3,
"{what}: {} shards plus the plug enclose {sum}, not the cell's 2.0",
shards.len()
);
assert!(plug.volume() > 0.0, "{what}: the plug enclosed nothing");
}
}
}
}
}
#[test]
fn every_plug_is_a_closed_convex_solid() {
let (cube, proxy) = cube_parts();
for radius in [0.02f32, 0.05, 0.12] {
for sides in [3u32, 8, 24] {
for jaggedness in [0.0f32, 1.0] {
for flare in [0.0f32, 0.5] {
let baked =
bake(&cube, &proxy, 6, vec![through_y(radius, sides, jaggedness, flare)]);
let what =
format!("radius {radius}, {sides} sides, jag {jaggedness}, flare {flare}");
assert_eq!(baked.ejecta.len(), 1, "{what}: expected exactly one plug");
for (i, e) in baked.ejecta.iter().enumerate() {
let a = crate::audit_cell(&e.cell)
.unwrap_or_else(|err| panic!("{what}: plug {i} unauditable: {err}"));
assert_eq!(a.boundary_edges, 0, "{what}: plug {i} is open: {a:?}");
assert!(a.is_manifold(), "{what}: plug {i} is not a manifold: {a:?}");
assert_eq!(
a.euler_characteristic, 2,
"{what}: plug {i} is not a topological sphere: {a:?}"
);
assert!(
a.supports_inside_outside,
"{what}: plug {i} is not solid enough for a collider: {a:?}"
);
}
}
}
}
}
}
#[test]
fn a_plug_is_absent_from_the_tree_and_from_the_bonds() {
let (cube, proxy) = cube_parts();
let baked = bake(&cube, &proxy, 1, vec![through_y(0.1, 8, 0.0, 0.0)]);
let ids = baked.tree.leaves();
assert_eq!(baked.ejecta.len(), 1, "one bore through one cell is one plug");
assert_eq!(
baked.len(),
ids.len(),
"at target 1 every node is a leaf, so a plug must not have been added as one"
);
let plug = &baked.ejecta[0].cell;
for (i, s) in baked.solids().iter().enumerate() {
assert_ne!(
s.cell.points(),
plug.points(),
"fragment {i} IS the plug — it was added to the proxy instead of ejected"
);
}
let members: Vec<(crate::FragmentId, &ProxyCell)> = ids
.iter()
.filter_map(|id| baked.solids().get(id.index()).map(|s| (*id, &s.cell)))
.collect();
let graph = BondGraph::of(&members, baked.tree.len());
assert_eq!(
graph.islands(&ids, &BondSet::new(&graph)).len(),
1,
"the {} shards around the channel must still be one island",
ids.len()
);
}
#[test]
fn a_plug_carries_the_wall_and_the_skin_the_channel_tore_out() {
let (cube, proxy) = cube_parts();
let baked = fracture_mesh(
&[(&cube, Mat4::IDENTITY)],
&proxy,
&CutSettings {
bores: vec![through_y(0.12, 24, 0.0, 0.0)],
soften: 0.0,
ejecta_soften: 0.0,
..CutSettings::new(1, 0.04, 0x5EED)
},
);
let e = &baked.ejecta[0];
assert!(e.cap.is_some(), "the plug has no channel wall to give the interior material");
assert!(
e.outer.is_some(),
"the plug has no skin patches — the entry and exit discs were dropped rather than carried"
);
let area = mesh_area(e.outer.as_ref().expect("skin"));
let disc = 0.5 * 24.0 * 0.12 * 0.12 * (std::f32::consts::TAU / 24.0).sin();
assert!(
area > disc * 1.5 && area < disc * 2.5,
"the plug's skin is {area}; entry plus exit is about {} (2 × {disc})",
disc * 2.0
);
}
#[test]
fn a_plug_leaves_along_the_channel_and_exits_where_the_bore_did() {
let (cube, proxy) = cube_parts();
let bore = through_y(0.1, 8, 0.0, 0.0);
let baked = bake(&cube, &proxy, 1, vec![bore]);
let e = &baked.ejecta[0];
assert_eq!(e.exit, bore.to, "the plug did not leave at the bore's own far end");
assert!(
(e.direction - Vec3::Y).length() < 1.0e-6,
"a bore along +Y must eject along +Y, got {:?}",
e.direction
);
assert!(
e.center_local.x.abs() < 1.0e-3 && e.center_local.z.abs() < 1.0e-3,
"the plug's centre {:?} is off the channel axis",
e.center_local
);
}
#[test]
fn a_bore_that_reached_nothing_ejects_nothing() {
let (cube, proxy) = cube_parts();
let miss = Bore {
from: Vec3::new(5.0, -1.5, 0.0),
to: Vec3::new(5.0, 1.5, 0.0),
..Bore::new(Vec3::ZERO, Vec3::Y, 0.05)
};
assert!(bake(&cube, &proxy, 8, vec![miss]).ejecta.is_empty(), "a missed bore ejected a plug");
let refused = through_y(1.0e-3, 8, 0.0, 0.0);
assert!(
bake(&cube, &proxy, 8, vec![refused]).ejecta.is_empty(),
"a refused bore ejected a plug"
);
assert!(
bake(&cube, &proxy, 8, Vec::new()).ejecta.is_empty(),
"an unbored bake ejected a plug"
);
}
#[test]
fn shattering_divides_the_plug_and_conserves_it() {
let (cube, proxy) = cube_parts();
let whole = bake(&cube, &proxy, 1, vec![shattered_y(0.12, 1)]);
assert_eq!(whole.ejecta.len(), 1, "shatter 1 must leave the plug whole");
let plug_volume = whole.ejecta[0].cell.volume();
assert!(plug_volume > 0.0, "the reference plug enclosed nothing");
for want in [1u32, 2, 3, 4, 6, 8, 12] {
let baked = bake(&cube, &proxy, 1, vec![shattered_y(0.12, want)]);
let n = baked.ejecta.len();
assert_eq!(
n, want as usize,
"asked for {want} pieces of a 0.12 plug and got {n}"
);
let sum: f32 = baked.ejecta.iter().map(|e| e.cell.volume()).sum();
assert!(
(sum - plug_volume).abs() < 1.0e-3,
"shatter {want}: the pieces enclose {sum}, but the plug is {plug_volume} — \
shattering must divide it, not resize it"
);
}
assert_eq!(
bake(&cube, &proxy, 1, vec![shattered_y(0.12, 0)]).ejecta.len(),
1,
"shatter 0 must clamp to one whole plug"
);
assert_eq!(
bake(&cube, &proxy, 1, vec![shattered_y(0.12, 999)]).ejecta.len(),
MAX_SHATTER as usize,
"shatter 999 must clamp to MAX_SHATTER"
);
}
#[test]
fn every_shattered_piece_is_a_closed_convex_solid() {
let (cube, proxy) = cube_parts();
for radius in [0.02f32, 0.05, 0.12] {
for want in [2u32, 4, 8, 12] {
let baked = bake(&cube, &proxy, 6, vec![shattered_y(radius, want)]);
let what = format!("radius {radius}, shatter {want}");
assert!(!baked.ejecta.is_empty(), "{what}: nothing was ejected");
for (i, e) in baked.ejecta.iter().enumerate() {
let a = crate::audit_cell(&e.cell)
.unwrap_or_else(|err| panic!("{what}: piece {i} unauditable: {err}"));
assert_eq!(a.boundary_edges, 0, "{what}: piece {i} is open: {a:?}");
assert!(a.is_manifold(), "{what}: piece {i} is not a manifold: {a:?}");
assert_eq!(
a.euler_characteristic, 2,
"{what}: piece {i} is not a topological sphere: {a:?}"
);
assert!(
a.supports_inside_outside,
"{what}: piece {i} is not solid enough for a collider: {a:?}"
);
}
}
}
}
fn mesh_area(mesh: &Mesh) -> f32 {
let Some(VertexAttributeValues::Float32x3(pos)) =
mesh.attribute(Mesh::ATTRIBUTE_POSITION)
else {
return 0.0;
};
let Some(idx) = mesh.indices() else { return 0.0 };
let v: Vec<u32> = idx.iter().map(|i| i as u32).collect();
let mut total = 0.0f32;
let mut i = 0;
while i + 2 < v.len() {
let p = |k: usize| Vec3::from_array(pos[v[k] as usize]);
total += 0.5 * (p(i + 1) - p(i)).cross(p(i + 2) - p(i)).length();
i += 3;
}
total
}
fn mesh_centroids(mesh: &Mesh, recenter: Vec3) -> Vec<Vec3> {
let Some(VertexAttributeValues::Float32x3(pos)) =
mesh.attribute(Mesh::ATTRIBUTE_POSITION)
else {
return Vec::new();
};
let Some(idx) = mesh.indices() else { return Vec::new() };
let v: Vec<u32> = idx.iter().map(|i| i as u32).collect();
let mut out = Vec::with_capacity(v.len() / 3);
let mut i = 0;
while i + 2 < v.len() {
let p = |k: usize| Vec3::from_array(pos[v[k] as usize]) + recenter;
out.push((p(i) + p(i + 1) + p(i + 2)) / 3.0);
i += 3;
}
out
}
}