use bevy::log::warn;
use bevy::math::Vec3;
use crate::bake::EjectaChunk;
use crate::bond::{Bond, BondGraph, BondId};
use crate::order::sort_total_by_key_at;
use crate::proxy::ProxyCell;
use crate::severance::Reach;
use crate::soup::MIN_CROSS2;
pub use bloodstain::WoundKind;
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Wound {
pub at: Vec3,
pub normal: Vec3,
pub area: f32,
pub severity: f32,
pub kind: WoundKind,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CapFace {
pub centroid: Vec3,
pub normal: Vec3,
pub area: f32,
}
pub fn wounds_from_reach(graph: &BondGraph, reach: &Reach, threshold: f32) -> Vec<Wound> {
let mut out: Vec<(BondId, Wound)> = reach
.iter()
.filter(|(_, severity)| *severity >= threshold)
.filter_map(|(id, severity)| {
let bond = graph.bond(id)?;
Some((id, wound_of(bond, severity.clamp(0.0, 1.0))))
})
.collect();
sort_total_by_key_at("wound::wounds_from_reach", &mut out, |(id, _)| *id);
out.into_iter().map(|(_, w)| w).collect()
}
pub fn wounds_from_bonds(graph: &BondGraph, broken: &[BondId]) -> Vec<Wound> {
let mut out: Vec<(BondId, Wound)> = broken
.iter()
.filter_map(|id| match graph.bond(*id) {
Some(bond) => Some((*id, wound_of(bond, 1.0))),
None => {
warn!("carnage: bond {id:?} is not in this graph — mixing graphs from two frontiers");
None
}
})
.collect();
sort_total_by_key_at("wound::wounds_from_bonds", &mut out, |(id, _)| *id);
out.into_iter().map(|(_, w)| w).collect()
}
fn wound_of(bond: &Bond, severity: f32) -> Wound {
Wound {
at: bond.centroid,
normal: bond.normal,
area: bond.area,
severity,
kind: WoundKind::Severance,
}
}
pub fn cap_faces(cell: &ProxyCell) -> Vec<CapFace> {
let points = cell.points();
let mut out = Vec::new();
for (fi, ring) in cell.faces().enumerate() {
if !cell.face_is_cut(fi) || ring.len() < 3 {
continue;
}
if ring.iter().any(|&i| i as usize >= points.len()) {
continue;
}
let pivot =
ring.iter().map(|&i| points[i as usize]).sum::<Vec3>() / ring.len() as f32;
let mut n = Vec3::ZERO;
let mut weighted = Vec3::ZERO;
let mut area2 = 0.0f32;
for k in 0..ring.len() {
let a = points[ring[k] as usize];
let b = points[ring[(k + 1) % ring.len()] as usize];
let cross = (a - pivot).cross(b - pivot);
n += cross;
let w = cross.length();
weighted += (pivot + a + b) / 3.0 * w;
area2 += w;
}
let n2 = n.length_squared();
if n2 < MIN_CROSS2 || area2 <= 0.0 {
continue;
}
let len = n2.sqrt();
out.push(CapFace {
centroid: weighted / area2,
normal: n / len,
area: 0.5 * len,
});
}
out
}
pub fn largest_cap(cell: &ProxyCell) -> Option<CapFace> {
cap_faces(cell)
.into_iter()
.reduce(|best, f| if f.area > best.area { f } else { best })
}
pub fn wound_of_channel(cell: &ProxyCell, exit: Vec3, direction: Vec3) -> Wound {
Wound {
at: exit,
normal: direction,
area: cap_faces(cell).iter().map(|f| f.area).sum(),
severity: 1.0,
kind: WoundKind::Channel,
}
}
pub fn wound_from_ejecta(chunk: &EjectaChunk) -> Wound {
wound_of_channel(&chunk.cell, chunk.exit, chunk.direction)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bond::BondGraph;
use crate::soup::fracture;
use crate::{Bore, CutSettings};
fn unit_cube_cells() -> Vec<ProxyCell> {
vec![ProxyCell::from_box(Vec3::ZERO, Vec3::splat(0.5))]
}
fn baked_graph(cut: CutSettings) -> BondGraph {
let (pieces, tree, _) = fracture(crate::soup::Soup::default(), &unit_cube_cells(), &cut);
let leaves = tree.leaves();
let members: Vec<_> =
leaves.iter().filter_map(|&id| pieces.get(id.index()).map(|p| (id, &p.cell))).collect();
BondGraph::of(&members, tree.len())
}
#[test]
fn an_uncut_cell_has_no_wound() {
let cell = ProxyCell::from_box(Vec3::ZERO, Vec3::splat(0.5));
assert!(cap_faces(&cell).is_empty(), "a supplied hull face is not an open wound");
assert!(largest_cap(&cell).is_none(), "and there is no largest one");
}
#[test]
fn a_bond_is_its_wound_exactly() {
let graph = baked_graph(CutSettings::new(2, 0.1, 0x00C0_FFEE));
assert!(graph.len() >= 1, "a two-piece cut must leave the halves bonded");
let id = BondId(0);
let bond = graph.bond(id).expect("bond 0 exists");
let wounds = wounds_from_bonds(&graph, &[id]);
assert_eq!(wounds.len(), 1, "one broken bond is one wound");
let w = wounds[0];
assert_eq!(w.at, bond.centroid, "the wound is where the shared face was");
assert_eq!(w.normal, bond.normal, "and faces the way that face did");
assert_eq!(w.area, bond.area, "and is as wide as that face was");
assert_eq!(w.severity, 1.0, "an already-broken bond is fully open");
assert_eq!(w.kind, WoundKind::Severance);
}
#[test]
fn a_bond_from_another_graph_is_skipped() {
let graph = baked_graph(CutSettings::new(2, 0.1, 0x00C0_FFEE));
let beyond = BondId(graph.len() as u32 + 7);
assert!(
wounds_from_bonds(&graph, &[beyond]).is_empty(),
"an id this graph does not hold must produce no wound at all"
);
}
#[test]
fn wounds_are_returned_in_bond_id_order() {
let graph = baked_graph(CutSettings::new(8, 0.05, 0xD00D));
assert!(graph.len() >= 3, "need a few bonds to have an order at all");
let ids: Vec<BondId> = (0..graph.len() as u32).map(BondId).collect();
let forward = wounds_from_bonds(&graph, &ids);
let mut reversed = ids.clone();
reversed.reverse();
let backward = wounds_from_bonds(&graph, &reversed);
assert_eq!(forward, backward, "the caller's iteration order must not reach the output");
}
#[test]
fn a_cut_cell_has_a_unit_normal_cap_of_real_area() {
let (pieces, _, _) =
fracture(crate::soup::Soup::default(), &unit_cube_cells(), &CutSettings::new(2, 0.1, 0x00C0_FFEE));
let with_caps: Vec<_> =
pieces.iter().filter(|p| !cap_faces(&p.cell).is_empty()).collect();
assert!(!with_caps.is_empty(), "a cut must leave at least one open face");
for p in with_caps {
for f in cap_faces(&p.cell) {
assert!(
(f.normal.length() - 1.0).abs() < 1.0e-4,
"cap normal length {} is not unit",
f.normal.length()
);
assert!(f.area > 0.0, "a cap face with no area should have been skipped");
}
}
}
#[test]
fn a_bored_cell_exposes_its_channel_wall_perpendicular_to_the_axis() {
let axis = Vec3::X;
let bore = Bore {
flare: 0.0,
..Bore::new(Vec3::new(-1.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), 0.08)
};
let cut = CutSettings { bores: vec![bore], ..CutSettings::new(2, 0.1, 0x00C0_FFEE) };
let (pieces, _, _) = fracture(crate::soup::Soup::default(), &unit_cube_cells(), &cut);
let walls: Vec<CapFace> = pieces
.iter()
.flat_map(|p| cap_faces(&p.cell))
.filter(|f| f.normal.dot(axis).abs() < 1.0e-3)
.collect();
assert!(
!walls.is_empty(),
"no cap face was perpendicular to the bore axis — the channel wall is not being \
reported as open, so a bullet hole would not bleed"
);
assert!(
walls.iter().all(|f| f.area > 0.0),
"a channel wall with no area would give a bullet hole no blood to throw"
);
const FLARE_TILT: f32 = 0.02;
let flared = Bore::new(Vec3::new(-1.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), 0.08);
let cut = CutSettings { bores: vec![flared], ..CutSettings::new(2, 0.1, 0x00C0_FFEE) };
let (pieces, _, _) = fracture(crate::soup::Soup::default(), &unit_cube_cells(), &cut);
assert!(
pieces
.iter()
.flat_map(|p| cap_faces(&p.cell))
.any(|f| f.normal.dot(axis).abs() < FLARE_TILT),
"a flared channel's wall should still be within the flare's own tilt of perpendicular"
);
}
#[test]
fn the_largest_cap_is_the_widest_one() {
let (pieces, _, _) =
fracture(crate::soup::Soup::default(), &unit_cube_cells(), &CutSettings::new(8, 0.05, 0xD00D));
for p in &pieces {
let all = cap_faces(&p.cell);
match largest_cap(&p.cell) {
None => assert!(all.is_empty(), "None must mean there were none"),
Some(best) => {
assert!(
all.iter().all(|f| f.area <= best.area),
"a wider cap face than the chosen one exists"
);
}
}
}
}
}