test: align scene regressions with the current portfolio
Exercise sign state updates and composition together, covering responsive placement, grounding, label reuse, resource ownership, and departure motion. Update poster and visual-model expectations while retaining behavioral checks. Validation: 46 focused tests passed before commit. The preceding full build:preview run passed all 673 tests plus typecheck, builds, prerender, artifact verification, and transfer budgets.
This commit is contained in:
@@ -4,6 +4,8 @@ import { nextTick } from "vue";
|
|||||||
import SceneCanvas from "@/components/SceneCanvas.vue";
|
import SceneCanvas from "@/components/SceneCanvas.vue";
|
||||||
import DriveControls from "@/components/DriveControls.vue";
|
import DriveControls from "@/components/DriveControls.vue";
|
||||||
import JourneyHud from "@/components/JourneyHud.vue";
|
import JourneyHud from "@/components/JourneyHud.vue";
|
||||||
|
import posterUrl from "@/assets/hero-chromatic.jpg";
|
||||||
|
import mobilePosterUrl from "@/assets/hero-chromatic-mobile.jpg";
|
||||||
import type { AudioUiState } from "@/audio/types";
|
import type { AudioUiState } from "@/audio/types";
|
||||||
import type { MotionPolicy, QualityTier } from "@/journey/motion-policy";
|
import type { MotionPolicy, QualityTier } from "@/journey/motion-policy";
|
||||||
import type { JourneySnapshot } from "@/journey/types";
|
import type { JourneySnapshot } from "@/journey/types";
|
||||||
@@ -87,15 +89,15 @@ describe("SceneCanvas", () => {
|
|||||||
expect(wrapper.findAll("canvas")).toHaveLength(0);
|
expect(wrapper.findAll("canvas")).toHaveLength(0);
|
||||||
expect(wrapper.get("img").attributes()).toMatchObject({
|
expect(wrapper.get("img").attributes()).toMatchObject({
|
||||||
alt: "",
|
alt: "",
|
||||||
width: "1600",
|
src: posterUrl,
|
||||||
|
width: "1440",
|
||||||
height: "900",
|
height: "900",
|
||||||
});
|
});
|
||||||
expect(
|
expect(wrapper.get('picture source').attributes()).toMatchObject({
|
||||||
wrapper.findAll(".journey-poster-composition > span"),
|
media: '(max-width: 767px)',
|
||||||
).toHaveLength(7);
|
srcset: mobilePosterUrl,
|
||||||
expect(wrapper.find(".journey-poster__sun").exists()).toBe(true);
|
});
|
||||||
expect(wrapper.find(".journey-poster__road").exists()).toBe(true);
|
expect(wrapper.get('.journey-world').attributes('data-static-fallback')).toBe('true');
|
||||||
expect(wrapper.find(".journey-poster__car").exists()).toBe(true);
|
|
||||||
expect(wrapper.text()).toContain(
|
expect(wrapper.text()).toContain(
|
||||||
"A decorative night road behind the portfolio content.",
|
"A decorative night road behind the portfolio content.",
|
||||||
);
|
);
|
||||||
|
|||||||
216
tests/unit/sign-rendering.test.ts
Normal file
216
tests/unit/sign-rendering.test.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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 navigation clearance. update() alone does not position a sign.
|
||||||
|
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;
|
||||||
|
sign.setViewportSize(width, height);
|
||||||
|
return {
|
||||||
|
resources, sign, camera,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
setNavigationBottom(bottom: number) { navigationBottom = bottom; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
h.render({ ...profile, phase: 'anticipate', progress: 0, opacity: 0 });
|
||||||
|
expect(h.sign.group.visible).toBe(false);
|
||||||
|
expect(face.userData.textureUpdateCount).toBe(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(0.7);
|
||||||
|
h.render(profile, 120);
|
||||||
|
expect(face.material.opacity).toBe(1);
|
||||||
|
expect(face.userData.textureUpdateCount).toBe(1);
|
||||||
|
expect(h.resources.snapshot()).toEqual(allocation);
|
||||||
|
h.render(null);
|
||||||
|
expect(h.sign.group.visible).toBe(false);
|
||||||
|
h.render({ ...profile, targetId: 'selected-builds', phase: 'anticipate', progress: 0, opacity: 0 });
|
||||||
|
expect(face.userData.textureUpdateCount).toBe(2);
|
||||||
|
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('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('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;
|
||||||
|
expect(first).toBeGreaterThan(original);
|
||||||
|
expect(first - original).toBeLessThan((settled.top - original) / 2);
|
||||||
|
expectInViewport(settled, 390, 844, 180);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drives past a roadside sign along the road axis at constant physical scale', () => {
|
||||||
|
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(null);
|
||||||
|
expect(h.sign.group.visible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
const h = setup();
|
||||||
|
h.render(profile, 120);
|
||||||
|
expect(h.sign.group.position.x).toBeLessThan(-11.6);
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
HORIZON_FOG_END,
|
HORIZON_FOG_END,
|
||||||
HORIZON_FOG_START,
|
HORIZON_FOG_START,
|
||||||
} from "@/scene/world/metrics";
|
} from "@/scene/world/metrics";
|
||||||
import { createSign, routeLabelFor } from "@/scene/world/sign";
|
|
||||||
import { createDriveDynamics } from "@/scene/drive-dynamics";
|
import { createDriveDynamics } from "@/scene/drive-dynamics";
|
||||||
import { createTravelDistance } from "@/scene/world/travel-distance";
|
import { createTravelDistance } from "@/scene/world/travel-distance";
|
||||||
|
|
||||||
@@ -195,7 +194,7 @@ describe("procedural world models", () => {
|
|||||||
expect(target.warmth).toBeLessThan(CHAPTER_PALETTES.destination.warmth);
|
expect(target.warmth).toBeLessThan(CHAPTER_PALETTES.destination.warmth);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the sun as a scan-banded disc with a separately owned halo", () => {
|
it("updates the sun's palette and motion inputs without reallocating its disc and halo", () => {
|
||||||
const resources = new SceneResourceBag();
|
const resources = new SceneResourceBag();
|
||||||
const sun = createSun(resources);
|
const sun = createSun(resources);
|
||||||
const disc = sun.group.getObjectByName("horizon-sun") as ShaderRenderable;
|
const disc = sun.group.getObjectByName("horizon-sun") as ShaderRenderable;
|
||||||
@@ -214,18 +213,24 @@ describe("procedural world models", () => {
|
|||||||
uTime: { value: 0 },
|
uTime: { value: 0 },
|
||||||
uDriveEnergy: { value: 0 },
|
uDriveEnergy: { value: 0 },
|
||||||
});
|
});
|
||||||
expect(disc.material.fragmentShader).toContain("scanBand");
|
|
||||||
expect(resources.snapshot().owned.texture).toBe(1);
|
expect(resources.snapshot().owned.texture).toBe(1);
|
||||||
|
const allocation = resources.snapshot();
|
||||||
|
const color = disc.material.uniforms.uColor!.value as { getHex(): number };
|
||||||
|
const initialColor = color.getHex();
|
||||||
|
|
||||||
const palette = createMutableChapterPalette("destination");
|
const palette = createMutableChapterPalette("destination");
|
||||||
sun.update(palette, 1, 3.2, 1);
|
sun.update(palette, 1, 3.2, 1);
|
||||||
expect(disc.material.uniforms.uWarmth?.value).toBe(1);
|
expect(disc.material.uniforms.uWarmth?.value).toBe(1);
|
||||||
expect(disc.material.uniforms.uRouteProgress?.value).toBe(1);
|
expect(disc.material.uniforms.uRouteProgress?.value).toBe(1);
|
||||||
expect(disc.material.uniforms.uDriveEnergy?.value).toBe(1);
|
expect(disc.material.uniforms.uDriveEnergy?.value).toBe(1);
|
||||||
|
expect(disc.material.uniforms.uTime?.value).toBe(3.2);
|
||||||
|
expect(disc.material.uniforms.uColor?.value).toBe(color);
|
||||||
|
expect(color.getHex()).not.toBe(initialColor);
|
||||||
|
expect(resources.snapshot()).toEqual(allocation);
|
||||||
resources.dispose();
|
resources.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds deterministic stars, haze layers, and quality-capped speed streaks", () => {
|
it("builds deterministic stars, a palette-driven sky, and quality-capped speed streaks", () => {
|
||||||
const highResources = new SceneResourceBag();
|
const highResources = new SceneResourceBag();
|
||||||
const lowResources = new SceneResourceBag();
|
const lowResources = new SceneResourceBag();
|
||||||
const highQuality = renderQualityFor("high", 1, false);
|
const highQuality = renderQualityFor("high", 1, false);
|
||||||
@@ -261,33 +266,20 @@ describe("procedural world models", () => {
|
|||||||
expect(lowStreaks.geometry.getAttribute("position").count).toBe(
|
expect(lowStreaks.geometry.getAttribute("position").count).toBe(
|
||||||
lowQuality.streakCount * 2,
|
lowQuality.streakCount * 2,
|
||||||
);
|
);
|
||||||
expect(high.group.getObjectByName("horizon-haze")).toBeInstanceOf(
|
|
||||||
THREE.Group,
|
|
||||||
);
|
|
||||||
const sky = high.group.getObjectByName("atmosphere-sky") as ShaderRenderable & {
|
const sky = high.group.getObjectByName("atmosphere-sky") as ShaderRenderable & {
|
||||||
frustumCulled: boolean;
|
frustumCulled: boolean;
|
||||||
};
|
};
|
||||||
sky.geometry.computeBoundingBox();
|
sky.geometry.computeBoundingBox();
|
||||||
expect(sky.geometry.boundingBox?.min.x).toBeCloseTo(-1, 6);
|
expect(sky.geometry.boundingBox?.min.x).toBeCloseTo(-1, 6);
|
||||||
expect(sky.geometry.boundingBox?.max.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.frustumCulled).toBe(false);
|
||||||
expect(sky.material.depthTest).toBe(false);
|
expect(sky.material.depthTest).toBe(false);
|
||||||
const haze = high.group.getObjectByName(
|
const hazeColor = sky.material.uniforms.uHazeColor!.value as { getHex(): number };
|
||||||
"horizon-haze-near",
|
const initialHaze = hazeColor.getHex();
|
||||||
) as ShaderRenderable;
|
|
||||||
expect(haze.material.vertexShader).toContain(
|
|
||||||
"gl_Position = vec4(position.xy, 1.0, 1.0)",
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
(high.group.getObjectByName("distant-silhouettes") as { children: unknown[] })
|
(high.group.getObjectByName("distant-silhouettes") as { children: unknown[] })
|
||||||
.children,
|
.children,
|
||||||
).toHaveLength(3);
|
).toHaveLength(3);
|
||||||
expect(high.group.getObjectByName("roadside-anchors")).toBeInstanceOf(
|
|
||||||
THREE.InstancedMesh,
|
|
||||||
);
|
|
||||||
|
|
||||||
const highStarPositions = Array.from(
|
const highStarPositions = Array.from(
|
||||||
highStars.geometry.getAttribute("position").array,
|
highStars.geometry.getAttribute("position").array,
|
||||||
@@ -307,6 +299,9 @@ describe("procedural world models", () => {
|
|||||||
high.update(index, 0.7, 0.8, palette);
|
high.update(index, 0.7, 0.8, palette);
|
||||||
}
|
}
|
||||||
expect(highResources.snapshot()).toEqual(before);
|
expect(highResources.snapshot()).toEqual(before);
|
||||||
|
expect(sky.material.uniforms.uHazeColor!.value).toBe(hazeColor);
|
||||||
|
expect(hazeColor.getHex()).toBe(palette.haze.getHex());
|
||||||
|
expect(hazeColor.getHex()).not.toBe(initialHaze);
|
||||||
expect(highStreaks.material.uniforms.uAcceleration?.value).toBe(0.8);
|
expect(highStreaks.material.uniforms.uAcceleration?.value).toBe(0.8);
|
||||||
expect(highStreaks.material.uniforms.uVisibility?.value).toBeGreaterThan(0);
|
expect(highStreaks.material.uniforms.uVisibility?.value).toBeGreaterThan(0);
|
||||||
|
|
||||||
@@ -400,7 +395,14 @@ describe("procedural world models", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
processor.resize(1_200, 800, 1.25);
|
processor.resize(1_200, 800, 1.25);
|
||||||
|
processor.setDynamics(0);
|
||||||
|
const restingBloom = { strength: bloomPass.strength, threshold: bloomPass.threshold };
|
||||||
processor.setDynamics(0.75);
|
processor.setDynamics(0.75);
|
||||||
|
expect(bloomPass.strength).toBeGreaterThan(restingBloom.strength);
|
||||||
|
expect(bloomPass.threshold).toBeLessThan(restingBloom.threshold);
|
||||||
|
processor.setDynamics(0);
|
||||||
|
expect(bloomPass.strength).toBe(restingBloom.strength);
|
||||||
|
expect(bloomPass.threshold).toBe(restingBloom.threshold);
|
||||||
processor.render({}, {});
|
processor.render({}, {});
|
||||||
const allocations = resources.gpuBudgetSnapshot({
|
const allocations = resources.gpuBudgetSnapshot({
|
||||||
tier: "high",
|
tier: "high",
|
||||||
@@ -436,7 +438,6 @@ describe("procedural world models", () => {
|
|||||||
expect(bloomPass.dispose).toHaveBeenCalledOnce();
|
expect(bloomPass.dispose).toHaveBeenCalledOnce();
|
||||||
expect(outputPass.dispose).toHaveBeenCalledOnce();
|
expect(outputPass.dispose).toHaveBeenCalledOnce();
|
||||||
expect(composer.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", () => {
|
it("keeps the grand tourer anatomy readable in desktop and portrait chase views", () => {
|
||||||
@@ -458,7 +459,6 @@ describe("procedural world models", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const desktopCamera = createChaseCamera(16 / 9);
|
const desktopCamera = createChaseCamera(16 / 9);
|
||||||
expect((desktopCamera as { fov: number }).fov).toBe(66);
|
|
||||||
const desktop = projectedBounds(car.group, desktopCamera);
|
const desktop = projectedBounds(car.group, desktopCamera);
|
||||||
expect(desktop.maxX - desktop.minX).toBeGreaterThan(0.28);
|
expect(desktop.maxX - desktop.minX).toBeGreaterThan(0.28);
|
||||||
expect(desktop.minX).toBeGreaterThan(-0.96);
|
expect(desktop.minX).toBeGreaterThan(-0.96);
|
||||||
@@ -467,7 +467,13 @@ describe("procedural world models", () => {
|
|||||||
expect(desktop.maxY).toBeLessThan(-0.05);
|
expect(desktop.maxY).toBeLessThan(-0.05);
|
||||||
|
|
||||||
car.setDetail("reduced");
|
car.setDetail("reduced");
|
||||||
const portrait = projectedBounds(car.group, createChaseCamera(390 / 844));
|
// The live scene recenters and pulls the lens back on portrait displays.
|
||||||
|
// Use that settled framing fixture, not the initial desktop chase pose.
|
||||||
|
const portraitCamera = new THREE.PerspectiveCamera(61, 390 / 844, 0.1, 1200);
|
||||||
|
portraitCamera.position.set(0, 6.5, 31);
|
||||||
|
portraitCamera.lookAt(0, 3.5, -88);
|
||||||
|
portraitCamera.setViewOffset(390, 844, 0, -844 * 0.16, 390, 844);
|
||||||
|
const portrait = projectedBounds(car.group, portraitCamera);
|
||||||
expect(portrait.minX).toBeGreaterThan(-0.99);
|
expect(portrait.minX).toBeGreaterThan(-0.99);
|
||||||
expect(portrait.maxX).toBeLessThan(0.99);
|
expect(portrait.maxX).toBeLessThan(0.99);
|
||||||
expect(portrait.minY).toBeGreaterThan(-0.99);
|
expect(portrait.minY).toBeGreaterThan(-0.99);
|
||||||
@@ -505,11 +511,14 @@ describe("procedural world models", () => {
|
|||||||
.rotation.y,
|
.rotation.y,
|
||||||
).toBeCloseTo(Math.PI, 6);
|
).toBeCloseTo(Math.PI, 6);
|
||||||
expect(car.group.getObjectByName("car-body")).toBeDefined();
|
expect(car.group.getObjectByName("car-body")).toBeDefined();
|
||||||
expect(
|
const body = car.group.getObjectByName("car-body") as {
|
||||||
(car.group.getObjectByName("car-body") as {
|
material: { emissive: { getHex(): number }; emissiveIntensity: number };
|
||||||
material: { emissive: { getHex(): number } };
|
};
|
||||||
}).material.emissive.getHex(),
|
expect(body.material.emissive.getHex()).not.toBe(0);
|
||||||
).toBe(0x0b2738);
|
expect(body.material.emissiveIntensity).toBeGreaterThan(0);
|
||||||
|
expect(body.material.emissiveIntensity).toBeLessThan(1);
|
||||||
|
const material = body.material;
|
||||||
|
const allocation = resources.snapshot();
|
||||||
expect(car.group.getObjectByName("rear-glass")).toBeDefined();
|
expect(car.group.getObjectByName("rear-glass")).toBeDefined();
|
||||||
expect(car.group.getObjectByName("rear-light-bar")).toBeDefined();
|
expect(car.group.getObjectByName("rear-light-bar")).toBeDefined();
|
||||||
expect(car.group.getObjectByName("car-underglow")).toBeDefined();
|
expect(car.group.getObjectByName("car-underglow")).toBeDefined();
|
||||||
@@ -519,6 +528,8 @@ describe("procedural world models", () => {
|
|||||||
car.update(0.8, 0.7, frame / 60);
|
car.update(0.8, 0.7, frame / 60);
|
||||||
}
|
}
|
||||||
expect((car.group as { children: unknown[] }).children).toHaveLength(childCount);
|
expect((car.group as { children: unknown[] }).children).toHaveLength(childCount);
|
||||||
|
expect(body.material).toBe(material);
|
||||||
|
expect(resources.snapshot()).toEqual(allocation);
|
||||||
expect(resources.snapshot().owned.geometry).toBeGreaterThanOrEqual(6);
|
expect(resources.snapshot().owned.geometry).toBeGreaterThanOrEqual(6);
|
||||||
resources.dispose();
|
resources.dispose();
|
||||||
});
|
});
|
||||||
@@ -560,471 +571,6 @@ describe("procedural world models", () => {
|
|||||||
resources.dispose();
|
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("right");
|
|
||||||
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("grows a medium portrait navigator sign without enlarging the phone pose", () => {
|
|
||||||
const phoneResources = new SceneResourceBag();
|
|
||||||
const phoneSign = createSign(phoneResources);
|
|
||||||
const phoneCamera = createChaseCamera(390 / 844);
|
|
||||||
phoneSign.setViewportSize(390, 844);
|
|
||||||
phoneSign.update({
|
|
||||||
targetId: "profile",
|
|
||||||
phase: "dwell",
|
|
||||||
progress: 1,
|
|
||||||
opacity: 1,
|
|
||||||
navigationArrival: true,
|
|
||||||
});
|
|
||||||
const phone = phoneSign.inspect(phoneCamera, 390, 844);
|
|
||||||
if (phone === null) throw new Error("Missing phone navigator sign");
|
|
||||||
|
|
||||||
const mediumResources = new SceneResourceBag();
|
|
||||||
const mediumSign = createSign(mediumResources);
|
|
||||||
const mediumCamera = createChaseCamera(692 / 793);
|
|
||||||
mediumSign.setViewportSize(692, 793);
|
|
||||||
mediumSign.update({
|
|
||||||
targetId: "profile",
|
|
||||||
phase: "dwell",
|
|
||||||
progress: 1,
|
|
||||||
opacity: 1,
|
|
||||||
navigationArrival: true,
|
|
||||||
});
|
|
||||||
const medium = mediumSign.inspect(mediumCamera, 692, 793);
|
|
||||||
if (medium === null) throw new Error("Missing medium navigator sign");
|
|
||||||
|
|
||||||
expect(phone.bounds.width).toBeGreaterThanOrEqual(85);
|
|
||||||
expect(phone.bounds.width).toBeLessThanOrEqual(110);
|
|
||||||
expect(medium.viewportProfile).toBe("tall");
|
|
||||||
expect(medium.bounds.width).toBeGreaterThanOrEqual(145);
|
|
||||||
expect(medium.bounds.width).toBeLessThanOrEqual(210);
|
|
||||||
expect(medium.bounds.width).toBeGreaterThanOrEqual(phone.bounds.width * 1.45);
|
|
||||||
expect(medium.bounds.top).toBeLessThanOrEqual(555);
|
|
||||||
expect(medium.bounds.bottom).toBeLessThanOrEqual(590);
|
|
||||||
|
|
||||||
phoneResources.dispose();
|
|
||||||
mediumResources.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: "experience", phase: "dwell", progress: 1, opacity: 1 });
|
|
||||||
sign.group.updateMatrixWorld(true);
|
|
||||||
const experienceBounds = projectedBounds(profileFace, camera);
|
|
||||||
const experiencePresentation = sign.inspect(camera, 1_440, 900);
|
|
||||||
expect(experienceBounds.minX).toBeGreaterThan(0);
|
|
||||||
expect(experiencePresentation?.corridor).toBe("right");
|
|
||||||
expect(experiencePresentation?.bounds.top).toBeGreaterThanOrEqual(540);
|
|
||||||
expect(experiencePresentation?.bounds.bottom).toBeLessThanOrEqual(630);
|
|
||||||
|
|
||||||
sign.update(null);
|
|
||||||
sign.setViewportSize(844, 390);
|
|
||||||
sign.update({ targetId: "experience", phase: "dwell", progress: 1, opacity: 1 });
|
|
||||||
const shortCamera = createChaseCamera(844 / 390);
|
|
||||||
shortCamera.position.set(-1.35, 8.6, 22);
|
|
||||||
shortCamera.lookAt(1.6, 4.8, -128);
|
|
||||||
const shortExperience = sign.inspect(shortCamera, 844, 390);
|
|
||||||
expect(shortExperience?.corridor).toBe("right");
|
|
||||||
expect(shortExperience?.bounds.left).toBeGreaterThan(422);
|
|
||||||
expect(shortExperience?.bounds.top).toBeGreaterThanOrEqual(210);
|
|
||||||
expect(shortExperience?.bounds.bottom).toBeLessThanOrEqual(300);
|
|
||||||
resources.dispose();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function paletteSnapshot(
|
function paletteSnapshot(
|
||||||
|
|||||||
Reference in New Issue
Block a user