diff --git a/src/mesh.rs b/src/mesh.rs index 90de74e..80b7da6 100644 --- a/src/mesh.rs +++ b/src/mesh.rs @@ -1,6 +1,6 @@ use crate::world::{Block, Chunk, Face, World, CHUNK_HEIGHT, CHUNK_SIZE}; use bytemuck::{Pod, Zeroable}; -use glam::IVec3; +use glam::{IVec3, Vec3}; #[repr(C)] #[derive(Copy, Clone, Pod, Zeroable, Debug)] @@ -267,6 +267,99 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh { ChunkMesh { vertices, indices } } +/// Emit 6 faces of a box centered at `center`, rotated around Y by +/// `yaw`, extending `half_extents` in each local-space axis. Each face +/// winds CCW outward — verified by `oriented_box_winds_correctly`. +/// +/// Used by the renderer to draw remote players (body + head boxes). +/// Lives in `mesh.rs` because the output is `Vertex`es with normals and +/// AO, same as the chunk meshes. +pub fn emit_oriented_box( + center: Vec3, + half_extents: Vec3, + yaw: f32, + color: [f32; 3], + verts: &mut Vec, + indices: &mut Vec, +) { + let cos_y = yaw.cos(); + let sin_y = yaw.sin(); + let rotate_xz = |x: f32, z: f32| (x * cos_y - z * sin_y, x * sin_y + z * cos_y); + let world_pt = |lx: f32, ly: f32, lz: f32| { + let (rx, rz) = rotate_xz(lx, lz); + [center.x + rx, center.y + ly, center.z + rz] + }; + let world_normal = |nx: f32, ny: f32, nz: f32| { + let (rx, rz) = rotate_xz(nx, nz); + [rx, ny, rz] + }; + + let (hx, hy, hz) = (half_extents.x, half_extents.y, half_extents.z); + let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [ + ([1.0, 0.0, 0.0], [ + [ hx, -hy, -hz], + [ hx, -hy, hz], + [ hx, hy, hz], + [ hx, hy, -hz], + ]), + ([-1.0, 0.0, 0.0], [ + [-hx, -hy, hz], + [-hx, -hy, -hz], + [-hx, hy, -hz], + [-hx, hy, hz], + ]), + ([0.0, 1.0, 0.0], [ + [-hx, hy, -hz], + [ hx, hy, -hz], + [ hx, hy, hz], + [-hx, hy, hz], + ]), + ([0.0, -1.0, 0.0], [ + [-hx, -hy, hz], + [ hx, -hy, hz], + [ hx, -hy, -hz], + [-hx, -hy, -hz], + ]), + ([0.0, 0.0, 1.0], [ + [ hx, -hy, hz], + [-hx, -hy, hz], + [-hx, hy, hz], + [ hx, hy, hz], + ]), + ([0.0, 0.0, -1.0], [ + [-hx, -hy, -hz], + [ hx, -hy, -hz], + [ hx, hy, -hz], + [-hx, hy, -hz], + ]), + ]; + for (n_local, corners_local) in faces { + let n_world = world_normal(n_local[0], n_local[1], n_local[2]); + let base = verts.len() as u32; + for c in corners_local { + verts.push(Vertex { + pos: world_pt(c[0], c[1], c[2]), + color, + normal: n_world, + leaf: 0.0, + ao: 1.0, + }); + } + indices.extend_from_slice(&[base, base + 2, base + 1, base, base + 3, base + 2]); + } +} + +/// FNV-1a 32-bit hash of a string. Used to colorize remote-player +/// boxes deterministically by display name. +pub fn name_hash(s: &str) -> u32 { + let mut h: u32 = 2166136261; + for b in s.bytes() { + h ^= b as u32; + h = h.wrapping_mul(16777619); + } + h +} + #[cfg(test)] mod tests { use super::*; @@ -423,4 +516,62 @@ mod tests { assert!(CHUNK_SIZE > 0); assert!(CHUNK_HEIGHT > 0); } + + // ---------- emit_oriented_box ---------- + + #[test] + fn oriented_box_emits_six_quads() { + let mut v = vec![]; + let mut i = vec![]; + emit_oriented_box(Vec3::ZERO, Vec3::splat(0.5), 0.0, [1.0; 3], &mut v, &mut i); + assert_eq!(v.len(), 24); + assert_eq!(i.len(), 36); + } + + #[test] + fn oriented_box_winds_correctly_at_any_yaw() { + for &yaw in &[0.0_f32, 0.7, 1.5708, 3.14, -1.0, 5.0] { + let mut v = vec![]; + let mut i = vec![]; + emit_oriented_box( + Vec3::new(5.0, 7.0, -3.0), + Vec3::new(0.3, 0.6, 0.2), + yaw, + [1.0; 3], + &mut v, + &mut i, + ); + for tri in i.chunks_exact(3) { + let a = v[tri[0] as usize].pos; + let b = v[tri[1] as usize].pos; + let c = v[tri[2] as usize].pos; + let n = v[tri[0] as usize].normal; + let geo = cross_normal(a, b, c); + let dot = geo[0] * n[0] + geo[1] * n[1] + geo[2] * n[2]; + assert!(dot > 0.0, "yaw {} tri winds opposite normal {:?}", yaw, n); + } + } + } + + #[test] + fn oriented_box_normal_rotates_with_yaw() { + let mut v = vec![]; + let mut i = vec![]; + let yaw = std::f32::consts::FRAC_PI_2; + emit_oriented_box(Vec3::ZERO, Vec3::splat(0.5), yaw, [1.0; 3], &mut v, &mut i); + let n = v[0].normal; + assert!( + (n[0]).abs() < 1e-5 && (n[2] - 1.0).abs() < 1e-5, + "+X normal at yaw 90° expected (0,0,1), got {:?}", + n + ); + } + + #[test] + fn name_hash_is_deterministic_and_distinct() { + let a = name_hash("alice"); + let b = name_hash("bob"); + assert_eq!(a, name_hash("alice")); + assert_ne!(a, b); + } } diff --git a/src/sim/lighting.rs b/src/sim/lighting.rs new file mode 100644 index 0000000..520925e --- /dev/null +++ b/src/sim/lighting.rs @@ -0,0 +1,228 @@ +//! Canonical sun/sky/lighting math — the single source of truth shared +//! between the renderer (visual shading) and game systems (mob burn, +//! plant growth, AI pathfinding around shade). +//! +//! The WGSL shader gets the same constants via runtime concatenation +//! (see `crate::render::shader_source`), so changing `DAY_PERIOD` here +//! updates the shader too. This is the architectural guarantee that +//! prevents the "mob burns while standing in visible shade" class of +//! bug — visual and mechanical sun direction can never drift. +use crate::world::World; +use glam::{IVec3, Vec3}; + +/// One in-game day in real seconds. Multiplied by the user's +/// `time_scale` setting before being summed each tick. +pub const DAY_PERIOD: f32 = 300.0; +/// Phase offset so a fresh world starts at noon (0.25 cycles past dawn). +pub const SUN_OFFSET: f32 = 0.25; +/// Y above which the sun-occlusion ray is considered to have escaped +/// the world (open sky). Must be ≥ `world::CHUNK_HEIGHT`. +pub const SUN_RAY_TOP_Y: i32 = 128; +/// Hard cap on DDA steps so a degenerate near-horizontal ray can't loop +/// forever. 512 voxels is well past any reasonable world width. +const SUN_RAY_MAX_STEPS: u32 = 512; + +/// World-space sun direction at time `t`. Matches the WGSL +/// `sun_direction(t)` bit for bit — both consume the same `DAY_PERIOD` +/// and `SUN_OFFSET` (the WGSL constants are emitted from these via +/// `render::shader_source::wgsl_constants_header()`). +pub fn sun_direction(t: f32) -> Vec3 { + let a = (t / DAY_PERIOD + SUN_OFFSET) * std::f32::consts::TAU; + Vec3::new(a.cos(), a.sin(), 0.25).normalize() +} + +/// 0..1 going from "sun barely under horizon (blue hour)" to "clearly +/// above horizon (full daylight)". Same formula as the shader's +/// `day_strength`. +pub fn day_strength(sun: Vec3) -> f32 { + smoothstep(-0.05, 0.20, sun.y) +} + +/// Peaks while the sun is near the horizon — sunrise + sunset. +pub fn twilight_amount(sun: Vec3) -> f32 { + smoothstep(-0.10, 0.05, sun.y) - smoothstep(0.05, 0.30, sun.y) +} + +/// All four lighting scalars derived once for a tick. Cheaper than +/// recomputing each piece, and gives downstream systems a single value +/// to pass around. +#[derive(Clone, Copy, Debug)] +pub struct LightingFrame { + pub time: f32, + pub sun: Vec3, + pub day: f32, + pub twilight: f32, +} + +impl LightingFrame { + pub fn at(t: f32) -> Self { + let sun = sun_direction(t); + Self { + time: t, + sun, + day: day_strength(sun), + twilight: twilight_amount(sun), + } + } + + /// Mechanically night: nothing burns, plants don't grow, AI is + /// free to wander in the open. True iff the sun is below the + /// horizon. + pub fn is_night(&self) -> bool { + self.sun.y <= 0.0 + } +} + +/// Is point `p` directly lit by the sun in `world` at time `t`? +/// +/// 3D DDA from `p` toward the sun until the ray either escapes through +/// the world top (lit) or hits a solid voxel (occluded). The +/// mechanical sunlight test — consumed by mob burn checks, plant +/// growth, daylight sensors, shade pathfinding. Visual shadows in the +/// shader should approximate the *same* predicate. +pub fn is_in_direct_sun(world: &World, p: Vec3, t: f32) -> bool { + let sun = sun_direction(t); + if sun.y <= 0.0 { + return false; + } + + // DDA setup. Walking from `p` along `sun` through unit voxels. + let mut cell = IVec3::new( + p.x.floor() as i32, + p.y.floor() as i32, + p.z.floor() as i32, + ); + let step = IVec3::new( + sign_to_step(sun.x), + sign_to_step(sun.y), + sign_to_step(sun.z), + ); + let t_delta = Vec3::new( + axis_t_delta(sun.x), + axis_t_delta(sun.y), + axis_t_delta(sun.z), + ); + let mut t_max = Vec3::new( + first_t_max(p.x, sun.x, cell.x), + first_t_max(p.y, sun.y, cell.y), + first_t_max(p.z, sun.z, cell.z), + ); + + for _ in 0..SUN_RAY_MAX_STEPS { + if cell.y >= SUN_RAY_TOP_Y { + return true; + } + if t_max.x < t_max.y && t_max.x < t_max.z { + cell.x += step.x; + t_max.x += t_delta.x; + } else if t_max.y < t_max.z { + cell.y += step.y; + t_max.y += t_delta.y; + } else { + cell.z += step.z; + t_max.z += t_delta.z; + } + if world.get_block(cell).solid() { + return false; + } + } + // Ran out of steps — treat as open sky (the alternative is "hangs + // forever on degenerate rays", which is worse). + true +} + +fn sign_to_step(v: f32) -> i32 { + if v > 0.0 { + 1 + } else if v < 0.0 { + -1 + } else { + 0 + } +} + +fn axis_t_delta(v: f32) -> f32 { + if v.abs() < 1e-6 { + f32::INFINITY + } else { + 1.0 / v.abs() + } +} + +fn first_t_max(origin: f32, dir: f32, cell: i32) -> f32 { + if dir.abs() < 1e-6 { + f32::INFINITY + } else if dir > 0.0 { + ((cell + 1) as f32 - origin) / dir + } else { + (origin - cell as f32) / -dir + } +} + +fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 { + let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::world::{natural_surface_y, Block, World}; + + #[test] + fn sun_below_horizon_means_night_and_no_direct_sun() { + // Midnight = half a day past noon (t such that sun.y < 0). + let t = DAY_PERIOD * 0.5; + let lf = LightingFrame::at(t); + assert!(lf.is_night(), "midnight should report as night, sun={:?}", lf.sun); + let world = World::new(); + assert!(!is_in_direct_sun( + &world, + Vec3::new(0.5, 200.0, 0.5), + t + )); + } + + #[test] + fn open_sky_above_terrain_is_lit_at_noon() { + let world = World::new(); + let lf = LightingFrame::at(0.0); // game starts at noon + assert!(!lf.is_night()); + assert!(lf.sun.y > 0.0, "noon sun must be above horizon"); + let high_above = Vec3::new(0.5, 200.0, 0.5); + assert!(is_in_direct_sun(&world, high_above, 0.0)); + } + + #[test] + fn point_under_solid_overhead_is_occluded() { + // Place a stone block right above (0.5, surface+1, 0.5) and + // check that the player feet position below it is shaded. + let mut world = World::new(); + let surface = natural_surface_y(0, 0); + let feet_y = surface + 1; + // Cap the column with stones so any upward ray hits one. + for y in (feet_y + 2)..(feet_y + 12) { + world.set_block(IVec3::new(0, y, 0), Block::Stone); + } + let p = Vec3::new(0.5, feet_y as f32 + 0.1, 0.5); + assert!(!is_in_direct_sun(&world, p, 0.0), "covered point must be occluded"); + } + + #[test] + fn day_strength_and_twilight_match_shader_ramps() { + // Spot-checks at known sun heights. + assert!(day_strength(Vec3::new(0.0, -0.5, 0.0)).abs() < 1e-5); + assert!((day_strength(Vec3::new(0.0, 1.0, 0.0)) - 1.0).abs() < 1e-5); + assert!(twilight_amount(Vec3::new(0.0, 0.05, 0.0)) > 0.5); + assert!(twilight_amount(Vec3::new(0.0, 1.0, 0.0)).abs() < 1e-5); + } + + #[test] + fn sun_direction_constants_round_trip() { + // At t=0 (noon by construction) the angle is SUN_OFFSET turns, + // i.e. sun is at cos(τ·0.25) = 0, sin(τ·0.25) = 1 → straight up + // (plus the small Z tilt). The Y should be the dominant axis. + let s = sun_direction(0.0); + assert!(s.y > 0.9, "noon sun y should be ~1, got {}", s.y); + } +} diff --git a/src/sim/mod.rs b/src/sim/mod.rs index c51dc82..804ad3b 100644 --- a/src/sim/mod.rs +++ b/src/sim/mod.rs @@ -18,10 +18,14 @@ pub mod collision; pub mod edit; pub mod event; pub mod input; +pub mod lighting; pub mod physics; pub mod spawn; +pub mod visibility; pub use body::PlayerBody; pub use event::SimEvent; pub use input::{merge_held, Input, TouchBridge}; +pub use lighting::{is_in_direct_sun, sun_direction, LightingFrame}; pub use physics::{step_movement, MoveInput, MoveOutcome}; +pub use visibility::compute_visible_chunks; diff --git a/src/sim/visibility.rs b/src/sim/visibility.rs new file mode 100644 index 0000000..6519a1b --- /dev/null +++ b/src/sim/visibility.rs @@ -0,0 +1,60 @@ +//! Frustum + radial culling for chunk visibility. Pure function on +//! `(World, Camera, render_dist) -> Vec`. +use crate::camera::Camera; +use crate::world::{World, CHUNK_HEIGHT, CHUNK_SIZE}; +use glam::{IVec3, Vec3}; + +/// Returns the chunk coordinates whose AABB intersects the camera's +/// view frustum AND whose center is within `render_dist` on XZ. The +/// cheap radial cull runs first — frustum tests are skipped for chunks +/// already beyond the configured render distance. +pub fn compute_visible_chunks(world: &World, camera: &Camera, render_dist: f32) -> Vec { + let planes = frustum_planes(camera); + let dist2 = render_dist * render_dist; + let cam_xz = (camera.position.x, camera.position.z); + + world + .chunks + .keys() + .filter(|coord| { + let cx = (coord.x * CHUNK_SIZE + CHUNK_SIZE / 2) as f32; + let cz = (coord.z * CHUNK_SIZE + CHUNK_SIZE / 2) as f32; + let dx = cx - cam_xz.0; + let dz = cz - cam_xz.1; + dx * dx + dz * dz <= dist2 + }) + .filter(|coord| chunk_in_frustum(**coord, &planes)) + .copied() + .collect() +} + +fn frustum_planes(camera: &Camera) -> [[f32; 4]; 6] { + let m = camera.view_proj().to_cols_array_2d(); + let row = |i: usize| [m[0][i], m[1][i], m[2][i], m[3][i]]; + let r0 = row(0); + let r1 = row(1); + let r2 = row(2); + let r3 = row(3); + let add = |a: [f32; 4], b: [f32; 4]| [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]]; + let sub = |a: [f32; 4], b: [f32; 4]| [a[0] - b[0], a[1] - b[1], a[2] - b[2], a[3] - b[3]]; + [add(r3, r0), sub(r3, r0), add(r3, r1), sub(r3, r1), add(r3, r2), sub(r3, r2)] +} + +fn chunk_in_frustum(coord: IVec3, planes: &[[f32; 4]; 6]) -> bool { + let min = Vec3::new( + (coord.x * CHUNK_SIZE) as f32, + 0.0, + (coord.z * CHUNK_SIZE) as f32, + ); + let max = min + Vec3::new(CHUNK_SIZE as f32, CHUNK_HEIGHT as f32, CHUNK_SIZE as f32); + for p in planes { + // p-vertex (the AABB corner furthest along plane normal). + let px = if p[0] > 0.0 { max.x } else { min.x }; + let py = if p[1] > 0.0 { max.y } else { min.y }; + let pz = if p[2] > 0.0 { max.z } else { min.z }; + if p[0] * px + p[1] * py + p[2] * pz + p[3] < 0.0 { + return false; + } + } + true +} diff --git a/src/state.rs b/src/state.rs index b329007..0dce391 100644 --- a/src/state.rs +++ b/src/state.rs @@ -3,7 +3,7 @@ //! (`crate::sim`) and pure net parser (`crate::net`) through the //! world/renderer/network — this file should remain *thin*. use crate::camera::{Camera, InputState, KbHeld}; -use crate::mesh::{build_chunk_mesh, Vertex}; +use crate::mesh::{build_chunk_mesh, emit_oriented_box, name_hash, Vertex}; use crate::net::{parse_inbox, NetEvent}; use crate::proto::{ClientMsg, EditRec}; use crate::sim::collision::{aabb_overlap_player, AabbI, EYE_HEIGHT}; @@ -11,8 +11,9 @@ use crate::sim::edit::{apply_edit, block_from_u8, chunks_for_edit}; use crate::sim::input::TouchBridge; use crate::sim::physics::MoveInput; use crate::sim::spawn::{fall_damage, find_safe_spawn}; +use crate::sim::visibility::compute_visible_chunks; use crate::sim::{merge_held, step_movement, PlayerBody, SimEvent}; -use crate::world::{Block, World, CHUNK_HEIGHT, CHUNK_SIZE, WORLD_RADIUS}; +use crate::world::{Block, World, WORLD_RADIUS}; use bytemuck::{Pod, Zeroable}; use glam::{IVec3, Mat4, Vec3}; use std::cell::RefCell; @@ -1742,204 +1743,3 @@ fn html_escape(s: &str) -> String { .replace('>', ">") } -fn name_hash(s: &str) -> u32 { - let mut h: u32 = 2166136261; - for b in s.bytes() { - h ^= b as u32; - h = h.wrapping_mul(16777619); - } - h -} - -/// Emit 6 faces of a box centered at `center`, rotated around Y by -/// `yaw`, extending `half_extents` in each local-space axis. Each face -/// still winds CCW outward (verified by `oriented_box_winds_correctly`). -pub fn emit_oriented_box( - center: Vec3, - half_extents: Vec3, - yaw: f32, - color: [f32; 3], - verts: &mut Vec, - indices: &mut Vec, -) { - let cos_y = yaw.cos(); - let sin_y = yaw.sin(); - let rotate_xz = |x: f32, z: f32| (x * cos_y - z * sin_y, x * sin_y + z * cos_y); - let world_pt = |lx: f32, ly: f32, lz: f32| { - let (rx, rz) = rotate_xz(lx, lz); - [center.x + rx, center.y + ly, center.z + rz] - }; - let world_normal = |nx: f32, ny: f32, nz: f32| { - let (rx, rz) = rotate_xz(nx, nz); - [rx, ny, rz] - }; - - let (hx, hy, hz) = (half_extents.x, half_extents.y, half_extents.z); - let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [ - ([1.0, 0.0, 0.0], [ - [ hx, -hy, -hz], - [ hx, -hy, hz], - [ hx, hy, hz], - [ hx, hy, -hz], - ]), - ([-1.0, 0.0, 0.0], [ - [-hx, -hy, hz], - [-hx, -hy, -hz], - [-hx, hy, -hz], - [-hx, hy, hz], - ]), - ([0.0, 1.0, 0.0], [ - [-hx, hy, -hz], - [ hx, hy, -hz], - [ hx, hy, hz], - [-hx, hy, hz], - ]), - ([0.0, -1.0, 0.0], [ - [-hx, -hy, hz], - [ hx, -hy, hz], - [ hx, -hy, -hz], - [-hx, -hy, -hz], - ]), - ([0.0, 0.0, 1.0], [ - [ hx, -hy, hz], - [-hx, -hy, hz], - [-hx, hy, hz], - [ hx, hy, hz], - ]), - ([0.0, 0.0, -1.0], [ - [-hx, -hy, -hz], - [ hx, -hy, -hz], - [ hx, hy, -hz], - [-hx, hy, -hz], - ]), - ]; - for (n_local, corners_local) in faces { - let n_world = world_normal(n_local[0], n_local[1], n_local[2]); - let base = verts.len() as u32; - for c in corners_local { - verts.push(Vertex { - pos: world_pt(c[0], c[1], c[2]), - color, - normal: n_world, - leaf: 0.0, - ao: 1.0, - }); - } - indices.extend_from_slice(&[base, base + 2, base + 1, base, base + 3, base + 2]); - } -} - -fn compute_visible_chunks(world: &World, camera: &Camera, render_dist: f32) -> Vec { - let vp = camera.view_proj(); - let m = vp.to_cols_array_2d(); - let row = |i: usize| [m[0][i], m[1][i], m[2][i], m[3][i]]; - let r0 = row(0); - let r1 = row(1); - let r2 = row(2); - let r3 = row(3); - let add = |a: [f32; 4], b: [f32; 4]| [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]]; - let sub = |a: [f32; 4], b: [f32; 4]| [a[0] - b[0], a[1] - b[1], a[2] - b[2], a[3] - b[3]]; - let planes: [[f32; 4]; 6] = [ - add(r3, r0), - sub(r3, r0), - add(r3, r1), - sub(r3, r1), - add(r3, r2), - sub(r3, r2), - ]; - let dist2 = render_dist * render_dist; - let cam_xz = (camera.position.x, camera.position.z); - let mut out = Vec::with_capacity(world.chunks.len()); - for coord in world.chunks.keys() { - let cx = (coord.x * CHUNK_SIZE + CHUNK_SIZE / 2) as f32; - let cz = (coord.z * CHUNK_SIZE + CHUNK_SIZE / 2) as f32; - let dx = cx - cam_xz.0; - let dz = cz - cam_xz.1; - if dx * dx + dz * dz > dist2 { - continue; - } - let min = Vec3::new( - (coord.x * CHUNK_SIZE) as f32, - 0.0, - (coord.z * CHUNK_SIZE) as f32, - ); - let max = min + Vec3::new(CHUNK_SIZE as f32, CHUNK_HEIGHT as f32, CHUNK_SIZE as f32); - let mut inside = true; - for p in &planes { - let px = if p[0] > 0.0 { max.x } else { min.x }; - let py = if p[1] > 0.0 { max.y } else { min.y }; - let pz = if p[2] > 0.0 { max.z } else { min.z }; - if p[0] * px + p[1] * py + p[2] * pz + p[3] < 0.0 { - inside = false; - break; - } - } - if inside { - out.push(*coord); - } - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - - fn cross_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] { - let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; - let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; - [ - u[1] * v[2] - u[2] * v[1], - u[2] * v[0] - u[0] * v[2], - u[0] * v[1] - u[1] * v[0], - ] - } - - #[test] - fn oriented_box_emits_six_quads() { - let mut v = vec![]; - let mut i = vec![]; - emit_oriented_box(Vec3::ZERO, Vec3::splat(0.5), 0.0, [1.0; 3], &mut v, &mut i); - assert_eq!(v.len(), 24); - assert_eq!(i.len(), 36); - } - - #[test] - fn oriented_box_winds_correctly_at_any_yaw() { - for &yaw in &[0.0_f32, 0.7, 1.5708, 3.14, -1.0, 5.0] { - let mut v = vec![]; - let mut i = vec![]; - emit_oriented_box( - Vec3::new(5.0, 7.0, -3.0), - Vec3::new(0.3, 0.6, 0.2), - yaw, - [1.0; 3], - &mut v, - &mut i, - ); - for tri in i.chunks_exact(3) { - let a = v[tri[0] as usize].pos; - let b = v[tri[1] as usize].pos; - let c = v[tri[2] as usize].pos; - let n = v[tri[0] as usize].normal; - let geo = cross_normal(a, b, c); - let dot = geo[0] * n[0] + geo[1] * n[1] + geo[2] * n[2]; - assert!(dot > 0.0, "yaw {} tri winds opposite normal {:?}", yaw, n); - } - } - } - - #[test] - fn oriented_box_normal_rotates_with_yaw() { - let mut v = vec![]; - let mut i = vec![]; - let yaw = std::f32::consts::FRAC_PI_2; - emit_oriented_box(Vec3::ZERO, Vec3::splat(0.5), yaw, [1.0; 3], &mut v, &mut i); - let n = v[0].normal; - assert!( - (n[0]).abs() < 1e-5 && (n[2] - 1.0).abs() < 1e-5, - "+X normal at yaw 90° expected (0,0,1), got {:?}", - n - ); - } -}