Add public curator notes and U-shaped hall wall hang.

Paintings get editable curator notes with brass plates in the 3D hall, and visit order now uses the far/end wall between left and right.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-07-25 14:21:22 +03:00
co-authored by Cursor
parent 5ddc3fd7f0
commit bc8369e373
29 changed files with 648 additions and 70 deletions
+96 -15
View File
@@ -13,7 +13,7 @@ import type {
TourGalleryDetail,
} from '../types';
import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client';
import { comparePaintingsChronological, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures';
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
import { useTexturedMaterial } from '../hooks/useTexturedMaterial';
@@ -294,17 +294,26 @@ function minSpanForWall(paintings: Painting[], maxRows: number = paintings.lengt
}
/**
* Visit order along the side walls: first half on the left (starting at the
* entrance, left of the opening view), second half on the right (ending at the
* entrance). Back wall stays empty so first/last always sit on left/right.
* 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 mid = Math.ceil(ordered.length / 2);
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 [
[] as Painting[],
ordered.slice(0, mid),
ordered.slice(mid),
ordered.slice(leftCount, leftCount + backCount),
ordered.slice(0, leftCount),
ordered.slice(leftCount + backCount),
];
}
@@ -390,18 +399,19 @@ function layoutWallSlots(
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(wallPaintings[0], MAX_WALL_ROWS);
let width = minSpanForWall(backPaintings, MAX_WALL_ROWS);
let depth = Math.max(
minSpanForWall(wallPaintings[1], MAX_WALL_ROWS),
minSpanForWall(wallPaintings[2], MAX_WALL_ROWS)
minSpanForWall(leftPaintings, MAX_WALL_ROWS),
minSpanForWall(rightPaintings, MAX_WALL_ROWS)
);
for (let i = 0; i < 24; i++) {
const nextWidth = minSpanForWall(wallPaintings[0], MAX_WALL_ROWS);
const nextWidth = minSpanForWall(backPaintings, MAX_WALL_ROWS);
const nextDepth = Math.max(
minSpanForWall(wallPaintings[1], MAX_WALL_ROWS),
minSpanForWall(wallPaintings[2], MAX_WALL_ROWS)
minSpanForWall(leftPaintings, MAX_WALL_ROWS),
minSpanForWall(rightPaintings, MAX_WALL_ROWS)
);
if (nextWidth === width && nextDepth === depth) break;
width = nextWidth;
@@ -694,6 +704,61 @@ function InfluencePictureLamp({
);
}
function CuratorNotesPlate({
frameWidth,
frameHeight,
matBorder,
rail,
frameDepth,
faceZ,
highlighted,
}: {
frameWidth: number;
frameHeight: number;
matBorder: number;
rail: number;
frameDepth: number;
faceZ: number;
highlighted: boolean;
}) {
const plateW = Math.min(0.28, Math.max(0.16, frameWidth * 0.42));
const plateH = 0.038;
const plateD = 0.012;
const y = -frameHeight / 2 - matBorder - rail - plateH / 2 - 0.028;
const z = frameDepth + faceZ + 0.01;
const brass = highlighted ? '#e8c76a' : '#d4af37';
const rim = highlighted ? '#a07828' : '#8a6820';
return (
<group position={[0, y, z]}>
{/* Slightly darker rim so the plate reads as a cast metal plaque */}
<mesh position={[0, 0, -0.001]} castShadow renderOrder={28}>
<boxGeometry args={[plateW + 0.012, plateH + 0.01, plateD]} />
<meshStandardMaterial color={rim} metalness={0.85} roughness={0.28} />
</mesh>
<mesh castShadow renderOrder={29}>
<boxGeometry args={[plateW, plateH, plateD]} />
<meshStandardMaterial
color={brass}
metalness={0.9}
roughness={0.22}
emissive={highlighted ? '#6a5010' : '#3a2a08'}
emissiveIntensity={highlighted ? 0.35 : 0.12}
/>
</mesh>
{/* Soft engraved center band */}
<mesh position={[0, 0, plateD / 2 + 0.001]} renderOrder={30}>
<planeGeometry args={[plateW * 0.72, plateH * 0.28]} />
<meshStandardMaterial
color={highlighted ? '#b8943a' : '#9a7828'}
metalness={0.7}
roughness={0.4}
/>
</mesh>
</group>
);
}
function PaintingFrame({
painting,
position,
@@ -726,6 +791,7 @@ function PaintingFrame({
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);
@@ -737,6 +803,9 @@ function PaintingFrame({
}
}, [texture, showImage]);
const captionY =
-height / 2 - matBorder - rail - (hasCuratorNotes ? 0.18 : 0.1);
return (
<group position={position} rotation={[0, rotationY, 0]}>
<spotLight
@@ -812,9 +881,21 @@ function PaintingFrame({
/>
)}
{hasCuratorNotes && (
<CuratorNotesPlate
frameWidth={width}
frameHeight={height}
matBorder={matBorder}
rail={rail}
frameDepth={frameDepth}
faceZ={faceZ}
highlighted={hovered}
/>
)}
{caption && (
<Text
position={[0, -height / 2 - matBorder - rail - 0.1, frameDepth + faceZ + 0.02]}
position={[0, captionY, frameDepth + faceZ + 0.02]}
fontSize={0.085}
maxWidth={Math.max(width + matBorder * 2, 0.55)}
color="#4a3828"