import { useRef, useState, useEffect, useMemo, Suspense, useCallback, createContext, useContext, Component } from 'react'; import type { ReactNode } from 'react'; import { Canvas, useFrame, useThree } from '@react-three/fiber'; import { Text, Environment } from '@react-three/drei'; import * as THREE from 'three'; import type { ArtistDetail, MovementGalleryDetail, Painting, ArtistPeriod, ArtistNavigation, MovementArtistGroup, TourGalleryDetail, } from '../types'; import { galleryImageUrlCandidates, imageUrl, api } from '../api/client'; import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils'; import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures'; import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles'; import { useTexturedMaterial } from '../hooks/useTexturedMaterial'; import MovementHallDetails from './MovementHallDetails'; import GalleryWindows, { GalleryTrackLights } from './GalleryWindows'; import HallPassage from './HallPassage'; import { buildAllMovementHallLayouts, computeSideWallWindows, type MovementHallLayout, } from '../utils/movementHallLayout'; import './VirtualGallery.css'; import GalleryLoadingMarker from './GalleryLoadingMarker'; const GalleryTextureLoadContext = createContext<{ begin: () => void; end: () => void; } | null>(null); /** * Keeps a single failing subtree (e.g. the network-loaded HDR environment map) * from unmounting the whole 3D scene and leaving a dark window. */ class SceneErrorBoundary extends Component< { children: ReactNode; fallback?: ReactNode; onError?: () => void }, { hasError: boolean } > { state = { hasError: false }; static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error: unknown) { console.warn('Gallery scene subtree failed, continuing without it.', error); this.props.onError?.(); } render() { if (this.state.hasError) return this.props.fallback ?? null; return this.props.children; } } interface BaseGalleryProps { imageRevisions?: Record; active?: boolean; onPaintingClick: (paintingId: number) => void; onBack: () => void; } interface ArtistGalleryProps extends BaseGalleryProps { mode: 'artist'; data: ArtistDetail; onNavigateArtist: (artistId: number) => void; onBioClick: () => void; } interface MovementGalleryProps extends BaseGalleryProps { mode: 'movement'; data: MovementGalleryDetail; } interface TourGalleryProps extends BaseGalleryProps { mode: 'tour'; data: TourGalleryDetail; } type Props = ArtistGalleryProps | MovementGalleryProps | TourGalleryProps; const WALL_HEIGHT = 4.2; const WALL_THICKNESS = 0.18; const MOUNT_OFFSET = 0.16; const WALL_STANDOFF = 0.07; const BACK_WALL_EXTRA = 0.05; const FRAME_FACE_Z = 0.018; const FRAME_DEPTH = 0.07; const FRAME_MAT_BORDER = 0.1; const FRAME_RAIL = 0.08; /** Checked paintings — double-width moulding. */ const REVIEWED_MAT_BORDER = FRAME_MAT_BORDER * 2; const REVIEWED_RAIL = FRAME_RAIL * 2; const EYE_HEIGHT = 1.65; const FRAME_GAP = 0.32; const SHADER_WARM_TIMEOUT_MS = 2000; /** Per-image fetch/decode deadline so a stuck request cannot hold the overlay counter forever. */ const TEXTURE_LOAD_TIMEOUT_MS = 20000; /** Cap parallel WebGL texture downloads — large halls otherwise stampede the browser pool. */ const MAX_PARALLEL_TEXTURE_LOADS = 8; const textureSlotWaiters: Array<() => void> = []; let textureLoadsInFlight = 0; function acquireTextureLoadSlot(): { promise: Promise<() => void>; cancel: () => void; } { let grantFn: (() => void) | null = null; let cancelled = false; const promise = new Promise<() => void>((resolve) => { grantFn = () => { if (cancelled) return; textureLoadsInFlight++; let released = false; resolve(() => { if (released) return; released = true; textureLoadsInFlight = Math.max(0, textureLoadsInFlight - 1); const next = textureSlotWaiters.shift(); if (next) next(); }); }; if (textureLoadsInFlight < MAX_PARALLEL_TEXTURE_LOADS) grantFn(); else textureSlotWaiters.push(grantFn); }); return { promise, cancel: () => { cancelled = true; if (grantFn) { const idx = textureSlotWaiters.indexOf(grantFn); if (idx >= 0) textureSlotWaiters.splice(idx, 1); } }, }; } const MIN_FRAME_W = 0.45; const MAX_FRAME_W = 1.05; const MAX_FRAME_H = 1.35; const MIN_HALL_SIZE = 9; const ROW_GAP = 0.2; const WALL_PADDING = 1.4; /** Every wall shows at most one row; side-wall depth grows to fit the catalog. */ const MAX_WALL_ROWS = 1; const DOOR_WIDTH = 2.4; /** Minimum horizontal distance from walls, corners, door planes, and painting faces. */ const PLAYER_CLEARANCE = 0.5; const DOOR_HEIGHT = 2.5; /** Museum exit — dimensions derived from door opening. */ const EXIT_JAMB = 0.13; const EXIT_HEADER = 0.15; const EXIT_TRANSOM = 0.52; const EXIT_SURROUND = 0.1; const TURN_SPEED = 0.032; const MOUSE_TURN_SENSITIVITY = 0.004; const DRAG_START_THRESHOLD_PX = 5; const DEFAULT_MOVEMENT_COLOR = '#8B7355'; const WALL_BASE_MAIN = '#f0ebe3'; const WALL_BASE_SIDE = '#e8e2d8'; function parseHex(hex: string): { r: number; g: number; b: number } { const n = parseInt(hex.replace('#', ''), 16); if (Number.isNaN(n)) return { r: 240, g: 235, b: 227 }; return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; } function blendHex(base: string, accent: string, accentWeight: number): string { const a = parseHex(accent); const b = parseHex(base); const w = Math.min(1, Math.max(0, accentWeight)); const mix = (ca: number, cb: number) => Math.round(ca * w + cb * (1 - w)); const r = mix(a.r, b.r); const g = mix(a.g, b.g); const bl = mix(a.b, b.b); return `#${((r << 16) | (g << 8) | bl).toString(16).padStart(6, '0')}`; } function galleryWallColors(movementColor?: string) { const accent = movementColor || DEFAULT_MOVEMENT_COLOR; return { main: blendHex(WALL_BASE_MAIN, accent, 0.34), side: blendHex(WALL_BASE_SIDE, accent, 0.26), trim: blendHex('#b8956a', accent, 0.45), }; } /** Height above frame top edge — keeps fixture out of the viewer's line of sight. */ const INFLUENCE_LAMP_ABOVE_FRAME = 0.46; type WallSide = 'back' | 'left' | 'right'; interface FrameSlot { position: [number, number, number]; rotationY: number; maxW: number; maxH: number; side: WallSide; } interface WallSegment { side: WallSide; label: string; paintings: Painting[]; slots: FrameSlot[]; } interface HallLayout { width: number; depth: number; segments: WallSegment[]; } interface XZBounds { minX: number; maxX: number; minZ: number; maxZ: number; } /** Inner wall face half-extents (room center → inside of wall box). */ function innerWallHalfExtents(width: number, depth: number): { halfW: number; halfD: number } { return { halfW: width / 2 - WALL_THICKNESS / 2, halfD: depth / 2 - WALL_THICKNESS / 2, }; } /** Keep-out box in front of a hanging painting (XZ), including player clearance. */ function paintingKeepOutBounds(slot: FrameSlot): XZBounds { const [px, , pz] = slot.position; const halfAlong = slot.maxW / 2 + FRAME_MAT_BORDER + FRAME_RAIL + 0.04; const intoRoom = FRAME_DEPTH + FRAME_FACE_Z + PLAYER_CLEARANCE; if (slot.side === 'left') { return { minX: px - 0.02, maxX: px + intoRoom, minZ: pz - halfAlong, maxZ: pz + halfAlong }; } if (slot.side === 'right') { return { minX: px - intoRoom, maxX: px + 0.02, minZ: pz - halfAlong, maxZ: pz + halfAlong }; } // back wall — faces into the room (+Z) return { minX: px - halfAlong, maxX: px + halfAlong, minZ: pz - 0.02, maxZ: pz + intoRoom }; } /** Door jamb keep-outs at an opening in the front (+Z) or back (−Z) wall. */ function doorJambKeepOuts(halfD: number, atFront: boolean): XZBounds[] { const jambZ = atFront ? halfD : -halfD; const along = PLAYER_CLEARANCE; const intoRoom = PLAYER_CLEARANCE; const leftX = -DOOR_WIDTH / 2; const rightX = DOOR_WIDTH / 2; if (atFront) { return [ { minX: leftX - along, maxX: leftX + along, minZ: jambZ - intoRoom, maxZ: jambZ + 0.02 }, { minX: rightX - along, maxX: rightX + along, minZ: jambZ - intoRoom, maxZ: jambZ + 0.02 }, ]; } return [ { minX: leftX - along, maxX: leftX + along, minZ: jambZ - 0.02, maxZ: jambZ + intoRoom }, { minX: rightX - along, maxX: rightX + along, minZ: jambZ - 0.02, maxZ: jambZ + intoRoom }, ]; } function pointInBounds(x: number, z: number, b: XZBounds): boolean { return x > b.minX && x < b.maxX && z > b.minZ && z < b.maxZ; } /** Push a point out of an AABB via the shortest axis (XZ). */ function pushOutOfBounds(pos: { x: number; z: number }, b: XZBounds): void { if (!pointInBounds(pos.x, pos.z, b)) return; const dxMin = pos.x - b.minX; const dxMax = b.maxX - pos.x; const dzMin = pos.z - b.minZ; const dzMax = b.maxZ - pos.z; const m = Math.min(dxMin, dxMax, dzMin, dzMax); if (m === dxMin) pos.x = b.minX; else if (m === dxMax) pos.x = b.maxX; else if (m === dzMin) pos.z = b.minZ; else pos.z = b.maxZ; } function paintingIsReviewed(painting: Painting): boolean { return !!painting.checkup_checked; } function frameDimsForReviewed(reviewed: boolean) { return reviewed ? { matBorder: REVIEWED_MAT_BORDER, rail: REVIEWED_RAIL, depth: FRAME_DEPTH * 1.08 } : { matBorder: FRAME_MAT_BORDER, rail: FRAME_RAIL, depth: FRAME_DEPTH }; } function frameOuterW(canvasW: number, reviewed: boolean): number { const { matBorder, rail } = frameDimsForReviewed(reviewed); return canvasW + matBorder * 2 + rail; } function frameOuterH(canvasH: number, reviewed: boolean): number { const { matBorder, rail } = frameDimsForReviewed(reviewed); return canvasH + matBorder * 2 + rail; } function layoutRow(paintings: Painting[], span: number) { const count = paintings.length; if (count === 0) return { slots: [], spanNeeded: span }; const gap = FRAME_GAP; const available = span - WALL_PADDING; let frameW = MAX_FRAME_W; for (let attempt = 0; attempt < 40; attempt++) { let total = 0; for (let i = 0; i < count; i++) { total += frameOuterW(frameW, paintingIsReviewed(paintings[i])); if (i < count - 1) total += gap; } if (total <= available) break; frameW -= 0.015; } frameW = Math.max(MIN_FRAME_W, frameW); const frameH = Math.min(MAX_FRAME_H, frameW * 1.22); const outers = paintings.map((p) => frameOuterW(frameW, paintingIsReviewed(p))); const rowWidth = outers.reduce((sum, w) => sum + w, 0) + (count - 1) * gap; const slots: { offset: number; maxW: number; maxH: number }[] = []; let cursor = -rowWidth / 2; for (let i = 0; i < count; i++) { const outerW = outers[i]; slots.push({ offset: cursor + outerW / 2, maxW: frameW, maxH: frameH, }); cursor += outerW + gap; } return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) }; } function wallRowHeights(paintings: Painting[], span: number, rows: number) { const perRow = Math.ceil(paintings.length / rows); const heights: number[] = []; for (let r = 0; r < rows; r++) { const rowStart = r * perRow; const row = paintings.slice(rowStart, rowStart + perRow); const { slots } = layoutRow(row, span); const reviewed = row.some(paintingIsReviewed); heights.push(frameOuterH(slots[0]?.maxH ?? MAX_FRAME_H, reviewed)); } return heights; } function wallStackHeight(heights: number[]) { return heights.reduce((sum, h, i) => sum + h + (i > 0 ? ROW_GAP : 0), 0); } function fitsOnWall(paintings: Painting[], span: number, rows: number) { const count = paintings.length; const perRow = Math.ceil(count / rows); const row = paintings.slice(0, perRow); const { slots } = layoutRow(row, span); if ((slots[0]?.maxW ?? 0) < MIN_FRAME_W) return false; const totalHeight = wallStackHeight(wallRowHeights(paintings, span, rows)); const bottom = EYE_HEIGHT - totalHeight / 2; const top = EYE_HEIGHT + totalHeight / 2; if (bottom < 0.35) return false; if (top > WALL_HEIGHT - 0.45) return false; return true; } function rowCountForWall(paintings: Painting[], span: number, maxRows: number = paintings.length) { const count = paintings.length; const limit = Math.max(1, Math.min(count, maxRows)); for (let rows = 1; rows <= limit; rows++) { if (fitsOnWall(paintings, span, rows)) return rows; } return limit; } function minSpanForWall(paintings: Painting[], maxRows: number = paintings.length) { const count = paintings.length; if (count === 0) return MIN_HALL_SIZE; let best = Infinity; const rowLimit = Math.max(1, Math.min(count, maxRows)); for (let rows = 1; rows <= rowLimit; rows++) { const perRow = Math.ceil(count / rows); const { spanNeeded } = layoutRow(paintings.slice(0, perRow), MIN_HALL_SIZE); if (fitsOnWall(paintings, spanNeeded, rows)) { best = Math.min(best, spanNeeded); } } if (best === Infinity) { const perRow = Math.ceil(count / rowLimit); best = layoutRow(paintings.slice(0, perRow), MIN_HALL_SIZE).spanNeeded; } return Math.max(MIN_HALL_SIZE, best); } /** * U-shaped visit order: * left wall (first, near entrance) → far/end wall → right wall (last, near entrance). * Fewer than 3 works keep the old left/right split so first stays left and last stays right. * The far wall is the solid wall ahead when entering (code side `'back'`). */ function distributePaintingsAcrossWalls(paintings: Painting[]) { const ordered = [...paintings].sort(comparePaintingsChronological); const n = ordered.length; if (n < 3) { const mid = Math.ceil(n / 2); return [[] as Painting[], ordered.slice(0, mid), ordered.slice(mid)]; } const q = Math.floor(n / 3); const r = n % 3; const leftCount = q + (r > 0 ? 1 : 0); const backCount = q + (r > 1 ? 1 : 0); return [ ordered.slice(leftCount, leftCount + backCount), ordered.slice(0, leftCount), ordered.slice(leftCount + backCount), ]; } function wallLabelForPaintings(wallPaintings: Painting[], periods: ArtistPeriod[]) { const periodIds = new Set( wallPaintings.map((p) => p.period_id).filter((id): id is number => id != null && id !== 0) ); const names = periods.filter((p) => periodIds.has(p.id)).map((p) => p.name); const hasUnassigned = wallPaintings.some((p) => !p.period_id); if (names.length > 0 && hasUnassigned) return `${names.join(' · ')} · Other`; if (names.length > 0) return names.join(' · '); if (hasUnassigned) return 'Other Works'; return wallPaintings.length > 0 ? 'Works' : ''; } function layoutWallSlots( paintings: Painting[], span: number, side: WallSide, halfW: number, halfD: number, inset: number, maxRows: number = paintings.length ): FrameSlot[] { const count = paintings.length; if (count === 0) return []; const rows = rowCountForWall(paintings, span, maxRows); const perRow = Math.ceil(count / rows); const slots: FrameSlot[] = []; const rowLayouts = []; for (let r = 0; r < rows; r++) { const rowStart = r * perRow; const inRow = Math.min(perRow, count - rowStart); rowLayouts.push(layoutRow(paintings.slice(rowStart, rowStart + inRow), span)); } const rowHeights = rowLayouts.map((row) => row.slots[0]?.maxH ?? MAX_FRAME_H); const totalHeight = wallStackHeight(rowHeights); let y = EYE_HEIGHT - totalHeight / 2 + rowHeights[0] / 2; for (let r = 0; r < rows; r++) { const { slots: rowSlots } = rowLayouts[r]; const rowFrameH = rowHeights[r]; for (let i = 0; i < rowSlots.length; i++) { const s = rowSlots[i]; if (side === 'back') { slots.push({ maxW: s.maxW, maxH: s.maxH, rotationY: 0, side, position: [s.offset, y, -halfD + inset + WALL_STANDOFF + BACK_WALL_EXTRA], }); } else if (side === 'left') { // Flip along-wall offset so index 0 is at the entrance (+Z), left of view. slots.push({ maxW: s.maxW, maxH: s.maxH, rotationY: Math.PI / 2, side, position: [-halfW + inset + WALL_STANDOFF, y, -s.offset], }); } else { slots.push({ maxW: s.maxW, maxH: s.maxH, rotationY: -Math.PI / 2, side, position: [halfW - inset - WALL_STANDOFF, y, s.offset], }); } } y += rowFrameH + ROW_GAP; } return slots; } function buildHallLayout(paintings: Painting[], periods: ArtistPeriod[]): HallLayout { const walls: WallSide[] = ['back', 'left', 'right']; const wallPaintings = distributePaintingsAcrossWalls(paintings); const [backPaintings, leftPaintings, rightPaintings] = wallPaintings; let width = minSpanForWall(backPaintings, MAX_WALL_ROWS); let depth = Math.max( minSpanForWall(leftPaintings, MAX_WALL_ROWS), minSpanForWall(rightPaintings, MAX_WALL_ROWS) ); for (let i = 0; i < 24; i++) { const nextWidth = minSpanForWall(backPaintings, MAX_WALL_ROWS); const nextDepth = Math.max( minSpanForWall(leftPaintings, MAX_WALL_ROWS), minSpanForWall(rightPaintings, MAX_WALL_ROWS) ); if (nextWidth === width && nextDepth === depth) break; width = nextWidth; depth = nextDepth; } width = Math.max(width, MIN_HALL_SIZE); depth = Math.max(depth, MIN_HALL_SIZE); const halfW = width / 2; const halfD = depth / 2; const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET; const segments: WallSegment[] = walls.map((side, i) => ({ side, label: wallLabelForPaintings(wallPaintings[i], periods), paintings: wallPaintings[i], slots: layoutWallSlots( wallPaintings[i], side === 'back' ? width : depth, side, halfW, halfD, inset, MAX_WALL_ROWS ), })); return { width, depth, segments }; } function computeFrameSize(aspect: number, maxW: number, maxH: number) { let w = maxW; let h = w / aspect; if (h > maxH) { h = maxH; w = h * aspect; } return { width: w, height: h }; } function paintingHasGalleryImage(painting: Painting) { return !!(painting.thumbnail_path || painting.image_path); } let canvasWeaveTexture: THREE.CanvasTexture | null = null; function getCanvasWeaveTexture() { if (canvasWeaveTexture) return canvasWeaveTexture; const size = 256; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); if (ctx) { ctx.fillStyle = '#ddd0b8'; ctx.fillRect(0, 0, size, size); for (let y = 0; y < size; y += 4) { for (let x = 0; x < size; x += 4) { ctx.fillStyle = (x + y) % 8 === 0 ? '#c4b494' : '#e4dac8'; ctx.fillRect(x, y, 4, 4); } } ctx.strokeStyle = 'rgba(72, 58, 42, 0.18)'; ctx.lineWidth = 1; for (let i = 0; i <= size; i += 8) { ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, size); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(size, i); ctx.stroke(); } } canvasWeaveTexture = new THREE.CanvasTexture(canvas); canvasWeaveTexture.wrapS = THREE.RepeatWrapping; canvasWeaveTexture.wrapT = THREE.RepeatWrapping; canvasWeaveTexture.colorSpace = THREE.SRGBColorSpace; return canvasWeaveTexture; } function frameFinish(reviewed: boolean, hovered: boolean) { if (reviewed) { return { color: hovered ? '#ffe566' : '#ffd700', roughness: 0.22, metalness: 0.78, emissive: hovered ? '#ffcc00' : '#daa520', emissiveIntensity: hovered ? 0.35 : 0.18, }; } return { color: hovered ? '#2a2a2a' : '#0a0a0a', roughness: 0.28, metalness: 0.72, emissive: '#000000', emissiveIntensity: 0, }; } function CanvasCover({ width, height, frameDepth, faceZ, matBorder, hovered, }: { width: number; height: number; frameDepth: number; faceZ: number; matBorder: number; hovered: boolean; }) { const weave = useMemo(() => { const tex = getCanvasWeaveTexture().clone(); tex.repeat.set(Math.max(3, width * 5), Math.max(3, height * 5)); return tex; }, [width, height]); useEffect(() => () => weave.dispose(), [weave]); const cloth = hovered ? '#ddd3bc' : '#c4b494'; const z = frameDepth + faceZ; return ( ); } function usePaintingTexture(urls: string[] | string | null) { const candidates = useMemo(() => { const list = Array.isArray(urls) ? urls.filter(Boolean) : urls ? [urls] : []; return list; }, [Array.isArray(urls) ? urls.join('|') : urls ?? '']); const candidateKey = candidates.join('|'); const [urlIndex, setUrlIndex] = useState(0); const url = candidates[urlIndex] ?? null; const [texture, setTexture] = useState(null); const [failed, setFailed] = useState(candidates.length === 0); const textureLoad = useContext(GalleryTextureLoadContext); const { gl } = useThree(); useEffect(() => { setUrlIndex(0); }, [candidateKey]); useEffect(() => { if (!url) { setTexture(null); setFailed(candidates.length === 0 || urlIndex >= candidates.length); return; } setFailed(false); setTexture(null); let disposed = false; let loaded: THREE.Texture | null = null; let settled = false; let releaseSlot: (() => void) | null = null; const loader = new THREE.TextureLoader(); // Relative /images URLs are same-origin via the Vite proxy — avoid CORS mode. if (/^https?:\/\//i.test(url)) { loader.setCrossOrigin('anonymous'); } const finish = () => { if (settled) return; settled = true; textureLoad?.end(); releaseSlot?.(); releaseSlot = null; }; textureLoad?.begin(); const loadTimeout = window.setTimeout(() => { if (settled || disposed) return; finish(); }, TEXTURE_LOAD_TIMEOUT_MS); const slot = acquireTextureLoadSlot(); void slot.promise.then((release) => { if (disposed) { release(); finish(); return; } releaseSlot = release; loader.load( url, (tex) => { window.clearTimeout(loadTimeout); if (disposed) { tex.dispose(); finish(); return; } const img = tex.image as HTMLImageElement | undefined; if (!img || img.width < 4 || img.height < 4) { tex.dispose(); finish(); if (!disposed) { if (urlIndex + 1 < candidates.length) setUrlIndex((i) => i + 1); else setFailed(true); } return; } loaded = tex; tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4; finish(); if (!disposed) { setFailed(false); setTexture(tex); } try { if (!disposed) gl.initTexture(tex); } catch { // Upload can fail after context loss; texture still usable later. } }, undefined, () => { window.clearTimeout(loadTimeout); finish(); if (!disposed) { if (urlIndex + 1 < candidates.length) setUrlIndex((i) => i + 1); else setFailed(true); } } ); }); return () => { disposed = true; slot.cancel(); window.clearTimeout(loadTimeout); finish(); loaded?.dispose(); setTexture(null); }; }, [url, urlIndex, candidates.length, textureLoad, gl]); return { texture, failed }; } function InfluencePictureLamp({ frameHeight, matBorder, frameDepth, highlighted, }: { frameHeight: number; matBorder: number; frameDepth: number; highlighted: boolean; }) { const mountY = frameHeight / 2 + matBorder + INFLUENCE_LAMP_ABOVE_FRAME; const glow = highlighted ? 1.6 : 1.15; const scale = 1.35; return ( {/* No per-painting lights — too many MeshStandard lights break hall shaders. */} ); } function CuratorNotesPlate({ frameWidth, frameHeight, matBorder, rail, frameDepth, faceZ, highlighted, }: { frameWidth: number; frameHeight: number; matBorder: number; rail: number; frameDepth: number; faceZ: number; highlighted: boolean; }) { const plateW = Math.min(0.28, Math.max(0.16, frameWidth * 0.42)); const plateH = 0.038; const plateD = 0.012; const y = -frameHeight / 2 - matBorder - rail - plateH / 2 - 0.028; const z = frameDepth + faceZ + 0.01; const brass = highlighted ? '#e8c76a' : '#d4af37'; const rim = highlighted ? '#a07828' : '#8a6820'; return ( {/* Slightly darker rim so the plate reads as a cast metal plaque */} {/* Soft engraved center band */} ); } function PaintingFrame({ painting, position, rotationY, maxWidth, maxHeight, wallSide, imageRevision, caption, onClick, }: { painting: Painting; position: [number, number, number]; rotationY: number; maxWidth: number; maxHeight: number; wallSide: WallSide; imageRevision?: number; caption?: string; onClick: () => void; }) { const [hovered, setHovered] = useState(false); const [aspect, setAspect] = useState(1.33); const { width, height } = computeFrameSize(aspect, maxWidth, maxHeight); const reviewed = paintingIsReviewed(painting); const { matBorder, rail, depth: frameDepth } = frameDimsForReviewed(reviewed); const hasImage = paintingHasGalleryImage(painting); const urls = hasImage ? galleryImageUrlCandidates(painting, imageRevision) : []; const { texture, failed } = usePaintingTexture(urls); const showImage = hasImage && !failed && !!texture; const showCanvas = !showImage; const hasInfluenceLinks = paintingHasInfluenceLinks(painting); const hasCuratorNotes = paintingHasCuratorNotes(painting); const finish = frameFinish(reviewed, hovered); const faceZ = FRAME_FACE_Z + (wallSide === 'back' ? 0.012 : 0); useEffect(() => { if (!showImage) return; const img = texture?.image as HTMLImageElement | undefined; if (img?.width && img.height) { setAspect(img.width / img.height); } }, [texture, showImage]); const captionY = -height / 2 - matBorder - rail - (hasCuratorNotes ? 0.18 : 0.1); // Hall lighting is shared (track/ambient). Per-frame spotLights (× dozens of // paintings) exceed WebGL light limits and make MeshStandard walls vanish. const frameEmissiveBoost = hovered ? 0.35 : showCanvas ? 0.08 : 0.12; return ( { e.stopPropagation(); onClick(); }} onPointerOver={() => setHovered(true)} onPointerOut={() => setHovered(false)} > {!showCanvas && ( )} {showCanvas ? ( ) : ( )} {hasInfluenceLinks && ( )} {hasCuratorNotes && ( )} {caption && ( {caption} )} ); } function GalleryWall({ position, size, rotation = [0, 0, 0], color = '#ebe4d8', }: { position: [number, number, number]; size: [number, number]; rotation?: [number, number, number]; color?: string; }) { const [w, h] = size; return ( ); } function ExitPortal({ position, active, onActivate, wallColor = '#f0ebe3', trimColor = '#ddd5c8', doorWood, }: { position: [number, number, number]; active: boolean; onActivate: () => void; wallColor?: string; trimColor?: string; doorWood?: [string, string, string]; }) { const [hovered, setHovered] = useState(false); const highlight = active || hovered; const openingW = DOOR_WIDTH; const openingH = DOOR_HEIGHT; const jamb = EXIT_JAMB; const header = EXIT_HEADER; const transomH = EXIT_TRANSOM; const surround = EXIT_SURROUND; const frameW = openingW + jamb * 2; const frameFullH = openingH + header + transomH; const leafW = (openingW - 0.025) / 2; const leafH = openingH - 0.08; const leafY = 0.04 + leafH / 2; const faceZ = -0.04; const woodDark = doorWood?.[0] ?? '#261a10'; const woodMid = doorWood?.[1] ?? '#3d2818'; const woodGrain = doorWood?.[2] ?? '#4e3624'; const stone = wallColor; const stoneDark = trimColor; const brass = highlight ? '#d4af37' : '#a08050'; const brassEmissive = highlight ? '#5a4010' : '#000000'; const corridorGlow = highlight ? '#fff0d0' : '#ffe8c0'; const handleActivate = (e: THREE.Event & { stopPropagation: () => void }) => { e.stopPropagation(); onActivate(); }; const setHover = (on: boolean) => () => setHovered(on); const doorLeaf = (side: 'left' | 'right') => { const x = side === 'left' ? -leafW / 2 - 0.006 : leafW / 2 + 0.006; const panelInset = 0.06; return ( {/* Raised panel */} {/* Stile edges */} {/* Brass handle */} ); }; return ( {/* Wall reveal — depth into opening */} {/* Warm vestibule glow beyond doors */} {/* Marble threshold */} {/* Door leaves */} {doorLeaf('left')} {doorLeaf('right')} {/* Center meeting stile / push bar */} {/* Side jambs */} {([-1, 1] as const).map((sign) => ( ))} {/* Header lintel */} {/* Transom — frosted museum glass */} {/* Transom mullions */} {[-0.35, 0, 0.35].map((ox) => ( ))} {/* Limestone surround */} {([-1, 1] as const).map((sign) => ( ))} {/* Crown on surround */} {/* Brass EXIT plaque */} EXIT {/* Wall sconces */} {([-1, 1] as const).map((sign) => ( ))} {/* Hinges */} {([-1, 1] as const).flatMap((side) => [0.35, 0.85, 1.35].map((hy) => ( )) )} {/* Large invisible click target */} ); } function TexturedWall({ kind, tint, position, size, rotation = [0, 0, 0], }: { kind: MovementInteriorStyle['surfaces']['wall']; tint: string; position: [number, number, number]; size: [number, number, number]; rotation?: [number, number, number]; }) { const [w, h, d] = size; const mat = useTexturedMaterial(kind, tint, Math.max(w, d), h); return ( ); } function TexturedCeiling({ kind, tint, width, depth, }: { kind: MovementInteriorStyle['surfaces']['ceiling']; tint: string; width: number; depth: number; }) { const mat = useTexturedMaterial(kind, tint, width + 0.4, depth + 0.4); return ( ); } function GalleryFloor({ width, depth, interiorStyle, }: { width: number; depth: number; interiorStyle: MovementInteriorStyle; }) { const floorW = width + 0.4; const floorD = depth + 0.4; const texture = useMemo(() => { const surf = interiorStyle.surfaces.floor; const meters = getSurfaceTexture(surf).metersPerRepeat; return cloneSurfaceTexture(surf, floorW / meters, floorD / meters); }, [interiorStyle.surfaces.floor, floorW, floorD]); useEffect( () => () => { texture.map.dispose(); texture.normalMap.dispose(); }, [texture] ); return ( ); } function ParquetFloor({ width, depth }: { width: number; depth: number }) { const mat = useTexturedMaterial('parquet-herringbone', '#c8a882', width + 0.4, depth + 0.4); return ( ); } function ArtistHall({ layout, hallTitle, hallSubtitle, movementColor, interiorStyle, imageRevisions, showCaptions = false, onPaintingClick, onExitActivate, nearExit, movementMode, computedWindows, hasNextHall, onNextHall, nearPassage, }: { layout: HallLayout | MovementHallLayout; hallTitle: string; hallSubtitle?: string; movementColor?: string; interiorStyle?: MovementInteriorStyle; imageRevisions?: Record; showCaptions?: boolean; onPaintingClick: (id: number) => void; onExitActivate: () => void; nearExit: boolean; movementMode?: boolean; computedWindows?: GalleryWindowSpec[]; hasNextHall?: boolean; onNextHall?: () => void; nearPassage?: boolean; }) { const { width, depth, segments } = layout; const endWallHasDoor = movementMode && 'endWallHasDoor' in layout ? layout.endWallHasDoor : false; const halfW = width / 2; const halfD = depth / 2; const walls = useMemo( () => interiorStyle ? { main: interiorStyle.tints.wall, side: interiorStyle.tints.wallSide ?? interiorStyle.tints.wall, trim: interiorStyle.tints.trim } : galleryWallColors(movementColor), [interiorStyle, movementColor] ); const titleColor = interiorStyle?.titleColor ?? '#4a3020'; const warmLight = interiorStyle?.warmLight ?? '#fff5e8'; const hallLightScale = interiorStyle?.lightScale ?? 1; const wallRoughness = interiorStyle ? 0.75 : 0.92; const wallMetalness = interiorStyle ? 0.08 : 0.06; const wallMaterial = (color: string) => ( ); return ( {interiorStyle ? ( <> ) : ( <> )} {interiorStyle ? ( <> {!movementMode && ( )} ) : ( <> {wallMaterial(walls.main)} {wallMaterial(walls.side)} {wallMaterial(walls.side)} )} {/* Front wall — next-wing passage, entrance exit (single-wing), or artist exit */} {movementMode && hasNextHall ? ( <> onNextHall?.()} wallColor={walls.main} trimColor={walls.trim} /> ) : movementMode && endWallHasDoor ? ( ) : ( <> )} {/* Back wall — multi-wing exit / navigator, or solid end wall for paintings */} {movementMode && endWallHasDoor ? ( <> ) : movementMode ? ( interiorStyle ? ( ) : ( ) ) : null} {interiorStyle && ( <> {computedWindows && computedWindows.length > 0 && ( )} {/* Always light the hall — do not gate track lights on window gaps. */} )} {interiorStyle?.tints.wallSide && interiorStyle.surfaces.wall !== interiorStyle.surfaces.wallSide && ( <> )} {/* Crown molding on back wall */} {interiorStyle && ( )} {hallTitle.toUpperCase()} {hallSubtitle && ( {hallSubtitle} )} {segments.map((seg) => ( {seg.paintings.map((painting, i) => ( onPaintingClick(painting.id)} /> ))} {seg.label && ( {seg.label} )} ))} {interiorStyle && ( )} ); } function levelHorizontalView(pos: THREE.Vector3, target: THREE.Vector3) { pos.y = EYE_HEIGHT; target.y = EYE_HEIGHT; } function FrameloopSync({ active }: { active: boolean }) { const { invalidate } = useThree(); useEffect(() => { if (active) invalidate(); }, [active, invalidate]); return null; } /** Compile all scene materials (incl. culled doors) before dismissing the loading overlay. */ function WarmHallGpu({ enabled, onDone, }: { enabled: boolean; onDone: () => void; }) { const { gl, scene, camera } = useThree(); useEffect(() => { if (!enabled) return; let cancelled = false; let settled = false; const done = () => { if (cancelled || settled) return; settled = true; onDone(); }; const run = async () => { try { const compile = typeof gl.compileAsync === 'function' ? gl.compileAsync(scene, camera) : Promise.resolve(gl.compile(scene, camera)); await Promise.race([ compile, new Promise((resolve) => { window.setTimeout(resolve, SHADER_WARM_TIMEOUT_MS); }), ]); } catch { // Still release the overlay if compile fails — better than hanging forever. } done(); }; void run(); return () => { cancelled = true; }; }, [enabled, gl, scene, camera, onDone]); return null; } function CameraController({ position, target, }: { position: THREE.Vector3; target: THREE.Vector3; }) { const { camera } = useThree(); useFrame(() => { levelHorizontalView(position, target); camera.position.lerp(position, 0.12); camera.lookAt(target.x, EYE_HEIGHT, target.z); }); return null; } function MovementHallNavPanel({ movementName, halls, currentIndex, onSelectHall, onExitTimeline, onClose, }: { movementName: string; halls: MovementHallLayout[]; currentIndex: number; onSelectHall: (index: number) => void; onExitTimeline: () => void; onClose: () => void; }) { return (

