Files
Art-gallery/client/src/components/VirtualGallery.tsx
T
Danila KhodjaefandCursor bc8369e373 Add public curator notes and U-shaped hall wall hang.
Paintings get editable curator notes with brass plates in the 3D hall, and visit order now uses the far/end wall between left and right.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 14:21:22 +03:00

2298 lines
74 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 { 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<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 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 (
<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(url: string | null) {
const [texture, setTexture] = useState<THREE.Texture | null>(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 (
<group position={[0, mountY, frameDepth * 0.55]} scale={scale}>
<mesh position={[0, 0.04, -0.05]} castShadow 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]} castShadow 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.4 * glow}
toneMapped={false}
/>
</mesh>
<pointLight
position={[0, -0.03, 0.05]}
intensity={1.8 * glow}
distance={4}
color="#ffe4a8"
decay={2}
/>
<spotLight
position={[0, -0.03, 0.05]}
angle={0.75}
penumbra={0.95}
intensity={2.2 * glow}
distance={5}
color="#fff0c8"
castShadow={false}
/>
</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]} castShadow renderOrder={28}>
<boxGeometry args={[plateW + 0.012, plateH + 0.01, plateD]} />
<meshStandardMaterial color={rim} metalness={0.85} roughness={0.28} />
</mesh>
<mesh castShadow 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 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 (
<group position={position} rotation={[0, rotationY, 0]}>
<spotLight
position={[0, height / 2 + 0.25, 0.3]}
angle={0.5}
penumbra={0.75}
intensity={showCanvas ? (hovered ? 0.9 : 0.55) : hovered ? 2.6 : 1.9}
distance={4.5}
color={showCanvas ? '#e8dcc8' : '#fff8ee'}
/>
<mesh
position={[0, 0, frameDepth / 2]}
castShadow
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={finish.emissive}
emissiveIntensity={finish.emissiveIntensity}
/>
</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} receiveShadow 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 castShadow>
<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]} receiveShadow>
<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]} castShadow>
<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]} castShadow>
<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]}
castShadow
>
<boxGeometry args={[surround, frameFullH + 0.08, 0.08]} />
<meshStandardMaterial color={stone} roughness={0.88} />
</mesh>
))}
<mesh position={[0, frameFullH + 0.04, faceZ - 0.01]} castShadow>
<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} receiveShadow 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]} receiveShadow 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]} receiveShadow>
<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.65}
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]} receiveShadow 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 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) => (
<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]} receiveShadow 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]} receiveShadow 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]} receiveShadow renderOrder={0}>
<boxGeometry args={[depth, WALL_HEIGHT, WALL_THICKNESS]} />
{wallMaterial(walls.side)}
</mesh>
</>
)}
{/* Front wall — passage to next wing, or solid (artist exit / movement entrance) */}
{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 ? (
<>
<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 — movement exit / navigation, or solid with title */}
{movementMode ? (
<>
<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>
</>
) : null}
{interiorStyle && computedWindows && computedWindows.length > 0 && (
<>
<GalleryWindows
windows={computedWindows}
halfW={halfW}
halfD={halfD}
trimColor={interiorStyle.tints.trim}
/>
<GalleryTrackLights
width={width}
depth={depth}
intensity={interiorStyle.trackLights}
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 ? interiorStyle.ambient * 1.2 : 0.5} color={warmLight} distance={width + depth} />
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD / 2]} intensity={interiorStyle ? interiorStyle.ambient * 0.85 : 0.35} color={warmLight} distance={Math.max(12, depth * 0.75)} />
{depth > 14 && (
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD + 1.2]} intensity={interiorStyle ? interiorStyle.ambient * 0.7 : 0.3} color={warmLight} distance={14} />
)}
{interiorStyle && (
<directionalLight
position={[0, 6, -halfD - 2]}
intensity={interiorStyle.ambient * 0.9}
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;
}
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}>
Exit to Timeline
</button>
</div>
</div>
</div>
</div>
);
}
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) => (
<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>
</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 [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<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 ? -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<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}`}
shadows
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, 18, fogFar]} />
<ambientLight intensity={ambientIntensity} />
<directionalLight
position={interiorStyle ? [2, 10, -4] : [3, 9, 4]}
intensity={interiorStyle ? 0.85 : 0.65}
color={interiorStyle?.sunLight ?? '#fff8f0'}
castShadow
shadow-mapSize={[1024, 1024]}
/>
<SceneErrorBoundary fallback={null}>
<Suspense fallback={null}>
<Environment
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
environmentIntensity={interiorStyle ? 0.35 : 0.15}
/>
</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>
<CameraController position={camPos} target={camTarget} />
</Canvas>
</div>
{!isWingedHall && showExitNav && (
<NavigationPanel
navigation={navigation}
loading={navLoading}
onSelect={handleNavigate}
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 &amp; right walls up to ~55 per wing</li>
<li>Back door: wing navigator &amp; 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> to visit related artists</li>
</>
)}
</ul>
</div>
</div>
</div>
);
}