fix: make nightdrive signage reliable and legible
This commit is contained in:
@@ -216,7 +216,13 @@ describe('scene controller lifecycle', () => {
|
||||
await controller.start();
|
||||
const input = journey({
|
||||
routeProgress: 0.25,
|
||||
sign: { targetId: 'selected-builds', phase: 'approach', progress: 0.2, opacity: 0.5 },
|
||||
sign: {
|
||||
targetId: 'selected-builds',
|
||||
phase: 'approach',
|
||||
progress: 0.2,
|
||||
opacity: 0.5,
|
||||
viewportProfile: 'wide',
|
||||
},
|
||||
});
|
||||
|
||||
controller.update(input);
|
||||
@@ -225,7 +231,10 @@ describe('scene controller lifecycle', () => {
|
||||
harness.scheduler.flushFrame(16);
|
||||
|
||||
expect(harness.generations[0]?.render).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ routeProgress: 0.25, sign: expect.objectContaining({ progress: 0.2 }) }),
|
||||
expect.objectContaining({
|
||||
routeProgress: 0.25,
|
||||
sign: expect.objectContaining({ progress: 0.2, viewportProfile: 'wide' }),
|
||||
}),
|
||||
16,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createSignController } from '@/journey/sign-controller';
|
||||
import { SIGN_ROUTE_ORDER, signRouteFor } from '@/journey/sign-routes';
|
||||
import type { SignControllerSample } from '@/journey/types';
|
||||
|
||||
const sample = (
|
||||
@@ -10,16 +11,30 @@ const sample = (
|
||||
direction: 1,
|
||||
acceleration: 0,
|
||||
deltaY: 16,
|
||||
viewportWidth: 1_440,
|
||||
viewportHeight: 800,
|
||||
crossedBoundaries: 0,
|
||||
candidateId: 'profile',
|
||||
candidateDistanceVh: 1,
|
||||
candidateDistanceVh: 0.5,
|
||||
activeSectionId: 'start',
|
||||
motionAllowed: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('clock-driven sign lifecycle', () => {
|
||||
it('defines one explicit route marker and safe corridor for every eligible destination', () => {
|
||||
expect(SIGN_ROUTE_ORDER.map((id) => signRouteFor(id))).toEqual([
|
||||
{ id: 'start', label: 'Start', marker: '01', eligible: false, corridor: null, triggerMaximumVh: 0 },
|
||||
{ id: 'profile', label: 'Profile', marker: '02', eligible: true, corridor: 'left', triggerMaximumVh: 0.55 },
|
||||
{ id: 'experience', label: 'Experience', marker: '03', eligible: true, corridor: 'right', triggerMaximumVh: 0.55 },
|
||||
{ id: 'infrastructure', label: 'Infrastructure', marker: '04', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55 },
|
||||
{ id: 'selected-builds', label: 'Selected builds', marker: '05', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55 },
|
||||
{ id: 'additional-work', label: 'Additional work', marker: '06', eligible: true, corridor: 'left', triggerMaximumVh: 0.55 },
|
||||
{ id: 'toolkit', label: 'Toolkit', marker: '07', eligible: true, corridor: 'outer-left', triggerMaximumVh: 0.55 },
|
||||
{ id: 'contact', label: 'Contact', marker: '08', eligible: true, corridor: 'right', triggerMaximumVh: 0.55 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('advances through exact anticipate, approach, dwell, depart, and cooldown timing', () => {
|
||||
const controller = createSignController();
|
||||
|
||||
@@ -28,6 +43,7 @@ describe('clock-driven sign lifecycle', () => {
|
||||
phase: 'anticipate',
|
||||
progress: 0,
|
||||
opacity: 0,
|
||||
viewportProfile: 'wide',
|
||||
});
|
||||
expect(controller.needsFrame()).toBe(true);
|
||||
expect(controller.sample(sample({ now: 299, direction: 0 }))).toMatchObject({
|
||||
@@ -87,21 +103,34 @@ describe('clock-driven sign lifecycle', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('holds a completed minimum dwell without scheduling idle frames until the target activates', () => {
|
||||
it('keeps the latched viewport profile immutable across a responsive resize', () => {
|
||||
const controller = createSignController();
|
||||
expect(controller.sample(sample())).toMatchObject({ viewportProfile: 'wide' });
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 500,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
viewportWidth: 844,
|
||||
viewportHeight: 390,
|
||||
}))).toMatchObject({ targetId: 'profile', viewportProfile: 'wide' });
|
||||
});
|
||||
|
||||
it('departs after the minimum dwell even when scrolling stops before the target activates', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0 }));
|
||||
|
||||
expect(controller.sample(sample({ now: 2_600, direction: 0 }))).toMatchObject({ phase: 'dwell' });
|
||||
expect(controller.needsFrame()).toBe(false);
|
||||
expect(controller.sample(sample({ now: 4_000, activeSectionId: 'profile' }))).toMatchObject({
|
||||
expect(controller.sample(sample({ now: 2_599, direction: 0, deltaY: 0 }))).toMatchObject({ phase: 'dwell' });
|
||||
expect(controller.needsFrame()).toBe(true);
|
||||
expect(controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }))).toMatchObject({
|
||||
phase: 'depart',
|
||||
progress: 0,
|
||||
});
|
||||
expect(controller.needsFrame()).toBe(true);
|
||||
});
|
||||
|
||||
it('cancels a concealed sign on reverse and departs a visible approaching sign cleanly', () => {
|
||||
it('cancels a concealed sign on reverse but preserves the full dwell for a visible approach', () => {
|
||||
const early = createSignController();
|
||||
early.sample(sample());
|
||||
expect(early.sample(sample({ now: 310, direction: -1, deltaY: -20 }))).toBeNull();
|
||||
@@ -109,10 +138,14 @@ describe('clock-driven sign lifecycle', () => {
|
||||
|
||||
const readable = createSignController();
|
||||
readable.sample(sample());
|
||||
expect(readable.sample(sample({ now: 700, direction: -1, deltaY: -20 }))).toMatchObject({
|
||||
phase: 'depart',
|
||||
progress: 0,
|
||||
});
|
||||
expect(readable.sample(sample({ now: 700, direction: -1, deltaY: -20 })))
|
||||
.toMatchObject({ phase: 'approach' });
|
||||
expect(readable.sample(sample({ now: 1_200, direction: 0, deltaY: 0 })))
|
||||
.toMatchObject({ phase: 'dwell' });
|
||||
expect(readable.sample(sample({ now: 2_599, direction: 0, deltaY: 0 })))
|
||||
.toMatchObject({ phase: 'dwell' });
|
||||
expect(readable.sample(sample({ now: 2_600, direction: 0, deltaY: 0 })))
|
||||
.toMatchObject({ phase: 'depart', progress: 0 });
|
||||
});
|
||||
|
||||
it('preserves the minimum readable dwell when reverse input requests departure', () => {
|
||||
@@ -166,7 +199,7 @@ describe('clock-driven sign lifecycle', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('departs when the authoritative section has already passed the latched target', () => {
|
||||
it('preserves approach and minimum dwell when the authoritative section passes the target', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
|
||||
@@ -176,9 +209,29 @@ describe('clock-driven sign lifecycle', () => {
|
||||
candidateId: 'infrastructure',
|
||||
}))).toMatchObject({
|
||||
targetId: 'profile',
|
||||
phase: 'depart',
|
||||
progress: 0,
|
||||
phase: 'approach',
|
||||
});
|
||||
expect(controller.sample(sample({
|
||||
now: 1_200,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'experience',
|
||||
candidateId: 'infrastructure',
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||
expect(controller.sample(sample({
|
||||
now: 2_599,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'experience',
|
||||
candidateId: 'infrastructure',
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||
expect(controller.sample(sample({
|
||||
now: 2_600,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'experience',
|
||||
candidateId: 'infrastructure',
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'depart', progress: 0 });
|
||||
});
|
||||
|
||||
it('does not truncate dwell when the authoritative section passes the target', () => {
|
||||
@@ -236,7 +289,7 @@ describe('clock-driven sign lifecycle', () => {
|
||||
it.each([
|
||||
['reverse input', { direction: -1 as const, deltaY: -1 }],
|
||||
['stationary input', { direction: 0 as const, deltaY: 0 }],
|
||||
['candidate before the approach window', { candidateDistanceVh: 1.200_001 }],
|
||||
['candidate before the approach window', { candidateDistanceVh: 0.550_001 }],
|
||||
['candidate already passed', { candidateDistanceVh: 0 }],
|
||||
['candidate is already active', { activeSectionId: 'profile' as const }],
|
||||
['missing candidate', { candidateId: null }],
|
||||
@@ -265,4 +318,144 @@ describe('clock-driven sign lifecycle', () => {
|
||||
controller.reset();
|
||||
expect(controller.sample(sample({ now: 100 }))).toMatchObject({ phase: 'anticipate' });
|
||||
});
|
||||
|
||||
it('resolves a rapid multi-section jump to the latest active destination', () => {
|
||||
const controller = createSignController();
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 20,
|
||||
deltaY: 2_400,
|
||||
crossedBoundaries: 4,
|
||||
activeSectionId: 'selected-builds',
|
||||
candidateId: 'additional-work',
|
||||
candidateDistanceVh: 0.4,
|
||||
acceleration: 1,
|
||||
}))).toMatchObject({
|
||||
targetId: 'selected-builds',
|
||||
phase: 'anticipate',
|
||||
});
|
||||
});
|
||||
|
||||
it('coalesces changing jump targets and reveals only the latest after a readable stale sign departs', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 1_300,
|
||||
deltaY: 2_400,
|
||||
crossedBoundaries: 4,
|
||||
activeSectionId: 'selected-builds',
|
||||
candidateId: 'additional-work',
|
||||
candidateDistanceVh: 0.4,
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||
expect(controller.sample(sample({
|
||||
now: 1_500,
|
||||
deltaY: 1_200,
|
||||
crossedBoundaries: 2,
|
||||
activeSectionId: 'toolkit',
|
||||
candidateId: 'contact',
|
||||
candidateDistanceVh: 0.35,
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||
expect(controller.sample(sample({
|
||||
now: 2_600,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
crossedBoundaries: 0,
|
||||
activeSectionId: 'toolkit',
|
||||
candidateId: 'contact',
|
||||
candidateDistanceVh: 0.35,
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'depart', progress: 0 });
|
||||
expect(controller.sample(sample({
|
||||
now: 3_300,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
crossedBoundaries: 0,
|
||||
activeSectionId: 'toolkit',
|
||||
candidateId: 'contact',
|
||||
candidateDistanceVh: 0.35,
|
||||
}))).toBeNull();
|
||||
expect(controller.sample(sample({
|
||||
now: 3_650,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
crossedBoundaries: 0,
|
||||
activeSectionId: 'toolkit',
|
||||
candidateId: 'contact',
|
||||
candidateDistanceVh: 0.35,
|
||||
}))).toMatchObject({ targetId: 'toolkit', phase: 'anticipate' });
|
||||
});
|
||||
|
||||
it('retains the latest jump target first observed on the departure-completion frame', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 3_300,
|
||||
deltaY: 2_400,
|
||||
crossedBoundaries: 4,
|
||||
activeSectionId: 'selected-builds',
|
||||
candidateId: 'additional-work',
|
||||
candidateDistanceVh: 0.4,
|
||||
}))).toBeNull();
|
||||
expect(controller.sample(sample({
|
||||
now: 3_650,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
crossedBoundaries: 0,
|
||||
activeSectionId: 'selected-builds',
|
||||
candidateId: 'additional-work',
|
||||
candidateDistanceVh: 0.4,
|
||||
}))).toMatchObject({ targetId: 'selected-builds', phase: 'anticipate' });
|
||||
});
|
||||
|
||||
it('starts the readable dwell clock on the first rendered dwell frame after a delayed gap', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 5_000,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||
expect(controller.sample(sample({
|
||||
now: 6_399,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||
expect(controller.sample(sample({
|
||||
now: 6_400,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'profile',
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'depart', progress: 0 });
|
||||
});
|
||||
|
||||
it('does not retrigger a completed route sign during small boundary oscillations', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||
controller.sample(sample({ now: 3_300, direction: 0, deltaY: 0 }));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 3_700,
|
||||
direction: -1,
|
||||
deltaY: -16,
|
||||
crossedBoundaries: 1,
|
||||
activeSectionId: 'start',
|
||||
}))).toBeNull();
|
||||
expect(controller.sample(sample({
|
||||
now: 3_800,
|
||||
direction: 1,
|
||||
deltaY: 16,
|
||||
crossedBoundaries: 1,
|
||||
activeSectionId: 'profile',
|
||||
candidateId: 'experience',
|
||||
}))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -576,8 +576,15 @@ describe("procedural world models", () => {
|
||||
}
|
||||
const face = sign.group.getObjectByName("sign-face") as {
|
||||
userData: { textureUpdateCount: number };
|
||||
material: { opacity: number };
|
||||
geometry: { getAttribute(name: string): { array: ArrayLike<number> } };
|
||||
material: {
|
||||
opacity: number;
|
||||
map: { anisotropy: number; image: { width: number; height: number } };
|
||||
};
|
||||
geometry: {
|
||||
boundingBox: { min: { x: number; y: number }; max: { x: number; y: number } } | null;
|
||||
computeBoundingBox(): void;
|
||||
getAttribute(name: string): { array: ArrayLike<number> };
|
||||
};
|
||||
position: { z: number };
|
||||
};
|
||||
const backplate = sign.group.getObjectByName("sign-backplate") as {
|
||||
@@ -593,6 +600,12 @@ describe("procedural world models", () => {
|
||||
const faceUvs = Array.from(face.geometry.getAttribute("uv").array);
|
||||
expect(Math.min(...faceUvs)).toBeGreaterThanOrEqual(0);
|
||||
expect(Math.max(...faceUvs)).toBeLessThanOrEqual(1);
|
||||
face.geometry.computeBoundingBox();
|
||||
const faceWidth = (face.geometry.boundingBox?.max.x ?? 0) - (face.geometry.boundingBox?.min.x ?? 0);
|
||||
const faceHeight = (face.geometry.boundingBox?.max.y ?? 0) - (face.geometry.boundingBox?.min.y ?? 0);
|
||||
expect(faceWidth / faceHeight).toBeCloseTo(4, 6);
|
||||
expect(face.material.map.image).toMatchObject({ width: 2_048, height: 512 });
|
||||
expect(face.material.map.anisotropy).toBeGreaterThanOrEqual(8);
|
||||
const allocation = resources.snapshot();
|
||||
|
||||
sign.update(null);
|
||||
@@ -667,6 +680,94 @@ describe("procedural world models", () => {
|
||||
expect(sign.group.scale.x).toBeCloseTo(approachScale, 6);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it("defers responsive pose changes for the complete lifecycle, including concealed anticipation", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sign = createSign(resources);
|
||||
const camera = createChaseCamera(16 / 9);
|
||||
sign.setViewportSize(1_440, 900);
|
||||
sign.update({ targetId: "profile", phase: "anticipate", progress: 0, opacity: 0 });
|
||||
|
||||
sign.setViewportSize(844, 390);
|
||||
const concealed = sign.inspect(camera, 844, 390);
|
||||
expect(concealed?.viewportProfile).toBe("wide");
|
||||
sign.update({ targetId: "profile", phase: "approach", progress: 0.5, opacity: 0.7 });
|
||||
expect(sign.inspect(camera, 844, 390)?.viewportProfile).toBe("wide");
|
||||
|
||||
sign.update(null);
|
||||
sign.update({ targetId: "experience", phase: "anticipate", progress: 0, opacity: 0 });
|
||||
expect(sign.inspect(camera, 844, 390)?.viewportProfile).toBe("short");
|
||||
resources.dispose();
|
||||
|
||||
const rebuiltResources = new SceneResourceBag();
|
||||
const rebuilt = createSign(rebuiltResources);
|
||||
rebuilt.setViewportSize(844, 390);
|
||||
rebuilt.update({
|
||||
targetId: "profile",
|
||||
phase: "dwell",
|
||||
progress: 1,
|
||||
opacity: 1,
|
||||
viewportProfile: "wide",
|
||||
});
|
||||
expect(rebuilt.inspect(camera, 844, 390)?.viewportProfile).toBe("wide");
|
||||
rebuiltResources.dispose();
|
||||
});
|
||||
|
||||
it("departs laterally without crossing the chase camera plane or growing toward the car", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sign = createSign(resources);
|
||||
sign.setViewportSize(1_440, 900);
|
||||
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
|
||||
const dwellX = sign.group.position.x;
|
||||
const dwellScale = sign.group.scale.x;
|
||||
|
||||
sign.update({ targetId: "profile", phase: "depart", progress: 0.9, opacity: 0.1 });
|
||||
|
||||
expect(sign.group.position.x).toBeLessThan(dwellX);
|
||||
expect(sign.group.position.z).toBeLessThanOrEqual(0);
|
||||
expect(sign.group.scale.x).toBeLessThan(dwellScale);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[390, 844],
|
||||
[844, 390],
|
||||
[1_440, 900],
|
||||
])("keeps the responsive sign footing planted at %ix%i", (width, height) => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sign = createSign(resources);
|
||||
sign.setViewportSize(width, height);
|
||||
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
|
||||
const footing = sign.group.getObjectByName("sign-footing") as { position: { y: number } };
|
||||
const footingWorldY = sign.group.position.y + footing.position.y * sign.group.scale.x;
|
||||
|
||||
expect(footingWorldY).toBeGreaterThanOrEqual(-0.5);
|
||||
expect(footingWorldY).toBeLessThanOrEqual(0.5);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it("places route signs in the corridor opposite their destination content", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sign = createSign(resources);
|
||||
sign.setViewportAspect(16 / 9);
|
||||
|
||||
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
|
||||
expect(sign.group.position.x).toBeLessThan(0);
|
||||
const profileFace = sign.group.getObjectByName("sign-face");
|
||||
const camera = createChaseCamera(16 / 9);
|
||||
camera.position.set(-1.35, 8.6, 22);
|
||||
camera.lookAt(1.6, 4.8, -128);
|
||||
sign.group.updateMatrixWorld(true);
|
||||
const profileBounds = projectedBounds(profileFace, camera);
|
||||
expect(profileBounds.maxX).toBeLessThan(0.08);
|
||||
|
||||
sign.update(null);
|
||||
sign.update({ targetId: "experience", phase: "dwell", progress: 1, opacity: 1 });
|
||||
sign.group.updateMatrixWorld(true);
|
||||
const experienceBounds = projectedBounds(profileFace, camera);
|
||||
expect(experienceBounds.minX).toBeGreaterThan(0);
|
||||
resources.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
function paletteSnapshot(
|
||||
|
||||
Reference in New Issue
Block a user