Files
Art-gallery/client/src/components/VirtualGallery.tsx
T
Danila KhodjaefandCursor cc39c2b138 Flip influence-link picture lamps upside down in the 3D hall.
Document the inverted InfluencePictureLamp mount so link indicators match the intended fixture orientation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:12:54 +03:00

2108 lines
68 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 } 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,
} from '../types';
import { galleryImageUrlWithRevision, imageUrl, api, preloadArtistImages } from '../api/client';
import { comparePaintingsChronological, 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';
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;
}
type Props = ArtistGalleryProps | MovementGalleryProps;
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;
/** Above this count, use a long corridor (short back wall, extended side walls). */
const CORRIDOR_CATALOG_THRESHOLD = 15;
const BACK_WALL_MAX_PAINTINGS = 8;
/** 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);
}
/** Left → right on each wall: later works on the left, earlier works on the right. */
function orderPaintingsForWallDisplay(paintings: Painting[]) {
return [...paintings].sort(comparePaintingsChronological).reverse();
}
function distributePaintingsAcrossWalls(paintings: Painting[]) {
const sorted = [...paintings].sort(comparePaintingsChronological);
const back: Painting[] = [];
const left: Painting[] = [];
const right: Painting[] = [];
if (sorted.length <= CORRIDOR_CATALOG_THRESHOLD) {
sorted.forEach((p, i) => {
if (i % 3 === 0) back.push(p);
else if (i % 3 === 1) left.push(p);
else right.push(p);
});
} else {
const backCount = Math.min(BACK_WALL_MAX_PAINTINGS, Math.max(4, Math.ceil(sorted.length * 0.12)));
back.push(...sorted.slice(0, backCount));
sorted.slice(backCount).forEach((p, i) => {
if (i % 2 === 0) left.push(p);
else right.push(p);
});
}
return [
orderPaintingsForWallDisplay(back),
orderPaintingsForWallDisplay(left),
orderPaintingsForWallDisplay(right),
];
}
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') {
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);
let width = minSpanForWall(wallPaintings[0], MAX_WALL_ROWS);
let depth = Math.max(
minSpanForWall(wallPaintings[1], MAX_WALL_ROWS),
minSpanForWall(wallPaintings[2], MAX_WALL_ROWS)
);
for (let i = 0; i < 24; i++) {
const nextWidth = minSpanForWall(wallPaintings[0], MAX_WALL_ROWS);
const nextDepth = Math.max(
minSpanForWall(wallPaintings[1], MAX_WALL_ROWS),
minSpanForWall(wallPaintings[2], 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);
useEffect(() => {
if (!url) {
setTexture(null);
setFailed(true);
return;
}
setFailed(false);
let disposed = false;
let loaded: THREE.Texture | null = null;
const loader = new THREE.TextureLoader();
loader.setCrossOrigin('anonymous');
loader.load(
url,
(tex) => {
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,
() => {
if (!disposed) setFailed(true);
}
);
return () => {
disposed = true;
loaded?.dispose();
setTexture(null);
};
}, [url]);
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]} 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 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 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]);
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}
/>
)}
{caption && (
<Text
position={[0, -height / 2 - matBorder - rail - 0.1, 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 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 hallKey = isMovement ? props.data.movement.id : props.data.artist.id;
const hallTitle = isMovement ? props.data.movement.name : props.data.artist.name;
const movementColor = 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]);
const initialPeriods = isMovement ? [] : 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);
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;
}
let cancelled = false;
const artistId = props.data.artist.id;
(async () => {
try {
setSyncStatus('Syncing images…');
const timeout = new Promise<void>((resolve) => setTimeout(resolve, 2000));
await Promise.race([preloadArtistImages(artistId), timeout]);
const fresh = await api.getArtist(artistId);
if (!cancelled) {
setPaintings(fresh.paintings);
setPeriods(fresh.periods);
const withImg = fresh.paintings.filter((p) => p.image_path || p.thumbnail_path).length;
setSyncStatus(
withImg < fresh.paintings.length
? `${withImg} of ${fresh.paintings.length} works have images`
: ''
);
}
} catch {
if (!cancelled) setSyncStatus('');
}
})();
return () => {
cancelled = true;
};
}, [hallKey, props.mode, initialPaintings.length, props.mode === 'artist' ? props.data.artist.id : null]);
const interiorStyle = useMemo(
() => (isMovement ? resolveMovementInteriorStyle(props.data.movement) : undefined),
[isMovement, isMovement ? props.data.movement : null]
);
const movementHalls = useMemo(
() => (isMovement ? buildAllMovementHallLayouts(paintings) : []),
[isMovement, paintings]
);
const [hallIndex, setHallIndex] = useState(0);
useEffect(() => {
setHallIndex(0);
}, [hallKey]);
const layout = useMemo(() => {
if (isMovement && movementHalls.length > 0) {
return movementHalls[Math.min(hallIndex, movementHalls.length - 1)];
}
return buildHallLayout(paintings, periods);
}, [isMovement, movementHalls, hallIndex, paintings, periods]);
const computedWindows = useMemo(() => {
if (!isMovement || !interiorStyle || !('hallIndex' in layout)) return undefined;
return computeSideWallWindows(layout as MovementHallLayout, interiorStyle);
}, [isMovement, interiorStyle, layout]);
const hasNextHall = isMovement && 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 (isMovement) {
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);
}
}, [isMovement, onBack, isMovement ? undefined : props.data.artist.id]);
const backExitZ = isMovement ? -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 (isMovement) {
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, isMovement, 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 (isMovement && 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, isMovement, nearPassage, hasNextHall, goToNextHall]);
const handleNavigate = (artistId: number) => {
if (isMovement) return;
setShowExitNav(false);
props.onNavigateArtist(artistId);
};
const subtitle = 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 = isMovement ? (
<>
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 =
isMovement && 'yearLabel' in layout
? `Wing ${hallIndex + 1} of ${movementHalls.length} · ${(layout as MovementHallLayout).yearLabel}`
: undefined;
const instructionsTitle = 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}>
{isMovement ? 'Wings / Exit →' : 'Exit →'}
</button>
{!isMovement && (
<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}
>
{syncStatus && (
<div className="gallery-loading-overlay gallery-sync-badge">
<p>{syncStatus}</p>
</div>
)}
{!showExitNav && (
<div className="gallery-exit-hint">{exitHint}</div>
)}
<Canvas
shadows
frameloop={active ? 'always' : 'never'}
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
>
<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={[2048, 2048]}
/>
<Suspense fallback={null}>
<Environment
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
environmentIntensity={interiorStyle ? 0.35 : 0.15}
/>
</Suspense>
<ArtistHall
layout={layout}
hallTitle={hallTitle}
hallSubtitle={hallSubtitle}
movementColor={movementColor}
interiorStyle={interiorStyle}
imageRevisions={imageRevisions}
showCaptions={isMovement}
onPaintingClick={onPaintingClick}
onExitActivate={openExitNav}
nearExit={nearExit}
movementMode={isMovement}
computedWindows={computedWindows}
hasNextHall={hasNextHall}
onNextHall={goToNextHall}
nearPassage={nearPassage}
/>
<CameraController position={camPos} target={camTarget} />
</Canvas>
</div>
{!isMovement && showExitNav && (
<NavigationPanel
navigation={navigation}
loading={navLoading}
onSelect={handleNavigate}
onClose={() => setShowExitNav(false)}
/>
)}
{isMovement && 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 and influences</li>
{isMovement ? (
<>
<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>
);
}