Files
Art-gallery/client/src/components/VirtualGallery.tsx
T
Danila KhodjaefandClaude Opus 5 1cb9eef935 Rework Byzantine hall as a late-antique basilica interior.
Replace the flat gold-mosaic chapel with marble revetment walls, a
Cosmatesque stone floor, and a coffered timber ceiling, matching
Santa Sabina / San Vitale reference interiors.

New procedural surfaces in galleryProceduralTextures.ts:
- marble-revetment: book-matched veined panels in moulded travertine
  surrounds (each panel mirrored about its centre)
- marble-opus-sectile: cut-stone bays with diagonal diamond inlays and
  corner triangles in porphyry, verd-antique, giallo, and grey
- coffered-wood: recessed walnut coffers with lit beam chamfers

Add a byzantine detail set to MovementHallDetails.tsx: engaged porphyry
colonnettes with white-marble basket capitals and impost blocks carrying
the ceiling, a marble revetment dado, and hanging brass polycandela.
Colonnettes stay flush in the corner pockets so they do not clip the
~0.7m frame hang margin.

Basilicas are lit by shafts from high windows rather than evenly
flooded, but the shared hall lights carry a brightness floor that
prevented dimming. Add an opt-in lightScale to the interior style that
scales the scene, hall, track, and HDR environment lights. It defaults
to 1, so all other halls are unchanged; only Byzantine opts down. Window
fill lights are left unscaled so the daylight shafts still read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:59:56 +03:00

