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; 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 { 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); }