Merge branch 'polish/scene-nits' into redesign/overnight
This commit is contained in:
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { SceneResourceBag } from '@/scene/resources';
|
||||
import { publishBillboard, billboardStops, type BillboardProject } from '@/journey/billboard-progress';
|
||||
import {
|
||||
BILLBOARD_DECK_CLEAR_Z,
|
||||
BILLBOARD_TIMING,
|
||||
BILLBOARD_Z_FROM,
|
||||
BILLBOARD_Z_TO,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
billboardZ,
|
||||
composeScreen,
|
||||
createBillboard,
|
||||
deckAhead,
|
||||
SCREEN_ASPECT,
|
||||
STANDING,
|
||||
textureSize,
|
||||
@@ -156,6 +158,23 @@ describe('billboard world', () => {
|
||||
expect(board.mesh.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('holds down until the EXIT 05 deck has passed the camera, then rises', () => {
|
||||
const board = createBillboard(new SceneResourceBag(), { mode: 'full', textureSize: 512, io: fakeIO().io });
|
||||
// The deck comes down the road from the haze toward the camera (z ~25).
|
||||
for (let z = -400; z < BILLBOARD_DECK_CLEAR_Z; z += 4) {
|
||||
board.update(frame('infrastructure', null, { deckZ: z, deckExit: 'selected-builds' }));
|
||||
expect(board.inspect().rise).toBe(0);
|
||||
}
|
||||
// Another chapter's deck, or no deck, never holds it.
|
||||
expect(deckAhead({ deckZ: -80, deckExit: 'additional-work' })).toBe(false);
|
||||
expect(deckAhead({ deckZ: null, deckExit: 'selected-builds' })).toBe(false);
|
||||
const up = run(board, 10, frame('infrastructure', null, { deckZ: BILLBOARD_DECK_CLEAR_Z + 2, deckExit: 'selected-builds' }));
|
||||
expect(up.at(-1)!.rise).toBe(1);
|
||||
// Standing (reverse drive back under the deck): it stays up.
|
||||
const back = run(board, 3, frame('infrastructure', null, { deckZ: -60, deckExit: 'selected-builds', direction: -1 }));
|
||||
expect(back.at(-1)!.rise).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the floods off while rising, then lights each project in order and reverses', async () => {
|
||||
publishBillboard(PROJECTS, STOPS);
|
||||
const board = createBillboard(new SceneResourceBag(), { mode: 'full', textureSize: 512, io: fakeIO().io });
|
||||
|
||||
35
tests/unit/expressway-lamps.test.ts
Normal file
35
tests/unit/expressway-lamps.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SceneResourceBag } from '@/scene/resources';
|
||||
import { renderQualityFor } from '@/scene/render-quality';
|
||||
import { createExpressway, PYLON_CORE_GAIN, PYLON_HALO_GAIN } from '@/scene/world/expressway';
|
||||
|
||||
interface Node {
|
||||
name: string;
|
||||
count?: number;
|
||||
material?: { blending: number; fragmentShader?: string };
|
||||
}
|
||||
const children = (group: unknown) => (group as { children: Node[] }).children;
|
||||
const luminance = ([r, g, b]: readonly number[]) => 0.2126 * r! + 0.7152 * g! + 0.0722 * b!;
|
||||
const CYAN = [0.02, 0.78, 0.93] as const;
|
||||
const MAGENTA = [1.0, 0.025, 0.34] as const;
|
||||
|
||||
describe('expressway pylon lamps', () => {
|
||||
it('draws a two-layer lamp: a core and a soft additive halo for every pylon strip', () => {
|
||||
const world = createExpressway(new SceneResourceBag(), renderQualityFor('high', 1, false));
|
||||
const core = children(world.group).find((child) => child.name === 'recessed-pylon-lights')!;
|
||||
const halo = children(world.group).find((child) => child.name === 'pylon-light-halos')!;
|
||||
expect(halo.count).toBe(core.count);
|
||||
expect(halo.material!.blending).toBe(core.material!.blending);
|
||||
expect(halo.material!.fragmentShader).toContain('across');
|
||||
});
|
||||
|
||||
it('keeps the core under the bloom threshold at full drive energy, so the hue survives the tone curve', () => {
|
||||
// Core: hue * (1.3 + energy 0.5) * gain; core + halo peak stays under 0.85 luminance too.
|
||||
for (const hue of [CYAN, MAGENTA]) {
|
||||
const peak = 1.8 * PYLON_CORE_GAIN;
|
||||
expect(luminance(hue.map((v) => v * peak))).toBeLessThan(0.85);
|
||||
expect(luminance(hue.map((v) => v * 1.8 * (PYLON_CORE_GAIN + PYLON_HALO_GAIN)))).toBeLessThan(1.2);
|
||||
}
|
||||
expect(PYLON_HALO_GAIN).toBeLessThan(PYLON_CORE_GAIN);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,15 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SceneResourceBag } from '@/scene/resources';
|
||||
import { BEAM_INTENSITY, createRelayMasts, RUNG_FLASH_GAP_SECONDS, relayLayout } from '@/scene/world/relay-masts';
|
||||
import {
|
||||
BEAM_INTENSITY,
|
||||
createRelayMasts,
|
||||
RUNG_CORE,
|
||||
RUNG_FLASH_GAP_SECONDS,
|
||||
RUNG_HALO,
|
||||
RUNG_HALO_GAIN,
|
||||
relayLayout,
|
||||
} from '@/scene/world/relay-masts';
|
||||
import { publishRelay } from '@/journey/relay-progress';
|
||||
import { readSwitchboard } from '@/audio/music-pulse';
|
||||
import { playRelayTone } from '@/audio/relay-tone';
|
||||
@@ -54,6 +62,35 @@ describe('relay mast layout', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('portrait: one large transmitter carries the rungs of every group, bottom-up in group order', () => {
|
||||
const masts = relayLayout(0.5, counts, false, 390 / 844);
|
||||
expect(masts).toHaveLength(4);
|
||||
expect(masts.filter((mast) => mast.shown)).toHaveLength(1);
|
||||
const [stand] = masts;
|
||||
// One stand, and it is big: well over twice the design mast.
|
||||
masts.forEach((mast) => expect([mast.x, mast.z, mast.scale]).toEqual([stand!.x, stand!.z, stand!.scale]));
|
||||
expect(stand!.height).toBeGreaterThan(2 * relayLayout(0.5, counts)[0]!.height);
|
||||
// On screen, left of the road and inside the frame (portrait lens: no shift).
|
||||
const tanHalfWidth = Math.tan((62 / 2) * (Math.PI / 180)) * (390 / 844);
|
||||
const fx = stand!.x / (tanHalfWidth * (25 - stand!.z));
|
||||
expect(fx).toBeLessThan(-0.2);
|
||||
expect(fx).toBeGreaterThan(-0.7);
|
||||
// Rungs: one per technology, strictly rising through the groups, inside the mast.
|
||||
const ys = masts.flatMap((mast) => mast.rungs.map((rung) => rung.y));
|
||||
expect(ys).toHaveLength(counts.reduce((sum, count) => sum + count, 0));
|
||||
ys.slice(1).forEach((y, i) => expect(y).toBeGreaterThan(ys[i]!));
|
||||
expect(ys.at(-1)!).toBeLessThan(stand!.height);
|
||||
});
|
||||
|
||||
it('keeps lit rung cores under the tone shoulder and bloom threshold, violet not white', () => {
|
||||
const luminance = (c: readonly number[]) => 0.2126 * c[0]! + 0.7152 * c[1]! + 0.0722 * c[2]!;
|
||||
expect(luminance(RUNG_CORE)).toBeLessThan(0.3);
|
||||
expect(luminance(RUNG_CORE.map((v, i) => v + RUNG_HALO[i]! * RUNG_HALO_GAIN))).toBeLessThan(0.85);
|
||||
// Saturated: blue dominates, green is a small fraction (lavender-white would be near-equal channels).
|
||||
expect(RUNG_CORE[1] / RUNG_CORE[2]).toBeLessThan(0.2);
|
||||
expect(RUNG_HALO[1] / RUNG_HALO[2]).toBeLessThan(0.2);
|
||||
});
|
||||
|
||||
it('is absent outside the chapter and fades at its edges', () => {
|
||||
expect(relayLayout(null, counts).every((mast) => mast.presence === 0)).toBe(true);
|
||||
expect(relayLayout(0, counts).every((mast) => mast.presence === 0)).toBe(true);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as THREE from 'three';
|
||||
import { SceneResourceBag } from '@/scene/resources';
|
||||
import { renderQualityFor } from '@/scene/render-quality';
|
||||
import { NO_POST_EXPOSURE_LIFT, renderQualityFor, tierExposure } from '@/scene/render-quality';
|
||||
import { createChaseCamera } from '@/scene/world/environment';
|
||||
import { createCar } from '@/scene/world/car';
|
||||
import { routeLabelFor } from '@/scene/world/sign';
|
||||
@@ -187,6 +187,14 @@ describe('scene render quality', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('lifts exposure only on the tier without bloom or post, and modestly', () => {
|
||||
expect(tierExposure(1.12, renderQualityFor('high', 1, false))).toBe(1.12);
|
||||
expect(tierExposure(1.12, renderQualityFor('medium', 1, false))).toBe(1.12);
|
||||
expect(tierExposure(1.12, renderQualityFor('low', 1, true))).toBeCloseTo(1.12 * NO_POST_EXPOSURE_LIFT, 6);
|
||||
expect(NO_POST_EXPOSURE_LIFT).toBeGreaterThan(1);
|
||||
expect(NO_POST_EXPOSURE_LIFT).toBeLessThanOrEqual(1.25);
|
||||
});
|
||||
|
||||
it('caps mobile and tablet pixel ratio at 1.5 even for High', () => {
|
||||
expect(renderQualityFor('high', 3, true).pixelRatio).toBe(1.5);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
INDICATOR_HZ,
|
||||
TRAFFIC_ASPHALT_HALF_WIDTH,
|
||||
TRAFFIC_BEHIND,
|
||||
TRAFFIC_CONVOY_GAP_MAX,
|
||||
TRAFFIC_LITE_CONVOY_NEAR_Z,
|
||||
TRAFFIC_LITE_DROUGHT_SECONDS,
|
||||
TRAFFIC_LITE_STAGE_SECONDS,
|
||||
TRAFFIC_DENSITY,
|
||||
TRAFFIC_ENTRY_FAR_Z,
|
||||
TRAFFIC_ENTRY_NEAR_Z,
|
||||
@@ -122,6 +126,8 @@ describe('traffic model: long randomised drives', () => {
|
||||
hero += (target - hero) * Math.min(1, dt * 3);
|
||||
if (time >= nextChapter) {
|
||||
step.chapter = CHAPTERS[Math.floor(random() * CHAPTERS.length)]!;
|
||||
// A chapter entry: lite stages a pass (the invariants hold through it).
|
||||
step.cue = true;
|
||||
nextChapter = time + 20 + random() * 40;
|
||||
if (random() < 0.4) sweepUntil = time + 0.5 + random();
|
||||
}
|
||||
@@ -134,6 +140,7 @@ describe('traffic model: long randomised drives', () => {
|
||||
step.cameraX = Math.sin(time * 0.05) * 5.6;
|
||||
step.dt = dt;
|
||||
const f = model.step(step);
|
||||
step.cue = false;
|
||||
const problem = violation(f);
|
||||
if (problem) throw new Error(`seed ${seed} t ${time.toFixed(2)}: ${problem}`);
|
||||
const events = model.events();
|
||||
@@ -489,7 +496,8 @@ describe('traffic model: the interaction is in frame', () => {
|
||||
const f = model.step(step);
|
||||
for (let i = 0; i < f.count; i += 1) {
|
||||
const x = f.active[i] === TRAFFIC_VISIBLE ? f.x[i]! : 9;
|
||||
if (prev[i]! > 3 && x <= 3) entries.push(f.z[i]!);
|
||||
// A pull-out from the right lane (not a pooled slot reappearing in another lane).
|
||||
if (prev[i]! > 3 && prev[i]! !== 9 && x <= 3) entries.push(f.z[i]!);
|
||||
if (Math.abs(prev[i]!) <= 3 && Math.abs(x) > 3 && x !== 9) exits.push(f.z[i]!);
|
||||
prev[i] = x;
|
||||
}
|
||||
@@ -532,8 +540,11 @@ describe('traffic model: the interaction is in frame', () => {
|
||||
seen[i] = 1;
|
||||
if (t === 0) continue;
|
||||
if (f.z[i]! > TRAFFIC_ENTRY_FAR_Z + 1 && f.z[i]! < TRAFFIC_EXIT_NOSE_Z - 1) {
|
||||
// Nearer than the far entry: screen-doored in, still in the haze.
|
||||
expect(f.z[i]!, `vehicle ${i} at ${t.toFixed(1)} s`).toBeLessThan(TRAFFIC_ENTRY_NEAR_Z + 2);
|
||||
// Nearer than the far entry: screen-doored in, at the haze. Lite
|
||||
// queues its convoy car (and stages its truck) a little nearer:
|
||||
// the rear of a car behind a truck at the window's near edge.
|
||||
const liteNear = TRAFFIC_LITE_CONVOY_NEAR_Z + TRAFFIC_KINDS[0]!.length + TRAFFIC_CONVOY_GAP_MAX;
|
||||
expect(f.z[i]!, `vehicle ${i} at ${t.toFixed(1)} s`).toBeLessThan(Math.max(TRAFFIC_ENTRY_NEAR_Z, liteNear) + 2);
|
||||
expect(f.z[i]!).toBeGreaterThan(TRAFFIC_ENTRY_NEAR_Z - TRAFFIC_ENTRY_NEAR_SPAN - 50);
|
||||
expect(f.dither[i]!).toBeGreaterThan(0.9);
|
||||
fading[i] = t;
|
||||
@@ -614,3 +625,79 @@ describe('flash detector', () => {
|
||||
expect(fired.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('traffic model: lite passes (phones)', () => {
|
||||
const LIVE: EnvironmentChapter[] = ['deep-night', 'systems', 'infrastructure', 'builds', 'pre-dawn'];
|
||||
|
||||
it('stages a pass at each chapter entry: a car pulls out within 30 s, 20 s on average', () => {
|
||||
// A truck staged at the haze takes ~15 s to reach the 150-215 m entry band.
|
||||
const waits: number[] = [];
|
||||
for (const seed of [1, 5, 8, 13, 21]) {
|
||||
const model = createTrafficModel({ pool: 6, seed, lite: true });
|
||||
const step = input();
|
||||
for (let t = 0; t < 5; t += 1 / 60) model.step(step);
|
||||
for (let entry = 0; entry < 8; entry += 1) {
|
||||
step.chapter = LIVE[(entry + seed) % LIVE.length]!;
|
||||
step.cue = true;
|
||||
const before = model.events().laneChanges;
|
||||
let waited = 0;
|
||||
while (model.events().laneChanges === before && waited < 40) {
|
||||
const f = model.step(step);
|
||||
step.cue = false;
|
||||
waited += 1 / 60;
|
||||
const problem = violation(f);
|
||||
if (problem) throw new Error(`seed ${seed} entry ${entry}: ${problem}`);
|
||||
}
|
||||
expect(waited, `seed ${seed} entry ${entry} (${step.chapter})`).toBeLessThan(30);
|
||||
waits.push(waited);
|
||||
// Read the chapter for a while before the next entry.
|
||||
for (let t = 0; t < 12; t += 1 / 60) model.step(step);
|
||||
}
|
||||
}
|
||||
expect(waits.reduce((a, b) => a + b, 0) / waits.length).toBeLessThan(20);
|
||||
expect(TRAFFIC_LITE_STAGE_SECONDS).toBeGreaterThanOrEqual(15);
|
||||
});
|
||||
|
||||
it('never goes long without a pass at a steady cruise (the drought rule)', () => {
|
||||
for (const seed of [2, 5, 8, 17, 3, 11, 29, 42]) {
|
||||
const model = createTrafficModel({ pool: 6, seed, lite: true });
|
||||
const step = input({ chapter: 'systems' });
|
||||
let last = 0;
|
||||
let longest = 0;
|
||||
let changes = 0;
|
||||
for (let t = 0; t < 600; t += 1 / 60) {
|
||||
const f = model.step(step);
|
||||
const problem = violation(f);
|
||||
if (problem) throw new Error(`seed ${seed} t ${t.toFixed(1)}: ${problem}`);
|
||||
if (model.events().laneChanges !== changes) {
|
||||
changes = model.events().laneChanges;
|
||||
longest = Math.max(longest, t - last);
|
||||
|
||||
last = t;
|
||||
}
|
||||
}
|
||||
longest = Math.max(longest, 600 - last);
|
||||
// Lane changes count the return too: drought, then ~15 s for a staged truck to reach the band, then the pass.
|
||||
// (Without staging this runs 82-88 s on these seeds.)
|
||||
expect(longest, `seed ${seed}`).toBeLessThan(55);
|
||||
}
|
||||
expect(TRAFFIC_LITE_DROUGHT_SECONDS).toBeLessThanOrEqual(15);
|
||||
});
|
||||
|
||||
it('staging stays inside the low-tier caps and never with the engine off', () => {
|
||||
const model = createTrafficModel({ pool: 6, seed: 6, lite: true });
|
||||
const step = input({ chapter: 'infrastructure' });
|
||||
let maxRight = 0;
|
||||
for (let t = 0; t < 300; t += 1 / 60) {
|
||||
step.cue = Math.floor(t * 60) % 600 === 0;
|
||||
const f = model.step(step);
|
||||
let right = 0;
|
||||
for (let i = 0; i < f.count; i += 1) if (f.active[i] === TRAFFIC_VISIBLE && f.x[i]! > 0) right += 1;
|
||||
maxRight = Math.max(maxRight, right);
|
||||
}
|
||||
expect(maxRight).toBeLessThanOrEqual(5);
|
||||
const parked = createTrafficModel({ pool: 6, seed: 6, lite: true });
|
||||
for (let t = 0; t < 60; t += 1 / 60) parked.step(input({ chapter: 'destination', engineOff: 1, heroSpeed: 0, cue: t < 0.02 }));
|
||||
expect(parked.events().laneChanges).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
FLARE_GAIN,
|
||||
FLARE_SWELL,
|
||||
flareReach,
|
||||
HERO_CAR_HALF_WIDTH,
|
||||
LAMPS_PER_VEHICLE,
|
||||
TRAFFIC_BODY,
|
||||
@@ -107,3 +112,25 @@ describe('expressway traffic stays clear of the hero car', () => {
|
||||
expect(trafficPoolSize({ postEffects: false, postScale: 1 })).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the flash to pass', () => {
|
||||
it('peaks between 120 and 250 m, not within 100 m, and is gone past 420 m', () => {
|
||||
const peak = Math.max(...Array.from({ length: 500 }, (_, d) => flareReach(d)));
|
||||
expect(peak).toBe(1);
|
||||
for (let d = 125; d <= 250; d += 5) expect(flareReach(d)).toBeGreaterThan(0.97);
|
||||
expect(flareReach(120)).toBeGreaterThan(0.9);
|
||||
for (let d = 0; d <= 100; d += 2) expect(flareReach(d)).toBeLessThan(0.35);
|
||||
for (let d = 0; d <= 60; d += 2) expect(flareReach(d)).toBeLessThanOrEqual(0.15);
|
||||
expect(flareReach(420)).toBe(0);
|
||||
});
|
||||
|
||||
it('flares lamps in their own red and amber, only a little brighter', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/scene/world/traffic.ts'), 'utf8');
|
||||
// No warm-white mix toward the headlight colour.
|
||||
expect(source).not.toContain('vec3(1.0, 0.72, 0.58)');
|
||||
expect(source).toContain('vGain * (1.0 + ${FLARE_GAIN.toFixed(2)} * vFlare)');
|
||||
// At full lift (vFlare <= 0.7 + 1.6), a lamp gains at most ~0.8x of its own level.
|
||||
expect(FLARE_GAIN * (0.7 + 1.6)).toBeLessThan(0.85);
|
||||
expect(FLARE_SWELL).toBeLessThanOrEqual(0.3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as THREE from 'three';
|
||||
import { SceneResourceBag } from '@/scene/resources';
|
||||
import { createYard, railYaw } from '@/scene/world/yard';
|
||||
import { STACK_TIERS, STACK_ZS, YARD_STOPS, yardStopsPassed } from '@/journey/yard-progress';
|
||||
@@ -21,7 +22,7 @@ const nodes = (group: unknown): Node[] => {
|
||||
const instancedMeshes = (group: unknown) => nodes(group).filter((child) => child.isInstancedMesh === true);
|
||||
|
||||
describe('night freight yard', () => {
|
||||
it('draws the stacks as one InstancedMesh of 4 + 3 + 2 + 1 containers', () => {
|
||||
it('draws the stacks as one InstancedMesh of 3 + 3 + 2 + 1 containers', () => {
|
||||
const yard = createYard(new SceneResourceBag(), true);
|
||||
const stacks = instancedMeshes(yard.group).filter((mesh) => mesh.name === 'yard-stacks');
|
||||
expect(stacks).toHaveLength(1);
|
||||
@@ -97,6 +98,51 @@ describe('night freight yard', () => {
|
||||
expect(yawAt(Number.NaN)).toBeCloseTo(railYaw(), 6);
|
||||
});
|
||||
|
||||
it('aims the rails from the live lens, so the yard holds its screen x while the camera eases', () => {
|
||||
const yard = createYard(new SceneResourceBag(), true);
|
||||
const group = yard.group as unknown as InstanceType<typeof THREE.Group>;
|
||||
const camera = new THREE.PerspectiveCamera(61, 1440 / 900, 0.1, 2000);
|
||||
const railScreenX = (s: number) => {
|
||||
group.updateMatrixWorld(true);
|
||||
return new THREE.Vector3(0, 5, s).applyMatrix4(group.matrixWorld).project(camera).x;
|
||||
};
|
||||
// Mid-ease lenses: position, aim, fov and view offset all off their settled values.
|
||||
const lenses = [
|
||||
{ x: 6, z: 40, fov: 48, shift: 0.1 },
|
||||
{ x: 0, z: 30, fov: 55, shift: -0.02 },
|
||||
{ x: -3.2, z: 24, fov: 61, shift: -0.12 },
|
||||
];
|
||||
for (const lens of lenses) {
|
||||
camera.fov = lens.fov;
|
||||
camera.setViewOffset(1440, 900, -1440 * lens.shift, 0, 1440, 900);
|
||||
camera.position.set(lens.x, 30, lens.z);
|
||||
camera.lookAt(0, 18, -220);
|
||||
yard.update('additional-work', 0.5, 0, 1 / 60, null, camera.aspect, camera);
|
||||
for (const s of [-150, -110, -70]) expect(railScreenX(s)).toBeCloseTo(-0.86, 1);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every container square to the road at any rail yaw', () => {
|
||||
const yard = createYard(new SceneResourceBag(), true);
|
||||
const stacks = instancedMeshes(yard.group).find((mesh) => mesh.name === 'yard-stacks') as unknown as InstanceType<typeof THREE.InstancedMesh>;
|
||||
const group = yard.group as unknown as InstanceType<typeof THREE.Group>;
|
||||
const local = new THREE.Matrix4();
|
||||
const world = new THREE.Matrix4();
|
||||
const axis = new THREE.Vector3();
|
||||
for (const aspect of [1920 / 1080, 1440 / 900, 1024 / 700]) {
|
||||
yard.update('additional-work', 0.5, 0, 1 / 60, null, aspect);
|
||||
group.updateMatrixWorld(true);
|
||||
for (let i = 0; i < stacks.count; i += 1) {
|
||||
stacks.getMatrixAt(i, local);
|
||||
world.multiplyMatrices(group.matrixWorld, local);
|
||||
// The box's long axis (local z) runs down the road: no more than the
|
||||
// bottom tier's hair of slop (0.75 degrees) off world z.
|
||||
axis.set(0, 0, 1).transformDirection(world);
|
||||
expect(Math.abs(Math.atan2(axis.x, axis.z))).toBeLessThan(0.013);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('frees every geometry and material on dispose', () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const yard = createYard(resources, true);
|
||||
|
||||
Reference in New Issue
Block a user