Files
Portfolio/tests/unit/world-models.test.ts

1034 lines
39 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import * as THREE from "three";
import type { EnvironmentChapter } from "@/journey/types";
import { renderQualityFor } from "@/scene/render-quality";
import { SceneResourceBag } from "@/scene/resources";
import {
CHAPTER_PALETTES,
createMutableChapterPalette,
writeChapterPalette,
} from "@/scene/world/palette";
import { createRoad } from "@/scene/world/road";
import { createTerrain } from "@/scene/world/terrain";
import { createSun } from "@/scene/world/sun";
import { createAtmosphere } from "@/scene/world/atmosphere";
import { createPostProcessor } from "@/scene/post-processing";
import { createCar } from "@/scene/world/car";
import { createChaseCamera, createWorldFog } from "@/scene/world/environment";
import {
HORIZON_FOG_END,
HORIZON_FOG_START,
} from "@/scene/world/metrics";
import { createSign, routeLabelFor } from "@/scene/world/sign";
import { createDriveDynamics } from "@/scene/drive-dynamics";
import { createTravelDistance } from "@/scene/world/travel-distance";
describe("procedural world models", () => {
it("uses the same linear horizon interval for scene-owned materials", () => {
const fog = createWorldFog(0x07101c);
expect(fog).toBeInstanceOf(THREE.Fog);
expect(fog.near).toBe(HORIZON_FOG_START);
expect(fog.far).toBe(HORIZON_FOG_END);
});
it("owns one monotonic forward-distance clock across cruise, input release, and delayed frames", () => {
const travel = createTravelDistance();
const cruise = travel.advance(0.1, 0.18, 0);
const accelerated = travel.advance(0.1, 1, 1);
const coast = travel.advance(0.1, 0.18, 0);
const delayed = travel.advance(4, 0.18, 0);
const malformed = travel.advance(Number.NaN, -1, Number.POSITIVE_INFINITY);
expect(cruise).toBeGreaterThan(0);
expect(accelerated).toBeGreaterThan(cruise);
expect(coast).toBeGreaterThan(accelerated);
expect(delayed).toBeGreaterThan(coast);
expect(delayed - coast).toBeCloseTo(cruise, 10);
expect(malformed).toBe(delayed);
expect(travel.snapshot()).toBe(delayed);
});
it("scales geometry and atmosphere work monotonically by live quality tier", () => {
const high = renderQualityFor("high", 3, false);
const medium = renderQualityFor("medium", 3, false);
const low = renderQualityFor("low", 3, false);
expect(high.roadSegments).toBeGreaterThan(medium.roadSegments);
expect(medium.roadSegments).toBeGreaterThan(low.roadSegments);
expect(high.terrainSegments).toBeGreaterThan(medium.terrainSegments);
expect(medium.terrainSegments).toBeGreaterThan(low.terrainSegments);
expect(high.streakCount).toBeGreaterThan(low.streakCount);
expect(low.postEffects).toBe(false);
});
it("builds a shader road whose motion is entirely uniform-driven", () => {
const resources = new SceneResourceBag();
const road = createRoad(resources, renderQualityFor("high", 1, false));
const surface = road.group.getObjectByName(
"road-surface",
) as ShaderRenderable;
expect(surface).toBeInstanceOf(THREE.Mesh);
expect(surface.geometry.index).not.toBeNull();
expect(surface.geometry.getAttribute("position").count).toBeGreaterThan(
1_000,
);
surface.geometry.computeBoundingBox();
expect(surface.geometry.boundingBox?.min.z).toBeLessThan(-700);
expect(surface.material.uniforms).toMatchObject({
uDistance: { value: 0 },
uSpeed: { value: 0 },
uAcceleration: { value: 0 },
uFogColor: { value: expect.any(THREE.Color) },
});
expect(surface.material.fragmentShader).toContain("centerLane");
expect(surface.material.fragmentShader).toContain("outerEdge");
const positionArray = surface.geometry.getAttribute("position").array;
const before = resources.snapshot();
for (let index = 0; index < 300; index += 1) {
road.update(index * 0.5, 0.65, 0.4);
}
expect(resources.snapshot()).toEqual(before);
expect(surface.geometry.getAttribute("position").array).toBe(positionArray);
expect(surface.material.uniforms.uDistance?.value).toBe(149.5);
expect(surface.material.uniforms.uSpeed?.value).toBe(0.65);
expect(surface.material.uniforms.uAcceleration?.value).toBe(0.4);
resources.dispose();
});
it("builds one wide indexed line terrain with a flat road-center mask", () => {
const resources = new SceneResourceBag();
const terrain = createTerrain(
resources,
renderQualityFor("medium", 1, false),
);
const lines = terrain.group.getObjectByName(
"terrain-grid",
) as ShaderRenderable;
expect(lines).toBeInstanceOf(THREE.LineSegments);
expect(lines.geometry.index).not.toBeNull();
lines.geometry.computeBoundingBox();
expect(lines.geometry.boundingBox).not.toBeNull();
expect(lines.geometry.boundingBox?.min.z).toBeLessThan(-700);
expect(
(lines.geometry.boundingBox?.max.x ?? 0) -
(lines.geometry.boundingBox?.min.x ?? 0),
).toBeGreaterThan(120);
const roadMask = lines.geometry.getAttribute("roadMask");
expect(roadMask).toBeDefined();
const maskValues = Array.from(roadMask.array as ArrayLike<number>);
expect(Math.min(...maskValues)).toBe(0);
expect(Math.max(...maskValues)).toBeGreaterThan(0.99);
expect(lines.material.uniforms).toMatchObject({
uDistance: { value: 0 },
uSpeed: { value: 0 },
uFlowDistance: { value: 0 },
uRouteProgress: { value: 0 },
uRelief: { value: expect.any(Number) },
uFogColor: { value: expect.any(THREE.Color) },
});
expect(lines.material.vertexShader).toContain("multiOctaveNoise");
expect(lines.material.vertexShader).toContain("mod(point, 256.0)");
const indexArray = lines.geometry.index?.array;
const positionArray = lines.geometry.getAttribute("position").array;
const lowColor = lines.material.uniforms.uLowColor?.value;
const highColor = lines.material.uniforms.uHighColor?.value;
const before = resources.snapshot();
for (let index = 0; index < 300; index += 1) {
terrain.update(index * 0.4, 0.7, "builds", index / 299);
}
expect(resources.snapshot()).toEqual(before);
expect(lines.geometry.index?.array).toBe(indexArray);
expect(lines.geometry.getAttribute("position").array).toBe(positionArray);
expect(lines.material.uniforms.uLowColor?.value).toBe(lowColor);
expect(lines.material.uniforms.uHighColor?.value).toBe(highColor);
expect(lines.material.uniforms.uRouteProgress?.value).toBe(1);
resources.dispose();
});
it("writes deterministic chapter blends into stable color allocations", () => {
const target = createMutableChapterPalette("deep-night");
const colorReferences = [
target.sky,
target.fog,
target.terrainLow,
target.terrainHigh,
target.sun,
target.haze,
];
expect(Object.keys(CHAPTER_PALETTES)).toEqual([
"deep-night",
"systems",
"infrastructure",
"builds",
"pre-dawn",
"destination",
] satisfies EnvironmentChapter[]);
expect(writeChapterPalette(target, "deep-night", "destination", 0.5)).toBe(
target,
);
const first = paletteSnapshot(target);
expect(writeChapterPalette(target, "deep-night", "destination", 0.5)).toBe(
target,
);
expect(paletteSnapshot(target)).toEqual(first);
expect([
target.sky,
target.fog,
target.terrainLow,
target.terrainHigh,
target.sun,
target.haze,
]).toEqual(colorReferences);
expect(target.warmth).toBeGreaterThan(
CHAPTER_PALETTES["deep-night"].warmth,
);
expect(target.warmth).toBeLessThan(CHAPTER_PALETTES.destination.warmth);
});
it("renders the sun as a scan-banded disc with a separately owned halo", () => {
const resources = new SceneResourceBag();
const sun = createSun(resources);
const disc = sun.group.getObjectByName("horizon-sun") as ShaderRenderable;
const halo = sun.group.getObjectByName("horizon-halo");
const limb = sun.group.getObjectByName("sun-limb-glow");
const bleed = sun.group.getObjectByName("horizon-light-bleed");
expect(disc).toBeInstanceOf(THREE.Mesh);
expect(halo).toBeInstanceOf(THREE.Sprite);
expect(limb).toBeInstanceOf(THREE.Mesh);
expect(bleed).toBeInstanceOf(THREE.Sprite);
expect((disc as unknown as { position: { z: number } }).position.z).toBeLessThan(-650);
expect(disc.material.uniforms).toMatchObject({
uScanBands: { value: 11 },
uWarmth: { value: CHAPTER_PALETTES["deep-night"].warmth },
uTime: { value: 0 },
uDriveEnergy: { value: 0 },
});
expect(disc.material.fragmentShader).toContain("scanBand");
expect(resources.snapshot().owned.texture).toBe(1);
const palette = createMutableChapterPalette("destination");
sun.update(palette, 1, 3.2, 1);
expect(disc.material.uniforms.uWarmth?.value).toBe(1);
expect(disc.material.uniforms.uRouteProgress?.value).toBe(1);
expect(disc.material.uniforms.uDriveEnergy?.value).toBe(1);
resources.dispose();
});
it("builds deterministic stars, haze layers, and quality-capped speed streaks", () => {
const highResources = new SceneResourceBag();
const lowResources = new SceneResourceBag();
const highQuality = renderQualityFor("high", 1, false);
const lowQuality = renderQualityFor("low", 1, true);
const high = createAtmosphere(highResources, highQuality);
const low = createAtmosphere(lowResources, lowQuality);
const highStars = high.group.getObjectByName(
"atmosphere-stars",
) as ShaderRenderable;
const lowStars = low.group.getObjectByName(
"atmosphere-stars",
) as ShaderRenderable;
const highStreaks = high.group.getObjectByName(
"speed-streaks",
) as ShaderRenderable;
const lowStreaks = low.group.getObjectByName(
"speed-streaks",
) as ShaderRenderable;
expect(highStars.geometry.getAttribute("position").count).toBe(
highQuality.starCount,
);
expect(lowStars.geometry.getAttribute("position").count).toBe(
lowQuality.starCount,
);
expect(highStars.material.uniforms).toMatchObject({
uPointScale: { value: 18 },
uMaxPointSize: { value: 4.5 },
});
expect(highStreaks.geometry.getAttribute("position").count).toBe(
highQuality.streakCount * 2,
);
expect(lowStreaks.geometry.getAttribute("position").count).toBe(
lowQuality.streakCount * 2,
);
expect(high.group.getObjectByName("horizon-haze")).toBeInstanceOf(
THREE.Group,
);
const sky = high.group.getObjectByName("atmosphere-sky") as ShaderRenderable & {
frustumCulled: boolean;
};
sky.geometry.computeBoundingBox();
expect(sky.geometry.boundingBox?.min.x).toBeCloseTo(-1, 6);
expect(sky.geometry.boundingBox?.max.x).toBeCloseTo(1, 6);
expect(sky.material.vertexShader).toContain(
"gl_Position = vec4(position.xy, 1.0, 1.0)",
);
expect(sky.frustumCulled).toBe(false);
expect(sky.material.depthTest).toBe(false);
const haze = high.group.getObjectByName(
"horizon-haze-near",
) as ShaderRenderable;
expect(haze.material.vertexShader).toContain(
"gl_Position = vec4(position.xy, 1.0, 1.0)",
);
expect(
(high.group.getObjectByName("distant-silhouettes") as { children: unknown[] })
.children,
).toHaveLength(3);
expect(high.group.getObjectByName("roadside-anchors")).toBeInstanceOf(
THREE.InstancedMesh,
);
const highStarPositions = Array.from(
highStars.geometry.getAttribute("position").array,
);
const secondResources = new SceneResourceBag();
const secondHigh = createAtmosphere(secondResources, highQuality);
const secondStars = secondHigh.group.getObjectByName(
"atmosphere-stars",
) as ShaderRenderable;
expect(
Array.from(secondStars.geometry.getAttribute("position").array),
).toEqual(highStarPositions);
const palette = createMutableChapterPalette("builds");
const before = highResources.snapshot();
for (let index = 0; index < 300; index += 1) {
high.update(index, 0.7, 0.8, palette);
}
expect(highResources.snapshot()).toEqual(before);
expect(highStreaks.material.uniforms.uAcceleration?.value).toBe(0.8);
expect(highStreaks.material.uniforms.uVisibility?.value).toBeGreaterThan(0);
high.update(100, 1, 1, palette);
const acceleratedPhase = highStreaks.material.uniforms.uDistance?.value as number;
high.update(102, 0.18, 0, palette);
const coastPhase = highStreaks.material.uniforms.uDistance?.value as number;
expect(coastPhase).toBeGreaterThan(acceleratedPhase);
high.setViewportAspect(32 / 9);
expect(sky.material.uniforms.uAspect?.value).toBeCloseTo(32 / 9, 6);
highResources.dispose();
lowResources.dispose();
secondResources.dispose();
});
it("keeps independent inertial response curves for the road, chase rig, streaks, bloom, and suspension", () => {
const dynamics = createDriveDynamics();
const stableSnapshot = dynamics.snapshot();
dynamics.update(1, 1, 0.1);
const attack = { ...dynamics.snapshot() };
expect(dynamics.snapshot()).toBe(stableSnapshot);
expect(attack.roadEnergy).toBeGreaterThan(attack.cameraEnergy);
expect(attack.cameraEnergy).toBeGreaterThan(attack.bloomEnergy);
expect(attack.streakEnergy).toBeGreaterThan(0);
expect(attack.suspensionEnergy).toBeGreaterThan(0);
expect(attack.directionBias).toBeGreaterThan(0);
dynamics.update(0, 0, 0.1);
const coast = dynamics.snapshot();
expect(coast.roadEnergy).toBeLessThan(attack.roadEnergy);
expect(coast.streakEnergy / attack.streakEnergy).toBeGreaterThan(
coast.roadEnergy / attack.roadEnergy,
);
expect(coast.bloomEnergy).toBeGreaterThan(0);
dynamics.update(1, -1, 10);
expect(Object.values(dynamics.snapshot()).every(Number.isFinite)).toBe(true);
expect(dynamics.snapshot().directionBias).toBeLessThan(1);
});
it("disposes a composer and its passes exactly once", () => {
const resources = new SceneResourceBag();
const target = (width: number, height: number, depthBuffer: boolean) => ({
width,
height,
depthBuffer,
samples: 1,
dispose: vi.fn(),
});
const composer = {
addPass: vi.fn(),
setPixelRatio: vi.fn(),
setSize: vi.fn(),
render: vi.fn(),
dispose: vi.fn(),
renderTarget1: target(1_500, 1_000, true),
renderTarget2: target(1_500, 1_000, true),
};
const renderPass = {
scene: null,
camera: null,
setSize: vi.fn(),
dispose: vi.fn(),
};
const bloomPass = {
strength: 0.54,
radius: 0.42,
threshold: 0.88,
setSize: vi.fn(),
dispose: vi.fn(),
renderTargetBright: target(750, 500, false),
renderTargetsHorizontal: [target(375, 250, false)],
renderTargetsVertical: [target(375, 250, false)],
};
const outputPass = {
setSize: vi.fn(),
dispose: vi.fn(),
};
const processor = createPostProcessor(
resources,
{},
{
createComposer: () => composer,
createRenderPass: () => renderPass,
createBloomPass: () => bloomPass,
createOutputPass: () => outputPass,
},
);
processor.resize(1_200, 800, 1.25);
processor.setDynamics(0.75);
processor.render({}, {});
const allocations = resources.gpuBudgetSnapshot({
tier: "high",
defaultFramebuffer: {
width: 1_500,
height: 1_000,
bytesPerPixel: 4,
attachments: 2,
samples: 4,
},
});
expect(
allocations.renderTargets.map((allocation) => allocation.id),
).toEqual(
expect.arrayContaining([
"composer-target-1",
"composer-target-2",
"bloom-bright",
"bloom-horizontal-0",
"bloom-vertical-0",
]),
);
processor.dispose();
processor.dispose();
resources.dispose();
expect(composer.addPass).toHaveBeenCalledTimes(3);
expect(composer.addPass).toHaveBeenLastCalledWith(outputPass);
expect(composer.setPixelRatio).toHaveBeenCalledWith(1.25);
expect(composer.setSize).toHaveBeenCalledWith(1_200, 800);
expect(composer.render).toHaveBeenCalledOnce();
expect(renderPass.dispose).toHaveBeenCalledOnce();
expect(bloomPass.dispose).toHaveBeenCalledOnce();
expect(outputPass.dispose).toHaveBeenCalledOnce();
expect(composer.dispose).toHaveBeenCalledOnce();
expect(bloomPass.strength).toBeGreaterThan(0.54);
});
it("keeps the grand tourer anatomy readable in desktop and portrait chase views", () => {
const resources = new SceneResourceBag();
const car = createCar(resources);
for (const name of [
"rear-silhouette",
"rear-body-face",
"rear-light-bar",
"rear-glass",
"rear-glass-panel",
"rear-fascia",
"rear-wheel-left",
"rear-wheel-right",
"car-underglow",
"car-contact-shadow",
]) {
expect(car.group.getObjectByName(name), name).toBeDefined();
}
const desktopCamera = createChaseCamera(16 / 9);
expect((desktopCamera as { fov: number }).fov).toBe(66);
const desktop = projectedBounds(car.group, desktopCamera);
expect(desktop.maxX - desktop.minX).toBeGreaterThan(0.28);
expect(desktop.minX).toBeGreaterThan(-0.96);
expect(desktop.maxX).toBeLessThan(0.96);
expect(desktop.minY).toBeGreaterThan(-0.99);
expect(desktop.maxY).toBeLessThan(-0.05);
car.setDetail("reduced");
const portrait = projectedBounds(car.group, createChaseCamera(390 / 844));
expect(portrait.minX).toBeGreaterThan(-0.99);
expect(portrait.maxX).toBeLessThan(0.99);
expect(portrait.minY).toBeGreaterThan(-0.99);
expect(portrait.maxY).toBeLessThan(0.16);
expect(
(car.group.getObjectByName("secondary-detail") as { visible: boolean })
.visible,
).toBe(false);
resources.dispose();
});
it("wraps the authored GLB hierarchy without rebuilding it during animation", () => {
const resources = new SceneResourceBag();
const authored = new THREE.Group();
authored.name = "chase-car";
for (const name of [
"car-body",
"rear-glass",
"rear-light-bar",
"rear-diffuser",
"car-underglow-source",
]) {
const material = name === "rear-light-bar"
? new THREE.MeshStandardMaterial({ emissive: 0xff174f, emissiveIntensity: 8 })
: new THREE.MeshStandardMaterial();
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), material);
mesh.name = name;
authored.add(mesh);
}
const car = createCar(resources, authored);
const childCount = (car.group as { children: unknown[] }).children.length;
expect(
(car.group.getObjectByName("grand-tourer-model") as { rotation: { y: number } })
.rotation.y,
).toBeCloseTo(Math.PI, 6);
expect(car.group.getObjectByName("car-body")).toBeDefined();
expect(
(car.group.getObjectByName("car-body") as {
material: { emissive: { getHex(): number } };
}).material.emissive.getHex(),
).toBe(0x0b2738);
expect(car.group.getObjectByName("rear-glass")).toBeDefined();
expect(car.group.getObjectByName("rear-light-bar")).toBeDefined();
expect(car.group.getObjectByName("car-underglow")).toBeDefined();
expect(car.group.getObjectByName("car-contact-shadow")).toBeDefined();
for (let frame = 0; frame < 60; frame += 1) {
car.update(0.8, 0.7, frame / 60);
}
expect((car.group as { children: unknown[] }).children).toHaveLength(childCount);
expect(resources.snapshot().owned.geometry).toBeGreaterThanOrEqual(6);
resources.dispose();
});
it("damps squat and light response without allocating or changing the scene graph", () => {
const resources = new SceneResourceBag();
const car = createCar(resources);
const light = car.group.getObjectByName("rear-light-bar") as {
material: { emissiveIntensity: number };
};
const underglow = car.group.getObjectByName("car-underglow") as {
material: { uniforms: Record<string, { value: number }> };
};
car.update(0.3, 0, 0);
const restingY = (car.group as { position: { y: number } }).position.y;
const restingLight = light.material.emissiveIntensity;
const restingGlow = underglow.material.uniforms.uOpacity?.value ?? 0;
const childCount = (car.group as { children: unknown[] }).children.length;
const allocation = resources.snapshot();
for (let frame = 1; frame <= 60; frame += 1) {
car.update(0.92, 1, frame / 60);
}
expect((car.group as { position: { y: number } }).position.y).toBeLessThan(
restingY,
);
expect(light.material.emissiveIntensity).toBeGreaterThan(restingLight);
expect(underglow.material.uniforms.uOpacity?.value).toBeGreaterThan(
restingGlow,
);
expect(
Math.abs((car.group as { rotation: { z: number } }).rotation.z),
).toBeLessThanOrEqual(0.006);
expect((car.group as { children: unknown[] }).children).toHaveLength(
childCount,
);
expect(resources.snapshot()).toEqual(allocation);
resources.dispose();
});
it("renders one mounted roadside sign without mutating a visible label", () => {
const resources = new SceneResourceBag();
const sign = createSign(resources);
for (const name of [
"sign-backplate",
"sign-face-panel",
"sign-face",
"sign-housing",
"sign-edge-light",
"sign-post",
"sign-post-secondary",
"sign-bracket",
"sign-back-rails",
"sign-fasteners",
"sign-footing",
]) {
expect(sign.group.getObjectByName(name), name).toBeDefined();
}
const face = sign.group.getObjectByName("sign-face") as {
userData: { textureUpdateCount: number };
material: {
depthWrite: boolean;
opacity: number;
toneMapped: boolean;
map: {
anisotropy: number;
image: { width: number; height: number };
premultiplyAlpha: boolean;
};
};
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 {
geometry: {
boundingBox: { max: { z: number } } | null;
computeBoundingBox(): void;
};
position: { z: number };
};
backplate.geometry.computeBoundingBox();
const backplateFront = backplate.position.z + (backplate.geometry.boundingBox?.max.z ?? 0);
expect(face.position.z - backplateFront).toBeGreaterThan(0.05);
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);
expect(face.material.map.premultiplyAlpha).toBe(true);
expect(face.material.depthWrite).toBe(false);
expect(face.material.toneMapped).toBe(false);
expect(face.material).toBeInstanceOf(THREE.MeshBasicMaterial);
const facePanel = sign.group.getObjectByName("sign-face-panel") as { material: unknown };
expect(facePanel.material).toBeInstanceOf(THREE.MeshStandardMaterial);
const allocation = resources.snapshot();
let renderablePartCount = 0;
(sign.group as unknown as {
traverse(callback: (object: { isMesh?: boolean }) => void): void;
}).traverse((object) => {
if (object.isMesh === true) renderablePartCount += 1;
});
expect(renderablePartCount).toBe(20);
expect(allocation.owned).toMatchObject({
geometry: 13,
material: 7,
texture: 1,
});
sign.update(null);
expect((sign.group as { visible: boolean }).visible).toBe(false);
sign.update({
targetId: "infrastructure",
phase: "anticipate",
progress: 0,
opacity: 0,
});
expect((sign.group as { visible: boolean }).visible).toBe(false);
expect(face.userData.textureUpdateCount).toBe(1);
expect(routeLabelFor("infrastructure")).toBe("Infrastructure");
sign.update({
targetId: "infrastructure",
phase: "approach",
progress: 0.5,
opacity: 0.7,
});
expect((sign.group as { visible: boolean }).visible).toBe(true);
expect(face.userData.textureUpdateCount).toBe(1);
expect(face.material.opacity).toBeCloseTo(0.7, 6);
const visiblePosition = sign.group.position.clone();
const visibleScale = sign.group.scale.x;
sign.setViewportAspect(0.6);
sign.update({
targetId: "selected-builds",
phase: "dwell",
progress: 1,
opacity: 1,
});
expect(face.userData.textureUpdateCount).toBe(1);
expect(routeLabelFor("selected-builds")).toBe("Selected builds");
expect(sign.group.position).toEqual(visiblePosition);
expect(sign.group.scale.x).toBeCloseTo(visibleScale, 6);
sign.update(null);
sign.update({
targetId: "selected-builds",
phase: "anticipate",
progress: 0,
opacity: 0,
});
expect(face.userData.textureUpdateCount).toBe(2);
expect(resources.snapshot()).toEqual(allocation);
resources.dispose();
});
it("caps the sign texture to the renderer limit without changing its four-to-one sampling ratio", () => {
const resources = new SceneResourceBag();
const sign = createSign(resources, {
maximumAnisotropy: 4,
maximumTextureSize: 1_024,
});
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
expect(sign.inspect(createChaseCamera(16 / 9), 1_440, 900)?.texture).toMatchObject({
width: 1_024,
height: 256,
maximumSize: 1_024,
anisotropy: 4,
});
resources.dispose();
});
it("accepts a new target when the previous face is below the concealed threshold", () => {
const resources = new SceneResourceBag();
const sign = createSign(resources);
const face = sign.group.getObjectByName("sign-face") as {
userData: { textureUpdateCount: number };
};
sign.update({
targetId: "profile",
phase: "approach",
progress: 0.03,
opacity: 0.01,
});
expect(face.userData.textureUpdateCount).toBe(1);
sign.update({
targetId: "experience",
phase: "anticipate",
progress: 0,
opacity: 0,
});
expect(face.userData.textureUpdateCount).toBe(2);
expect(sign.inspect(createChaseCamera(16 / 9), 1_440, 900)?.corridor).toBe("left");
resources.dispose();
});
it("begins sign departure from the rendered approach pose without a teleport", () => {
const resources = new SceneResourceBag();
const sign = createSign(resources);
sign.setViewportAspect(16 / 9);
sign.update({
targetId: "profile",
phase: "approach",
progress: 0.62,
opacity: 1,
});
const approachPosition = sign.group.position.clone();
const approachScale = sign.group.scale.x;
sign.update({
targetId: "profile",
phase: "depart",
progress: 0,
opacity: 1,
});
expect(sign.group.position.distanceTo(approachPosition)).toBeLessThan(0.001);
expect(sign.group.scale.x).toBeCloseTo(approachScale, 6);
resources.dispose();
});
it("reflows a live sign to a new responsive pose without a resize teleport", () => {
const resources = new SceneResourceBag();
const sign = createSign(resources);
sign.setViewportSize(1_440, 900);
const dwell = { targetId: "profile", phase: "dwell", progress: 1, opacity: 1 } as const;
sign.update(dwell, 0);
const resizeOrigin = sign.group.position.clone();
sign.setViewportSize(844, 390);
sign.update(dwell, 100);
expect(sign.group.position.distanceTo(resizeOrigin)).toBeLessThan(0.001);
sign.update(dwell, 260);
const midpoint = sign.group.position.clone();
expect(midpoint.distanceTo(resizeOrigin)).toBeGreaterThan(0.1);
sign.update(dwell, 450);
expect(sign.group.position.distanceTo(midpoint)).toBeGreaterThan(0.1);
sign.update(null);
sign.update({ targetId: "experience", phase: "anticipate", progress: 0, opacity: 0 });
const camera = createChaseCamera(844 / 390);
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("short");
rebuiltResources.dispose();
});
it("reflows a visible portrait sign into its navigator-safe pose without a teleport", () => {
const resources = new SceneResourceBag();
const sign = createSign(resources);
const camera = createChaseCamera(390 / 844);
sign.setViewportSize(390, 844);
const passiveDwell = {
targetId: "profile",
phase: "dwell",
progress: 1,
opacity: 1,
} as const;
sign.update(passiveDwell, 0);
const passivePosition = sign.group.position.clone();
const navigationDwell = { ...passiveDwell, navigationArrival: true as const };
sign.update(navigationDwell, 100);
expect(sign.group.position.distanceTo(passivePosition)).toBeLessThan(0.001);
sign.update(navigationDwell, 260);
const midpoint = sign.group.position.clone();
expect(midpoint.distanceTo(passivePosition)).toBeGreaterThan(0.1);
sign.update(navigationDwell, 450);
const presentation = sign.inspect(camera, 390, 844);
if (presentation === null) throw new Error("Missing navigator-safe Profile sign");
expect(sign.group.position.distanceTo(midpoint)).toBeGreaterThan(0.1);
expect(presentation.bounds.top).toBeGreaterThanOrEqual(626);
expect(presentation.bounds.bottom).toBeLessThanOrEqual(690);
resources.dispose();
});
it("drives past a sign at constant world scale before carrying it beyond the roadside edge", () => {
const resources = new SceneResourceBag();
const sign = createSign(resources);
const camera = createChaseCamera(16 / 9);
sign.setViewportSize(1_440, 900);
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
const dwell = sign.inspect(camera, 1_440, 900);
if (dwell === null) throw new Error("Missing Profile dwell presentation");
const dwellPosition = sign.group.position.clone();
const dwellScale = sign.group.scale.x;
sign.update({ targetId: "profile", phase: "depart", progress: 0.5, opacity: 1 });
const passing = sign.inspect(camera, 1_440, 900);
if (passing === null) throw new Error("Missing Profile pass-by presentation");
expect(sign.group.position.x).toBeLessThan(dwellPosition.x);
expect(sign.group.position.y).toBeCloseTo(dwellPosition.y, 6);
expect(sign.group.position.z).toBeGreaterThan(dwellPosition.z);
expect(sign.group.scale.x).toBeCloseTo(dwellScale, 6);
expect(passing.bounds.width).toBeGreaterThan(dwell.bounds.width);
expect(passing.bounds.left).toBeLessThan(dwell.bounds.left);
const passingZ = sign.group.position.z;
sign.update({ targetId: "profile", phase: "depart", progress: 0.9, opacity: 0.1 });
const leaving = sign.inspect(camera, 1_440, 900);
if (leaving === null) throw new Error("Missing Profile departure presentation");
expect(sign.group.position.z).toBeGreaterThan(passingZ);
expect(sign.group.position.z).toBeLessThan(camera.position.z - 2);
expect(sign.group.scale.x).toBeCloseTo(dwellScale, 6);
expect(leaving.bounds.right).toBeLessThan(passing.bounds.right);
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("keeps a short-landscape left sign inside the narrow desktop roadside gutter", () => {
const resources = new SceneResourceBag();
const sign = createSign(resources);
const camera = createChaseCamera(780 / 493);
camera.position.set(-1.35, 8.6, 22);
camera.lookAt(1.6, 4.8, -128);
sign.setViewportSize(780, 493);
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
const presentation = sign.inspect(camera, 780, 493);
if (presentation === null) throw new Error("Missing short-landscape Profile sign");
expect(presentation.bounds.left).toBeGreaterThanOrEqual(8);
expect(presentation.bounds.right).toBeLessThanOrEqual(265);
resources.dispose();
});
it("keeps a short-landscape left sign horizontally clear at 844 by 390", () => {
const resources = new SceneResourceBag();
const sign = createSign(resources);
const camera = createChaseCamera(844 / 390);
camera.position.set(-1.35, 8.6, 22);
camera.lookAt(1.6, 4.8, -128);
sign.setViewportSize(844, 390);
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
const presentation = sign.inspect(camera, 844, 390);
if (presentation === null) throw new Error("Missing 844 by 390 Profile sign");
expect(presentation.bounds.left).toBeGreaterThanOrEqual(8);
expect(presentation.bounds.right).toBeLessThanOrEqual(288);
resources.dispose();
});
it("places a portrait navigator arrival below its full-width destination panel", () => {
const resources = new SceneResourceBag();
const sign = createSign(resources);
const camera = createChaseCamera(390 / 844);
sign.setViewportSize(390, 844);
sign.update({
targetId: "profile",
phase: "dwell",
progress: 1,
opacity: 1,
navigationArrival: true,
});
const presentation = sign.inspect(camera, 390, 844);
if (presentation === null) throw new Error("Missing portrait navigator sign");
expect(presentation.bounds.left).toBeGreaterThanOrEqual(8);
expect(presentation.bounds.top).toBeGreaterThanOrEqual(626);
expect(presentation.bounds.right).toBeLessThanOrEqual(382);
expect(presentation.bounds.bottom).toBeLessThanOrEqual(690);
expect(presentation.bounds.width).toBeGreaterThanOrEqual(85);
resources.dispose();
});
it("lands a resized Profile lifecycle in the short-landscape safe corridor", () => {
const resources = new SceneResourceBag();
const sign = createSign(resources);
sign.setViewportSize(1_440, 900);
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
sign.setViewportSize(844, 390);
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 }, 100);
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 }, 450);
const camera = createChaseCamera(844 / 390);
camera.position.set(-1.35, 8.6, 22);
camera.lookAt(1.6, 4.8, -128);
const presentation = sign.inspect(camera, 844, 390);
if (presentation === null) throw new Error("Missing resized Profile sign");
expect(presentation.viewportProfile).toBe("short");
expect(presentation.bounds.left).toBeGreaterThanOrEqual(8);
expect(presentation.bounds.right).toBeLessThanOrEqual(288);
resources.dispose();
});
it("places route signs in their authored transition-safe corridors", () => {
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: "contact", phase: "dwell", progress: 1, opacity: 1 });
sign.group.updateMatrixWorld(true);
const experienceBounds = projectedBounds(profileFace, camera);
expect(experienceBounds.minX).toBeGreaterThan(0);
resources.dispose();
});
});
function paletteSnapshot(
palette: ReturnType<typeof createMutableChapterPalette>,
) {
return {
sky: palette.sky.getHex(),
fog: palette.fog.getHex(),
terrainLow: palette.terrainLow.getHex(),
terrainHigh: palette.terrainHigh.getHex(),
sun: palette.sun.getHex(),
haze: palette.haze.getHex(),
warmth: palette.warmth,
terrainEnergy: palette.terrainEnergy,
starVisibility: palette.starVisibility,
streakEnergy: palette.streakEnergy,
};
}
interface ShaderRenderable {
geometry: {
index: { array: ArrayLike<number> } | null;
boundingBox: { min: { x: number; z: number }; max: { x: number; z: number } } | null;
computeBoundingBox(): void;
getAttribute(name: string): { count: number; array: ArrayLike<number> };
};
material: {
uniforms: Record<string, { value: unknown }>;
vertexShader: string;
fragmentShader: string;
depthTest: boolean;
};
}
function projectedBounds(group: unknown, camera: unknown) {
const typedGroup = group as { updateMatrixWorld(force: boolean): void };
const typedCamera = camera as { updateMatrixWorld(force: boolean): void };
typedGroup.updateMatrixWorld(true);
typedCamera.updateMatrixWorld(true);
const bounds = new THREE.Box3().setFromObject(group);
const projected = boxCorners(bounds).map((point) => point.project(camera));
return {
minX: Math.min(...projected.map((point) => point.x)),
maxX: Math.max(...projected.map((point) => point.x)),
minY: Math.min(...projected.map((point) => point.y)),
maxY: Math.max(...projected.map((point) => point.y)),
};
}
function boxCorners(bounds: {
min: { x: number; y: number; z: number };
max: { x: number; y: number; z: number };
}) {
return [
new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.min.z),
new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.max.z),
new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.min.z),
new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.max.z),
new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.min.z),
new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.max.z),
new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.min.z),
new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.max.z),
];
}