feat: integrate the cinematic nightdrive scene
This commit is contained in:
@@ -3,6 +3,7 @@ import type { QualityTier } from '@/journey/motion-policy';
|
|||||||
import type { SceneResourceBag } from '@/scene/resources';
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
|
|
||||||
export type LiveQualityTier = Exclude<QualityTier, 'static'>;
|
export type LiveQualityTier = Exclude<QualityTier, 'static'>;
|
||||||
|
export type SceneDetailMode = 'full' | 'reduced';
|
||||||
|
|
||||||
export type SceneLifecycleState =
|
export type SceneLifecycleState =
|
||||||
| { kind: 'idle' }
|
| { kind: 'idle' }
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { SceneResourceBag } from '@/scene/resources';
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
|
import type { SceneDetailMode } from '@/scene/types';
|
||||||
|
|
||||||
export interface CarWorld {
|
export interface CarWorld {
|
||||||
group: CarGroup;
|
group: CarGroup;
|
||||||
setDetail(detail: 'full' | 'reduced'): void;
|
setDetail(detail: SceneDetailMode): void;
|
||||||
update(speed: number, acceleration: number, elapsedSeconds: number): void;
|
update(speed: number, acceleration: number, elapsedSeconds: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,12 +247,12 @@ export function createCar(resources: SceneResourceBag): CarWorld {
|
|||||||
}
|
}
|
||||||
group.add(secondary);
|
group.add(secondary);
|
||||||
|
|
||||||
let detail: 'full' | 'reduced' = 'full';
|
let detail: SceneDetailMode = 'full';
|
||||||
let squat = 0;
|
let squat = 0;
|
||||||
let squatVelocity = 0;
|
let squatVelocity = 0;
|
||||||
let previousElapsed: number | null = null;
|
let previousElapsed: number | null = null;
|
||||||
|
|
||||||
const setDetail = (nextDetail: 'full' | 'reduced'): void => {
|
const setDetail = (nextDetail: SceneDetailMode): void => {
|
||||||
if (detail === nextDetail) return;
|
if (detail === nextDetail) return;
|
||||||
detail = nextDetail;
|
detail = nextDetail;
|
||||||
secondary.visible = detail === 'full';
|
secondary.visible = detail === 'full';
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
car.setDetail(width < 1_100 || height < 620 ? 'reduced' : 'full');
|
car.setDetail(width < 1_100 || height < 620 ? 'reduced' : 'full');
|
||||||
car.group.visible = height >= 340;
|
car.group.visible = height >= 340;
|
||||||
|
sign.setViewportAspect(camera.aspect);
|
||||||
postProcessor?.resize(width, height, quality.pixelRatio * quality.postScale);
|
postProcessor?.resize(width, height, quality.pixelRatio * quality.postScale);
|
||||||
if (TEST_BUILD && context.quality !== 'medium') {
|
if (TEST_BUILD && context.quality !== 'medium') {
|
||||||
window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(context.resources.gpuBudgetSnapshot({
|
window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(context.resources.gpuBudgetSnapshot({
|
||||||
|
|||||||
@@ -4,83 +4,215 @@ import type { SignSnapshot } from '@/journey/types';
|
|||||||
import type { SceneResourceBag } from '@/scene/resources';
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
|
|
||||||
const routeLabels: Readonly<Record<SectionId, string>> = {
|
const routeLabels: Readonly<Record<SectionId, string>> = {
|
||||||
start: '00 / START',
|
start: 'Start',
|
||||||
profile: '01 / PROFILE',
|
profile: 'Profile',
|
||||||
experience: '02 / EXPERIENCE',
|
experience: 'Experience',
|
||||||
infrastructure: '03 / INFRASTRUCTURE',
|
infrastructure: 'Infrastructure',
|
||||||
'selected-builds': '04 / SELECTED BUILDS',
|
'selected-builds': 'Selected builds',
|
||||||
'additional-work': '05 / ADDITIONAL WORK',
|
'additional-work': 'Additional work',
|
||||||
toolkit: '06 / TOOLKIT',
|
toolkit: 'Toolkit',
|
||||||
contact: '07 / DESTINATION',
|
contact: 'Contact',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface SignWorld {
|
||||||
|
group: SignGroup;
|
||||||
|
setViewportAspect(aspect: number): void;
|
||||||
|
update(sign: SignSnapshot | null): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SignGroup {
|
||||||
|
visible: boolean;
|
||||||
|
position: { x: number; y: number; z: number; set(x: number, y: number, z: number): void };
|
||||||
|
rotation: { y: number };
|
||||||
|
scale: { setScalar(scale: number): void };
|
||||||
|
add(...objects: unknown[]): void;
|
||||||
|
getObjectByName(name: string): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export function routeLabelFor(id: SectionId): string {
|
export function routeLabelFor(id: SectionId): string {
|
||||||
return routeLabels[id];
|
return routeLabels[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSign(resources: SceneResourceBag) {
|
export function createSign(resources: SceneResourceBag): SignWorld {
|
||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
|
group.name = 'roadside-sign';
|
||||||
group.visible = false;
|
group.visible = false;
|
||||||
const geometry = resources.own('geometry', new THREE.PlaneGeometry(8.4, 2.2));
|
|
||||||
|
const backplateMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x07101a,
|
||||||
|
roughness: 0.7,
|
||||||
|
metalness: 0.42,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0,
|
||||||
|
}));
|
||||||
|
const frameMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x2d6f79,
|
||||||
|
emissive: 0x43cfdd,
|
||||||
|
emissiveIntensity: 0.68,
|
||||||
|
roughness: 0.38,
|
||||||
|
metalness: 0.58,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0,
|
||||||
|
}));
|
||||||
|
const postMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x172b35,
|
||||||
|
roughness: 0.64,
|
||||||
|
metalness: 0.62,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const backplate = new THREE.Mesh(
|
||||||
|
resources.own('geometry', new THREE.BoxGeometry(8.2, 2.35, 0.24)),
|
||||||
|
backplateMaterial,
|
||||||
|
);
|
||||||
|
backplate.name = 'sign-backplate';
|
||||||
|
group.add(backplate);
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = 768;
|
canvas.width = 1_024;
|
||||||
canvas.height = 192;
|
canvas.height = 256;
|
||||||
const context = canvas.getContext('2d');
|
const context = canvas.getContext('2d');
|
||||||
const texture = resources.own('texture', new THREE.CanvasTexture(canvas));
|
const texture = resources.own('texture', new THREE.CanvasTexture(canvas));
|
||||||
texture.colorSpace = THREE.SRGBColorSpace;
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
const material = resources.own('material', new THREE.MeshBasicMaterial({
|
texture.generateMipmaps = false;
|
||||||
|
texture.minFilter = THREE.LinearFilter;
|
||||||
|
const faceMaterial = resources.own('material', new THREE.MeshBasicMaterial({
|
||||||
color: 0xffffff,
|
color: 0xffffff,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.96,
|
opacity: 0,
|
||||||
side: THREE.DoubleSide,
|
side: THREE.DoubleSide,
|
||||||
map: texture,
|
map: texture,
|
||||||
|
toneMapped: true,
|
||||||
}));
|
}));
|
||||||
const panel = new THREE.Mesh(geometry, material);
|
const face = new THREE.Mesh(
|
||||||
group.add(panel);
|
resources.own('geometry', new THREE.PlaneGeometry(7.72, 1.87)),
|
||||||
|
faceMaterial,
|
||||||
|
);
|
||||||
|
face.name = 'sign-face';
|
||||||
|
face.position.z = 0.132;
|
||||||
|
face.userData.textureUpdateCount = 0;
|
||||||
|
group.add(face);
|
||||||
|
|
||||||
const thrusterGeometry = resources.own('geometry', new THREE.ConeGeometry(0.18, 0.72, 8));
|
const frame = new THREE.Group();
|
||||||
const thrusterMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0x44e8f5 }));
|
frame.name = 'sign-frame';
|
||||||
for (const x of [-3.5, 3.5]) {
|
const horizontalGeometry = resources.own('geometry', new THREE.BoxGeometry(8.42, 0.12, 0.32));
|
||||||
const thruster = new THREE.Mesh(thrusterGeometry, thrusterMaterial);
|
const verticalGeometry = resources.own('geometry', new THREE.BoxGeometry(0.12, 2.35, 0.32));
|
||||||
thruster.rotation.x = Math.PI / 2;
|
for (const y of [-1.175, 1.175]) {
|
||||||
thruster.position.set(x, -1.22, 0.08);
|
const rail = new THREE.Mesh(horizontalGeometry, frameMaterial);
|
||||||
group.add(thruster);
|
rail.position.y = y;
|
||||||
|
frame.add(rail);
|
||||||
|
}
|
||||||
|
for (const x of [-4.15, 4.15]) {
|
||||||
|
const rail = new THREE.Mesh(verticalGeometry, frameMaterial);
|
||||||
|
rail.position.x = x;
|
||||||
|
frame.add(rail);
|
||||||
|
}
|
||||||
|
group.add(frame);
|
||||||
|
|
||||||
|
const postGeometry = resources.own('geometry', new THREE.BoxGeometry(0.17, 3.5, 0.17));
|
||||||
|
for (const [x, side] of [[-2.9, 'left'], [2.9, 'right']] as const) {
|
||||||
|
const post = new THREE.Mesh(postGeometry, postMaterial);
|
||||||
|
post.name = `sign-post-${side}`;
|
||||||
|
post.position.set(x, -2.86, -0.08);
|
||||||
|
group.add(post);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fadeMaterials = [backplateMaterial, frameMaterial, postMaterial, faceMaterial];
|
||||||
let target: SectionId | null = null;
|
let target: SectionId | null = null;
|
||||||
|
let narrowViewport = false;
|
||||||
|
|
||||||
const updateTexture = (id: SectionId): void => {
|
const updateTexture = (id: SectionId): void => {
|
||||||
if (target === id) return;
|
if (target === id) return;
|
||||||
target = id;
|
target = id;
|
||||||
if (!context) return;
|
face.userData.textureUpdateCount += 1;
|
||||||
context.fillStyle = '#07111b';
|
if (context) drawLabel(context, routeLabelFor(id), canvas.width, canvas.height);
|
||||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
context.strokeStyle = '#35ddeb';
|
|
||||||
context.lineWidth = 7;
|
|
||||||
context.strokeRect(8, 8, canvas.width - 16, canvas.height - 16);
|
|
||||||
context.fillStyle = '#e8fbff';
|
|
||||||
context.font = '600 46px ui-monospace, monospace';
|
|
||||||
context.textAlign = 'center';
|
|
||||||
context.textBaseline = 'middle';
|
|
||||||
context.fillText(routeLabelFor(id), canvas.width / 2, canvas.height / 2);
|
|
||||||
texture.needsUpdate = true;
|
texture.needsUpdate = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyOpacity = (opacity: number): void => {
|
||||||
|
for (const material of fadeMaterials) material.opacity = opacity;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
group,
|
group,
|
||||||
update(sign: SignSnapshot | null) {
|
setViewportAspect(aspect) {
|
||||||
group.visible = sign !== null && sign.opacity > 0;
|
narrowViewport = !Number.isFinite(aspect) || aspect < 0.85;
|
||||||
if (!sign) return;
|
},
|
||||||
|
update(sign) {
|
||||||
|
if (!sign) {
|
||||||
|
group.visible = false;
|
||||||
|
applyOpacity(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
updateTexture(sign.targetId);
|
updateTexture(sign.targetId);
|
||||||
material.opacity = sign.opacity;
|
const opacity = clamp01(sign.opacity);
|
||||||
const phaseOffset = sign.phase === 'anticipate'
|
applyOpacity(opacity);
|
||||||
? -30
|
group.visible = opacity > 0;
|
||||||
: sign.phase === 'approach'
|
const progress = smoothstep(clamp01(sign.progress));
|
||||||
? -26 + sign.progress * 21
|
const dwell = narrowViewport
|
||||||
: sign.phase === 'dwell'
|
? { x: 3.1, y: 3.25, z: -20, scale: 0.68 }
|
||||||
? -5
|
: { x: 8.25, y: 3.1, z: -8.5, scale: 1 };
|
||||||
: -5 + sign.progress * 18;
|
const horizon = narrowViewport
|
||||||
group.position.set(7.2, 4.1, phaseOffset);
|
? { x: 4.2, y: 4.1, z: -88, scale: 0.2 }
|
||||||
group.rotation.y = -0.12;
|
: { x: 12.4, y: 5.3, z: -92, scale: 0.24 };
|
||||||
|
|
||||||
|
if (sign.phase === 'anticipate') {
|
||||||
|
group.position.set(horizon.x, horizon.y, horizon.z);
|
||||||
|
group.scale.setScalar(horizon.scale);
|
||||||
|
} else if (sign.phase === 'approach') {
|
||||||
|
group.position.set(
|
||||||
|
THREE.MathUtils.lerp(horizon.x, dwell.x, progress),
|
||||||
|
THREE.MathUtils.lerp(horizon.y, dwell.y, progress),
|
||||||
|
THREE.MathUtils.lerp(horizon.z, dwell.z, progress),
|
||||||
|
);
|
||||||
|
group.scale.setScalar(THREE.MathUtils.lerp(horizon.scale, dwell.scale, progress));
|
||||||
|
} else if (sign.phase === 'dwell') {
|
||||||
|
group.position.set(dwell.x, dwell.y, dwell.z);
|
||||||
|
group.scale.setScalar(dwell.scale);
|
||||||
|
} else {
|
||||||
|
group.position.set(
|
||||||
|
dwell.x + progress * (narrowViewport ? 1.4 : 3.2),
|
||||||
|
dwell.y + progress * 0.7,
|
||||||
|
THREE.MathUtils.lerp(dwell.z, 17, progress),
|
||||||
|
);
|
||||||
|
group.scale.setScalar(dwell.scale * THREE.MathUtils.lerp(1, 1.08, progress));
|
||||||
|
}
|
||||||
|
group.rotation.y = narrowViewport ? -0.08 : -0.16;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawLabel(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
label: string,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): void {
|
||||||
|
context.clearRect(0, 0, width, height);
|
||||||
|
context.fillStyle = '#07111b';
|
||||||
|
context.strokeStyle = '#56d4df';
|
||||||
|
context.lineWidth = 4;
|
||||||
|
context.beginPath();
|
||||||
|
if (typeof context.roundRect === 'function') {
|
||||||
|
context.roundRect(14, 14, width - 28, height - 28, 12);
|
||||||
|
} else {
|
||||||
|
context.rect(14, 14, width - 28, height - 28);
|
||||||
|
}
|
||||||
|
context.fill();
|
||||||
|
context.stroke();
|
||||||
|
context.fillStyle = '#eef7f8';
|
||||||
|
context.font = '600 58px "Geist Mono Variable", ui-monospace, monospace';
|
||||||
|
context.textAlign = 'center';
|
||||||
|
context.textBaseline = 'middle';
|
||||||
|
context.fillText(label, width / 2, height / 2 + 2, width - 88);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(value: number): number {
|
||||||
|
return Number.isFinite(value) ? THREE.MathUtils.clamp(value, 0, 1) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothstep(value: number): number {
|
||||||
|
return value * value * (3 - 2 * value);
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ test('draws only the independently eligible next roadside sign during passive fo
|
|||||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||||
const headingTop = profile.getBoundingClientRect().top + window.scrollY;
|
const headingTop = profile.getBoundingClientRect().top + window.scrollY;
|
||||||
const destination = Math.max(1, headingTop - viewportHeight * (0.35 + 0.8));
|
const destination = Math.max(1, headingTop - viewportHeight * (0.35 + 0.8));
|
||||||
return { destination, viewportHeight, expectedLabel: '01 / PROFILE' };
|
return { destination, viewportHeight, expectedLabel: 'Profile' };
|
||||||
});
|
});
|
||||||
await page.evaluate(() => window.dispatchEvent(new WheelEvent('wheel', { deltaY: 1 })));
|
await page.evaluate(() => window.dispatchEvent(new WheelEvent('wheel', { deltaY: 1 })));
|
||||||
for (let y = 1; y < oracle.destination; y += oracle.viewportHeight * 0.5) {
|
for (let y = 1; y < oracle.destination; y += oracle.viewportHeight * 0.5) {
|
||||||
|
|||||||
@@ -78,13 +78,23 @@ export async function installSceneInstrumentation(page: Page): Promise<void> {
|
|||||||
}, { once: true });
|
}, { once: true });
|
||||||
|
|
||||||
const nativeFillText = CanvasRenderingContext2D.prototype.fillText;
|
const nativeFillText = CanvasRenderingContext2D.prototype.fillText;
|
||||||
|
const routeLabels = new Set([
|
||||||
|
'Start',
|
||||||
|
'Profile',
|
||||||
|
'Experience',
|
||||||
|
'Infrastructure',
|
||||||
|
'Selected builds',
|
||||||
|
'Additional work',
|
||||||
|
'Toolkit',
|
||||||
|
'Contact',
|
||||||
|
]);
|
||||||
CanvasRenderingContext2D.prototype.fillText = function fillText(
|
CanvasRenderingContext2D.prototype.fillText = function fillText(
|
||||||
text: string,
|
text: string,
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
maxWidth?: number,
|
maxWidth?: number,
|
||||||
): void {
|
): void {
|
||||||
if (/^0[0-7] \/ /u.test(text)) state.routeLabelsDrawn.push(text);
|
if (routeLabels.has(text)) state.routeLabelsDrawn.push(text);
|
||||||
if (maxWidth === undefined) nativeFillText.call(this, text, x, y);
|
if (maxWidth === undefined) nativeFillText.call(this, text, x, y);
|
||||||
else nativeFillText.call(this, text, x, y, maxWidth);
|
else nativeFillText.call(this, text, x, y, maxWidth);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -192,9 +192,10 @@ describe('scene render quality', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps route sign labels canonical and compact', () => {
|
it('keeps route sign labels canonical and compact', () => {
|
||||||
expect(routeLabelFor('start')).toBe('00 / START');
|
expect(routeLabelFor('start')).toBe('Start');
|
||||||
expect(routeLabelFor('infrastructure')).toBe('03 / INFRASTRUCTURE');
|
expect(routeLabelFor('infrastructure')).toBe('Infrastructure');
|
||||||
expect(routeLabelFor('contact')).toBe('07 / DESTINATION');
|
expect(routeLabelFor('selected-builds')).toBe('Selected builds');
|
||||||
|
expect(routeLabelFor('contact')).toBe('Contact');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the chase car recognizably wide and fully inside the desktop view', () => {
|
it('keeps the chase car recognizably wide and fully inside the desktop view', () => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { createAtmosphere } from '@/scene/world/atmosphere';
|
|||||||
import { createPostProcessor } from '@/scene/post-processing';
|
import { createPostProcessor } from '@/scene/post-processing';
|
||||||
import { createCar } from '@/scene/world/car';
|
import { createCar } from '@/scene/world/car';
|
||||||
import { createChaseCamera } from '@/scene/world/environment';
|
import { createChaseCamera } from '@/scene/world/environment';
|
||||||
|
import { createSign, routeLabelFor } from '@/scene/world/sign';
|
||||||
|
|
||||||
describe('procedural world models', () => {
|
describe('procedural world models', () => {
|
||||||
it('scales geometry and atmosphere work monotonically by live quality tier', () => {
|
it('scales geometry and atmosphere work monotonically by live quality tier', () => {
|
||||||
@@ -299,6 +300,61 @@ describe('procedural world models', () => {
|
|||||||
expect(resources.snapshot()).toEqual(allocation);
|
expect(resources.snapshot()).toEqual(allocation);
|
||||||
resources.dispose();
|
resources.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders one physical roadside sign and updates its plain label once per target', () => {
|
||||||
|
const resources = new SceneResourceBag();
|
||||||
|
const sign = createSign(resources);
|
||||||
|
for (const name of [
|
||||||
|
'sign-backplate',
|
||||||
|
'sign-face',
|
||||||
|
'sign-frame',
|
||||||
|
'sign-post-left',
|
||||||
|
'sign-post-right',
|
||||||
|
]) {
|
||||||
|
expect(sign.group.getObjectByName(name), name).toBeDefined();
|
||||||
|
}
|
||||||
|
const face = sign.group.getObjectByName('sign-face') as {
|
||||||
|
userData: { textureUpdateCount: number };
|
||||||
|
material: { opacity: number };
|
||||||
|
};
|
||||||
|
const allocation = resources.snapshot();
|
||||||
|
|
||||||
|
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 desktopX = (sign.group as { position: { x: number } }).position.x;
|
||||||
|
sign.setViewportAspect(0.6);
|
||||||
|
sign.update({
|
||||||
|
targetId: 'selected-builds',
|
||||||
|
phase: 'dwell',
|
||||||
|
progress: 1,
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
|
expect(face.userData.textureUpdateCount).toBe(2);
|
||||||
|
expect(routeLabelFor('selected-builds')).toBe('Selected builds');
|
||||||
|
expect((sign.group as { position: { x: number } }).position.x).toBeLessThan(desktopX);
|
||||||
|
expect(resources.snapshot()).toEqual(allocation);
|
||||||
|
resources.dispose();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function paletteSnapshot(palette: ReturnType<typeof createMutableChapterPalette>) {
|
function paletteSnapshot(palette: ReturnType<typeof createMutableChapterPalette>) {
|
||||||
|
|||||||
Reference in New Issue
Block a user