The top bar becomes a steel gantry. Its centre window is a mask cut-out the 3D exit sign docks into; between signs the window shows the dash read-out. The drive controls move into the right bay, and the reading viewport is clipped at the beam, so the empty band under the bar is gone.
218 lines
9.7 KiB
TypeScript
218 lines
9.7 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest';
|
|
import type { SignSnapshot } from '@/journey/types';
|
|
import { SceneResourceBag } from '@/scene/resources';
|
|
import { createChaseCamera } from '@/scene/world/environment';
|
|
import { createSign, routeLabelFor, type SignDock, type SignPresentation, type SignRenderOptions, type SignTilt } from '@/scene/world/sign';
|
|
|
|
interface SignMesh {
|
|
position: { z: number };
|
|
userData: { textureUpdateCount: number };
|
|
geometry: {
|
|
computeBoundingBox(): void;
|
|
boundingBox: { min: { x: number; y: number }; max: { x: number; y: number; z: number } } | null;
|
|
getAttribute(name: string): { array: ArrayLike<number> };
|
|
};
|
|
material: {
|
|
opacity: number;
|
|
depthWrite: boolean;
|
|
toneMapped: boolean;
|
|
map: { image: { width: number; height: number }; anisotropy: number; premultiplyAlpha: boolean };
|
|
};
|
|
}
|
|
|
|
const owned: SceneResourceBag[] = [];
|
|
afterEach(() => { owned.splice(0).forEach(resources => resources.dispose()); });
|
|
const profile: SignSnapshot = { targetId: 'profile', phase: 'dwell', progress: 1, opacity: 1 };
|
|
|
|
// Follow the live sequence: update journey state, then compose with the camera,
|
|
// viewport and the gantry window. update() alone does not position a sign.
|
|
function windowFor(width: number): SignDock {
|
|
const w = width >= 1280 ? 256 : width >= 768 ? 224 : 160;
|
|
const h = w / 4;
|
|
return { left: (width - w) / 2, top: 4, width: w, height: h };
|
|
}
|
|
|
|
function setup(width = 1440, height = 900, options: SignRenderOptions = {}) {
|
|
const resources = new SceneResourceBag();
|
|
owned.push(resources);
|
|
const sign = createSign(resources, options);
|
|
const camera = createChaseCamera(width / height);
|
|
let now = 0;
|
|
let dock = windowFor(width);
|
|
let tilt: SignTilt = { x: 0, y: 0 };
|
|
sign.setViewportSize(width, height);
|
|
return {
|
|
resources, sign, camera,
|
|
get dock() { return dock; },
|
|
render(snapshot: SignSnapshot | null, frames = 1) {
|
|
for (let frame = 0; frame < frames; frame++) {
|
|
now += 1000 / 60;
|
|
sign.update(snapshot, now);
|
|
sign.compose(camera, width, height, dock, now, tilt);
|
|
}
|
|
return sign.inspect(camera, width, height);
|
|
},
|
|
resize(nextWidth: number, nextHeight: number) {
|
|
width = nextWidth;
|
|
height = nextHeight;
|
|
camera.aspect = width / height;
|
|
camera.updateProjectionMatrix();
|
|
sign.setViewportSize(width, height);
|
|
dock = windowFor(width);
|
|
},
|
|
setDock(next: SignDock) { dock = next; },
|
|
setTilt(next: SignTilt) { tilt = next; },
|
|
};
|
|
}
|
|
|
|
function expectDocked(bounds: SignPresentation['bounds'], dock: SignDock, tolerance = 2) {
|
|
expect(Math.abs(bounds.left - dock.left)).toBeLessThan(tolerance);
|
|
expect(Math.abs(bounds.right - (dock.left + dock.width))).toBeLessThan(tolerance);
|
|
expect(Math.abs(bounds.top - dock.top)).toBeLessThan(tolerance);
|
|
expect(Math.abs(bounds.bottom - (dock.top + dock.height))).toBeLessThan(tolerance);
|
|
}
|
|
|
|
describe('composed route signs', () => {
|
|
it('keeps the readable face in front of its housing with capped, undistorted sampling', () => {
|
|
const { sign } = setup();
|
|
const face = sign.group.getObjectByName('sign-face') as SignMesh;
|
|
const backplate = sign.group.getObjectByName('sign-backplate') as SignMesh;
|
|
backplate.geometry.computeBoundingBox();
|
|
expect(face.position.z - (backplate.position.z + backplate.geometry.boundingBox!.max.z)).toBeGreaterThan(0.05);
|
|
face.geometry.computeBoundingBox();
|
|
const { min, max } = face.geometry.boundingBox!;
|
|
expect((max.x - min.x) / (max.y - min.y)).toBeCloseTo(4, 6);
|
|
const uvs = Array.from(face.geometry.getAttribute('uv').array);
|
|
expect(Math.min(...uvs)).toBeGreaterThanOrEqual(0);
|
|
expect(Math.max(...uvs)).toBeLessThanOrEqual(1);
|
|
expect(face.material.map!.image).toMatchObject({ width: 2048, height: 512 });
|
|
expect(face.material.map!.anisotropy).toBeGreaterThanOrEqual(8);
|
|
expect(face.material.map!.premultiplyAlpha).toBe(true);
|
|
expect(face.material.depthWrite).toBe(false);
|
|
expect(face.material.toneMapped).toBe(false);
|
|
});
|
|
|
|
it('fades into view, reuses the active label and resources, and hides when cancelled', () => {
|
|
const h = setup();
|
|
const face = h.sign.group.getObjectByName('sign-face') as SignMesh;
|
|
const allocation = h.resources.snapshot();
|
|
h.render(null);
|
|
expect(h.sign.group.visible).toBe(false);
|
|
h.render({ ...profile, phase: 'anticipate', progress: 0, opacity: 0 });
|
|
expect(h.sign.group.visible).toBe(false);
|
|
expect(face.userData.textureUpdateCount).toBe(1);
|
|
h.render({ ...profile, phase: 'approach', progress: 0.5, opacity: 0.7 });
|
|
expect(h.sign.group.visible).toBe(true);
|
|
expect(face.material.opacity).toBeGreaterThan(0);
|
|
expect(face.material.opacity).toBeLessThanOrEqual(0.7);
|
|
h.render(profile, 120);
|
|
expect(face.material.opacity).toBe(1);
|
|
expect(face.userData.textureUpdateCount).toBe(1);
|
|
expect(h.resources.snapshot()).toEqual(allocation);
|
|
h.render(null);
|
|
expect(h.sign.group.visible).toBe(false);
|
|
h.render({ ...profile, targetId: 'selected-builds', phase: 'anticipate', progress: 0, opacity: 0 });
|
|
expect(face.userData.textureUpdateCount).toBe(2);
|
|
expect(routeLabelFor('selected-builds')).toBe('Selected builds');
|
|
expect(h.resources.snapshot()).toEqual(allocation);
|
|
h.resources.dispose();
|
|
expect(h.resources.snapshot().owned).toMatchObject({ geometry: 0, material: 0, texture: 0 });
|
|
});
|
|
|
|
it('caps label texture size and anisotropy to the renderer limits', () => {
|
|
const h = setup(1440, 900, { maximumAnisotropy: 4, maximumTextureSize: 1024 });
|
|
expect(h.render(profile)!.texture).toMatchObject({ width: 1024, height: 256, maximumSize: 1024, anisotropy: 4 });
|
|
});
|
|
|
|
it('accepts a concealed new target without reallocating its label texture', () => {
|
|
const h = setup();
|
|
h.render({ ...profile, phase: 'approach', progress: 0.03, opacity: 0.01 });
|
|
const allocation = h.resources.snapshot();
|
|
const face = h.sign.group.getObjectByName('sign-face') as SignMesh;
|
|
const presentation = h.render({ ...profile, targetId: 'experience', phase: 'anticipate', progress: 0, opacity: 0 });
|
|
expect(face.userData.textureUpdateCount).toBe(2);
|
|
expect(presentation!.corridor).toBe('right');
|
|
expect(presentation!.visible).toBe(false);
|
|
expect(h.resources.snapshot()).toEqual(allocation);
|
|
});
|
|
|
|
it.each([[1440, 900], [1920, 1080], [1024, 768], [390, 844], [844, 390]])('docks the face exactly into the gantry window at %ix%i', (width, height) => {
|
|
const h = setup(width, height);
|
|
const docked = h.render(profile, 120)!;
|
|
expectDocked(docked.bounds, h.dock);
|
|
expect(h.sign.group.userData.anchoring).toBe('gantry');
|
|
expect(h.sign.dockState()).toBe('docked');
|
|
});
|
|
|
|
it('follows a moved window over successive frames without a one-frame teleport', () => {
|
|
const h = setup();
|
|
const original = h.render(profile, 60)!.bounds.left;
|
|
h.setDock({ ...h.dock, left: h.dock.left + 300 });
|
|
const first = h.render(profile)!.bounds.left;
|
|
const settled = h.render(profile, 120)!.bounds;
|
|
expect(first).toBeGreaterThan(original);
|
|
expect(first - original).toBeLessThan((settled.left - original) / 2);
|
|
expectDocked(settled, h.dock);
|
|
});
|
|
|
|
it('lifts off the horizon and grows into the window while approaching', () => {
|
|
const h = setup();
|
|
h.render({ ...profile, phase: 'approach', progress: 0, opacity: 1 }, 30);
|
|
const early = h.render({ ...profile, phase: 'approach', progress: 0.2, opacity: 1 })!.bounds;
|
|
const late = h.render({ ...profile, phase: 'approach', progress: 0.7, opacity: 1 })!.bounds;
|
|
const docked = h.render(profile)!.bounds;
|
|
expect(h.sign.dockState()).toBe('docked');
|
|
expect(early.width).toBeLessThan(late.width);
|
|
expect(late.width).toBeLessThan(docked.width + 1);
|
|
expect(early.top).toBeGreaterThan(late.top);
|
|
expect(late.top).toBeGreaterThan(docked.top - 1);
|
|
expect(early.top).toBeGreaterThan(900 * 0.3);
|
|
});
|
|
|
|
it('passes overhead and out of the top of the frame as it departs', () => {
|
|
const h = setup();
|
|
const docked = h.render(profile, 120)!.bounds;
|
|
const passing = h.render({ ...profile, phase: 'depart', progress: 0.5, opacity: 0.6 })!.bounds;
|
|
expect(h.sign.dockState()).toBe('passing');
|
|
expect(passing.top).toBeLessThan(docked.top);
|
|
expect(passing.width).toBeGreaterThan(docked.width);
|
|
const gone = h.render({ ...profile, phase: 'depart', progress: 1, opacity: 0.05 })!.bounds;
|
|
expect(gone.bottom).toBeLessThan(docked.bottom);
|
|
h.render(null);
|
|
expect(h.sign.group.visible).toBe(false);
|
|
expect(h.sign.dockState()).toBe('idle');
|
|
});
|
|
|
|
it('turns toward the pointer only while docked, and settles back', () => {
|
|
const h = setup();
|
|
h.render(profile, 120);
|
|
const group = h.sign.group as unknown as { quaternion: { clone(): { angleTo(q: unknown): number }; angleTo(q: unknown): number } };
|
|
const rest = group.quaternion.clone();
|
|
h.setTilt({ x: 1, y: -1 });
|
|
h.render(profile, 60);
|
|
expect(group.quaternion.angleTo(rest)).toBeGreaterThan(0.2);
|
|
h.setTilt({ x: 0, y: 0 });
|
|
h.render(profile, 120);
|
|
expect(group.quaternion.angleTo(rest)).toBeLessThan(0.01);
|
|
h.setTilt({ x: 1, y: 1 });
|
|
h.render(null);
|
|
h.render({ ...profile, phase: 'approach', progress: 0.5, opacity: 1 }, 60);
|
|
const approaching = group.quaternion.clone();
|
|
expect(approaching.angleTo(h.camera.quaternion)).toBeLessThan(0.01);
|
|
});
|
|
|
|
it('shows no sign when the gantry has no window', () => {
|
|
const h = setup(320, 640);
|
|
h.setDock({ left: 0, top: 0, width: 0, height: 0 });
|
|
h.render(profile, 10);
|
|
expect(h.sign.group.visible).toBe(false);
|
|
expect(h.sign.dockState()).toBe('idle');
|
|
});
|
|
|
|
it('reports an incoming sign before it docks', () => {
|
|
const h = setup();
|
|
h.render({ ...profile, phase: 'approach', progress: 0.3, opacity: 1 });
|
|
expect(h.sign.dockState()).toBe('incoming');
|
|
});
|
|
});
|