781 lines
23 KiB
TypeScript
781 lines
23 KiB
TypeScript
import * as THREE from 'three';
|
|
import type { SectionId } from '@/content/types';
|
|
import type { SignSnapshot } from '@/journey/types';
|
|
import {
|
|
signRouteFor,
|
|
signViewportProfileFor,
|
|
type SignCorridor,
|
|
type SignRouteSpec,
|
|
type SignViewportProfile,
|
|
} from '@/journey/sign-routes';
|
|
import type { SceneResourceBag } from '@/scene/resources';
|
|
import { roadsideSignMetrics, usesRoadsideSign } from './sign-layout';
|
|
|
|
interface ProjectingCamera {
|
|
position: { x: number; y: number; z: number };
|
|
rotation: { y: number };
|
|
updateMatrixWorld(force: boolean): void;
|
|
}
|
|
|
|
const SIGN_MARKER_FONT =
|
|
'560 46px "Geist Mono Variable", ui-monospace, monospace';
|
|
const SIGN_LABEL_FONT = '650 180px "Geist Variable", sans-serif';
|
|
export interface SignWorld {
|
|
group: SignGroup;
|
|
inspect(
|
|
camera: ProjectingCamera,
|
|
width: number,
|
|
height: number,
|
|
): SignPresentation | null;
|
|
compose(
|
|
camera: ProjectingCamera,
|
|
width: number,
|
|
height: number,
|
|
navigationBottom: number,
|
|
now: number,
|
|
carAnchor?: { x: number; z: number },
|
|
): void;
|
|
setViewportAspect(aspect: number): void;
|
|
setViewportSize(width: number, height: number): void;
|
|
update(sign: SignSnapshot | null, now?: number): void;
|
|
}
|
|
|
|
export interface SignPresentation {
|
|
visible: boolean;
|
|
corridor: SignCorridor;
|
|
viewportProfile: SignViewportProfile;
|
|
bounds: {
|
|
left: number;
|
|
top: number;
|
|
right: number;
|
|
bottom: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
texture: {
|
|
width: number;
|
|
height: number;
|
|
maximumSize: number;
|
|
anisotropy: number;
|
|
fontReadyWhenDrawn: boolean;
|
|
};
|
|
}
|
|
|
|
export interface SignRenderOptions {
|
|
maximumAnisotropy?: number;
|
|
maximumTextureSize?: number;
|
|
}
|
|
|
|
interface SignGroup {
|
|
visible: boolean;
|
|
userData: Record<string, unknown>;
|
|
position: {
|
|
x: number;
|
|
y: number;
|
|
z: number;
|
|
set(x: number, y: number, z: number): void;
|
|
clone(): SignGroup['position'];
|
|
distanceTo(other: SignGroup['position']): number;
|
|
};
|
|
rotation: { y: number };
|
|
scale: { x: number; setScalar(scale: number): void };
|
|
add(...objects: unknown[]): void;
|
|
getObjectByName(name: string): unknown;
|
|
updateMatrixWorld(force: boolean): void;
|
|
}
|
|
|
|
export function routeLabelFor(id: SectionId): string {
|
|
return signRouteFor(id).label;
|
|
}
|
|
|
|
export async function prepareSignTypography(
|
|
signal: AbortSignal,
|
|
): Promise<void> {
|
|
const fonts = document.fonts;
|
|
if (fonts === undefined) return;
|
|
await Promise.all([
|
|
fonts.load(SIGN_MARKER_FONT),
|
|
fonts.load(SIGN_LABEL_FONT),
|
|
fonts.ready,
|
|
]);
|
|
if (signal.aborted)
|
|
throw new DOMException('Scene build aborted', 'AbortError');
|
|
}
|
|
|
|
export function createSign(
|
|
resources: SceneResourceBag,
|
|
options: SignRenderOptions = {},
|
|
): SignWorld {
|
|
const group = new THREE.Group();
|
|
group.name = 'roadside-sign';
|
|
group.visible = false;
|
|
|
|
const housingMaterial = resources.own(
|
|
'material',
|
|
new THREE.MeshStandardMaterial({
|
|
color: 0x654169,
|
|
emissive: 0x512547,
|
|
emissiveIntensity: 0.6,
|
|
roughness: 0.28,
|
|
metalness: 0.7,
|
|
transparent: true,
|
|
opacity: 0,
|
|
alphaTest: 0.02,
|
|
}),
|
|
);
|
|
const backplateMaterial = resources.own(
|
|
'material',
|
|
new THREE.MeshStandardMaterial({
|
|
color: 0x301f47,
|
|
roughness: 0.72,
|
|
metalness: 0.38,
|
|
transparent: true,
|
|
opacity: 0,
|
|
alphaTest: 0.02,
|
|
}),
|
|
);
|
|
const facePanelMaterial = resources.own(
|
|
'material',
|
|
new THREE.MeshStandardMaterial({
|
|
color: 0x11091d,
|
|
roughness: 0.9,
|
|
metalness: 0.12,
|
|
transparent: true,
|
|
opacity: 0,
|
|
alphaTest: 0.02,
|
|
}),
|
|
);
|
|
const supportMaterial = resources.own(
|
|
'material',
|
|
new THREE.MeshStandardMaterial({
|
|
color: 0x3c2752,
|
|
emissive: 0x38204e,
|
|
emissiveIntensity: 0.4,
|
|
roughness: 0.56,
|
|
metalness: 0.68,
|
|
transparent: true,
|
|
opacity: 0,
|
|
alphaTest: 0.02,
|
|
}),
|
|
);
|
|
const edgeMaterial = resources.own(
|
|
'material',
|
|
new THREE.MeshStandardMaterial({
|
|
color: 0xa3488d,
|
|
emissive: 0xff79c0,
|
|
emissiveIntensity: 1.1,
|
|
roughness: 0.42,
|
|
metalness: 0.5,
|
|
transparent: true,
|
|
opacity: 0,
|
|
alphaTest: 0.02,
|
|
}),
|
|
);
|
|
const routeMaterial = resources.own(
|
|
'material',
|
|
new THREE.MeshStandardMaterial({
|
|
color: 0xbc744d,
|
|
emissive: 0xffae6b,
|
|
emissiveIntensity: 0.8,
|
|
roughness: 0.48,
|
|
metalness: 0.42,
|
|
transparent: true,
|
|
opacity: 0,
|
|
alphaTest: 0.02,
|
|
}),
|
|
);
|
|
|
|
const housing = new THREE.Mesh(
|
|
resources.own('geometry', createSignBody(8.64, 2.72, 0.5, 0.24)),
|
|
housingMaterial,
|
|
);
|
|
housing.name = 'sign-housing';
|
|
housing.position.z = -0.08;
|
|
group.add(housing);
|
|
|
|
const backplate = new THREE.Mesh(
|
|
resources.own('geometry', createSignBody(8.3, 2.38, 0.2, 0.18)),
|
|
backplateMaterial,
|
|
);
|
|
backplate.name = 'sign-backplate';
|
|
backplate.position.z = 0.15;
|
|
group.add(backplate);
|
|
|
|
const facePanel = new THREE.Mesh(
|
|
resources.own('geometry', createSignBody(8.08, 2.08, 0.1, 0.14)),
|
|
facePanelMaterial,
|
|
);
|
|
facePanel.name = 'sign-face-panel';
|
|
facePanel.position.z = 0.43;
|
|
group.add(facePanel);
|
|
|
|
const canvas = document.createElement('canvas');
|
|
const maximumTextureSize = normalizeTextureLimit(options.maximumTextureSize);
|
|
canvas.width = Math.min(2_048, powerOfTwoAtMost(maximumTextureSize));
|
|
canvas.height = Math.max(32, canvas.width / 4);
|
|
const context = canvas.getContext('2d');
|
|
const texture = resources.own('texture', new THREE.CanvasTexture(canvas));
|
|
texture.colorSpace = THREE.SRGBColorSpace;
|
|
texture.premultiplyAlpha = true;
|
|
texture.generateMipmaps = true;
|
|
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
texture.magFilter = THREE.LinearFilter;
|
|
texture.anisotropy = Math.min(
|
|
8,
|
|
Math.max(1, Math.floor(options.maximumAnisotropy ?? 8)),
|
|
);
|
|
const faceMaterial = resources.own(
|
|
'material',
|
|
new THREE.MeshBasicMaterial({
|
|
color: 0xffffff,
|
|
transparent: true,
|
|
opacity: 0,
|
|
alphaTest: 0.02,
|
|
side: THREE.FrontSide,
|
|
map: texture,
|
|
polygonOffset: true,
|
|
polygonOffsetFactor: -1,
|
|
polygonOffsetUnits: -1,
|
|
depthWrite: false,
|
|
toneMapped: false,
|
|
}),
|
|
);
|
|
const face = new THREE.Mesh(
|
|
resources.own('geometry', new THREE.PlaneGeometry(8, 2)),
|
|
faceMaterial,
|
|
);
|
|
face.name = 'sign-face';
|
|
face.position.z = 0.54;
|
|
face.userData.textureUpdateCount = 0;
|
|
face.userData.fontReadyWhenDrawn = false;
|
|
group.add(face);
|
|
|
|
const edgeLight = new THREE.Group();
|
|
edgeLight.name = 'sign-edge-light';
|
|
const edgeGeometry = resources.own(
|
|
'geometry',
|
|
new THREE.BoxGeometry(0.045, 1.56, 0.045),
|
|
);
|
|
const upperEdgeGeometry = resources.own(
|
|
'geometry',
|
|
new THREE.BoxGeometry(7.6, 0.035, 0.045),
|
|
);
|
|
const leftEdge = new THREE.Mesh(edgeGeometry, edgeMaterial);
|
|
leftEdge.position.set(-3.9, 0, 0.57);
|
|
edgeLight.add(leftEdge);
|
|
const upperEdge = new THREE.Mesh(upperEdgeGeometry, edgeMaterial);
|
|
upperEdge.position.set(0, 1.14, 0.57);
|
|
edgeLight.add(upperEdge);
|
|
const lowerEdge = new THREE.Mesh(upperEdgeGeometry, routeMaterial);
|
|
lowerEdge.position.set(0, -1.14, 0.57);
|
|
edgeLight.add(lowerEdge);
|
|
const finGeometry = resources.own(
|
|
'geometry',
|
|
new THREE.BoxGeometry(0.055, 1.86, 0.48),
|
|
);
|
|
for (const side of [-1, 1])
|
|
for (let index = 0; index < 3; index++) {
|
|
const fin = new THREE.Mesh(finGeometry, supportMaterial);
|
|
fin.position.set(side * (4.26 + index * 0.12), 0, -0.04);
|
|
edgeLight.add(fin);
|
|
}
|
|
group.add(edgeLight);
|
|
|
|
const routeAccent = new THREE.Mesh(
|
|
resources.own('geometry', new THREE.BoxGeometry(1.08, 0.045, 0.055)),
|
|
routeMaterial,
|
|
);
|
|
routeAccent.name = 'sign-route-accent';
|
|
routeAccent.position.set(3.38, -1, 0.57);
|
|
group.add(routeAccent);
|
|
|
|
const backRails = new THREE.Group();
|
|
backRails.name = 'sign-back-rails';
|
|
const backRailGeometry = resources.own(
|
|
'geometry',
|
|
new THREE.BoxGeometry(7.15, 0.13, 0.16),
|
|
);
|
|
for (const y of [-0.68, 0.68]) {
|
|
const rail = new THREE.Mesh(backRailGeometry, supportMaterial);
|
|
rail.position.set(0, y, -0.38);
|
|
backRails.add(rail);
|
|
}
|
|
group.add(backRails);
|
|
|
|
const postGeometry = resources.own(
|
|
'geometry',
|
|
new THREE.BoxGeometry(0.22, 5.05, 0.26),
|
|
);
|
|
const post = new THREE.Mesh(postGeometry, supportMaterial);
|
|
post.name = 'sign-post';
|
|
post.position.set(2.62, -3.58, -0.38);
|
|
group.add(post);
|
|
|
|
const secondaryPost = new THREE.Mesh(postGeometry, supportMaterial);
|
|
secondaryPost.name = 'sign-post-secondary';
|
|
secondaryPost.position.set(-2.62, -3.58, -0.38);
|
|
group.add(secondaryPost);
|
|
const mountedPosts = [post, secondaryPost] as const;
|
|
|
|
const bracket = new THREE.Group();
|
|
bracket.name = 'sign-bracket';
|
|
const arm = new THREE.Mesh(
|
|
resources.own('geometry', new THREE.BoxGeometry(6.1, 0.13, 0.18)),
|
|
supportMaterial,
|
|
);
|
|
arm.position.set(0, -1.24, -0.38);
|
|
bracket.add(arm);
|
|
const braceGeometry = resources.own(
|
|
'geometry',
|
|
new THREE.BoxGeometry(0.12, 1.18, 0.14),
|
|
);
|
|
for (const side of [-1, 1]) {
|
|
const brace = new THREE.Mesh(braceGeometry, supportMaterial);
|
|
brace.position.set(side * 2.18, -1.68, -0.38);
|
|
brace.rotation.z = side * -0.72;
|
|
bracket.add(brace);
|
|
}
|
|
group.add(bracket);
|
|
|
|
const fasteners = new THREE.Group();
|
|
fasteners.name = 'sign-fasteners';
|
|
const fastenerGeometry = resources.own(
|
|
'geometry',
|
|
new THREE.CylinderGeometry(0.055, 0.055, 0.045, 8),
|
|
);
|
|
for (const x of [-3.72, 3.72]) {
|
|
for (const y of [-0.78, 0.78]) {
|
|
const fastener = new THREE.Mesh(fastenerGeometry, supportMaterial);
|
|
fastener.position.set(x, y, 0.57);
|
|
fastener.rotation.x = Math.PI * 0.5;
|
|
fasteners.add(fastener);
|
|
}
|
|
}
|
|
group.add(fasteners);
|
|
|
|
const footing = new THREE.Group();
|
|
footing.name = 'sign-footing';
|
|
const footingGeometry = resources.own(
|
|
'geometry',
|
|
new THREE.CylinderGeometry(0.3, 0.38, 0.32, 8),
|
|
);
|
|
for (const x of [-2.62, 2.62]) {
|
|
const base = new THREE.Mesh(footingGeometry, supportMaterial);
|
|
base.position.x = x;
|
|
footing.add(base);
|
|
}
|
|
footing.position.set(0, -6.18, -0.38);
|
|
group.add(footing);
|
|
|
|
const sweepMaterial = resources.own(
|
|
'material',
|
|
new THREE.ShaderMaterial({
|
|
uniforms: { uSweep: { value: -1 }, uOpacity: { value: 0 } },
|
|
vertexShader: `varying vec2 vUv;void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
|
|
fragmentShader: `uniform float uSweep;uniform float uOpacity;varying vec2 vUv;
|
|
void main(){float beam=exp(-pow((vUv.x+vUv.y*0.14-uSweep)*12.0,2.0));
|
|
gl_FragColor=vec4(0.36,0.82,0.9,beam*uOpacity);}`,
|
|
transparent: true,
|
|
depthWrite: false,
|
|
depthTest: true,
|
|
blending: THREE.AdditiveBlending,
|
|
}),
|
|
);
|
|
const sweep = new THREE.Mesh(
|
|
resources.own('geometry', new THREE.PlaneGeometry(8, 2)),
|
|
sweepMaterial,
|
|
);
|
|
sweep.name = 'sign-illumination-sweep';
|
|
sweep.position.z = 0.56;
|
|
group.add(sweep);
|
|
|
|
const fadeMaterials = [
|
|
[housingMaterial, 0.97],
|
|
[backplateMaterial, 1],
|
|
[facePanelMaterial, 1],
|
|
[supportMaterial, 0.94],
|
|
[edgeMaterial, 0.76],
|
|
[routeMaterial, 0.82],
|
|
[faceMaterial, 1],
|
|
] as const;
|
|
let target: SectionId | null = null;
|
|
let snapshot: SignSnapshot | null = null;
|
|
let viewport: SignViewportProfile = 'wide';
|
|
let activeCorridor: SignCorridor = 'right';
|
|
let viewportWidth = 1440;
|
|
let viewportHeight = 900;
|
|
let previousNow = 0;
|
|
let screenX = 0.5;
|
|
let screenY = 120;
|
|
let screenWidth = 340;
|
|
let initialized = false;
|
|
let roadsideMix = 0;
|
|
let previousPhase: SignSnapshot['phase'] | null = null;
|
|
let dwellDrift = 0;
|
|
let departureDrift = 0;
|
|
const physical = roadsideSignMetrics(900);
|
|
const point = new THREE.Vector3();
|
|
const ray = new THREE.Vector3();
|
|
const forward = new THREE.Vector3();
|
|
const worldRight = new THREE.Vector3();
|
|
const worldLeft = new THREE.Vector3();
|
|
const corner = new THREE.Vector3();
|
|
const corners = [
|
|
[-4, -1],
|
|
[4, -1],
|
|
[4, 1],
|
|
[-4, 1],
|
|
];
|
|
function plantMounting() {
|
|
const footY = -group.position.y / Math.max(0.001, group.scale.x);
|
|
const height = Math.max(0.36, -1.36 - footY);
|
|
for (const post of mountedPosts) {
|
|
post.position.y = (-1.36 + footY) * 0.5;
|
|
post.scale.set(1, height / 5.05, 1);
|
|
}
|
|
footing.position.y = footY;
|
|
}
|
|
function worldPoint(
|
|
out: typeof point,
|
|
x: number,
|
|
y: number,
|
|
depth: number,
|
|
camera: ProjectingCamera,
|
|
) {
|
|
out
|
|
.set((x / viewportWidth) * 2 - 1, 1 - (y / viewportHeight) * 2, 0.5)
|
|
.unproject(camera);
|
|
ray.copy(out).sub(camera.position).normalize();
|
|
out
|
|
.copy(camera.position)
|
|
.addScaledVector(ray, depth / Math.max(0.05, ray.dot(forward)));
|
|
}
|
|
return {
|
|
group,
|
|
inspect(camera, width, height) {
|
|
if (target === null) return null;
|
|
group.updateMatrixWorld(true);
|
|
camera.updateMatrixWorld(true);
|
|
let left = Infinity,
|
|
top = Infinity,
|
|
right = -Infinity,
|
|
bottom = -Infinity;
|
|
for (const [x, y] of corners) {
|
|
corner.set(x, y, 0).applyMatrix4(face.matrixWorld).project(camera);
|
|
const px = ((corner.x + 1) * width) / 2,
|
|
py = ((1 - corner.y) * height) / 2;
|
|
left = Math.min(left, px);
|
|
right = Math.max(right, px);
|
|
top = Math.min(top, py);
|
|
bottom = Math.max(bottom, py);
|
|
}
|
|
return {
|
|
visible: group.visible,
|
|
corridor: activeCorridor,
|
|
viewportProfile: viewport,
|
|
bounds: {
|
|
left,
|
|
top,
|
|
right,
|
|
bottom,
|
|
width: right - left,
|
|
height: bottom - top,
|
|
},
|
|
texture: {
|
|
width: canvas.width,
|
|
height: canvas.height,
|
|
maximumSize: maximumTextureSize,
|
|
anisotropy: texture.anisotropy,
|
|
fontReadyWhenDrawn: face.userData.fontReadyWhenDrawn === true,
|
|
},
|
|
};
|
|
},
|
|
setViewportAspect(aspect) {
|
|
viewport = aspect < 1.05 ? 'tall' : 'wide';
|
|
},
|
|
setViewportSize(width, height) {
|
|
viewportWidth = Math.max(1, width);
|
|
viewportHeight = Math.max(1, height);
|
|
viewport = signViewportProfileFor(width, height);
|
|
},
|
|
update(sign) {
|
|
snapshot = sign;
|
|
group.userData.phase = sign?.phase ?? 'inactive';
|
|
group.userData.target = sign?.targetId ?? null;
|
|
if (!sign) {
|
|
group.visible = false;
|
|
initialized = false;
|
|
previousPhase = null;
|
|
dwellDrift = 0;
|
|
return;
|
|
}
|
|
if (target !== sign.targetId) {
|
|
previousPhase = null;
|
|
dwellDrift = 0;
|
|
target = sign.targetId;
|
|
face.userData.textureUpdateCount += 1;
|
|
face.userData.fontReadyWhenDrawn =
|
|
document.fonts?.check(SIGN_LABEL_FONT) ?? true;
|
|
if (context)
|
|
drawLabel(
|
|
context,
|
|
signRouteFor(sign.targetId),
|
|
canvas.width,
|
|
canvas.height,
|
|
);
|
|
texture.needsUpdate = true;
|
|
initialized = false;
|
|
}
|
|
},
|
|
compose(
|
|
camera,
|
|
width,
|
|
height,
|
|
navigationBottom,
|
|
now,
|
|
carAnchor = { x: 0, z: 0.72 },
|
|
) {
|
|
if (!snapshot) return;
|
|
viewportWidth = width;
|
|
viewportHeight = height;
|
|
camera.updateMatrixWorld(true);
|
|
forward
|
|
.set(0, 0, -1)
|
|
.transformDirection(
|
|
(camera as ProjectingCamera & { matrixWorld: unknown }).matrixWorld,
|
|
);
|
|
const route = signRouteFor(snapshot.targetId);
|
|
activeCorridor = route.corridor ?? 'right';
|
|
const side = activeCorridor.includes('left') ? -1 : 1;
|
|
const compact = width < 900;
|
|
const shortSide = height < 560 && width >= 768;
|
|
const lowerStrip = height < 560 && !shortSide;
|
|
const targetWidth = Math.min(
|
|
width - 44,
|
|
compact ? 288 : 356,
|
|
height < 650 ? 250 : 356,
|
|
);
|
|
const targetX = shortSide ? 0.82 : compact ? 0.5 : side < 0 ? 0.23 : 0.77;
|
|
const targetY = shortSide
|
|
? navigationBottom + 32 + (height - navigationBottom - 96) * 0.25
|
|
: lowerStrip
|
|
? height - 112
|
|
: navigationBottom + 12 + targetWidth / 3.18 / 2;
|
|
const dt = Math.min(0.05, Math.max(0, (now - previousNow) / 1000));
|
|
previousNow = now;
|
|
const mix = initialized ? 1 - Math.exp(-dt / 0.18) : 1;
|
|
const roadside = usesRoadsideSign(width, height);
|
|
roadsideMix += ((roadside ? 1 : 0) - roadsideMix) * mix;
|
|
screenX += (targetX - screenX) * mix;
|
|
screenY += (targetY - screenY) * mix;
|
|
screenWidth += (targetWidth - screenWidth) * mix;
|
|
initialized = true;
|
|
const p = smoothstep(clamp01(snapshot.progress));
|
|
let arrival =
|
|
snapshot.phase === 'anticipate'
|
|
? 0
|
|
: snapshot.phase === 'approach'
|
|
? p
|
|
: 1;
|
|
const closeDepth = lowerStrip ? 12 : 18;
|
|
let depth = THREE.MathUtils.lerp(190, closeDepth, arrival);
|
|
let cx = THREE.MathUtils.lerp(0.5 + side * 0.1, screenX, arrival) * width;
|
|
let cy = THREE.MathUtils.lerp(
|
|
height * 0.43,
|
|
screenY,
|
|
lowerStrip ? arrival * arrival * arrival : arrival,
|
|
);
|
|
let projectedWidth = screenWidth * THREE.MathUtils.lerp(0.1, 1, arrival);
|
|
if (snapshot.phase === 'depart') {
|
|
depth = THREE.MathUtils.lerp(closeDepth, lowerStrip ? 4 : 12, p);
|
|
cx += side * p * width * 0.3;
|
|
projectedWidth *= 1 + p * 0.3;
|
|
}
|
|
if (snapshot.phase !== 'depart') {
|
|
projectedWidth = Math.min(width - 44, projectedWidth);
|
|
cx = THREE.MathUtils.clamp(
|
|
cx,
|
|
projectedWidth / 2 + 14,
|
|
width - projectedWidth / 2 - 14,
|
|
);
|
|
}
|
|
worldPoint(point, cx, cy, depth, camera);
|
|
worldPoint(worldLeft, cx - projectedWidth / 2, cy, depth, camera);
|
|
worldPoint(worldRight, cx + projectedWidth / 2, cy, depth, camera);
|
|
if (lowerStrip) point.y = Math.max(1.4, point.y);
|
|
// A wide-screen sign is a real roadside object: fixed lateral offset,
|
|
// fixed mast height, constant size, and travel only along the road axis.
|
|
// Camera movement changes its projection, never its world-space anchor.
|
|
const desiredPhysical = roadsideSignMetrics(height, snapshot.targetId);
|
|
for (const key of ['x', 'y', 'scale', 'dwellZ'] as const) {
|
|
physical[key] += (desiredPhysical[key] - physical[key]) * mix;
|
|
}
|
|
// Canonical dwell progress is always 1; visual drift must integrate time.
|
|
if (snapshot.phase === 'dwell')
|
|
dwellDrift = Math.min(3, dwellDrift + dt * 1.7);
|
|
if (snapshot.phase === 'depart' && previousPhase !== 'depart')
|
|
departureDrift = dwellDrift;
|
|
previousPhase = snapshot.phase;
|
|
const roadZ =
|
|
snapshot.phase === 'depart'
|
|
? THREE.MathUtils.lerp(physical.dwellZ + departureDrift, 48, p)
|
|
: THREE.MathUtils.lerp(-230, physical.dwellZ, arrival) + dwellDrift;
|
|
point.x = THREE.MathUtils.lerp(
|
|
point.x,
|
|
carAnchor.x + side * physical.x,
|
|
roadsideMix,
|
|
);
|
|
point.y = THREE.MathUtils.lerp(point.y, physical.y, roadsideMix);
|
|
point.z = THREE.MathUtils.lerp(point.z, carAnchor.z + roadZ, roadsideMix);
|
|
group.position.set(point.x, point.y, point.z);
|
|
group.scale.setScalar(
|
|
THREE.MathUtils.lerp(
|
|
worldRight.distanceTo(worldLeft) / 8.64,
|
|
physical.scale,
|
|
roadsideMix,
|
|
),
|
|
);
|
|
group.rotation.y = THREE.MathUtils.lerp(
|
|
camera.rotation.y - side * 0.065,
|
|
-side * 0.1,
|
|
roadsideMix,
|
|
);
|
|
plantMounting();
|
|
const opacity = 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));
|
|
edgeMaterial.emissiveIntensity = 0.8 + Math.sin(arrival * Math.PI) * 1.4;
|
|
sweepMaterial.uniforms.uSweep.value = arrival * 1.5 - 0.2;
|
|
sweepMaterial.uniforms.uOpacity.value =
|
|
snapshot.phase === 'approach' ? opacity * 0.14 : 0;
|
|
group.visible = opacity > 0;
|
|
group.userData.anchoring = roadside ? 'roadside' : 'compact-shelf';
|
|
},
|
|
};
|
|
}
|
|
|
|
function createSignBody(
|
|
width: number,
|
|
height: number,
|
|
depth: number,
|
|
cut: number,
|
|
) {
|
|
const geometry = new THREE.ExtrudeGeometry(
|
|
createClippedShape(width, height, cut),
|
|
{
|
|
depth,
|
|
bevelEnabled: true,
|
|
bevelSegments: 1,
|
|
bevelSize: 0.045,
|
|
bevelThickness: 0.045,
|
|
curveSegments: 1,
|
|
},
|
|
);
|
|
geometry.translate(0, 0, -depth * 0.5);
|
|
geometry.computeVertexNormals();
|
|
return geometry;
|
|
}
|
|
|
|
function createClippedShape(width: number, height: number, cut: number) {
|
|
const halfWidth = width * 0.5;
|
|
const halfHeight = height * 0.5;
|
|
const shape = new THREE.Shape();
|
|
shape.moveTo(-halfWidth + cut, -halfHeight);
|
|
shape.lineTo(halfWidth - cut, -halfHeight);
|
|
shape.lineTo(halfWidth, -halfHeight + cut);
|
|
shape.lineTo(halfWidth, halfHeight - cut);
|
|
shape.lineTo(halfWidth - cut, halfHeight);
|
|
shape.lineTo(-halfWidth + cut, halfHeight);
|
|
shape.lineTo(-halfWidth, halfHeight - cut);
|
|
shape.lineTo(-halfWidth, -halfHeight + cut);
|
|
shape.closePath();
|
|
return shape;
|
|
}
|
|
|
|
function drawLabel(
|
|
context: CanvasRenderingContext2D,
|
|
route: SignRouteSpec,
|
|
width: number,
|
|
height: number,
|
|
): void {
|
|
context.clearRect(0, 0, width, height);
|
|
context.save();
|
|
context.scale(width / 2_048, height / 512);
|
|
context.imageSmoothingEnabled = true;
|
|
context.imageSmoothingQuality = 'high';
|
|
|
|
context.globalAlpha = 0.16;
|
|
context.fillStyle = '#cfabcd';
|
|
for (const [x, y, scratchWidth] of [
|
|
[312, 84, 142],
|
|
[742, 424, 96],
|
|
[1_116, 66, 188],
|
|
[1_488, 446, 112],
|
|
] as const) {
|
|
context.fillRect(x, y, scratchWidth, 2);
|
|
}
|
|
|
|
context.globalAlpha = 1;
|
|
context.fillStyle = '#ff95cd';
|
|
context.fillRect(82, 72, 10, 368);
|
|
context.fillStyle = '#bbaae0';
|
|
context.font = SIGN_MARKER_FONT;
|
|
context.textAlign = 'left';
|
|
context.textBaseline = 'middle';
|
|
context.fillText(`ROUTE ${route.marker} / NEXT`, 142, 120);
|
|
|
|
context.globalAlpha = 0.6;
|
|
context.fillStyle = '#61416d';
|
|
context.fillRect(142, 174, 1_610, 2);
|
|
|
|
context.globalAlpha = 1;
|
|
context.fillStyle = '#fff2df';
|
|
const labelSize = fittedLabelSize(context, route.label, 1_520);
|
|
context.font = `650 ${labelSize}px "Geist Variable", sans-serif`;
|
|
context.fillText(route.label, 142, 326);
|
|
|
|
context.strokeStyle = '#ffb879';
|
|
context.lineWidth = 10;
|
|
context.lineCap = 'square';
|
|
context.lineJoin = 'miter';
|
|
context.beginPath();
|
|
context.moveTo(1_776, 304);
|
|
context.lineTo(1_890, 304);
|
|
context.lineTo(1_846, 260);
|
|
context.moveTo(1_890, 304);
|
|
context.lineTo(1_846, 348);
|
|
context.stroke();
|
|
context.restore();
|
|
}
|
|
|
|
function fittedLabelSize(
|
|
context: CanvasRenderingContext2D,
|
|
label: string,
|
|
maximumWidth: number,
|
|
): number {
|
|
for (let size = 180; size >= 128; size -= 4) {
|
|
context.font = `650 ${size}px "Geist Variable", sans-serif`;
|
|
if (context.measureText(label).width <= maximumWidth) return size;
|
|
}
|
|
return 112;
|
|
}
|
|
|
|
function normalizeTextureLimit(value: number | undefined): number {
|
|
return value !== undefined && Number.isFinite(value)
|
|
? Math.max(32, Math.floor(value))
|
|
: 2_048;
|
|
}
|
|
|
|
function powerOfTwoAtMost(value: number): number {
|
|
return 2 ** Math.floor(Math.log2(Math.max(1, value)));
|
|
}
|
|
|
|
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);
|
|
}
|