polish: scene nits — live-lens yard, square stacks, deck-clear billboard, two-layer lamps, flare reach, phone traffic and exposure

This commit is contained in:
2026-10-12 18:59:33 +03:00
parent 71465fa481
commit 3602eeb3d1
23 changed files with 671 additions and 73 deletions

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import * as THREE from 'three';
import { SceneResourceBag } from '@/scene/resources';
import { createYard, railYaw } from '@/scene/world/yard';
import { STACK_TIERS, STACK_ZS, YARD_STOPS, yardStopsPassed } from '@/journey/yard-progress';
@@ -21,7 +22,7 @@ const nodes = (group: unknown): Node[] => {
const instancedMeshes = (group: unknown) => nodes(group).filter((child) => child.isInstancedMesh === true);
describe('night freight yard', () => {
it('draws the stacks as one InstancedMesh of 4 + 3 + 2 + 1 containers', () => {
it('draws the stacks as one InstancedMesh of 3 + 3 + 2 + 1 containers', () => {
const yard = createYard(new SceneResourceBag(), true);
const stacks = instancedMeshes(yard.group).filter((mesh) => mesh.name === 'yard-stacks');
expect(stacks).toHaveLength(1);
@@ -97,6 +98,51 @@ describe('night freight yard', () => {
expect(yawAt(Number.NaN)).toBeCloseTo(railYaw(), 6);
});
it('aims the rails from the live lens, so the yard holds its screen x while the camera eases', () => {
const yard = createYard(new SceneResourceBag(), true);
const group = yard.group as unknown as InstanceType<typeof THREE.Group>;
const camera = new THREE.PerspectiveCamera(61, 1440 / 900, 0.1, 2000);
const railScreenX = (s: number) => {
group.updateMatrixWorld(true);
return new THREE.Vector3(0, 5, s).applyMatrix4(group.matrixWorld).project(camera).x;
};
// Mid-ease lenses: position, aim, fov and view offset all off their settled values.
const lenses = [
{ x: 6, z: 40, fov: 48, shift: 0.1 },
{ x: 0, z: 30, fov: 55, shift: -0.02 },
{ x: -3.2, z: 24, fov: 61, shift: -0.12 },
];
for (const lens of lenses) {
camera.fov = lens.fov;
camera.setViewOffset(1440, 900, -1440 * lens.shift, 0, 1440, 900);
camera.position.set(lens.x, 30, lens.z);
camera.lookAt(0, 18, -220);
yard.update('additional-work', 0.5, 0, 1 / 60, null, camera.aspect, camera);
for (const s of [-150, -110, -70]) expect(railScreenX(s)).toBeCloseTo(-0.86, 1);
}
});
it('keeps every container square to the road at any rail yaw', () => {
const yard = createYard(new SceneResourceBag(), true);
const stacks = instancedMeshes(yard.group).find((mesh) => mesh.name === 'yard-stacks') as unknown as InstanceType<typeof THREE.InstancedMesh>;
const group = yard.group as unknown as InstanceType<typeof THREE.Group>;
const local = new THREE.Matrix4();
const world = new THREE.Matrix4();
const axis = new THREE.Vector3();
for (const aspect of [1920 / 1080, 1440 / 900, 1024 / 700]) {
yard.update('additional-work', 0.5, 0, 1 / 60, null, aspect);
group.updateMatrixWorld(true);
for (let i = 0; i < stacks.count; i += 1) {
stacks.getMatrixAt(i, local);
world.multiplyMatrices(group.matrixWorld, local);
// The box's long axis (local z) runs down the road: no more than the
// bottom tier's hair of slop (0.75 degrees) off world z.
axis.set(0, 0, 1).transformDirection(world);
expect(Math.abs(Math.atan2(axis.x, axis.z))).toBeLessThan(0.013);
}
}
});
it('frees every geometry and material on dispose', () => {
const resources = new SceneResourceBag();
const yard = createYard(resources, true);