Improve 3D gallery and debug checkup workflow for image curation.
Add parquet floor, museum-style exit, movement-tinted walls, and gold/black frames with gallery sync after Fix it. Debug panel gets Checked and Fix it buttons; documentation and image-fetch reliability updates included. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
4c6acd5a3a
commit
019ce4e136
@@ -9,12 +9,14 @@ import type {
|
||||
ArtistNavigation,
|
||||
MovementArtistGroup,
|
||||
} from '../types';
|
||||
import { galleryImageUrl, imageUrl, api, preloadArtistImages } from '../api/client';
|
||||
import { galleryImageUrlWithRevision, imageUrl, api, preloadArtistImages } from '../api/client';
|
||||
import { comparePaintingsChronological, paintingHasInfluenceLinks } from '../utils/paintingUtils';
|
||||
import { createParquetFloorTexture, PARQUET_METERS_PER_TILE } from '../utils/parquetFloorTexture';
|
||||
import './VirtualGallery.css';
|
||||
|
||||
interface Props {
|
||||
data: ArtistDetail;
|
||||
imageRevisions?: Record<number, number>;
|
||||
active?: boolean;
|
||||
onPaintingClick: (paintingId: number) => void;
|
||||
onNavigateArtist: (artistId: number) => void;
|
||||
@@ -28,20 +30,66 @@ 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.18;
|
||||
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;
|
||||
|
||||
@@ -68,34 +116,73 @@ interface HallLayout {
|
||||
segments: WallSegment[];
|
||||
}
|
||||
|
||||
function layoutRow(count: number, span: number) {
|
||||
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 padding = 1.4;
|
||||
const available = span - padding;
|
||||
let frameW = Math.min(MAX_FRAME_W, (available - (count - 1) * gap) / Math.max(count, 1));
|
||||
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 rowWidth = count * frameW + (count - 1) * gap;
|
||||
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: -rowWidth / 2 + frameW / 2 + i * (frameW + gap),
|
||||
offset: cursor + outerW / 2,
|
||||
maxW: frameW,
|
||||
maxH: frameH,
|
||||
});
|
||||
cursor += outerW + gap;
|
||||
}
|
||||
|
||||
return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) };
|
||||
}
|
||||
|
||||
function wallRowHeights(count: number, span: number, rows: number) {
|
||||
const perRow = Math.ceil(count / rows);
|
||||
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 inRow = Math.min(perRow, count - r * perRow);
|
||||
const { slots } = layoutRow(inRow, span);
|
||||
heights.push(slots[0]?.maxH ?? MAX_FRAME_H);
|
||||
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;
|
||||
}
|
||||
@@ -104,16 +191,14 @@ function wallStackHeight(heights: number[]) {
|
||||
return heights.reduce((sum, h, i) => sum + h + (i > 0 ? ROW_GAP : 0), 0);
|
||||
}
|
||||
|
||||
function fitsOnWall(count: number, span: number, rows: number) {
|
||||
function fitsOnWall(paintings: Painting[], span: number, rows: number) {
|
||||
const count = paintings.length;
|
||||
const perRow = Math.ceil(count / rows);
|
||||
const available = span - WALL_PADDING;
|
||||
const frameW = Math.min(
|
||||
MAX_FRAME_W,
|
||||
(available - (perRow - 1) * FRAME_GAP) / Math.max(perRow, 1)
|
||||
);
|
||||
if (frameW < MIN_FRAME_W) return false;
|
||||
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(count, span, rows));
|
||||
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;
|
||||
@@ -121,26 +206,35 @@ function fitsOnWall(count: number, span: number, rows: number) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function rowCountForWall(count: number, span: number) {
|
||||
for (let rows = 1; rows <= count; rows++) {
|
||||
if (fitsOnWall(count, span, rows)) return rows;
|
||||
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 count;
|
||||
return limit;
|
||||
}
|
||||
|
||||
function minSpanForWall(count: number) {
|
||||
function minSpanForWall(paintings: Painting[], maxRows: number = paintings.length) {
|
||||
const count = paintings.length;
|
||||
if (count === 0) return MIN_HALL_SIZE;
|
||||
|
||||
let best = Infinity;
|
||||
for (let rows = 1; rows <= count; rows++) {
|
||||
const rowLimit = Math.max(1, Math.min(count, maxRows));
|
||||
for (let rows = 1; rows <= rowLimit; rows++) {
|
||||
const perRow = Math.ceil(count / rows);
|
||||
const span = perRow * MIN_FRAME_W + (perRow - 1) * FRAME_GAP + WALL_PADDING;
|
||||
if (fitsOnWall(count, span, rows)) {
|
||||
best = Math.min(best, span);
|
||||
const { spanNeeded } = layoutRow(paintings.slice(0, perRow), MIN_HALL_SIZE);
|
||||
if (fitsOnWall(paintings, spanNeeded, rows)) {
|
||||
best = Math.min(best, spanNeeded);
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(MIN_HALL_SIZE, best === Infinity ? MIN_HALL_SIZE : best);
|
||||
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. */
|
||||
@@ -150,9 +244,30 @@ function orderPaintingsForWallDisplay(paintings: Painting[]) {
|
||||
|
||||
function distributePaintingsAcrossWalls(paintings: Painting[]) {
|
||||
const sorted = [...paintings].sort(comparePaintingsChronological);
|
||||
const walls: Painting[][] = [[], [], []];
|
||||
sorted.forEach((p, i) => walls[i % 3].push(p));
|
||||
return walls.map(orderPaintingsForWallDisplay);
|
||||
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[]) {
|
||||
@@ -173,12 +288,13 @@ function layoutWallSlots(
|
||||
side: WallSide,
|
||||
halfW: number,
|
||||
halfD: number,
|
||||
inset: number
|
||||
inset: number,
|
||||
maxRows: number = paintings.length
|
||||
): FrameSlot[] {
|
||||
const count = paintings.length;
|
||||
if (count === 0) return [];
|
||||
|
||||
const rows = rowCountForWall(count, span);
|
||||
const rows = rowCountForWall(paintings, span, maxRows);
|
||||
const perRow = Math.ceil(count / rows);
|
||||
const slots: FrameSlot[] = [];
|
||||
|
||||
@@ -186,7 +302,7 @@ function layoutWallSlots(
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const rowStart = r * perRow;
|
||||
const inRow = Math.min(perRow, count - rowStart);
|
||||
rowLayouts.push(layoutRow(inRow, span));
|
||||
rowLayouts.push(layoutRow(paintings.slice(rowStart, rowStart + inRow), span));
|
||||
}
|
||||
|
||||
const rowHeights = rowLayouts.map((row) => row.slots[0]?.maxH ?? MAX_FRAME_H);
|
||||
@@ -236,14 +352,17 @@ function buildHallLayout(paintings: Painting[], periods: ArtistPeriod[]): HallLa
|
||||
const walls: WallSide[] = ['back', 'left', 'right'];
|
||||
const wallPaintings = distributePaintingsAcrossWalls(paintings);
|
||||
|
||||
let width = minSpanForWall(wallPaintings[0].length);
|
||||
let depth = Math.max(minSpanForWall(wallPaintings[1].length), minSpanForWall(wallPaintings[2].length));
|
||||
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].length);
|
||||
const nextWidth = minSpanForWall(wallPaintings[0], MAX_WALL_ROWS);
|
||||
const nextDepth = Math.max(
|
||||
minSpanForWall(wallPaintings[1].length),
|
||||
minSpanForWall(wallPaintings[2].length)
|
||||
minSpanForWall(wallPaintings[1], MAX_WALL_ROWS),
|
||||
minSpanForWall(wallPaintings[2], MAX_WALL_ROWS)
|
||||
);
|
||||
if (nextWidth === width && nextDepth === depth) break;
|
||||
width = nextWidth;
|
||||
@@ -267,7 +386,8 @@ function buildHallLayout(paintings: Painting[], periods: ArtistPeriod[]): HallLa
|
||||
side,
|
||||
halfW,
|
||||
halfD,
|
||||
inset
|
||||
inset,
|
||||
MAX_WALL_ROWS
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -328,6 +448,25 @@ function getCanvasWeaveTexture() {
|
||||
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,
|
||||
@@ -510,6 +649,7 @@ function PaintingFrame({
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
wallSide,
|
||||
imageRevision,
|
||||
onClick,
|
||||
}: {
|
||||
painting: Painting;
|
||||
@@ -518,19 +658,21 @@ function PaintingFrame({
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
wallSide: WallSide;
|
||||
imageRevision?: number;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [aspect, setAspect] = useState(1.33);
|
||||
const { width, height } = computeFrameSize(aspect, maxWidth, maxHeight);
|
||||
const frameDepth = 0.06;
|
||||
const matBorder = 0.05;
|
||||
const reviewed = paintingIsReviewed(painting);
|
||||
const { matBorder, rail, depth: frameDepth } = frameDimsForReviewed(reviewed);
|
||||
const hasImage = paintingHasGalleryImage(painting);
|
||||
const url = hasImage ? galleryImageUrl(painting) : null;
|
||||
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(() => {
|
||||
@@ -563,18 +705,24 @@ function PaintingFrame({
|
||||
onPointerOver={() => setHovered(true)}
|
||||
onPointerOut={() => setHovered(false)}
|
||||
>
|
||||
<boxGeometry args={[width + matBorder * 2 + 0.04, height + matBorder * 2 + 0.04, frameDepth]} />
|
||||
<boxGeometry args={[width + matBorder * 2 + rail, height + matBorder * 2 + rail, frameDepth]} />
|
||||
<meshStandardMaterial
|
||||
color={showCanvas ? (hovered ? '#8a7355' : '#5a4520') : hovered ? '#c9a227' : '#6b4f1d'}
|
||||
roughness={0.45}
|
||||
metalness={showCanvas ? 0.15 : 0.5}
|
||||
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="#f5f0e6" roughness={0.95} />
|
||||
<meshStandardMaterial
|
||||
color={reviewed ? '#fff6cc' : '#f5f0e6'}
|
||||
roughness={reviewed ? 0.75 : 0.95}
|
||||
metalness={reviewed ? 0.15 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
@@ -637,69 +785,311 @@ function ExitPortal({
|
||||
position,
|
||||
active,
|
||||
onActivate,
|
||||
wallColor = '#f0ebe3',
|
||||
trimColor = '#ddd5c8',
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
active: boolean;
|
||||
onActivate: () => void;
|
||||
wallColor?: string;
|
||||
trimColor?: string;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const glow = active || hovered;
|
||||
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 = '#261a10';
|
||||
const woodMid = '#3d2818';
|
||||
const woodGrain = '#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}>
|
||||
<mesh position={[0, DOOR_HEIGHT / 2, 0]} castShadow>
|
||||
<boxGeometry args={[DOOR_WIDTH, DOOR_HEIGHT, 0.12]} />
|
||||
{/* 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={glow ? '#d4af37' : '#8b6914'}
|
||||
emissive={glow ? '#5a4010' : '#000000'}
|
||||
emissiveIntensity={glow ? 0.45 : 0}
|
||||
roughness={0.55}
|
||||
metalness={0.35}
|
||||
color="#fff4e8"
|
||||
emissive="#ffdcb0"
|
||||
emissiveIntensity={highlight ? 0.35 : 0.18}
|
||||
roughness={0.9}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Large invisible target — easier to click from anywhere in the hall */}
|
||||
<mesh
|
||||
position={[0, DOOR_HEIGHT / 2, -0.12]}
|
||||
rotation={[0, Math.PI, 0]}
|
||||
onClick={handleActivate}
|
||||
onPointerOver={() => setHovered(true)}
|
||||
onPointerOut={() => setHovered(false)}
|
||||
>
|
||||
<planeGeometry args={[DOOR_WIDTH * 3, DOOR_HEIGHT * 1.8]} />
|
||||
<meshBasicMaterial transparent opacity={0} depthWrite={false} side={THREE.DoubleSide} />
|
||||
{/* 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>
|
||||
|
||||
<Text
|
||||
position={[0, DOOR_HEIGHT + 0.25, -0.06]}
|
||||
{/* 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]}
|
||||
fontSize={0.22}
|
||||
color={glow ? '#f5e6c8' : '#c9a96e'}
|
||||
anchorX="center"
|
||||
onClick={handleActivate}
|
||||
onPointerOver={() => setHovered(true)}
|
||||
onPointerOut={() => setHovered(false)}
|
||||
onPointerOver={setHover(true)}
|
||||
onPointerOut={setHover(false)}
|
||||
>
|
||||
EXIT
|
||||
</Text>
|
||||
<planeGeometry args={[DOOR_WIDTH * 2.2, frameFullH * 1.15]} />
|
||||
<meshBasicMaterial transparent opacity={0} depthWrite={false} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function ParquetFloor({ width, depth }: { width: number; depth: number }) {
|
||||
const floorW = width + 0.4;
|
||||
const floorD = depth + 0.4;
|
||||
const texture = useMemo(() => {
|
||||
const map = createParquetFloorTexture();
|
||||
map.repeat.set(floorW / PARQUET_METERS_PER_TILE, floorD / PARQUET_METERS_PER_TILE);
|
||||
map.needsUpdate = true;
|
||||
return map;
|
||||
}, [floorW, floorD]);
|
||||
|
||||
return (
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.002, 0]} receiveShadow>
|
||||
<planeGeometry args={[floorW, floorD]} />
|
||||
<meshStandardMaterial
|
||||
map={texture}
|
||||
roughness={0.38}
|
||||
metalness={0.12}
|
||||
envMapIntensity={0.55}
|
||||
color="#c8a882"
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtistHall({
|
||||
layout,
|
||||
artistName,
|
||||
movementColor,
|
||||
imageRevisions,
|
||||
onPaintingClick,
|
||||
onExitActivate,
|
||||
nearExit,
|
||||
}: {
|
||||
layout: HallLayout;
|
||||
artistName: string;
|
||||
movementColor?: string;
|
||||
imageRevisions?: Record<number, number>;
|
||||
onPaintingClick: (id: number) => void;
|
||||
onExitActivate: () => void;
|
||||
nearExit: boolean;
|
||||
@@ -707,52 +1097,56 @@ function ArtistHall({
|
||||
const { width, depth, segments } = layout;
|
||||
const halfW = width / 2;
|
||||
const halfD = depth / 2;
|
||||
const walls = useMemo(() => galleryWallColors(movementColor), [movementColor]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Floor & ceiling — open centre, no furniture */}
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]} receiveShadow>
|
||||
<planeGeometry args={[width + 0.4, depth + 0.4]} />
|
||||
<meshStandardMaterial color="#4a3020" roughness={0.78} />
|
||||
</mesh>
|
||||
<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>
|
||||
|
||||
{/* Back wall */}
|
||||
<GalleryWall position={[0, WALL_HEIGHT / 2, -halfD]} size={[width, WALL_HEIGHT]} color="#f0ebe3" />
|
||||
<GalleryWall position={[0, WALL_HEIGHT / 2, -halfD]} size={[width, WALL_HEIGHT]} color={walls.main} />
|
||||
{/* Left wall */}
|
||||
<GalleryWall
|
||||
position={[-halfW, WALL_HEIGHT / 2, 0]}
|
||||
size={[depth, WALL_HEIGHT]}
|
||||
rotation={[0, Math.PI / 2, 0]}
|
||||
color="#e8e2d8"
|
||||
color={walls.side}
|
||||
/>
|
||||
{/* Right wall */}
|
||||
<GalleryWall
|
||||
position={[halfW, WALL_HEIGHT / 2, 0]}
|
||||
size={[depth, WALL_HEIGHT]}
|
||||
rotation={[0, Math.PI / 2, 0]}
|
||||
color="#e8e2d8"
|
||||
color={walls.side}
|
||||
/>
|
||||
|
||||
{/* Front wall — two segments with door gap */}
|
||||
<GalleryWall
|
||||
position={[-(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
|
||||
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
|
||||
color="#f0ebe3"
|
||||
color={walls.main}
|
||||
/>
|
||||
<GalleryWall
|
||||
position={[(DOOR_WIDTH / 2 + (width - DOOR_WIDTH) / 4), WALL_HEIGHT / 2, halfD]}
|
||||
size={[(width - DOOR_WIDTH) / 2, WALL_HEIGHT]}
|
||||
color="#f0ebe3"
|
||||
color={walls.main}
|
||||
/>
|
||||
{/* Wall section above door opening */}
|
||||
<GalleryWall
|
||||
position={[0, DOOR_HEIGHT + (WALL_HEIGHT - DOOR_HEIGHT) / 2, halfD]}
|
||||
size={[DOOR_WIDTH, WALL_HEIGHT - DOOR_HEIGHT]}
|
||||
color={walls.main}
|
||||
/>
|
||||
|
||||
{/* 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="#b8956a" roughness={0.5} metalness={0.25} />
|
||||
<meshStandardMaterial color={walls.trim} roughness={0.5} metalness={0.25} />
|
||||
</mesh>
|
||||
|
||||
<Text
|
||||
@@ -768,13 +1162,14 @@ function ArtistHall({
|
||||
<group key={`${seg.side}-${seg.label}`}>
|
||||
{seg.paintings.map((painting, i) => (
|
||||
<PaintingFrame
|
||||
key={painting.id}
|
||||
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]}
|
||||
onClick={() => onPaintingClick(painting.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -800,10 +1195,15 @@ function ArtistHall({
|
||||
position={[0, 0, halfD - WALL_THICKNESS / 2 - 0.02]}
|
||||
active={nearExit}
|
||||
onActivate={onExitActivate}
|
||||
wallColor={walls.main}
|
||||
trimColor={walls.trim}
|
||||
/>
|
||||
|
||||
<pointLight position={[0, WALL_HEIGHT - 0.4, 0]} intensity={0.5} color="#fff5e8" distance={width + depth} />
|
||||
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD / 2]} intensity={0.35} color="#fff0dd" distance={12} />
|
||||
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD / 2]} intensity={0.35} color="#fff0dd" distance={Math.max(12, depth * 0.75)} />
|
||||
{depth > 14 && (
|
||||
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD + 1.2]} intensity={0.3} color="#fff0dd" distance={14} />
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -900,6 +1300,7 @@ function NavigationPanel({
|
||||
|
||||
export default function VirtualGallery({
|
||||
data,
|
||||
imageRevisions,
|
||||
active = true,
|
||||
onPaintingClick,
|
||||
onNavigateArtist,
|
||||
@@ -916,6 +1317,12 @@ export default function VirtualGallery({
|
||||
const [nearExit, setNearExit] = useState(false);
|
||||
const [isLooking, setIsLooking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setArtist(data.artist);
|
||||
setPeriods(data.periods);
|
||||
setPaintings(data.paintings);
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
@@ -946,16 +1353,17 @@ export default function VirtualGallery({
|
||||
|
||||
const layout = useMemo(() => buildHallLayout(paintings, periods), [paintings, periods]);
|
||||
|
||||
const halfW = layout.width / 2 - 0.8;
|
||||
const halfD = layout.depth / 2 - 0.8;
|
||||
const exitZ = layout.depth / 2 - 0.6;
|
||||
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 / 4),
|
||||
() => new THREE.Vector3(0, EYE_HEIGHT, -layout.depth * (layout.depth > 14 ? 0.38 : 0.25)),
|
||||
[layout.depth]
|
||||
);
|
||||
|
||||
@@ -1141,7 +1549,7 @@ export default function VirtualGallery({
|
||||
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
|
||||
>
|
||||
<color attach="background" args={['#0d0906']} />
|
||||
<fog attach="fog" args={['#0d0906', 18, 55]} />
|
||||
<fog attach="fog" args={['#0d0906', 18, fogFar]} />
|
||||
<ambientLight intensity={0.42} />
|
||||
<directionalLight position={[3, 9, 4]} intensity={0.65} castShadow shadow-mapSize={[1024, 1024]} />
|
||||
<Suspense fallback={null}>
|
||||
@@ -1150,6 +1558,8 @@ export default function VirtualGallery({
|
||||
<ArtistHall
|
||||
layout={layout}
|
||||
artistName={artist.name}
|
||||
movementColor={artist.movement_color}
|
||||
imageRevisions={imageRevisions}
|
||||
onPaintingClick={onPaintingClick}
|
||||
onExitActivate={openExitNav}
|
||||
nearExit={nearExit}
|
||||
|
||||
Reference in New Issue
Block a user