- 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>
547 lines
26 KiB
Rust
547 lines
26 KiB
Rust
//! 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, >, 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, >, 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, >, Vec3::new(13.0, 6.0, 0.0), Vec3::NEG_Y, false).is_none());
|
||
let hit = ray_pick_mesh(&mesh, >, 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, >, 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, >, 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, >, 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));
|
||
}
|
||
}
|