Movement halls routed 24 of the 26 movements through 8 generic detail kinds, four of which (modern, industrial, atelier, museum) rendered nothing at all, and `salon` was a single ceiling ring shared by eight movements. `details` is now a 26-value MovementDetailKind, one per movement, each built from the architecture of the same period as the movement itself. Gothic and Byzantine are unchanged and stay in MovementHallDetails.tsx as the reference implementations; the rest live under components/hall-details/, split by era, over shared wall-flush primitives. hall-details/geometry.ts records the budget the Gothic and Byzantine components established, since frames hang 0.23 m proud with a 0.7 m clear margin at each wall end: relief limits above and below the frame line, corner pockets for freestanding masses, doorway and title-band clearance, and a cap of two added lights per hall. Halls now pass their computed windows down so upper-wall panels, arches and roundels step around the openings. Also holds the pale interiors back from blowing out: gallery wall tints are authored around 0.50-0.56 luminance instead of near-white, and the 19 halls that read too bright take lightScale 0.30, matching the basilica. Verified by rendering all 26 interiors offline at two hall sizes and measuring the scene graph: nothing through walls, floor or ceiling, maximum 0.17 m intrusion into the hang zone, at most two added lights per hall. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2752 lines
89 KiB
TypeScript
2752 lines
89 KiB
TypeScript
import { useRef, useState, useEffect, useMemo, Suspense, useCallback, createContext, useContext, Component } from 'react';
|
||
import type { ReactNode } from 'react';
|
||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||
import { Text, Environment } from '@react-three/drei';
|
||
import * as THREE from 'three';
|
||
import type {
|
||
ArtistDetail,
|
||
MovementGalleryDetail,
|
||
Painting,
|
||
ArtistPeriod,
|
||
ArtistNavigation,
|
||
MovementArtistGroup,
|
||
TourGalleryDetail,
|
||
} from '../types';
|
||
import { galleryImageUrlCandidates, imageUrl, api } from '../api/client';
|
||
import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
|
||
import { cloneSurfaceTexture, getSurfaceTexture, type SurfaceTextureKind } from '../utils/galleryProceduralTextures';
|
||
import {
|
||
resolveMovementInteriorStyle,
|
||
type MovementDetailKind,
|
||
type MovementInteriorStyle,
|
||
type GalleryWindowSpec,
|
||
} from '../data/movement-interior-styles';
|
||
import { useTexturedMaterial } from '../hooks/useTexturedMaterial';
|
||
import MovementHallDetails from './MovementHallDetails';
|
||
import GalleryWindows, { GalleryTrackLights } from './GalleryWindows';
|
||
import HallPassage from './HallPassage';
|
||
import {
|
||
buildAllMovementHallLayouts,
|
||
computeSideWallWindows,
|
||
type MovementHallLayout,
|
||
} from '../utils/movementHallLayout';
|
||
import './VirtualGallery.css';
|
||
import GalleryLoadingMarker from './GalleryLoadingMarker';
|
||
|
||
const GalleryTextureLoadContext = createContext<{
|
||
begin: () => void;
|
||
end: () => void;
|
||
} | null>(null);
|
||
|
||
/**
|
||
* Keeps a single failing subtree (e.g. the network-loaded HDR environment map)
|
||
* from unmounting the whole 3D scene and leaving a dark window.
|
||
*/
|
||
class SceneErrorBoundary extends Component<
|
||
{ children: ReactNode; fallback?: ReactNode; onError?: () => void },
|
||
{ hasError: boolean }
|
||
> {
|
||
state = { hasError: false };
|
||
|
||
static getDerivedStateFromError() {
|
||
return { hasError: true };
|
||
}
|
||
|
||
componentDidCatch(error: unknown) {
|
||
console.warn('Gallery scene subtree failed, continuing without it.', error);
|
||
this.props.onError?.();
|
||
}
|
||
|
||
render() {
|
||
if (this.state.hasError) return this.props.fallback ?? null;
|
||
return this.props.children;
|
||
}
|
||
}
|
||
|
||
interface BaseGalleryProps {
|
||
imageRevisions?: Record<number, number>;
|
||
active?: boolean;
|
||
onPaintingClick: (paintingId: number) => void;
|
||
onBack: () => void;
|
||
}
|
||
|
||
interface ArtistGalleryProps extends BaseGalleryProps {
|
||
mode: 'artist';
|
||
data: ArtistDetail;
|
||
onNavigateArtist: (artistId: number) => void;
|
||
onBioClick: () => void;
|
||
}
|
||
|
||
interface MovementGalleryProps extends BaseGalleryProps {
|
||
mode: 'movement';
|
||
data: MovementGalleryDetail;
|
||
}
|
||
|
||
interface TourGalleryProps extends BaseGalleryProps {
|
||
mode: 'tour';
|
||
data: TourGalleryDetail;
|
||
}
|
||
|
||
type Props = ArtistGalleryProps | MovementGalleryProps | TourGalleryProps;
|
||
|
||
/** Period interiors built from steel, concrete or a white cube read better under the city HDR. */
|
||
const CITY_ENVIRONMENT_DETAILS = new Set<MovementDetailKind>([
|
||
'futurist',
|
||
'suprematist',
|
||
'constructivist',
|
||
'loft',
|
||
'white-cube',
|
||
]);
|
||
|
||
const WALL_HEIGHT = 4.2;
|
||
const WALL_THICKNESS = 0.18;
|
||
const MOUNT_OFFSET = 0.16;
|
||
const WALL_STANDOFF = 0.07;
|
||
const BACK_WALL_EXTRA = 0.05;
|
||
const FRAME_FACE_Z = 0.018;
|
||
const FRAME_DEPTH = 0.07;
|
||
const FRAME_MAT_BORDER = 0.1;
|
||
const FRAME_RAIL = 0.08;
|
||
/** Checked paintings — double-width moulding. */
|
||
const REVIEWED_MAT_BORDER = FRAME_MAT_BORDER * 2;
|
||
const REVIEWED_RAIL = FRAME_RAIL * 2;
|
||
const EYE_HEIGHT = 1.65;
|
||
const FRAME_GAP = 0.32;
|
||
const SHADER_WARM_TIMEOUT_MS = 2000;
|
||
/** Per-image fetch/decode deadline so a stuck request cannot hold the overlay counter forever. */
|
||
const TEXTURE_LOAD_TIMEOUT_MS = 20000;
|
||
/** Cap parallel WebGL texture downloads — large halls otherwise stampede the browser pool. */
|
||
const MAX_PARALLEL_TEXTURE_LOADS = 8;
|
||
|
||
const textureSlotWaiters: Array<() => void> = [];
|
||
let textureLoadsInFlight = 0;
|
||
|
||
function acquireTextureLoadSlot(): {
|
||
promise: Promise<() => void>;
|
||
cancel: () => void;
|
||
} {
|
||
let grantFn: (() => void) | null = null;
|
||
let cancelled = false;
|
||
const promise = new Promise<() => void>((resolve) => {
|
||
grantFn = () => {
|
||
if (cancelled) return;
|
||
textureLoadsInFlight++;
|
||
let released = false;
|
||
resolve(() => {
|
||
if (released) return;
|
||
released = true;
|
||
textureLoadsInFlight = Math.max(0, textureLoadsInFlight - 1);
|
||
const next = textureSlotWaiters.shift();
|
||
if (next) next();
|
||
});
|
||
};
|
||
if (textureLoadsInFlight < MAX_PARALLEL_TEXTURE_LOADS) grantFn();
|
||
else textureSlotWaiters.push(grantFn);
|
||
});
|
||
return {
|
||
promise,
|
||
cancel: () => {
|
||
cancelled = true;
|
||
if (grantFn) {
|
||
const idx = textureSlotWaiters.indexOf(grantFn);
|
||
if (idx >= 0) textureSlotWaiters.splice(idx, 1);
|
||
}
|
||
},
|
||
};
|
||
}
|
||
const MIN_FRAME_W = 0.45;
|
||
const MAX_FRAME_W = 1.05;
|
||
const MAX_FRAME_H = 1.35;
|
||
const MIN_HALL_SIZE = 9;
|
||
const ROW_GAP = 0.2;
|
||
const WALL_PADDING = 1.4;
|
||
/** Every wall shows at most one row; side-wall depth grows to fit the catalog. */
|
||
const MAX_WALL_ROWS = 1;
|
||
const DOOR_WIDTH = 2.4;
|
||
/** Minimum horizontal distance from walls, corners, door planes, and painting faces. */
|
||
const PLAYER_CLEARANCE = 0.5;
|
||
const DOOR_HEIGHT = 2.5;
|
||
/** Museum exit — dimensions derived from door opening. */
|
||
const EXIT_JAMB = 0.13;
|
||
const EXIT_HEADER = 0.15;
|
||
const EXIT_TRANSOM = 0.52;
|
||
const EXIT_SURROUND = 0.1;
|
||
const TURN_SPEED = 0.032;
|
||
const MOUSE_TURN_SENSITIVITY = 0.004;
|
||
const DRAG_START_THRESHOLD_PX = 5;
|
||
|
||
const DEFAULT_MOVEMENT_COLOR = '#8B7355';
|
||
const WALL_BASE_MAIN = '#f0ebe3';
|
||
const WALL_BASE_SIDE = '#e8e2d8';
|
||
|
||
function parseHex(hex: string): { r: number; g: number; b: number } {
|
||
const n = parseInt(hex.replace('#', ''), 16);
|
||
if (Number.isNaN(n)) return { r: 240, g: 235, b: 227 };
|
||
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
||
}
|
||
|
||
function blendHex(base: string, accent: string, accentWeight: number): string {
|
||
const a = parseHex(accent);
|
||
const b = parseHex(base);
|
||
const w = Math.min(1, Math.max(0, accentWeight));
|
||
const mix = (ca: number, cb: number) => Math.round(ca * w + cb * (1 - w));
|
||
const r = mix(a.r, b.r);
|
||
const g = mix(a.g, b.g);
|
||
const bl = mix(a.b, b.b);
|
||
return `#${((r << 16) | (g << 8) | bl).toString(16).padStart(6, '0')}`;
|
||
}
|
||
|
||
function galleryWallColors(movementColor?: string) {
|
||
const accent = movementColor || DEFAULT_MOVEMENT_COLOR;
|
||
return {
|
||
main: blendHex(WALL_BASE_MAIN, accent, 0.34),
|
||
side: blendHex(WALL_BASE_SIDE, accent, 0.26),
|
||
trim: blendHex('#b8956a', accent, 0.45),
|
||
};
|
||
}
|
||
|
||
/** Clearance above the tallest frame in the hall for influence lamps (metres). */
|
||
const INFLUENCE_LAMP_ABOVE_HIGHEST_M = 0.4;
|
||
|
||
type WallSide = 'back' | 'left' | 'right';
|
||
|
||
interface FrameSlot {
|
||
position: [number, number, number];
|
||
rotationY: number;
|
||
maxW: number;
|
||
maxH: number;
|
||
side: WallSide;
|
||
}
|
||
|
||
interface WallSegment {
|
||
side: WallSide;
|
||
label: string;
|
||
paintings: Painting[];
|
||
slots: FrameSlot[];
|
||
}
|
||
|
||
interface HallLayout {
|
||
width: number;
|
||
depth: number;
|
||
segments: WallSegment[];
|
||
}
|
||
|
||
interface XZBounds {
|
||
minX: number;
|
||
maxX: number;
|
||
minZ: number;
|
||
maxZ: number;
|
||
}
|
||
|
||
/** Inner wall face half-extents (room center → inside of wall box). */
|
||
function innerWallHalfExtents(width: number, depth: number): { halfW: number; halfD: number } {
|
||
return {
|
||
halfW: width / 2 - WALL_THICKNESS / 2,
|
||
halfD: depth / 2 - WALL_THICKNESS / 2,
|
||
};
|
||
}
|
||
|
||
/** Keep-out box in front of a hanging painting (XZ), including player clearance. */
|
||
function paintingKeepOutBounds(slot: FrameSlot): XZBounds {
|
||
const [px, , pz] = slot.position;
|
||
const halfAlong = slot.maxW / 2 + FRAME_MAT_BORDER + FRAME_RAIL + 0.04;
|
||
const intoRoom = FRAME_DEPTH + FRAME_FACE_Z + PLAYER_CLEARANCE;
|
||
if (slot.side === 'left') {
|
||
return { minX: px - 0.02, maxX: px + intoRoom, minZ: pz - halfAlong, maxZ: pz + halfAlong };
|
||
}
|
||
if (slot.side === 'right') {
|
||
return { minX: px - intoRoom, maxX: px + 0.02, minZ: pz - halfAlong, maxZ: pz + halfAlong };
|
||
}
|
||
// back wall — faces into the room (+Z)
|
||
return { minX: px - halfAlong, maxX: px + halfAlong, minZ: pz - 0.02, maxZ: pz + intoRoom };
|
||
}
|
||
|
||
/** Door jamb keep-outs at an opening in the front (+Z) or back (−Z) wall. */
|
||
function doorJambKeepOuts(halfD: number, atFront: boolean): XZBounds[] {
|
||
const jambZ = atFront ? halfD : -halfD;
|
||
const along = PLAYER_CLEARANCE;
|
||
const intoRoom = PLAYER_CLEARANCE;
|
||
const leftX = -DOOR_WIDTH / 2;
|
||
const rightX = DOOR_WIDTH / 2;
|
||
if (atFront) {
|
||
return [
|
||
{ minX: leftX - along, maxX: leftX + along, minZ: jambZ - intoRoom, maxZ: jambZ + 0.02 },
|
||
{ minX: rightX - along, maxX: rightX + along, minZ: jambZ - intoRoom, maxZ: jambZ + 0.02 },
|
||
];
|
||
}
|
||
return [
|
||
{ minX: leftX - along, maxX: leftX + along, minZ: jambZ - 0.02, maxZ: jambZ + intoRoom },
|
||
{ minX: rightX - along, maxX: rightX + along, minZ: jambZ - 0.02, maxZ: jambZ + intoRoom },
|
||
];
|
||
}
|
||
|
||
function pointInBounds(x: number, z: number, b: XZBounds): boolean {
|
||
return x > b.minX && x < b.maxX && z > b.minZ && z < b.maxZ;
|
||
}
|
||
|
||
/** Push a point out of an AABB via the shortest axis (XZ). */
|
||
function pushOutOfBounds(pos: { x: number; z: number }, b: XZBounds): void {
|
||
if (!pointInBounds(pos.x, pos.z, b)) return;
|
||
const dxMin = pos.x - b.minX;
|
||
const dxMax = b.maxX - pos.x;
|
||
const dzMin = pos.z - b.minZ;
|
||
const dzMax = b.maxZ - pos.z;
|
||
const m = Math.min(dxMin, dxMax, dzMin, dzMax);
|
||
if (m === dxMin) pos.x = b.minX;
|
||
else if (m === dxMax) pos.x = b.maxX;
|
||
else if (m === dzMin) pos.z = b.minZ;
|
||
else pos.z = b.maxZ;
|
||
}
|
||
|
||
function paintingIsReviewed(painting: Painting): boolean {
|
||
return !!painting.checkup_checked;
|
||
}
|
||
|
||
function frameDimsForReviewed(reviewed: boolean) {
|
||
return reviewed
|
||
? { matBorder: REVIEWED_MAT_BORDER, rail: REVIEWED_RAIL, depth: FRAME_DEPTH * 1.08 }
|
||
: { matBorder: FRAME_MAT_BORDER, rail: FRAME_RAIL, depth: FRAME_DEPTH };
|
||
}
|
||
|
||
function frameOuterW(canvasW: number, reviewed: boolean): number {
|
||
const { matBorder, rail } = frameDimsForReviewed(reviewed);
|
||
return canvasW + matBorder * 2 + rail;
|
||
}
|
||
|
||
function frameOuterH(canvasH: number, reviewed: boolean): number {
|
||
const { matBorder, rail } = frameDimsForReviewed(reviewed);
|
||
return canvasH + matBorder * 2 + rail;
|
||
}
|
||
|
||
/** World Y of the top edge of the tallest allocated frame in the hall. */
|
||
function highestPaintingTopY(segments: WallSegment[]): number {
|
||
let maxTop = EYE_HEIGHT;
|
||
for (const seg of segments) {
|
||
for (let i = 0; i < seg.paintings.length; i++) {
|
||
const slot = seg.slots[i];
|
||
if (!slot) continue;
|
||
const top = slot.position[1] + frameOuterH(slot.maxH, paintingIsReviewed(seg.paintings[i])) / 2;
|
||
if (top > maxTop) maxTop = top;
|
||
}
|
||
}
|
||
return maxTop;
|
||
}
|
||
|
||
function layoutRow(paintings: Painting[], span: number) {
|
||
const count = paintings.length;
|
||
if (count === 0) return { slots: [], spanNeeded: span };
|
||
|
||
const gap = FRAME_GAP;
|
||
const available = span - WALL_PADDING;
|
||
|
||
let frameW = MAX_FRAME_W;
|
||
for (let attempt = 0; attempt < 40; attempt++) {
|
||
let total = 0;
|
||
for (let i = 0; i < count; i++) {
|
||
total += frameOuterW(frameW, paintingIsReviewed(paintings[i]));
|
||
if (i < count - 1) total += gap;
|
||
}
|
||
if (total <= available) break;
|
||
frameW -= 0.015;
|
||
}
|
||
frameW = Math.max(MIN_FRAME_W, frameW);
|
||
|
||
const frameH = Math.min(MAX_FRAME_H, frameW * 1.22);
|
||
const outers = paintings.map((p) => frameOuterW(frameW, paintingIsReviewed(p)));
|
||
const rowWidth = outers.reduce((sum, w) => sum + w, 0) + (count - 1) * gap;
|
||
const slots: { offset: number; maxW: number; maxH: number }[] = [];
|
||
|
||
let cursor = -rowWidth / 2;
|
||
for (let i = 0; i < count; i++) {
|
||
const outerW = outers[i];
|
||
slots.push({
|
||
offset: cursor + outerW / 2,
|
||
maxW: frameW,
|
||
maxH: frameH,
|
||
});
|
||
cursor += outerW + gap;
|
||
}
|
||
|
||
return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) };
|
||
}
|
||
|
||
function wallRowHeights(paintings: Painting[], span: number, rows: number) {
|
||
const perRow = Math.ceil(paintings.length / rows);
|
||
const heights: number[] = [];
|
||
for (let r = 0; r < rows; r++) {
|
||
const rowStart = r * perRow;
|
||
const row = paintings.slice(rowStart, rowStart + perRow);
|
||
const { slots } = layoutRow(row, span);
|
||
const reviewed = row.some(paintingIsReviewed);
|
||
heights.push(frameOuterH(slots[0]?.maxH ?? MAX_FRAME_H, reviewed));
|
||
}
|
||
return heights;
|
||
}
|
||
|
||
function wallStackHeight(heights: number[]) {
|
||
return heights.reduce((sum, h, i) => sum + h + (i > 0 ? ROW_GAP : 0), 0);
|
||
}
|
||
|
||
function fitsOnWall(paintings: Painting[], span: number, rows: number) {
|
||
const count = paintings.length;
|
||
const perRow = Math.ceil(count / rows);
|
||
const row = paintings.slice(0, perRow);
|
||
const { slots } = layoutRow(row, span);
|
||
if ((slots[0]?.maxW ?? 0) < MIN_FRAME_W) return false;
|
||
|
||
const totalHeight = wallStackHeight(wallRowHeights(paintings, span, rows));
|
||
const bottom = EYE_HEIGHT - totalHeight / 2;
|
||
const top = EYE_HEIGHT + totalHeight / 2;
|
||
if (bottom < 0.35) return false;
|
||
if (top > WALL_HEIGHT - 0.45) return false;
|
||
return true;
|
||
}
|
||
|
||
function rowCountForWall(paintings: Painting[], span: number, maxRows: number = paintings.length) {
|
||
const count = paintings.length;
|
||
const limit = Math.max(1, Math.min(count, maxRows));
|
||
for (let rows = 1; rows <= limit; rows++) {
|
||
if (fitsOnWall(paintings, span, rows)) return rows;
|
||
}
|
||
return limit;
|
||
}
|
||
|
||
function minSpanForWall(paintings: Painting[], maxRows: number = paintings.length) {
|
||
const count = paintings.length;
|
||
if (count === 0) return MIN_HALL_SIZE;
|
||
|
||
let best = Infinity;
|
||
const rowLimit = Math.max(1, Math.min(count, maxRows));
|
||
for (let rows = 1; rows <= rowLimit; rows++) {
|
||
const perRow = Math.ceil(count / rows);
|
||
const { spanNeeded } = layoutRow(paintings.slice(0, perRow), MIN_HALL_SIZE);
|
||
if (fitsOnWall(paintings, spanNeeded, rows)) {
|
||
best = Math.min(best, spanNeeded);
|
||
}
|
||
}
|
||
|
||
if (best === Infinity) {
|
||
const perRow = Math.ceil(count / rowLimit);
|
||
best = layoutRow(paintings.slice(0, perRow), MIN_HALL_SIZE).spanNeeded;
|
||
}
|
||
|
||
return Math.max(MIN_HALL_SIZE, best);
|
||
}
|
||
|
||
/**
|
||
* U-shaped visit order:
|
||
* left wall (first, near entrance) → far/end wall → right wall (last, near entrance).
|
||
* Fewer than 3 works keep the old left/right split so first stays left and last stays right.
|
||
* The far wall is the solid wall ahead when entering (code side `'back'`).
|
||
*/
|
||
function distributePaintingsAcrossWalls(paintings: Painting[]) {
|
||
const ordered = [...paintings].sort(comparePaintingsChronological);
|
||
const n = ordered.length;
|
||
if (n < 3) {
|
||
const mid = Math.ceil(n / 2);
|
||
return [[] as Painting[], ordered.slice(0, mid), ordered.slice(mid)];
|
||
}
|
||
const q = Math.floor(n / 3);
|
||
const r = n % 3;
|
||
const leftCount = q + (r > 0 ? 1 : 0);
|
||
const backCount = q + (r > 1 ? 1 : 0);
|
||
return [
|
||
ordered.slice(leftCount, leftCount + backCount),
|
||
ordered.slice(0, leftCount),
|
||
ordered.slice(leftCount + backCount),
|
||
];
|
||
}
|
||
|
||
function wallLabelForPaintings(wallPaintings: Painting[], periods: ArtistPeriod[]) {
|
||
const periodIds = new Set(
|
||
wallPaintings.map((p) => p.period_id).filter((id): id is number => id != null && id !== 0)
|
||
);
|
||
const names = periods.filter((p) => periodIds.has(p.id)).map((p) => p.name);
|
||
const hasUnassigned = wallPaintings.some((p) => !p.period_id);
|
||
if (names.length > 0 && hasUnassigned) return `${names.join(' · ')} · Other`;
|
||
if (names.length > 0) return names.join(' · ');
|
||
if (hasUnassigned) return 'Other Works';
|
||
return wallPaintings.length > 0 ? 'Works' : '';
|
||
}
|
||
|
||
function layoutWallSlots(
|
||
paintings: Painting[],
|
||
span: number,
|
||
side: WallSide,
|
||
halfW: number,
|
||
halfD: number,
|
||
inset: number,
|
||
maxRows: number = paintings.length
|
||
): FrameSlot[] {
|
||
const count = paintings.length;
|
||
if (count === 0) return [];
|
||
|
||
const rows = rowCountForWall(paintings, span, maxRows);
|
||
const perRow = Math.ceil(count / rows);
|
||
const slots: FrameSlot[] = [];
|
||
|
||
const rowLayouts = [];
|
||
for (let r = 0; r < rows; r++) {
|
||
const rowStart = r * perRow;
|
||
const inRow = Math.min(perRow, count - rowStart);
|
||
rowLayouts.push(layoutRow(paintings.slice(rowStart, rowStart + inRow), span));
|
||
}
|
||
|
||
const rowHeights = rowLayouts.map((row) => row.slots[0]?.maxH ?? MAX_FRAME_H);
|
||
const totalHeight = wallStackHeight(rowHeights);
|
||
let y = EYE_HEIGHT - totalHeight / 2 + rowHeights[0] / 2;
|
||
|
||
for (let r = 0; r < rows; r++) {
|
||
const { slots: rowSlots } = rowLayouts[r];
|
||
const rowFrameH = rowHeights[r];
|
||
|
||
for (let i = 0; i < rowSlots.length; i++) {
|
||
const s = rowSlots[i];
|
||
if (side === 'back') {
|
||
slots.push({
|
||
maxW: s.maxW,
|
||
maxH: s.maxH,
|
||
rotationY: 0,
|
||
side,
|
||
position: [s.offset, y, -halfD + inset + WALL_STANDOFF + BACK_WALL_EXTRA],
|
||
});
|
||
} else if (side === 'left') {
|
||
// Flip along-wall offset so index 0 is at the entrance (+Z), left of view.
|
||
slots.push({
|
||
maxW: s.maxW,
|
||
maxH: s.maxH,
|
||
rotationY: Math.PI / 2,
|
||
side,
|
||
position: [-halfW + inset + WALL_STANDOFF, y, -s.offset],
|
||
});
|
||
} else {
|
||
slots.push({
|
||
maxW: s.maxW,
|
||
maxH: s.maxH,
|
||
rotationY: -Math.PI / 2,
|
||
side,
|
||
position: [halfW - inset - WALL_STANDOFF, y, s.offset],
|
||
});
|
||
}
|
||
}
|
||
|
||
y += rowFrameH + ROW_GAP;
|
||
}
|
||
|
||
return slots;
|
||
}
|
||
|
||
function buildHallLayout(paintings: Painting[], periods: ArtistPeriod[]): HallLayout {
|
||
const walls: WallSide[] = ['back', 'left', 'right'];
|
||
const wallPaintings = distributePaintingsAcrossWalls(paintings);
|
||
const [backPaintings, leftPaintings, rightPaintings] = wallPaintings;
|
||
|
||
let width = minSpanForWall(backPaintings, MAX_WALL_ROWS);
|
||
let depth = Math.max(
|
||
minSpanForWall(leftPaintings, MAX_WALL_ROWS),
|
||
minSpanForWall(rightPaintings, MAX_WALL_ROWS)
|
||
);
|
||
|
||
for (let i = 0; i < 24; i++) {
|
||
const nextWidth = minSpanForWall(backPaintings, MAX_WALL_ROWS);
|
||
const nextDepth = Math.max(
|
||
minSpanForWall(leftPaintings, MAX_WALL_ROWS),
|
||
minSpanForWall(rightPaintings, MAX_WALL_ROWS)
|
||
);
|
||
if (nextWidth === width && nextDepth === depth) break;
|
||
width = nextWidth;
|
||
depth = nextDepth;
|
||
}
|
||
|
||
width = Math.max(width, MIN_HALL_SIZE);
|
||
depth = Math.max(depth, MIN_HALL_SIZE);
|
||
|
||
const halfW = width / 2;
|
||
const halfD = depth / 2;
|
||
const inset = WALL_THICKNESS / 2 + MOUNT_OFFSET;
|
||
|
||
const segments: WallSegment[] = walls.map((side, i) => ({
|
||
side,
|
||
label: wallLabelForPaintings(wallPaintings[i], periods),
|
||
paintings: wallPaintings[i],
|
||
slots: layoutWallSlots(
|
||
wallPaintings[i],
|
||
side === 'back' ? width : depth,
|
||
side,
|
||
halfW,
|
||
halfD,
|
||
inset,
|
||
MAX_WALL_ROWS
|
||
),
|
||
}));
|
||
|
||
return { width, depth, segments };
|
||
}
|
||
|
||
function computeFrameSize(aspect: number, maxW: number, maxH: number) {
|
||
let w = maxW;
|
||
let h = w / aspect;
|
||
if (h > maxH) {
|
||
h = maxH;
|
||
w = h * aspect;
|
||
}
|
||
return { width: w, height: h };
|
||
}
|
||
|
||
function paintingHasGalleryImage(painting: Painting) {
|
||
return !!(painting.thumbnail_path || painting.image_path);
|
||
}
|
||
|
||
let canvasWeaveTexture: THREE.CanvasTexture | null = null;
|
||
|
||
function getCanvasWeaveTexture() {
|
||
if (canvasWeaveTexture) return canvasWeaveTexture;
|
||
|
||
const size = 256;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = size;
|
||
canvas.height = size;
|
||
const ctx = canvas.getContext('2d');
|
||
if (ctx) {
|
||
ctx.fillStyle = '#ddd0b8';
|
||
ctx.fillRect(0, 0, size, size);
|
||
for (let y = 0; y < size; y += 4) {
|
||
for (let x = 0; x < size; x += 4) {
|
||
ctx.fillStyle = (x + y) % 8 === 0 ? '#c4b494' : '#e4dac8';
|
||
ctx.fillRect(x, y, 4, 4);
|
||
}
|
||
}
|
||
ctx.strokeStyle = 'rgba(72, 58, 42, 0.18)';
|
||
ctx.lineWidth = 1;
|
||
for (let i = 0; i <= size; i += 8) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(i, 0);
|
||
ctx.lineTo(i, size);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, i);
|
||
ctx.lineTo(size, i);
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
canvasWeaveTexture = new THREE.CanvasTexture(canvas);
|
||
canvasWeaveTexture.wrapS = THREE.RepeatWrapping;
|
||
canvasWeaveTexture.wrapT = THREE.RepeatWrapping;
|
||
canvasWeaveTexture.colorSpace = THREE.SRGBColorSpace;
|
||
return canvasWeaveTexture;
|
||
}
|
||
|
||
function frameFinish(reviewed: boolean, hovered: boolean) {
|
||
if (reviewed) {
|
||
return {
|
||
color: hovered ? '#ffe566' : '#ffd700',
|
||
roughness: 0.22,
|
||
metalness: 0.78,
|
||
emissive: hovered ? '#ffcc00' : '#daa520',
|
||
emissiveIntensity: hovered ? 0.35 : 0.18,
|
||
};
|
||
}
|
||
return {
|
||
color: hovered ? '#2a2a2a' : '#0a0a0a',
|
||
roughness: 0.28,
|
||
metalness: 0.72,
|
||
emissive: '#000000',
|
||
emissiveIntensity: 0,
|
||
};
|
||
}
|
||
|
||
function CanvasCover({
|
||
width,
|
||
height,
|
||
frameDepth,
|
||
faceZ,
|
||
matBorder,
|
||
hovered,
|
||
}: {
|
||
width: number;
|
||
height: number;
|
||
frameDepth: number;
|
||
faceZ: number;
|
||
matBorder: number;
|
||
hovered: boolean;
|
||
}) {
|
||
const weave = useMemo(() => {
|
||
const tex = getCanvasWeaveTexture().clone();
|
||
tex.repeat.set(Math.max(3, width * 5), Math.max(3, height * 5));
|
||
return tex;
|
||
}, [width, height]);
|
||
|
||
useEffect(() => () => weave.dispose(), [weave]);
|
||
|
||
const cloth = hovered ? '#ddd3bc' : '#c4b494';
|
||
const z = frameDepth + faceZ;
|
||
|
||
return (
|
||
<group renderOrder={20}>
|
||
<mesh position={[0, 0, z]}>
|
||
<planeGeometry args={[width + matBorder * 1.6, height + matBorder * 1.6]} />
|
||
<meshBasicMaterial
|
||
map={weave}
|
||
color={cloth}
|
||
toneMapped={false}
|
||
depthWrite
|
||
polygonOffset
|
||
polygonOffsetFactor={-6}
|
||
polygonOffsetUnits={-6}
|
||
/>
|
||
</mesh>
|
||
<mesh position={[-width * 0.22, 0, z + 0.004]} rotation={[0, 0.22, 0.02]}>
|
||
<planeGeometry args={[width * 0.48, height * 0.98]} />
|
||
<meshBasicMaterial
|
||
map={weave}
|
||
color={hovered ? '#a89478' : '#958470'}
|
||
toneMapped={false}
|
||
transparent
|
||
opacity={0.55}
|
||
depthWrite={false}
|
||
polygonOffset
|
||
polygonOffsetFactor={-5}
|
||
polygonOffsetUnits={-5}
|
||
/>
|
||
</mesh>
|
||
</group>
|
||
);
|
||
}
|
||
|
||
function usePaintingTexture(urls: string[] | string | null) {
|
||
const candidates = useMemo(() => {
|
||
const list = Array.isArray(urls) ? urls.filter(Boolean) : urls ? [urls] : [];
|
||
return list;
|
||
}, [Array.isArray(urls) ? urls.join('|') : urls ?? '']);
|
||
const candidateKey = candidates.join('|');
|
||
const [urlIndex, setUrlIndex] = useState(0);
|
||
const url = candidates[urlIndex] ?? null;
|
||
const [texture, setTexture] = useState<THREE.Texture | null>(null);
|
||
const [failed, setFailed] = useState(candidates.length === 0);
|
||
const textureLoad = useContext(GalleryTextureLoadContext);
|
||
const { gl } = useThree();
|
||
|
||
useEffect(() => {
|
||
setUrlIndex(0);
|
||
}, [candidateKey]);
|
||
|
||
useEffect(() => {
|
||
if (!url) {
|
||
setTexture(null);
|
||
setFailed(candidates.length === 0 || urlIndex >= candidates.length);
|
||
return;
|
||
}
|
||
|
||
setFailed(false);
|
||
setTexture(null);
|
||
let disposed = false;
|
||
let loaded: THREE.Texture | null = null;
|
||
let settled = false;
|
||
let releaseSlot: (() => void) | null = null;
|
||
const loader = new THREE.TextureLoader();
|
||
// Relative /images URLs are same-origin via the Vite proxy — avoid CORS mode.
|
||
if (/^https?:\/\//i.test(url)) {
|
||
loader.setCrossOrigin('anonymous');
|
||
}
|
||
|
||
const finish = () => {
|
||
if (settled) return;
|
||
settled = true;
|
||
textureLoad?.end();
|
||
releaseSlot?.();
|
||
releaseSlot = null;
|
||
};
|
||
|
||
textureLoad?.begin();
|
||
const loadTimeout = window.setTimeout(() => {
|
||
if (settled || disposed) return;
|
||
finish();
|
||
}, TEXTURE_LOAD_TIMEOUT_MS);
|
||
|
||
const slot = acquireTextureLoadSlot();
|
||
void slot.promise.then((release) => {
|
||
if (disposed) {
|
||
release();
|
||
finish();
|
||
return;
|
||
}
|
||
releaseSlot = release;
|
||
loader.load(
|
||
url,
|
||
(tex) => {
|
||
window.clearTimeout(loadTimeout);
|
||
if (disposed) {
|
||
tex.dispose();
|
||
finish();
|
||
return;
|
||
}
|
||
const img = tex.image as HTMLImageElement | undefined;
|
||
if (!img || img.width < 4 || img.height < 4) {
|
||
tex.dispose();
|
||
finish();
|
||
if (!disposed) {
|
||
if (urlIndex + 1 < candidates.length) setUrlIndex((i) => i + 1);
|
||
else setFailed(true);
|
||
}
|
||
return;
|
||
}
|
||
loaded = tex;
|
||
tex.colorSpace = THREE.SRGBColorSpace;
|
||
tex.anisotropy = 4;
|
||
finish();
|
||
if (!disposed) {
|
||
setFailed(false);
|
||
setTexture(tex);
|
||
}
|
||
try {
|
||
if (!disposed) gl.initTexture(tex);
|
||
} catch {
|
||
// Upload can fail after context loss; texture still usable later.
|
||
}
|
||
},
|
||
undefined,
|
||
() => {
|
||
window.clearTimeout(loadTimeout);
|
||
finish();
|
||
if (!disposed) {
|
||
if (urlIndex + 1 < candidates.length) setUrlIndex((i) => i + 1);
|
||
else setFailed(true);
|
||
}
|
||
}
|
||
);
|
||
});
|
||
|
||
return () => {
|
||
disposed = true;
|
||
slot.cancel();
|
||
window.clearTimeout(loadTimeout);
|
||
finish();
|
||
loaded?.dispose();
|
||
setTexture(null);
|
||
};
|
||
}, [url, urlIndex, candidates.length, textureLoad, gl]);
|
||
|
||
return { texture, failed };
|
||
}
|
||
|
||
function InfluencePictureLamp({
|
||
frameWorldY,
|
||
lampWorldY,
|
||
frameDepth,
|
||
highlighted,
|
||
}: {
|
||
/** Painting group world Y — local lamp offset = lampWorldY − frameWorldY. */
|
||
frameWorldY: number;
|
||
/** Shared hall rail: 40 cm above the tallest frame. */
|
||
lampWorldY: number;
|
||
frameDepth: number;
|
||
highlighted: boolean;
|
||
}) {
|
||
const mountY = lampWorldY - frameWorldY;
|
||
const glow = highlighted ? 1.6 : 1.15;
|
||
const scale = 1.35;
|
||
|
||
return (
|
||
<group position={[0, mountY, frameDepth * 0.55]} rotation={[Math.PI, Math.PI, 0]} scale={scale}>
|
||
<mesh position={[0, 0.04, -0.05]} renderOrder={30}>
|
||
<boxGeometry args={[0.11, 0.05, 0.05]} />
|
||
<meshStandardMaterial color="#2f2f2f" metalness={0.9} roughness={0.2} />
|
||
</mesh>
|
||
<mesh position={[0, 0.01, -0.01]} rotation={[0.25, 0, 0]} renderOrder={30}>
|
||
<boxGeometry args={[0.04, 0.08, 0.035]} />
|
||
<meshStandardMaterial color="#555555" metalness={0.8} roughness={0.3} />
|
||
</mesh>
|
||
<group position={[0, -0.02, 0.03]} rotation={[-0.55, 0, 0]}>
|
||
<mesh position={[0, 0, 0.04]} rotation={[Math.PI, 0, 0]} renderOrder={31}>
|
||
<coneGeometry args={[0.09, 0.1, 16, 1, true]} />
|
||
<meshStandardMaterial
|
||
color={highlighted ? '#f0d050' : '#d4af37'}
|
||
emissive={highlighted ? '#c89400' : '#8a6500'}
|
||
emissiveIntensity={0.75 * glow}
|
||
metalness={0.45}
|
||
roughness={0.35}
|
||
side={THREE.DoubleSide}
|
||
/>
|
||
</mesh>
|
||
<mesh position={[0, -0.04, 0.04]} renderOrder={32}>
|
||
<sphereGeometry args={[0.022, 10, 10]} />
|
||
<meshStandardMaterial
|
||
color="#fff8e0"
|
||
emissive="#ffcc55"
|
||
emissiveIntensity={1.8 * glow}
|
||
toneMapped={false}
|
||
/>
|
||
</mesh>
|
||
{/* No per-painting lights — too many MeshStandard lights break hall shaders. */}
|
||
</group>
|
||
</group>
|
||
);
|
||
}
|
||
|
||
function CuratorNotesPlate({
|
||
frameWidth,
|
||
frameHeight,
|
||
matBorder,
|
||
rail,
|
||
frameDepth,
|
||
faceZ,
|
||
highlighted,
|
||
}: {
|
||
frameWidth: number;
|
||
frameHeight: number;
|
||
matBorder: number;
|
||
rail: number;
|
||
frameDepth: number;
|
||
faceZ: number;
|
||
highlighted: boolean;
|
||
}) {
|
||
const plateW = Math.min(0.28, Math.max(0.16, frameWidth * 0.42));
|
||
const plateH = 0.038;
|
||
const plateD = 0.012;
|
||
const y = -frameHeight / 2 - matBorder - rail - plateH / 2 - 0.028;
|
||
const z = frameDepth + faceZ + 0.01;
|
||
const brass = highlighted ? '#e8c76a' : '#d4af37';
|
||
const rim = highlighted ? '#a07828' : '#8a6820';
|
||
|
||
return (
|
||
<group position={[0, y, z]}>
|
||
{/* Slightly darker rim so the plate reads as a cast metal plaque */}
|
||
<mesh position={[0, 0, -0.001]} renderOrder={28}>
|
||
<boxGeometry args={[plateW + 0.012, plateH + 0.01, plateD]} />
|
||
<meshStandardMaterial color={rim} metalness={0.85} roughness={0.28} />
|
||
</mesh>
|
||
<mesh renderOrder={29}>
|
||
<boxGeometry args={[plateW, plateH, plateD]} />
|
||
<meshStandardMaterial
|
||
color={brass}
|
||
metalness={0.9}
|
||
roughness={0.22}
|
||
emissive={highlighted ? '#6a5010' : '#3a2a08'}
|
||
emissiveIntensity={highlighted ? 0.35 : 0.12}
|
||
/>
|
||
</mesh>
|
||
{/* Soft engraved center band */}
|
||
<mesh position={[0, 0, plateD / 2 + 0.001]} renderOrder={30}>
|
||
<planeGeometry args={[plateW * 0.72, plateH * 0.28]} />
|
||
<meshStandardMaterial
|
||
color={highlighted ? '#b8943a' : '#9a7828'}
|
||
metalness={0.7}
|
||
roughness={0.4}
|
||
/>
|
||
</mesh>
|
||
</group>
|
||
);
|
||
}
|
||
|
||
function PaintingFrame({
|
||
painting,
|
||
position,
|
||
rotationY,
|
||
maxWidth,
|
||
maxHeight,
|
||
wallSide,
|
||
imageRevision,
|
||
caption,
|
||
influenceLampWorldY,
|
||
onClick,
|
||
}: {
|
||
painting: Painting;
|
||
position: [number, number, number];
|
||
rotationY: number;
|
||
maxWidth: number;
|
||
maxHeight: number;
|
||
wallSide: WallSide;
|
||
imageRevision?: number;
|
||
caption?: string;
|
||
influenceLampWorldY: number;
|
||
onClick: () => void;
|
||
}) {
|
||
const [hovered, setHovered] = useState(false);
|
||
const [aspect, setAspect] = useState(1.33);
|
||
const { width, height } = computeFrameSize(aspect, maxWidth, maxHeight);
|
||
const reviewed = paintingIsReviewed(painting);
|
||
const { matBorder, rail, depth: frameDepth } = frameDimsForReviewed(reviewed);
|
||
const hasImage = paintingHasGalleryImage(painting);
|
||
const urls = hasImage ? galleryImageUrlCandidates(painting, imageRevision) : [];
|
||
const { texture, failed } = usePaintingTexture(urls);
|
||
const showImage = hasImage && !failed && !!texture;
|
||
const showCanvas = !showImage;
|
||
const hasInfluenceLinks = paintingHasInfluenceLinks(painting);
|
||
const hasCuratorNotes = paintingHasCuratorNotes(painting);
|
||
const finish = frameFinish(reviewed, hovered);
|
||
const faceZ = FRAME_FACE_Z + (wallSide === 'back' ? 0.012 : 0);
|
||
|
||
useEffect(() => {
|
||
if (!showImage) return;
|
||
const img = texture?.image as HTMLImageElement | undefined;
|
||
if (img?.width && img.height) {
|
||
setAspect(img.width / img.height);
|
||
}
|
||
}, [texture, showImage]);
|
||
|
||
const captionY =
|
||
-height / 2 - matBorder - rail - (hasCuratorNotes ? 0.18 : 0.1);
|
||
|
||
// Hall lighting is shared (track/ambient). Per-frame spotLights (× dozens of
|
||
// paintings) exceed WebGL light limits and make MeshStandard walls vanish.
|
||
const frameEmissiveBoost = hovered ? 0.35 : showCanvas ? 0.08 : 0.12;
|
||
|
||
return (
|
||
<group position={position} rotation={[0, rotationY, 0]}>
|
||
<mesh
|
||
position={[0, 0, frameDepth / 2]}
|
||
renderOrder={1}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onClick();
|
||
}}
|
||
onPointerOver={() => setHovered(true)}
|
||
onPointerOut={() => setHovered(false)}
|
||
>
|
||
<boxGeometry args={[width + matBorder * 2 + rail, height + matBorder * 2 + rail, frameDepth]} />
|
||
<meshStandardMaterial
|
||
color={finish.color}
|
||
roughness={finish.roughness}
|
||
metalness={finish.metalness}
|
||
emissive={reviewed ? finish.emissive : '#d8c090'}
|
||
emissiveIntensity={finish.emissiveIntensity + frameEmissiveBoost}
|
||
/>
|
||
</mesh>
|
||
|
||
{!showCanvas && (
|
||
<mesh position={[0, 0, frameDepth + 0.002]} renderOrder={2}>
|
||
<boxGeometry args={[width + matBorder * 2, height + matBorder * 2, 0.008]} />
|
||
<meshStandardMaterial
|
||
color={reviewed ? '#fff6cc' : '#f5f0e6'}
|
||
roughness={reviewed ? 0.75 : 0.95}
|
||
metalness={reviewed ? 0.15 : 0}
|
||
/>
|
||
</mesh>
|
||
)}
|
||
|
||
{showCanvas ? (
|
||
<CanvasCover
|
||
width={width}
|
||
height={height}
|
||
frameDepth={frameDepth}
|
||
faceZ={faceZ}
|
||
matBorder={matBorder}
|
||
hovered={hovered}
|
||
/>
|
||
) : (
|
||
<mesh position={[0, 0, frameDepth + faceZ]} renderOrder={20}>
|
||
<planeGeometry args={[width, height]} />
|
||
<meshBasicMaterial
|
||
map={texture}
|
||
color="#ffffff"
|
||
toneMapped={false}
|
||
polygonOffset
|
||
polygonOffsetFactor={-4}
|
||
polygonOffsetUnits={-4}
|
||
/>
|
||
</mesh>
|
||
)}
|
||
|
||
{hasInfluenceLinks && (
|
||
<InfluencePictureLamp
|
||
frameWorldY={position[1]}
|
||
lampWorldY={influenceLampWorldY}
|
||
frameDepth={frameDepth}
|
||
highlighted={hovered}
|
||
/>
|
||
)}
|
||
|
||
{hasCuratorNotes && (
|
||
<CuratorNotesPlate
|
||
frameWidth={width}
|
||
frameHeight={height}
|
||
matBorder={matBorder}
|
||
rail={rail}
|
||
frameDepth={frameDepth}
|
||
faceZ={faceZ}
|
||
highlighted={hovered}
|
||
/>
|
||
)}
|
||
|
||
{caption && (
|
||
<Text
|
||
position={[0, captionY, frameDepth + faceZ + 0.02]}
|
||
fontSize={0.085}
|
||
maxWidth={Math.max(width + matBorder * 2, 0.55)}
|
||
color="#4a3828"
|
||
anchorX="center"
|
||
anchorY="top"
|
||
textAlign="center"
|
||
>
|
||
{caption}
|
||
</Text>
|
||
)}
|
||
</group>
|
||
);
|
||
}
|
||
|
||
function GalleryWall({
|
||
position,
|
||
size,
|
||
rotation = [0, 0, 0],
|
||
color = '#ebe4d8',
|
||
kind,
|
||
tint,
|
||
}: {
|
||
position: [number, number, number];
|
||
size: [number, number];
|
||
rotation?: [number, number, number];
|
||
color?: string;
|
||
/** When set, the panel is textured like the rest of the hall instead of flat colour. */
|
||
kind?: SurfaceTextureKind;
|
||
tint?: string;
|
||
}) {
|
||
const [w, h] = size;
|
||
// Door-flanking panels would otherwise read as flat single-colour blocks
|
||
// beside fully textured walls.
|
||
if (kind) {
|
||
return (
|
||
<TexturedWall
|
||
kind={kind}
|
||
tint={tint ?? color}
|
||
position={position}
|
||
size={[w, h, WALL_THICKNESS]}
|
||
rotation={rotation}
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<mesh position={position} rotation={rotation} renderOrder={0}>
|
||
<boxGeometry args={[w, h, WALL_THICKNESS]} />
|
||
<meshStandardMaterial color={color} roughness={0.92} />
|
||
</mesh>
|
||
);
|
||
}
|
||
|
||
function ExitPortal({
|
||
position,
|
||
active,
|
||
onActivate,
|
||
wallColor = '#f0ebe3',
|
||
trimColor = '#ddd5c8',
|
||
doorWood,
|
||
}: {
|
||
position: [number, number, number];
|
||
active: boolean;
|
||
onActivate: () => void;
|
||
wallColor?: string;
|
||
trimColor?: string;
|
||
doorWood?: [string, string, string];
|
||
}) {
|
||
const [hovered, setHovered] = useState(false);
|
||
const highlight = active || hovered;
|
||
|
||
const openingW = DOOR_WIDTH;
|
||
const openingH = DOOR_HEIGHT;
|
||
const jamb = EXIT_JAMB;
|
||
const header = EXIT_HEADER;
|
||
const transomH = EXIT_TRANSOM;
|
||
const surround = EXIT_SURROUND;
|
||
const frameW = openingW + jamb * 2;
|
||
const frameFullH = openingH + header + transomH;
|
||
const leafW = (openingW - 0.025) / 2;
|
||
const leafH = openingH - 0.08;
|
||
const leafY = 0.04 + leafH / 2;
|
||
const faceZ = -0.04;
|
||
|
||
const woodDark = doorWood?.[0] ?? '#261a10';
|
||
const woodMid = doorWood?.[1] ?? '#3d2818';
|
||
const woodGrain = doorWood?.[2] ?? '#4e3624';
|
||
const stone = wallColor;
|
||
const stoneDark = trimColor;
|
||
const brass = highlight ? '#d4af37' : '#a08050';
|
||
const brassEmissive = highlight ? '#5a4010' : '#000000';
|
||
const corridorGlow = highlight ? '#fff0d0' : '#ffe8c0';
|
||
|
||
const handleActivate = (e: THREE.Event & { stopPropagation: () => void }) => {
|
||
e.stopPropagation();
|
||
onActivate();
|
||
};
|
||
|
||
const setHover = (on: boolean) => () => setHovered(on);
|
||
|
||
const doorLeaf = (side: 'left' | 'right') => {
|
||
const x = side === 'left' ? -leafW / 2 - 0.006 : leafW / 2 + 0.006;
|
||
const panelInset = 0.06;
|
||
return (
|
||
<group key={side} position={[x, leafY, faceZ]}>
|
||
<mesh>
|
||
<boxGeometry args={[leafW, leafH, 0.055]} />
|
||
<meshStandardMaterial color={woodMid} roughness={0.62} metalness={0.04} />
|
||
</mesh>
|
||
{/* Raised panel */}
|
||
<mesh position={[0, 0.08, 0.03]}>
|
||
<boxGeometry args={[leafW - panelInset * 2, leafH * 0.38, 0.012]} />
|
||
<meshStandardMaterial color={woodGrain} roughness={0.58} metalness={0.03} />
|
||
</mesh>
|
||
<mesh position={[0, -leafH * 0.22, 0.03]}>
|
||
<boxGeometry args={[leafW - panelInset * 2, leafH * 0.32, 0.012]} />
|
||
<meshStandardMaterial color={woodDark} roughness={0.65} metalness={0.02} />
|
||
</mesh>
|
||
{/* Stile edges */}
|
||
<mesh position={[side === 'left' ? leafW / 2 - 0.025 : -leafW / 2 + 0.025, 0, 0.028]}>
|
||
<boxGeometry args={[0.05, leafH, 0.008]} />
|
||
<meshStandardMaterial color={woodDark} roughness={0.7} />
|
||
</mesh>
|
||
{/* Brass handle */}
|
||
<mesh position={[side === 'left' ? leafW / 2 - 0.1 : -leafW / 2 + 0.1, 0, 0.038]} rotation={[0, 0, side === 'left' ? 0.08 : -0.08]}>
|
||
<boxGeometry args={[0.04, 0.22, 0.025]} />
|
||
<meshStandardMaterial
|
||
color={brass}
|
||
roughness={0.22}
|
||
metalness={0.88}
|
||
emissive={brassEmissive}
|
||
emissiveIntensity={highlight ? 0.15 : 0}
|
||
/>
|
||
</mesh>
|
||
</group>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<group position={position}>
|
||
{/* Wall reveal — depth into opening */}
|
||
<mesh position={[0, frameFullH / 2, 0.02]}>
|
||
<boxGeometry args={[openingW - 0.04, frameFullH - 0.04, WALL_THICKNESS - 0.04]} />
|
||
<meshStandardMaterial color="#1a1410" roughness={0.95} />
|
||
</mesh>
|
||
|
||
{/* Warm vestibule glow beyond doors */}
|
||
<pointLight position={[0, openingH * 0.55, 0.22]} intensity={highlight ? 1.1 : 0.65} distance={4.5} color={corridorGlow} />
|
||
<mesh position={[0, openingH * 0.55, 0.1]}>
|
||
<planeGeometry args={[openingW * 0.85, openingH * 0.9]} />
|
||
<meshStandardMaterial
|
||
color="#fff4e8"
|
||
emissive="#ffdcb0"
|
||
emissiveIntensity={highlight ? 0.35 : 0.18}
|
||
roughness={0.9}
|
||
/>
|
||
</mesh>
|
||
|
||
{/* Marble threshold */}
|
||
<mesh position={[0, 0.025, faceZ + 0.04]}>
|
||
<boxGeometry args={[frameW + 0.12, 0.05, 0.14]} />
|
||
<meshStandardMaterial color="#e8e4dc" roughness={0.28} metalness={0.08} />
|
||
</mesh>
|
||
<mesh position={[0, 0.052, faceZ + 0.05]}>
|
||
<boxGeometry args={[frameW + 0.08, 0.006, 0.1]} />
|
||
<meshStandardMaterial color="#b8860b" roughness={0.3} metalness={0.75} />
|
||
</mesh>
|
||
|
||
{/* Door leaves */}
|
||
{doorLeaf('left')}
|
||
{doorLeaf('right')}
|
||
|
||
{/* Center meeting stile / push bar */}
|
||
<mesh position={[0, leafY, faceZ + 0.042]}>
|
||
<boxGeometry args={[0.035, leafH * 0.72, 0.018]} />
|
||
<meshStandardMaterial
|
||
color={brass}
|
||
roughness={0.2}
|
||
metalness={0.9}
|
||
emissive={brassEmissive}
|
||
emissiveIntensity={highlight ? 0.2 : 0}
|
||
/>
|
||
</mesh>
|
||
|
||
{/* Side jambs */}
|
||
{([-1, 1] as const).map((sign) => (
|
||
<mesh key={sign} position={[sign * (openingW / 2 + jamb / 2), frameFullH / 2, faceZ + 0.01]}>
|
||
<boxGeometry args={[jamb, frameFullH, 0.1]} />
|
||
<meshStandardMaterial color={woodDark} roughness={0.55} metalness={0.06} />
|
||
</mesh>
|
||
))}
|
||
|
||
{/* Header lintel */}
|
||
<mesh position={[0, openingH + header / 2, faceZ + 0.01]}>
|
||
<boxGeometry args={[frameW, header, 0.1]} />
|
||
<meshStandardMaterial color={woodMid} roughness={0.58} metalness={0.05} />
|
||
</mesh>
|
||
|
||
{/* Transom — frosted museum glass */}
|
||
<mesh position={[0, openingH + header + transomH / 2, faceZ + 0.02]}>
|
||
<boxGeometry args={[openingW - 0.06, transomH - 0.06, 0.025]} />
|
||
<meshStandardMaterial
|
||
color="#f5f0e8"
|
||
emissive={highlight ? '#fff0d8' : '#ffeacc'}
|
||
emissiveIntensity={highlight ? 0.25 : 0.12}
|
||
roughness={0.15}
|
||
metalness={0.05}
|
||
transparent
|
||
opacity={0.88}
|
||
/>
|
||
</mesh>
|
||
{/* Transom mullions */}
|
||
{[-0.35, 0, 0.35].map((ox) => (
|
||
<mesh key={ox} position={[ox, openingH + header + transomH / 2, faceZ + 0.035]}>
|
||
<boxGeometry args={[0.04, transomH - 0.1, 0.012]} />
|
||
<meshStandardMaterial color={woodDark} roughness={0.6} />
|
||
</mesh>
|
||
))}
|
||
|
||
{/* Limestone surround */}
|
||
{([-1, 1] as const).map((sign) => (
|
||
<mesh
|
||
key={`sur-${sign}`}
|
||
position={[sign * (openingW / 2 + jamb + surround / 2), frameFullH / 2, faceZ - 0.01]}
|
||
>
|
||
<boxGeometry args={[surround, frameFullH + 0.08, 0.08]} />
|
||
<meshStandardMaterial color={stone} roughness={0.88} />
|
||
</mesh>
|
||
))}
|
||
<mesh position={[0, frameFullH + 0.04, faceZ - 0.01]}>
|
||
<boxGeometry args={[frameW + surround * 2, 0.12, 0.08]} />
|
||
<meshStandardMaterial color={stoneDark} roughness={0.82} metalness={0.08} />
|
||
</mesh>
|
||
|
||
{/* Crown on surround */}
|
||
<mesh position={[0, frameFullH + 0.12, faceZ - 0.005]}>
|
||
<boxGeometry args={[frameW + surround * 2 + 0.06, 0.08, 0.1]} />
|
||
<meshStandardMaterial color={stoneDark} roughness={0.5} metalness={0.2} />
|
||
</mesh>
|
||
|
||
{/* Brass EXIT plaque */}
|
||
<group position={[0, openingH + header + transomH + 0.18, faceZ - 0.02]}>
|
||
<mesh
|
||
onClick={handleActivate}
|
||
onPointerOver={setHover(true)}
|
||
onPointerOut={setHover(false)}
|
||
>
|
||
<boxGeometry args={[0.55, 0.14, 0.018]} />
|
||
<meshStandardMaterial
|
||
color={brass}
|
||
roughness={0.25}
|
||
metalness={0.85}
|
||
emissive={brassEmissive}
|
||
emissiveIntensity={highlight ? 0.25 : 0.05}
|
||
/>
|
||
</mesh>
|
||
<Text
|
||
position={[0, 0, -0.012]}
|
||
rotation={[0, Math.PI, 0]}
|
||
fontSize={0.09}
|
||
color={highlight ? '#fff8e8' : '#3d2818'}
|
||
anchorX="center"
|
||
anchorY="middle"
|
||
onClick={handleActivate}
|
||
onPointerOver={setHover(true)}
|
||
onPointerOut={setHover(false)}
|
||
>
|
||
EXIT
|
||
</Text>
|
||
</group>
|
||
|
||
{/* Wall sconces */}
|
||
{([-1, 1] as const).map((sign) => (
|
||
<group key={`sconce-${sign}`} position={[sign * (frameW / 2 + surround + 0.18), openingH + 0.35, faceZ - 0.02]}>
|
||
<mesh>
|
||
<boxGeometry args={[0.08, 0.22, 0.1]} />
|
||
<meshStandardMaterial color={brass} roughness={0.3} metalness={0.8} />
|
||
</mesh>
|
||
<mesh position={[0, -0.08, -0.04]} rotation={[Math.PI / 2, 0, 0]}>
|
||
<coneGeometry args={[0.07, 0.12, 8, 1, true]} />
|
||
<meshStandardMaterial
|
||
color="#fff4e0"
|
||
emissive="#ffdcb0"
|
||
emissiveIntensity={highlight ? 0.5 : 0.28}
|
||
roughness={0.4}
|
||
side={THREE.DoubleSide}
|
||
/>
|
||
</mesh>
|
||
<pointLight
|
||
position={[0, -0.12, -0.06]}
|
||
intensity={highlight ? 0.45 : 0.22}
|
||
distance={2.2}
|
||
color="#fff0d8"
|
||
/>
|
||
</group>
|
||
))}
|
||
|
||
{/* Hinges */}
|
||
{([-1, 1] as const).flatMap((side) =>
|
||
[0.35, 0.85, 1.35].map((hy) => (
|
||
<mesh
|
||
key={`hinge-${side}-${hy}`}
|
||
position={[side * (openingW / 2 + 0.02), hy, faceZ + 0.03]}
|
||
rotation={[0, 0, side === -1 ? 0 : Math.PI]}
|
||
>
|
||
<boxGeometry args={[0.025, 0.1, 0.04]} />
|
||
<meshStandardMaterial color={brass} roughness={0.28} metalness={0.85} />
|
||
</mesh>
|
||
))
|
||
)}
|
||
|
||
{/* Large invisible click target */}
|
||
<mesh
|
||
position={[0, frameFullH / 2, faceZ - 0.15]}
|
||
rotation={[0, Math.PI, 0]}
|
||
onClick={handleActivate}
|
||
onPointerOver={setHover(true)}
|
||
onPointerOut={setHover(false)}
|
||
>
|
||
<planeGeometry args={[DOOR_WIDTH * 2.2, frameFullH * 1.15]} />
|
||
<meshBasicMaterial transparent opacity={0} depthWrite={false} side={THREE.DoubleSide} />
|
||
</mesh>
|
||
</group>
|
||
);
|
||
}
|
||
|
||
function TexturedWall({
|
||
kind,
|
||
tint,
|
||
position,
|
||
size,
|
||
rotation = [0, 0, 0],
|
||
}: {
|
||
kind: MovementInteriorStyle['surfaces']['wall'];
|
||
tint: string;
|
||
position: [number, number, number];
|
||
size: [number, number, number];
|
||
rotation?: [number, number, number];
|
||
}) {
|
||
const [w, h, d] = size;
|
||
const mat = useTexturedMaterial(kind, tint, Math.max(w, d), h);
|
||
return (
|
||
<mesh position={position} rotation={rotation} material={mat} renderOrder={0}>
|
||
<boxGeometry args={size} />
|
||
</mesh>
|
||
);
|
||
}
|
||
|
||
function TexturedCeiling({
|
||
kind,
|
||
tint,
|
||
width,
|
||
depth,
|
||
}: {
|
||
kind: MovementInteriorStyle['surfaces']['ceiling'];
|
||
tint: string;
|
||
width: number;
|
||
depth: number;
|
||
}) {
|
||
const mat = useTexturedMaterial(kind, tint, width + 0.4, depth + 0.4);
|
||
return (
|
||
<mesh rotation={[Math.PI / 2, 0, 0]} position={[0, WALL_HEIGHT, 0]} material={mat}>
|
||
<planeGeometry args={[width + 0.4, depth + 0.4]} />
|
||
</mesh>
|
||
);
|
||
}
|
||
|
||
function GalleryFloor({
|
||
width,
|
||
depth,
|
||
interiorStyle,
|
||
}: {
|
||
width: number;
|
||
depth: number;
|
||
interiorStyle: MovementInteriorStyle;
|
||
}) {
|
||
const floorW = width + 0.4;
|
||
const floorD = depth + 0.4;
|
||
const texture = useMemo(() => {
|
||
const surf = interiorStyle.surfaces.floor;
|
||
const meters = getSurfaceTexture(surf).metersPerRepeat;
|
||
return cloneSurfaceTexture(surf, floorW / meters, floorD / meters);
|
||
}, [interiorStyle.surfaces.floor, floorW, floorD]);
|
||
|
||
useEffect(
|
||
() => () => {
|
||
texture.map.dispose();
|
||
texture.normalMap.dispose();
|
||
},
|
||
[texture]
|
||
);
|
||
|
||
return (
|
||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.002, 0]}>
|
||
<planeGeometry args={[floorW, floorD]} />
|
||
<meshStandardMaterial
|
||
map={texture.map}
|
||
normalMap={texture.normalMap}
|
||
normalScale={new THREE.Vector2(texture.normalScale, texture.normalScale)}
|
||
roughness={texture.roughness}
|
||
metalness={texture.metalness}
|
||
envMapIntensity={0.3}
|
||
color={interiorStyle.tints.floor}
|
||
/>
|
||
</mesh>
|
||
);
|
||
}
|
||
|
||
function ParquetFloor({ width, depth }: { width: number; depth: number }) {
|
||
const mat = useTexturedMaterial('parquet-herringbone', '#c8a882', width + 0.4, depth + 0.4);
|
||
return (
|
||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.002, 0]} material={mat}>
|
||
<planeGeometry args={[width + 0.4, depth + 0.4]} />
|
||
</mesh>
|
||
);
|
||
}
|
||
|
||
function ArtistHall({
|
||
layout,
|
||
hallTitle,
|
||
hallSubtitle,
|
||
movementColor,
|
||
interiorStyle,
|
||
imageRevisions,
|
||
showCaptions = false,
|
||
onPaintingClick,
|
||
onExitActivate,
|
||
nearExit,
|
||
movementMode,
|
||
computedWindows,
|
||
hasNextHall,
|
||
onNextHall,
|
||
nearPassage,
|
||
}: {
|
||
layout: HallLayout | MovementHallLayout;
|
||
hallTitle: string;
|
||
hallSubtitle?: string;
|
||
movementColor?: string;
|
||
interiorStyle?: MovementInteriorStyle;
|
||
imageRevisions?: Record<number, number>;
|
||
showCaptions?: boolean;
|
||
onPaintingClick: (id: number) => void;
|
||
onExitActivate: () => void;
|
||
nearExit: boolean;
|
||
movementMode?: boolean;
|
||
computedWindows?: GalleryWindowSpec[];
|
||
hasNextHall?: boolean;
|
||
onNextHall?: () => void;
|
||
nearPassage?: boolean;
|
||
}) {
|
||
const { width, depth, segments } = layout;
|
||
const endWallHasDoor = movementMode && 'endWallHasDoor' in layout ? layout.endWallHasDoor : false;
|
||
const halfW = width / 2;
|
||
const halfD = depth / 2;
|
||
const walls = useMemo(
|
||
() =>
|
||
interiorStyle
|
||
? { main: interiorStyle.tints.wall, side: interiorStyle.tints.wallSide ?? interiorStyle.tints.wall, trim: interiorStyle.tints.trim }
|
||
: galleryWallColors(movementColor),
|
||
[interiorStyle, movementColor]
|
||
);
|
||
const titleColor = interiorStyle?.titleColor ?? '#4a3020';
|
||
const warmLight = interiorStyle?.warmLight ?? '#fff5e8';
|
||
const hallLightScale = interiorStyle?.lightScale ?? 1;
|
||
const wallRoughness = interiorStyle ? 0.75 : 0.92;
|
||
const wallMetalness = interiorStyle ? 0.08 : 0.06;
|
||
const influenceLampWorldY = useMemo(
|
||
() => highestPaintingTopY(segments) + INFLUENCE_LAMP_ABOVE_HIGHEST_M,
|
||
[segments]
|
||
);
|
||
|
||
const wallMaterial = (color: string) => (
|
||
<meshStandardMaterial color={color} roughness={wallRoughness} metalness={wallMetalness} />
|
||
);
|
||
|
||
return (
|
||
<group>
|
||
{interiorStyle ? (
|
||
<>
|
||
<GalleryFloor width={width} depth={depth} interiorStyle={interiorStyle} />
|
||
<TexturedCeiling
|
||
kind={interiorStyle.surfaces.ceiling}
|
||
tint={interiorStyle.tints.ceiling}
|
||
width={width}
|
||
depth={depth}
|
||
/>
|
||
</>
|
||
) : (
|
||
<>
|
||
<ParquetFloor width={width} depth={depth} />
|
||
<mesh rotation={[Math.PI / 2, 0, 0]} position={[0, WALL_HEIGHT, 0]}>
|
||
<planeGeometry args={[width + 0.4, depth + 0.4]} />
|
||
<meshStandardMaterial color="#2a2018" roughness={0.9} />
|
||
</mesh>
|
||
</>
|
||
)}
|
||
|
||
{interiorStyle ? (
|
||
<>
|
||
{!movementMode && (
|
||
<TexturedWall
|
||
kind={interiorStyle.surfaces.wall}
|
||
tint={interiorStyle.tints.wall}
|
||
position={[0, WALL_HEIGHT / 2, -halfD]}
|
||
size={[width, WALL_HEIGHT, WALL_THICKNESS]}
|
||
/>
|
||
)}
|
||
<TexturedWall
|
||
kind={interiorStyle.surfaces.wallSide ?? interiorStyle.surfaces.wall}
|
||
tint={interiorStyle.tints.wallSide ?? interiorStyle.tints.wall}
|
||
position={[-halfW, WALL_HEIGHT / 2, 0]}
|
||
size={[depth, WALL_HEIGHT, WALL_THICKNESS]}
|
||
rotation={[0, Math.PI / 2, 0]}
|
||
/>
|
||
<TexturedWall
|
||
kind={interiorStyle.surfaces.wallSide ?? interiorStyle.surfaces.wall}
|
||
tint={interiorStyle.tints.wallSide ?? interiorStyle.tints.wall}
|
||
position={[halfW, WALL_HEIGHT / 2, 0]}
|
||
size={[depth, WALL_HEIGHT, WALL_THICKNESS]}
|
||
rotation={[0, Math.PI / 2, 0]}
|
||
/>
|
||
</>
|
||
) : (
|
||
<>
|
||
<mesh position={[0, WALL_HEIGHT / 2, -halfD]} renderOrder={0}>
|
||
<boxGeometry args={[width, WALL_HEIGHT, WALL_THICKNESS]} />
|
||
{wallMaterial(walls.main)}
|
||
</mesh>
|
||
<mesh position={[-halfW, WALL_HEIGHT / 2, 0]} rotation={[0, Math.PI / 2, 0]} renderOrder={0}>
|
||
<boxGeometry args={[depth, WALL_HEIGHT, WALL_THICKNESS]} />
|
||
{wallMaterial(walls.side)}
|
||
</mesh>
|
||
<mesh position={[halfW, WALL_HEIGHT / 2, 0]} rotation={[0, Math.PI / 2, 0]} renderOrder={0}>
|
||
<boxGeometry args={[depth, WALL_HEIGHT, WALL_THICKNESS]} />
|
||
{wallMaterial(walls.side)}
|
||
</mesh>
|
||
</>
|
||
)}
|
||
|
||
{/* Front wall — next-wing passage, entrance exit (single-wing), or artist exit */}
|
||
{movementMode && hasNextHall ? (
|
||
<>
|
||
<GalleryWall
|
||
position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
|
||
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
|
||
color={walls.main}
|
||
kind={interiorStyle?.surfaces.wall}
|
||
tint={interiorStyle?.tints.wall}
|
||
/>
|
||
<GalleryWall
|
||
position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
|
||
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
|
||
color={walls.main}
|
||
kind={interiorStyle?.surfaces.wall}
|
||
tint={interiorStyle?.tints.wall}
|
||
/>
|
||
<GalleryWall
|
||
position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, halfD]}
|
||
size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]}
|
||
color={walls.main}
|
||
kind={interiorStyle?.surfaces.wall}
|
||
tint={interiorStyle?.tints.wall}
|
||
/>
|
||
<HallPassage
|
||
position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]}
|
||
active={!!nearPassage}
|
||
label="Next wing →"
|
||
onActivate={() => onNextHall?.()}
|
||
wallColor={walls.main}
|
||
trimColor={walls.trim}
|
||
/>
|
||
</>
|
||
) : movementMode && endWallHasDoor ? (
|
||
<GalleryWall position={[0, WALL_HEIGHT / 2, halfD]} size={[width, WALL_HEIGHT]} color={walls.main}
|
||
kind={interiorStyle?.surfaces.wall}
|
||
tint={interiorStyle?.tints.wall} />
|
||
) : (
|
||
<>
|
||
<GalleryWall position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} color={walls.main}
|
||
kind={interiorStyle?.surfaces.wall}
|
||
tint={interiorStyle?.tints.wall} />
|
||
<GalleryWall position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]} size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]} color={walls.main}
|
||
kind={interiorStyle?.surfaces.wall}
|
||
tint={interiorStyle?.tints.wall} />
|
||
<GalleryWall position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, halfD]} size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]} color={walls.main}
|
||
kind={interiorStyle?.surfaces.wall}
|
||
tint={interiorStyle?.tints.wall} />
|
||
<ExitPortal
|
||
position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]}
|
||
active={nearExit}
|
||
onActivate={onExitActivate}
|
||
wallColor={walls.main}
|
||
trimColor={walls.trim}
|
||
doorWood={interiorStyle?.doorWood}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{/* Back wall — multi-wing exit / navigator, or solid end wall for paintings */}
|
||
{movementMode && endWallHasDoor ? (
|
||
<>
|
||
<GalleryWall
|
||
position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, -halfD]}
|
||
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
|
||
color={walls.main}
|
||
kind={interiorStyle?.surfaces.wall}
|
||
tint={interiorStyle?.tints.wall}
|
||
/>
|
||
<GalleryWall
|
||
position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, -halfD]}
|
||
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
|
||
color={walls.main}
|
||
kind={interiorStyle?.surfaces.wall}
|
||
tint={interiorStyle?.tints.wall}
|
||
/>
|
||
<GalleryWall
|
||
position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, -halfD]}
|
||
size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]}
|
||
color={walls.main}
|
||
kind={interiorStyle?.surfaces.wall}
|
||
tint={interiorStyle?.tints.wall}
|
||
/>
|
||
<group position={[0, 0, -halfD + WALL_THICKNESS / 2 + 0.02]} rotation={[0, Math.PI, 0]}>
|
||
<ExitPortal
|
||
position={[0, 0, 0]}
|
||
active={nearExit}
|
||
onActivate={onExitActivate}
|
||
wallColor={walls.main}
|
||
trimColor={walls.trim}
|
||
doorWood={interiorStyle?.doorWood}
|
||
/>
|
||
</group>
|
||
</>
|
||
) : movementMode ? (
|
||
interiorStyle ? (
|
||
<TexturedWall
|
||
kind={interiorStyle.surfaces.wall}
|
||
tint={interiorStyle.tints.wall}
|
||
position={[0, WALL_HEIGHT / 2, -halfD]}
|
||
size={[width, WALL_HEIGHT, WALL_THICKNESS]}
|
||
/>
|
||
) : (
|
||
<GalleryWall position={[0, WALL_HEIGHT / 2, -halfD]} size={[width, WALL_HEIGHT]} color={walls.main} />
|
||
)
|
||
) : null}
|
||
|
||
{interiorStyle && (
|
||
<>
|
||
{computedWindows && computedWindows.length > 0 && (
|
||
<GalleryWindows
|
||
windows={computedWindows}
|
||
halfW={halfW}
|
||
halfD={halfD}
|
||
trimColor={interiorStyle.tints.trim}
|
||
/>
|
||
)}
|
||
{/* Always light the hall — do not gate track lights on window gaps. */}
|
||
<GalleryTrackLights
|
||
width={width}
|
||
depth={depth}
|
||
intensity={Math.max(1.8, interiorStyle.trackLights * 2.8) * Math.PI * interiorStyle.lightScale}
|
||
color={interiorStyle.warmLight}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{interiorStyle?.tints.wallSide && interiorStyle.surfaces.wall !== interiorStyle.surfaces.wallSide && (
|
||
<>
|
||
<mesh position={[0, 1.05, -halfD + WALL_THICKNESS / 2]}>
|
||
<boxGeometry args={[width, 2.1, 0.04]} />
|
||
<meshStandardMaterial color={interiorStyle.tints.trim} roughness={0.45} metalness={0.35} />
|
||
</mesh>
|
||
</>
|
||
)}
|
||
|
||
{/* Crown molding on back wall */}
|
||
<mesh position={[0, WALL_HEIGHT - 0.08, -halfD + WALL_THICKNESS / 2]}>
|
||
<boxGeometry args={[width, 0.14, 0.1]} />
|
||
<meshStandardMaterial color={walls.trim} roughness={0.5} metalness={0.25} />
|
||
</mesh>
|
||
|
||
{interiorStyle && (
|
||
<MovementHallDetails
|
||
style={interiorStyle}
|
||
width={width}
|
||
depth={depth}
|
||
halfW={halfW}
|
||
halfD={halfD}
|
||
windows={computedWindows}
|
||
/>
|
||
)}
|
||
|
||
<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}
|
||
influenceLampWorldY={influenceLampWorldY}
|
||
onClick={() => onPaintingClick(painting.id)}
|
||
/>
|
||
))}
|
||
{seg.label && (
|
||
<Text
|
||
position={[
|
||
seg.side === 'back' ? 0 : seg.side === 'left' ? -halfW + 0.15 : halfW - 0.15,
|
||
WALL_HEIGHT - 0.85,
|
||
seg.side === 'back' ? -halfD + 0.15 : 0,
|
||
]}
|
||
rotation={[0, seg.side === 'left' ? Math.PI / 2 : seg.side === 'right' ? -Math.PI / 2 : 0, 0]}
|
||
fontSize={0.14}
|
||
color="#6b5344"
|
||
anchorX="center"
|
||
>
|
||
{seg.label}
|
||
</Text>
|
||
)}
|
||
</group>
|
||
))}
|
||
|
||
<pointLight
|
||
position={[0, WALL_HEIGHT - 0.4, 0]}
|
||
intensity={(interiorStyle ? Math.max(1.0, interiorStyle.ambient * 1.8) : 0.7) * Math.PI * hallLightScale}
|
||
distance={width + depth}
|
||
decay={0}
|
||
color={warmLight}
|
||
/>
|
||
<pointLight
|
||
position={[0, WALL_HEIGHT - 0.4, -halfD / 2]}
|
||
intensity={(interiorStyle ? Math.max(0.7, interiorStyle.ambient * 1.2) : 0.5) * Math.PI * hallLightScale}
|
||
distance={Math.max(12, depth * 0.75)}
|
||
decay={0}
|
||
color={warmLight}
|
||
/>
|
||
{interiorStyle && (
|
||
<directionalLight
|
||
position={[0, 6, -halfD - 2]}
|
||
intensity={Math.max(0.55, interiorStyle.ambient * 0.85) * Math.PI * hallLightScale}
|
||
color={interiorStyle.sunLight}
|
||
/>
|
||
)}
|
||
</group>
|
||
);
|
||
}
|
||
|
||
function levelHorizontalView(pos: THREE.Vector3, target: THREE.Vector3) {
|
||
pos.y = EYE_HEIGHT;
|
||
target.y = EYE_HEIGHT;
|
||
}
|
||
|
||
function FrameloopSync({ active }: { active: boolean }) {
|
||
const { invalidate } = useThree();
|
||
useEffect(() => {
|
||
if (active) invalidate();
|
||
}, [active, invalidate]);
|
||
return null;
|
||
}
|
||
|
||
/** Compile all scene materials (incl. culled doors) before dismissing the loading overlay. */
|
||
function WarmHallGpu({
|
||
enabled,
|
||
onDone,
|
||
}: {
|
||
enabled: boolean;
|
||
onDone: () => void;
|
||
}) {
|
||
const { gl, scene, camera } = useThree();
|
||
|
||
useEffect(() => {
|
||
if (!enabled) return;
|
||
let cancelled = false;
|
||
let settled = false;
|
||
const done = () => {
|
||
if (cancelled || settled) return;
|
||
settled = true;
|
||
onDone();
|
||
};
|
||
|
||
const run = async () => {
|
||
try {
|
||
const compile = typeof gl.compileAsync === 'function'
|
||
? gl.compileAsync(scene, camera)
|
||
: Promise.resolve(gl.compile(scene, camera));
|
||
await Promise.race([
|
||
compile,
|
||
new Promise<void>((resolve) => {
|
||
window.setTimeout(resolve, SHADER_WARM_TIMEOUT_MS);
|
||
}),
|
||
]);
|
||
} catch {
|
||
// Still release the overlay if compile fails — better than hanging forever.
|
||
}
|
||
done();
|
||
};
|
||
void run();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [enabled, gl, scene, camera, onDone]);
|
||
|
||
return null;
|
||
}
|
||
|
||
function CameraController({
|
||
position,
|
||
target,
|
||
}: {
|
||
position: THREE.Vector3;
|
||
target: THREE.Vector3;
|
||
}) {
|
||
const { camera } = useThree();
|
||
useFrame(() => {
|
||
levelHorizontalView(position, target);
|
||
camera.position.lerp(position, 0.12);
|
||
camera.lookAt(target.x, EYE_HEIGHT, target.z);
|
||
});
|
||
return null;
|
||
}
|
||
|
||
function MovementHallNavPanel({
|
||
movementName,
|
||
halls,
|
||
currentIndex,
|
||
onSelectHall,
|
||
onExitTimeline,
|
||
onClose,
|
||
}: {
|
||
movementName: string;
|
||
halls: MovementHallLayout[];
|
||
currentIndex: number;
|
||
onSelectHall: (index: number) => void;
|
||
onExitTimeline: () => void;
|
||
onClose: () => void;
|
||
}) {
|
||
return (
|
||
<div className="exit-nav-overlay" role="dialog" aria-modal="true">
|
||
<div className="exit-nav-panel">
|
||
<header className="exit-nav-header">
|
||
<h2>{movementName} — gallery wings</h2>
|
||
<button type="button" className="exit-nav-close" onClick={onClose} aria-label="Close">
|
||
×
|
||
</button>
|
||
</header>
|
||
<div className="exit-nav-columns" style={{ gridTemplateColumns: '1fr' }}>
|
||
<div className="exit-nav-column">
|
||
<h3>Choose a wing</h3>
|
||
<ul className="movement-hall-nav-list">
|
||
{halls.map((hall, i) => (
|
||
<li key={i}>
|
||
<button
|
||
type="button"
|
||
className={i === currentIndex ? 'movement-hall-nav-active' : undefined}
|
||
onClick={() => onSelectHall(i)}
|
||
>
|
||
<span>
|
||
Wing {i + 1} of {hall.hallCount}
|
||
<small>
|
||
{hall.yearLabel} · {hall.paintingCount} works
|
||
{i === currentIndex ? ' · you are here' : ''}
|
||
</small>
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<button type="button" className="gallery-exit-btn movement-hall-exit-timeline" onClick={onExitTimeline}>
|
||
Back to Timeline
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NavigationPanel({
|
||
navigation,
|
||
loading,
|
||
onSelect,
|
||
onExitTimeline,
|
||
onClose,
|
||
}: {
|
||
navigation: ArtistNavigation | null;
|
||
loading: boolean;
|
||
onSelect: (artistId: number) => void;
|
||
onExitTimeline: () => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const renderColumn = (title: string, groups: MovementArtistGroup[], emptyHint: string) => (
|
||
<div className="exit-nav-column">
|
||
<h3>{title}</h3>
|
||
{loading && <p className="exit-nav-loading">Loading…</p>}
|
||
{!loading && groups.length === 0 && <p className="exit-nav-empty">{emptyHint}</p>}
|
||
{!loading &&
|
||
groups.map((group) => (
|
||
<div key={group.movement_id ?? group.movement_name} className="exit-nav-movement">
|
||
<h4 style={{ borderColor: group.movement_color }}>{group.movement_name}</h4>
|
||
<ul>
|
||
{group.artists.map((artist) => (
|
||
<li key={artist.id}>
|
||
<button type="button" onClick={() => onSelect(artist.id)}>
|
||
<img src={imageUrl(artist.portrait_path)} alt="" />
|
||
<span>
|
||
{artist.name}
|
||
{artist.birth_year && (
|
||
<small>
|
||
{artist.birth_year}
|
||
{artist.death_year ? `–${artist.death_year}` : ''}
|
||
</small>
|
||
)}
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="exit-nav-overlay" role="dialog" aria-modal="true">
|
||
<div className="exit-nav-panel">
|
||
<header className="exit-nav-header">
|
||
<h2>Choose your path</h2>
|
||
<button type="button" className="exit-nav-close" onClick={onClose} aria-label="Close">
|
||
×
|
||
</button>
|
||
</header>
|
||
<div className="exit-nav-columns">
|
||
{renderColumn(
|
||
'Predecessors',
|
||
navigation?.predecessors ?? [],
|
||
'No documented predecessors via painting influences.'
|
||
)}
|
||
{renderColumn(
|
||
'Successors',
|
||
navigation?.successors ?? [],
|
||
'No documented successors via painting influences.'
|
||
)}
|
||
</div>
|
||
<div className="exit-nav-footer">
|
||
<button type="button" className="gallery-exit-btn movement-hall-exit-timeline" onClick={onExitTimeline}>
|
||
Back to Timeline
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function VirtualGallery(props: Props) {
|
||
const {
|
||
imageRevisions,
|
||
active = true,
|
||
onPaintingClick,
|
||
onBack,
|
||
} = props;
|
||
|
||
const isMovement = props.mode === 'movement';
|
||
const isTour = props.mode === 'tour';
|
||
const isWingedHall = isMovement || isTour;
|
||
const hallKey = isTour
|
||
? props.data.tour.id
|
||
: isMovement
|
||
? props.data.movement.id
|
||
: props.data.artist.id;
|
||
const hallTitle = isTour
|
||
? props.data.tour.title
|
||
: isMovement
|
||
? props.data.movement.name
|
||
: props.data.artist.name;
|
||
const movementColor = isTour
|
||
? DEFAULT_MOVEMENT_COLOR
|
||
: isMovement
|
||
? props.data.movement.color
|
||
: props.data.artist.movement_color;
|
||
const initialPaintings = useMemo(() => {
|
||
if (props.mode === 'movement') {
|
||
return [...props.data.paintings].sort(comparePaintingsChronological);
|
||
}
|
||
return props.data.paintings;
|
||
}, [
|
||
props.mode,
|
||
props.mode === 'tour'
|
||
? props.data.tour.id
|
||
: props.mode === 'movement'
|
||
? props.data.movement.id
|
||
: props.data.artist.id,
|
||
props.data.paintings,
|
||
]);
|
||
const initialPeriods = props.mode === 'artist' ? props.data.periods : [];
|
||
|
||
const [paintings, setPaintings] = useState(initialPaintings);
|
||
const [periods, setPeriods] = useState(initialPeriods);
|
||
const [syncStatus, setSyncStatus] = useState('');
|
||
const [showExitNav, setShowExitNav] = useState(false);
|
||
const [navigation, setNavigation] = useState<ArtistNavigation | null>(null);
|
||
const [navLoading, setNavLoading] = useState(false);
|
||
const [nearExit, setNearExit] = useState(false);
|
||
const [nearPassage, setNearPassage] = useState(false);
|
||
const [isLooking, setIsLooking] = useState(false);
|
||
const [texturesPending, setTexturesPending] = useState(0);
|
||
const [canvasReady, setCanvasReady] = useState(false);
|
||
const [shadersWarmed, setShadersWarmed] = useState(false);
|
||
const [glEpoch, setGlEpoch] = useState(0);
|
||
const [glLost, setGlLost] = useState(false);
|
||
|
||
const textureLoad = useMemo(
|
||
() => ({
|
||
begin: () => setTexturesPending((n) => n + 1),
|
||
end: () => setTexturesPending((n) => Math.max(0, n - 1)),
|
||
}),
|
||
[]
|
||
);
|
||
|
||
const handleShadersWarmed = useCallback(() => {
|
||
setShadersWarmed(true);
|
||
}, []);
|
||
|
||
const handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => {
|
||
const canvas = state.gl.domElement;
|
||
// Physically correct lights (three r155+) need higher exposure so stone/dark
|
||
// period halls stay readable without HDR IBL.
|
||
state.gl.toneMapping = THREE.ACESFilmicToneMapping;
|
||
state.gl.toneMappingExposure = 1.25;
|
||
// A freshly created canvas has a healthy context, so clear any lingering
|
||
// "restoring" state from a previous loss/remount.
|
||
setGlLost(false);
|
||
setCanvasReady(true);
|
||
const onLost = (event: Event) => {
|
||
// Prevent the default so the browser can restore the context, and
|
||
// force a clean remount to obtain a fresh WebGL context if it does not.
|
||
event.preventDefault();
|
||
setGlLost(true);
|
||
window.setTimeout(() => {
|
||
setGlLost((stillLost) => {
|
||
if (stillLost) setGlEpoch((n) => n + 1);
|
||
return stillLost;
|
||
});
|
||
}, 600);
|
||
};
|
||
const onRestored = () => {
|
||
setGlLost(false);
|
||
setGlEpoch((n) => n + 1);
|
||
};
|
||
canvas.addEventListener('webglcontextlost', onLost as EventListener, false);
|
||
canvas.addEventListener('webglcontextrestored', onRestored as EventListener, false);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
setPaintings(initialPaintings);
|
||
setPeriods(initialPeriods);
|
||
}, [initialPaintings, initialPeriods, hallKey]);
|
||
|
||
useEffect(() => {
|
||
if (props.mode === 'movement') {
|
||
const withImg = initialPaintings.filter((p) => p.image_path || p.thumbnail_path).length;
|
||
setSyncStatus(
|
||
withImg < initialPaintings.length
|
||
? `${withImg} of ${initialPaintings.length} works have images`
|
||
: ''
|
||
);
|
||
return;
|
||
}
|
||
|
||
const withImg = initialPaintings.filter((p) => p.image_path || p.thumbnail_path).length;
|
||
setSyncStatus(
|
||
withImg < initialPaintings.length
|
||
? `${withImg} of ${initialPaintings.length} works have images`
|
||
: ''
|
||
);
|
||
}, [hallKey, props.mode, initialPaintings]);
|
||
|
||
const interiorStyle = useMemo(
|
||
() => (isMovement ? resolveMovementInteriorStyle(props.data.movement) : undefined),
|
||
[isMovement, isMovement ? props.data.movement : null]
|
||
);
|
||
|
||
const movementHalls = useMemo(
|
||
() => (isWingedHall ? buildAllMovementHallLayouts(paintings) : []),
|
||
[isWingedHall, paintings]
|
||
);
|
||
|
||
const [hallIndex, setHallIndex] = useState(0);
|
||
|
||
useEffect(() => {
|
||
setHallIndex(0);
|
||
}, [hallKey]);
|
||
|
||
useEffect(() => {
|
||
setShadersWarmed(false);
|
||
setTexturesPending(0);
|
||
}, [hallKey, glEpoch]);
|
||
|
||
useEffect(() => {
|
||
setShadersWarmed(false);
|
||
}, [hallIndex]);
|
||
|
||
// If shader warm-up never settles, dismiss the overlay anyway.
|
||
useEffect(() => {
|
||
if (shadersWarmed || !canvasReady) return;
|
||
const t = window.setTimeout(() => setShadersWarmed(true), SHADER_WARM_TIMEOUT_MS + 500);
|
||
return () => window.clearTimeout(t);
|
||
}, [shadersWarmed, canvasReady, hallKey, glEpoch, hallIndex]);
|
||
|
||
const layout = useMemo(() => {
|
||
if (isWingedHall && movementHalls.length > 0) {
|
||
return movementHalls[Math.min(hallIndex, movementHalls.length - 1)];
|
||
}
|
||
return buildHallLayout(paintings, periods);
|
||
}, [isWingedHall, movementHalls, hallIndex, paintings, periods]);
|
||
|
||
// Do not block hall entry on HDR Environment (CDN) — warm shaders as soon as
|
||
// the canvas exists; Environment continues loading in the background.
|
||
const gallerySceneLoading =
|
||
active &&
|
||
!glLost &&
|
||
(!canvasReady || !shadersWarmed);
|
||
|
||
const galleryLoadingMessage =
|
||
canvasReady && texturesPending > 0 ? 'Loading paintings…' : 'Loading gallery…';
|
||
|
||
const warmGpuEnabled = canvasReady && !shadersWarmed;
|
||
|
||
const computedWindows = useMemo(() => {
|
||
if (!isMovement || !interiorStyle || !('hallIndex' in layout)) return undefined;
|
||
return computeSideWallWindows(layout as MovementHallLayout, interiorStyle);
|
||
}, [isMovement, interiorStyle, layout]);
|
||
|
||
const hasNextHall = isWingedHall && movementHalls.length > 1 && hallIndex < movementHalls.length - 1;
|
||
|
||
const { halfW: wallInnerHalfW, halfD: wallInnerHalfD } = useMemo(
|
||
() => innerWallHalfExtents(layout.width, layout.depth),
|
||
[layout.width, layout.depth]
|
||
);
|
||
/** Playable half-extents: 0.5 m clear of inner wall faces (corners included). */
|
||
const playHalfW = wallInnerHalfW - PLAYER_CLEARANCE;
|
||
const playHalfD = wallInnerHalfD - PLAYER_CLEARANCE;
|
||
const exitZ = playHalfD;
|
||
const fogFar = Math.max(55, layout.depth + 42);
|
||
|
||
const collisionObstacles = useMemo(() => {
|
||
const boxes: XZBounds[] = [];
|
||
for (const seg of layout.segments) {
|
||
for (const slot of seg.slots) boxes.push(paintingKeepOutBounds(slot));
|
||
}
|
||
const endWallHasDoor =
|
||
isWingedHall && 'endWallHasDoor' in layout ? layout.endWallHasDoor : false;
|
||
// Front door/passage jambs when the entrance wall has an opening.
|
||
if (!isWingedHall || hasNextHall || !endWallHasDoor) {
|
||
boxes.push(...doorJambKeepOuts(wallInnerHalfD, true));
|
||
}
|
||
// Back exit jambs for multi-wing halls.
|
||
if (isWingedHall && endWallHasDoor) {
|
||
boxes.push(...doorJambKeepOuts(wallInnerHalfD, false));
|
||
}
|
||
return boxes;
|
||
}, [layout, wallInnerHalfD, isWingedHall, hasNextHall]);
|
||
|
||
const endWallHasDoor =
|
||
isWingedHall && 'endWallHasDoor' in layout ? layout.endWallHasDoor : false;
|
||
const exitOnFront = !isWingedHall || !endWallHasDoor;
|
||
|
||
const resolvePlayerPosition = useCallback(
|
||
(pos: { x: number; z: number }, allowFrontDoorApproach: boolean) => {
|
||
const clampToWalls = () => {
|
||
pos.x = Math.max(-playHalfW, Math.min(playHalfW, pos.x));
|
||
const inDoorBand = Math.abs(pos.x) < DOOR_WIDTH / 2 - PLAYER_CLEARANCE * 0.35;
|
||
if (isWingedHall) {
|
||
let minZ = -playHalfD;
|
||
let maxZ = playHalfD;
|
||
if (inDoorBand) {
|
||
if (endWallHasDoor) minZ = -wallInnerHalfD + 0.08;
|
||
if (hasNextHall || allowFrontDoorApproach || exitOnFront) {
|
||
maxZ = wallInnerHalfD - 0.08;
|
||
}
|
||
}
|
||
pos.z = Math.max(minZ, Math.min(maxZ, pos.z));
|
||
} else {
|
||
let maxZ = exitZ;
|
||
if (inDoorBand && allowFrontDoorApproach) maxZ = wallInnerHalfD - 0.08;
|
||
pos.z = Math.max(-playHalfD, Math.min(maxZ, pos.z));
|
||
}
|
||
};
|
||
|
||
clampToWalls();
|
||
for (const box of collisionObstacles) pushOutOfBounds(pos, box);
|
||
clampToWalls();
|
||
},
|
||
[
|
||
playHalfW,
|
||
playHalfD,
|
||
exitZ,
|
||
isWingedHall,
|
||
hasNextHall,
|
||
endWallHasDoor,
|
||
exitOnFront,
|
||
wallInnerHalfD,
|
||
collisionObstacles,
|
||
]
|
||
);
|
||
|
||
const initialPos = useMemo(
|
||
() => new THREE.Vector3(0, EYE_HEIGHT, layout.depth / 2 - 2.2),
|
||
[layout.depth]
|
||
);
|
||
const initialTarget = useMemo(
|
||
() => new THREE.Vector3(0, EYE_HEIGHT, -layout.depth * (layout.depth > 14 ? 0.38 : 0.25)),
|
||
[layout.depth]
|
||
);
|
||
|
||
const [camPos, setCamPos] = useState(() => initialPos.clone());
|
||
const [camTarget, setCamTarget] = useState(() => initialTarget.clone());
|
||
const keysPressed = useRef<Set<string>>(new Set());
|
||
const dragTurnActive = useRef(false);
|
||
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
|
||
const camPosRef = useRef(camPos);
|
||
const camTargetRef = useRef(camTarget);
|
||
camPosRef.current = camPos;
|
||
camTargetRef.current = camTarget;
|
||
|
||
const goToHall = useCallback(
|
||
(index: number, enterFrom: 'front' | 'back' | 'default' = 'default') => {
|
||
const clamped = Math.max(0, Math.min(index, movementHalls.length - 1));
|
||
setHallIndex(clamped);
|
||
setShowExitNav(false);
|
||
setNearExit(false);
|
||
setNearPassage(false);
|
||
const nextLayout = movementHalls[clamped];
|
||
if (!nextLayout) return;
|
||
const nextHalfD = nextLayout.depth / 2;
|
||
if (enterFrom === 'back') {
|
||
setCamPos(new THREE.Vector3(0, EYE_HEIGHT, -nextHalfD + 2.2));
|
||
setCamTarget(new THREE.Vector3(0, EYE_HEIGHT, nextHalfD * 0.25));
|
||
} else {
|
||
setCamPos(new THREE.Vector3(0, EYE_HEIGHT, nextHalfD - 2.2));
|
||
setCamTarget(new THREE.Vector3(0, EYE_HEIGHT, -nextHalfD * 0.25));
|
||
}
|
||
},
|
||
[movementHalls]
|
||
);
|
||
|
||
const goToNextHall = useCallback(() => {
|
||
if (hallIndex < movementHalls.length - 1) goToHall(hallIndex + 1, 'back');
|
||
}, [hallIndex, movementHalls.length, goToHall]);
|
||
|
||
useEffect(() => {
|
||
setCamPos(initialPos.clone());
|
||
setCamTarget(initialTarget.clone());
|
||
setShowExitNav(false);
|
||
setNearExit(false);
|
||
setNearPassage(false);
|
||
}, [hallKey, initialPos, initialTarget]);
|
||
|
||
const openExitNav = useCallback(async () => {
|
||
if (isWingedHall) {
|
||
setShowExitNav(true);
|
||
return;
|
||
}
|
||
setShowExitNav(true);
|
||
setNavLoading(true);
|
||
try {
|
||
const nav = await api.getArtistNavigation(props.data.artist.id);
|
||
setNavigation(nav);
|
||
} catch {
|
||
setNavigation({ predecessors: [], successors: [] });
|
||
} finally {
|
||
setNavLoading(false);
|
||
}
|
||
}, [isWingedHall, onBack, !isWingedHall ? props.data.artist.id : undefined]);
|
||
|
||
const backExitZ = isWingedHall && endWallHasDoor ? -playHalfD : exitZ;
|
||
const frontPassageZ = playHalfD;
|
||
|
||
const updateProximityFlags = useCallback(
|
||
(pos: { x: number; z: number }) => {
|
||
if (isWingedHall) {
|
||
const atBackExit =
|
||
endWallHasDoor &&
|
||
pos.z < backExitZ + 0.8 &&
|
||
Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
||
const atFrontExit =
|
||
!endWallHasDoor &&
|
||
pos.z > frontPassageZ - 0.8 &&
|
||
Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
||
const atFrontPassage =
|
||
hasNextHall && pos.z > frontPassageZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
||
setNearExit(!!(atBackExit || atFrontExit));
|
||
setNearPassage(!!atFrontPassage);
|
||
} else {
|
||
const atExit = pos.z > exitZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
||
setNearExit(atExit);
|
||
}
|
||
},
|
||
[isWingedHall, endWallHasDoor, backExitZ, frontPassageZ, hasNextHall, exitZ]
|
||
);
|
||
|
||
const moveCamera = useCallback(
|
||
(forward: number, strafe: number, rotY: number) => {
|
||
const pos = camPosRef.current.clone();
|
||
const target = camTargetRef.current.clone();
|
||
const angle = Math.atan2(target.x - pos.x, target.z - pos.z);
|
||
|
||
// Turn in place: never move; only change look direction.
|
||
if (rotY !== 0) {
|
||
const newAngle = angle + rotY;
|
||
const dist = Math.max(0.5, pos.distanceTo(target));
|
||
target.x = pos.x + Math.sin(newAngle) * dist;
|
||
target.z = pos.z + Math.cos(newAngle) * dist;
|
||
levelHorizontalView(pos, target);
|
||
updateProximityFlags(pos);
|
||
setCamPos(pos);
|
||
setCamTarget(target);
|
||
return;
|
||
}
|
||
|
||
const dx = Math.sin(angle) * forward + Math.sin(angle + Math.PI / 2) * strafe;
|
||
const dz = Math.cos(angle) * forward + Math.cos(angle + Math.PI / 2) * strafe;
|
||
if (dx === 0 && dz === 0) {
|
||
updateProximityFlags(pos);
|
||
return;
|
||
}
|
||
|
||
const proposed = { x: pos.x + dx, z: pos.z + dz };
|
||
const resolved = { x: proposed.x, z: proposed.z };
|
||
resolvePlayerPosition(resolved, true);
|
||
|
||
// Hit wall/painting/door jamb: cancel the whole step — no slide, no view change.
|
||
if (
|
||
Math.abs(resolved.x - proposed.x) > 1e-4 ||
|
||
Math.abs(resolved.z - proposed.z) > 1e-4
|
||
) {
|
||
updateProximityFlags(pos);
|
||
return;
|
||
}
|
||
|
||
pos.x = proposed.x;
|
||
pos.z = proposed.z;
|
||
target.x += dx;
|
||
target.z += dz;
|
||
levelHorizontalView(pos, target);
|
||
updateProximityFlags(pos);
|
||
setCamPos(pos);
|
||
setCamTarget(target);
|
||
},
|
||
[resolvePlayerPosition, updateProximityFlags]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (active) return;
|
||
keysPressed.current.clear();
|
||
dragTurnActive.current = false;
|
||
dragStartRef.current = null;
|
||
setIsLooking(false);
|
||
}, [active]);
|
||
|
||
useEffect(() => {
|
||
if (!active) return;
|
||
|
||
const onKeyDown = (e: KeyboardEvent) => {
|
||
keysPressed.current.add(e.key);
|
||
if ((e.key === 'e' || e.key === 'E') && !showExitNav) {
|
||
if (isWingedHall && nearPassage && hasNextHall) {
|
||
goToNextHall();
|
||
} else {
|
||
openExitNav();
|
||
}
|
||
}
|
||
};
|
||
const onKeyUp = (e: KeyboardEvent) => keysPressed.current.delete(e.key);
|
||
window.addEventListener('keydown', onKeyDown);
|
||
window.addEventListener('keyup', onKeyUp);
|
||
|
||
const interval = setInterval(() => {
|
||
const keys = keysPressed.current;
|
||
if (keys.has('ArrowUp') || keys.has('w') || keys.has('W')) moveCamera(0.1, 0, 0);
|
||
if (keys.has('ArrowDown') || keys.has('s') || keys.has('S')) moveCamera(-0.1, 0, 0);
|
||
if (keys.has('ArrowLeft') || keys.has('a') || keys.has('A') || keys.has('q') || keys.has('Q')) {
|
||
moveCamera(0, 0, TURN_SPEED);
|
||
}
|
||
if (keys.has('ArrowRight') || keys.has('d') || keys.has('D')) {
|
||
moveCamera(0, 0, -TURN_SPEED);
|
||
}
|
||
}, 16);
|
||
|
||
return () => {
|
||
window.removeEventListener('keydown', onKeyDown);
|
||
window.removeEventListener('keyup', onKeyUp);
|
||
clearInterval(interval);
|
||
};
|
||
}, [active, moveCamera, showExitNav, openExitNav, isWingedHall, nearPassage, hasNextHall, goToNextHall]);
|
||
|
||
const handleNavigate = (artistId: number) => {
|
||
if (isWingedHall) return;
|
||
setShowExitNav(false);
|
||
props.onNavigateArtist(artistId);
|
||
};
|
||
|
||
const subtitle = isTour
|
||
? `Guided tour · ${paintings.length} works${
|
||
movementHalls.length > 1 ? ` · Wing ${hallIndex + 1}/${movementHalls.length}` : ''
|
||
}`
|
||
: isMovement
|
||
? interiorStyle
|
||
? `${interiorStyle.subtitle} · ${paintings.length} works${
|
||
movementHalls.length > 1
|
||
? ` · Wing ${hallIndex + 1}/${movementHalls.length} (${(layout as MovementHallLayout).yearLabel})`
|
||
: ''
|
||
}`
|
||
: `Movement gallery · ${paintings.length} works · chronological`
|
||
: `Personal hall · ${paintings.length} works on the walls`;
|
||
|
||
const sceneBackground = interiorStyle?.background ?? '#0d0906';
|
||
const sceneFog = interiorStyle?.fog ?? '#0d0906';
|
||
// Floor ambient so dark period styles (Gothic, Byzantine) stay readable without HDR IBL.
|
||
// three r155+ physical lights need ~π× legacy intensity for similar brightness.
|
||
const lightScale = interiorStyle?.lightScale ?? 1;
|
||
const ambientIntensity = Math.max(0.85, interiorStyle?.ambient ?? 0.55) * Math.PI * lightScale;
|
||
|
||
const handleCanvasPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||
if (!active || showExitNav || e.button !== 0) return;
|
||
dragStartRef.current = { x: e.clientX, y: e.clientY };
|
||
};
|
||
|
||
const handleCanvasPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||
if (!active || showExitNav || !dragStartRef.current) return;
|
||
|
||
const dx = e.clientX - dragStartRef.current.x;
|
||
const dy = e.clientY - dragStartRef.current.y;
|
||
const distSq = dx * dx + dy * dy;
|
||
|
||
if (!dragTurnActive.current && distSq >= DRAG_START_THRESHOLD_PX * DRAG_START_THRESHOLD_PX) {
|
||
dragTurnActive.current = true;
|
||
setIsLooking(true);
|
||
e.currentTarget.setPointerCapture(e.pointerId);
|
||
}
|
||
|
||
if (dragTurnActive.current && e.movementX !== 0) {
|
||
moveCamera(0, 0, e.movementX * MOUSE_TURN_SENSITIVITY);
|
||
}
|
||
};
|
||
|
||
const endCanvasDrag = (e: React.PointerEvent<HTMLDivElement>) => {
|
||
dragStartRef.current = null;
|
||
dragTurnActive.current = false;
|
||
setIsLooking(false);
|
||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||
}
|
||
};
|
||
|
||
const exitHint = isWingedHall ? (
|
||
<>
|
||
Back wall: <kbd>E</kbd> for wing navigator · Front arch: next wing
|
||
{movementHalls.length > 1 ? ` (${hallIndex + 1}/${movementHalls.length})` : ''}
|
||
</>
|
||
) : (
|
||
<>Click the exit door, <kbd>E</kbd>, or <strong>Exit →</strong> above</>
|
||
);
|
||
|
||
const hallSubtitle =
|
||
isWingedHall && 'yearLabel' in layout
|
||
? `Wing ${hallIndex + 1} of ${movementHalls.length}${
|
||
isMovement ? ` · ${(layout as MovementHallLayout).yearLabel}` : ''
|
||
}`
|
||
: undefined;
|
||
|
||
const instructionsTitle = isTour
|
||
? `${hallTitle} · Guided tour`
|
||
: isMovement
|
||
? interiorStyle
|
||
? `${hallTitle} · ${interiorStyle.label}`
|
||
: `${hallTitle} Gallery`
|
||
: `${hallTitle}'s Hall`;
|
||
|
||
return (
|
||
<div className="virtual-gallery">
|
||
<div className="gallery-header">
|
||
<button className="gallery-back-btn" onClick={onBack}>← Back to Timeline</button>
|
||
<div className="gallery-title-block">
|
||
<h2>{hallTitle}</h2>
|
||
<p className="gallery-career-path">{subtitle}</p>
|
||
</div>
|
||
<div className="gallery-header-meta">
|
||
<button type="button" className="gallery-exit-btn" onClick={openExitNav}>
|
||
{isWingedHall ? 'Wings / Exit →' : 'Exit →'}
|
||
</button>
|
||
{!isWingedHall && (
|
||
<button className="gallery-bio-btn" onClick={props.onBioClick}>Biography</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
className={`gallery-canvas-container${isLooking ? ' gallery-canvas-dragging' : ''}`}
|
||
onPointerDown={handleCanvasPointerDown}
|
||
onPointerMove={handleCanvasPointerMove}
|
||
onPointerUp={endCanvasDrag}
|
||
onPointerLeave={endCanvasDrag}
|
||
onPointerCancel={endCanvasDrag}
|
||
>
|
||
{active && (gallerySceneLoading || glLost) && (
|
||
<GalleryLoadingMarker
|
||
overlay
|
||
message={glLost ? 'Restoring gallery…' : galleryLoadingMessage}
|
||
/>
|
||
)}
|
||
{syncStatus && !gallerySceneLoading && !glLost && (
|
||
<div className="gallery-loading-overlay gallery-sync-badge">
|
||
<p>{syncStatus}</p>
|
||
</div>
|
||
)}
|
||
{!showExitNav && (
|
||
<div className="gallery-exit-hint">{exitHint}</div>
|
||
)}
|
||
<Canvas
|
||
key={`${hallKey}-${glEpoch}`}
|
||
frameloop={active ? 'always' : 'never'}
|
||
gl={{ preserveDrawingBuffer: true, powerPreference: 'high-performance' }}
|
||
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
|
||
onCreated={handleCanvasCreated}
|
||
>
|
||
<FrameloopSync active={active} />
|
||
<color attach="background" args={[sceneBackground]} />
|
||
<fog
|
||
attach="fog"
|
||
args={[sceneFog, Math.max(45, layout.depth * 1.15), Math.max(fogFar, layout.depth + 55)]}
|
||
/>
|
||
<ambientLight intensity={ambientIntensity} />
|
||
<hemisphereLight
|
||
color={interiorStyle?.warmLight ?? '#fff8f0'}
|
||
groundColor={sceneFog}
|
||
intensity={0.55 * Math.PI * lightScale}
|
||
/>
|
||
<directionalLight
|
||
position={interiorStyle ? [2, 10, -4] : [3, 9, 4]}
|
||
intensity={(interiorStyle ? 1.4 : 0.9) * Math.PI * lightScale}
|
||
color={interiorStyle?.sunLight ?? '#fff8f0'}
|
||
/>
|
||
<directionalLight
|
||
position={[-4, 6, 5]}
|
||
intensity={0.55 * Math.PI * lightScale}
|
||
color={interiorStyle?.warmLight ?? '#fff5e8'}
|
||
/>
|
||
{/* Guaranteed fill so walls never disappear if HDR Environment fails. */}
|
||
<pointLight
|
||
position={[0, 3.2, 0]}
|
||
intensity={2.2 * Math.PI * lightScale}
|
||
distance={40}
|
||
decay={0}
|
||
color={interiorStyle?.warmLight ?? '#fff5e8'}
|
||
/>
|
||
<SceneErrorBoundary
|
||
key={`env-${hallKey}-${glEpoch}`}
|
||
fallback={null}
|
||
>
|
||
<Suspense fallback={null}>
|
||
<Environment
|
||
preset={interiorStyle && CITY_ENVIRONMENT_DETAILS.has(interiorStyle.details) ? 'city' : 'warehouse'}
|
||
environmentIntensity={(interiorStyle ? 0.55 : 0.25) * lightScale}
|
||
/>
|
||
</Suspense>
|
||
</SceneErrorBoundary>
|
||
<GalleryTextureLoadContext.Provider value={textureLoad}>
|
||
<ArtistHall
|
||
layout={layout}
|
||
hallTitle={hallTitle}
|
||
hallSubtitle={hallSubtitle}
|
||
movementColor={movementColor}
|
||
interiorStyle={interiorStyle}
|
||
imageRevisions={imageRevisions}
|
||
showCaptions={isWingedHall}
|
||
onPaintingClick={onPaintingClick}
|
||
onExitActivate={openExitNav}
|
||
nearExit={nearExit}
|
||
movementMode={isWingedHall}
|
||
computedWindows={computedWindows}
|
||
hasNextHall={hasNextHall}
|
||
onNextHall={goToNextHall}
|
||
nearPassage={nearPassage}
|
||
/>
|
||
</GalleryTextureLoadContext.Provider>
|
||
<WarmHallGpu enabled={warmGpuEnabled} onDone={handleShadersWarmed} />
|
||
<CameraController position={camPos} target={camTarget} />
|
||
</Canvas>
|
||
</div>
|
||
|
||
{!isWingedHall && showExitNav && (
|
||
<NavigationPanel
|
||
navigation={navigation}
|
||
loading={navLoading}
|
||
onSelect={handleNavigate}
|
||
onExitTimeline={onBack}
|
||
onClose={() => setShowExitNav(false)}
|
||
/>
|
||
)}
|
||
|
||
{isWingedHall && showExitNav && (
|
||
<MovementHallNavPanel
|
||
movementName={hallTitle}
|
||
halls={movementHalls}
|
||
currentIndex={hallIndex}
|
||
onSelectHall={(i) => goToHall(i)}
|
||
onExitTimeline={onBack}
|
||
onClose={() => setShowExitNav(false)}
|
||
/>
|
||
)}
|
||
|
||
<div className="gallery-controls">
|
||
<div className="control-pad">
|
||
<button onClick={() => moveCamera(-0.35, 0, 0)} title="Walk forward">▲</button>
|
||
<div className="control-row">
|
||
<button onClick={() => moveCamera(0, 0, TURN_SPEED * 4)} title="Turn left">↺</button>
|
||
<button onClick={() => moveCamera(0.35, 0, 0)} title="Walk back">▼</button>
|
||
<button onClick={() => moveCamera(0, 0, -TURN_SPEED * 4)} title="Turn right">↻</button>
|
||
</div>
|
||
</div>
|
||
<div className="gallery-instructions">
|
||
<h4>{instructionsTitle}</h4>
|
||
<ul>
|
||
<li><kbd>W</kbd> / <kbd>↑</kbd> Walk forward</li>
|
||
<li><kbd>S</kbd> / <kbd>↓</kbd> Walk back</li>
|
||
<li><kbd>A</kbd> / <kbd>←</kbd> / <kbd>Q</kbd> Turn left</li>
|
||
<li><kbd>D</kbd> / <kbd>→</kbd> Turn right</li>
|
||
<li>Drag on the view to look around</li>
|
||
<li>Click a painting to view details{isTour ? ' and tour notes' : ' and influences'}</li>
|
||
{isWingedHall ? (
|
||
<>
|
||
<li>Date and artist labels appear below each frame</li>
|
||
<li>Works hang on left, end, and right walls — up to ~55 per wing</li>
|
||
<li>
|
||
{movementHalls.length > 1
|
||
? 'Back door: wing navigator & exit to timeline · Front: next wing'
|
||
: 'Entrance door: exit to timeline'}
|
||
</li>
|
||
{movementHalls.length > 1 && (
|
||
<li>Front archway: walk to the next chronological wing</li>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
<li>Golden lamps mark works linked in the influence graph</li>
|
||
<li>Click the exit door or <kbd>E</kbd> for related artists or back to the timeline</li>
|
||
</>
|
||
)}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|