{movementName} — gallery wings

Choose a wing

    {halls.map((hall, i) => (
  • ))}
); } function NavigationPanel({ navigation, loading, onSelect, onExitTimeline, onClose, }: { navigation: ArtistNavigation | null; loading: boolean; onSelect: (artistId: number) => void; onExitTimeline: () => void; onClose: () => void; }) { const renderColumn = (title: string, groups: MovementArtistGroup[], emptyHint: string) => (

{title}

{loading &&

Loading…

} {!loading && groups.length === 0 &&

{emptyHint}

} {!loading && groups.map((group) => (

{group.movement_name}

    {group.artists.map((artist) => (
  • ))}
))}
); return (

Choose your path

{renderColumn( 'Predecessors', navigation?.predecessors ?? [], 'No documented predecessors via painting influences.' )} {renderColumn( 'Successors', navigation?.successors ?? [], 'No documented successors via painting influences.' )}
); } export default function VirtualGallery(props: Props) { const { imageRevisions, active = true, onPaintingClick, onBack, } = props; const isMovement = props.mode === 'movement'; const isTour = props.mode === 'tour'; const isWingedHall = isMovement || isTour; const hallKey = isTour ? props.data.tour.id : isMovement ? props.data.movement.id : props.data.artist.id; const hallTitle = isTour ? props.data.tour.title : isMovement ? props.data.movement.name : props.data.artist.name; const movementColor = isTour ? DEFAULT_MOVEMENT_COLOR : isMovement ? props.data.movement.color : props.data.artist.movement_color; const initialPaintings = useMemo(() => { if (props.mode === 'movement') { return [...props.data.paintings].sort(comparePaintingsChronological); } return props.data.paintings; }, [ props.mode, props.mode === 'tour' ? props.data.tour.id : props.mode === 'movement' ? props.data.movement.id : props.data.artist.id, props.data.paintings, ]); const initialPeriods = props.mode === 'artist' ? props.data.periods : []; const [paintings, setPaintings] = useState(initialPaintings); const [periods, setPeriods] = useState(initialPeriods); const [syncStatus, setSyncStatus] = useState(''); const [showExitNav, setShowExitNav] = useState(false); const [navigation, setNavigation] = useState(null); const [navLoading, setNavLoading] = useState(false); const [nearExit, setNearExit] = useState(false); const [nearPassage, setNearPassage] = useState(false); const [isLooking, setIsLooking] = useState(false); const [texturesPending, setTexturesPending] = useState(0); const [canvasReady, setCanvasReady] = useState(false); const [shadersWarmed, setShadersWarmed] = useState(false); const [glEpoch, setGlEpoch] = useState(0); const [glLost, setGlLost] = useState(false); const textureLoad = useMemo( () => ({ begin: () => setTexturesPending((n) => n + 1), end: () => setTexturesPending((n) => Math.max(0, n - 1)), }), [] ); const handleShadersWarmed = useCallback(() => { setShadersWarmed(true); }, []); const handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => { const canvas = state.gl.domElement; // Physically correct lights (three r155+) need higher exposure so stone/dark // period halls stay readable without HDR IBL. state.gl.toneMapping = THREE.ACESFilmicToneMapping; state.gl.toneMappingExposure = 1.25; // A freshly created canvas has a healthy context, so clear any lingering // "restoring" state from a previous loss/remount. setGlLost(false); setCanvasReady(true); const onLost = (event: Event) => { // Prevent the default so the browser can restore the context, and // force a clean remount to obtain a fresh WebGL context if it does not. event.preventDefault(); setGlLost(true); window.setTimeout(() => { setGlLost((stillLost) => { if (stillLost) setGlEpoch((n) => n + 1); return stillLost; }); }, 600); }; const onRestored = () => { setGlLost(false); setGlEpoch((n) => n + 1); }; canvas.addEventListener('webglcontextlost', onLost as EventListener, false); canvas.addEventListener('webglcontextrestored', onRestored as EventListener, false); }, []); useEffect(() => { setPaintings(initialPaintings); setPeriods(initialPeriods); }, [initialPaintings, initialPeriods, hallKey]); useEffect(() => { if (props.mode === 'movement') { const withImg = initialPaintings.filter((p) => p.image_path || p.thumbnail_path).length; setSyncStatus( withImg < initialPaintings.length ? `${withImg} of ${initialPaintings.length} works have images` : '' ); return; } const withImg = initialPaintings.filter((p) => p.image_path || p.thumbnail_path).length; setSyncStatus( withImg < initialPaintings.length ? `${withImg} of ${initialPaintings.length} works have images` : '' ); }, [hallKey, props.mode, initialPaintings]); const interiorStyle = useMemo( () => (isMovement ? resolveMovementInteriorStyle(props.data.movement) : undefined), [isMovement, isMovement ? props.data.movement : null] ); const movementHalls = useMemo( () => (isWingedHall ? buildAllMovementHallLayouts(paintings) : []), [isWingedHall, paintings] ); const [hallIndex, setHallIndex] = useState(0); useEffect(() => { setHallIndex(0); }, [hallKey]); useEffect(() => { setShadersWarmed(false); setTexturesPending(0); }, [hallKey, glEpoch]); useEffect(() => { setShadersWarmed(false); }, [hallIndex]); // If shader warm-up never settles, dismiss the overlay anyway. useEffect(() => { if (shadersWarmed || !canvasReady) return; const t = window.setTimeout(() => setShadersWarmed(true), SHADER_WARM_TIMEOUT_MS + 500); return () => window.clearTimeout(t); }, [shadersWarmed, canvasReady, hallKey, glEpoch, hallIndex]); const layout = useMemo(() => { if (isWingedHall && movementHalls.length > 0) { return movementHalls[Math.min(hallIndex, movementHalls.length - 1)]; } return buildHallLayout(paintings, periods); }, [isWingedHall, movementHalls, hallIndex, paintings, periods]); // Do not block hall entry on HDR Environment (CDN) — warm shaders as soon as // the canvas exists; Environment continues loading in the background. const gallerySceneLoading = active && !glLost && (!canvasReady || !shadersWarmed); const galleryLoadingMessage = canvasReady && texturesPending > 0 ? 'Loading paintings…' : 'Loading gallery…'; const warmGpuEnabled = canvasReady && !shadersWarmed; const computedWindows = useMemo(() => { if (!isMovement || !interiorStyle || !('hallIndex' in layout)) return undefined; return computeSideWallWindows(layout as MovementHallLayout, interiorStyle); }, [isMovement, interiorStyle, layout]); const hasNextHall = isWingedHall && movementHalls.length > 1 && hallIndex < movementHalls.length - 1; const { halfW: wallInnerHalfW, halfD: wallInnerHalfD } = useMemo( () => innerWallHalfExtents(layout.width, layout.depth), [layout.width, layout.depth] ); /** Playable half-extents: 0.5 m clear of inner wall faces (corners included). */ const playHalfW = wallInnerHalfW - PLAYER_CLEARANCE; const playHalfD = wallInnerHalfD - PLAYER_CLEARANCE; const exitZ = playHalfD; const fogFar = Math.max(55, layout.depth + 42); const collisionObstacles = useMemo(() => { const boxes: XZBounds[] = []; for (const seg of layout.segments) { for (const slot of seg.slots) boxes.push(paintingKeepOutBounds(slot)); } const endWallHasDoor = isWingedHall && 'endWallHasDoor' in layout ? layout.endWallHasDoor : false; // Front door/passage jambs when the entrance wall has an opening. if (!isWingedHall || hasNextHall || !endWallHasDoor) { boxes.push(...doorJambKeepOuts(wallInnerHalfD, true)); } // Back exit jambs for multi-wing halls. if (isWingedHall && endWallHasDoor) { boxes.push(...doorJambKeepOuts(wallInnerHalfD, false)); } return boxes; }, [layout, wallInnerHalfD, isWingedHall, hasNextHall]); const endWallHasDoor = isWingedHall && 'endWallHasDoor' in layout ? layout.endWallHasDoor : false; const exitOnFront = !isWingedHall || !endWallHasDoor; const resolvePlayerPosition = useCallback( (pos: { x: number; z: number }, allowFrontDoorApproach: boolean) => { const clampToWalls = () => { pos.x = Math.max(-playHalfW, Math.min(playHalfW, pos.x)); const inDoorBand = Math.abs(pos.x) < DOOR_WIDTH / 2 - PLAYER_CLEARANCE * 0.35; if (isWingedHall) { let minZ = -playHalfD; let maxZ = playHalfD; if (inDoorBand) { if (endWallHasDoor) minZ = -wallInnerHalfD + 0.08; if (hasNextHall || allowFrontDoorApproach || exitOnFront) { maxZ = wallInnerHalfD - 0.08; } } pos.z = Math.max(minZ, Math.min(maxZ, pos.z)); } else { let maxZ = exitZ; if (inDoorBand && allowFrontDoorApproach) maxZ = wallInnerHalfD - 0.08; pos.z = Math.max(-playHalfD, Math.min(maxZ, pos.z)); } }; clampToWalls(); for (const box of collisionObstacles) pushOutOfBounds(pos, box); clampToWalls(); }, [ playHalfW, playHalfD, exitZ, isWingedHall, hasNextHall, endWallHasDoor, exitOnFront, wallInnerHalfD, collisionObstacles, ] ); const initialPos = useMemo( () => new THREE.Vector3(0, EYE_HEIGHT, layout.depth / 2 - 2.2), [layout.depth] ); const initialTarget = useMemo( () => new THREE.Vector3(0, EYE_HEIGHT, -layout.depth * (layout.depth > 14 ? 0.38 : 0.25)), [layout.depth] ); const [camPos, setCamPos] = useState(() => initialPos.clone()); const [camTarget, setCamTarget] = useState(() => initialTarget.clone()); const keysPressed = useRef>(new Set()); const dragTurnActive = useRef(false); const dragStartRef = useRef<{ x: number; y: number } | null>(null); const camPosRef = useRef(camPos); const camTargetRef = useRef(camTarget); camPosRef.current = camPos; camTargetRef.current = camTarget; const goToHall = useCallback( (index: number, enterFrom: 'front' | 'back' | 'default' = 'default') => { const clamped = Math.max(0, Math.min(index, movementHalls.length - 1)); setHallIndex(clamped); setShowExitNav(false); setNearExit(false); setNearPassage(false); const nextLayout = movementHalls[clamped]; if (!nextLayout) return; const nextHalfD = nextLayout.depth / 2; if (enterFrom === 'back') { setCamPos(new THREE.Vector3(0, EYE_HEIGHT, -nextHalfD + 2.2)); setCamTarget(new THREE.Vector3(0, EYE_HEIGHT, nextHalfD * 0.25)); } else { setCamPos(new THREE.Vector3(0, EYE_HEIGHT, nextHalfD - 2.2)); setCamTarget(new THREE.Vector3(0, EYE_HEIGHT, -nextHalfD * 0.25)); } }, [movementHalls] ); const goToNextHall = useCallback(() => { if (hallIndex < movementHalls.length - 1) goToHall(hallIndex + 1, 'back'); }, [hallIndex, movementHalls.length, goToHall]); useEffect(() => { setCamPos(initialPos.clone()); setCamTarget(initialTarget.clone()); setShowExitNav(false); setNearExit(false); setNearPassage(false); }, [hallKey, initialPos, initialTarget]); const openExitNav = useCallback(async () => { if (isWingedHall) { setShowExitNav(true); return; } setShowExitNav(true); setNavLoading(true); try { const nav = await api.getArtistNavigation(props.data.artist.id); setNavigation(nav); } catch { setNavigation({ predecessors: [], successors: [] }); } finally { setNavLoading(false); } }, [isWingedHall, onBack, !isWingedHall ? props.data.artist.id : undefined]); const backExitZ = isWingedHall && endWallHasDoor ? -playHalfD : exitZ; const frontPassageZ = playHalfD; const updateProximityFlags = useCallback( (pos: { x: number; z: number }) => { if (isWingedHall) { const atBackExit = endWallHasDoor && pos.z < backExitZ + 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6; const atFrontExit = !endWallHasDoor && pos.z > frontPassageZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6; const atFrontPassage = hasNextHall && pos.z > frontPassageZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6; setNearExit(!!(atBackExit || atFrontExit)); setNearPassage(!!atFrontPassage); } else { const atExit = pos.z > exitZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6; setNearExit(atExit); } }, [isWingedHall, endWallHasDoor, backExitZ, frontPassageZ, hasNextHall, exitZ] ); const moveCamera = useCallback( (forward: number, strafe: number, rotY: number) => { const pos = camPosRef.current.clone(); const target = camTargetRef.current.clone(); const angle = Math.atan2(target.x - pos.x, target.z - pos.z); // Turn in place: never move; only change look direction. if (rotY !== 0) { const newAngle = angle + rotY; const dist = Math.max(0.5, pos.distanceTo(target)); target.x = pos.x + Math.sin(newAngle) * dist; target.z = pos.z + Math.cos(newAngle) * dist; levelHorizontalView(pos, target); updateProximityFlags(pos); setCamPos(pos); setCamTarget(target); return; } const dx = Math.sin(angle) * forward + Math.sin(angle + Math.PI / 2) * strafe; const dz = Math.cos(angle) * forward + Math.cos(angle + Math.PI / 2) * strafe; if (dx === 0 && dz === 0) { updateProximityFlags(pos); return; } const proposed = { x: pos.x + dx, z: pos.z + dz }; const resolved = { x: proposed.x, z: proposed.z }; resolvePlayerPosition(resolved, true); // Hit wall/painting/door jamb: cancel the whole step — no slide, no view change. if ( Math.abs(resolved.x - proposed.x) > 1e-4 || Math.abs(resolved.z - proposed.z) > 1e-4 ) { updateProximityFlags(pos); return; } pos.x = proposed.x; pos.z = proposed.z; target.x += dx; target.z += dz; levelHorizontalView(pos, target); updateProximityFlags(pos); setCamPos(pos); setCamTarget(target); }, [resolvePlayerPosition, updateProximityFlags] ); useEffect(() => { if (active) return; keysPressed.current.clear(); dragTurnActive.current = false; dragStartRef.current = null; setIsLooking(false); }, [active]); useEffect(() => { if (!active) return; const onKeyDown = (e: KeyboardEvent) => { keysPressed.current.add(e.key); if ((e.key === 'e' || e.key === 'E') && !showExitNav) { if (isWingedHall && nearPassage && hasNextHall) { goToNextHall(); } else { openExitNav(); } } }; const onKeyUp = (e: KeyboardEvent) => keysPressed.current.delete(e.key); window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp); const interval = setInterval(() => { const keys = keysPressed.current; if (keys.has('ArrowUp') || keys.has('w') || keys.has('W')) moveCamera(0.1, 0, 0); if (keys.has('ArrowDown') || keys.has('s') || keys.has('S')) moveCamera(-0.1, 0, 0); if (keys.has('ArrowLeft') || keys.has('a') || keys.has('A') || keys.has('q') || keys.has('Q')) { moveCamera(0, 0, TURN_SPEED); } if (keys.has('ArrowRight') || keys.has('d') || keys.has('D')) { moveCamera(0, 0, -TURN_SPEED); } }, 16); return () => { window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp); clearInterval(interval); }; }, [active, moveCamera, showExitNav, openExitNav, isWingedHall, nearPassage, hasNextHall, goToNextHall]); const handleNavigate = (artistId: number) => { if (isWingedHall) return; setShowExitNav(false); props.onNavigateArtist(artistId); }; const subtitle = isTour ? `Guided tour · ${paintings.length} works${ movementHalls.length > 1 ? ` · Wing ${hallIndex + 1}/${movementHalls.length}` : '' }` : isMovement ? interiorStyle ? `${interiorStyle.subtitle} · ${paintings.length} works${ movementHalls.length > 1 ? ` · Wing ${hallIndex + 1}/${movementHalls.length} (${(layout as MovementHallLayout).yearLabel})` : '' }` : `Movement gallery · ${paintings.length} works · chronological` : `Personal hall · ${paintings.length} works on the walls`; const sceneBackground = interiorStyle?.background ?? '#0d0906'; const sceneFog = interiorStyle?.fog ?? '#0d0906'; // Floor ambient so dark period styles (Gothic, Byzantine) stay readable without HDR IBL. // three r155+ physical lights need ~π× legacy intensity for similar brightness. const lightScale = interiorStyle?.lightScale ?? 1; const ambientIntensity = Math.max(0.85, interiorStyle?.ambient ?? 0.55) * Math.PI * lightScale; const handleCanvasPointerDown = (e: React.PointerEvent) => { if (!active || showExitNav || e.button !== 0) return; dragStartRef.current = { x: e.clientX, y: e.clientY }; }; const handleCanvasPointerMove = (e: React.PointerEvent) => { if (!active || showExitNav || !dragStartRef.current) return; const dx = e.clientX - dragStartRef.current.x; const dy = e.clientY - dragStartRef.current.y; const distSq = dx * dx + dy * dy; if (!dragTurnActive.current && distSq >= DRAG_START_THRESHOLD_PX * DRAG_START_THRESHOLD_PX) { dragTurnActive.current = true; setIsLooking(true); e.currentTarget.setPointerCapture(e.pointerId); } if (dragTurnActive.current && e.movementX !== 0) { moveCamera(0, 0, e.movementX * MOUSE_TURN_SENSITIVITY); } }; const endCanvasDrag = (e: React.PointerEvent) => { dragStartRef.current = null; dragTurnActive.current = false; setIsLooking(false); if (e.currentTarget.hasPointerCapture(e.pointerId)) { e.currentTarget.releasePointerCapture(e.pointerId); } }; const exitHint = isWingedHall ? ( <> Back wall: E for wing navigator · Front arch: next wing {movementHalls.length > 1 ? ` (${hallIndex + 1}/${movementHalls.length})` : ''} ) : ( <>Click the exit door, E, or Exit → above ); const hallSubtitle = isWingedHall && 'yearLabel' in layout ? `Wing ${hallIndex + 1} of ${movementHalls.length}${ isMovement ? ` · ${(layout as MovementHallLayout).yearLabel}` : '' }` : undefined; const instructionsTitle = isTour ? `${hallTitle} · Guided tour` : isMovement ? interiorStyle ? `${hallTitle} · ${interiorStyle.label}` : `${hallTitle} Gallery` : `${hallTitle}'s Hall`; return (

{hallTitle}

{subtitle}

{!isWingedHall && ( )}
{active && (gallerySceneLoading || glLost) && ( )} {syncStatus && !gallerySceneLoading && !glLost && (

{syncStatus}

)} {!showExitNav && (
{exitHint}
)} {/* Guaranteed fill so walls never disappear if HDR Environment fails. */}
{!isWingedHall && showExitNav && ( setShowExitNav(false)} /> )} {isWingedHall && showExitNav && ( goToHall(i)} onExitTimeline={onBack} onClose={() => setShowExitNav(false)} /> )}

{instructionsTitle}

  • W / Walk forward
  • S / Walk back
  • A / / Q Turn left
  • D / Turn right
  • Drag on the view to look around
  • Click a painting to view details{isTour ? ' and tour notes' : ' and influences'}
  • {isWingedHall ? ( <>
  • Date and artist labels appear below each frame
  • Works hang on left, end, and right walls — up to ~55 per wing
  • {movementHalls.length > 1 ? 'Back door: wing navigator & exit to timeline · Front: next wing' : 'Entrance door: exit to timeline'}
  • {movementHalls.length > 1 && (
  • Front archway: walk to the next chronological wing
  • )} ) : ( <>
  • Golden lamps mark works linked in the influence graph
  • Click the exit door or E for related artists or back to the timeline
  • )}
); }