diff --git a/Documentation/basics.md b/Documentation/basics.md
index effa599..7311b99 100644
--- a/Documentation/basics.md
+++ b/Documentation/basics.md
@@ -299,10 +299,10 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi
| Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) |
| Wall tint | Gallery walls blend the artist’s **movement colour** into cream plaster tones |
| Frame finish | **Unchecked** works: black moulding; **Reviewed** (`checkup_checked`): bright gold moulding at **double width** |
-| Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API); fixture is mounted **upside down** (`InfluencePictureLamp` in `VirtualGallery.tsx`) |
+| Influence lamps | A golden picture light appears **above frames** whose work has any influence-graph edge (`has_influence_links` from the API); fixture is mounted **upside down** and is **emissive-only** (no per-frame lights — see light budget below) |
| Curator-note plates | A small brass plate hangs **beneath frames** that have non-empty `curator_notes` |
| Eye-level viewing | Frame centres sit at **eye height (~1.65 m)**; the camera stays **level with the floor** (no pitch up/down) |
-| Open centre | Floor and ceiling only — no columns, pedestals, or other centre objects |
+| Open centre | Floor and ceiling only — no freestanding columns or pedestals in the walkway |
| Museum exit | Front-wall **double doors** with transom, brass hardware, sconces, marble threshold, and warm vestibule glow |
| Shadows | **Disabled** — no Canvas shadow maps / `castShadow` (performance; flat lighting only) |
| Hall-to-hall travel | Exit opens a panel: **Predecessors** (left) and **Successors** (right), each grouped by art movement |
@@ -338,11 +338,12 @@ Enter from the home page by clicking a **movement name** on the movement flow. F
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **1–26** → authored keys **27–52**, with Northern/High Renaissance swapped) so Byzantine gets the chapel, not Gothic stone |
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco — plus **single-colour painted walls** (`painted-lime`, `painted-oil-matte`, `painted-oil-satin`, `painted-emulsion`, `painted-flat`) tinted per movement for Renaissance salons through modern white cubes |
| Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue |
-| Windows | **Side walls only** — placed in gaps between frames (high on the wall, no overlap with paintings); style matches the movement era |
-| Lighting | Daylight from windows + ceiling track lights + ambient/sun fill |
+| Windows | **Side walls only** — placed in gaps between frames when possible; if a wall is packed, high **clerestory** windows are still added so the hall keeps daylight (`computeSideWallWindows`) |
+| Lighting | Shared hall lights only (ambient / hemisphere / directional + capped ceiling track spots + one fill per window). See **light budget** below |
+| Period details | `MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** (never freestanding mid-hall or proud corner shafts that cover frames) |
| Back wall | Single wing: solid display wall. Multi-wing: **Exit double doors** → **Wing navigator** or **Exit to Timeline** |
| Front / entrance wall | Single wing: **Exit double doors** (leave the way you entered). Multi-wing: **“Next wing →”** archway when a later wing exists |
-| Influence lamps | Same golden upside-down lamps as artist halls when `has_influence_links` is true |
+| Influence lamps | Same golden upside-down emissive fixtures as artist halls when `has_influence_links` is true |
| Missing images | Draped canvas cover in frame |
| Detail return | **Back to Gallery** from painting detail returns to the same wing with camera preserved; **Back to Timeline** exits the hall entirely |
@@ -373,6 +374,12 @@ Full guide: [tours.md](tours.md).
### Shared 3D behaviour
+**Light budget (critical):** Three.js forward shading cannot compile `MeshStandardMaterial` with dozens of dynamic lights. Large wings used to add **one `spotLight` per painting** (plus influence lamps), which pushed the scene to ~100–200 lights — walls, floor, and ceiling then rendered as a black void while painting canvases (`MeshBasicMaterial`) still showed. Halls therefore use **shared lighting only**: ambient / hemisphere / directional fill, a small set of ceiling track spots (`GalleryTrackLights`, capped), and **one** window spot each. Painting canvases stay unlit (`MeshBasicMaterial`); frames and influence fixtures use **emissive** accents instead of per-frame lights. Intensities are scaled for three.js physical lights (post-r155).
+
+**Period architecture:** Freestanding columns must not sit in the walkway or in the corner pocket in front of frames (hang margin is only ~0.7 m from each wall end). Classical / neoclassical details use **engaged corner pilasters** flush to the walls (`MovementHallDetails.tsx`).
+
+**Surface textures:** Procedural colour maps use sRGB; **normal maps use `NoColorSpace`** (`galleryProceduralTextures.ts`). Wrong colour space on normals breaks wall shading.
+
**3D images** prefer local **thumbnail** files (`galleryImageUrlCandidates` in `client/src/api/client.ts`: thumb → `GET /api/paintings/:id/image?size=thumb`; full originals are **not** used for hall frames — they can be tens of MB and made large halls take ~1 minute). Remote Wikipedia fetches are too slow for realtime WebGL textures. Entering an **artist** or **movement** hall fires `POST /api/artists/:id/preload-images` or `POST /api/movements/:id/preload-images` in the **background** (does not block hall open). Texture downloads are **queued** (max 8 parallel). While a texture is loading, the frame shows the canvas cover instead of a white placeholder. A per-image deadline only releases the boot overlay counter — it does **not** permanently blank the frame if the download finishes later.
**Boot overlay** (`VirtualGallery.tsx`): the center shows **“Loading gallery…”** until the WebGL canvas is ready and a short `gl.compileAsync` warm-up finishes (so entrance doors / passages do not hitch on the first turn). HDR `Environment` loads in a Suspense boundary **without** blocking the overlay. Painting textures keep loading in the background. The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again.
diff --git a/Documentation/data-and-images.md b/Documentation/data-and-images.md
index 876d623..6b905c6 100644
--- a/Documentation/data-and-images.md
+++ b/Documentation/data-and-images.md
@@ -278,9 +278,9 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli
## Movement gallery interiors (frontend)
-Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (columns, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts`.
+Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (pilasters, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts` (colour maps in sRGB, normal maps in linear/`NoColorSpace`). Period mesh details live in `client/src/components/MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** so shafts never cover frames.
-Wing layout (up to 55 works per wing, U-shaped hang, window gap placement) lives in `client/src/utils/movementHallLayout.ts`. Visit order fills the **left wall** (first work at the entrance), then the **far/end wall**, then the **right** (last work at the entrance). The same hang applies to artist halls and guided tours. To change a movement’s look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
+Wing layout (up to 55 works per wing, U-shaped hang, window gap placement with packed-wall **clerestory** fallback) lives in `client/src/utils/movementHallLayout.ts`. Visit order fills the **left wall** (first work at the entrance), then the **far/end wall**, then the **right** (last work at the entrance). The same hang applies to artist halls and guided tours. Hall lighting is **shared** (no per-painting spotlights) so `MeshStandardMaterial` walls stay within WebGL light limits — see [basics.md](basics.md) § Shared 3D behaviour. To change a movement’s look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
## Historical event markers (frontend timeline)
diff --git a/client/src/components/GalleryWindows.tsx b/client/src/components/GalleryWindows.tsx
index d05e1ac..a5a20e1 100644
--- a/client/src/components/GalleryWindows.tsx
+++ b/client/src/components/GalleryWindows.tsx
@@ -173,22 +173,16 @@ function SingleWindow({
- {/* Daylight into room */}
+ {/* One fill light per window — keep total scene lights well under WebGL limits. */}
-
);
}
@@ -222,8 +216,9 @@ export function GalleryTrackLights({
}) {
const positions = useMemo(() => {
const pts: [number, number, number][] = [];
- const cols = Math.max(2, Math.min(5, Math.floor(width / 4)));
- const rows = Math.max(1, Math.min(3, Math.floor(depth / 6)));
+ // Cap fixtures so MeshStandard hall materials stay within WebGL light limits.
+ const cols = Math.max(2, Math.min(3, Math.floor(width / 5)));
+ const rows = Math.max(1, Math.min(2, Math.floor(depth / 8)));
for (let c = 0; c < cols; c++) {
for (let r = 0; r < rows; r++) {
const x = -width / 2 + (width / (cols + 1)) * (c + 1);
@@ -244,10 +239,11 @@ export function GalleryTrackLights({
diff --git a/client/src/components/MovementHallDetails.tsx b/client/src/components/MovementHallDetails.tsx
index 065d3f8..5d0569f 100644
--- a/client/src/components/MovementHallDetails.tsx
+++ b/client/src/components/MovementHallDetails.tsx
@@ -140,49 +140,65 @@ function MedievalDetails({ halfW, halfD }: Pick) {
}
function NeoclassicalDetails({ halfW, halfD, trim }: Pick & { trim: string }) {
- const columns = [
- [-halfW + 0.45, -halfD + 0.6],
- [halfW - 0.45, -halfD + 0.6],
- [-halfW + 0.45, halfD - 0.6],
- [halfW - 0.45, halfD - 0.6],
- ] as [number, number][];
+ // Shallow engaged corner pilasters only. Freestanding columns (~0.5m into the
+ // room) overlap the outermost frames — hang margin is only ~0.7m from each end.
+ const pilasters = useMemo(
+ () =>
+ [
+ [-halfW + 0.06, -halfD + 0.06],
+ [halfW - 0.06, -halfD + 0.06],
+ [-halfW + 0.06, halfD - 0.06],
+ [halfW - 0.06, halfD - 0.06],
+ ] as [number, number][],
+ [halfW, halfD]
+ );
return (
- {columns.map(([x, z], i) => (
+ {pilasters.map(([x, z], i) => (
-
+
-
-
+
+
))}
-
-
+ {/* Cornice above wall title (title sits ~y 3.75) */}
+
+
);
}
-function ClassicalDetails({ halfW, trim }: Pick & { trim: string }) {
+function ClassicalDetails({ halfW, halfD, trim }: Pick & { trim: string }) {
+ // Engaged corner shafts only — anything proud of the corner pocket covers frames.
+ const columns = useMemo(
+ () =>
+ [
+ [-halfW + 0.06, -halfD + 0.06],
+ [halfW - 0.06, -halfD + 0.06],
+ [-halfW + 0.06, halfD - 0.06],
+ [halfW - 0.06, halfD - 0.06],
+ ] as [number, number][],
+ [halfW, halfD]
+ );
+
return (
- {[
- [-halfW + 0.5, 0],
- [halfW - 0.5, 0],
- ].map(([x, z], i) => (
+ {columns.map(([x, z], i) => (
-
+
-
+
@@ -213,7 +229,7 @@ export default function MovementHallDetails({ style, width, depth, halfW, halfD
case 'neoclassical':
return ;
case 'classical':
- return ;
+ return ;
case 'salon':
return ;
default:
diff --git a/client/src/components/VirtualGallery.tsx b/client/src/components/VirtualGallery.tsx
index 9f2f0e1..581154a 100644
--- a/client/src/components/VirtualGallery.tsx
+++ b/client/src/components/VirtualGallery.tsx
@@ -844,25 +844,11 @@ function InfluencePictureLamp({
-
-
+ {/* No per-painting lights — too many MeshStandard lights break hall shaders. */}
);
@@ -970,17 +956,12 @@ function PaintingFrame({
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 (
-
-
@@ -1441,7 +1422,7 @@ function GalleryFloor({
normalScale={new THREE.Vector2(texture.normalScale, texture.normalScale)}
roughness={texture.roughness}
metalness={texture.metalness}
- envMapIntensity={0.65}
+ envMapIntensity={0.3}
color={interiorStyle.tints.floor}
/>
@@ -1661,18 +1642,21 @@ function ArtistHall({
)
) : null}
- {interiorStyle && computedWindows && computedWindows.length > 0 && (
+ {interiorStyle && (
<>
-
+ {computedWindows && computedWindows.length > 0 && (
+
+ )}
+ {/* Always light the hall — do not gate track lights on window gaps. */}
>
@@ -1750,15 +1734,24 @@ function ArtistHall({
))}
-
-
- {depth > 14 && (
-
- )}
+
+
{interiorStyle && (
)}
@@ -2043,6 +2036,10 @@ export default function VirtualGallery(props: Props) {
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);
@@ -2432,7 +2429,9 @@ export default function VirtualGallery(props: Props) {
const sceneBackground = interiorStyle?.background ?? '#0d0906';
const sceneFog = interiorStyle?.fog ?? '#0d0906';
- const ambientIntensity = interiorStyle?.ambient ?? 0.42;
+ // Floor ambient so dark period styles (Gothic, Byzantine) stay readable without HDR IBL.
+ // three r155+ physical lights need ~π× legacy intensity for similar brightness.
+ const ambientIntensity = Math.max(0.85, interiorStyle?.ambient ?? 0.55) * Math.PI;
const handleCanvasPointerDown = (e: React.PointerEvent) => {
if (!active || showExitNav || e.button !== 0) return;
@@ -2539,13 +2538,34 @@ export default function VirtualGallery(props: Props) {
>
-
+
+
+
+ {/* Guaranteed fill so walls never disappear if HDR Environment fails. */}
+
diff --git a/client/src/data/movement-interior-styles.ts b/client/src/data/movement-interior-styles.ts
index e57b3bc..a3b09af 100644
--- a/client/src/data/movement-interior-styles.ts
+++ b/client/src/data/movement-interior-styles.ts
@@ -144,17 +144,19 @@ const BY_MOVEMENT_ID: Record = {
{
details: 'medieval',
titleColor: '#e8dcc8',
- ambient: 0.52,
+ ambient: 0.72,
warmLight: '#e8d8c0',
sunLight: '#d0e8ff',
- fog: '#0c0c10',
+ // Keep atmosphere dark, but not pure void (walls must stay readable).
+ fog: '#1a1816',
+ background: '#141210',
windows: windows(
{ wall: 'back', x: -2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 },
{ wall: 'back', x: 2, y: 2.8, width: 0.8, height: 2.8, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 3.5 },
{ wall: 'left', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 },
{ wall: 'right', x: 0, y: 3.0, width: 0.9, height: 2.6, style: 'gothic-lancet', lightColor: '#c8e0ff', lightIntensity: 2.8 }
),
- trackLights: 0.3,
+ trackLights: 0.85,
}
),
30: mk(
diff --git a/client/src/hooks/useTexturedMaterial.ts b/client/src/hooks/useTexturedMaterial.ts
index f6349af..addb385 100644
--- a/client/src/hooks/useTexturedMaterial.ts
+++ b/client/src/hooks/useTexturedMaterial.ts
@@ -22,7 +22,8 @@ export function useTexturedMaterial(
color: tint,
roughness: surf.roughness,
metalness: surf.metalness,
- envMapIntensity: kind.startsWith('painted-') ? 0.45 : 0.65,
+ // Keep walls readable without HDR Environment IBL (CDN may be slow/offline).
+ envMapIntensity: kind.startsWith('painted-') ? 0.2 : 0.3,
});
}, [kind, tint, spanW, spanH]);
diff --git a/client/src/utils/galleryProceduralTextures.ts b/client/src/utils/galleryProceduralTextures.ts
index 62d50d8..5e3350c 100644
--- a/client/src/utils/galleryProceduralTextures.ts
+++ b/client/src/utils/galleryProceduralTextures.ts
@@ -583,7 +583,7 @@ function imageDataToNormalMap(data: ImageData, size: number, strength = 2.5): Im
return out;
}
-function imageDataToTexture(data: ImageData): THREE.CanvasTexture {
+function imageDataToTexture(data: ImageData, colorSpace: THREE.ColorSpace): THREE.CanvasTexture {
const canvas = document.createElement('canvas');
canvas.width = data.width;
canvas.height = data.height;
@@ -591,8 +591,8 @@ function imageDataToTexture(data: ImageData): THREE.CanvasTexture {
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
- tex.colorSpace = THREE.SRGBColorSpace;
- tex.anisotropy = 16;
+ tex.colorSpace = colorSpace;
+ tex.anisotropy = 8;
return tex;
}
@@ -686,8 +686,9 @@ export function getSurfaceTexture(kind: SurfaceTextureKind): SurfaceTextureSet {
const normalData = imageDataToNormalMap(colorData, TEXTURE_SIZE, normalStrengthFor(kind));
const set: SurfaceTextureSet = {
- map: imageDataToTexture(colorData),
- normalMap: imageDataToTexture(normalData),
+ map: imageDataToTexture(colorData, THREE.SRGBColorSpace),
+ // Normal maps must stay linear — sRGB encoding breaks lighting on walls.
+ normalMap: imageDataToTexture(normalData, THREE.NoColorSpace),
roughness: ROUGHNESS[kind] ?? 0.75,
metalness: METALNESS[kind] ?? 0.04,
metersPerRepeat: METERS[kind] ?? 2.8,
diff --git a/client/src/utils/movementHallLayout.ts b/client/src/utils/movementHallLayout.ts
index ca90ea0..2245673 100644
--- a/client/src/utils/movementHallLayout.ts
+++ b/client/src/utils/movementHallLayout.ts
@@ -326,7 +326,7 @@ function windowTemplate(style: MovementInteriorStyle): Pick s.wall === side)) {
+ const span = Math.max(2.4, halfD * 1.2);
+ const count = span > 8 ? 2 : 1;
+ for (let i = 0; i < count; i++) {
+ const t = count === 1 ? 0 : (i === 0 ? -0.35 : 0.35);
+ specs.push({
+ wall: side,
+ x: halfD * t,
+ y: 3.45,
+ width: Math.min(tmpl.width, 1.15),
+ height: Math.min(tmpl.height, 1.05),
+ style: tmpl.style,
+ lightColor: tmpl.lightColor,
+ lightIntensity: tmpl.lightIntensity * 0.85,
+ });
+ }
+ }
}
return specs;