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 { galleryImageUrlWithRevision, 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 }, { hasError: boolean } > { state = { hasError: false }; static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error: unknown) { console.warn('Gallery scene subtree failed, continuing without it.', error); } 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 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; 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[]; } 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(url: string | null) { const [texture, setTexture] = useState(null); const [failed, setFailed] = useState(!url); const textureLoad = useContext(GalleryTextureLoadContext); useEffect(() => { if (!url) { setTexture(null); setFailed(true); return; } setFailed(false); let disposed = false; let loaded: THREE.Texture | null = null; let settled = false; const loader = new THREE.TextureLoader(); loader.setCrossOrigin('anonymous'); const finish = () => { if (settled) return; settled = true; textureLoad?.end(); }; textureLoad?.begin(); loader.load( url, (tex) => { finish(); if (disposed) { tex.dispose(); return; } const img = tex.image as HTMLImageElement | undefined; if (!img || img.width < 4 || img.height < 4) { tex.dispose(); setFailed(true); return; } loaded = tex; tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4; setTexture(tex); }, undefined, () => { finish(); if (!disposed) setFailed(true); } ); return () => { disposed = true; finish(); loaded?.dispose(); setTexture(null); }; }, [url, textureLoad]); 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 ( ); } 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 url = hasImage ? galleryImageUrlWithRevision(painting, imageRevision) : null; const { texture, failed } = usePaintingTexture(url); 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); 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 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 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 — passage to next wing, or solid (artist exit / movement entrance) */} {movementMode && hasNextHall ? ( <> onNextHall?.()} wallColor={walls.main} trimColor={walls.trim} /> ) : movementMode ? ( <> ) : ( <> )} {/* Back wall — movement exit / navigation, or solid with title */} {movementMode ? ( <> ) : null} {interiorStyle && computedWindows && computedWindows.length > 0 && ( <> )} {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} )} ))} {depth > 14 && ( )} {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; } 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, onClose, }: { navigation: ArtistNavigation | null; loading: boolean; onSelect: (artistId: number) => 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 [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 handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => { const canvas = state.gl.domElement; // 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]); 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]); const gallerySceneLoading = active && !glLost && (!canvasReady || texturesPending > 0); const galleryLoadingMessage = !canvasReady ? 'Loading gallery…' : texturesPending > 0 ? 'Loading paintings…' : 'Loading gallery…'; 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 = layout.width / 2 - 0.55; const halfD = layout.depth / 2 - 0.35; const exitZ = layout.depth / 2 - 0.55; const fogFar = Math.max(55, layout.depth + 42); 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 ? -layout.depth / 2 + 0.55 : layout.depth / 2 - 0.55; const frontPassageZ = layout.depth / 2 - 0.55; 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); if (rotY !== 0) { const newAngle = angle + rotY; const dist = pos.distanceTo(target); target.x = pos.x + Math.sin(newAngle) * dist; target.z = pos.z + Math.cos(newAngle) * dist; } else { const newAngle = angle; pos.x += Math.sin(newAngle) * forward + Math.sin(newAngle + Math.PI / 2) * strafe; pos.z += Math.cos(newAngle) * forward + Math.cos(newAngle + Math.PI / 2) * strafe; target.x += Math.sin(newAngle) * forward + Math.sin(newAngle + Math.PI / 2) * strafe; target.z += Math.cos(newAngle) * forward + Math.cos(newAngle + Math.PI / 2) * strafe; } pos.x = Math.max(-halfW, Math.min(halfW, pos.x)); target.x = Math.max(-halfW, Math.min(halfW, target.x)); if (isWingedHall) { pos.z = Math.max(-halfD, Math.min(halfD, pos.z)); target.z = Math.max(-halfD, Math.min(halfD, target.z)); const atBackExit = pos.z < backExitZ + 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); setNearPassage(!!atFrontPassage); } else { pos.z = Math.max(-halfD, Math.min(exitZ, pos.z)); target.z = Math.max(-halfD, Math.min(exitZ, target.z)); const atExit = pos.z > exitZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6; setNearExit(atExit); } levelHorizontalView(pos, target); setCamPos(pos); setCamTarget(target); }, [halfW, halfD, exitZ, isWingedHall, backExitZ, frontPassageZ, hasNextHall] ); 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'; const ambientIntensity = interiorStyle?.ambient ?? 0.42; 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}
)}
{!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 & right walls — up to ~55 per wing
  • Back door: wing navigator & 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 to visit related artists
  • )}
); }