feat: sculpt portfolio into an immersive midnight expressway
Author physical road furniture, layered terrain and atmosphere, projected illuminated route signs, and detailed Blender coachwork. Refine camera mass, wheel contact, chapter composition, and compact reading space while preserving approved content and audio contracts. Include isolated Blender source, before/after and hardware Chrome motion evidence, preservation hashes, validated preview checks, and documented inherited unit failures.
This commit is contained in:
@@ -2,7 +2,6 @@ import * as THREE from 'three';
|
||||
import type { SectionId } from '@/content/types';
|
||||
import type { SignSnapshot } from '@/journey/types';
|
||||
import {
|
||||
SIGN_LIFECYCLE,
|
||||
signRouteFor,
|
||||
signViewportProfileFor,
|
||||
type SignCorridor,
|
||||
@@ -11,106 +10,15 @@ import {
|
||||
} from '@/journey/sign-routes';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
|
||||
interface SignPose {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
interface SignPoseSet {
|
||||
horizon: SignPose;
|
||||
dwell: SignPose;
|
||||
}
|
||||
|
||||
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 = '660 164px "Geist Mono Variable", ui-monospace, monospace';
|
||||
const SHORT_CORRIDOR_REFERENCE_ASPECT = 1.58;
|
||||
const SHORT_CORRIDOR_BASE_RELIEF = 20;
|
||||
const SHORT_CORRIDOR_RELIEF_PER_ASPECT = 20;
|
||||
const SHORT_CORRIDOR_MINIMUM_RELIEF = 12;
|
||||
const SHORT_CORRIDOR_MAXIMUM_RELIEF = 34;
|
||||
const RESPONSIVE_REFLOW_MS = 320;
|
||||
const PORTRAIT_NAVIGATION_PHONE_MAX_WIDTH = 480;
|
||||
const PORTRAIT_NAVIGATION_MEDIUM_WIDTH = 720;
|
||||
const PORTRAIT_NAVIGATION_MEDIUM_SCALE = 0.44;
|
||||
const PORTRAIT_NAVIGATION_MEDIUM_Y = 4.5;
|
||||
|
||||
const SIGN_POSES: Readonly<Record<SignViewportProfile, Readonly<Record<SignCorridor, SignPoseSet>>>> = {
|
||||
wide: {
|
||||
left: {
|
||||
horizon: { x: -9.6, y: 7.2, z: -156, scale: 0.2 },
|
||||
dwell: { x: -11.9, y: 7.95, z: -16, scale: 1.36 },
|
||||
},
|
||||
right: {
|
||||
horizon: { x: 10.2, y: 7.2, z: -156, scale: 0.2 },
|
||||
dwell: { x: 12.8, y: 7.95, z: -16, scale: 1.36 },
|
||||
},
|
||||
'outer-left': {
|
||||
horizon: { x: -21.5, y: 7.4, z: -168, scale: 0.2 },
|
||||
dwell: { x: -30.4, y: 8.65, z: -24, scale: 1.46 },
|
||||
},
|
||||
'outer-right': {
|
||||
horizon: { x: 26.5, y: 7.4, z: -168, scale: 0.2 },
|
||||
dwell: { x: 41.8, y: 8.65, z: -24, scale: 1.46 },
|
||||
},
|
||||
},
|
||||
tall: {
|
||||
left: {
|
||||
horizon: { x: -4.2, y: 6.4, z: -136, scale: 0.16 },
|
||||
dwell: { x: -5.4, y: 8.15, z: -24, scale: 1.4 },
|
||||
},
|
||||
right: {
|
||||
horizon: { x: 4.2, y: 6.4, z: -136, scale: 0.16 },
|
||||
dwell: { x: 5.4, y: 8.15, z: -24, scale: 1.4 },
|
||||
},
|
||||
'outer-left': {
|
||||
horizon: { x: -5.8, y: 6.7, z: -148, scale: 0.16 },
|
||||
dwell: { x: -7.6, y: 8.8, z: -30, scale: 1.48 },
|
||||
},
|
||||
'outer-right': {
|
||||
horizon: { x: 5.8, y: 6.7, z: -148, scale: 0.16 },
|
||||
dwell: { x: 7.6, y: 8.8, z: -30, scale: 1.48 },
|
||||
},
|
||||
},
|
||||
short: {
|
||||
left: {
|
||||
horizon: { x: -7.8, y: 6.6, z: -144, scale: 0.18 },
|
||||
dwell: { x: -8.8, y: 5.45, z: -26, scale: 2.62 },
|
||||
},
|
||||
right: {
|
||||
horizon: { x: 7.8, y: 6.6, z: -144, scale: 0.18 },
|
||||
dwell: { x: 8.8, y: 5.45, z: -26, scale: 2.62 },
|
||||
},
|
||||
'outer-left': {
|
||||
horizon: { x: -14.2, y: 6.8, z: -154, scale: 0.18 },
|
||||
dwell: { x: -26.5, y: 5.8, z: -32, scale: 2.3 },
|
||||
},
|
||||
'outer-right': {
|
||||
horizon: { x: 14.2, y: 6.8, z: -154, scale: 0.18 },
|
||||
dwell: { x: 26.5, y: 5.8, z: -32, scale: 2.3 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const PORTRAIT_NAVIGATION_DWELL: Readonly<Record<SignCorridor, SignPose>> = {
|
||||
left: { x: -2.9, y: 3, z: 8, scale: 0.25 },
|
||||
right: { x: 2.9, y: 3, z: 8, scale: 0.25 },
|
||||
'outer-left': { x: -2.9, y: 3, z: 8, scale: 0.25 },
|
||||
'outer-right': { x: 2.9, y: 3, z: 8, scale: 0.25 },
|
||||
};
|
||||
|
||||
const EXPERIENCE_PASSIVE_DWELL: Readonly<Partial<Record<SignViewportProfile, SignPose>>> = {
|
||||
wide: { x: 12.8, y: 3.7, z: 0, scale: 0.9 },
|
||||
// The near short pose counterbalances the shared aspect relief while keeping
|
||||
// the projected face in the right-hand gap between both visible panels.
|
||||
short: { x: -7.7, y: 3.3, z: -4, scale: 1.4 },
|
||||
};
|
||||
|
||||
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(
|
||||
@@ -118,6 +26,13 @@ export interface SignWorld {
|
||||
width: number,
|
||||
height: number,
|
||||
): SignPresentation | null;
|
||||
compose(
|
||||
camera: ProjectingCamera,
|
||||
width: number,
|
||||
height: number,
|
||||
navigationBottom: number,
|
||||
now: number,
|
||||
): void;
|
||||
setViewportAspect(aspect: number): void;
|
||||
setViewportSize(width: number, height: number): void;
|
||||
update(sign: SignSnapshot | null, now?: number): void;
|
||||
@@ -151,6 +66,7 @@ export interface SignRenderOptions {
|
||||
|
||||
interface SignGroup {
|
||||
visible: boolean;
|
||||
userData: Record<string, unknown>;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -170,7 +86,9 @@ export function routeLabelFor(id: SectionId): string {
|
||||
return signRouteFor(id).label;
|
||||
}
|
||||
|
||||
export async function prepareSignTypography(signal: AbortSignal): Promise<void> {
|
||||
export async function prepareSignTypography(
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const fonts = document.fonts;
|
||||
if (fonts === undefined) return;
|
||||
await Promise.all([
|
||||
@@ -178,7 +96,8 @@ export async function prepareSignTypography(signal: AbortSignal): Promise<void>
|
||||
fonts.load(SIGN_LABEL_FONT),
|
||||
fonts.ready,
|
||||
]);
|
||||
if (signal.aborted) throw new DOMException('Scene build aborted', 'AbortError');
|
||||
if (signal.aborted)
|
||||
throw new DOMException('Scene build aborted', 'AbortError');
|
||||
}
|
||||
|
||||
export function createSign(
|
||||
@@ -189,52 +108,80 @@ export function createSign(
|
||||
group.name = 'roadside-sign';
|
||||
group.visible = false;
|
||||
|
||||
const housingMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x08131c,
|
||||
roughness: 0.5,
|
||||
metalness: 0.7,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const backplateMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x172731,
|
||||
roughness: 0.72,
|
||||
metalness: 0.38,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const facePanelMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x061018,
|
||||
roughness: 0.9,
|
||||
metalness: 0.12,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const supportMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x21343e,
|
||||
roughness: 0.56,
|
||||
metalness: 0.68,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const edgeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x39747a,
|
||||
emissive: 0x55b9c0,
|
||||
emissiveIntensity: 0.42,
|
||||
roughness: 0.42,
|
||||
metalness: 0.5,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const routeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x8e6446,
|
||||
emissive: 0xb9723f,
|
||||
emissiveIntensity: 0.28,
|
||||
roughness: 0.48,
|
||||
metalness: 0.42,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const housingMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x334956,
|
||||
emissive: 0x23404e,
|
||||
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: 0x172e3c,
|
||||
roughness: 0.72,
|
||||
metalness: 0.38,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
alphaTest: 0.02,
|
||||
}),
|
||||
);
|
||||
const facePanelMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x061018,
|
||||
roughness: 0.9,
|
||||
metalness: 0.12,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
alphaTest: 0.02,
|
||||
}),
|
||||
);
|
||||
const supportMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x21343e,
|
||||
emissive: 0x163845,
|
||||
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: 0x39747a,
|
||||
emissive: 0x55b9c0,
|
||||
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: 0x8e6446,
|
||||
emissive: 0xb9723f,
|
||||
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)),
|
||||
@@ -275,18 +222,22 @@ export function createSign(
|
||||
8,
|
||||
Math.max(1, Math.floor(options.maximumAnisotropy ?? 8)),
|
||||
);
|
||||
const faceMaterial = resources.own('material', new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
side: THREE.FrontSide,
|
||||
map: texture,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -1,
|
||||
polygonOffsetUnits: -1,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
}));
|
||||
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,
|
||||
@@ -299,14 +250,33 @@ export function createSign(
|
||||
|
||||
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(1.18, 0.04, 0.045));
|
||||
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(-3.33, 1, 0.57);
|
||||
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(
|
||||
@@ -319,7 +289,10 @@ export function createSign(
|
||||
|
||||
const backRails = new THREE.Group();
|
||||
backRails.name = 'sign-back-rails';
|
||||
const backRailGeometry = resources.own('geometry', new THREE.BoxGeometry(7.15, 0.13, 0.16));
|
||||
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);
|
||||
@@ -327,11 +300,11 @@ export function createSign(
|
||||
}
|
||||
group.add(backRails);
|
||||
|
||||
const postGeometry = resources.own('geometry', new THREE.BoxGeometry(0.22, 5.05, 0.26));
|
||||
const post = new THREE.Mesh(
|
||||
postGeometry,
|
||||
supportMaterial,
|
||||
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);
|
||||
@@ -350,7 +323,10 @@ export function createSign(
|
||||
);
|
||||
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));
|
||||
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);
|
||||
@@ -361,7 +337,10 @@ export function createSign(
|
||||
|
||||
const fasteners = new THREE.Group();
|
||||
fasteners.name = 'sign-fasteners';
|
||||
const fastenerGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.055, 0.055, 0.045, 8));
|
||||
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);
|
||||
@@ -374,7 +353,10 @@ export function createSign(
|
||||
|
||||
const footing = new THREE.Group();
|
||||
footing.name = 'sign-footing';
|
||||
const footingGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.3, 0.38, 0.32, 8));
|
||||
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;
|
||||
@@ -383,6 +365,28 @@ export function createSign(
|
||||
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],
|
||||
@@ -392,166 +396,76 @@ export function createSign(
|
||||
[routeMaterial, 0.82],
|
||||
[faceMaterial, 1],
|
||||
] as const;
|
||||
const departureOrigin = new THREE.Vector3();
|
||||
const poseTransitionOrigin = new THREE.Vector3();
|
||||
const poseTransitionDestination = new THREE.Vector3();
|
||||
let departureScale = 1;
|
||||
let poseTransitionOriginScale = 1;
|
||||
let poseTransitionOriginRotationY = 0;
|
||||
let poseTransitionPending = false;
|
||||
let poseTransitionStartedAt: number | null = null;
|
||||
let target: SectionId | null = null;
|
||||
let pendingViewport: SignViewportProfile = 'wide';
|
||||
let activeViewport: SignViewportProfile = 'wide';
|
||||
let pendingAspect = 16 / 9;
|
||||
let activeAspect = 16 / 9;
|
||||
let pendingViewportWidth = 1_440;
|
||||
let activeViewportWidth = 1_440;
|
||||
let activeNavigationArrival = false;
|
||||
let snapshot: SignSnapshot | null = null;
|
||||
let viewport: SignViewportProfile = 'wide';
|
||||
let activeCorridor: SignCorridor = 'right';
|
||||
let previousPhase: SignSnapshot['phase'] | null = null;
|
||||
let renderedOpacity = 0;
|
||||
const faceCornerCoordinates = [
|
||||
[-4, -1, 0],
|
||||
[4, -1, 0],
|
||||
[4, 1, 0],
|
||||
[-4, 1, 0],
|
||||
] as const;
|
||||
const projectedCorner = new THREE.Vector3();
|
||||
|
||||
const updateTexture = (id: SectionId): void => {
|
||||
if (target === id) return;
|
||||
target = id;
|
||||
face.userData.textureUpdateCount += 1;
|
||||
face.userData.fontReadyWhenDrawn = document.fonts?.check(SIGN_LABEL_FONT) ?? true;
|
||||
if (context) drawLabel(context, signRouteFor(id), canvas.width, canvas.height);
|
||||
texture.needsUpdate = true;
|
||||
};
|
||||
|
||||
const applyOpacity = (opacity: number): void => {
|
||||
for (const [material, weight] of fadeMaterials) material.opacity = opacity * weight;
|
||||
};
|
||||
|
||||
const shortCorridorRelief = (): number => activeViewport === 'short'
|
||||
? THREE.MathUtils.clamp(
|
||||
SHORT_CORRIDOR_BASE_RELIEF
|
||||
+ (activeAspect - SHORT_CORRIDOR_REFERENCE_ASPECT)
|
||||
* SHORT_CORRIDOR_RELIEF_PER_ASPECT,
|
||||
SHORT_CORRIDOR_MINIMUM_RELIEF,
|
||||
SHORT_CORRIDOR_MAXIMUM_RELIEF,
|
||||
)
|
||||
: 0;
|
||||
|
||||
const resolvedPoseX = (
|
||||
pose: SignPose,
|
||||
side: number,
|
||||
weight: number,
|
||||
routeOffsetX = 0,
|
||||
): number => pose.x + side * shortCorridorRelief() * weight + routeOffsetX * weight;
|
||||
|
||||
const applyPose = (
|
||||
pose: SignPose,
|
||||
side: number,
|
||||
lateralWeight: number,
|
||||
routeOffsetX = 0,
|
||||
scale = pose.scale,
|
||||
y = pose.y,
|
||||
): void => {
|
||||
group.position.set(
|
||||
resolvedPoseX(pose, side, lateralWeight, routeOffsetX),
|
||||
y,
|
||||
pose.z,
|
||||
);
|
||||
group.scale.setScalar(scale);
|
||||
plantMounting(y, scale);
|
||||
};
|
||||
|
||||
const portraitNavigationWidthProgress = (): number => smoothstep(clamp01(
|
||||
(activeViewportWidth - PORTRAIT_NAVIGATION_PHONE_MAX_WIDTH)
|
||||
/ (PORTRAIT_NAVIGATION_MEDIUM_WIDTH - PORTRAIT_NAVIGATION_PHONE_MAX_WIDTH),
|
||||
));
|
||||
|
||||
const portraitNavigationScale = (phoneScale: number): number => THREE.MathUtils.lerp(
|
||||
phoneScale,
|
||||
PORTRAIT_NAVIGATION_MEDIUM_SCALE,
|
||||
portraitNavigationWidthProgress(),
|
||||
);
|
||||
|
||||
const portraitNavigationY = (phoneY: number): number => THREE.MathUtils.lerp(
|
||||
phoneY,
|
||||
PORTRAIT_NAVIGATION_MEDIUM_Y,
|
||||
portraitNavigationWidthProgress(),
|
||||
);
|
||||
|
||||
const plantMounting = (groupY: number, groupScale: number): void => {
|
||||
const safeScale = Math.max(0.001, groupScale);
|
||||
const footingY = -groupY / safeScale;
|
||||
const postTopY = -1.36;
|
||||
const postHeight = Math.max(0.36, postTopY - footingY);
|
||||
for (const mountedPost of mountedPosts) {
|
||||
mountedPost.position.y = (postTopY + footingY) * 0.5;
|
||||
mountedPost.scale.set(1, postHeight / 5.05, 1);
|
||||
let viewportWidth = 1440;
|
||||
let viewportHeight = 900;
|
||||
let previousNow = 0;
|
||||
let screenX = 0.5;
|
||||
let screenY = 120;
|
||||
let screenWidth = 340;
|
||||
let initialized = false;
|
||||
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 = footingY;
|
||||
footing.scale.setScalar(1);
|
||||
};
|
||||
|
||||
const stagePoseTransition = (): void => {
|
||||
if (previousPhase === null || previousPhase === 'depart') return;
|
||||
poseTransitionOrigin.copy(group.position);
|
||||
poseTransitionOriginScale = group.scale.x;
|
||||
poseTransitionOriginRotationY = group.rotation.y;
|
||||
poseTransitionPending = true;
|
||||
poseTransitionStartedAt = null;
|
||||
};
|
||||
|
||||
const stageResponsiveViewport = (
|
||||
viewport: SignViewportProfile,
|
||||
aspect: number,
|
||||
width: number,
|
||||
): void => {
|
||||
const changed = viewport !== pendingViewport
|
||||
|| Math.abs(aspect - pendingAspect) > 0.001
|
||||
|| Math.abs(width - pendingViewportWidth) > 0.5;
|
||||
if (changed) stagePoseTransition();
|
||||
pendingViewport = viewport;
|
||||
pendingAspect = aspect;
|
||||
pendingViewportWidth = width;
|
||||
if (previousPhase === null) {
|
||||
activeViewport = pendingViewport;
|
||||
activeAspect = pendingAspect;
|
||||
activeViewportWidth = pendingViewportWidth;
|
||||
poseTransitionPending = false;
|
||||
poseTransitionStartedAt = null;
|
||||
}
|
||||
};
|
||||
|
||||
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 || width <= 0 || height <= 0) return null;
|
||||
if (target === null) return null;
|
||||
group.updateMatrixWorld(true);
|
||||
camera.updateMatrixWorld(true);
|
||||
let left = Number.POSITIVE_INFINITY;
|
||||
let top = Number.POSITIVE_INFINITY;
|
||||
let right = Number.NEGATIVE_INFINITY;
|
||||
let bottom = Number.NEGATIVE_INFINITY;
|
||||
for (const [xPosition, yPosition, zPosition] of faceCornerCoordinates) {
|
||||
projectedCorner
|
||||
.set(xPosition, yPosition, zPosition)
|
||||
.applyMatrix4(face.matrixWorld)
|
||||
.project(camera);
|
||||
const x = (projectedCorner.x + 1) * 0.5 * width;
|
||||
const y = (1 - projectedCorner.y) * 0.5 * height;
|
||||
left = Math.min(left, x);
|
||||
top = Math.min(top, y);
|
||||
right = Math.max(right, x);
|
||||
bottom = Math.max(bottom, y);
|
||||
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: activeViewport,
|
||||
viewportProfile: viewport,
|
||||
bounds: {
|
||||
left,
|
||||
top,
|
||||
@@ -570,177 +484,139 @@ export function createSign(
|
||||
};
|
||||
},
|
||||
setViewportAspect(aspect) {
|
||||
const nextAspect = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
|
||||
stageResponsiveViewport(
|
||||
nextAspect < 0.85 ? 'tall' : 'wide',
|
||||
nextAspect,
|
||||
pendingViewportWidth,
|
||||
);
|
||||
viewport = aspect < 1.05 ? 'tall' : 'wide';
|
||||
},
|
||||
setViewportSize(width, height) {
|
||||
const nextAspect = Number.isFinite(width) && Number.isFinite(height) && height > 0
|
||||
? Math.max(1, width) / height
|
||||
: 1;
|
||||
stageResponsiveViewport(
|
||||
signViewportProfileFor(width, height),
|
||||
nextAspect,
|
||||
Number.isFinite(width) ? Math.max(1, width) : 1,
|
||||
);
|
||||
viewportWidth = Math.max(1, width);
|
||||
viewportHeight = Math.max(1, height);
|
||||
viewport = signViewportProfileFor(width, height);
|
||||
},
|
||||
update(sign, now) {
|
||||
update(sign) {
|
||||
snapshot = sign;
|
||||
group.userData.phase = sign?.phase ?? 'inactive';
|
||||
group.userData.target = sign?.targetId ?? null;
|
||||
if (!sign) {
|
||||
group.visible = false;
|
||||
applyOpacity(0);
|
||||
renderedOpacity = 0;
|
||||
previousPhase = null;
|
||||
activeViewport = pendingViewport;
|
||||
activeAspect = pendingAspect;
|
||||
activeViewportWidth = pendingViewportWidth;
|
||||
activeNavigationArrival = false;
|
||||
poseTransitionPending = false;
|
||||
poseTransitionStartedAt = null;
|
||||
initialized = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
target !== null
|
||||
&& target !== sign.targetId
|
||||
&& renderedOpacity > SIGN_LIFECYCLE.concealedOpacity
|
||||
) return;
|
||||
const navigationArrival = sign.navigationArrival === true;
|
||||
if (target !== sign.targetId) {
|
||||
// The renderer owns projection. A quality-tier change can rebuild this world
|
||||
// while a lifecycle is already visible, so its latched profile may describe
|
||||
// the generation that was just disposed rather than this canvas.
|
||||
activeViewport = pendingViewport;
|
||||
activeAspect = pendingAspect;
|
||||
activeViewportWidth = pendingViewportWidth;
|
||||
activeNavigationArrival = navigationArrival;
|
||||
poseTransitionPending = false;
|
||||
poseTransitionStartedAt = null;
|
||||
updateTexture(sign.targetId);
|
||||
} else if (navigationArrival !== activeNavigationArrival) {
|
||||
stagePoseTransition();
|
||||
activeNavigationArrival = navigationArrival;
|
||||
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;
|
||||
}
|
||||
|
||||
if (poseTransitionPending && sign.phase === 'depart') {
|
||||
poseTransitionPending = false;
|
||||
poseTransitionStartedAt = null;
|
||||
} else if (poseTransitionPending) {
|
||||
activeViewport = pendingViewport;
|
||||
activeAspect = pendingAspect;
|
||||
activeViewportWidth = pendingViewportWidth;
|
||||
if (poseTransitionStartedAt === null) {
|
||||
poseTransitionStartedAt = Number.isFinite(now) ? now! : 0;
|
||||
}
|
||||
}
|
||||
|
||||
const opacity = clamp01(sign.opacity);
|
||||
const progress = smoothstep(clamp01(sign.progress));
|
||||
const route = signRouteFor(sign.targetId);
|
||||
const corridor = route.corridor ?? 'right';
|
||||
activeCorridor = corridor;
|
||||
const poseSet = SIGN_POSES[activeViewport][corridor];
|
||||
const horizon = poseSet.horizon;
|
||||
let dwell = activeViewport === 'tall' && activeNavigationArrival
|
||||
? PORTRAIT_NAVIGATION_DWELL[corridor]
|
||||
: poseSet.dwell;
|
||||
if (!activeNavigationArrival && sign.targetId === 'experience') {
|
||||
dwell = EXPERIENCE_PASSIVE_DWELL[activeViewport] ?? dwell;
|
||||
}
|
||||
const dwellScale = activeViewport === 'tall' && activeNavigationArrival
|
||||
? portraitNavigationScale(dwell.scale)
|
||||
: dwell.scale;
|
||||
const dwellY = activeViewport === 'tall' && activeNavigationArrival
|
||||
? portraitNavigationY(dwell.y)
|
||||
: dwell.y;
|
||||
const side = corridor.includes('left') ? -1 : 1;
|
||||
const routeOffsetX = route.dwellOffsetX ?? 0;
|
||||
|
||||
if (sign.phase === 'anticipate') {
|
||||
applyPose(horizon, side, 0.35);
|
||||
} else if (sign.phase === 'approach') {
|
||||
group.position.set(
|
||||
THREE.MathUtils.lerp(
|
||||
resolvedPoseX(horizon, side, 0.35),
|
||||
resolvedPoseX(dwell, side, 1, routeOffsetX),
|
||||
progress,
|
||||
),
|
||||
THREE.MathUtils.lerp(horizon.y, dwellY, progress),
|
||||
THREE.MathUtils.lerp(horizon.z, dwell.z, progress),
|
||||
},
|
||||
compose(camera, width, height, navigationBottom, now) {
|
||||
if (!snapshot) return;
|
||||
viewportWidth = width;
|
||||
viewportHeight = height;
|
||||
camera.updateMatrixWorld(true);
|
||||
forward
|
||||
.set(0, 0, -1)
|
||||
.transformDirection(
|
||||
(camera as ProjectingCamera & { matrixWorld: unknown }).matrixWorld,
|
||||
);
|
||||
group.scale.setScalar(THREE.MathUtils.lerp(horizon.scale, dwellScale, progress));
|
||||
plantMounting(group.position.y, group.scale.x);
|
||||
} else if (sign.phase === 'dwell') {
|
||||
applyPose(dwell, side, 1, routeOffsetX, dwellScale, dwellY);
|
||||
} else {
|
||||
if (previousPhase !== 'depart') {
|
||||
if (previousPhase === null) applyPose(dwell, side, 1, routeOffsetX, dwellScale, dwellY);
|
||||
departureOrigin.copy(group.position);
|
||||
departureScale = group.scale.x;
|
||||
}
|
||||
const outerCorridor = corridor.startsWith('outer');
|
||||
const lateralExit = activeViewport === 'wide'
|
||||
? outerCorridor ? 2.6 : 3.4
|
||||
: activeViewport === 'short' ? 2.2 : 2.4;
|
||||
group.position.set(
|
||||
departureOrigin.x + progress * side * lateralExit,
|
||||
departureOrigin.y,
|
||||
THREE.MathUtils.lerp(departureOrigin.z, 18, progress),
|
||||
);
|
||||
group.scale.setScalar(departureScale);
|
||||
plantMounting(group.position.y, group.scale.x);
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
const desiredRotationY = -side * (activeViewport === 'wide' ? 0.14 : 0.1);
|
||||
if (poseTransitionPending) {
|
||||
const elapsed = Number.isFinite(now)
|
||||
? Math.max(0, now! - (poseTransitionStartedAt ?? now!))
|
||||
: RESPONSIVE_REFLOW_MS;
|
||||
const mix = smoothstep(clamp01(elapsed / RESPONSIVE_REFLOW_MS));
|
||||
poseTransitionDestination.copy(group.position);
|
||||
const poseTransitionDestinationScale = group.scale.x;
|
||||
group.position.set(
|
||||
THREE.MathUtils.lerp(poseTransitionOrigin.x, poseTransitionDestination.x, mix),
|
||||
THREE.MathUtils.lerp(poseTransitionOrigin.y, poseTransitionDestination.y, mix),
|
||||
THREE.MathUtils.lerp(poseTransitionOrigin.z, poseTransitionDestination.z, mix),
|
||||
if (snapshot.phase !== 'depart') {
|
||||
projectedWidth = Math.min(width - 44, projectedWidth);
|
||||
cx = THREE.MathUtils.clamp(
|
||||
cx,
|
||||
projectedWidth / 2 + 14,
|
||||
width - projectedWidth / 2 - 14,
|
||||
);
|
||||
group.scale.setScalar(THREE.MathUtils.lerp(
|
||||
poseTransitionOriginScale,
|
||||
poseTransitionDestinationScale,
|
||||
mix,
|
||||
));
|
||||
group.rotation.y = THREE.MathUtils.lerp(
|
||||
poseTransitionOriginRotationY,
|
||||
desiredRotationY,
|
||||
mix,
|
||||
);
|
||||
plantMounting(group.position.y, group.scale.x);
|
||||
if (mix >= 1) {
|
||||
poseTransitionPending = false;
|
||||
poseTransitionStartedAt = null;
|
||||
}
|
||||
} else {
|
||||
group.rotation.y = desiredRotationY;
|
||||
}
|
||||
applyOpacity(opacity);
|
||||
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);
|
||||
group.position.set(point.x, point.y, point.z);
|
||||
group.scale.setScalar(worldRight.distanceTo(worldLeft) / 8.64);
|
||||
group.rotation.y = camera.rotation.y - side * 0.065;
|
||||
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;
|
||||
renderedOpacity = opacity;
|
||||
previousPhase = sign.phase;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
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;
|
||||
@@ -799,9 +675,9 @@ function drawLabel(
|
||||
context.fillRect(142, 174, 1_610, 2);
|
||||
|
||||
context.globalAlpha = 1;
|
||||
context.fillStyle = '#d7e4e2';
|
||||
context.fillStyle = '#e2f7f4';
|
||||
const labelSize = fittedLabelSize(context, route.label, 1_520);
|
||||
context.font = `660 ${labelSize}px "Geist Mono Variable", ui-monospace, monospace`;
|
||||
context.font = `650 ${labelSize}px "Geist Variable", sans-serif`;
|
||||
context.fillText(route.label, 142, 326);
|
||||
|
||||
context.strokeStyle = '#cb9160';
|
||||
@@ -823,8 +699,8 @@ function fittedLabelSize(
|
||||
label: string,
|
||||
maximumWidth: number,
|
||||
): number {
|
||||
for (let size = 164; size >= 112; size -= 4) {
|
||||
context.font = `660 ${size}px "Geist Mono Variable", ui-monospace, monospace`;
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user