Document the inverted InfluencePictureLamp mount so link indicators match the intended fixture orientation. Co-authored-by: Cursor <cursoragent@cursor.com>
2108 lines
68 KiB
TypeScript
2108 lines
68 KiB
TypeScript
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 & right walls — up to ~55 per wing</li>
|
||
<li>Back door: wing navigator & 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>
|
||
);
|
||
}
|