Phase 1: sim/lighting + sim/visibility + mesh utilities

New modules:
  src/sim/lighting.rs   - DAY_PERIOD, SUN_OFFSET constants; sun_direction,
                          day_strength, twilight_amount, LightingFrame::at;
                          is_in_direct_sun(world, p, t) via 3D DDA — the
                          mechanical sunlight predicate that future mob
                          burn / plant growth / shade pathfinding will all
                          consume. Mirrors the shader's sun math exactly
                          (Phase 2 will wire the WGSL side to consume the
                          same constants from this module).
  src/sim/visibility.rs - compute_visible_chunks moved out of state.rs;
                          frustum_planes + chunk_in_frustum decomposed.

Moves into mesh.rs:
  emit_oriented_box, name_hash + their tests. These are mesh utilities
  (Vertex output, color hash for remote-player boxes), not GPU shell.

state.rs:
  - drops the moved functions
  - imports compute_visible_chunks / emit_oriented_box / name_hash from
    their new homes
  - 230 lines lighter

Tests: 51 passing (up from 45). New coverage: sun-below-horizon=>night,
open-sky-at-noon-is-lit, occluded-by-overhead-block, ramp shapes match
the WGSL smoothstep, name_hash determinism.
This commit is contained in:
Maximus Gorog 2026-05-23 23:12:01 -06:00
parent c6f50bcb50
commit 989de4f43d
5 changed files with 447 additions and 204 deletions

View file

@ -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<Vertex>,
indices: &mut Vec<u32>,
) {
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);
}
}

228
src/sim/lighting.rs Normal file
View file

@ -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);
}
}

View file

@ -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;

60
src/sim/visibility.rs Normal file
View file

@ -0,0 +1,60 @@
//! Frustum + radial culling for chunk visibility. Pure function on
//! `(World, Camera, render_dist) -> Vec<ChunkCoord>`.
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<IVec3> {
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
}

View file

@ -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('>', "&gt;")
}
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<Vertex>,
indices: &mut Vec<u32>,
) {
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<IVec3> {
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
);
}
}