feat: overhead gantry top bar with a docking sign window
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.
This commit is contained in:
@@ -2,7 +2,7 @@ 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 SignPresentation, type SignRenderOptions } from '@/scene/world/sign';
|
||||
import { createSign, routeLabelFor, type SignDock, type SignPresentation, type SignRenderOptions, type SignTilt } from '@/scene/world/sign';
|
||||
|
||||
interface SignMesh {
|
||||
position: { z: number };
|
||||
@@ -25,22 +25,30 @@ 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 navigation clearance. update() alone does not position a sign.
|
||||
// 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 navigationBottom = 68;
|
||||
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, navigationBottom, now);
|
||||
sign.compose(camera, width, height, dock, now, tilt);
|
||||
}
|
||||
return sign.inspect(camera, width, height);
|
||||
},
|
||||
@@ -50,17 +58,18 @@ function setup(width = 1440, height = 900, options: SignRenderOptions = {}) {
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
sign.setViewportSize(width, height);
|
||||
dock = windowFor(width);
|
||||
},
|
||||
setNavigationBottom(bottom: number) { navigationBottom = bottom; },
|
||||
setDock(next: SignDock) { dock = next; },
|
||||
setTilt(next: SignTilt) { tilt = next; },
|
||||
};
|
||||
}
|
||||
|
||||
function expectInViewport(bounds: SignPresentation['bounds'], width: number, height: number, navigationBottom = 68) {
|
||||
expect(bounds.left).toBeGreaterThanOrEqual(8);
|
||||
expect(bounds.right).toBeLessThanOrEqual(width - 8);
|
||||
expect(bounds.top).toBeGreaterThanOrEqual(navigationBottom);
|
||||
expect(bounds.bottom).toBeLessThanOrEqual(height - 8);
|
||||
expect(bounds.width).toBeGreaterThan(80);
|
||||
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', () => {
|
||||
@@ -127,90 +136,82 @@ describe('composed route signs', () => {
|
||||
expect(h.resources.snapshot()).toEqual(allocation);
|
||||
});
|
||||
|
||||
it('eases between roadside and compact layouts over successive rendered frames', () => {
|
||||
const h = setup();
|
||||
h.render(profile, 120);
|
||||
const origin = h.sign.group.position.clone();
|
||||
h.resize(844, 390);
|
||||
h.render(profile);
|
||||
const first = h.sign.group.position.clone();
|
||||
h.render(profile, 10);
|
||||
const midpoint = h.sign.group.position.clone();
|
||||
const final = h.render(profile, 120)!;
|
||||
const destination = h.sign.group.position.clone();
|
||||
expect(first.distanceTo(origin)).toBeGreaterThan(0);
|
||||
expect(first.distanceTo(origin)).toBeLessThan(destination.distanceTo(origin) / 2);
|
||||
expect(midpoint.distanceTo(destination)).toBeLessThan(first.distanceTo(destination));
|
||||
expect(h.sign.group.userData.anchoring).toBe('compact-shelf');
|
||||
expectInViewport(final.bounds, 844, 390);
|
||||
h.resize(1440, 900);
|
||||
h.render(profile, 120);
|
||||
expect(h.sign.group.userData.anchoring).toBe('roadside');
|
||||
expect(h.sign.group.position.x).toBeCloseTo(origin.x, 2);
|
||||
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('moves the compact shelf below a taller navigator without a one-frame teleport', () => {
|
||||
const h = setup(390, 844);
|
||||
const original = h.render(profile, 60)!.bounds.top;
|
||||
h.setNavigationBottom(180);
|
||||
const first = h.render({ ...profile, navigationArrival: true })!.bounds.top;
|
||||
const settled = h.render({ ...profile, navigationArrival: true }, 120)!.bounds;
|
||||
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.top - original) / 2);
|
||||
expectInViewport(settled, 390, 844, 180);
|
||||
expect(first - original).toBeLessThan((settled.left - original) / 2);
|
||||
expectDocked(settled, h.dock);
|
||||
});
|
||||
|
||||
it('drives past a roadside sign along the road axis at constant physical scale', () => {
|
||||
it('lifts off the horizon and grows into the window while approaching', () => {
|
||||
const h = setup();
|
||||
const dwell = h.render(profile, 120)!;
|
||||
const position = h.sign.group.position.clone();
|
||||
const scale = h.sign.group.scale.x;
|
||||
const passing = h.render({ ...profile, phase: 'depart', progress: 0.5 })!;
|
||||
expect(h.sign.group.position.x).toBeCloseTo(position.x, 6);
|
||||
expect(h.sign.group.position.y).toBeCloseTo(position.y, 6);
|
||||
expect(h.sign.group.position.z).toBeGreaterThan(position.z);
|
||||
expect(h.sign.group.scale.x).toBeCloseTo(scale, 6);
|
||||
expect(passing.bounds.width).toBeGreaterThan(dwell.bounds.width);
|
||||
const passingZ = h.sign.group.position.z;
|
||||
h.render({ ...profile, phase: 'depart', progress: 0.9, opacity: 0.1 });
|
||||
expect(h.sign.group.position.z).toBeGreaterThan(passingZ);
|
||||
expect(h.sign.group.scale.x).toBeCloseTo(scale, 6);
|
||||
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.each([[390, 844], [844, 390], [1440, 900]])('keeps both sign supports on the ground at %ix%i', (width, height) => {
|
||||
const h = setup(width, height);
|
||||
h.render(profile);
|
||||
const footing = h.sign.group.getObjectByName('sign-footing') as { position: { y: number }; children: unknown[] };
|
||||
expect(h.sign.group.position.y + footing.position.y * h.sign.group.scale.x).toBeCloseTo(0, 6);
|
||||
expect(footing.children).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([[780, 493], [844, 390]])('keeps short-landscape signs in the right-side shelf at %ix%i', (width, height) => {
|
||||
const h = setup(width, height);
|
||||
const presentation = h.render(profile, 60)!;
|
||||
expectInViewport(presentation.bounds, width, height);
|
||||
expect(presentation.bounds.left).toBeGreaterThan(width / 2);
|
||||
expect(h.sign.group.userData.anchoring).toBe('compact-shelf');
|
||||
});
|
||||
|
||||
it.each([[320, 640], [390, 844], [692, 793], [1024, 768]])('keeps a readable compact navigator sign inside %ix%i', (width, height) => {
|
||||
const h = setup(width, height);
|
||||
const presentation = h.render({ ...profile, navigationArrival: true }, 60)!;
|
||||
expectInViewport(presentation.bounds, width, height);
|
||||
expect(presentation.bounds.width).toBeGreaterThan(200);
|
||||
expect(presentation.bounds.width).toBeLessThan(380);
|
||||
expect(h.sign.group.userData.anchoring).toBe('compact-shelf');
|
||||
});
|
||||
|
||||
it('anchors opposite chapter signs on opposite roadsides and clears the car envelope', () => {
|
||||
it('turns toward the pointer only while docked, and settles back', () => {
|
||||
const h = setup();
|
||||
h.render(profile, 120);
|
||||
expect(h.sign.group.position.x).toBeLessThan(-11.6);
|
||||
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, targetId: 'experience' }, 120);
|
||||
expect(h.sign.group.position.x).toBeGreaterThan(11.6);
|
||||
expect(h.sign.group.userData.anchoring).toBe('roadside');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user