feat: sign interlude, window plate and a two-button control bay
The sign closes in over the road on a gantry truss, arcs into a wider window and hands off to a flat 2D plate that mirrors its face; content steps aside for the interlude and rises after. The bay keeps sound and settings; volume and Pause motion move into the settings popover.
This commit is contained in:
@@ -154,6 +154,7 @@ export async function createProceduralSceneGeneration(
|
||||
car.group,
|
||||
speedWake.group,
|
||||
sign.group,
|
||||
sign.truss,
|
||||
);
|
||||
|
||||
const postProcessor = quality.postEffects
|
||||
@@ -192,6 +193,8 @@ export async function createProceduralSceneGeneration(
|
||||
// The outro mirrors the intro: on arrival the camera cranes back up toward
|
||||
// the sun on a long lens. Leaving the destination reverses it, faster.
|
||||
let outroProgress = 0;
|
||||
let interludeOn = false;
|
||||
let riseUntil = 0;
|
||||
let gantryState = '';
|
||||
|
||||
const resize = (): void => {
|
||||
@@ -373,7 +376,8 @@ export async function createProceduralSceneGeneration(
|
||||
car.group.position,
|
||||
width < 1100 || height < 620 ? 2.32 : 2.78,
|
||||
);
|
||||
sign.update(snapshot.sign, now);
|
||||
sign.setResting(snapshot.activeSectionId);
|
||||
sign.update(snapshot.sign, now, snapshot.driveSpeed);
|
||||
atmosphere.update(
|
||||
driveDistance,
|
||||
visualSpeed,
|
||||
@@ -568,6 +572,18 @@ export async function createProceduralSceneGeneration(
|
||||
gantryState = gantry;
|
||||
document.documentElement.dataset.gantry = gantry;
|
||||
}
|
||||
// The interlude: the reading plates step aside while the sign has the
|
||||
// road to itself (CSS keys off this attribute).
|
||||
// 'on' while the sign approaches, then 'rise' while the plates return.
|
||||
const interlude = sign.interlude();
|
||||
if (interlude !== interludeOn) {
|
||||
interludeOn = interlude;
|
||||
riseUntil = interlude ? 0 : now + INTERLUDE_RISE_MS;
|
||||
document.documentElement.dataset.interlude = interlude ? 'on' : 'rise';
|
||||
} else if (riseUntil > 0 && now > riseUntil) {
|
||||
riseUntil = 0;
|
||||
delete document.documentElement.dataset.interlude;
|
||||
}
|
||||
if (TEST_BUILD && snapshot.sign !== null) {
|
||||
const presentation = sign.inspect(camera, width, height);
|
||||
if (presentation !== null) {
|
||||
@@ -603,6 +619,7 @@ export async function createProceduralSceneGeneration(
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
delete document.documentElement.dataset.gantry;
|
||||
delete document.documentElement.dataset.interlude;
|
||||
// Quality changes reuse the canvas context. Leave texture upload flags at
|
||||
// defaults before the next renderer creates its empty array textures.
|
||||
const gl = renderer.getContext();
|
||||
@@ -617,6 +634,8 @@ const INTRO_SECONDS = 4.2;
|
||||
const OUTRO_SECONDS = 5.5;
|
||||
const OUTRO_RETURN_SECONDS = 1.6;
|
||||
const OUTRO_REACH = 0.62;
|
||||
/** Plates rise back over 720 ms with up to three 90 ms staggers. */
|
||||
const INTERLUDE_RISE_MS = 1100;
|
||||
|
||||
function easeInOutCubic(t: number): number {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
|
||||
@@ -100,10 +100,12 @@ export function createSignStage(resources: SceneResourceBag) {
|
||||
const rect = windowElement.getBoundingClientRect();
|
||||
tilt.x = Math.max(-1, Math.min(1, ((event.clientX - rect.left) / rect.width) * 2 - 1));
|
||||
tilt.y = Math.max(-1, Math.min(1, ((event.clientY - rect.top) / rect.height) * 2 - 1));
|
||||
if (event.pointerType === 'mouse') tilt.hover = true;
|
||||
}, { passive: true });
|
||||
resources.listen(windowElement, 'pointerleave', () => {
|
||||
tilt.x = 0;
|
||||
tilt.y = 0;
|
||||
tilt.hover = false;
|
||||
}, { passive: true });
|
||||
}
|
||||
const rect = windowElement?.getBoundingClientRect();
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as THREE from 'three';
|
||||
import type { SectionId } from '@/content/types';
|
||||
import type { SignSnapshot } from '@/journey/types';
|
||||
import {
|
||||
SIGN_ACCENTS,
|
||||
signRouteFor,
|
||||
signViewportProfileFor,
|
||||
type SignCorridor,
|
||||
@@ -25,10 +26,13 @@ export interface SignDock {
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Pointer offset over the docked sign, -1..1 on each axis; 0 at rest. */
|
||||
/** Pointer offset over the docked sign, -1..1 on each axis; 0 at rest.
|
||||
* `hover` is true while the pointer is over the window: the 3D twin of the
|
||||
* current exit plate then shows, tilted toward it. */
|
||||
export interface SignTilt {
|
||||
x: number;
|
||||
y: number;
|
||||
hover?: boolean;
|
||||
}
|
||||
|
||||
/** What the gantry shows: its own 2D read-out, an incoming sign, the
|
||||
@@ -37,12 +41,22 @@ export type GantryState = 'idle' | 'incoming' | 'docked' | 'passing';
|
||||
|
||||
const REST_TILT: SignTilt = { x: 0, y: 0 };
|
||||
const FACE_Z = 0.54;
|
||||
/** Hover tilt, about 4 degrees at the window's edge. */
|
||||
const TILT_RADIANS = 0.07;
|
||||
/** The docked 3D face crossfades into the window's flat 2D plate. */
|
||||
const HANDOFF_MS = 200;
|
||||
/** Above this scroll drive speed a passive crossing skips the interlude. */
|
||||
const BRISK_DRIVE_SPEED = 0.5;
|
||||
/** Share of the approach spent on the truss before the arc into the window. */
|
||||
const ON_TRUSS = 0.7;
|
||||
|
||||
const SIGN_MARKER_FONT =
|
||||
'600 60px "Geist Mono Variable", ui-monospace, monospace';
|
||||
const SIGN_LABEL_FONT = '650 180px "Geist Variable", sans-serif';
|
||||
export interface SignWorld {
|
||||
group: SignGroup;
|
||||
/** The overhead truss the sign hangs from on its approach. */
|
||||
truss: unknown;
|
||||
inspect(
|
||||
camera: ProjectingCamera,
|
||||
width: number,
|
||||
@@ -57,9 +71,13 @@ export interface SignWorld {
|
||||
tilt?: SignTilt,
|
||||
): void;
|
||||
dockState(): GantryState;
|
||||
/** True while a full interlude runs: the reading plates make way for it. */
|
||||
interlude(): boolean;
|
||||
setViewportAspect(aspect: number): void;
|
||||
setViewportSize(width: number, height: number): void;
|
||||
update(sign: SignSnapshot | null, now?: number): void;
|
||||
/** The exit the window rests on between signs (its hover twin). */
|
||||
setResting(id: SectionId): void;
|
||||
update(sign: SignSnapshot | null, now?: number, driveSpeed?: number): void;
|
||||
}
|
||||
|
||||
export interface SignPresentation {
|
||||
@@ -385,6 +403,23 @@ export function createSign(
|
||||
let tiltX = 0;
|
||||
let tiltY = 0;
|
||||
let state: GantryState = 'idle';
|
||||
let brisk = false;
|
||||
let dwellStartedAt: number | null = null;
|
||||
let hoverLevel = 0;
|
||||
let trussHeld = 0;
|
||||
let resting: SectionId = 'start';
|
||||
const truss = createTruss(resources);
|
||||
const drawTarget = (id: SectionId): void => {
|
||||
if (target === id) return;
|
||||
target = id;
|
||||
face.userData.textureUpdateCount += 1;
|
||||
face.userData.fontReadyWhenDrawn =
|
||||
document.fonts?.check(SIGN_LABEL_FONT) ?? true;
|
||||
edgeMaterial.emissive.set(SIGN_ACCENTS[id]);
|
||||
if (context)
|
||||
drawLabel(context, signRouteFor(id), canvas.width, canvas.height);
|
||||
texture.needsUpdate = true;
|
||||
};
|
||||
const point = new THREE.Vector3();
|
||||
const ray = new THREE.Vector3();
|
||||
const forward = new THREE.Vector3();
|
||||
@@ -417,6 +452,7 @@ export function createSign(
|
||||
}
|
||||
return {
|
||||
group,
|
||||
truss: truss.group,
|
||||
inspect(camera, width, height) {
|
||||
if (target === null) return null;
|
||||
group.updateMatrixWorld(true);
|
||||
@@ -463,41 +499,40 @@ export function createSign(
|
||||
viewportHeight = Math.max(1, height);
|
||||
viewport = signViewportProfileFor(width, height);
|
||||
},
|
||||
update(sign) {
|
||||
update(sign, now = 0, driveSpeed = 0) {
|
||||
// A new latch: decide once whether this sign gets the full interlude.
|
||||
if (sign && (snapshot === null || snapshot.targetId !== sign.targetId)) {
|
||||
brisk = sign.navigationArrival !== true && driveSpeed > BRISK_DRIVE_SPEED;
|
||||
dwellStartedAt = null;
|
||||
}
|
||||
snapshot = sign;
|
||||
group.userData.phase = sign?.phase ?? 'inactive';
|
||||
group.userData.target = sign?.targetId ?? null;
|
||||
if (sign?.phase === 'dwell' && dwellStartedAt === null) dwellStartedAt = now;
|
||||
if (!sign) {
|
||||
group.visible = false;
|
||||
initialized = false;
|
||||
state = 'idle';
|
||||
truss.hide();
|
||||
drawTarget(resting);
|
||||
return;
|
||||
}
|
||||
if (target !== sign.targetId) {
|
||||
target = sign.targetId;
|
||||
face.userData.textureUpdateCount += 1;
|
||||
face.userData.fontReadyWhenDrawn =
|
||||
document.fonts?.check(SIGN_LABEL_FONT) ?? true;
|
||||
edgeMaterial.emissive.set(SIGN_ACCENTS[sign.targetId]);
|
||||
if (context)
|
||||
drawLabel(
|
||||
context,
|
||||
signRouteFor(sign.targetId),
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
texture.needsUpdate = true;
|
||||
initialized = false;
|
||||
}
|
||||
drawTarget(sign.targetId);
|
||||
},
|
||||
setResting(id) {
|
||||
resting = id;
|
||||
},
|
||||
dockState() {
|
||||
return state;
|
||||
},
|
||||
interlude() {
|
||||
return !brisk && state === 'incoming';
|
||||
},
|
||||
compose(camera, width, height, dock, now, tilt = REST_TILT) {
|
||||
if (!snapshot) return;
|
||||
const dt = Math.min(0.05, Math.max(0, (now - previousNow) / 1000));
|
||||
previousNow = now;
|
||||
// No window on the narrowest screens: the gantry shows no sign there.
|
||||
if (dock.width < 40 || dock.height < 10) {
|
||||
group.visible = false;
|
||||
truss.hide();
|
||||
state = 'idle';
|
||||
return;
|
||||
}
|
||||
@@ -509,82 +544,120 @@ export function createSign(
|
||||
.transformDirection(
|
||||
(camera as ProjectingCamera & { matrixWorld: unknown }).matrixWorld,
|
||||
);
|
||||
const route = signRouteFor(snapshot.targetId);
|
||||
activeCorridor = route.corridor ?? 'right';
|
||||
const side = activeCorridor.includes('left') ? -1 : 1;
|
||||
// The face fills the gantry window; its housing tucks behind the beam.
|
||||
const targetX = dock.left + dock.width / 2;
|
||||
const targetY = dock.top + dock.height / 2;
|
||||
const targetWidth = Math.max(40, Math.min(dock.width, dock.height * 4));
|
||||
const dt = Math.min(0.05, Math.max(0, (now - previousNow) / 1000));
|
||||
previousNow = now;
|
||||
const mix = initialized ? 1 - Math.exp(-dt / 0.18) : 1;
|
||||
screenX += (targetX - screenX) * mix;
|
||||
screenY += (targetY - screenY) * mix;
|
||||
screenWidth += (targetWidth - screenWidth) * mix;
|
||||
initialized = true;
|
||||
const p = smoothstep(clamp01(snapshot.progress));
|
||||
const hovering = tilt.hover === true;
|
||||
const hoverMix = 1 - Math.exp(-dt / 0.08);
|
||||
hoverLevel += ((hovering ? 1 : 0) - hoverLevel) * hoverMix;
|
||||
|
||||
const phase = snapshot?.phase ?? 'rest';
|
||||
const raw = clamp01(snapshot?.progress ?? 1);
|
||||
const arrival =
|
||||
snapshot.phase === 'anticipate'
|
||||
? 0
|
||||
: snapshot.phase === 'approach'
|
||||
? p
|
||||
: 1;
|
||||
// Lift-off: the sign rises off the horizon on its side of the road and
|
||||
// curves up into the window, growing as it closes in.
|
||||
const lateral = 1 - (1 - arrival) * (1 - arrival);
|
||||
const closeDepth = 18;
|
||||
let depth = THREE.MathUtils.lerp(190, closeDepth, arrival);
|
||||
let cx = THREE.MathUtils.lerp(width * (0.5 + side * 0.07), screenX, lateral);
|
||||
let cy = THREE.MathUtils.lerp(height * 0.44, screenY, arrival);
|
||||
let projectedWidth =
|
||||
screenWidth * THREE.MathUtils.lerp(0.14, 1, arrival * arrival);
|
||||
let pitch = 0;
|
||||
if (snapshot.phase === 'depart') {
|
||||
// Passing under the gantry: up and out of the top of the frame,
|
||||
// seen more and more from below.
|
||||
depth = THREE.MathUtils.lerp(closeDepth, 9, p);
|
||||
cy = screenY - p * p * (screenY + dock.height * 1.6);
|
||||
projectedWidth *= 1 + p * 0.45;
|
||||
pitch = -p * 0.85;
|
||||
phase === 'anticipate' ? 0 : phase === 'approach' && !brisk ? raw : 1;
|
||||
// Full scale over the road, in the upper third: about 600 px wide.
|
||||
const bigWidth = Math.min(620, width * (width < 768 ? 0.82 : 0.42));
|
||||
const bigY = height * 0.3;
|
||||
let cx = screenX;
|
||||
let cy = screenY;
|
||||
let projectedWidth = screenWidth;
|
||||
let depth = 18;
|
||||
let trussOpacity = 0;
|
||||
let trussDepth = 0;
|
||||
let trussY = 0;
|
||||
if (snapshot && (phase === 'anticipate' || phase === 'approach') && !brisk) {
|
||||
if (arrival < ON_TRUSS) {
|
||||
// Hanging from the truss, closing in down the road.
|
||||
const t = arrival / ON_TRUSS;
|
||||
const near = 1 - (1 - t) * (1 - t);
|
||||
depth = THREE.MathUtils.lerp(150, 46, near);
|
||||
cx = width / 2;
|
||||
cy = bigY;
|
||||
projectedWidth = bigWidth * THREE.MathUtils.lerp(0.32, 1, near);
|
||||
trussOpacity = smoothstep(Math.min(1, t * 4));
|
||||
} else {
|
||||
// The handoff: the sign lifts off the truss and arcs up into the
|
||||
// window, shrinking with scale continuity to the window's size.
|
||||
const q = (arrival - ON_TRUSS) / (1 - ON_TRUSS);
|
||||
const e = 1 - Math.pow(1 - q, 3);
|
||||
depth = THREE.MathUtils.lerp(46, 18, e);
|
||||
cx = THREE.MathUtils.lerp(width / 2, screenX, e);
|
||||
// Up first, then across: an arc, not a straight cut.
|
||||
cy = THREE.MathUtils.lerp(bigY, screenY, Math.sqrt(e));
|
||||
projectedWidth = THREE.MathUtils.lerp(bigWidth, screenWidth, e);
|
||||
trussOpacity = 1;
|
||||
}
|
||||
// The truss carries on toward the camera and passes overhead exactly
|
||||
// as the sign docks.
|
||||
trussDepth = arrival < ON_TRUSS
|
||||
? depth
|
||||
: THREE.MathUtils.lerp(46, -6, (arrival - ON_TRUSS) / (1 - ON_TRUSS));
|
||||
}
|
||||
|
||||
const docked = phase === 'dwell' || phase === 'rest' || (brisk && phase === 'approach');
|
||||
const tiltMix = 1 - Math.exp(-dt / 0.16);
|
||||
const docked = snapshot.phase === 'dwell' ? 1 : 0;
|
||||
tiltX += (tilt.x * docked - tiltX) * (initialized ? tiltMix : 1);
|
||||
tiltY += (tilt.y * docked - tiltY) * (initialized ? tiltMix : 1);
|
||||
const tiltOn = docked ? hoverLevel : 0;
|
||||
tiltX += (tilt.x * tiltOn - tiltX) * tiltMix;
|
||||
tiltY += (tilt.y * tiltOn - tiltY) * tiltMix;
|
||||
|
||||
worldPoint(point, cx, cy, depth, camera);
|
||||
worldPoint(worldLeft, cx - projectedWidth / 2, cy, depth, camera);
|
||||
worldPoint(worldRight, cx + projectedWidth / 2, cy, depth, camera);
|
||||
const scale = worldRight.distanceTo(worldLeft) / 8;
|
||||
// The face sits FACE_Z in front of the group origin: set the origin
|
||||
// back by that much so the face itself lands on the target plane.
|
||||
if (trussOpacity > 0) {
|
||||
// The bottom chord sits just above the sign's top edge.
|
||||
if (arrival < ON_TRUSS) trussY = point.y + scale * 1.25;
|
||||
else trussY = trussHeld;
|
||||
trussHeld = trussY;
|
||||
const camera3 = camera as ProjectingCamera;
|
||||
truss.place(trussY, camera3.position.z - Math.max(-8, trussDepth) * Math.max(0.2, -forward.z), trussOpacity);
|
||||
} else {
|
||||
truss.hide();
|
||||
}
|
||||
point.addScaledVector(forward, FACE_Z * scale);
|
||||
group.position.set(point.x, point.y, point.z);
|
||||
group.scale.setScalar(scale);
|
||||
// Parallel to the image plane, so the docked face fits the window
|
||||
// exactly; hover turns it toward the pointer.
|
||||
group.quaternion.copy(camera.quaternion);
|
||||
group.quaternion.multiply(turn.setFromAxisAngle(yAxis, tiltX * 0.3));
|
||||
group.quaternion.multiply(turn.setFromAxisAngle(xAxis, pitch - tiltY * 0.24));
|
||||
const opacity = clamp01(snapshot.opacity);
|
||||
group.quaternion.multiply(turn.setFromAxisAngle(yAxis, tiltX * TILT_RADIANS));
|
||||
group.quaternion.multiply(turn.setFromAxisAngle(xAxis, -tiltY * TILT_RADIANS));
|
||||
|
||||
// Visibility of the 3D face: the approach fades it in; once docked it
|
||||
// hands off to the window's 2D plate unless the pointer asks for it.
|
||||
let opacity: number;
|
||||
if (!snapshot) {
|
||||
opacity = hoverLevel;
|
||||
} else if (phase === 'anticipate') {
|
||||
opacity = 0;
|
||||
} else if (phase === 'approach') {
|
||||
opacity = brisk ? smoothstep(raw) : smoothstep(Math.min(1, raw * 3));
|
||||
} else if (phase === 'dwell') {
|
||||
const since = dwellStartedAt === null ? 0 : now - dwellStartedAt;
|
||||
const handoff = 1 - clamp01(since / HANDOFF_MS);
|
||||
opacity = Math.max(handoff, hoverLevel);
|
||||
} else {
|
||||
opacity = hoverLevel * clamp01(snapshot.opacity);
|
||||
}
|
||||
for (const [mat, weight] of fadeMaterials) mat.opacity = opacity * weight;
|
||||
// The frame catches light first; one moving highlight resolves into a quiet face.
|
||||
faceMaterial.opacity = opacity * smoothstep(Math.min(1, arrival * 1.8));
|
||||
faceMaterial.opacity = opacity;
|
||||
edgeMaterial.emissiveIntensity =
|
||||
0.8 + Math.sin(arrival * Math.PI) * 1.4 + Math.hypot(tiltX, tiltY) * 0.6;
|
||||
0.8 + Math.sin(arrival * Math.PI) * 1.4 + hoverLevel * 0.6;
|
||||
sweepMaterial.uniforms.uSweep.value = arrival * 1.5 - 0.2;
|
||||
sweepMaterial.uniforms.uOpacity.value =
|
||||
snapshot.phase === 'approach' ? opacity * 0.14 : 0;
|
||||
group.visible = opacity > 0;
|
||||
phase === 'approach' && !brisk ? opacity * 0.14 : 0;
|
||||
group.visible = opacity > 0.002;
|
||||
group.userData.anchoring = 'gantry';
|
||||
state =
|
||||
opacity <= 0
|
||||
? 'idle'
|
||||
: snapshot.phase === 'depart'
|
||||
? 'passing'
|
||||
: arrival >= 0.82
|
||||
? 'docked'
|
||||
: 'incoming';
|
||||
state = !snapshot
|
||||
? 'idle'
|
||||
: phase === 'depart'
|
||||
? 'passing'
|
||||
: phase === 'dwell' || (phase === 'approach' && (brisk || arrival >= 0.97))
|
||||
? 'docked'
|
||||
: 'incoming';
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -628,16 +701,6 @@ function createClippedShape(width: number, height: number, cut: number) {
|
||||
}
|
||||
|
||||
/** Chapter accents, matching the CSS --chapter-accent of each panel. */
|
||||
const SIGN_ACCENTS: Readonly<Record<SectionId, string>> = {
|
||||
start: '#ff83c2',
|
||||
profile: '#ff83c2',
|
||||
experience: '#c4a2ff',
|
||||
infrastructure: '#8be9ed',
|
||||
'selected-builds': '#ffa68d',
|
||||
'additional-work': '#ff83c2',
|
||||
toolkit: '#c4a2ff',
|
||||
contact: '#ffa68d',
|
||||
};
|
||||
|
||||
export function signAccentFor(id: SectionId): string {
|
||||
return SIGN_ACCENTS[id];
|
||||
@@ -754,3 +817,79 @@ function clamp01(value: number): number {
|
||||
function smoothstep(value: number): number {
|
||||
return value * value * (3 - 2 * value);
|
||||
}
|
||||
|
||||
const TRUSS_HALF_SPAN = 17;
|
||||
const TRUSS_DEPTH = 1.5;
|
||||
const TRUSS_PANELS = 12;
|
||||
|
||||
/** One instanced, untextured truss: top and bottom chords, verticals,
|
||||
* diagonals and two legs. One draw call. The legs are resized every frame
|
||||
* so they always reach the road. */
|
||||
function createTruss(resources: SceneResourceBag) {
|
||||
const material = resources.own(
|
||||
'material',
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x1c1530,
|
||||
emissive: 0x24102c,
|
||||
emissiveIntensity: 0.5,
|
||||
metalness: 0.72,
|
||||
roughness: 0.42,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}),
|
||||
);
|
||||
const box = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1));
|
||||
const panel = (TRUSS_HALF_SPAN * 2) / TRUSS_PANELS;
|
||||
const members: Array<[number, number, number, number, number, number]> = [];
|
||||
// [x, y, length, thickness, rotationZ, isLeg]
|
||||
members.push([0, TRUSS_DEPTH, TRUSS_HALF_SPAN * 2 + 0.4, 0.34, 0, 0]);
|
||||
members.push([0, 0, TRUSS_HALF_SPAN * 2 + 0.4, 0.34, 0, 0]);
|
||||
for (let i = 0; i <= TRUSS_PANELS; i++) {
|
||||
const x = -TRUSS_HALF_SPAN + i * panel;
|
||||
members.push([x, TRUSS_DEPTH / 2, TRUSS_DEPTH, 0.16, Math.PI / 2, 0]);
|
||||
if (i < TRUSS_PANELS) {
|
||||
const angle = Math.atan2(TRUSS_DEPTH, panel) * (i % 2 === 0 ? 1 : -1);
|
||||
members.push([x + panel / 2, TRUSS_DEPTH / 2, Math.hypot(panel, TRUSS_DEPTH), 0.12, angle, 0]);
|
||||
}
|
||||
}
|
||||
members.push([-TRUSS_HALF_SPAN, 0, 1, 0.5, Math.PI / 2, 1]);
|
||||
members.push([TRUSS_HALF_SPAN, 0, 1, 0.5, Math.PI / 2, 1]);
|
||||
const mesh = new THREE.InstancedMesh(box, material, members.length);
|
||||
mesh.name = 'gantry-truss';
|
||||
mesh.frustumCulled = false;
|
||||
const group = new THREE.Group();
|
||||
group.name = 'gantry-truss-group';
|
||||
group.visible = false;
|
||||
group.add(mesh);
|
||||
const dummy = new THREE.Object3D();
|
||||
const write = (legHeight: number) => {
|
||||
members.forEach(([x, y, length, thickness, rotation, leg], index) => {
|
||||
const height = leg ? Math.max(1, legHeight) : length;
|
||||
dummy.position.set(x, leg ? -height / 2 : y, 0);
|
||||
dummy.rotation.set(0, 0, rotation);
|
||||
dummy.scale.set(leg ? height : length, thickness, leg ? thickness : 0.34);
|
||||
dummy.updateMatrix();
|
||||
mesh.setMatrixAt(index, dummy.matrix);
|
||||
});
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
};
|
||||
write(10);
|
||||
let lastLeg = 10;
|
||||
return {
|
||||
group,
|
||||
material,
|
||||
/** Places the truss: world y of its bottom chord and world z. */
|
||||
place(y: number, z: number, opacity: number) {
|
||||
group.position.set(0, y, z);
|
||||
if (Math.abs(y - lastLeg) > 0.05) {
|
||||
lastLeg = y;
|
||||
write(y);
|
||||
}
|
||||
material.opacity = opacity;
|
||||
group.visible = opacity > 0.001;
|
||||
},
|
||||
hide() {
|
||||
group.visible = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user