#![allow(missing_docs)]
#![allow(dead_code)]
use std::collections::HashMap;
use std::collections::HashSet;
#[inline]
fn v3_sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
#[inline]
fn v3_dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
#[inline]
fn v3_len2(a: [f64; 3]) -> f64 {
v3_dot(a, a)
}
#[inline]
fn v3_scale(a: [f64; 3], s: f64) -> [f64; 3] {
[a[0] * s, a[1] * s, a[2] * s]
}
#[inline]
fn v3_add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
#[derive(Clone, Debug)]
pub enum TriggerShape {
Sphere {
center: [f64; 3],
radius: f64,
},
Aabb {
min: [f64; 3],
max: [f64; 3],
},
Capsule {
start: [f64; 3],
end: [f64; 3],
radius: f64,
},
}
impl TriggerShape {
pub fn contains_point_with_radius(&self, p: [f64; 3], r: f64) -> bool {
match self {
TriggerShape::Sphere { center, radius } => {
let d2 = v3_len2(v3_sub(p, *center));
let combined = radius + r;
d2 <= combined * combined
}
TriggerShape::Aabb { min, max } => {
(p[0] >= min[0] - r && p[0] <= max[0] + r)
&& (p[1] >= min[1] - r && p[1] <= max[1] + r)
&& (p[2] >= min[2] - r && p[2] <= max[2] + r)
}
TriggerShape::Capsule { start, end, radius } => {
let ab = v3_sub(*end, *start);
let ap = v3_sub(p, *start);
let t = (v3_dot(ap, ab) / v3_dot(ab, ab).max(1e-12)).clamp(0.0, 1.0);
let closest = v3_add(*start, v3_scale(ab, t));
let d2 = v3_len2(v3_sub(p, closest));
let combined = radius + r;
d2 <= combined * combined
}
}
}
pub fn bounding_radius(&self) -> f64 {
match self {
TriggerShape::Sphere { radius, .. } => *radius,
TriggerShape::Aabb { min, max } => {
let d = v3_sub(*max, *min);
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() * 0.5
}
TriggerShape::Capsule { start, end, radius } => {
v3_len2(v3_sub(*end, *start)).sqrt() * 0.5 + radius
}
}
}
}
#[derive(Clone, Debug)]
pub struct TriggerVolume {
pub id: u32,
pub shape: TriggerShape,
pub tags: Vec<String>,
pub enabled: bool,
}
impl TriggerVolume {
pub fn has_tag(&self, tag: &str) -> bool {
self.tags.iter().any(|t| t == tag)
}
}
#[derive(Clone, Debug)]
pub struct BodyEntry {
pub index: usize,
pub position: [f64; 3],
pub radius: f64,
pub is_sleeping: bool,
}
#[derive(Clone, Debug)]
pub enum TriggerEvent {
Enter {
trigger_id: u32,
body_index: usize,
step: u64,
},
Exit {
trigger_id: u32,
body_index: usize,
step: u64,
},
Stay {
trigger_id: u32,
body_index: usize,
step: u64,
},
}
impl TriggerEvent {
pub fn trigger_id(&self) -> u32 {
match self {
TriggerEvent::Enter { trigger_id, .. }
| TriggerEvent::Exit { trigger_id, .. }
| TriggerEvent::Stay { trigger_id, .. } => *trigger_id,
}
}
pub fn body_index(&self) -> usize {
match self {
TriggerEvent::Enter { body_index, .. }
| TriggerEvent::Exit { body_index, .. }
| TriggerEvent::Stay { body_index, .. } => *body_index,
}
}
pub fn is_enter(&self) -> bool {
matches!(self, TriggerEvent::Enter { .. })
}
pub fn is_exit(&self) -> bool {
matches!(self, TriggerEvent::Exit { .. })
}
pub fn is_stay(&self) -> bool {
matches!(self, TriggerEvent::Stay { .. })
}
}
struct OverlapTest {
body_index: usize,
trigger_id: u32,
now_inside: bool,
}
#[derive(Debug, Default)]
pub struct TriggerWorld {
volumes: Vec<TriggerVolume>,
next_id: u32,
occupied: HashMap<usize, Vec<u32>>,
emit_stay: bool,
}
impl TriggerWorld {
pub fn new() -> Self {
Self::default()
}
pub fn set_emit_stay(&mut self, emit: bool) {
self.emit_stay = emit;
}
pub fn add_sphere(&mut self, center: [f64; 3], radius: f64, tags: Vec<String>) -> u32 {
let id = self.next_id;
self.next_id += 1;
self.volumes.push(TriggerVolume {
id,
shape: TriggerShape::Sphere { center, radius },
tags,
enabled: true,
});
id
}
pub fn add_aabb(&mut self, min: [f64; 3], max: [f64; 3], tags: Vec<String>) -> u32 {
let id = self.next_id;
self.next_id += 1;
self.volumes.push(TriggerVolume {
id,
shape: TriggerShape::Aabb { min, max },
tags,
enabled: true,
});
id
}
pub fn add_capsule(
&mut self,
start: [f64; 3],
end: [f64; 3],
radius: f64,
tags: Vec<String>,
) -> u32 {
let id = self.next_id;
self.next_id += 1;
self.volumes.push(TriggerVolume {
id,
shape: TriggerShape::Capsule { start, end, radius },
tags,
enabled: true,
});
id
}
pub fn remove_volume(&mut self, id: u32) -> bool {
if let Some(pos) = self.volumes.iter().position(|v| v.id == id) {
self.volumes.swap_remove(pos);
for occ in self.occupied.values_mut() {
occ.retain(|&tid| tid != id);
}
self.occupied.retain(|_, ids| !ids.is_empty());
true
} else {
false
}
}
pub fn enable_volume(&mut self, id: u32) {
if let Some(v) = self.volumes.iter_mut().find(|v| v.id == id) {
v.enabled = true;
}
}
pub fn disable_volume(&mut self, id: u32) {
if let Some(v) = self.volumes.iter_mut().find(|v| v.id == id) {
v.enabled = false;
}
}
pub fn bodies_in(&self, trigger_id: u32) -> Vec<usize> {
self.occupied
.iter()
.filter_map(|(&bi, ids)| {
if ids.contains(&trigger_id) {
Some(bi)
} else {
None
}
})
.collect()
}
pub fn triggers_containing(&self, body_index: usize) -> Vec<u32> {
self.occupied.get(&body_index).cloned().unwrap_or_default()
}
pub fn volume_count(&self) -> usize {
self.volumes.len()
}
pub fn active_occupancies(&self) -> usize {
self.occupied.values().map(|v| v.len()).sum()
}
pub fn volume(&self, id: u32) -> Option<&TriggerVolume> {
self.volumes.iter().find(|v| v.id == id)
}
pub fn volumes_with_tag(&self, tag: &str) -> Vec<&TriggerVolume> {
self.volumes.iter().filter(|v| v.has_tag(tag)).collect()
}
pub fn update(&mut self, step: u64, bodies: &[BodyEntry]) -> Vec<TriggerEvent> {
let mut events = Vec::new();
let present: HashSet<usize> = bodies.iter().map(|b| b.index).collect();
let absent_exits: Vec<(usize, u32)> = self
.occupied
.iter()
.filter(|&(&bi, _)| !present.contains(&bi))
.flat_map(|(&bi, ids)| ids.iter().map(move |&tid| (bi, tid)))
.collect();
for (body_index, trigger_id) in absent_exits {
events.push(TriggerEvent::Exit {
trigger_id,
body_index,
step,
});
if let Some(occ) = self.occupied.get_mut(&body_index) {
occ.retain(|&t| t != trigger_id);
}
}
self.occupied.retain(|_, ids| !ids.is_empty());
let tests: Vec<OverlapTest> = self
.volumes
.iter()
.filter(|v| v.enabled)
.flat_map(|vol| {
let vid = vol.id;
bodies.iter().map(move |body| OverlapTest {
body_index: body.index,
trigger_id: vid,
now_inside: vol
.shape
.contains_point_with_radius(body.position, body.radius),
})
})
.collect();
for test in tests {
let was_inside = self
.occupied
.get(&test.body_index)
.is_some_and(|ids| ids.contains(&test.trigger_id));
match (was_inside, test.now_inside) {
(false, true) => {
self.occupied
.entry(test.body_index)
.or_default()
.push(test.trigger_id);
events.push(TriggerEvent::Enter {
trigger_id: test.trigger_id,
body_index: test.body_index,
step,
});
}
(true, false) => {
if let Some(occ) = self.occupied.get_mut(&test.body_index) {
occ.retain(|&t| t != test.trigger_id);
}
events.push(TriggerEvent::Exit {
trigger_id: test.trigger_id,
body_index: test.body_index,
step,
});
}
(true, true) => {
if self.emit_stay {
events.push(TriggerEvent::Stay {
trigger_id: test.trigger_id,
body_index: test.body_index,
step,
});
}
}
(false, false) => {}
}
}
events
}
}
impl std::fmt::Display for TriggerWorld {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"TriggerWorld {{ volumes: {}, occupancies: {} }}",
self.volumes.len(),
self.active_occupancies(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn body(index: usize, pos: [f64; 3]) -> BodyEntry {
BodyEntry {
index,
position: pos,
radius: 0.5,
is_sleeping: false,
}
}
#[test]
fn sphere_enter_exit() {
let mut world = TriggerWorld::new();
let id = world.add_sphere([0.0, 0.0, 0.0], 5.0, vec!["zone".into()]);
let inside = vec![body(0, [0.0, 0.0, 0.0])];
let ev = world.update(1, &inside);
assert_eq!(ev.len(), 1);
assert!(ev[0].is_enter());
assert_eq!(ev[0].body_index(), 0);
let ev = world.update(2, &inside);
assert!(ev.is_empty());
let outside = vec![body(0, [20.0, 0.0, 0.0])];
let ev = world.update(3, &outside);
assert_eq!(ev.len(), 1);
assert!(ev[0].is_exit());
assert_eq!(world.bodies_in(id).len(), 0);
assert_eq!(world.active_occupancies(), 0);
}
#[test]
fn aabb_trigger() {
let mut world = TriggerWorld::new();
world.add_aabb([-5.0, -5.0, -5.0], [5.0, 5.0, 5.0], vec![]);
let ev = world.update(1, &[body(0, [0.0, 0.0, 0.0])]);
assert!(ev.iter().any(|e| e.is_enter()));
let ev = world.update(2, &[body(0, [100.0, 0.0, 0.0])]);
assert!(ev.iter().any(|e| e.is_exit()));
}
#[test]
fn capsule_trigger() {
let mut world = TriggerWorld::new();
world.add_capsule([0.0, 0.0, 0.0], [0.0, 10.0, 0.0], 2.0, vec![]);
let ev = world.update(1, &[body(0, [0.0, 5.0, 0.0])]);
assert!(ev[0].is_enter());
}
#[test]
fn stay_events_when_enabled() {
let mut world = TriggerWorld::new();
world.set_emit_stay(true);
world.add_sphere([0.0, 0.0, 0.0], 5.0, vec![]);
let bodies = vec![body(0, [0.0, 0.0, 0.0])];
world.update(1, &bodies); let ev = world.update(2, &bodies); assert!(ev.iter().any(|e| e.is_stay()));
}
#[test]
fn absent_body_emits_exit() {
let mut world = TriggerWorld::new();
world.add_sphere([0.0, 0.0, 0.0], 5.0, vec![]);
world.update(1, &[body(0, [0.0, 0.0, 0.0])]);
let ev = world.update(2, &[]);
assert!(ev.iter().any(|e| e.is_exit()));
}
#[test]
fn remove_volume_clears_occupancy() {
let mut world = TriggerWorld::new();
let id = world.add_sphere([0.0, 0.0, 0.0], 5.0, vec![]);
world.update(1, &[body(0, [0.0, 0.0, 0.0])]);
assert_eq!(world.active_occupancies(), 1);
world.remove_volume(id);
assert_eq!(world.active_occupancies(), 0);
}
}