Fix movement-hall black walls and column clipping.
Cap shared hall lights (no per-painting spots) so MeshStandard walls stay within WebGL limits, tuck classical/neoclassical details into engaged corner pilasters, and document the light budget. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
710d262516
commit
1853222e01
+12
-5
@@ -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`) |
|
| Floor | Herringbone **parquet** procedural texture (`parquetFloorTexture.ts`) |
|
||||||
| Wall tint | Gallery walls blend the artist’s **movement colour** into cream plaster tones |
|
| 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** |
|
| 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` |
|
| 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) |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 | Daylight from windows + ceiling track lights + ambient/sun fill |
|
| 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** |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
### 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.
|
**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.
|
**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.
|
||||||
|
|||||||
@@ -278,9 +278,9 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli
|
|||||||
|
|
||||||
## Movement gallery interiors (frontend)
|
## 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)
|
## Historical event markers (frontend timeline)
|
||||||
|
|
||||||
|
|||||||
@@ -173,22 +173,16 @@ function SingleWindow({
|
|||||||
<meshBasicMaterial color="#ffffff" transparent opacity={0.25} toneMapped={false} />
|
<meshBasicMaterial color="#ffffff" transparent opacity={0.25} toneMapped={false} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Daylight into room */}
|
{/* One fill light per window — keep total scene lights well under WebGL limits. */}
|
||||||
<spotLight
|
<spotLight
|
||||||
position={[0, 0, 0.15]}
|
position={[0, 0, 0.15]}
|
||||||
angle={Math.min(1.2, (spec.width / Math.max(spec.height, 0.5)) * 0.55)}
|
angle={Math.min(1.2, (spec.width / Math.max(spec.height, 0.5)) * 0.55)}
|
||||||
penumbra={0.95}
|
penumbra={0.95}
|
||||||
intensity={spec.lightIntensity}
|
intensity={spec.lightIntensity * Math.PI * 0.85}
|
||||||
distance={14}
|
distance={14}
|
||||||
|
decay={0}
|
||||||
color={spec.lightColor}
|
color={spec.lightColor}
|
||||||
/>
|
/>
|
||||||
<pointLight
|
|
||||||
position={[0, 0, 0.25]}
|
|
||||||
intensity={spec.lightIntensity * 0.45}
|
|
||||||
distance={10}
|
|
||||||
color={glassColor}
|
|
||||||
decay={2}
|
|
||||||
/>
|
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -222,8 +216,9 @@ export function GalleryTrackLights({
|
|||||||
}) {
|
}) {
|
||||||
const positions = useMemo(() => {
|
const positions = useMemo(() => {
|
||||||
const pts: [number, number, number][] = [];
|
const pts: [number, number, number][] = [];
|
||||||
const cols = Math.max(2, Math.min(5, Math.floor(width / 4)));
|
// Cap fixtures so MeshStandard hall materials stay within WebGL light limits.
|
||||||
const rows = Math.max(1, Math.min(3, Math.floor(depth / 6)));
|
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 c = 0; c < cols; c++) {
|
||||||
for (let r = 0; r < rows; r++) {
|
for (let r = 0; r < rows; r++) {
|
||||||
const x = -width / 2 + (width / (cols + 1)) * (c + 1);
|
const x = -width / 2 + (width / (cols + 1)) * (c + 1);
|
||||||
@@ -244,10 +239,11 @@ export function GalleryTrackLights({
|
|||||||
</mesh>
|
</mesh>
|
||||||
<spotLight
|
<spotLight
|
||||||
position={[0, -0.04, 0]}
|
position={[0, -0.04, 0]}
|
||||||
angle={0.55}
|
angle={0.7}
|
||||||
penumbra={0.85}
|
penumbra={0.9}
|
||||||
intensity={intensity}
|
intensity={intensity}
|
||||||
distance={8}
|
distance={12}
|
||||||
|
decay={0}
|
||||||
color={color}
|
color={color}
|
||||||
/>
|
/>
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -140,49 +140,65 @@ function MedievalDetails({ halfW, halfD }: Pick<Props, 'halfW' | 'halfD'>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NeoclassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
|
function NeoclassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
|
||||||
const columns = [
|
// Shallow engaged corner pilasters only. Freestanding columns (~0.5m into the
|
||||||
[-halfW + 0.45, -halfD + 0.6],
|
// room) overlap the outermost frames — hang margin is only ~0.7m from each end.
|
||||||
[halfW - 0.45, -halfD + 0.6],
|
const pilasters = useMemo(
|
||||||
[-halfW + 0.45, halfD - 0.6],
|
() =>
|
||||||
[halfW - 0.45, halfD - 0.6],
|
[
|
||||||
] as [number, number][];
|
[-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 (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{columns.map(([x, z], i) => (
|
{pilasters.map(([x, z], i) => (
|
||||||
<group key={i} position={[x, 0, z]}>
|
<group key={i} position={[x, 0, z]}>
|
||||||
<mesh position={[0, 1.85, 0]}>
|
<mesh position={[0, 1.85, 0]}>
|
||||||
<cylinderGeometry args={[0.18, 0.2, 3.7, 12]} />
|
<boxGeometry args={[0.12, 3.7, 0.12]} />
|
||||||
<meshStandardMaterial color="#f0ece8" roughness={0.72} metalness={0.12} />
|
<meshStandardMaterial color="#f0ece8" roughness={0.72} metalness={0.12} />
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh position={[0, 3.85, 0]}>
|
<mesh position={[0, 3.78, 0]}>
|
||||||
<boxGeometry args={[0.42, 0.12, 0.42]} />
|
<boxGeometry args={[0.18, 0.1, 0.18]} />
|
||||||
<meshStandardMaterial color={trim} roughness={0.4} metalness={0.35} />
|
<meshStandardMaterial color={trim} roughness={0.4} metalness={0.35} />
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
))}
|
))}
|
||||||
<mesh position={[0, 3.88, -halfD + 0.12]}>
|
{/* Cornice above wall title (title sits ~y 3.75) */}
|
||||||
<boxGeometry args={[2.8, 0.14, 0.2]} />
|
<mesh position={[0, 4.08, -halfD + 0.1]}>
|
||||||
|
<boxGeometry args={[Math.max(2.4, halfW * 2 - 0.5), 0.1, 0.16]} />
|
||||||
<meshStandardMaterial color={trim} roughness={0.45} metalness={0.3} />
|
<meshStandardMaterial color={trim} roughness={0.45} metalness={0.3} />
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClassicalDetails({ halfW, trim }: Pick<Props, 'halfW'> & { trim: string }) {
|
function ClassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { 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 (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{[
|
{columns.map(([x, z], i) => (
|
||||||
[-halfW + 0.5, 0],
|
|
||||||
[halfW - 0.5, 0],
|
|
||||||
].map(([x, z], i) => (
|
|
||||||
<group key={i} position={[x, 0, z]}>
|
<group key={i} position={[x, 0, z]}>
|
||||||
<mesh position={[0, 2, 0]}>
|
<mesh position={[0, 2, 0]}>
|
||||||
<cylinderGeometry args={[0.16, 0.18, 4, 10]} />
|
<cylinderGeometry args={[0.08, 0.09, 4, 10]} />
|
||||||
<meshStandardMaterial color="#e0d8c8" roughness={0.88} />
|
<meshStandardMaterial color="#e0d8c8" roughness={0.88} />
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh position={[0, 4.05, 0]}>
|
<mesh position={[0, 4.05, 0]}>
|
||||||
<boxGeometry args={[0.36, 0.1, 0.36]} />
|
<boxGeometry args={[0.18, 0.1, 0.18]} />
|
||||||
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.25} />
|
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.25} />
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
@@ -213,7 +229,7 @@ export default function MovementHallDetails({ style, width, depth, halfW, halfD
|
|||||||
case 'neoclassical':
|
case 'neoclassical':
|
||||||
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
||||||
case 'classical':
|
case 'classical':
|
||||||
return <ClassicalDetails halfW={halfW} trim={trim} />;
|
return <ClassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
|
||||||
case 'salon':
|
case 'salon':
|
||||||
return <SalonDetails trim={trim} />;
|
return <SalonDetails trim={trim} />;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -844,25 +844,11 @@ function InfluencePictureLamp({
|
|||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color="#fff8e0"
|
color="#fff8e0"
|
||||||
emissive="#ffcc55"
|
emissive="#ffcc55"
|
||||||
emissiveIntensity={1.4 * glow}
|
emissiveIntensity={1.8 * glow}
|
||||||
toneMapped={false}
|
toneMapped={false}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
<pointLight
|
{/* No per-painting lights — too many MeshStandard lights break hall shaders. */}
|
||||||
position={[0, -0.03, 0.05]}
|
|
||||||
intensity={1.8 * glow}
|
|
||||||
distance={4}
|
|
||||||
color="#ffe4a8"
|
|
||||||
decay={2}
|
|
||||||
/>
|
|
||||||
<spotLight
|
|
||||||
position={[0, -0.03, 0.05]}
|
|
||||||
angle={0.75}
|
|
||||||
penumbra={0.95}
|
|
||||||
intensity={2.2 * glow}
|
|
||||||
distance={5}
|
|
||||||
color="#fff0c8"
|
|
||||||
/>
|
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
@@ -970,17 +956,12 @@ function PaintingFrame({
|
|||||||
const captionY =
|
const captionY =
|
||||||
-height / 2 - matBorder - rail - (hasCuratorNotes ? 0.18 : 0.1);
|
-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 (
|
return (
|
||||||
<group position={position} rotation={[0, rotationY, 0]}>
|
<group position={position} rotation={[0, rotationY, 0]}>
|
||||||
<spotLight
|
|
||||||
position={[0, height / 2 + 0.25, 0.3]}
|
|
||||||
angle={0.5}
|
|
||||||
penumbra={0.75}
|
|
||||||
intensity={showCanvas ? (hovered ? 0.9 : 0.55) : hovered ? 2.6 : 1.9}
|
|
||||||
distance={4.5}
|
|
||||||
color={showCanvas ? '#e8dcc8' : '#fff8ee'}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<mesh
|
<mesh
|
||||||
position={[0, 0, frameDepth / 2]}
|
position={[0, 0, frameDepth / 2]}
|
||||||
renderOrder={1}
|
renderOrder={1}
|
||||||
@@ -996,8 +977,8 @@ function PaintingFrame({
|
|||||||
color={finish.color}
|
color={finish.color}
|
||||||
roughness={finish.roughness}
|
roughness={finish.roughness}
|
||||||
metalness={finish.metalness}
|
metalness={finish.metalness}
|
||||||
emissive={finish.emissive}
|
emissive={reviewed ? finish.emissive : '#d8c090'}
|
||||||
emissiveIntensity={finish.emissiveIntensity}
|
emissiveIntensity={finish.emissiveIntensity + frameEmissiveBoost}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
@@ -1441,7 +1422,7 @@ function GalleryFloor({
|
|||||||
normalScale={new THREE.Vector2(texture.normalScale, texture.normalScale)}
|
normalScale={new THREE.Vector2(texture.normalScale, texture.normalScale)}
|
||||||
roughness={texture.roughness}
|
roughness={texture.roughness}
|
||||||
metalness={texture.metalness}
|
metalness={texture.metalness}
|
||||||
envMapIntensity={0.65}
|
envMapIntensity={0.3}
|
||||||
color={interiorStyle.tints.floor}
|
color={interiorStyle.tints.floor}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
@@ -1661,18 +1642,21 @@ function ArtistHall({
|
|||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{interiorStyle && computedWindows && computedWindows.length > 0 && (
|
{interiorStyle && (
|
||||||
<>
|
<>
|
||||||
<GalleryWindows
|
{computedWindows && computedWindows.length > 0 && (
|
||||||
windows={computedWindows}
|
<GalleryWindows
|
||||||
halfW={halfW}
|
windows={computedWindows}
|
||||||
halfD={halfD}
|
halfW={halfW}
|
||||||
trimColor={interiorStyle.tints.trim}
|
halfD={halfD}
|
||||||
/>
|
trimColor={interiorStyle.tints.trim}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Always light the hall — do not gate track lights on window gaps. */}
|
||||||
<GalleryTrackLights
|
<GalleryTrackLights
|
||||||
width={width}
|
width={width}
|
||||||
depth={depth}
|
depth={depth}
|
||||||
intensity={interiorStyle.trackLights}
|
intensity={Math.max(1.8, interiorStyle.trackLights * 2.8) * Math.PI}
|
||||||
color={interiorStyle.warmLight}
|
color={interiorStyle.warmLight}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -1750,15 +1734,24 @@ function ArtistHall({
|
|||||||
</group>
|
</group>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<pointLight position={[0, WALL_HEIGHT - 0.4, 0]} intensity={interiorStyle ? interiorStyle.ambient * 1.2 : 0.5} color={warmLight} distance={width + depth} />
|
<pointLight
|
||||||
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD / 2]} intensity={interiorStyle ? interiorStyle.ambient * 0.85 : 0.35} color={warmLight} distance={Math.max(12, depth * 0.75)} />
|
position={[0, WALL_HEIGHT - 0.4, 0]}
|
||||||
{depth > 14 && (
|
intensity={(interiorStyle ? Math.max(1.0, interiorStyle.ambient * 1.8) : 0.7) * Math.PI}
|
||||||
<pointLight position={[0, WALL_HEIGHT - 0.4, -halfD + 1.2]} intensity={interiorStyle ? interiorStyle.ambient * 0.7 : 0.3} color={warmLight} distance={14} />
|
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}
|
||||||
|
distance={Math.max(12, depth * 0.75)}
|
||||||
|
decay={0}
|
||||||
|
color={warmLight}
|
||||||
|
/>
|
||||||
{interiorStyle && (
|
{interiorStyle && (
|
||||||
<directionalLight
|
<directionalLight
|
||||||
position={[0, 6, -halfD - 2]}
|
position={[0, 6, -halfD - 2]}
|
||||||
intensity={interiorStyle.ambient * 0.9}
|
intensity={Math.max(0.55, interiorStyle.ambient * 0.85) * Math.PI}
|
||||||
color={interiorStyle.sunLight}
|
color={interiorStyle.sunLight}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -2043,6 +2036,10 @@ export default function VirtualGallery(props: Props) {
|
|||||||
|
|
||||||
const handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => {
|
const handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => {
|
||||||
const canvas = state.gl.domElement;
|
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
|
// A freshly created canvas has a healthy context, so clear any lingering
|
||||||
// "restoring" state from a previous loss/remount.
|
// "restoring" state from a previous loss/remount.
|
||||||
setGlLost(false);
|
setGlLost(false);
|
||||||
@@ -2432,7 +2429,9 @@ export default function VirtualGallery(props: Props) {
|
|||||||
|
|
||||||
const sceneBackground = interiorStyle?.background ?? '#0d0906';
|
const sceneBackground = interiorStyle?.background ?? '#0d0906';
|
||||||
const sceneFog = interiorStyle?.fog ?? '#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<HTMLDivElement>) => {
|
const handleCanvasPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
if (!active || showExitNav || e.button !== 0) return;
|
if (!active || showExitNav || e.button !== 0) return;
|
||||||
@@ -2539,13 +2538,34 @@ export default function VirtualGallery(props: Props) {
|
|||||||
>
|
>
|
||||||
<FrameloopSync active={active} />
|
<FrameloopSync active={active} />
|
||||||
<color attach="background" args={[sceneBackground]} />
|
<color attach="background" args={[sceneBackground]} />
|
||||||
<fog attach="fog" args={[sceneFog, 18, fogFar]} />
|
<fog
|
||||||
|
attach="fog"
|
||||||
|
args={[sceneFog, Math.max(45, layout.depth * 1.15), Math.max(fogFar, layout.depth + 55)]}
|
||||||
|
/>
|
||||||
<ambientLight intensity={ambientIntensity} />
|
<ambientLight intensity={ambientIntensity} />
|
||||||
|
<hemisphereLight
|
||||||
|
color={interiorStyle?.warmLight ?? '#fff8f0'}
|
||||||
|
groundColor={sceneFog}
|
||||||
|
intensity={0.55 * Math.PI}
|
||||||
|
/>
|
||||||
<directionalLight
|
<directionalLight
|
||||||
position={interiorStyle ? [2, 10, -4] : [3, 9, 4]}
|
position={interiorStyle ? [2, 10, -4] : [3, 9, 4]}
|
||||||
intensity={interiorStyle ? 0.85 : 0.65}
|
intensity={(interiorStyle ? 1.4 : 0.9) * Math.PI}
|
||||||
color={interiorStyle?.sunLight ?? '#fff8f0'}
|
color={interiorStyle?.sunLight ?? '#fff8f0'}
|
||||||
/>
|
/>
|
||||||
|
<directionalLight
|
||||||
|
position={[-4, 6, 5]}
|
||||||
|
intensity={0.55 * Math.PI}
|
||||||
|
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}
|
||||||
|
distance={40}
|
||||||
|
decay={0}
|
||||||
|
color={interiorStyle?.warmLight ?? '#fff5e8'}
|
||||||
|
/>
|
||||||
<SceneErrorBoundary
|
<SceneErrorBoundary
|
||||||
key={`env-${hallKey}-${glEpoch}`}
|
key={`env-${hallKey}-${glEpoch}`}
|
||||||
fallback={null}
|
fallback={null}
|
||||||
@@ -2553,7 +2573,7 @@ export default function VirtualGallery(props: Props) {
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Environment
|
<Environment
|
||||||
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
|
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
|
||||||
environmentIntensity={interiorStyle ? 0.35 : 0.15}
|
environmentIntensity={interiorStyle ? 0.55 : 0.25}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</SceneErrorBoundary>
|
</SceneErrorBoundary>
|
||||||
|
|||||||
@@ -144,17 +144,19 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
|
|||||||
{
|
{
|
||||||
details: 'medieval',
|
details: 'medieval',
|
||||||
titleColor: '#e8dcc8',
|
titleColor: '#e8dcc8',
|
||||||
ambient: 0.52,
|
ambient: 0.72,
|
||||||
warmLight: '#e8d8c0',
|
warmLight: '#e8d8c0',
|
||||||
sunLight: '#d0e8ff',
|
sunLight: '#d0e8ff',
|
||||||
fog: '#0c0c10',
|
// Keep atmosphere dark, but not pure void (walls must stay readable).
|
||||||
|
fog: '#1a1816',
|
||||||
|
background: '#141210',
|
||||||
windows: windows(
|
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: '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: '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 }
|
{ 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(
|
30: mk(
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export function useTexturedMaterial(
|
|||||||
color: tint,
|
color: tint,
|
||||||
roughness: surf.roughness,
|
roughness: surf.roughness,
|
||||||
metalness: surf.metalness,
|
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]);
|
}, [kind, tint, spanW, spanH]);
|
||||||
|
|
||||||
|
|||||||
@@ -583,7 +583,7 @@ function imageDataToNormalMap(data: ImageData, size: number, strength = 2.5): Im
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageDataToTexture(data: ImageData): THREE.CanvasTexture {
|
function imageDataToTexture(data: ImageData, colorSpace: THREE.ColorSpace): THREE.CanvasTexture {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = data.width;
|
canvas.width = data.width;
|
||||||
canvas.height = data.height;
|
canvas.height = data.height;
|
||||||
@@ -591,8 +591,8 @@ function imageDataToTexture(data: ImageData): THREE.CanvasTexture {
|
|||||||
const tex = new THREE.CanvasTexture(canvas);
|
const tex = new THREE.CanvasTexture(canvas);
|
||||||
tex.wrapS = THREE.RepeatWrapping;
|
tex.wrapS = THREE.RepeatWrapping;
|
||||||
tex.wrapT = THREE.RepeatWrapping;
|
tex.wrapT = THREE.RepeatWrapping;
|
||||||
tex.colorSpace = THREE.SRGBColorSpace;
|
tex.colorSpace = colorSpace;
|
||||||
tex.anisotropy = 16;
|
tex.anisotropy = 8;
|
||||||
return tex;
|
return tex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -686,8 +686,9 @@ export function getSurfaceTexture(kind: SurfaceTextureKind): SurfaceTextureSet {
|
|||||||
const normalData = imageDataToNormalMap(colorData, TEXTURE_SIZE, normalStrengthFor(kind));
|
const normalData = imageDataToNormalMap(colorData, TEXTURE_SIZE, normalStrengthFor(kind));
|
||||||
|
|
||||||
const set: SurfaceTextureSet = {
|
const set: SurfaceTextureSet = {
|
||||||
map: imageDataToTexture(colorData),
|
map: imageDataToTexture(colorData, THREE.SRGBColorSpace),
|
||||||
normalMap: imageDataToTexture(normalData),
|
// Normal maps must stay linear — sRGB encoding breaks lighting on walls.
|
||||||
|
normalMap: imageDataToTexture(normalData, THREE.NoColorSpace),
|
||||||
roughness: ROUGHNESS[kind] ?? 0.75,
|
roughness: ROUGHNESS[kind] ?? 0.75,
|
||||||
metalness: METALNESS[kind] ?? 0.04,
|
metalness: METALNESS[kind] ?? 0.04,
|
||||||
metersPerRepeat: METERS[kind] ?? 2.8,
|
metersPerRepeat: METERS[kind] ?? 2.8,
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ function windowTemplate(style: MovementInteriorStyle): Pick<GalleryWindowSpec, '
|
|||||||
return { style: 'sash', lightColor: style.warmLight, lightIntensity: 2.8, width: 1.4, height: 1.4 };
|
return { style: 'sash', lightColor: style.warmLight, lightIntensity: 2.8, width: 1.4, height: 1.4 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Place windows on side walls only, in gaps between painting frames. */
|
/** Place windows on side walls in gaps between frames; fall back to high clerestory when packed. */
|
||||||
export function computeSideWallWindows(
|
export function computeSideWallWindows(
|
||||||
layout: MovementHallLayout,
|
layout: MovementHallLayout,
|
||||||
interiorStyle: MovementInteriorStyle
|
interiorStyle: MovementInteriorStyle
|
||||||
@@ -364,6 +364,25 @@ export function computeSideWallWindows(
|
|||||||
lightIntensity: tmpl.lightIntensity,
|
lightIntensity: tmpl.lightIntensity,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Packed walls: still add high clerestory windows so the hall gets daylight + style.
|
||||||
|
if (!specs.some((s) => 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;
|
return specs;
|
||||||
|
|||||||
Reference in New Issue
Block a user