2670 lines
86 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number, number>;
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 (
<group renderOrder={20}>
<mesh position={[0, 0, z]}>
<planeGeometry args={[width + matBorder * 1.6, height + matBorder * 1.6]} />
<meshBasicMaterial
map={weave}
color={cloth}
toneMapped={false}
depthWrite
polygonOffset
polygonOffsetFactor={-6}
polygonOffsetUnits={-6}
/>
</mesh>
<mesh position={[-width * 0.22, 0, z + 0.004]} rotation={[0, 0.22, 0.02]}>
<planeGeometry args={[width * 0.48, height * 0.98]} />
<meshBasicMaterial
map={weave}
color={hovered ? '#a89478' : '#958470'}
toneMapped={false}
transparent
opacity={0.55}
depthWrite={false}
polygonOffset
polygonOffsetFactor={-5}
polygonOffsetUnits={-5}
/>
</mesh>
</group>
);
}
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<THREE.Texture | null>(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 (
<group position={[0, mountY, frameDepth * 0.55]} rotation={[Math.PI, 0, 0]} scale={scale}>
<mesh position={[0, 0.04, -0.05]} renderOrder={30}>
<boxGeometry args={[0.11, 0.05, 0.05]} />
<meshStandardMaterial color="#2f2f2f" metalness={0.9} roughness={0.2} />
</mesh>
<mesh position={[0, 0.01, -0.01]} rotation={[0.25, 0, 0]} renderOrder={30}>
<boxGeometry args={[0.04, 0.08, 0.035]} />
<meshStandardMaterial color="#555555" metalness={0.8} roughness={0.3} />
</mesh>
<group position={[0, -0.02, 0.03]} rotation={[-0.55, 0, 0]}>
<mesh position={[0, 0, 0.04]} rotation={[Math.PI, 0, 0]} renderOrder={31}>
<coneGeometry args={[0.09, 0.1, 16, 1, true]} />
<meshStandardMaterial
color={highlighted ? '#f0d050' : '#d4af37'}
emissive={highlighted ? '#c89400' : '#8a6500'}
emissiveIntensity={0.75 * glow}
metalness={0.45}
roughness={0.35}
side={THREE.DoubleSide}
/>
</mesh>
<mesh position={[0, -0.04, 0.04]} renderOrder={32}>
<sphereGeometry args={[0.022, 10, 10]} />
<meshStandardMaterial
color="#fff8e0"
emissive="#ffcc55"
emissiveIntensity={1.8 * glow}
toneMapped={false}
/>
</mesh>
{/* No per-painting lights — too many MeshStandard lights break hall shaders. */}
</group>
</group>
);
}
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 (
<group position={[0, y, z]}>
{/* Slightly darker rim so the plate reads as a cast metal plaque */}
<mesh position={[0, 0, -0.001]} renderOrder={28}>
<boxGeometry args={[plateW + 0.012, plateH + 0.01, plateD]} />
<meshStandardMaterial color={rim} metalness={0.85} roughness={0.28} />
</mesh>
<mesh renderOrder={29}>
<boxGeometry args={[plateW, plateH, plateD]} />
<meshStandardMaterial
color={brass}
metalness={0.9}
roughness={0.22}
emissive={highlighted ? '#6a5010' : '#3a2a08'}
emissiveIntensity={highlighted ? 0.35 : 0.12}
/>
</mesh>
{/* Soft engraved center band */}
<mesh position={[0, 0, plateD / 2 + 0.001]} renderOrder={30}>
<planeGeometry args={[plateW * 0.72, plateH * 0.28]} />
<meshStandardMaterial
color={highlighted ? '#b8943a' : '#9a7828'}
metalness={0.7}
roughness={0.4}
/>
</mesh>
</group>
);
}
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 (
<group position={position} rotation={[0, rotationY, 0]}>
<mesh
position={[0, 0, frameDepth / 2]}
renderOrder={1}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<boxGeometry args={[width + matBorder * 2 + rail, height + matBorder * 2 + rail, frameDepth]} />
<meshStandardMaterial
color={finish.color}
roughness={finish.roughness}
metalness={finish.metalness}
emissive={reviewed ? finish.emissive : '#d8c090'}
emissiveIntensity={finish.emissiveIntensity + frameEmissiveBoost}
/>
</mesh>
{!showCanvas && (
<mesh position={[0, 0, frameDepth + 0.002]} renderOrder={2}>
<boxGeometry args={[width + matBorder * 2, height + matBorder * 2, 0.008]} />
<meshStandardMaterial
color={reviewed ? '#fff6cc' : '#f5f0e6'}
roughness={reviewed ? 0.75 : 0.95}
metalness={reviewed ? 0.15 : 0}
/>
</mesh>
)}
{showCanvas ? (
<CanvasCover
width={width}
height={height}
frameDepth={frameDepth}
faceZ={faceZ}
matBorder={matBorder}
hovered={hovered}
/>
) : (
<mesh position={[0, 0, frameDepth + faceZ]} renderOrder={20}>
<planeGeometry args={[width, height]} />
<meshBasicMaterial
map={texture}
color="#ffffff"
toneMapped={false}
polygonOffset
polygonOffsetFactor={-4}
polygonOffsetUnits={-4}
/>
</mesh>
)}
{hasInfluenceLinks && (
<InfluencePictureLamp
frameHeight={height}
matBorder={matBorder}
frameDepth={frameDepth}
highlighted={hovered}
/>
)}
{hasCuratorNotes && (
<CuratorNotesPlate
frameWidth={width}
frameHeight={height}
matBorder={matBorder}
rail={rail}
frameDepth={frameDepth}
faceZ={faceZ}
highlighted={hovered}
/>
)}
{caption && (
<Text
position={[0, captionY, frameDepth + faceZ + 0.02]}
fontSize={0.085}
maxWidth={Math.max(width + matBorder * 2, 0.55)}
color="#4a3828"
anchorX="center"
anchorY="top"
textAlign="center"
>
{caption}
</Text>
)}
</group>
);
}
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 (
<mesh position={position} rotation={rotation} renderOrder={0}>
<boxGeometry args={[w, h, WALL_THICKNESS]} />
<meshStandardMaterial color={color} roughness={0.92} />
</mesh>
);
}
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 (
<group key={side} position={[x, leafY, faceZ]}>
<mesh>
<boxGeometry args={[leafW, leafH, 0.055]} />
<meshStandardMaterial color={woodMid} roughness={0.62} metalness={0.04} />
</mesh>
{/* Raised panel */}
<mesh position={[0, 0.08, 0.03]}>
<boxGeometry args={[leafW - panelInset * 2, leafH * 0.38, 0.012]} />
<meshStandardMaterial color={woodGrain} roughness={0.58} metalness={0.03} />
</mesh>
<mesh position={[0, -leafH * 0.22, 0.03]}>
<boxGeometry args={[leafW - panelInset * 2, leafH * 0.32, 0.012]} />
<meshStandardMaterial color={woodDark} roughness={0.65} metalness={0.02} />
</mesh>
{/* Stile edges */}
<mesh position={[side === 'left' ? leafW / 2 - 0.025 : -leafW / 2 + 0.025, 0, 0.028]}>
<boxGeometry args={[0.05, leafH, 0.008]} />
<meshStandardMaterial color={woodDark} roughness={0.7} />
</mesh>
{/* Brass handle */}
<mesh position={[side === 'left' ? leafW / 2 - 0.1 : -leafW / 2 + 0.1, 0, 0.038]} rotation={[0, 0, side === 'left' ? 0.08 : -0.08]}>
<boxGeometry args={[0.04, 0.22, 0.025]} />
<meshStandardMaterial
color={brass}
roughness={0.22}
metalness={0.88}
emissive={brassEmissive}
emissiveIntensity={highlight ? 0.15 : 0}
/>
</mesh>
</group>
);
};
return (
<group position={position}>
{/* Wall reveal — depth into opening */}
<mesh position={[0, frameFullH / 2, 0.02]}>
<boxGeometry args={[openingW - 0.04, frameFullH - 0.04, WALL_THICKNESS - 0.04]} />
<meshStandardMaterial color="#1a1410" roughness={0.95} />
</mesh>
{/* Warm vestibule glow beyond doors */}
<pointLight position={[0, openingH * 0.55, 0.22]} intensity={highlight ? 1.1 : 0.65} distance={4.5} color={corridorGlow} />
<mesh position={[0, openingH * 0.55, 0.1]}>
<planeGeometry args={[openingW * 0.85, openingH * 0.9]} />
<meshStandardMaterial
color="#fff4e8"
emissive="#ffdcb0"
emissiveIntensity={highlight ? 0.35 : 0.18}
roughness={0.9}
/>
</mesh>
{/* Marble threshold */}
<mesh position={[0, 0.025, faceZ + 0.04]}>
<boxGeometry args={[frameW + 0.12, 0.05, 0.14]} />
<meshStandardMaterial color="#e8e4dc" roughness={0.28} metalness={0.08} />
</mesh>
<mesh position={[0, 0.052, faceZ + 0.05]}>
<boxGeometry args={[frameW + 0.08, 0.006, 0.1]} />
<meshStandardMaterial color="#b8860b" roughness={0.3} metalness={0.75} />
</mesh>
{/* Door leaves */}
{doorLeaf('left')}
{doorLeaf('right')}
{/* Center meeting stile / push bar */}
<mesh position={[0, leafY, faceZ + 0.042]}>
<boxGeometry args={[0.035, leafH * 0.72, 0.018]} />
<meshStandardMaterial
color={brass}
roughness={0.2}
metalness={0.9}
emissive={brassEmissive}
emissiveIntensity={highlight ? 0.2 : 0}
/>
</mesh>
{/* Side jambs */}
{([-1, 1] as const).map((sign) => (
<mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), frameFullH / 2, faceZ + 0.01]}>
<boxGeometry args={[jamb, frameFullH, 0.1]} />
<meshStandardMaterial color={woodDark} roughness={0.55} metalness={0.06} />
</mesh>
))}
{/* Header lintel */}
<mesh position={[0, openingH + header / 2, faceZ + 0.01]}>
<boxGeometry args={[frameW, header, 0.1]} />
<meshStandardMaterial color={woodMid} roughness={0.58} metalness={0.05} />
</mesh>
{/* Transom — frosted museum glass */}
<mesh position={[0, openingH + header + transomH / 2, faceZ + 0.02]}>
<boxGeometry args={[openingW - 0.06, transomH - 0.06, 0.025]} />
<meshStandardMaterial
color="#f5f0e8"
emissive={highlight ? '#fff0d8' : '#ffeacc'}
emissiveIntensity={highlight ? 0.25 : 0.12}
roughness={0.15}
metalness={0.05}
transparent
opacity={0.88}
/>
</mesh>
{/* Transom mullions */}
{[-0.35, 0, 0.35].map((ox) => (
<mesh key={ox} position={[ox, openingH + header + transomH / 2, faceZ + 0.035]}>
<boxGeometry args={[0.04, transomH - 0.1, 0.012]} />
<meshStandardMaterial color={woodDark} roughness={0.6} />
</mesh>
))}
{/* Limestone surround */}
{([-1, 1] as const).map((sign) => (
<mesh
key={`sur-${sign}`}
position={[sign * (openingW / 2 + jamb + surround / 2), frameFullH / 2, faceZ - 0.01]}
>
<boxGeometry args={[surround, frameFullH + 0.08, 0.08]} />
<meshStandardMaterial color={stone} roughness={0.88} />
</mesh>
))}
<mesh position={[0, frameFullH + 0.04, faceZ - 0.01]}>
<boxGeometry args={[frameW + surround * 2, 0.12, 0.08]} />
<meshStandardMaterial color={stoneDark} roughness={0.82} metalness={0.08} />
</mesh>
{/* Crown on surround */}
<mesh position={[0, frameFullH + 0.12, faceZ - 0.005]}>
<boxGeometry args={[frameW + surround * 2 + 0.06, 0.08, 0.1]} />
<meshStandardMaterial color={stoneDark} roughness={0.5} metalness={0.2} />
</mesh>
{/* Brass EXIT plaque */}
<group position={[0, openingH + header + transomH + 0.18, faceZ - 0.02]}>
<mesh
onClick={handleActivate}
onPointerOver={setHover(true)}
onPointerOut={setHover(false)}
>
<boxGeometry args={[0.55, 0.14, 0.018]} />
<meshStandardMaterial
color={brass}
roughness={0.25}
metalness={0.85}
emissive={brassEmissive}
emissiveIntensity={highlight ? 0.25 : 0.05}
/>
</mesh>
<Text
position={[0, 0, -0.012]}
rotation={[0, Math.PI, 0]}
fontSize={0.09}
color={highlight ? '#fff8e8' : '#3d2818'}
anchorX="center"
anchorY="middle"
onClick={handleActivate}
onPointerOver={setHover(true)}
onPointerOut={setHover(false)}
>
EXIT
</Text>
</group>
{/* Wall sconces */}
{([-1, 1] as const).map((sign) => (
<group key={`sconce-${sign}`} position={[sign * (frameW / 2 + surround + 0.18), openingH + 0.35, faceZ - 0.02]}>
<mesh>
<boxGeometry args={[0.08, 0.22, 0.1]} />
<meshStandardMaterial color={brass} roughness={0.3} metalness={0.8} />
</mesh>
<mesh position={[0, -0.08, -0.04]} rotation={[Math.PI / 2, 0, 0]}>
<coneGeometry args={[0.07, 0.12, 8, 1, true]} />
<meshStandardMaterial
color="#fff4e0"
emissive="#ffdcb0"
emissiveIntensity={highlight ? 0.5 : 0.28}
roughness={0.4}
side={THREE.DoubleSide}
/>
</mesh>
<pointLight
position={[0, -0.12, -0.06]}
intensity={highlight ? 0.45 : 0.22}
distance={2.2}
color="#fff0d8"
/>
</group>
))}
{/* Hinges */}
{([-1, 1] as const).flatMap((side) =>
[0.35, 0.85, 1.35].map((hy) => (
<mesh
key={`hinge-${side}-${hy}`}
position={[side * (openingW / 2 + 0.02), hy, faceZ + 0.03]}
rotation={[0, 0, side === -1 ? 0 : Math.PI]}
>
<boxGeometry args={[0.025, 0.1, 0.04]} />
<meshStandardMaterial color={brass} roughness={0.28} metalness={0.85} />
</mesh>
))
)}
{/* Large invisible click target */}
<mesh
position={[0, frameFullH / 2, faceZ - 0.15]}
rotation={[0, Math.PI, 0]}
onClick={handleActivate}
onPointerOver={setHover(true)}
onPointerOut={setHover(false)}
>
<planeGeometry args={[DOOR_WIDTH * 2.2, frameFullH * 1.15]} />
<meshBasicMaterial transparent opacity={0} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
</group>
);
}
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 (
<mesh position={position} rotation={rotation} material={mat} renderOrder={0}>
<boxGeometry args={size} />
</mesh>
);
}
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 (
<mesh rotation={[Math.PI / 2, 0, 0]} position={[0, WALL_HEIGHT, 0]} material={mat}>
<planeGeometry args={[width + 0.4, depth + 0.4]} />
</mesh>
);
}
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 (
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.002, 0]}>
<planeGeometry args={[floorW, floorD]} />
<meshStandardMaterial
map={texture.map}
normalMap={texture.normalMap}
normalScale={new THREE.Vector2(texture.normalScale, texture.normalScale)}
roughness={texture.roughness}
metalness={texture.metalness}
envMapIntensity={0.3}
color={interiorStyle.tints.floor}
/>
</mesh>
);
}
function ParquetFloor({ width, depth }: { width: number; depth: number }) {
const mat = useTexturedMaterial('parquet-herringbone', '#c8a882', width + 0.4, depth + 0.4);
return (
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.002, 0]} material={mat}>
<planeGeometry args={[width + 0.4, depth + 0.4]} />
</mesh>
);
}
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<number, number>;
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) => (
<meshStandardMaterial color={color} roughness={wallRoughness} metalness={wallMetalness} />
);
return (
<group>
{interiorStyle ? (
<>
<GalleryFloor width={width} depth={depth} interiorStyle={interiorStyle} />
<TexturedCeiling
kind={interiorStyle.surfaces.ceiling}
tint={interiorStyle.tints.ceiling}
width={width}
depth={depth}
/>
</>
) : (
<>
<ParquetFloor width={width} depth={depth} />
<mesh rotation={[Math.PI / 2, 0, 0]} position={[0, WALL_HEIGHT, 0]}>
<planeGeometry args={[width + 0.4, depth + 0.4]} />
<meshStandardMaterial color="#2a2018" roughness={0.9} />
</mesh>
</>
)}
{interiorStyle ? (
<>
{!movementMode && (
<TexturedWall
kind={interiorStyle.surfaces.wall}
tint={interiorStyle.tints.wall}
position={[0, WALL_HEIGHT / 2, -halfD]}
size={[width, WALL_HEIGHT, WALL_THICKNESS]}
/>
)}
<TexturedWall
kind={interiorStyle.surfaces.wallSide ?? interiorStyle.surfaces.wall}
tint={interiorStyle.tints.wallSide ?? interiorStyle.tints.wall}
position={[-halfW, WALL_HEIGHT / 2, 0]}
size={[depth, WALL_HEIGHT, WALL_THICKNESS]}
rotation={[0, Math.PI / 2, 0]}
/>
<TexturedWall
kind={interiorStyle.surfaces.wallSide ?? interiorStyle.surfaces.wall}
tint={interiorStyle.tints.wallSide ?? interiorStyle.tints.wall}
position={[halfW, WALL_HEIGHT / 2, 0]}
size={[depth, WALL_HEIGHT, WALL_THICKNESS]}
rotation={[0, Math.PI / 2, 0]}
/>
</>
) : (
<>
<mesh position={[0, WALL_HEIGHT / 2, -halfD]} renderOrder={0}>
<boxGeometry args={[width, WALL_HEIGHT, WALL_THICKNESS]} />
{wallMaterial(walls.main)}
</mesh>
<mesh position={[-halfW, WALL_HEIGHT / 2, 0]} rotation={[0, Math.PI / 2, 0]} renderOrder={0}>
<boxGeometry args={[depth, WALL_HEIGHT, WALL_THICKNESS]} />
{wallMaterial(walls.side)}
</mesh>
<mesh position={[halfW, WALL_HEIGHT / 2, 0]} rotation={[0, Math.PI / 2, 0]} renderOrder={0}>
<boxGeometry args={[depth, WALL_HEIGHT, WALL_THICKNESS]} />
{wallMaterial(walls.side)}
</mesh>
</>
)}
{/* Front wall — next-wing passage, entrance exit (single-wing), or artist exit */}
{movementMode && hasNextHall ? (
<>
<GalleryWall
position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
color={walls.main}
/>
<GalleryWall
position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
color={walls.main}
/>
<GalleryWall
position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, halfD]}
size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]}
color={walls.main}
/>
<HallPassage
position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]}
active={!!nearPassage}
label="Next wing →"
onActivate={() => onNextHall?.()}
wallColor={walls.main}
trimColor={walls.trim}
/>
</>
) : movementMode && endWallHasDoor ? (
<GalleryWall position={[0, WALL_HEIGHT / 2, halfD]} size={[width, WALL_HEIGHT]} color={walls.main} />
) : (
<>
<GalleryWall position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} color={walls.main} />
<GalleryWall position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} color={walls.main} />
<GalleryWall position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, halfD]} size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]} color={walls.main} />
<ExitPortal
position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]}
active={nearExit}
onActivate={onExitActivate}
wallColor={walls.main}
trimColor={walls.trim}
doorWood={interiorStyle?.doorWood}
/>
</>
)}
{/* Back wall — multi-wing exit / navigator, or solid end wall for paintings */}
{movementMode && endWallHasDoor ? (
<>
<GalleryWall
position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, -halfD]}
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
color={walls.main}
/>
<GalleryWall
position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, -halfD]}
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
color={walls.main}
/>
<GalleryWall
position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, -halfD]}
size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]}
color={walls.main}
/>
<group position={[0, 0, -halfD + WALL_THICKNESS / 2 + 0.02]} rotation={[0, Math.PI, 0]}>
<ExitPortal
position={[0, 0, 0]}
active={nearExit}
onActivate={onExitActivate}
wallColor={walls.main}
trimColor={walls.trim}
doorWood={interiorStyle?.doorWood}
/>
</group>
</>
) : movementMode ? (
interiorStyle ? (
<TexturedWall
kind={interiorStyle.surfaces.wall}
tint={interiorStyle.tints.wall}
position={[0, WALL_HEIGHT / 2, -halfD]}
size={[width, WALL_HEIGHT, WALL_THICKNESS]}
/>
) : (
<GalleryWall position={[0, WALL_HEIGHT / 2, -halfD]} size={[width, WALL_HEIGHT]} color={walls.main} />
)
) : null}
{interiorStyle && (
<>
{computedWindows && computedWindows.length > 0 && (
<GalleryWindows
windows={computedWindows}
halfW={halfW}
halfD={halfD}
trimColor={interiorStyle.tints.trim}
/>
)}
{/* Always light the hall — do not gate track lights on window gaps. */}
<GalleryTrackLights
width={width}
depth={depth}
intensity={Math.max(1.8, interiorStyle.trackLights * 2.8) * Math.PI * interiorStyle.lightScale}
color={interiorStyle.warmLight}
/>
</>
)}
{interiorStyle?.tints.wallSide && interiorStyle.surfaces.wall !== interiorStyle.surfaces.wallSide && (
<>
<mesh position={[0, 1.05, -halfD + WALL_THICKNESS / 2]}>
<boxGeometry args={[width, 2.1, 0.04]} />
<meshStandardMaterial color={interiorStyle.tints.trim} roughness={0.45} metalness={0.35} />
</mesh>
</>
)}
{/* Crown molding on back wall */}
<mesh position={[0, WALL_HEIGHT - 0.08, -halfD + WALL_THICKNESS / 2]}>
<boxGeometry args={[width, 0.14, 0.1]} />
<meshStandardMaterial color={walls.trim} roughness={0.5} metalness={0.25} />
</mesh>
{interiorStyle && (
<MovementHallDetails style={interiorStyle} width={width} depth={depth} halfW={halfW} halfD={halfD} />
)}
<Text
position={[0, WALL_HEIGHT - 0.45, -halfD + WALL_THICKNESS + 0.02]}
fontSize={0.28}
color={titleColor}
anchorX="center"
>
{hallTitle.toUpperCase()}
</Text>
{hallSubtitle && (
<Text
position={[0, WALL_HEIGHT - 0.72, -halfD + WALL_THICKNESS + 0.02]}
fontSize={0.12}
color={titleColor}
anchorX="center"
>
{hallSubtitle}
</Text>
)}
{segments.map((seg) => (
<group key={`${seg.side}-${seg.label}`}>
{seg.paintings.map((painting, i) => (
<PaintingFrame
key={`${painting.id}-${imageRevisions?.[painting.id] ?? 0}`}
painting={painting}
position={seg.slots[i].position}
rotationY={seg.slots[i].rotationY}
maxWidth={seg.slots[i].maxW}
maxHeight={seg.slots[i].maxH}
wallSide={seg.slots[i].side}
imageRevision={imageRevisions?.[painting.id]}
caption={showCaptions ? paintingWallCaption(painting) : undefined}
onClick={() => onPaintingClick(painting.id)}
/>
))}
{seg.label && (
<Text
position={[
seg.side === 'back' ? 0 : seg.side === 'left' ? -halfW + 0.15 : halfW - 0.15,
WALL_HEIGHT - 0.85,
seg.side === 'back' ? -halfD + 0.15 : 0,
]}
rotation={[0, seg.side === 'left' ? Math.PI / 2 : seg.side === 'right' ? -Math.PI / 2 : 0, 0]}
fontSize={0.14}
color="#6b5344"
anchorX="center"
>
{seg.label}
</Text>
)}
</group>
))}
<pointLight
position={[0, WALL_HEIGHT - 0.4, 0]}
intensity={(interiorStyle ? Math.max(1.0, interiorStyle.ambient * 1.8) : 0.7) * Math.PI * hallLightScale}
distance={width + depth}
decay={0}
color={warmLight}
/>
<pointLight
position={[0, WALL_HEIGHT - 0.4, -halfD / 2]}
intensity={(interiorStyle ? Math.max(0.7, interiorStyle.ambient * 1.2) : 0.5) * Math.PI * hallLightScale}
distance={Math.max(12, depth * 0.75)}
decay={0}
color={warmLight}
/>
{interiorStyle && (
<directionalLight
position={[0, 6, -halfD - 2]}
intensity={Math.max(0.55, interiorStyle.ambient * 0.85) * Math.PI * hallLightScale}
color={interiorStyle.sunLight}
/>
)}
</group>
);
}
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<void>((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 (
<div className="exit-nav-overlay" role="dialog" aria-modal="true">
<div className="exit-nav-panel">
<header className="exit-nav-header">
<h2>{movementName} gallery wings</h2>
<button type="button" className="exit-nav-close" onClick={onClose} aria-label="Close">
×
</button>
</header>
<div className="exit-nav-columns" style={{ gridTemplateColumns: '1fr' }}>
<div className="exit-nav-column">
<h3>Choose a wing</h3>
<ul className="movement-hall-nav-list">
{halls.map((hall, i) => (
<li key={i}>
<button
type="button"
className={i === currentIndex ? 'movement-hall-nav-active' : undefined}
onClick={() => onSelectHall(i)}
>
<span>
Wing {i + 1} of {hall.hallCount}
<small>
{hall.yearLabel} · {hall.paintingCount} works
{i === currentIndex ? ' · you are here' : ''}
</small>
</span>
</button>
</li>
))}
</ul>
<button type="button" className="gallery-exit-btn movement-hall-exit-timeline" onClick={onExitTimeline}>
Back to Timeline
</button>
</div>
</div>
</div>
</div>
);
}
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) => (
<div className="exit-nav-column">
<h3>{title}</h3>
{loading && <p className="exit-nav-loading">Loading</p>}
{!loading && groups.length === 0 && <p className="exit-nav-empty">{emptyHint}</p>}
{!loading &&
groups.map((group) => (
<div key={group.movement_id ?? group.movement_name} className="exit-nav-movement">
<h4 style={{ borderColor: group.movement_color }}>{group.movement_name}</h4>
<ul>
{group.artists.map((artist) => (
<li key={artist.id}>
<button type="button" onClick={() => onSelect(artist.id)}>
<img src={imageUrl(artist.portrait_path)} alt="" />
<span>
{artist.name}
{artist.birth_year && (
<small>
{artist.birth_year}
{artist.death_year ? `${artist.death_year}` : ''}
</small>
)}
</span>
</button>
</li>
))}
</ul>
</div>
))}
</div>
);
return (
<div className="exit-nav-overlay" role="dialog" aria-modal="true">
<div className="exit-nav-panel">
<header className="exit-nav-header">
<h2>Choose your path</h2>
<button type="button" className="exit-nav-close" onClick={onClose} aria-label="Close">
×
</button>
</header>
<div className="exit-nav-columns">
{renderColumn(
'Predecessors',
navigation?.predecessors ?? [],
'No documented predecessors via painting influences.'
)}
{renderColumn(
'Successors',
navigation?.successors ?? [],
'No documented successors via painting influences.'
)}
</div>
<div className="exit-nav-footer">
<button type="button" className="gallery-exit-btn movement-hall-exit-timeline" onClick={onExitTimeline}>
Back to Timeline
</button>
</div>
</div>
</div>
);
}
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<ArtistNavigation | null>(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<Set<string>>(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<HTMLDivElement>) => {
if (!active || showExitNav || e.button !== 0) return;
dragStartRef.current = { x: e.clientX, y: e.clientY };
};
const handleCanvasPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
dragStartRef.current = null;
dragTurnActive.current = false;
setIsLooking(false);
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
};
const exitHint = isWingedHall ? (
<>
Back wall: <kbd>E</kbd> for wing navigator · Front arch: next wing
{movementHalls.length > 1 ? ` (${hallIndex + 1}/${movementHalls.length})` : ''}
</>
) : (
<>Click the exit door, <kbd>E</kbd>, or <strong>Exit </strong> 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 (
<div className="virtual-gallery">
<div className="gallery-header">
<button className="gallery-back-btn" onClick={onBack}> Back to Timeline</button>
<div className="gallery-title-block">
<h2>{hallTitle}</h2>
<p className="gallery-career-path">{subtitle}</p>
</div>
<div className="gallery-header-meta">
<button type="button" className="gallery-exit-btn" onClick={openExitNav}>
{isWingedHall ? 'Wings / Exit →' : 'Exit →'}
</button>
{!isWingedHall && (
<button className="gallery-bio-btn" onClick={props.onBioClick}>Biography</button>
)}
</div>
</div>
<div
className={`gallery-canvas-container${isLooking ? ' gallery-canvas-dragging' : ''}`}
onPointerDown={handleCanvasPointerDown}
onPointerMove={handleCanvasPointerMove}
onPointerUp={endCanvasDrag}
onPointerLeave={endCanvasDrag}
onPointerCancel={endCanvasDrag}
>
{active && (gallerySceneLoading || glLost) && (
<GalleryLoadingMarker
overlay
message={glLost ? 'Restoring gallery…' : galleryLoadingMessage}
/>
)}
{syncStatus && !gallerySceneLoading && !glLost && (
<div className="gallery-loading-overlay gallery-sync-badge">
<p>{syncStatus}</p>
</div>
)}
{!showExitNav && (
<div className="gallery-exit-hint">{exitHint}</div>
)}
<Canvas
key={`${hallKey}-${glEpoch}`}
frameloop={active ? 'always' : 'never'}
gl={{ preserveDrawingBuffer: true, powerPreference: 'high-performance' }}
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
onCreated={handleCanvasCreated}
>
<FrameloopSync active={active} />
<color attach="background" args={[sceneBackground]} />
<fog
attach="fog"
args={[sceneFog, Math.max(45, layout.depth * 1.15), Math.max(fogFar, layout.depth + 55)]}
/>
<ambientLight intensity={ambientIntensity} />
<hemisphereLight
color={interiorStyle?.warmLight ?? '#fff8f0'}
groundColor={sceneFog}
intensity={0.55 * Math.PI * lightScale}
/>
<directionalLight
position={interiorStyle ? [2, 10, -4] : [3, 9, 4]}
intensity={(interiorStyle ? 1.4 : 0.9) * Math.PI * lightScale}
color={interiorStyle?.sunLight ?? '#fff8f0'}
/>
<directionalLight
position={[-4, 6, 5]}
intensity={0.55 * Math.PI * lightScale}
color={interiorStyle?.warmLight ?? '#fff5e8'}
/>
{/* Guaranteed fill so walls never disappear if HDR Environment fails. */}
<pointLight
position={[0, 3.2, 0]}
intensity={2.2 * Math.PI * lightScale}
distance={40}
decay={0}
color={interiorStyle?.warmLight ?? '#fff5e8'}
/>
<SceneErrorBoundary
key={`env-${hallKey}-${glEpoch}`}
fallback={null}
>
<Suspense fallback={null}>
<Environment
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
environmentIntensity={(interiorStyle ? 0.55 : 0.25) * lightScale}
/>
</Suspense>
</SceneErrorBoundary>
<GalleryTextureLoadContext.Provider value={textureLoad}>
<ArtistHall
layout={layout}
hallTitle={hallTitle}
hallSubtitle={hallSubtitle}
movementColor={movementColor}
interiorStyle={interiorStyle}
imageRevisions={imageRevisions}
showCaptions={isWingedHall}
onPaintingClick={onPaintingClick}
onExitActivate={openExitNav}
nearExit={nearExit}
movementMode={isWingedHall}
computedWindows={computedWindows}
hasNextHall={hasNextHall}
onNextHall={goToNextHall}
nearPassage={nearPassage}
/>
</GalleryTextureLoadContext.Provider>
<WarmHallGpu enabled={warmGpuEnabled} onDone={handleShadersWarmed} />
<CameraController position={camPos} target={camTarget} />
</Canvas>
</div>
{!isWingedHall && showExitNav && (
<NavigationPanel
navigation={navigation}
loading={navLoading}
onSelect={handleNavigate}
onExitTimeline={onBack}
onClose={() => setShowExitNav(false)}
/>
)}
{isWingedHall && showExitNav && (
<MovementHallNavPanel
movementName={hallTitle}
halls={movementHalls}
currentIndex={hallIndex}
onSelectHall={(i) => goToHall(i)}
onExitTimeline={onBack}
onClose={() => setShowExitNav(false)}
/>
)}
<div className="gallery-controls">
<div className="control-pad">
<button onClick={() => moveCamera(-0.35, 0, 0)} title="Walk forward"></button>
<div className="control-row">
<button onClick={() => moveCamera(0, 0, TURN_SPEED * 4)} title="Turn left"></button>
<button onClick={() => moveCamera(0.35, 0, 0)} title="Walk back"></button>
<button onClick={() => moveCamera(0, 0, -TURN_SPEED * 4)} title="Turn right"></button>
</div>
</div>
<div className="gallery-instructions">
<h4>{instructionsTitle}</h4>
<ul>
<li><kbd>W</kbd> / <kbd></kbd> Walk forward</li>
<li><kbd>S</kbd> / <kbd></kbd> Walk back</li>
<li><kbd>A</kbd> / <kbd></kbd> / <kbd>Q</kbd> Turn left</li>
<li><kbd>D</kbd> / <kbd></kbd> Turn right</li>
<li>Drag on the view to look around</li>
<li>Click a painting to view details{isTour ? ' and tour notes' : ' and influences'}</li>
{isWingedHall ? (
<>
<li>Date and artist labels appear below each frame</li>
<li>Works hang on left, end, and right walls up to ~55 per wing</li>
<li>
{movementHalls.length > 1
? 'Back door: wing navigator & exit to timeline · Front: next wing'
: 'Entrance door: exit to timeline'}
</li>
{movementHalls.length > 1 && (
<li>Front archway: walk to the next chronological wing</li>
)}
</>
) : (
<>
<li>Golden lamps mark works linked in the influence graph</li>
<li>Click the exit door or <kbd>E</kbd> for related artists or back to the timeline</li>
</>
)}
</ul>
</div>
</div>
</div>
);
}