1/**2 * Follow-cam boom occlusion law (camera.ts clampBoom/resolveBoom).3 *4 * The clamp must keep the camera strictly on the near side of any hit5 * surface (clipping is worse than any framing compromise), respect a6 * deliberate user zoom-in, and hold the minimum boom only when there is7 * room for it. Wide views tolerate foreground occlusion while still8 * protecting the lens. The boom smoother snaps in and eases out.9 */10import { assert, assertEquals } from "@std/assert";11import * as THREE from "three";12import {13 cameraObstructionHit,14 clampBoom,15 resolveBoom,16} from "../src/client/camera.ts";1718const origin = new THREE.Vector3();19const direction = new THREE.Vector3(0, 0, 1);20function wall(z: number, depth: number): THREE.Mesh {21 const mesh = new THREE.Mesh(new THREE.BoxGeometry(10, 10, depth));22 mesh.position.z = z;23 mesh.updateMatrixWorld(true);24 return mesh;25}2627Deno.test("camera occlusion: air between separate thin pieces is not solid", () => {28 assertEquals(29 cameraObstructionHit(new THREE.Raycaster(), origin, direction, 10, [30 wall(3, 0.25),31 wall(7, 0.25),32 ]),33 Infinity,34 );35});3637Deno.test("camera occlusion: separate instances do not form one obstruction", () => {38 const mesh = new THREE.InstancedMesh(39 new THREE.BoxGeometry(10, 10, 0.25),40 new THREE.MeshBasicMaterial(),41 2,42 );43 mesh.setMatrixAt(0, new THREE.Matrix4().makeTranslation(0, 0, 3));44 mesh.setMatrixAt(1, new THREE.Matrix4().makeTranslation(0, 0, 7));45 mesh.updateMatrixWorld(true);46 assertEquals(47 cameraObstructionHit(new THREE.Raycaster(), origin, direction, 10, [mesh]),48 Infinity,49 );50});5152Deno.test("camera occlusion: thin foreground does not hide a solid mass", () => {53 assertEquals(54 cameraObstructionHit(new THREE.Raycaster(), origin, direction, 10, [55 wall(3, 0.25),56 wall(7, 3),57 ]),58 5.5,59 );60});6162Deno.test("camera occlusion: lens inside even a thin wall still pulls clear", () => {63 assertEquals(64 cameraObstructionHit(new THREE.Raycaster(), origin, direction, 3, [65 wall(3, 0.25),66 ]),67 2.875,68 );69});7071Deno.test("wide camera: passing foreground mass keeps the chosen framing", () => {72 const mesh = wall(5, 3);73 assertEquals(74 cameraObstructionHit(new THREE.Raycaster(), origin, direction, 10, [mesh]),75 3.5,76 );77 assertEquals(78 cameraObstructionHit(79 new THREE.Raycaster(),80 origin,81 direction,82 10,83 [mesh],84 true,85 ),86 Infinity,87 );88});8990Deno.test("wide camera: containing mass still protects the lens", () => {91 assertEquals(92 cameraObstructionHit(93 new THREE.Raycaster(),94 origin,95 direction,96 8,97 [98 wall(3, 0.25),99 wall(7, 4),100 ],101 true,102 ),103 5,104 );105});106107Deno.test("wide camera: containing instance is distinct from foreground", () => {108 const mesh = new THREE.InstancedMesh(109 new THREE.BoxGeometry(10, 10, 2),110 new THREE.MeshBasicMaterial(),111 2,112 );113 mesh.setMatrixAt(0, new THREE.Matrix4().makeTranslation(0, 0, 3));114 mesh.setMatrixAt(1, new THREE.Matrix4().makeTranslation(0, 0, 7));115 mesh.updateMatrixWorld(true);116 assertEquals(117 cameraObstructionHit(118 new THREE.Raycaster(),119 origin,120 direction,121 7,122 [mesh],123 true,124 ),125 6,126 );127});128129Deno.test("wide camera: open foreground shell does not contain a distant lens", () => {130 const mesh = new THREE.Mesh(new THREE.PlaneGeometry(10, 10));131 mesh.rotation.y = Math.PI;132 mesh.position.z = 3;133 mesh.updateMatrixWorld(true);134 assertEquals(135 cameraObstructionHit(new THREE.Raycaster(), origin, direction, 10, [mesh]),136 3,137 );138 assertEquals(139 cameraObstructionHit(140 new THREE.Raycaster(),141 origin,142 direction,143 10,144 [mesh],145 true,146 ),147 Infinity,148 );149});150151Deno.test("clampBoom: clear ray keeps the desired boom exactly", () => {152 assertEquals(clampBoom(7, Infinity), 7);153 // A user zoom-in below the minimum boom is respected when nothing154 // occludes — the floor must not push the camera back out.155 assertEquals(clampBoom(0.6, Infinity), 0.6);156});157158Deno.test("clampBoom: hit pulls the boom in front of the surface", () => {159 // Plenty of room: settles CLIP_MARGIN (0.35) short of the hit.160 const d = clampBoom(7, 3);161 assert(Math.abs(d - 2.65) < 1e-9, `expected 2.65, got ${d}`);162 // Hit closer than MIN_BOOM + margin: the floor holds MIN_BOOM as long163 // as NEAR_CLEARANCE to the wall survives.164 assertEquals(clampBoom(7, 1.0), 0.8);165});166167Deno.test("clampBoom: wall just BEHIND the desired spot still pulls in", () => {168 // The near plane extends past the camera origin — a surface within the169 // margin beyond the desired position must shorten the boom too.170 const d = clampBoom(7, 7.1);171 assert(d < 7, `boom must shrink, got ${d}`);172 assert(d <= 7.1 - 0.2 + 1e-9, `near clearance violated: ${d}`);173});174175Deno.test("clampBoom: never inverts past the hit, never negative", () => {176 for (let i = 0; i < 400; i++) {177 // Deterministic sweep over awkward combinations, including hits178 // closer than the marble's own look offset.179 const hit = 0.01 + (i % 20) * 0.5;180 const desired = 0.05 + Math.floor(i / 20) * 0.6;181 const d = clampBoom(desired, hit);182 assert(d > 0, `boom must stay positive (hit=${hit} desired=${desired})`);183 assert(184 d < hit,185 `camera crossed the wall: boom=${d} hit=${hit} desired=${desired}`,186 );187 assert(188 d <= desired + 1e-9,189 `boom overshot desired: boom=${d} desired=${desired}`,190 );191 }192});193194Deno.test("resolveBoom: uninitialized state adopts the clamp directly", () => {195 assertEquals(resolveBoom(7, Infinity, -1, 1 / 60), 7);196 assertEquals(resolveBoom(7, 3, -1, 1 / 60), clampBoom(7, 3));197});198199Deno.test("resolveBoom: snaps in, eases out", () => {200 // Snap in: an intruding mass takes effect in ONE frame.201 assertEquals(resolveBoom(7, 3, 7, 1 / 60), clampBoom(7, 3));202 // Ease out: once clear, the boom releases gradually toward desired...203 let boom = clampBoom(7, 3);204 const first = resolveBoom(7, Infinity, boom, 1 / 60);205 assert(first > boom && first < 7, `ease-out jumped: ${first}`);206 // ...monotonically, converging within a few seconds.207 for (let t = 0; t < 5 * 60; t++) {208 const next = resolveBoom(7, Infinity, boom, 1 / 60);209 assert(next >= boom - 1e-9, "release must be monotone");210 boom = next;211 }212 assert(Math.abs(boom - 7) < 1e-3, `did not converge: ${boom}`);213});214215Deno.test("resolveBoom: release never overshoots the safe clamp", () => {216 // While easing out, a hit reappearing mid-release still bounds the217 // boom by the clamp for THAT hit — the smoothed value may never sit218 // beyond what clampBoom allows this frame.219 let boom = 0.5;220 for (let t = 0; t < 120; t++) {221 const hit = 2 + Math.sin(t * 0.3); // wobbling occluder, 1..3222 boom = resolveBoom(7, hit, boom, 1 / 60);223 assert(224 boom <= clampBoom(7, hit) + 1e-9,225 `boom ${boom} exceeds clamp ${clampBoom(7, hit)} at t=${t}`,226 );227 assert(boom < hit, `crossed the wall mid-release at t=${t}`);228 }229});