use bevy::math::Vec3;
use crate::bond::{BondGraph, BondId};
use crate::tree::FragmentId;
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Reach {
hits: Vec<(BondId, f32)>,
}
impl Reach {
fn from_hits(mut hits: Vec<(BondId, f32)>) -> Reach {
hits.retain(|(_, s)| *s > 0.0 && s.is_finite());
hits.sort_unstable_by_key(|(id, _)| *id);
Reach { hits }
}
pub fn iter(&self) -> impl Iterator<Item = (BondId, f32)> + '_ {
self.hits.iter().copied()
}
pub fn severity(&self, id: BondId) -> f32 {
self.hits.binary_search_by_key(&id, |(b, _)| *b).map_or(0.0, |i| self.hits[i].1)
}
pub fn above(&self, threshold: f32) -> Vec<BondId> {
self.hits.iter().filter(|(_, s)| *s >= threshold).map(|(id, _)| *id).collect()
}
pub fn len(&self) -> usize {
self.hits.len()
}
pub fn is_empty(&self) -> bool {
self.hits.is_empty()
}
pub fn strongest(&self) -> Option<(BondId, f32)> {
self.hits.iter().copied().reduce(|a, b| if b.1 > a.1 { b } else { a })
}
}
fn falloff(distance: f32, min: f32, max: f32) -> f32 {
if distance <= min {
return 1.0;
}
if distance >= max || max <= min {
return 0.0;
}
1.0 - (distance - min) / (max - min)
}
pub fn radial(graph: &BondGraph, center: Vec3, min_radius: f32, max_radius: f32) -> Reach {
Reach::from_hits(
graph
.bonds()
.iter()
.enumerate()
.map(|(i, b)| (BondId(i as u32), falloff(b.centroid.distance(center), min_radius, max_radius)))
.collect(),
)
}
pub fn capsule(graph: &BondGraph, a: Vec3, b: Vec3, min_radius: f32, max_radius: f32) -> Reach {
Reach::from_hits(
graph
.bonds()
.iter()
.enumerate()
.map(|(i, bond)| {
let d = point_to_segment(bond.centroid, a, b);
(BondId(i as u32), falloff(d, min_radius, max_radius))
})
.collect(),
)
}
pub fn directional(
graph: &BondGraph,
origin: Vec3,
direction: Vec3,
min_radius: f32,
max_radius: f32,
) -> Reach {
let d = direction.normalize_or_zero();
if d == Vec3::ZERO {
return Reach::default();
}
Reach::from_hits(
graph
.bonds()
.iter()
.enumerate()
.map(|(i, b)| {
let near = falloff(b.centroid.distance(origin), min_radius, max_radius);
(BondId(i as u32), near * b.normal.dot(d).abs())
})
.collect(),
)
}
pub fn swept_triangle(graph: &BondGraph, a: Vec3, b: Vec3, c: Vec3) -> Reach {
Reach::from_hits(
graph
.bonds()
.iter()
.enumerate()
.filter_map(|(i, bond)| {
let (p, q) = (graph.center(bond.a)?, graph.center(bond.b)?);
segment_hits_triangle(p, q, a, b, c).then_some((BondId(i as u32), 1.0))
})
.collect(),
)
}
pub fn spread(graph: &BondGraph, point: Vec3, min_radius: f32, max_radius: f32) -> Reach {
let Some(seed) = nearest(graph, point) else { return Reach::default() };
let members = graph.members();
let mut dist: Vec<f32> = vec![f32::INFINITY; members.len()];
let mut done = vec![false; members.len()];
let slot = |id: FragmentId| members.binary_search(&id).ok();
let Some(seed_slot) = slot(seed) else { return Reach::default() };
dist[seed_slot] = 0.0;
for _ in 0..members.len() {
let mut best: Option<usize> = None;
for s in 0..members.len() {
if !done[s] && dist[s].is_finite() && best.is_none_or(|b| dist[s] < dist[b]) {
best = Some(s);
}
}
let Some(cur) = best else { break };
done[cur] = true;
let here = members[cur];
let Some(here_at) = graph.center(here) else { continue };
for &bid in graph.incident(here) {
let Some(next) = graph.across(bid, here) else { continue };
let (Some(next_slot), Some(next_at)) = (slot(next), graph.center(next)) else { continue };
let step = dist[cur] + here_at.distance(next_at);
if step < dist[next_slot] {
dist[next_slot] = step;
}
}
}
let reach_of = |end: FragmentId, face: Vec3| -> f32 {
match (slot(end), graph.center(end)) {
(Some(s), Some(c)) => dist[s] + c.distance(face),
_ => f32::INFINITY,
}
};
Reach::from_hits(
graph
.bonds()
.iter()
.enumerate()
.map(|(i, b)| {
let d = reach_of(b.a, b.centroid).min(reach_of(b.b, b.centroid));
(BondId(i as u32), falloff(d, min_radius, max_radius))
})
.collect(),
)
}
fn nearest(graph: &BondGraph, point: Vec3) -> Option<FragmentId> {
let mut best: Option<(FragmentId, f32)> = None;
for &id in graph.members() {
let Some(c) = graph.center(id) else { continue };
let d = c.distance_squared(point);
if best.is_none_or(|(_, bd)| d < bd) {
best = Some((id, d));
}
}
best.map(|(id, _)| id)
}
fn point_to_segment(p: Vec3, a: Vec3, b: Vec3) -> f32 {
let ab = b - a;
let len2 = ab.length_squared();
if len2 < 1.0e-20 {
return p.distance(a);
}
let t = ((p - a).dot(ab) / len2).clamp(0.0, 1.0);
p.distance(a + ab * t)
}
fn segment_hits_triangle(p: Vec3, q: Vec3, a: Vec3, b: Vec3, c: Vec3) -> bool {
let dir = q - p;
let (e1, e2) = (b - a, c - a);
let h = dir.cross(e2);
let det = e1.dot(h);
if det.abs() < 1.0e-12 {
return false;
}
let inv = 1.0 / det;
let s = p - a;
let u = s.dot(h) * inv;
if !(0.0..=1.0).contains(&u) {
return false;
}
let g = s.cross(e1);
let v = dir.dot(g) * inv;
if v < 0.0 || u + v > 1.0 {
return false;
}
let t = e2.dot(g) * inv;
(0.0..=1.0).contains(&t)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CutSettings;
use crate::bond::BondSet;
use crate::proxy::ProxyCell;
use crate::soup::{Soup, fracture};
use crate::tree::FragmentTree;
fn row() -> (BondGraph, Vec<FragmentId>) {
let cells: Vec<ProxyCell> =
(0..4).map(|i| ProxyCell::from_box(Vec3::new(i as f32, 0.0, 0.0), Vec3::splat(0.5))).collect();
let members: Vec<(FragmentId, &ProxyCell)> =
cells.iter().enumerate().map(|(i, c)| (FragmentId(i as u32), c)).collect();
let g = BondGraph::of(&members, 4);
let ids = (0..4).map(FragmentId).collect();
(g, ids)
}
#[test]
fn the_row_fixture_is_a_chain() {
let (g, ids) = row();
assert_eq!(g.len(), 3, "four cubes in a line share three faces");
assert_eq!(g.islands(&ids, &BondSet::new(&g)).len(), 1);
for b in g.bonds() {
assert!((b.area - 1.0).abs() < 1.0e-5);
}
}
#[test]
fn falloff_is_full_inside_min_and_gone_past_max() {
assert_eq!(falloff(0.0, 1.0, 3.0), 1.0);
assert_eq!(falloff(1.0, 1.0, 3.0), 1.0);
assert!((falloff(2.0, 1.0, 3.0) - 0.5).abs() < 1.0e-6, "halfway is half");
assert_eq!(falloff(3.0, 1.0, 3.0), 0.0);
assert_eq!(falloff(9.0, 1.0, 3.0), 0.0);
assert_eq!(falloff(0.5, 1.0, 1.0), 1.0);
assert_eq!(falloff(1.5, 1.0, 1.0), 0.0);
}
#[test]
fn a_spread_at_one_end_detaches_only_that_end() {
let (g, ids) = row();
let hit = spread(&g, Vec3::new(3.0, 0.0, 0.0), 0.5, 1.5);
let mut broken = BondSet::new(&g);
broken.sever_all(&hit.above(0.5));
let islands = g.islands(&ids, &broken);
assert_eq!(islands.len(), 2, "one piece left, got {islands:?}");
assert!(islands.contains(&vec![FragmentId(3)]), "and it was the struck end");
assert!(islands.contains(&vec![FragmentId(0), FragmentId(1), FragmentId(2)]));
}
#[test]
fn spread_falls_off_along_the_chain_not_through_space() {
let (g, _) = row();
let hit = spread(&g, Vec3::new(0.0, 0.0, 0.0), 0.0, 10.0);
let s: Vec<f32> = (0..3).map(|i| hit.severity(BondId(i))).collect();
assert!(s[0] > s[1] && s[1] > s[2], "severity must decay along the chain, got {s:?}");
assert_eq!(hit.strongest().map(|(id, _)| id), Some(BondId(0)), "strongest at the hit");
}
#[test]
fn radial_reaches_by_distance_alone() {
let (g, _) = row();
let hit = radial(&g, Vec3::new(1.5, 5.0, 0.0), 0.0, 10.0);
assert!(hit.severity(BondId(1)) > hit.severity(BondId(0)));
assert!((hit.severity(BondId(0)) - hit.severity(BondId(2))).abs() < 1.0e-5, "symmetric");
}
#[test]
fn a_capsule_cuts_along_its_length() {
let (g, _) = row();
let along = capsule(&g, Vec3::new(0.0, 0.0, 0.0), Vec3::new(3.0, 0.0, 0.0), 0.1, 0.2);
assert_eq!(along.above(0.9).len(), 3, "the swing ran the length of the row");
let beside = capsule(&g, Vec3::new(0.0, 9.0, 0.0), Vec3::new(3.0, 9.0, 0.0), 0.1, 0.2);
assert!(beside.is_empty(), "a swing nine units away touches nothing");
}
#[test]
fn a_swept_triangle_severs_only_what_it_passed_through() {
let (g, ids) = row();
let hit = swept_triangle(
&g,
Vec3::new(1.5, -5.0, -5.0),
Vec3::new(1.5, 5.0, -5.0),
Vec3::new(1.5, 0.0, 5.0),
);
assert_eq!(hit.above(1.0), vec![BondId(1)], "exactly the bond it passed through");
let mut broken = BondSet::new(&g);
broken.sever_all(&hit.above(1.0));
let islands = g.islands(&ids, &broken);
assert_eq!(islands.len(), 2, "cleaved in two");
assert_eq!(islands[0], vec![FragmentId(0), FragmentId(1)]);
assert_eq!(islands[1], vec![FragmentId(2), FragmentId(3)]);
}
#[test]
fn a_swept_triangle_that_misses_severs_nothing() {
let (g, _) = row();
let hit = swept_triangle(
&g,
Vec3::new(1.5, 20.0, -5.0),
Vec3::new(1.5, 30.0, -5.0),
Vec3::new(1.5, 25.0, 5.0),
);
assert!(hit.is_empty());
}
#[test]
fn a_pull_only_reaches_faces_that_meet_it() {
let (g, _) = row();
let along = directional(&g, Vec3::new(1.5, 0.0, 0.0), Vec3::X, 10.0, 20.0);
let across = directional(&g, Vec3::new(1.5, 0.0, 0.0), Vec3::Y, 10.0, 20.0);
assert_eq!(along.len(), 3, "every face in the row faces along X");
assert!(along.iter().all(|(_, s)| (s - 1.0).abs() < 1.0e-4));
assert!(across.is_empty(), "and none of them face along Y");
assert!(directional(&g, Vec3::ZERO, Vec3::ZERO, 1.0, 2.0).is_empty(), "a pull with no direction");
}
#[test]
fn repeated_blows_take_it_apart_progressively() {
let (g, ids) = row();
let mut broken = BondSet::new(&g);
let mut counts = Vec::new();
for x in [3.0f32, 0.0, 1.5] {
let hit = spread(&g, Vec3::new(x, 0.0, 0.0), 0.5, 1.5);
broken.sever_all(&hit.above(0.5));
counts.push(g.islands(&ids, &broken).len());
}
assert_eq!(counts, vec![2, 3, 4], "each blow takes another piece off");
assert!(broken.severed() == g.len(), "and by the third, nothing holds");
}
#[test]
fn the_queries_are_pure_and_reproducible() {
let cells = vec![ProxyCell::from_box(Vec3::ZERO, Vec3::splat(0.5))];
let build = || -> (BondGraph, FragmentTree) {
let (pieces, tree, _) = fracture(Soup::default(), &cells, &CutSettings::new(8, 0.05, 0xD00D));
(crate::mesh::bond_graph(&pieces, &tree), tree)
};
let (ga, _) = build();
let (gb, _) = build();
assert_eq!(ga, gb, "the graph itself is reproducible");
for (a, b) in [
(spread(&ga, Vec3::X * 0.4, 0.1, 0.6), spread(&gb, Vec3::X * 0.4, 0.1, 0.6)),
(radial(&ga, Vec3::ZERO, 0.1, 0.6), radial(&gb, Vec3::ZERO, 0.1, 0.6)),
(
capsule(&ga, -Vec3::X, Vec3::X, 0.1, 0.6),
capsule(&gb, -Vec3::X, Vec3::X, 0.1, 0.6),
),
(
directional(&ga, Vec3::ZERO, Vec3::Y, 0.1, 0.6),
directional(&gb, Vec3::ZERO, Vec3::Y, 0.1, 0.6),
),
(
swept_triangle(&ga, Vec3::new(0.0, -1.0, -1.0), Vec3::new(0.0, 1.0, -1.0), Vec3::new(0.0, 0.0, 1.0)),
swept_triangle(&gb, Vec3::new(0.0, -1.0, -1.0), Vec3::new(0.0, 1.0, -1.0), Vec3::new(0.0, 0.0, 1.0)),
),
] {
assert_eq!(a, b, "a region query must be a pure function of the bake");
}
}
#[test]
fn a_hit_takes_part_of_the_subject_and_leaves_the_rest_standing() {
let cells = vec![
ProxyCell::from_box(Vec3::ZERO, Vec3::new(0.35, 0.55, 0.2)),
ProxyCell::from_box(Vec3::new(0.0, 0.75, 0.0), Vec3::splat(0.2)),
];
let (pieces, tree, _) = fracture(Soup::default(), &cells, &CutSettings::new(34, 0.08, 0x00C0_FFEE));
let standing = tree.leaves();
let graph = crate::mesh::bond_graph(&pieces, &tree);
assert!(standing.len() > 12, "need a fine enough bake to take a small piece off");
assert_eq!(
graph.islands(&standing, &BondSet::new(&graph)).len(),
1,
"the subject starts as one body"
);
let mut broken = BondSet::new(&graph);
let hit = spread(&graph, Vec3::new(0.0, 0.82, 0.0), 0.06, 0.34);
assert!(!hit.is_empty(), "the blow reached nothing at all");
broken.sever_all(&hit.above(0.5));
let islands = graph.islands(&standing, &broken);
assert!(islands.len() >= 2, "something should have come off");
let biggest = islands.iter().map(|i| i.len()).max().unwrap_or(0);
let off: usize = standing.len() - biggest;
assert!(off > 0, "nothing detached");
assert!(
off < standing.len() / 2,
"{off} of {} came off — a localised hit must not take most of the body",
standing.len()
);
let detached: Vec<FragmentId> =
islands.iter().filter(|i| i.len() != biggest).flatten().copied().collect();
for id in &detached {
let Some(c) = graph.center(*id) else { continue };
assert!(c.y > 0.2, "fragment {id:?} left from {c:?}, nowhere near a hit at y = 0.82");
}
let mut count = islands.len();
let mut progressed = false;
for y in [-0.30f32, 0.0, 0.30, -0.45, 0.45] {
broken.sever_all(&spread(&graph, Vec3::new(0.0, y, 0.0), 0.08, 0.45).above(0.5));
let now = graph.islands(&standing, &broken).len();
assert!(now >= count, "a blow at y = {y} re-joined something: {count} -> {now}");
progressed |= now > count;
count = now;
}
assert!(progressed, "five more blows and nothing else came off");
assert!(count > islands.len(), "the subject ended up no more broken than after one blow");
}
#[test]
fn an_empty_graph_reaches_nothing() {
let g = BondGraph::default();
assert!(spread(&g, Vec3::ZERO, 1.0, 2.0).is_empty());
assert!(radial(&g, Vec3::ZERO, 1.0, 2.0).is_empty());
assert!(capsule(&g, Vec3::ZERO, Vec3::X, 1.0, 2.0).is_empty());
assert!(directional(&g, Vec3::ZERO, Vec3::X, 1.0, 2.0).is_empty());
assert!(swept_triangle(&g, Vec3::ZERO, Vec3::X, Vec3::Y).is_empty());
assert_eq!(Reach::default().severity(BondId(0)), 0.0);
assert!(Reach::default().strongest().is_none());
}
}