Files
Portfolio/tests/unit/sign-rendering.test.ts

234 lines
11 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);
// Between signs the face holds the resting chapter's label.
const resting = face.userData.textureUpdateCount;
h.render({ ...profile, phase: 'anticipate', progress: 0, opacity: 0 });
expect(h.sign.group.visible).toBe(false);
expect(face.userData.textureUpdateCount).toBe(resting + 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(1);
// Docked, the 3D face hands off to the window's flat plate in 200 ms.
h.render(profile, 120);
expect(face.material.opacity).toBe(0);
expect(h.sign.group.visible).toBe(false);
expect(face.userData.textureUpdateCount).toBe(resting + 1);
expect(h.resources.snapshot()).toEqual(allocation);
h.render(null);
expect(h.sign.group.visible).toBe(false);
const between = face.userData.textureUpdateCount;
h.render({ ...profile, targetId: 'selected-builds', phase: 'anticipate', progress: 0, opacity: 0 });
expect(face.userData.textureUpdateCount).toBe(between + 1);
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('closes in over the road in the upper third, then arcs up into the window', () => {
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 big = h.render({ ...profile, phase: 'approach', progress: 0.61, opacity: 1 })!.bounds;
const arcing = h.render({ ...profile, phase: 'approach', progress: 0.85, opacity: 1 })!.bounds;
const docked = h.render(profile)!.bounds;
expect(h.sign.dockState()).toBe('docked');
// Grows down the road to about 600 px, centred in the upper third.
expect(early.width).toBeLessThan(big.width);
expect(big.width).toBeGreaterThan(560);
expect(big.width).toBeLessThan(640);
expect((big.top + big.bottom) / 2).toBeGreaterThan(900 * 0.2);
expect((big.top + big.bottom) / 2).toBeLessThan(900 * 0.4);
// Then shrinks and rises into the window, landing within 2 px.
expect(arcing.width).toBeLessThan(big.width);
expect(arcing.top).toBeLessThan(big.top);
expectDocked(docked, h.dock);
});
it('leaves the window to the flat plate as it departs', () => {
const h = setup();
h.render(profile, 120);
// The flat plate holds the window; the 3D face stays hidden as it departs.
h.render({ ...profile, phase: 'depart', progress: 0.5, opacity: 0.6 });
expect(h.sign.dockState()).toBe('passing');
expect(h.sign.group.visible).toBe(false);
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 hovered, 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();
// Without hover the plate stays flat: no tilt.
h.setTilt({ x: 1, y: -1 });
h.render(profile, 60);
expect(group.quaternion.angleTo(rest)).toBeLessThan(0.01);
// Hovered, the 3D twin appears and leans at most about 4 degrees.
h.setTilt({ x: 1, y: -1, hover: true });
h.render(profile, 60);
expect(h.sign.group.visible).toBe(true);
expect(group.quaternion.angleTo(rest)).toBeGreaterThan(0.05);
expect(group.quaternion.angleTo(rest)).toBeLessThan(0.11);
h.setTilt({ x: 0, y: 0 });
h.render(profile, 120);
expect(group.quaternion.angleTo(rest)).toBeLessThan(0.01);
h.setTilt({ x: 1, y: 1, hover: true });
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');
});
});