Files
benilla-octo/crates/benilla-app/src/interact/pick.rs
T
SamandClaude Fable 5 cd58f90bec a chat link is one token now, and tooltips print true costs
- a chat link edits as one atomic token: the caret can't land inside it, its colors survive, one keypress removes it whole
- spell tooltips print the resolved mana cost through the reference's own table walk, and the "% of base mana" line dies
- the spirit healer's confirm comes back after a cancel and wears its warning icon
- the dressing room's pane is a live animating model, and a tried-on bow lands in the hand

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:02:39 +07:00

547 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The shared **ray caster** — the pick-geometry declarations and the triangle-accurate cast every
//! "what is under the cursor" consumer runs through: the inspector's mouseover, the target module's
//! GameObject hover, the `WOW_PICK` probe. Casts against **resident geometry** ([`PickMesh`] —
//! decision 0857), because the render meshes are `RENDER_WORLD`-only (0834) and a physics ray
//! misses colliderless props. Pick geometry is **declared, never inferred** (decision 0929).
use benilla_assets::coords::wow_to_bevy;
use bevy::camera::primitives::Aabb;
use bevy::platform::collections::HashSet;
use bevy::prelude::*;
/// The resident **pick geometry** of one drawn batch: the model's decoded `RenderSubmesh`, `Arc`-shared
/// with the model asset itself (decision 0857). The render forms are `RENDER_WORLD`-only since 0834 —
/// their main-world vertex data is gone after extract, which is why Bevy's `MeshRayCast` silently
/// stopped hitting every static model (the GO hover, the inspector, `WOW_PICK`). So the pickers read
/// triangles from THIS instead, through the same WoW→Bevy bake the render form was built with
/// (`submesh_to_static_mesh`: `wow_to_bevy` per vertex, billboard cards centred at their pivot).
/// Attached beside `Mesh3d` at every spawn site that also attaches a pick key
/// ([`super::WorldObject`] / `ModelPart`). A keyed entity carrying neither this nor [`PickBox`] is
/// **not pickable** — pick geometry is declared, never inferred from a bound (decision 0929).
#[derive(Component, Clone)]
pub struct PickMesh(pub std::sync::Arc<benilla_formats::RenderSubmesh>);
/// "My `Aabb` **is** my pick geometry" — the model-less cube fallback ([`crate::entities::attach`]),
/// whose cuboid is exactly its bound, so a slab test is the exact answer and 12 triangles would only
/// be a slower way to say it.
///
/// It exists as a **positive declaration** because the alternative — inferring it from the *absence*
/// of a [`PickMesh`] — silently promoted every other keyed-but-mesh-less drawn entity into a solid
/// invisible box. That is what broke the inspector inside a city WMO (decision 0929): a WMO group's
/// MLIQ pool is a drawn `Mesh3d` with no resident geometry, it inherits the building's
/// [`super::WorldObject`] from the placement's blanket tag, and its render mesh keeps the **whole**
/// MLIQ vertex grid — dry cells included, sitting at height 0 while the drawn lava is 86–127 yd
/// below. Ironforge's thirteen pools therefore hung ~100-yard invisible boxes over the entire city,
/// and the box-entry hit won every pick: every hover, on anything, answered "Wmo · ironforge.wmo".
#[derive(Component, Clone, Copy)]
pub struct PickBox;
/// One triangle-accurate hit from [`cast_pick_ray`]: the world-space point, the hit triangle's
/// geometric normal (two-sided — the orientation is the authored winding's), and the distance along
/// the ray.
pub struct RayHit {
pub point: Vec3,
pub normal: Vec3,
pub distance: f32,
}
/// The one query every mesh picker casts against: a part's resident geometry, its world pose, the
/// render world's own visibility verdict (the cast honours it, as `RayCastVisibility::VisibleInView`
/// did), its bound for the broad phase, and whether that bound is itself the pick shape
/// ([`PickBox`]).
pub type PickParts<'w, 's> = Query<
'w,
's,
(
Option<&'static PickMesh>,
&'static GlobalTransform,
&'static ViewVisibility,
Option<&'static Aabb>,
Has<PickBox>,
),
>;
/// Cast a ray from the logical `cursor` position into the world and return the nearest hit among
/// `pickable`: `(entity, world point, distance)`. The `pickable` set restricts the cast (terrain,
/// particle billboards, and other un-identified meshes stay transparent), so callers choose *what* is
/// pickable while sharing *how* — the inspector's per-frame mouseover passes every
/// [`super::WorldObject`], the target picker passes only unit meshes (so a doodad in front doesn't
/// block a click on a mob).
pub fn pick_at_cursor(
cursor: Vec2,
camera: &Camera,
cam_tf: &GlobalTransform,
pickable: &HashSet<Entity>,
parts: &PickParts,
) -> Option<(Entity, Vec3, f32)> {
let ray = camera.viewport_to_world(cam_tf, cursor).ok()?;
cast_pick_ray(ray, pickable, parts, false)
.into_iter()
.next()
.map(|(e, hit)| (e, hit.point, hit.distance))
}
/// Cast `ray` against `pickable`'s **resident geometry** ([`PickMesh`] — decision 0857: the render
/// meshes are `RENDER_WORLD`-only, so there is no main-world mesh data to cast against) and return
/// the hits nearest-first: one per entity (its nearest triangle), the whole list with `all_hits`
/// (the `WOW_PICK` probe's everything-along-the-ray reading) or just the front entity without.
///
/// Broad phase: a world-space slab test on each candidate's `Aabb` (entry distance), nearest entry
/// first, so the narrow walk stops as soon as a confirmed hit is closer than every remaining box. A
/// part with no bound (a `NoFrustumCulling` fx part) is narrow-tested unconditionally; a
/// [`PickBox`] entity takes its box entry as the hit, its cuboid being exactly its bound.
/// Triangles test **two-sided**, like the unit picker's narrow phase — a generous pick beats a
/// strict one at silhouette edges.
///
/// **Pick geometry is required, never inferred** (decision 0929): an entity that is neither a
/// [`PickMesh`] nor a [`PickBox`] is not pickable, however identified or bounded it is. The box hit
/// used to be the fallback for *any* keyed entity with an `Aabb` and no mesh, which quietly turned
/// a WMO's MLIQ pool — a drawn mesh with no resident geometry, wearing its building's identity —
/// into a city-sized invisible occluder. See [`PickBox`].
pub fn cast_pick_ray(
ray: Ray3d,
pickable: &HashSet<Entity>,
parts: &PickParts,
all_hits: bool,
) -> Vec<(Entity, RayHit)> {
cast_pick_ray_impl(ray, pickable, parts, all_hits, false)
}
/// [`cast_pick_ray`]'s **generous second pass** (decision 1071 — wow-re object-layer mouse-pick,
/// resolve `0x7089c0` pass 2, mouse-pick only): every vertex displaced by its **authored normal,
/// added raw** — 1 model-unit (× the part's world scale) outward, the same halo the unit picker
/// builds from skinned normals. A part without authored normals cannot build the halo and stays
/// exact-only, like the unit path. A [`PickBox`] inflates its cuboid by the same 1 model-unit —
/// the cube fallback stands in for a model, so the halo applies to it too. Returns **all** hits
/// (the caller ranks them by the reference's pass-2 priority ladder, not pure distance). The
/// reference clips its halo to the sequence bounds *sphere*; our broad bound is the tight mesh
/// `Aabb` inflated by the same 1 model-unit — strictly more permissive, never less (the target
/// module's standing direction of error).
pub fn cast_pick_ray_inflated(
ray: Ray3d,
pickable: &HashSet<Entity>,
parts: &PickParts,
) -> Vec<(Entity, RayHit)> {
cast_pick_ray_impl(ray, pickable, parts, true, true)
}
fn cast_pick_ray_impl(
ray: Ray3d,
pickable: &HashSet<Entity>,
parts: &PickParts,
all_hits: bool,
inflate: bool,
) -> Vec<(Entity, RayHit)> {
let (origin, dir) = (ray.origin, *ray.direction);
let mut candidates: Vec<(f32, Entity)> = Vec::new();
for &entity in pickable {
let Ok((mesh, gt, vis, aabb, is_box)) = parts.get(entity) else {
continue;
};
if !vis.get() {
continue; // not drawn in any view this frame — the old cast's VisibleInView rule
}
let entry = match (aabb, mesh, is_box) {
(Some(aabb), Some(_), _) | (Some(aabb), None, true) => {
// Inflated cast: the halo displaces at most 1 model-unit (local space), so the
// local bound grown by exactly that can never clip it.
let bound = if inflate {
Aabb {
center: aabb.center,
half_extents: aabb.half_extents + bevy::math::Vec3A::ONE,
}
} else {
*aabb
};
let (min, max) = world_aabb(&bound, gt);
match ray_aabb(origin, dir, min, max) {
Some(t) => t,
None => continue,
}
}
(None, Some(_), _) => 0.0,
// No pick geometry — a drawn mesh nobody armed for the ray (a liquid surface), or a
// `PickBox` with no bound to be. Not pickable, rather than pickable as its bound.
(_, None, _) => continue,
};
candidates.push((entry, entity));
}
candidates.sort_unstable_by(|a, b| a.0.total_cmp(&b.0));
let mut hits: Vec<(Entity, RayHit)> = Vec::new();
let mut best = f32::INFINITY;
for (entry, entity) in candidates {
if !all_hits && best < entry {
break; // every remaining box starts beyond the confirmed nearest hit
}
let Ok((mesh, gt, _, _, _)) = parts.get(entity) else {
continue;
};
let hit = match mesh {
Some(m) => ray_pick_mesh(m, gt, origin, dir, inflate),
// A `PickBox` (the broad phase admitted no other mesh-less candidate): its box entry is
// exactly its surface, the cuboid BEING its Aabb (grown by the halo when inflated).
None => Some(RayHit {
point: origin + dir * entry,
normal: -dir,
distance: entry,
}),
};
if let Some(h) = hit {
best = best.min(h.distance);
hits.push((entity, h));
}
}
hits.sort_unstable_by(|a, b| a.1.distance.total_cmp(&b.1.distance));
if !all_hits {
hits.truncate(1);
}
hits
}
/// The narrow phase for one part: ray-test every triangle of its resident geometry, in **model-local
/// space** — the world ray mapped through the part's inverse affine, under which the ray parameter is
/// unchanged, so a local `t` (against the world-normalized direction's image) *is* the world distance.
/// Vertices go through the render form's own bake (`build_submesh_mesh`): `wow_to_bevy` per vertex,
/// a billboard card centred at its pivot — so the pick tests exactly the surface the part draws
/// (a card's live camera-facing rotation rides its `GlobalTransform`, shared here too). With
/// `inflate` (the generous pass 2, decision 1071), each vertex is additionally displaced by its
/// authored normal, raw — 1 model-unit in local space, which the world transform then scales,
/// exactly the reference's `skinned_pos + rot·normal` with no extra constant. `wow_to_bevy` is a
/// pure axis permutation with sign flips (orthonormal), so applying it to the normal is exact. A
/// submesh authored without normals can't build the halo and reports a miss (the exact pass
/// already had its say).
fn ray_pick_mesh(
mesh: &PickMesh,
gt: &GlobalTransform,
origin: Vec3,
dir: Vec3,
inflate: bool,
) -> Option<RayHit> {
let geo = &*mesh.0;
if inflate && geo.normals.len() != geo.positions.len() {
return None; // no authored normals — no halo to build
}
let inv = gt.affine().inverse();
let local_origin = inv.transform_point3(origin);
let local_dir = inv.transform_vector3(dir);
let center = geo
.billboard
.as_ref()
.map_or(Vec3::ZERO, |b| wow_to_bevy(b.pivot));
let pos = |i: u32| {
let p = wow_to_bevy(*geo.positions.get(i as usize)?) - center;
Some(if inflate {
p + wow_to_bevy(geo.normals[i as usize])
} else {
p
})
};
let mut nearest: Option<(f32, [Vec3; 3])> = None;
for t in geo.indices.chunks_exact(3) {
let (Some(a), Some(b), Some(c)) = (pos(t[0]), pos(t[1]), pos(t[2])) else {
continue; // an out-of-range index — corrupt authoring; skip the triangle, not the model
};
let tri = [a, b, c];
if let Some(d) = ray_triangle(local_origin, local_dir, &tri) {
if nearest.is_none_or(|(nd, _)| d < nd) {
nearest = Some((d, tri));
}
}
}
let (t, tri) = nearest?;
let world = tri.map(|v| gt.transform_point(v));
let normal = (world[1] - world[0])
.cross(world[2] - world[0])
.normalize_or_zero();
Some(RayHit {
point: origin + dir * t,
normal,
distance: t,
})
}
/// Ray–triangle intersection (Möller–Trumbore), **two-sided**, returning `t ≥ 0` along `dir`
/// (unnormalized is fine — `t` is in `dir` lengths) or `None` on miss/parallel. Two-sided because a
/// posed mesh can present back faces at silhouette edges and a generous pick beats a strict one.
pub(crate) fn ray_triangle(origin: Vec3, dir: Vec3, tri: &[Vec3; 3]) -> Option<f32> {
let (e1, e2) = (tri[1] - tri[0], tri[2] - tri[0]);
let p = dir.cross(e2);
let det = e1.dot(p);
if det.abs() < 1e-8 {
return None;
}
let inv = 1.0 / det;
let s = origin - tri[0];
let u = s.dot(p) * inv;
if !(0.0..=1.0).contains(&u) {
return None;
}
let q = s.cross(e1);
let v = dir.dot(q) * inv;
if v < 0.0 || u + v > 1.0 {
return None;
}
let t = e2.dot(q) * inv;
(t >= 0.0).then_some(t)
}
/// A mesh's model-local [`Aabb`] transformed into a world-space axis-aligned box `(min, max)` — its 8
/// corners run through the entity's world transform, then min/max'd. (An AABB of the rotated box: a hair
/// larger than the true oriented box, which only makes the hover more forgiving.)
pub(crate) fn world_aabb(aabb: &Aabb, gt: &GlobalTransform) -> (Vec3, Vec3) {
let center = Vec3::from(aabb.center);
let he = Vec3::from(aabb.half_extents);
let mut min = Vec3::splat(f32::INFINITY);
let mut max = Vec3::splat(f32::NEG_INFINITY);
for sx in [-1.0, 1.0] {
for sy in [-1.0, 1.0] {
for sz in [-1.0, 1.0] {
let world = gt.transform_point(center + he * Vec3::new(sx, sy, sz));
min = min.min(world);
max = max.max(world);
}
}
}
(min, max)
}
/// Ray vs axis-aligned box (the slab test): the entry distance along `dir` if the ray hits, else `None`.
/// `dir` need not be normalized; a zero component is handled by the infinities `recip` produces.
pub(crate) fn ray_aabb(origin: Vec3, dir: Vec3, min: Vec3, max: Vec3) -> Option<f32> {
let inv = dir.recip();
let t1 = (min - origin) * inv;
let t2 = (max - origin) * inv;
let tmin = t1.min(t2).max_element();
let tmax = t1.max(t2).min_element();
(tmax >= tmin.max(0.0)).then_some(tmin.max(0.0))
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_formats::RenderSubmesh;
const TRI: [Vec3; 3] = [
Vec3::new(-1.0, 0.0, -1.0),
Vec3::new(1.0, 0.0, -1.0),
Vec3::new(0.0, 0.0, 1.0),
];
#[test]
fn ray_triangle_hits_at_the_right_distance() {
let t = ray_triangle(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y, &TRI).expect("hit");
assert!((t - 5.0).abs() < 1e-5);
}
#[test]
fn ray_triangle_is_two_sided() {
// Same triangle hit from below (reversed winding relative to the ray) still connects.
assert!(ray_triangle(Vec3::new(0.0, -5.0, 0.0), Vec3::Y, &TRI).is_some());
}
#[test]
fn ray_triangle_misses_outside_and_behind() {
// Outside the triangle's extent.
assert!(ray_triangle(Vec3::new(3.0, 5.0, 0.0), Vec3::NEG_Y, &TRI).is_none());
// Triangle behind the ray origin (t < 0).
assert!(ray_triangle(Vec3::new(0.0, 5.0, 0.0), Vec3::Y, &TRI).is_none());
// Parallel to the plane.
assert!(ray_triangle(Vec3::new(0.0, 5.0, 0.0), Vec3::X, &TRI).is_none());
}
/// [`TRI`]'s pre-image under `wow_to_bevy` (`bevy = (−y, z, −x)` ⇒ `wow = (−bz, −bx, by)`):
/// a submesh authored with THESE WoW-space positions must be picked exactly where the render
/// form draws it, i.e. at [`TRI`] in model-local Bevy space.
fn wow_tri() -> RenderSubmesh {
RenderSubmesh {
positions: vec![[1.0, 1.0, 0.0], [1.0, -1.0, 0.0], [-1.0, 0.0, 0.0]],
indices: vec![0, 1, 2],
..Default::default()
}
}
/// The picker ↔ render-form bake contract (decision 0857): [`ray_pick_mesh`] must test the
/// resident WoW-axes geometry through the SAME `wow_to_bevy` bake `submesh_to_static_mesh`
/// builds the drawn mesh with, under the part's world transform — the render mesh itself is
/// `RENDER_WORLD`-only (0834), so this path is the only thing keeping static models pickable.
#[test]
fn resident_geometry_picks_where_the_render_form_draws() {
let mesh = PickMesh(std::sync::Arc::new(wow_tri()));
// A translated + uniformly scaled part: the triangle spans x∈[8,12], z∈[-2,2] at y=1.
let gt =
GlobalTransform::from(Transform::from_xyz(10.0, 1.0, 0.0).with_scale(Vec3::splat(2.0)));
let hit = ray_pick_mesh(&mesh, &gt, Vec3::new(10.0, 6.0, 0.0), Vec3::NEG_Y, false)
.expect("the baked triangle must be hit under its world transform");
assert!((hit.distance - 5.0).abs() < 1e-4);
assert!(hit.point.abs_diff_eq(Vec3::new(10.0, 1.0, 0.0), 1e-4));
assert!(hit.normal.abs_diff_eq(Vec3::Y, 1e-4) || hit.normal.abs_diff_eq(-Vec3::Y, 1e-4));
// …and a miss stays a miss.
assert!(ray_pick_mesh(&mesh, &gt, Vec3::new(20.0, 6.0, 0.0), Vec3::NEG_Y, false).is_none());
}
/// [`wow_tri`] with every vertex normal authored as model-local Bevy `+X` (WoW pre-image
/// `(0, −1, 0)`): the inflated pass displaces the whole triangle +1 model-unit in x.
fn wow_tri_with_x_normals() -> RenderSubmesh {
RenderSubmesh {
normals: vec![[0.0, -1.0, 0.0]; 3],
..wow_tri()
}
}
/// **Decision 1071 — the generous pass.** The inflated narrow phase displaces each vertex by
/// its authored normal, raw: 1 model-unit in local space, scaled by the part's world transform
/// (the reference's `skinned_pos + rot·normal`, no extra constant). A ray 1 world-unit beyond
/// the drawn silhouette misses the exact pass and hits the halo; the halo is 2 world-units wide
/// here because the part's scale is 2.
#[test]
fn inflated_pick_hits_the_one_model_unit_halo() {
let mesh = PickMesh(std::sync::Arc::new(wow_tri_with_x_normals()));
// Scale 2: the exact triangle spans x∈[8,12] at y=1; the halo shifts it to x∈[10,14].
let gt =
GlobalTransform::from(Transform::from_xyz(10.0, 1.0, 0.0).with_scale(Vec3::splat(2.0)));
// x=13: 1 world-unit past the exact edge (12), inside the ×2-scaled halo (14).
assert!(ray_pick_mesh(&mesh, &gt, Vec3::new(13.0, 6.0, 0.0), Vec3::NEG_Y, false).is_none());
let hit = ray_pick_mesh(&mesh, &gt, Vec3::new(13.0, 6.0, 0.0), Vec3::NEG_Y, true)
.expect("the halo must catch a ray 1 world-unit past the silhouette");
assert!((hit.distance - 5.0).abs() < 1e-4);
// …and the halo has its own edge: 2 world-units past the ×2 halo is still a miss.
assert!(ray_pick_mesh(&mesh, &gt, Vec3::new(16.0, 6.0, 0.0), Vec3::NEG_Y, true).is_none());
}
/// A submesh authored without normals cannot build the halo: the inflated pass reports a miss
/// (never a panic, never a silently un-displaced hit) — the exact pass already had its say.
#[test]
fn no_authored_normals_means_no_halo() {
let mesh = PickMesh(std::sync::Arc::new(wow_tri()));
let gt = GlobalTransform::from(Transform::from_xyz(10.0, 1.0, 0.0));
assert!(ray_pick_mesh(&mesh, &gt, Vec3::new(10.0, 6.0, 0.0), Vec3::NEG_Y, true).is_none());
}
/// Cast a ray at a hand-built world and report `(entity, distance)` for every hit, nearest
/// first — the whole ray, so a candidate that was silently dropped is distinguishable from one
/// that merely lost. `inflate` runs the generous pass instead of the exact one.
fn cast_in(world: &mut World, origin: Vec3, dir: Vec3, inflate: bool) -> Vec<(Entity, f32)> {
use bevy::ecs::system::RunSystemOnce;
fn cast(
In((origin, dir, inflate)): In<(Vec3, Vec3, bool)>,
ids: Query<Entity, With<ViewVisibility>>,
parts: PickParts,
) -> Vec<(Entity, f32)> {
let all: HashSet<Entity> = ids.iter().collect();
let ray = Ray3d::new(origin, Dir3::new(dir).expect("a non-zero ray"));
cast_pick_ray_impl(ray, &all, &parts, true, inflate)
.into_iter()
.map(|(e, h)| (e, h.distance))
.collect()
}
world
.run_system_once_with(cast, (origin, dir, inflate))
.expect("cast")
}
/// Everything a drawn, bounded, visible part needs to be a pick candidate — minus the geometry,
/// which each test supplies (or deliberately withholds).
fn drawn(world: &mut World, at: Vec3, half: Vec3) -> EntityWorldMut<'_> {
use bevy::camera::visibility::SetViewVisibility;
let mut e = world.spawn((
GlobalTransform::from(Transform::from_translation(at)),
Aabb::from_min_max(-half, half),
ViewVisibility::HIDDEN,
));
e.get_mut::<ViewVisibility>()
.expect("just spawned")
.set_visible();
e
}
/// **Decision 0929.** A drawn, identified, bounded entity with NO pick geometry — a WMO group's
/// MLIQ pool, whose render mesh keeps the whole (mostly dry) vertex grid and so bounds a box
/// ~100 yd tall over a city — must be transparent to the ray. It used to be picked at its box
/// entry, which put an invisible occluder in front of everything: inside Ironforge every hover,
/// on any NPC or GameObject, answered "Wmo · ironforge.wmo".
#[test]
fn a_bounded_mesh_less_entity_is_not_a_pick_occluder() {
let mut world = World::new();
// The pool's box straddles the camera (entry 0.0 — it wins any distance sort it enters).
let pool = drawn(&mut world, Vec3::ZERO, Vec3::new(60.0, 60.0, 60.0)).id();
// The NPC behind it: real geometry at 10 yd (`wow_tri` spans x,z ∈ [-1,1] at y = 0).
let npc = drawn(&mut world, Vec3::new(0.0, -10.0, 0.0), Vec3::splat(1.0))
.insert(PickMesh(std::sync::Arc::new(wow_tri())))
.id();
let hits = cast_in(&mut world, Vec3::ZERO, Vec3::NEG_Y, false);
assert!(
!hits.iter().any(|(e, _)| *e == pool),
"the mesh-less pool must not be pickable at all, yet it hit: {hits:?}",
);
assert_eq!(hits.first().map(|(e, _)| *e), Some(npc));
}
/// The other half of the same law: an entity that DECLARES its box is its shape ([`PickBox`] —
/// the model-less cube fallback) is still picked, at the box entry.
#[test]
fn a_declared_pick_box_is_picked_at_its_box_entry() {
let mut world = World::new();
let cube = drawn(&mut world, Vec3::new(0.0, -10.0, 0.0), Vec3::splat(2.0))
.insert(PickBox)
.id();
let hits = cast_in(&mut world, Vec3::ZERO, Vec3::NEG_Y, false);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].0, cube);
assert!((hits[0].1 - 8.0).abs() < 1e-4, "{hits:?}"); // the box's near face
}
/// **Decision 1071, the broad phase.** The inflated cast must grow the candidate's bound by the
/// same 1 model-unit as the halo, or the slab test rejects the entity before its halo is ever
/// narrow-tested — a mesh whose bound the ray misses by half a unit must still halo-hit. And a
/// [`PickBox`] inflates its declared cuboid the same way (its box IS its geometry).
#[test]
fn inflated_cast_grows_the_broad_bound_with_the_halo() {
let mut world = World::new();
// The mesh part: exact triangle spans x∈[-1,1] (bound half 1); +X normals → halo to x=2.
let herb = drawn(&mut world, Vec3::new(0.0, -10.0, 0.0), Vec3::splat(1.0))
.insert(PickMesh(std::sync::Arc::new(wow_tri_with_x_normals())))
.id();
// x=1.5: outside the exact bound (the uninflated cast drops the candidate entirely)…
assert!(cast_in(&mut world, Vec3::new(1.5, 0.0, 0.0), Vec3::NEG_Y, false).is_empty());
// …inside the halo (and the inflated bound that must admit it).
let hits = cast_in(&mut world, Vec3::new(1.5, 0.0, 0.0), Vec3::NEG_Y, true);
assert_eq!(hits.first().map(|(e, _)| *e), Some(herb));
// The cube fallback: box spans x∈[-2,2] at y∈[-12,-8]; a ray at x=2.5 misses the exact
// cuboid, hits the 1-unit-inflated one — at its grown near face (y=-7 → entry 7).
let mut world = World::new();
let cube = drawn(&mut world, Vec3::new(0.0, -10.0, 0.0), Vec3::splat(2.0))
.insert(PickBox)
.id();
assert!(cast_in(&mut world, Vec3::new(2.5, 0.0, 0.0), Vec3::NEG_Y, false).is_empty());
let hits = cast_in(&mut world, Vec3::new(2.5, 0.0, 0.0), Vec3::NEG_Y, true);
assert_eq!(hits.first().map(|(e, _)| *e), Some(cube));
assert!((hits[0].1 - 7.0).abs() < 1e-4, "{hits:?}");
}
/// A billboard card's render form is centred at its pivot (`build_submesh_mesh`); the pick must
/// subtract the same pivot or it tests the card a whole pivot-offset away from where it draws.
#[test]
fn billboard_card_picks_pivot_centred() {
let mut sub = wow_tri();
// Pivot at WoW (0, 0, 3) → Bevy (0, 3, 0): the baked card shifts down 3 in model-local y.
sub.billboard = Some(benilla_formats::Billboard {
pivot: [0.0, 0.0, 3.0],
bone: 0,
kind: benilla_formats::BillboardKind::Spherical,
scale_anim: None,
seq_translations: Vec::new(),
});
let mesh = PickMesh(std::sync::Arc::new(sub));
// The card entity sits AT the pivot's world spot (the spawn sites bake
// `transform_point(pivot)` into its translation), so the authored triangle — 3 below the
// pivot in model-local Bevy y — draws at world y = 0: a straight-down ray from y = 8 hits
// at distance 8. Without the pivot subtraction it would (wrongly) hit at y = 3.
let gt = GlobalTransform::from(Transform::from_xyz(0.0, 3.0, 0.0));
let hit = ray_pick_mesh(&mesh, &gt, Vec3::new(0.0, 8.0, 0.0), Vec3::NEG_Y, false)
.expect("the pivot-centred card must be hit where it draws");
assert!((hit.distance - 8.0).abs() < 1e-4);
assert!(hit.point.abs_diff_eq(Vec3::ZERO, 1e-4));
}
}