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;