The sign closes in over the road on a gantry truss, arcs into a wider window and hands off to a flat 2D plate that mirrors its face; content steps aside for the interlude and rises after. The bay keeps sound and settings; volume and Pause motion move into the settings popover.
234 lines
11 KiB
TypeScript
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.69, 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');
|
|
});
|
|
});
|