Author SHA1 Message Date
Danila KhodjaefandClaude Opus 5 f2a256132c Give every movement its own period interior.
Movement halls routed 24 of the 26 movements through 8 generic detail kinds,
four of which (modern, industrial, atelier, museum) rendered nothing at all,
and `salon` was a single ceiling ring shared by eight movements.

`details` is now a 26-value MovementDetailKind, one per movement, each built
from the architecture of the same period as the movement itself. Gothic and
Byzantine are unchanged and stay in MovementHallDetails.tsx as the reference
implementations; the rest live under components/hall-details/, split by era,
over shared wall-flush primitives.

hall-details/geometry.ts records the budget the Gothic and Byzantine
components established, since frames hang 0.23 m proud with a 0.7 m clear
margin at each wall end: relief limits above and below the frame line, corner
pockets for freestanding masses, doorway and title-band clearance, and a cap
of two added lights per hall. Halls now pass their computed windows down so
upper-wall panels, arches and roundels step around the openings.

Also holds the pale interiors back from blowing out: gallery wall tints are
authored around 0.50-0.56 luminance instead of near-white, and the 19 halls
that read too bright take lightScale 0.30, matching the basilica.

Verified by rendering all 26 interiors offline at two hall sizes and
measuring the scene graph: nothing through walls, floor or ceiling, maximum
0.17 m intrusion into the hang zone, at most two added lights per hall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 18:44:05 +03:00
11 changed files with 2828 additions and 233 deletions
+9 -3
View File
@@ -37,7 +37,8 @@ Gallery/
│ │ ├── components/GalleryLoadingMarker.tsx # Loading spinner overlay/banner (catalog, portraits, halls) │ │ ├── components/GalleryLoadingMarker.tsx # Loading spinner overlay/banner (catalog, portraits, halls)
│ │ ├── components/GalleryWindows.tsx # Side-wall daylight windows (movement) │ │ ├── components/GalleryWindows.tsx # Side-wall daylight windows (movement)
│ │ ├── components/HallPassage.tsx # Open archway between movement wings │ │ ├── components/HallPassage.tsx # Open archway between movement wings
│ │ ├── components/MovementHallDetails.tsx # Period architectural details │ │ ├── components/MovementHallDetails.tsx # Period architecture dispatcher (Gothic + Byzantine reference)
│ │ ├── components/hall-details/ # One period interior per movement + shared primitives
│ │ ├── data/movement-interior-styles.ts # Per-movement interior themes │ │ ├── data/movement-interior-styles.ts # Per-movement interior themes
│ │ ├── utils/movementHallLayout.ts # Wing split + window gap placement │ │ ├── utils/movementHallLayout.ts # Wing split + window gap placement
│ │ ├── utils/galleryProceduralTextures.ts # Hi-res wall/floor textures │ │ ├── utils/galleryProceduralTextures.ts # Hi-res wall/floor textures
@@ -353,13 +354,14 @@ Enter from the home page by clicking a **movement name** on the movement flow. F
| Wall order | Same U-shaped hang as artist halls (left → end → right) | | Wall order | Same U-shaped hang as artist halls (left → end → right) |
| Frame captions | **Year · artist** label below each frame | | Frame captions | **Year · artist** label below each frame |
| Period interior | Each of the 26 seeded movements maps to a unique style in `movement-interior-styles.ts` (Italian palazzo, Baroque palace, Byzantine basilica, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **126** → authored keys **2752**, with Northern/High Renaissance swapped) so Byzantine gets the basilica, 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, Byzantine basilica, NYC loft, white cube, etc.). Style keys use a legacy id map (`DB_ID_TO_STYLE_KEY`: gallery ids **126** → authored keys **2752**, with Northern/High Renaissance swapped) so Byzantine gets the basilica, not Gothic stone |
| Period architecture | Each style names a `details` kind (26 of them) that selects one component under `components/hall-details/` — the hall is built from the architecture of the **same period as the movement** (Roman entablature, Brunelleschi arcade, Flemish beam ceiling, Horta whiplash brackets, Rodchenko lattice, 1960s troffer grid). Gothic and Byzantine remain the reference implementations in `MovementHallDetails.tsx`; the geometry budget every module works to is documented in `hall-details/geometry.ts` |
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco, **marble revetment** (framed book-matched panels), **Cosmatesque paving** (`marble-opus-sectile`), **coffered timber** ceilings, **blind-arcaded ashlar** (`gothic-ashlar`) and **ribbed vaulting** (`gothic-vault`) — 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, **marble revetment** (framed book-matched panels), **Cosmatesque paving** (`marble-opus-sectile`), **coffered timber** ceilings, **blind-arcaded ashlar** (`gothic-ashlar`) and **ribbed vaulting** (`gothic-vault`) — 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 |
| Architecture in texture | Period halls that need arcades, vaults, or panelling draw that **relief into the texture** rather than adding geometry: `gothic-ashlar` paints a full storey (plinth → blind arcade → string course → triforium → cornice) into one tile whose `metersPerRepeat` equals `WALL_HEIGHT`, so it maps **once vertically** onto the wall; `gothic-vault` paints a quadripartite bay with tiercerons and a boss. Light/dark banding plus a high `normalStrengthFor()` makes it read as carved stone, keeping the mesh and light budget flat | | Architecture in texture | Period halls that need arcades, vaults, or panelling draw that **relief into the texture** rather than adding geometry: `gothic-ashlar` paints a full storey (plinth → blind arcade → string course → triforium → cornice) into one tile whose `metersPerRepeat` equals `WALL_HEIGHT`, so it maps **once vertically** onto the wall; `gothic-vault` paints a quadripartite bay with tiercerons and a boss. Light/dark banding plus a high `normalStrengthFor()` makes it read as carved stone, keeping the mesh and light budget flat |
| Door-flanking panels | Wall segments beside exit doors and passages take the hall's own wall texture (`GalleryWall` accepts `kind`/`tint`) instead of rendering as flat single-colour blocks next to textured walls | | Door-flanking panels | Wall segments beside exit doors and passages take the hall's own wall texture (`GalleryWall` accepts `kind`/`tint`) instead of rendering as flat single-colour blocks next to textured walls |
| 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 when possible; if a wall is packed, high **clerestory** windows are still added so the hall keeps daylight (`computeSideWallWindows`). Styles are drawn in `GalleryWindows.tsx`; `gothic-lancet` builds a two-centred arch head from chords with a glazed spandrel, mullions, and transoms | | 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`). Styles are drawn in `GalleryWindows.tsx`; `gothic-lancet` builds a two-centred arch head from chords with a glazed spandrel, mullions, and transoms |
| Lighting | Shared hall lights only (ambient / hemisphere / directional + capped ceiling track spots + one fill per window). See **light budget** below | | 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); `byzantine` adds engaged porphyry colonnettes with basket capitals, a marble revetment dado, and hanging brass polycandela; `gothic` adds bay-spaced compound piers with vault springers, transverse ribs arching across the nave, and a moulded string course — all flush to the side walls | | Period details | `MovementHallDetails.tsx` dispatches to one module per movement. `byzantine` adds engaged porphyry colonnettes with basket capitals, a marble revetment dado, and hanging brass polycandela; `gothic` adds bay-spaced compound piers with vault springers, transverse ribs arching across the nave, and a moulded string course. Every other movement follows the same rules in `components/hall-details/`: orders and piers stay **flush to the side walls** (≤ 0.17 m proud), freestanding masses only in the corner pockets, and no more than two added lights per hall — see `hall-details/geometry.ts` |
| 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 emissive fixtures as artist halls when `has_influence_links` is true (shared rail: 40 cm above the tallest frame in the wing) | | Influence lamps | Same golden upside-down emissive fixtures as artist halls when `has_influence_links` is true (shared rail: 40 cm above the tallest frame in the wing) |
@@ -395,7 +397,11 @@ Full guide: [tours.md](tours.md).
**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 ~100200 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). **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 ~100200 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).
Shared lights carry a **brightness floor** so dark period styles never render as a black void. A style that is *meant* to be dim (a basilica lit by window shafts, not flooded) sets **`lightScale`** in `movement-interior-styles.ts` — it multiplies the scene ambient/hemisphere/directional/fill lights, the hall point lights, the ceiling track spots, and the HDR `environmentIntensity`. It defaults to **1**, so only styles that opt in are affected. Window fill lights are deliberately **not** scaled, so the daylight shafts still read against the darker room. Shared lights carry a **brightness floor** so dark period styles never render as a black void. That floor is high enough to blow out a pale wall, so **`lightScale`** in `movement-interior-styles.ts` is the knob that holds a hall back — it multiplies the scene ambient/hemisphere/directional/fill lights, the hall point lights, the ceiling track spots, and the HDR `environmentIntensity`. Window fill lights are deliberately **not** scaled, so daylight shafts still read against the darker room.
It defaults to **1**, but most halls now set it: Gothic uses **0.26**, Byzantine **0.30**, and the 19 formerly pale interiors (Roman through Pop Art) also use **0.30**. Only the five halls that are already dark by albedo — Symbolist, Expressionist, Surrealist, Baroque, Romantic — stay at 1, since scaling their lights would crush walls that already sit at 0.150.27 luminance.
**Wall brightness is albedo × `lightScale`.** Gallery walls are authored well below white for that reason: the reworked interiors sit around **0.500.56** wall luminance (0.66 for the two white-cube halls), landing at an effective 0.140.20 — the range Gothic and Byzantine established. Reach for `lightScale` before darkening a tint further; the two multiply, and dimming the hall dims the paintings with it.
**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`). **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`).
+31 -2
View File
@@ -278,9 +278,38 @@ 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 (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; Byzantine adds porphyry colonnettes / polycandela; Gothic (`details: 'gothic'`) adds bay-spaced compound piers with vault springers and transverse ribs flush to the side walls. 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 the `details` kind that selects its architecture. Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts` (colour maps in sRGB, normal maps in linear/`NoColorSpace`).
Gothic surfaces include **`gothic-ashlar`** (full-storey blind arcade mapped once vertically via `metersPerRepeat === WALL_HEIGHT`), **`gothic-vault`** (quadripartite rib bay), and **`gothic-stone-floor`**. Door-flanking wall panels use the hall wall texture via `GalleryWall` `kind`/`tint` so they match the textured walls. Deliberately dim naves set **`lightScale`** (Byzantine / Gothic) so shared lights stay soft while window shafts stay bright. ### Period architecture per movement
Every movement is built from the architecture of **its own period** — no two movements share an interior. `client/src/components/MovementHallDetails.tsx` dispatches on `style.details` (`MovementDetailKind`, 26 values) to one component:
| Module | Movements |
|--------|-----------|
| `MovementHallDetails.tsx` (reference implementations) | `gothic` — bay-spaced compound piers, vault springers, transverse ribs · `byzantine` — porphyry colonnettes, basket capitals, revetment dado, hanging polycandela |
| `hall-details/classical.tsx` | `roman` (Tuscan half-columns, dentil entablature, Pompeian socle) · `quattrocento` (pietra serena pilasters, blind arcade, tondi) · `cinquecento` (paired fluted Corinthian pilasters, Pantheon coffers) |
| `hall-details/courtly.tsx` | `flemish` (linenfold wainscot, beam-and-corbel ceiling) · `mannerist` (herm pilasters, banded rustication, broken cornice) · `baroque` (solomonic corner columns, modillions, velvet field panels) · `rococo` (boiserie, rocaille cartouches, painted cove) · `neoclassical` (fluted Ionic order, meander frieze) |
| `hall-details/nineteenth.tsx` | `gothic-revival` · `bourgeois` · `north-light` · `paris-atelier` · `symbolist` · `art-nouveau` |
| `hall-details/modernism.tsx` | `fauvist` · `expressionist` · `cubist-atelier` · `futurist` · `suprematist` · `constructivist` · `dada` · `surrealist` · `loft` · `white-cube` |
`hall-details/primitives.tsx` holds the wall-flush building blocks (`WallBand`, `CorniceRing`, `FlutedShaft`, capitals, `CofferedCeiling`, `HangingFixture`, `WallSconce`); `hall-details/geometry.ts` holds the shared hooks and the **geometry budget** every module has to obey, because frames hang 0.23 m proud of the wall face with a 0.7 m clear margin at each wall end:
- relief below the frame line stays within **0.11 m** proud; friezes and cornices above 2.6 m within **0.17 m**
- freestanding masses only inside the **0.34 m corner pocket**; nothing under 2.6 m within 1.45 m of a wall centre (doorways)
- the end wall carries the hall title between **3.4 m and 3.9 m** — keep it clear
- side-wall windows are placed at runtime between 2.4 m and 3.9 m, so panels, arches and roundels up there are filtered through **`useWallClearance`** (the hall passes its computed windows into `MovementHallDetails`)
- at most **two added lights per hall**, so hall materials stay inside the WebGL light limits the shared track lighting already budgets for
Gothic surfaces include **`gothic-ashlar`** (full-storey blind arcade mapped once vertically via `metersPerRepeat === WALL_HEIGHT`), **`gothic-vault`** (quadripartite rib bay), and **`gothic-stone-floor`**. Door-flanking wall panels use the hall wall texture via `GalleryWall` `kind`/`tint` so they match the textured walls.
### Wall brightness
Halls are lit by a shared rig with a high brightness floor, so a pale wall tint clips to flat white and loses its texture. Two settings control this together, and they **multiply**:
- **tints** — gallery walls are authored around **0.500.56** relative luminance (0.66 for the Suprematist and Pop white cubes, 0.150.27 for the deliberately dark halls). Nothing sits near white.
- **`lightScale`** — Gothic **0.26**, Byzantine **0.30**, and every formerly pale interior **0.30**; the already-dark halls keep **1**. Window fills are not scaled, so daylight shafts stay bright.
When a hall reads too bright, prefer `lightScale`: it keeps the material colour believable (Byzantine's warm stone is a genuine `#a89880`, simply lit dimly) and avoids greying tints toward neutral. Remember that dimming a hall dims its paintings too — the lighting is shared, with no per-painting spots.
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 movements 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 movements look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
+100 -183
View File
@@ -1,6 +1,38 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import * as THREE from 'three'; import type { MovementInteriorStyle, GalleryWindowSpec } from '../data/movement-interior-styles';
import type { MovementInteriorStyle } from '../data/movement-interior-styles'; import type { RoomDims } from './hall-details/geometry';
import {
CinquecentoDetails,
QuattrocentoDetails,
RomanAtriumDetails,
} from './hall-details/classical';
import {
BaroqueStateDetails,
FlemishDetails,
ManneristDetails,
NeoclassicalDetails,
RococoDetails,
} from './hall-details/courtly';
import {
ArtNouveauDetails,
BourgeoisSalonDetails,
GothicRevivalDetails,
NorthLightSalonDetails,
ParisAtelierDetails,
SymbolistDetails,
} from './hall-details/nineteenth';
import {
ConstructivistDetails,
CubistAtelierDetails,
DadaDetails,
ExpressionistDetails,
FauvistDetails,
FuturistDetails,
LoftDetails,
SuprematistDetails,
SurrealistDetails,
WhiteCubeDetails,
} from './hall-details/modernism';
interface Props { interface Props {
style: MovementInteriorStyle; style: MovementInteriorStyle;
@@ -8,99 +40,11 @@ interface Props {
depth: number; depth: number;
halfW: number; halfW: number;
halfD: number; halfD: number;
/** Runtime window placement, so upper-wall relief can step around openings. */
windows?: GalleryWindowSpec[];
} }
function PalazzoDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) { function GothicDetails({ width, depth, halfW, halfD, trim }: Omit<Props, 'style' | 'windows'> & { trim: string }) {
const pilasterPositions = useMemo(
() =>
[
[-halfW + 0.35, -halfD + 0.35],
[halfW - 0.35, -halfD + 0.35],
[-halfW + 0.35, halfD - 0.35],
[halfW - 0.35, halfD - 0.35],
] as [number, number][],
[halfW, halfD]
);
return (
<group>
{pilasterPositions.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 1.8, 0]}>
<boxGeometry args={[0.22, 3.6, 0.22]} />
<meshStandardMaterial color="#e8dcc8" roughness={0.85} metalness={0.05} />
</mesh>
<mesh position={[0, 3.65, 0]}>
<boxGeometry args={[0.28, 0.14, 0.28]} />
<meshStandardMaterial color={trim} roughness={0.35} metalness={0.55} />
</mesh>
</group>
))}
{/* Wainscoting rail */}
{[
[0, -halfD + 0.09, width, 0.12] as const,
[0, halfD - 0.09, width, 0.12] as const,
[-halfW + 0.09, 0, 0.12, depth] as const,
[halfW - 0.09, 0, 0.12, depth] as const,
].map(([x, z, w, d], i) => (
<mesh key={i} position={[x, 1.05, z]}>
<boxGeometry args={[w, 0.08, d]} />
<meshStandardMaterial color="#ddd0b8" roughness={0.78} metalness={0.08} />
</mesh>
))}
{/* Coffered ceiling */}
{Array.from({ length: Math.min(6, Math.floor(width / 2.5)) }, (_, col) =>
Array.from({ length: Math.min(5, Math.floor(depth / 2.8)) }, (_, row) => {
const cx = -halfW + 1.4 + col * 2.4;
const cz = -halfD + 1.5 + row * 2.6;
return (
<mesh key={`${col}-${row}`} position={[cx, 4.12, cz]} rotation={[Math.PI / 2, 0, 0]}>
<boxGeometry args={[2.0, 2.2, 0.06]} />
<meshStandardMaterial color="#f5efe4" roughness={0.88} />
</mesh>
);
})
)}
</group>
);
}
function BaroqueDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
return (
<group>
{/* Gilded cornice ring */}
{[
[0, -halfD + 0.06, width, 0.1] as const,
[0, halfD - 0.06, width, 0.1] as const,
[-halfW + 0.06, 0, 0.1, depth] as const,
[halfW - 0.06, 0, 0.1, depth] as const,
].map(([x, z, w, d], i) => (
<mesh key={i} position={[x, 3.95, z]}>
<boxGeometry args={[w, 0.18, d]} />
<meshStandardMaterial color={trim} roughness={0.25} metalness={0.75} emissive="#3a2808" emissiveIntensity={0.08} />
</mesh>
))}
{/* Wall panels */}
{[-halfW + 0.12, halfW - 0.12].map((x, i) => (
<mesh key={i} position={[x, 2.1, 0]} rotation={[0, Math.PI / 2, 0]}>
<boxGeometry args={[depth * 0.85, 2.8, 0.04]} />
<meshStandardMaterial color="#3a1820" roughness={0.88} metalness={0.06} />
</mesh>
))}
<pointLight position={[0, 3.6, 0]} intensity={0.9} distance={Math.max(width, depth)} color="#ffd080" />
<mesh position={[0, 3.5, 0]}>
<torusGeometry args={[0.55, 0.04, 8, 24]} />
<meshStandardMaterial color={trim} roughness={0.2} metalness={0.85} emissive="#5a4010" emissiveIntensity={0.15} />
</mesh>
</group>
);
}
function GothicDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
// Compound piers along the side walls, spaced by bay, each rising into the // Compound piers along the side walls, spaced by bay, each rising into the
// vault springer. Shafts stay flush against the wall (0.16 m proud) so they // vault springer. Shafts stay flush against the wall (0.16 m proud) so they
// never intrude on the ~0.7 m frame hang margin. // never intrude on the ~0.7 m frame hang margin.
@@ -292,102 +236,75 @@ function ByzantineDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'>
); );
} }
function NeoclassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) { /**
// Shallow engaged corner pilasters only. Freestanding columns (~0.5m into the * Period architecture for a movement hall.
// room) overlap the outermost frames — hang margin is only ~0.7m from each end. *
const pilasters = useMemo( * Every movement has its own interior, built from the architecture of the same
() => * period as the movement itself. The Gothic nave and Byzantine basilica below
[ * are the reference implementations; everything else lives in
[-halfW + 0.06, -halfD + 0.06], * `hall-details/`, sharing the geometry budget documented in
[halfW - 0.06, -halfD + 0.06], * `hall-details/primitives.tsx`.
[-halfW + 0.06, halfD - 0.06], */
[halfW - 0.06, halfD - 0.06], export default function MovementHallDetails({ style, width, depth, halfW, halfD, windows }: Props) {
] as [number, number][],
[halfW, halfD]
);
return (
<group>
{pilasters.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 1.85, 0]}>
<boxGeometry args={[0.12, 3.7, 0.12]} />
<meshStandardMaterial color="#f0ece8" roughness={0.72} metalness={0.12} />
</mesh>
<mesh position={[0, 3.78, 0]}>
<boxGeometry args={[0.18, 0.1, 0.18]} />
<meshStandardMaterial color={trim} roughness={0.4} metalness={0.35} />
</mesh>
</group>
))}
{/* Cornice above wall title (title sits ~y 3.75) */}
<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} />
</mesh>
</group>
);
}
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 (
<group>
{columns.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 2, 0]}>
<cylinderGeometry args={[0.08, 0.09, 4, 10]} />
<meshStandardMaterial color="#e0d8c8" roughness={0.88} />
</mesh>
<mesh position={[0, 4.05, 0]}>
<boxGeometry args={[0.18, 0.1, 0.18]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.25} />
</mesh>
</group>
))}
</group>
);
}
function SalonDetails({ trim }: { trim: string }) {
return (
<mesh position={[0, 4.05, 0]} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.3, 1.2, 32]} />
<meshStandardMaterial color={trim} roughness={0.35} metalness={0.5} side={THREE.DoubleSide} />
</mesh>
);
}
export default function MovementHallDetails({ style, width, depth, halfW, halfD }: Props) {
const trim = style.tints.trim; const trim = style.tints.trim;
const room: RoomDims = useMemo(
() => ({ width, depth, halfW, halfD }),
[width, depth, halfW, halfD]
);
const period = { room, trim, windows };
switch (style.details) { switch (style.details) {
case 'palazzo':
return <PalazzoDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'baroque':
return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'gothic': case 'gothic':
return <GothicDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />; return <GothicDetails width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'byzantine': case 'byzantine':
return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />; return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'roman':
return <RomanAtriumDetails {...period} />;
case 'quattrocento':
return <QuattrocentoDetails {...period} />;
case 'cinquecento':
return <CinquecentoDetails {...period} />;
case 'flemish':
return <FlemishDetails {...period} />;
case 'mannerist':
return <ManneristDetails {...period} />;
case 'baroque':
return <BaroqueStateDetails {...period} />;
case 'rococo':
return <RococoDetails {...period} />;
case 'neoclassical': case 'neoclassical':
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />; return <NeoclassicalDetails {...period} />;
case 'classical': case 'gothic-revival':
return <ClassicalDetails halfW={halfW} halfD={halfD} trim={trim} />; return <GothicRevivalDetails {...period} />;
case 'salon': case 'bourgeois':
return <SalonDetails trim={trim} />; return <BourgeoisSalonDetails {...period} />;
default: case 'north-light':
return null; return <NorthLightSalonDetails {...period} />;
case 'paris-atelier':
return <ParisAtelierDetails {...period} />;
case 'symbolist':
return <SymbolistDetails {...period} />;
case 'art-nouveau':
return <ArtNouveauDetails {...period} />;
case 'fauvist':
return <FauvistDetails {...period} />;
case 'expressionist':
return <ExpressionistDetails {...period} />;
case 'cubist-atelier':
return <CubistAtelierDetails {...period} />;
case 'futurist':
return <FuturistDetails {...period} />;
case 'suprematist':
return <SuprematistDetails {...period} />;
case 'constructivist':
return <ConstructivistDetails {...period} />;
case 'dada':
return <DadaDetails {...period} />;
case 'surrealist':
return <SurrealistDetails {...period} />;
case 'loft':
return <LoftDetails {...period} />;
case 'white-cube':
return <WhiteCubeDetails {...period} />;
} }
} }
+24 -3
View File
@@ -15,7 +15,12 @@ import type {
import { galleryImageUrlCandidates, imageUrl, api } from '../api/client'; import { galleryImageUrlCandidates, imageUrl, api } from '../api/client';
import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils'; import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
import { cloneSurfaceTexture, getSurfaceTexture, type SurfaceTextureKind } from '../utils/galleryProceduralTextures'; import { cloneSurfaceTexture, getSurfaceTexture, type SurfaceTextureKind } from '../utils/galleryProceduralTextures';
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles'; import {
resolveMovementInteriorStyle,
type MovementDetailKind,
type MovementInteriorStyle,
type GalleryWindowSpec,
} from '../data/movement-interior-styles';
import { useTexturedMaterial } from '../hooks/useTexturedMaterial'; import { useTexturedMaterial } from '../hooks/useTexturedMaterial';
import MovementHallDetails from './MovementHallDetails'; import MovementHallDetails from './MovementHallDetails';
import GalleryWindows, { GalleryTrackLights } from './GalleryWindows'; import GalleryWindows, { GalleryTrackLights } from './GalleryWindows';
@@ -84,6 +89,15 @@ interface TourGalleryProps extends BaseGalleryProps {
type Props = ArtistGalleryProps | MovementGalleryProps | TourGalleryProps; type Props = ArtistGalleryProps | MovementGalleryProps | TourGalleryProps;
/** Period interiors built from steel, concrete or a white cube read better under the city HDR. */
const CITY_ENVIRONMENT_DETAILS = new Set<MovementDetailKind>([
'futurist',
'suprematist',
'constructivist',
'loft',
'white-cube',
]);
const WALL_HEIGHT = 4.2; const WALL_HEIGHT = 4.2;
const WALL_THICKNESS = 0.18; const WALL_THICKNESS = 0.18;
const MOUNT_OFFSET = 0.16; const MOUNT_OFFSET = 0.16;
@@ -1739,7 +1753,14 @@ function ArtistHall({
</mesh> </mesh>
{interiorStyle && ( {interiorStyle && (
<MovementHallDetails style={interiorStyle} width={width} depth={depth} halfW={halfW} halfD={halfD} /> <MovementHallDetails
style={interiorStyle}
width={width}
depth={depth}
halfW={halfW}
halfD={halfD}
windows={computedWindows}
/>
)} )}
<Text <Text
@@ -2635,7 +2656,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 && CITY_ENVIRONMENT_DETAILS.has(interiorStyle.details) ? 'city' : 'warehouse'}
environmentIntensity={(interiorStyle ? 0.55 : 0.25) * lightScale} environmentIntensity={(interiorStyle ? 0.55 : 0.25) * lightScale}
/> />
</Suspense> </Suspense>
@@ -0,0 +1,271 @@
import {
CofferedCeiling,
CorniceRing,
FlutedShaft,
HangingFixture,
RoundArch,
ShaftBase,
TuscanCapital,
WallBand,
} from './primitives';
import {
shade,
useBayZ,
useCorners,
useRepeat,
useWallClearance,
type PeriodProps,
} from './geometry';
/**
* Roman atrium — Ancient / Classical.
*
* Engaged Tuscan half-columns on a bay rhythm carry a compressed but complete
* entablature (architrave, plain frieze, dentil course, projecting cornice).
* Below the frame line the walls carry the Pompeian red socle of a Fourth Style
* room; a bronze polycandelon hangs over the centre in place of ceiling lights.
*/
export function RomanAtriumDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 4.0, 9);
const corners = useCorners(room.halfW, room.halfD, 0.1);
const stone = '#e6dcc6';
const shadowStone = shade(stone, -0.14);
const socle = '#7c3227';
const dentils = useRepeat(room.depth, 0.46, 30);
return (
<group>
{/* Pompeian socle with a black skirting course */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.62} height={1.16} proud={0.07} color={socle} roughness={0.8} metalness={0.04} />
<WallBand wall={wall} room={room} y={0.09} height={0.18} proud={0.09} color="#241a14" roughness={0.7} metalness={0.06} />
<WallBand wall={wall} room={room} y={1.24} height={0.08} proud={0.1} color={stone} roughness={0.66} metalness={0.06} />
</group>
))}
{/* Engaged half-columns on the bay rhythm */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.115), 0, z]}>
<ShaftBase size={0.15} color={shadowStone} />
<group position={[0, 0.26, 0]}>
<FlutedShaft height={3.34} radius={0.135} color={stone} flutes={10} facing="x" />
</group>
<TuscanCapital y={3.72} size={0.15} color={stone} />
</group>
))}
</group>
))}
{/* Entablature: architrave, frieze and dentils along the colonnaded walls */}
{(['left', 'right'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={3.9} height={0.1} proud={0.16} color={shadowStone} roughness={0.7} />
<WallBand wall={wall} room={room} y={3.99} height={0.11} proud={0.13} color={stone} roughness={0.74} />
{dentils.map((z, i) => (
<mesh
key={i}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.16), 4.07, z]}
>
<boxGeometry args={[0.16, 0.07, 0.16]} />
<meshStandardMaterial color={shade(stone, 0.06)} roughness={0.7} metalness={0.05} />
</mesh>
))}
</group>
))}
{/* Projecting cornice returning around the whole room */}
<CorniceRing room={room} y={4.13} height={0.09} proud={0.19} color={trim} roughness={0.62} metalness={0.12} />
{/* Corner antae — square pilasters closing the order at each corner */}
{corners.map(([x, z], i) => (
<mesh key={i} position={[x, 1.95, z]}>
<boxGeometry args={[0.2, 3.9, 0.2]} />
<meshStandardMaterial color={shade(stone, 0.04)} roughness={0.76} metalness={0.04} />
</mesh>
))}
<HangingFixture
position={[0, 4.05, 0]}
dropLength={0.72}
radius={0.46}
arms={8}
metalColor={trim}
light
lightColor="#ffb862"
lightIntensity={0.9}
lightDistance={9}
/>
</group>
);
}
/**
* Florentine palazzo — Early Renaissance.
*
* Brunelleschi's grammar: flat pietra serena pilasters set against lime plaster,
* a blind arcade of semicircular arches springing between them, glazed roundels
* in the spandrels, and a painted-and-beamed quattrocento ceiling.
*/
export function QuattrocentoDetails({ room, trim, windows }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 3.8, 9);
const clearLeft = useWallClearance(windows, 'left');
const clearRight = useWallClearance(windows, 'right');
const serena = '#8b8f8a';
const serenaLight = shade(serena, 0.16);
const step = bays.length > 1 ? bays[1] - bays[0] : room.depth / 2;
return (
<group>
{/* Pietra serena dado with a moulded cap */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.6} height={1.2} proud={0.05} color={shade(serena, 0.24)} roughness={0.72} metalness={0.05} />
<WallBand wall={wall} room={room} y={1.24} height={0.09} proud={0.08} color={serena} roughness={0.62} metalness={0.07} />
</group>
))}
{/* Flat pilasters carrying the arcade */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.05), 0, z]}>
<mesh position={[0, 1.78, 0]}>
<boxGeometry args={[0.08, 3.56, 0.34]} />
<meshStandardMaterial color={serena} roughness={0.66} metalness={0.07} />
</mesh>
<mesh position={[0, 0.11, 0]}>
<boxGeometry args={[0.11, 0.22, 0.44]} />
<meshStandardMaterial color={shade(serena, -0.08)} roughness={0.7} metalness={0.06} />
</mesh>
{/* Simplified Corinthian capital */}
<mesh position={[0, 3.68, 0]}>
<boxGeometry args={[0.1, 0.2, 0.44]} />
<meshStandardMaterial color={serenaLight} roughness={0.6} metalness={0.08} />
</mesh>
<mesh position={[0, 3.82, 0]}>
<boxGeometry args={[0.12, 0.09, 0.5]} />
<meshStandardMaterial color={serenaLight} roughness={0.58} metalness={0.08} />
</mesh>
</group>
))}
</group>
))}
{/* Blind arcade between the pilasters, plus a roundel in each spandrel */}
{bays.slice(0, -1).map((z, i) => {
const mid = z + step / 2;
return (
<group key={i}>
{([-1, 1] as const).map((side) => (
<group key={side} position={[side * (room.halfW - 0.05), 3.88, mid]} rotation={[0, Math.PI / 2, 0]}>
<RoundArch
span={step - 0.34}
rise={0.24}
thickness={0.12}
depth={0.09}
color={serena}
segments={9}
roughness={0.64}
metalness={0.07}
/>
{/* Glazed tondo, only where no window claims the bay */}
{(side < 0 ? clearLeft : clearRight)(mid, 0.22) && (
<mesh position={[0, -0.5, 0]}>
<torusGeometry args={[0.17, 0.035, 8, 20]} />
<meshStandardMaterial color={trim} roughness={0.42} metalness={0.35} />
</mesh>
)}
</group>
))}
</group>
);
})}
{/* Painted beam ceiling over the arcade */}
<CorniceRing room={room} y={4.11} height={0.1} proud={0.14} color={serena} roughness={0.64} metalness={0.07} />
<CofferedCeiling
room={room}
y={4.09}
cell={2.0}
panelColor="#e2d3ba"
ribColor="#8a5a30"
ribWidth={0.14}
ribDrop={0.14}
/>
</group>
);
}
/**
* Roman palazzo — High Renaissance.
*
* Bramante's cinquecento order: paired fluted Corinthian pilasters on a wide
* bay, a full marble entablature, panelled dado, and a rosetted coffer field
* modelled on the Pantheon soffit.
*/
export function CinquecentoDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 5.2, 6);
const marble = '#efe9dd';
const grey = shade(marble, -0.16);
return (
<group>
{/* Panelled marble dado */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.58} height={1.16} proud={0.06} color={grey} roughness={0.44} metalness={0.1} />
<WallBand wall={wall} room={room} y={1.2} height={0.1} proud={0.09} color={marble} roughness={0.38} metalness={0.12} />
<WallBand wall={wall} room={room} y={0.07} height={0.14} proud={0.1} color={shade(grey, -0.18)} roughness={0.4} metalness={0.12} />
</group>
))}
{/* Paired fluted pilasters */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) =>
[-0.19, 0.19].map((dz) => (
<group key={`${side}-${dz}`} position={[side * (room.halfW - 0.13), 0, z + dz]}>
<ShaftBase size={0.12} color={grey} />
<group position={[0, 0.24, 0]}>
<FlutedShaft height={3.32} radius={0.11} color={marble} flutes={8} facing="x" />
</group>
<mesh position={[0, 3.66, 0]}>
<cylinderGeometry args={[0.15, 0.1, 0.24, 12]} />
<meshStandardMaterial color={marble} roughness={0.42} metalness={0.1} />
</mesh>
<mesh position={[0, 3.82, 0]}>
<boxGeometry args={[0.12, 0.1, 0.34]} />
<meshStandardMaterial color={shade(marble, 0.06)} roughness={0.4} metalness={0.11} />
</mesh>
</group>
))
)}
</group>
))}
{/* Full entablature on the pilastered walls, cornice all round */}
{(['left', 'right'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={3.92} height={0.1} proud={0.15} color={marble} roughness={0.42} metalness={0.1} />
<WallBand wall={wall} room={room} y={4.02} height={0.1} proud={0.12} color={grey} roughness={0.46} metalness={0.1} />
</group>
))}
<CorniceRing room={room} y={4.12} height={0.09} proud={0.18} color={trim} roughness={0.4} metalness={0.28} />
<CofferedCeiling
room={room}
y={4.08}
cell={2.6}
panelColor="#f2ede3"
ribColor="#ded5c4"
ribWidth={0.2}
ribDrop={0.18}
rosette={trim}
/>
</group>
);
}
@@ -0,0 +1,415 @@
import {
CeilingBeams,
CeilingRose,
CofferedCeiling,
CorniceRing,
FlutedShaft,
HangingFixture,
IonicCapital,
ShaftBase,
WallBand,
} from './primitives';
import {
shade,
useBayZ,
useCorners,
useRepeat,
useWallClearance,
type PeriodProps,
} from './geometry';
/**
* Flemish panel hall — Northern Renaissance.
*
* Oak linenfold wainscot under a moulded cap rail, carved corbels carrying an
* exposed beam-and-joist ceiling, and the brass ring chandelier that hangs in
* the background of half the period's interiors.
*/
export function FlemishDetails({ room, trim }: PeriodProps) {
const beams = useBayZ(room.depth, room.halfD, 2.3, 10);
const oak = '#6f4a2c';
const oakLight = shade(oak, 0.2);
const folds = useRepeat(room.depth, 0.55, 34);
const backFolds = useRepeat(room.width, 0.55, 34);
return (
<group>
{/* Wainscot ground, cap rail and skirting */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.66} height={1.24} proud={0.06} color={oak} roughness={0.66} metalness={0.08} />
<WallBand wall={wall} room={room} y={1.32} height={0.12} proud={0.1} color={oakLight} roughness={0.54} metalness={0.1} />
<WallBand wall={wall} room={room} y={0.08} height={0.16} proud={0.09} color={shade(oak, -0.3)} roughness={0.6} metalness={0.09} />
</group>
))}
{/* Linenfold ribs across the wainscot */}
{(['left', 'right'] as const).map((wall) =>
folds.map((z, i) => (
<mesh
key={`${wall}-${i}`}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.07), 0.68, z]}
>
<cylinderGeometry args={[0.035, 0.035, 1.18, 6]} />
<meshStandardMaterial color={oakLight} roughness={0.6} metalness={0.09} />
</mesh>
))
)}
{backFolds.map((x, i) => (
<mesh key={`b-${i}`} position={[x, 0.68, -room.halfD + 0.07]}>
<cylinderGeometry args={[0.035, 0.035, 1.18, 6]} />
<meshStandardMaterial color={oakLight} roughness={0.6} metalness={0.09} />
</mesh>
))}
{/* Carved corbels carrying the beams */}
{beams.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * (room.halfW - 0.11), 3.84, z]}>
<boxGeometry args={[0.2, 0.24, 0.2]} />
<meshStandardMaterial color={shade(oak, -0.12)} roughness={0.66} metalness={0.08} />
</mesh>
))}
</group>
))}
<CeilingBeams room={room} positions={beams} axis="x" width={0.18} height={0.26} y={3.98} color={shade(oak, -0.2)} roughness={0.72} metalness={0.06} />
<CorniceRing room={room} y={4.14} height={0.08} proud={0.12} color={oakLight} roughness={0.56} metalness={0.1} />
<HangingFixture
position={[0, 3.94, 0]}
dropLength={0.5}
radius={0.4}
arms={8}
metalColor={trim}
flameColor="#ffe0a0"
light
lightColor="#ffca80"
lightIntensity={0.85}
lightDistance={8}
/>
</group>
);
}
/**
* Mannerist gallery — Mannerism.
*
* The order used against itself: herm pilasters that taper the wrong way,
* banded rustication blocking the shafts, oversized scroll consoles, and a
* cornice deliberately broken over each bay.
*/
export function ManneristDetails({ room, trim, windows }: PeriodProps) {
const clearLeft = useWallClearance(windows, 'left');
const clearRight = useWallClearance(windows, 'right');
const bays = useBayZ(room.depth, room.halfD, 4.4, 7);
const stone = '#ddc9ae';
const dark = shade(stone, -0.34);
const bands = [0.55, 1.55, 2.35, 3.05];
return (
<group>
{/* Heavy blocked socle */}
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.5} height={1.0} proud={0.08} color={dark} roughness={0.82} metalness={0.05} />
))}
{/* Herm pilasters — wider at the shoulder than at the foot */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.05), 0, z]}>
<mesh position={[0, 1.9, 0]}>
<boxGeometry args={[0.09, 3.8, 0.3]} />
<meshStandardMaterial color={stone} roughness={0.78} metalness={0.05} />
</mesh>
{/* Rustication blocks interrupting the shaft */}
{bands.map((y) => (
<mesh key={y} position={[side * 0.03, y, 0]}>
<boxGeometry args={[0.14, 0.3, 0.44]} />
<meshStandardMaterial color={shade(stone, -0.16)} roughness={0.86} metalness={0.04} />
</mesh>
))}
{/* Scroll console instead of a capital */}
<mesh position={[0, 3.86, 0]} rotation={[0, Math.PI / 2, 0]}>
<torusGeometry args={[0.14, 0.055, 8, 16, Math.PI * 1.5]} />
<meshStandardMaterial color={shade(stone, 0.12)} roughness={0.66} metalness={0.08} />
</mesh>
<mesh position={[0, 4.03, 0]}>
<boxGeometry args={[0.16, 0.12, 0.46]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.24} />
</mesh>
</group>
))}
</group>
))}
{/* Broken cornice — segments that stop short of each bay */}
{(['left', 'right'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={3.96} height={0.12} proud={0.14} span={0.62} color={stone} roughness={0.72} metalness={0.06} />
<WallBand wall={wall} room={room} y={4.1} height={0.08} proud={0.17} span={0.86} color={trim} roughness={0.52} metalness={0.26} />
</group>
))}
<CorniceRing room={room} y={4.1} height={0.08} proud={0.15} walls={['back', 'front']} color={trim} roughness={0.52} metalness={0.26} />
{/* Cartouches slung between the bays, clear of the window openings */}
{bays.slice(0, -1).map((z, i) => {
const mid = (z + bays[i + 1]) / 2;
return (
<group key={i}>
{([-1, 1] as const)
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, 0.24))
.map((side) => (
<group key={side} position={[side * (room.halfW - 0.07), 3.6, mid]} rotation={[0, Math.PI / 2, 0]}>
<mesh>
<sphereGeometry args={[0.17, 12, 8]} />
<meshStandardMaterial color={shade(stone, 0.16)} roughness={0.66} metalness={0.08} />
</mesh>
<mesh position={[0, 0, 0.04]}>
<torusGeometry args={[0.19, 0.03, 6, 18]} />
<meshStandardMaterial color={trim} roughness={0.44} metalness={0.4} />
</mesh>
</group>
))}
</group>
);
})}
</group>
);
}
/**
* Baroque state gallery — Baroque.
*
* Twisted solomonic half-columns at the corners, gilt-framed velvet field
* panels between the bays, a modillion cornice, and a crystal-and-gilt
* chandelier over the centre of the room.
*/
export function BaroqueStateDetails({ room, trim, windows }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 4.2, 8);
const corners = useCorners(room.halfW, room.halfD, 0.16);
const clearLeft = useWallClearance(windows, 'left');
const clearRight = useWallClearance(windows, 'right');
const gilt = trim;
const modillions = useRepeat(room.depth, 0.86, 26);
return (
<group>
{/* Gilt-edged velvet field panels between the bays, clear of the windows */}
{bays.slice(0, -1).map((z, i) => {
const mid = (z + bays[i + 1]) / 2;
const w = Math.min(1.5, Math.max(0.7, (bays[i + 1] - z) * 0.4));
return (
<group key={i}>
{([-1, 1] as const)
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, w / 2 + 0.1))
.map((side) => (
<group key={side} position={[side * (room.halfW - 0.055), 3.2, mid]}>
<mesh>
<boxGeometry args={[0.03, 1.0, w]} />
<meshStandardMaterial color="#4a1220" roughness={0.9} metalness={0.04} />
</mesh>
<mesh position={[-side * 0.015, 0, 0]}>
<boxGeometry args={[0.04, 1.12, w + 0.12]} />
<meshStandardMaterial color={gilt} roughness={0.3} metalness={0.7} emissive="#3a2808" emissiveIntensity={0.06} />
</mesh>
</group>
))}
</group>
);
})}
{/* Solomonic corner columns — twist read as stacked, rotated drums */}
{corners.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<ShaftBase size={0.13} color="#2a1a12" />
{Array.from({ length: 14 }, (_, d) => (
<mesh key={d} position={[0, 0.34 + d * 0.24, 0]} rotation={[0, d * 0.42, 0]}>
<boxGeometry args={[0.2, 0.24, 0.13]} />
<meshStandardMaterial color={gilt} roughness={0.32} metalness={0.68} emissive="#2e2006" emissiveIntensity={0.05} />
</mesh>
))}
<mesh position={[0, 3.86, 0]}>
<cylinderGeometry args={[0.2, 0.13, 0.28, 10]} />
<meshStandardMaterial color={gilt} roughness={0.28} metalness={0.75} />
</mesh>
</group>
))}
{/* Modillion brackets under a heavy gilt cornice */}
{(['left', 'right'] as const).map((wall) =>
modillions.map((z, i) => (
<mesh key={`${wall}-${i}`} position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.17), 3.92, z]}>
<boxGeometry args={[0.18, 0.16, 0.12]} />
<meshStandardMaterial color={shade(gilt, 0.1)} roughness={0.34} metalness={0.62} />
</mesh>
))
)}
<CorniceRing room={room} y={4.06} height={0.18} proud={0.19} color={gilt} roughness={0.24} metalness={0.78} emissive="#3a2808" emissiveIntensity={0.08} />
<CeilingRose y={4.1} radius={1.15} color={gilt} rings={4} />
<HangingFixture
position={[0, 4.05, 0]}
dropLength={0.5}
radius={0.6}
arms={10}
metalColor={gilt}
flameColor="#fff0c0"
glowColor="#ffbe58"
light
lightColor="#ffd08a"
lightIntensity={1.0}
lightDistance={11}
/>
</group>
);
}
/**
* Rococo salon — Rococo.
*
* Boiserie panelling with rounded corner arcs, asymmetric rocaille cartouches,
* a deep painted cove instead of a cornice, and a light gilt lustre. Everything
* is kept thin and pale: the period's relief is shallow by design.
*/
export function RococoDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 3.4, 9);
const gilt = trim;
const boiserie = '#efe6ee';
return (
<group>
{/* Boiserie dado with a moulded chair rail */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.55} height={1.04} proud={0.045} color={boiserie} roughness={0.6} metalness={0.08} />
<WallBand wall={wall} room={room} y={1.12} height={0.07} proud={0.08} color={gilt} roughness={0.34} metalness={0.52} />
<WallBand wall={wall} room={room} y={0.07} height={0.14} proud={0.08} color={boiserie} roughness={0.62} metalness={0.07} />
</group>
))}
{/* Panel styles with quarter-round corners, and a rocaille cartouche above */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.06), 0, z]}>
<mesh position={[0, 2.05, 0]}>
<boxGeometry args={[0.04, 4.0, 0.07]} />
<meshStandardMaterial color={gilt} roughness={0.36} metalness={0.5} />
</mesh>
<group position={[0, 3.5, 0]} rotation={[0, Math.PI / 2, 0]}>
<mesh>
<torusGeometry args={[0.2, 0.028, 6, 18, Math.PI * 1.35]} />
<meshStandardMaterial color={gilt} roughness={0.3} metalness={0.6} />
</mesh>
<mesh position={[0.1, -0.14, 0]} rotation={[0, 0, 0.7]}>
<torusGeometry args={[0.12, 0.022, 6, 14, Math.PI * 1.1]} />
<meshStandardMaterial color={gilt} roughness={0.3} metalness={0.6} />
</mesh>
</group>
</group>
))}
</group>
))}
{/* Painted cove: two stacked, slightly stepped bands reading as a curve */}
<CorniceRing room={room} y={3.86} height={0.16} proud={0.08} color={shade(boiserie, 0.04)} roughness={0.72} metalness={0.05} />
<CorniceRing room={room} y={4.0} height={0.16} proud={0.14} color={boiserie} roughness={0.7} metalness={0.05} />
<CorniceRing room={room} y={4.13} height={0.07} proud={0.17} color={gilt} roughness={0.3} metalness={0.6} />
<CeilingRose y={4.09} radius={0.9} color={gilt} rings={3} />
<HangingFixture
position={[0, 4.02, 0]}
dropLength={0.42}
radius={0.42}
arms={8}
metalColor={gilt}
flameColor="#fff4dc"
glowColor="#ffd58c"
light
lightColor="#ffe6c0"
lightIntensity={0.8}
lightDistance={10}
/>
</group>
);
}
/**
* Neoclassical museum — Neoclassicism.
*
* Schinkel's museum grammar: fluted Ionic pilasters on a wide bay, a Greek-key
* meander frieze, a dentilled cornice, and a plain coffered soffit around the
* lantern. Deliberately cool and flat next to the Baroque hall.
*/
export function NeoclassicalDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 4.8, 6);
const stone = '#f1eee8';
const shadowStone = shade(stone, -0.14);
const meander = useRepeat(room.depth, 0.72, 24);
return (
<group>
{/* Plinth course under the hang */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.36} height={0.72} proud={0.05} color={shadowStone} roughness={0.72} metalness={0.08} />
<WallBand wall={wall} room={room} y={0.75} height={0.08} proud={0.08} color={stone} roughness={0.62} metalness={0.1} />
</group>
))}
{/* Fluted Ionic pilasters */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.13), 0, z]}>
<ShaftBase size={0.13} color={shadowStone} />
<group position={[0, 0.26, 0]}>
<FlutedShaft height={3.3} radius={0.12} color={stone} flutes={10} facing="x" />
</group>
<IonicCapital y={3.68} size={0.15} color={stone} axis="z" />
</group>
))}
</group>
))}
{/* Meander frieze — alternating key blocks read as a running fret */}
{(['left', 'right'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={3.9} height={0.24} proud={0.1} color={shade(stone, 0.03)} roughness={0.66} metalness={0.09} />
{meander.map((z, i) => (
<group key={i} position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.14), 3.9, z]}>
<mesh position={[0, i % 2 === 0 ? 0.06 : -0.06, 0]}>
<boxGeometry args={[0.06, 0.05, 0.34]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.28} />
</mesh>
<mesh position={[0, 0, 0.15]}>
<boxGeometry args={[0.06, 0.16, 0.05]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.28} />
</mesh>
</group>
))}
</group>
))}
<CorniceRing room={room} y={4.07} height={0.1} proud={0.16} color={stone} roughness={0.6} metalness={0.1} />
<CorniceRing room={room} y={4.15} height={0.06} proud={0.19} color={trim} roughness={0.48} metalness={0.24} />
<CofferedCeiling
room={room}
y={4.06}
cell={2.9}
panelColor="#f7f6f2"
ribColor="#e2ded4"
ribWidth={0.18}
ribDrop={0.13}
/>
</group>
);
}
@@ -0,0 +1,109 @@
import { useMemo } from 'react';
import type { GalleryWindowSpec } from '../../data/movement-interior-styles';
/**
* Shared geometry budget for every period interior.
*
* Movement halls hang frames 0.23 m proud of the inner wall face
* (`MOUNT_OFFSET` + `WALL_STANDOFF`), centred at eye height with tops around
* 2.4 m, and each wall keeps a 0.7 m clear margin at both ends
* (`WALL_PADDING` in `movementHallLayout.ts`). Period architecture therefore
* has to stay inside these limits, exactly as the Gothic nave and Byzantine
* basilica do:
*
* - relief below the frame line: at most 0.11 m proud of the wall face
* - friezes and cornices: 2.6 m and above, at most 0.17 m proud
* - freestanding masses: only inside the 0.34 m corner pocket at a corner
* - doorways: nothing below 2.6 m within 1.45 m of a wall centre
* - the end wall carries the hall title from 3.4 m to 3.9 m — keep it clear
* - side-wall windows are placed at runtime between 2.4 m and 3.9 m, so
* anything wider than a mullion up there goes through `useWallClearance`
* - added lights: at most two per hall, so hall materials stay inside the
* WebGL light limits the shared track lighting already budgets for
*/
/** Half of WALL_THICKNESS — distance from a wall's centre plane to its inner face. */
export const WALL_INNER = 0.09;
/** Highest a detail can sit before it breaks the ceiling plane at 4.2 m. */
export const CEILING_Y = 4.13;
/** Sink bands this far into the wall so they never z-fight with the surface. */
export const EMBED = 0.02;
export type WallId = 'back' | 'front' | 'left' | 'right';
export interface RoomDims {
width: number;
depth: number;
halfW: number;
halfD: number;
}
/** Every period module takes the same three inputs. */
export interface PeriodProps {
room: RoomDims;
/** The movement's trim colour, already blended with its accent. */
trim: string;
/** Windows the hall placed at runtime, so upper-wall relief can avoid them. */
windows?: GalleryWindowSpec[];
}
/** Lighten (amount > 0) or darken (amount < 0) a hex colour. */
export function shade(hex: string, amount: number): string {
const n = parseInt(hex.replace('#', ''), 16);
const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((c) =>
Math.max(0, Math.min(255, Math.round(amount >= 0 ? c + (255 - c) * amount : c * (1 + amount))))
);
return `#${((ch[0] << 16) | (ch[1] << 8) | ch[2]).toString(16).padStart(6, '0')}`;
}
/** Bay rhythm along the depth axis — the spacing that drives piers and beams. */
export function useBayZ(depth: number, halfD: number, spacing = 4.2, max = 9): number[] {
return useMemo(() => {
const count = Math.max(2, Math.min(max, Math.floor(depth / spacing)));
const step = depth / (count + 1);
return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1));
}, [depth, halfD, spacing, max]);
}
/**
* Evenly spaced positions centred on a wall, for the repeating small members —
* dentils, linenfold ribs, modillions, stencil motifs, quatrefoils.
*/
export function useRepeat(span: number, step: number, max: number): number[] {
return useMemo(() => {
const count = Math.min(max, Math.max(3, Math.floor((span - 0.6) / step)));
const run = (count - 1) * step;
return Array.from({ length: count }, (_, i) => -run / 2 + i * step);
}, [span, step, max]);
}
/**
* Side-wall windows are placed at runtime in whatever gaps the hang leaves, so
* any panel, arch or roundel a period module paints across the upper wall has
* to step around them. Returns a predicate: true when the span is clear.
*/
export function useWallClearance(
windows: GalleryWindowSpec[] | undefined,
wall: 'left' | 'right' | 'back'
) {
return useMemo(() => {
const spans = (windows ?? [])
.filter((w) => w.wall === wall)
.map((w) => [w.x - w.width / 2 - 0.18, w.x + w.width / 2 + 0.18] as [number, number]);
return (pos: number, halfSpan = 0.2) =>
!spans.some(([a, b]) => pos + halfSpan > a && pos - halfSpan < b);
}, [windows, wall]);
}
/** The four corner columns of the room, each inside its hang-margin pocket. */
export function useCorners(halfW: number, halfD: number, inset = 0.08): [number, number][] {
return useMemo(
() =>
[
[-halfW + inset, -halfD + inset],
[halfW - inset, -halfD + inset],
[-halfW + inset, halfD - inset],
[halfW - inset, halfD - inset],
] as [number, number][],
[halfW, halfD, inset]
);
}
@@ -0,0 +1,717 @@
import { useMemo } from 'react';
import {
CeilingBeams,
CorniceRing,
HangingFixture,
SideRails,
WallBand,
} from './primitives';
import {
shade,
useBayZ,
useCorners,
useRepeat,
type PeriodProps,
} from './geometry';
/**
* Fauvist studio — Fauvism.
*
* The Collioure summer room: close-set whitewashed rafters, plank shutters
* folded back against the wall beside every window, and a plain painted rail.
* The architecture stays vernacular so the wall colour does the work.
*/
export function FauvistDetails({ room, trim, windows }: PeriodProps) {
const rafters = useBayZ(room.depth, room.halfD, 1.5, 14);
const timber = '#d8c8a8';
const shutters = useMemo(
() =>
(windows ?? [])
.filter((w) => w.wall === 'left' || w.wall === 'right')
.flatMap((w) =>
[-1, 1].map((dir) => ({
wall: w.wall as 'left' | 'right',
z: w.x + dir * (w.width / 2 + w.width * 0.28),
y: w.y,
h: w.height,
w: w.width * 0.5,
}))
),
[windows]
);
return (
<group>
<CeilingBeams
room={room}
positions={rafters}
axis="x"
width={0.1}
height={0.16}
y={4.02}
color={timber}
roughness={0.88}
metalness={0.02}
/>
<CeilingBeams
room={room}
positions={[0]}
axis="z"
width={0.2}
height={0.24}
y={3.86}
color={shade(timber, -0.24)}
roughness={0.88}
metalness={0.02}
/>
{/* Folded-back plank shutters flanking the windows */}
{shutters.map((s, i) => (
<group
key={i}
position={[(s.wall === 'left' ? -1 : 1) * (room.halfW - 0.13), s.y, s.z]}
>
<mesh>
<boxGeometry args={[0.06, s.h, s.w]} />
<meshStandardMaterial color={trim} roughness={0.8} metalness={0.04} />
</mesh>
{[-0.3, 0, 0.3].map((t) => (
<mesh key={t} position={[0.04, s.h * t, 0]}>
<boxGeometry args={[0.02, s.h * 0.22, s.w * 0.86]} />
<meshStandardMaterial color={shade(trim, -0.22)} roughness={0.84} metalness={0.03} />
</mesh>
))}
</group>
))}
<SideRails room={room} y={2.6} height={0.08} proud={0.11} color={timber} roughness={0.82} metalness={0.03} />
<WallBand wall="back" room={room} y={2.6} height={0.08} proud={0.11} color={timber} roughness={0.82} metalness={0.03} />
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.12} height={0.24} proud={0.07} color={trim} roughness={0.8} metalness={0.04} />
))}
</group>
);
}
/**
* Expressionist room — Expressionism.
*
* Die Brücke's carved-plank interior pushed towards the raked sets of the
* period's cinema: battens canted at alternating angles above the hang, a
* zig-zag frieze, and ceiling members that refuse to run square.
*/
export function ExpressionistDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 2.2, 11);
const plank = '#5a3f27';
const light = shade(plank, 0.3);
return (
<group>
{/* Canted battens, alternating lean */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<mesh
key={side}
position={[side * (room.halfW - 0.07), 3.28, z]}
rotation={[i % 2 === 0 ? 0.3 : -0.3, 0, 0]}
>
<boxGeometry args={[0.08, 1.5, 0.17]} />
<meshStandardMaterial color={i % 2 === 0 ? plank : light} roughness={0.84} metalness={0.04} />
</mesh>
))}
</group>
))}
{/* Zig-zag frieze under the ceiling */}
{(['left', 'right'] as const).map((wall) =>
bays.map((z, i) => (
<mesh
key={`${wall}-${i}`}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.08), 4.06, z]}
rotation={[i % 2 === 0 ? 0.62 : -0.62, 0, 0]}
>
<boxGeometry args={[0.1, 0.42, 0.1]} />
<meshStandardMaterial color={trim} roughness={0.7} metalness={0.1} />
</mesh>
))
)}
{/* Ceiling members deliberately off-square */}
{bays
.filter((_, i) => i % 3 === 0)
.map((z, i) => (
<mesh key={i} position={[0, 3.98, z]} rotation={[0, i % 2 === 0 ? 0.14 : -0.14, 0]}>
<boxGeometry args={[room.width * 0.97, 0.2, 0.15]} />
<meshStandardMaterial color={shade(plank, -0.24)} roughness={0.88} metalness={0.03} />
</mesh>
))}
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.13} height={0.26} proud={0.09} color={shade(plank, -0.3)} roughness={0.86} metalness={0.03} />
))}
</group>
);
}
/**
* Cubist studio — Cubism.
*
* The Bateau-Lavoir atelier: slim cast-iron columns in the corners, riveted tie
* rods across the room, and a cornice broken into faceted plaster planes — the
* building itself analysed into flat facets.
*/
export function CubistAtelierDetails({ room, trim }: PeriodProps) {
const corners = useCorners(room.halfW, room.halfD, 0.24);
const ties = useBayZ(room.depth, room.halfD, 3.6, 8);
const iron = '#4d4a45';
const facet = '#dedad2';
const facets = useRepeat(room.depth, 1.15, 22);
return (
<group>
{/* Cast-iron columns in the corner pockets */}
{corners.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 0.1, 0]}>
<boxGeometry args={[0.3, 0.2, 0.3]} />
<meshStandardMaterial color={shade(iron, -0.2)} roughness={0.6} metalness={0.45} />
</mesh>
<mesh position={[0, 2.1, 0]}>
<cylinderGeometry args={[0.09, 0.11, 3.8, 10]} />
<meshStandardMaterial color={iron} roughness={0.52} metalness={0.55} />
</mesh>
<mesh position={[0, 4.02, 0]}>
<boxGeometry args={[0.34, 0.18, 0.34]} />
<meshStandardMaterial color={iron} roughness={0.5} metalness={0.58} />
</mesh>
</group>
))}
{/* Riveted tie rods spanning the room, with a turnbuckle at mid-span */}
{ties.map((z, i) => (
<group key={i} position={[0, 3.8, z]}>
<mesh rotation={[0, 0, Math.PI / 2]}>
<cylinderGeometry args={[0.035, 0.035, room.width - 0.2, 8]} />
<meshStandardMaterial color={iron} roughness={0.46} metalness={0.62} />
</mesh>
<mesh rotation={[0, 0, Math.PI / 2]}>
<cylinderGeometry args={[0.06, 0.06, 0.3, 8]} />
<meshStandardMaterial color={shade(iron, -0.2)} roughness={0.4} metalness={0.7} />
</mesh>
</group>
))}
{/* Faceted cornice: flat planes tilted alternately */}
{(['left', 'right'] as const).map((wall) =>
facets.map((z, i) => (
<mesh
key={`${wall}-${i}`}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.1), 4.02, z]}
rotation={[0, 0, i % 2 === 0 ? 0.34 : -0.34]}
>
<boxGeometry args={[0.2, 0.22, 0.86]} />
<meshStandardMaterial
color={i % 3 === 0 ? shade(facet, -0.14) : facet}
roughness={0.82}
metalness={0.05}
/>
</mesh>
))
)}
<CorniceRing room={room} y={4.15} height={0.08} proud={0.13} color={trim} roughness={0.6} metalness={0.2} />
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.1} height={0.2} proud={0.07} color={shade(iron, 0.24)} roughness={0.7} metalness={0.16} />
))}
</group>
);
}
/**
* Futurist loft — Futurism.
*
* Sant'Elia's Città Nuova reduced to one room: riveted steel stanchions,
* diagonal wind bracing across the upper wall, and a gantry rail running the
* length of the hall. Structure celebrated, nothing concealed.
*/
export function FuturistDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 4.0, 8);
const steel = '#8f9298';
const dark = shade(steel, -0.42);
return (
<group>
{/* Riveted stanchions */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.06), 0, z]}>
<mesh position={[0, 2.05, 0]}>
<boxGeometry args={[0.09, 4.1, 0.36]} />
<meshStandardMaterial color={steel} roughness={0.42} metalness={0.72} />
</mesh>
{[0.6, 1.7, 2.8, 3.8].map((y) => (
<mesh key={y} position={[side * -0.03, y, 0]}>
<boxGeometry args={[0.06, 0.1, 0.44]} />
<meshStandardMaterial color={dark} roughness={0.38} metalness={0.8} />
</mesh>
))}
</group>
))}
</group>
))}
{/* Diagonal wind bracing between the stanchions, above the hang */}
{bays.slice(0, -1).map((z, i) => {
const mid = (z + bays[i + 1]) / 2;
const run = bays[i + 1] - z;
return (
<group key={i}>
{[-1, 1].map((side) =>
[-1, 1].map((dir) => (
<mesh
key={`${side}-${dir}`}
position={[side * (room.halfW - 0.1), 3.4, mid]}
rotation={[dir * Math.atan2(run - 0.4, 0.9), 0, 0]}
>
<boxGeometry args={[0.06, Math.hypot(run - 0.4, 0.9), 0.06]} />
<meshStandardMaterial color={steel} roughness={0.4} metalness={0.75} />
</mesh>
))
)}
</group>
);
})}
{/* Gantry rail and its brackets */}
<SideRails room={room} y={2.72} height={0.12} proud={0.15} color={dark} roughness={0.36} metalness={0.82} />
<CorniceRing room={room} y={4.13} height={0.1} proud={0.16} color={trim} roughness={0.34} metalness={0.7} />
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.11} height={0.22} proud={0.09} color={dark} roughness={0.4} metalness={0.7} />
))}
</group>
);
}
/**
* Suprematist white cube — Suprematism.
*
* The 0,10 room: a bare white shell, a shelf slung across the upper corner
* where Malevich hung the Black Square in the icon position, and thin black and
* red geometric bands drifting off the axis of the walls.
*/
export function SuprematistDetails({ room, trim }: PeriodProps) {
const bars = useMemo(() => {
const seeds = [-0.62, -0.24, 0.18, 0.55];
return seeds.map((t, i) => ({
z: t,
y: 3.0 + (i % 3) * 0.28,
len: 0.9 + (i % 2) * 0.7,
tilt: i % 2 === 0 ? 0.22 : -0.34,
color: i % 3 === 0 ? '#c8302a' : '#141414',
}));
}, []);
return (
<group>
{/* Icon-corner shelf, carried on two plain brackets */}
<group position={[room.halfW - 0.46, 3.3, -room.halfD + 0.46]} rotation={[0, Math.PI / 4, 0]}>
<mesh>
<boxGeometry args={[1.16, 0.05, 0.34]} />
<meshStandardMaterial color="#f2f2f2" roughness={0.72} metalness={0.04} />
</mesh>
{[-0.4, 0.4].map((t) => (
<mesh key={t} position={[t, -0.16, 0.02]} rotation={[0, 0, 0]}>
<boxGeometry args={[0.05, 0.28, 0.24]} />
<meshStandardMaterial color="#e2e2e2" roughness={0.74} metalness={0.04} />
</mesh>
))}
</group>
{/* Drifting bands above the hang */}
{(['left', 'right'] as const).map((wall) =>
bars.map((b, i) => (
<mesh
key={`${wall}-${i}`}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.07), b.y, b.z * room.halfD]}
rotation={[b.tilt, 0, 0]}
>
<boxGeometry args={[0.04, 0.14, b.len]} />
<meshStandardMaterial color={b.color} roughness={0.66} metalness={0.06} />
</mesh>
))
)}
{/* Shadow-gap reveal instead of a cornice */}
<CorniceRing room={room} y={4.04} height={0.05} proud={0.03} color="#d2d2d2" roughness={0.8} metalness={0.04} />
<CorniceRing room={room} y={4.16} height={0.06} proud={0.1} color={trim} roughness={0.7} metalness={0.08} />
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.07} height={0.14} proud={0.06} color="#dcdcdc" roughness={0.76} metalness={0.05} />
))}
</group>
);
}
/**
* Constructivist space — Constructivism.
*
* Rodchenko's lattice made architecture: red-painted diagonal trusses under the
* ceiling, angled Proun panels folded into the corners, and exposed steel ties.
* Every member is doing structural work and says so.
*/
export function ConstructivistDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 2.8, 10);
const corners = useCorners(room.halfW, room.halfD, 0.3);
const red = '#b8342a';
const steel = '#9a9a96';
return (
<group>
{/* Lattice truss under the ceiling — top chord, bottom chord, diagonals */}
<CeilingBeams room={room} positions={[-0.9, 0.9]} axis="z" width={0.1} height={0.12} y={4.06} color={steel} roughness={0.44} metalness={0.6} />
<CeilingBeams room={room} positions={[-0.9, 0.9]} axis="z" width={0.09} height={0.1} y={3.66} color={steel} roughness={0.44} metalness={0.6} />
{bays.map((z, i) =>
[-0.9, 0.9].map((x) => (
<mesh key={`${i}-${x}`} position={[x, 3.86, z]} rotation={[i % 2 === 0 ? 0.62 : -0.62, 0, 0]}>
<boxGeometry args={[0.07, 0.62, 0.07]} />
<meshStandardMaterial color={red} roughness={0.56} metalness={0.3} />
</mesh>
))
)}
{/* Proun panels folded into the corners */}
{corners.map(([x, z], i) => (
<group key={i} position={[x, 2.9, z]} rotation={[0, Math.PI / 4 + (i % 2) * 0.2, 0]}>
<mesh rotation={[0, 0, 0.18]}>
<boxGeometry args={[0.66, 1.5, 0.05]} />
<meshStandardMaterial color={i % 2 === 0 ? red : '#141414'} roughness={0.66} metalness={0.12} />
</mesh>
<mesh position={[0.1, -0.3, 0.05]} rotation={[0, 0, -0.5]}>
<boxGeometry args={[0.5, 0.14, 0.04]} />
<meshStandardMaterial color={steel} roughness={0.42} metalness={0.6} />
</mesh>
</group>
))}
{/* Steel tie rails at the hang line and the skirting */}
<SideRails room={room} y={2.64} height={0.07} proud={0.12} color={steel} roughness={0.4} metalness={0.68} />
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.1} height={0.2} proud={0.08} color={red} roughness={0.62} metalness={0.2} />
))}
<CorniceRing room={room} y={4.16} height={0.06} proud={0.11} color={trim} roughness={0.5} metalness={0.4} />
</group>
);
}
/**
* Dada salon — Dada.
*
* A bourgeois room being dismantled: cornice fragments that stop and restart at
* different heights, a picture rail hung visibly out of level, and a service
* pipe left running across the wall where the moulding should be.
*/
export function DadaDetails({ room, trim }: PeriodProps) {
const fragments = useMemo(
() =>
[
{ wall: 'left' as const, y: 4.04, span: 0.3, proud: 0.14 },
{ wall: 'left' as const, y: 3.78, span: 0.18, proud: 0.1 },
{ wall: 'right' as const, y: 4.12, span: 0.44, proud: 0.16 },
{ wall: 'right' as const, y: 3.9, span: 0.22, proud: 0.09 },
{ wall: 'back' as const, y: 4.06, span: 0.35, proud: 0.13 },
],
[]
);
const plaster = '#e6dac4';
const pipe = '#6a6259';
return (
<group>
{/* Cornice, in pieces */}
{fragments.map((f, i) => (
<WallBand
key={i}
wall={f.wall}
room={room}
y={f.y}
height={0.13}
proud={f.proud}
span={f.span}
color={i % 2 === 0 ? plaster : shade(plaster, -0.18)}
roughness={0.76}
metalness={0.05}
/>
))}
{/* Picture rail, visibly out of level */}
{[-1, 1].map((side) => (
<mesh key={side} position={[side * (room.halfW - 0.07), 2.62, 0]} rotation={[side * 0.035, 0, 0]}>
<boxGeometry args={[0.1, 0.08, room.depth * 0.86]} />
<meshStandardMaterial color={trim} roughness={0.6} metalness={0.18} />
</mesh>
))}
{/* Service pipe run, with a clumsy elbow into the ceiling */}
<mesh position={[-room.halfW + 0.16, 3.34, 0]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.06, 0.06, room.depth * 0.8, 10]} />
<meshStandardMaterial color={pipe} roughness={0.64} metalness={0.4} />
</mesh>
<mesh position={[-room.halfW + 0.16, 3.7, room.depth * 0.4]}>
<cylinderGeometry args={[0.06, 0.06, 0.75, 10]} />
<meshStandardMaterial color={pipe} roughness={0.64} metalness={0.4} />
</mesh>
{/* Skirting that changes its mind halfway along */}
<WallBand wall="left" room={room} y={0.11} height={0.22} proud={0.08} span={0.55} color={plaster} roughness={0.78} metalness={0.04} />
<WallBand wall="right" room={room} y={0.15} height={0.3} proud={0.1} span={0.8} color={shade(plaster, -0.26)} roughness={0.78} metalness={0.04} />
<WallBand wall="back" room={room} y={0.11} height={0.22} proud={0.08} color={plaster} roughness={0.78} metalness={0.04} />
<HangingFixture
position={[room.halfW * 0.32, 3.9, room.halfD * 0.2]}
dropLength={0.7}
radius={0.2}
arms={3}
metalColor={trim}
flameColor="#fff0cc"
glowColor="#ffb45a"
light
lightColor="#ffd0a0"
lightIntensity={0.7}
lightDistance={8}
/>
</group>
);
}
/**
* Surrealist interior — Surrealism.
*
* The 1938 Exposition Internationale: sacks slung under the ceiling, drapery
* pulled across the cornice, and a door standing in a corner where no door can
* lead anywhere. The room is furnished as an image, not as a gallery.
*/
export function SurrealistDetails({ room, trim }: PeriodProps) {
const sacks = useBayZ(room.depth, room.halfD, 2.6, 9);
return (
<group>
{/* Sacks hanging under the ceiling */}
{sacks.map((z, i) =>
[-1, 0, 1].map((c) => (
<mesh
key={`${i}-${c}`}
position={[c * room.width * 0.26, 3.86 - ((i + c) % 3) * 0.09, z]}
rotation={[0, ((i + c) % 4) * 0.4, 0.1 * ((i % 2) * 2 - 1)]}
>
<sphereGeometry args={[0.34, 10, 8]} />
<meshStandardMaterial color={i % 2 === 0 ? '#3a3128' : '#2c261f'} roughness={0.96} metalness={0.02} />
</mesh>
))
)}
{/* Drapery gathered along the top of the side walls */}
{(['left', 'right'] as const).map((wall) =>
sacks.map((z, i) => (
<mesh
key={`${wall}-${i}`}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.13), 3.7, z]}
rotation={[0, 0, 0]}
>
<cylinderGeometry args={[0.13, 0.09, 0.86, 8]} />
<meshStandardMaterial color="#38304a" roughness={0.92} metalness={0.03} />
</mesh>
))
)}
{/* A door that leads nowhere, standing in the corner pocket */}
<group position={[-room.halfW + 0.34, 0, -room.halfD + 0.34]} rotation={[0, Math.PI / 4, 0]}>
<mesh position={[0, 1.05, 0]}>
<boxGeometry args={[0.72, 2.1, 0.07]} />
<meshStandardMaterial color="#4a3a2a" roughness={0.78} metalness={0.06} />
</mesh>
<mesh position={[0, 1.12, 0]}>
<boxGeometry args={[0.84, 2.24, 0.05]} />
<meshStandardMaterial color={shade('#4a3a2a', -0.3)} roughness={0.8} metalness={0.05} />
</mesh>
<mesh position={[0.26, 1.02, 0.06]}>
<sphereGeometry args={[0.05, 10, 10]} />
<meshStandardMaterial color={trim} roughness={0.34} metalness={0.66} />
</mesh>
</group>
<CorniceRing room={room} y={4.14} height={0.09} proud={0.12} color={trim} roughness={0.62} metalness={0.2} />
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.11} height={0.22} proud={0.08} color="#2e2822" roughness={0.82} metalness={0.05} />
))}
{/* A brazier glow in the opposite corner */}
<group position={[room.halfW - 0.44, 0, room.halfD - 0.44]}>
<mesh position={[0, 0.34, 0]}>
<cylinderGeometry args={[0.22, 0.15, 0.28, 10]} />
<meshStandardMaterial color="#2a2622" roughness={0.7} metalness={0.35} />
</mesh>
<mesh position={[0, 0.5, 0]}>
<sphereGeometry args={[0.16, 10, 8]} />
<meshStandardMaterial color="#ff9a40" emissive="#ff6a12" emissiveIntensity={1.1} toneMapped={false} />
</mesh>
<pointLight position={[0, 0.7, 0]} intensity={0.8} distance={7} color="#ff8a3c" />
</group>
</group>
);
}
/**
* NYC loft — Abstract Expressionism.
*
* A Tenth Street cast-iron building: fluted iron columns on their corbelled
* caps, a pressed-tin ceiling field, and the sprinkler main running the length
* of the room. Industrial fabric left exactly as the painters found it.
*/
export function LoftDetails({ room, trim }: PeriodProps) {
const corners = useCorners(room.halfW, room.halfD, 0.26);
const joists = useBayZ(room.depth, room.halfD, 1.7, 13);
const iron = '#5c5852';
return (
<group>
{/* Cast-iron columns with corbelled caps */}
{corners.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 0.12, 0]}>
<cylinderGeometry args={[0.2, 0.24, 0.24, 12]} />
<meshStandardMaterial color={shade(iron, -0.2)} roughness={0.62} metalness={0.42} />
</mesh>
<mesh position={[0, 2.05, 0]}>
<cylinderGeometry args={[0.11, 0.13, 3.6, 12]} />
<meshStandardMaterial color={iron} roughness={0.56} metalness={0.48} />
</mesh>
<mesh position={[0, 3.92, 0]}>
<cylinderGeometry args={[0.22, 0.13, 0.2, 12]} />
<meshStandardMaterial color={iron} roughness={0.54} metalness={0.5} />
</mesh>
<mesh position={[0, 4.06, 0]}>
<boxGeometry args={[0.4, 0.14, 0.4]} />
<meshStandardMaterial color={shade(iron, 0.1)} roughness={0.54} metalness={0.5} />
</mesh>
</group>
))}
{/* Pressed-tin ceiling field on close-set joists */}
<CeilingBeams
room={room}
positions={joists}
axis="x"
width={0.08}
height={0.09}
y={4.05}
color="#b4b0a6"
roughness={0.48}
metalness={0.42}
/>
<CeilingBeams
room={room}
positions={[-room.width * 0.24, 0, room.width * 0.24]}
axis="z"
width={0.14}
height={0.16}
y={3.96}
color={iron}
roughness={0.6}
metalness={0.4}
/>
{/* Sprinkler main with drops */}
<mesh position={[0, 3.78, 0]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.055, 0.055, room.depth * 0.92, 10]} />
<meshStandardMaterial color="#7a3f2c" roughness={0.66} metalness={0.4} />
</mesh>
{joists
.filter((_, i) => i % 4 === 0)
.map((z, i) => (
<mesh key={i} position={[0, 3.68, z]}>
<cylinderGeometry args={[0.02, 0.025, 0.16, 6]} />
<meshStandardMaterial color="#8a4a34" roughness={0.6} metalness={0.45} />
</mesh>
))}
{/* Steel skirting angle */}
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.09} height={0.18} proud={0.08} color={trim} roughness={0.56} metalness={0.44} />
))}
</group>
);
}
/**
* Pop Art gallery — Pop Art.
*
* The 1960s dealer's room: a flat white shell, recessed fluorescent troffers in
* a ceiling grid, an aluminium reveal at the floor, and a shadow gap where a
* cornice used to be. The architecture is engineered to disappear.
*/
export function WhiteCubeDetails({ room, trim }: PeriodProps) {
const troffers = useMemo(() => {
const cols = Math.max(2, Math.min(3, Math.round(room.width / 4.5)));
const rows = Math.max(2, Math.min(6, Math.round(room.depth / 3.4)));
const out: [number, number][] = [];
for (let c = 0; c < cols; c++) {
for (let r = 0; r < rows; r++) {
out.push([
-room.halfW + (room.width / (cols + 1)) * (c + 1),
-room.halfD + (room.depth / (rows + 1)) * (r + 1),
]);
}
}
return out;
}, [room.width, room.depth, room.halfW, room.halfD]);
return (
<group>
{/* Recessed fluorescent troffers */}
{troffers.map(([x, z], i) => (
<group key={i} position={[x, 4.08, z]}>
<mesh>
<boxGeometry args={[1.24, 0.06, 0.62]} />
<meshStandardMaterial color="#c8c8c8" roughness={0.4} metalness={0.5} />
</mesh>
<mesh position={[0, -0.04, 0]}>
<boxGeometry args={[1.1, 0.03, 0.5]} />
<meshStandardMaterial
color="#ffffff"
emissive="#f4f8ff"
emissiveIntensity={0.85}
toneMapped={false}
/>
</mesh>
</group>
))}
{/* Ceiling grid tees between the fittings */}
<CeilingBeams
room={room}
positions={troffers.map(([, z]) => z).filter((z, i, a) => a.indexOf(z) === i)}
axis="x"
width={0.05}
height={0.04}
y={4.1}
color="#d6d6d6"
roughness={0.5}
metalness={0.36}
/>
{/* Shadow gap and aluminium skirting reveal */}
<CorniceRing room={room} y={4.02} height={0.06} proud={0.02} color="#c0c0c0" roughness={0.66} metalness={0.16} />
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.06} height={0.12} proud={0.05} color={trim} roughness={0.42} metalness={0.5} />
<WallBand wall={wall} room={room} y={0.14} height={0.03} proud={0.02} color="#9a9a9a" roughness={0.6} metalness={0.3} />
</group>
))}
</group>
);
}
@@ -0,0 +1,507 @@
import {
CeilingBeams,
CeilingRose,
CorniceRing,
HangingFixture,
SideRails,
WallBand,
WallSconce,
} from './primitives';
import {
shade,
useBayZ,
useRepeat,
useWallClearance,
type PeriodProps,
} from './geometry';
/**
* Romantic gallery — Romanticism (Gothic Revival).
*
* Pugin's revival vocabulary rather than a real nave: dark walnut linenfold
* panelling, a cusped blind arcade of pointed arches, a quatrefoil frieze, and
* gasoliers. The pointed arch here is applied ornament, which is exactly what
* separates the revival from the Gothic hall it quotes.
*/
export function GothicRevivalDetails({ room, trim, windows }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 3.8, 8);
const clearLeft = useWallClearance(windows, 'left');
const clearRight = useWallClearance(windows, 'right');
const walnut = '#4c3323';
const walnutLight = shade(walnut, 0.22);
const quatrefoils = useRepeat(room.depth, 0.95, 24);
return (
<group>
{/* Panelled dado with a heavy moulded rail */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.62} height={1.2} proud={0.06} color={walnut} roughness={0.68} metalness={0.08} />
<WallBand wall={wall} room={room} y={1.28} height={0.13} proud={0.11} color={walnutLight} roughness={0.52} metalness={0.12} />
<WallBand wall={wall} room={room} y={0.09} height={0.18} proud={0.1} color={shade(walnut, -0.35)} roughness={0.62} metalness={0.08} />
</group>
))}
{/* Colonnettes between the bays, carrying an applied pointed arcade */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.06), 0, z]}>
<mesh position={[0, 2.0, 0]}>
<cylinderGeometry args={[0.055, 0.065, 3.9, 8]} />
<meshStandardMaterial color={walnutLight} roughness={0.6} metalness={0.1} />
</mesh>
<mesh position={[0, 3.98, 0]}>
<boxGeometry args={[0.13, 0.16, 0.22]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.2} />
</mesh>
</group>
))}
</group>
))}
{/* Cusped pointed arches over each bay, stepping around the windows */}
{bays.slice(0, -1).map((z, i) => {
const mid = (z + bays[i + 1]) / 2;
// Capped so the raking limbs stay under the ceiling in deep halls.
const half = Math.min(1.1, (bays[i + 1] - z) / 2 - 0.1);
return (
<group key={i}>
{([-1, 1] as const)
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, half))
.map((side) => (
<group key={side} position={[side * (room.halfW - 0.06), 0, mid]}>
{[-1, 1].map((dir) => (
<mesh
key={dir}
position={[0, 3.62, (dir * half) / 2]}
rotation={[dir * 0.42, 0, 0]}
>
<boxGeometry args={[0.09, half * 1.12, 0.09]} />
<meshStandardMaterial color={walnutLight} roughness={0.58} metalness={0.1} />
</mesh>
))}
<mesh position={[0, 3.9, 0]}>
<sphereGeometry args={[0.07, 8, 8]} />
<meshStandardMaterial color={trim} roughness={0.44} metalness={0.28} />
</mesh>
</group>
))}
</group>
);
})}
{/* Quatrefoil frieze under the cornice */}
{(['left', 'right'] as const).map((wall) =>
quatrefoils.map((z, i) => (
<group
key={`${wall}-${i}`}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.1), 4.02, z]}
rotation={[0, Math.PI / 2, 0]}
>
{[0, 1, 2, 3].map((q) => {
const a = (q / 4) * Math.PI * 2 + Math.PI / 4;
return (
<mesh key={q} position={[Math.cos(a) * 0.07, Math.sin(a) * 0.07, 0]}>
<sphereGeometry args={[0.055, 8, 6]} />
<meshStandardMaterial color={shade(walnut, 0.34)} roughness={0.6} metalness={0.1} />
</mesh>
);
})}
</group>
))
)}
<CorniceRing room={room} y={4.14} height={0.1} proud={0.15} color={walnutLight} roughness={0.5} metalness={0.14} />
{[-1, 1].map((side) => (
<WallSconce
key={side}
wall={side < 0 ? 'left' : 'right'}
room={room}
along={side * room.halfD * 0.3}
y={2.9}
armColor={trim}
glowColor="#ff9c46"
shadeColor="#ffd8a0"
/>
))}
<HangingFixture
position={[0, 4.0, 0]}
dropLength={0.55}
radius={0.36}
arms={6}
metalColor={trim}
flameColor="#ffdca0"
glowColor="#ff9a3c"
light
lightColor="#ffb268"
lightIntensity={0.8}
lightDistance={9}
/>
</group>
);
}
/**
* Realist picture gallery — Realism.
*
* The mid-century bourgeois interior the Realists exhibited against: a plaster
* dado and picture rail, run cornice with a moulded frieze, a ceiling rose, and
* gas brackets. Restrained, machine-made mouldings — no carving anywhere.
*/
export function BourgeoisSalonDetails({ room, trim }: PeriodProps) {
const plaster = '#efe8dc';
const shadow = shade(plaster, -0.12);
return (
<group>
{/* Dado, chair rail, skirting */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.5} height={0.96} proud={0.04} color={shadow} roughness={0.78} metalness={0.05} />
<WallBand wall={wall} room={room} y={1.03} height={0.09} proud={0.09} color={plaster} roughness={0.7} metalness={0.06} />
<WallBand wall={wall} room={room} y={0.09} height={0.18} proud={0.1} color={plaster} roughness={0.74} metalness={0.05} />
</group>
))}
{/* Picture rail just above the hang, as the period actually hung its walls */}
<SideRails room={room} y={2.52} height={0.07} proud={0.09} color={trim} roughness={0.55} metalness={0.2} />
{/* Run cornice: cavetto, bead and a moulded frieze */}
<CorniceRing room={room} y={3.9} height={0.1} proud={0.07} color={shadow} roughness={0.76} metalness={0.05} />
<CorniceRing room={room} y={4.0} height={0.12} proud={0.13} color={plaster} roughness={0.72} metalness={0.05} />
<CorniceRing room={room} y={4.13} height={0.07} proud={0.17} color={shade(plaster, 0.06)} roughness={0.7} metalness={0.06} />
<CeilingRose y={4.09} radius={0.85} color={shade(plaster, 0.04)} rings={3} />
{[-1, 1].map((side) => (
<WallSconce
key={side}
wall={side < 0 ? 'left' : 'right'}
room={room}
along={side * room.halfD * 0.34}
y={2.78}
armColor={trim}
/>
))}
<HangingFixture
position={[0, 4.04, 0]}
dropLength={0.62}
radius={0.3}
arms={4}
metalColor={trim}
flameColor="#fff0cc"
glowColor="#ffbe6a"
light
lightColor="#ffd8a8"
lightIntensity={0.75}
lightDistance={9}
/>
</group>
);
}
/**
* Impressionist salon — Impressionism.
*
* The 1874 Boulevard des Capucines idea of a hung room: an iron-and-glass
* lantern overhead, a coved plaster cornice, and a velvet-faced picture rail
* with visible hanging rods above the frames. Nothing carved, everything pale.
*/
export function NorthLightSalonDetails({ room, trim }: PeriodProps) {
const iron = '#6d6a63';
const plaster = '#f4f1ea';
const trusses = useBayZ(room.depth, room.halfD, 2.4, 11);
return (
<group>
{/* Skylight lantern: glazing bars across a shallow raised centre */}
<mesh position={[0, 4.11, 0]}>
<boxGeometry args={[Math.min(room.width - 2.2, 6.4), 0.06, Math.min(room.depth - 2.2, 14)]} />
<meshStandardMaterial
color="#ffffff"
emissive="#eaf2ff"
emissiveIntensity={0.55}
roughness={0.4}
metalness={0.05}
/>
</mesh>
<CeilingBeams
room={room}
positions={trusses}
axis="x"
width={0.07}
height={0.1}
y={4.05}
color={iron}
roughness={0.5}
metalness={0.55}
/>
{[-1, 1].map((side) => (
<mesh key={side} position={[(side * Math.min(room.width - 2.2, 6.4)) / 2, 4.03, 0]}>
<boxGeometry args={[0.1, 0.18, Math.min(room.depth - 2.2, 14)]} />
<meshStandardMaterial color={iron} roughness={0.48} metalness={0.6} />
</mesh>
))}
{/* Picture rail with hanging rods dropping to the frame line */}
<SideRails room={room} y={2.6} height={0.09} proud={0.1} color={trim} roughness={0.52} metalness={0.24} />
<WallBand wall="back" room={room} y={2.6} height={0.09} proud={0.1} color={trim} roughness={0.52} metalness={0.24} />
{/* Coved cornice */}
<CorniceRing room={room} y={3.94} height={0.14} proud={0.06} color={shade(plaster, -0.06)} roughness={0.8} metalness={0.04} />
<CorniceRing room={room} y={4.07} height={0.1} proud={0.12} color={plaster} roughness={0.78} metalness={0.04} />
{/* Dark skirting so the pale walls sit on something */}
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.1} height={0.2} proud={0.09} color="#b6a88f" roughness={0.7} metalness={0.06} />
))}
</group>
);
}
/**
* Montmartre atelier — Post-Impressionism.
*
* A working painter's room rather than a gallery: a king-post roof truss under
* the slope, a cast-iron stove flue climbing the end wall, plain board skirting
* and a rough shelf rail carrying the studio's clutter line.
*/
export function ParisAtelierDetails({ room, trim }: PeriodProps) {
const trusses = useBayZ(room.depth, room.halfD, 3.1, 8);
const timber = '#8a6a48';
const iron = '#3a3632';
return (
<group>
{/* Roof trusses: tie beam, king post and raking struts */}
{trusses.map((z, i) => {
// Struts run from the head of the king post down to the tie beam, and
// have to stay inside the 0.3 m the ceiling leaves above the beam.
const run = room.halfW - 0.6;
const rise = 0.16;
const strut = Math.hypot(run, rise);
return (
<group key={i} position={[0, 0, z]}>
<mesh position={[0, 3.9, 0]}>
<boxGeometry args={[room.width - 0.1, 0.16, 0.16]} />
<meshStandardMaterial color={timber} roughness={0.86} metalness={0.03} />
</mesh>
<mesh position={[0, 4.02, 0]}>
<boxGeometry args={[0.13, 0.24, 0.13]} />
<meshStandardMaterial color={timber} roughness={0.86} metalness={0.03} />
</mesh>
{[-1, 1].map((side) => (
<mesh
key={side}
position={[(side * run) / 2, 4.06, 0]}
rotation={[0, 0, side * Math.atan2(-rise, run)]}
>
<boxGeometry args={[strut, 0.09, 0.11]} />
<meshStandardMaterial color={shade(timber, -0.16)} roughness={0.88} metalness={0.03} />
</mesh>
))}
</group>
);
})}
{/* Stove flue running up the end wall and out along the ceiling */}
<mesh position={[room.halfW - 0.28, 2.1, -room.halfD + 0.28]}>
<cylinderGeometry args={[0.09, 0.09, 4.0, 10]} />
<meshStandardMaterial color={iron} roughness={0.62} metalness={0.42} />
</mesh>
<mesh position={[room.halfW - 0.28, 4.02, -room.halfD + 0.28]}>
<cylinderGeometry args={[0.12, 0.12, 0.14, 10]} />
<meshStandardMaterial color={shade(iron, 0.14)} roughness={0.58} metalness={0.5} />
</mesh>
{/* Board skirting and a rough shelf rail above the hang */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.12} height={0.24} proud={0.06} color={shade(timber, -0.2)} roughness={0.88} metalness={0.03} />
<WallBand wall={wall} room={room} y={2.66} height={0.06} proud={0.13} color={timber} roughness={0.84} metalness={0.04} />
</group>
))}
{/* A single bare bulb on a flex, the way the ateliers were actually lit */}
<mesh position={[0, 3.7, 0]}>
<cylinderGeometry args={[0.008, 0.008, 0.5, 6]} />
<meshStandardMaterial color={iron} roughness={0.6} metalness={0.3} />
</mesh>
<mesh position={[0, 3.42, 0]}>
<sphereGeometry args={[0.09, 12, 12]} />
<meshStandardMaterial color="#fff2d0" emissive="#ffc061" emissiveIntensity={0.95} toneMapped={false} />
</mesh>
<pointLight position={[0, 3.4, 0]} intensity={0.9} distance={9} color={trim} />
</group>
);
}
/**
* Symbolist chamber — Symbolism.
*
* The aesthetic interior: a stencilled frieze of repeating motifs, a heavy
* portière rail, dark lacquered dado, and low amber sconces. Light is kept
* pooled and low, which is the whole point of the period's rooms.
*/
export function SymbolistDetails({ room, trim }: PeriodProps) {
const lacquer = '#12281f';
const stencil = shade(trim, 0.1);
const motifs = useRepeat(room.depth, 0.8, 26);
return (
<group>
{/* Lacquered dado under a gilt bead */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.54} height={1.04} proud={0.05} color={lacquer} roughness={0.42} metalness={0.16} />
<WallBand wall={wall} room={room} y={1.1} height={0.06} proud={0.09} color={trim} roughness={0.38} metalness={0.44} />
</group>
))}
{/* Stencilled frieze — a repeating lotus/flame motif under the cornice */}
{(['left', 'right'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={3.86} height={0.34} proud={0.04} color={shade(lacquer, 0.1)} roughness={0.6} metalness={0.12} />
{motifs.map((z, i) => (
<group key={i} position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.11), 3.86, z]} rotation={[0, Math.PI / 2, 0]}>
<mesh>
<coneGeometry args={[0.09, 0.24, 5]} />
<meshStandardMaterial color={stencil} roughness={0.44} metalness={0.36} />
</mesh>
<mesh position={[0, -0.16, 0]}>
<sphereGeometry args={[0.045, 8, 6]} />
<meshStandardMaterial color={stencil} roughness={0.44} metalness={0.36} />
</mesh>
</group>
))}
</group>
))}
<CorniceRing room={room} y={4.08} height={0.12} proud={0.13} color={shade(lacquer, 0.16)} roughness={0.5} metalness={0.18} />
<CorniceRing room={room} y={4.16} height={0.05} proud={0.16} color={trim} roughness={0.36} metalness={0.5} />
{/* Portière rail across the end wall */}
<WallBand wall="back" room={room} y={2.72} height={0.08} proud={0.12} color={trim} roughness={0.4} metalness={0.44} />
{[-0.36, 0.36].map((t) => (
<WallSconce
key={t}
wall={t < 0 ? 'left' : 'right'}
room={room}
along={t * room.halfD}
y={2.94}
armColor={trim}
glowColor="#ff8a2e"
shadeColor="#ffcf94"
/>
))}
<HangingFixture
position={[0, 4.04, 0]}
dropLength={0.66}
radius={0.26}
arms={5}
metalColor={trim}
flameColor="#ffd9a0"
glowColor="#ff8f2c"
light
lightColor="#ffa050"
lightIntensity={0.7}
lightDistance={8}
/>
</group>
);
}
/**
* Art Nouveau salon — Art Nouveau.
*
* Horta's structural line: whiplash brackets springing from the wall into the
* ceiling, tendrils running the cornice, and organic sconces. The curve is
* carried by the structure itself rather than applied as ornament.
*/
export function ArtNouveauDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 3.0, 10);
const iron = shade(trim, -0.24);
return (
<group>
{/* Whiplash brackets: a stem up the wall curling into the ceiling */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.07), 0, z]}>
<mesh position={[0, 2.05, 0]}>
<cylinderGeometry args={[0.045, 0.06, 4.0, 8]} />
<meshStandardMaterial color={iron} roughness={0.44} metalness={0.42} />
</mesh>
{[0, 1, 2, 3].map((s) => {
const t = (s + 1) / 4;
return (
<mesh
key={s}
position={[-side * (0.1 + t * 0.32), 4.02 - t * t * 0.22, 0]}
rotation={[0, 0, side * (0.35 + t * 0.7)]}
>
<cylinderGeometry args={[0.035, 0.045, 0.34, 6]} />
<meshStandardMaterial color={iron} roughness={0.44} metalness={0.42} />
</mesh>
);
})}
{/* Bud terminal */}
<mesh position={[-side * 0.52, 3.78, 0]}>
<sphereGeometry args={[0.07, 10, 8]} />
<meshStandardMaterial color={trim} roughness={0.36} metalness={0.5} />
</mesh>
</group>
))}
</group>
))}
{/* Tendril cornice — a wave riding the top of every wall */}
<CorniceRing room={room} y={4.05} height={0.11} proud={0.11} color={shade(trim, 0.18)} roughness={0.5} metalness={0.3} />
<CorniceRing room={room} y={4.15} height={0.06} proud={0.15} color={trim} roughness={0.42} metalness={0.42} />
{/* Sinuous dado line */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.11} height={0.22} proud={0.08} color={shade(trim, -0.3)} roughness={0.52} metalness={0.28} />
<WallBand wall={wall} room={room} y={0.86} height={0.05} proud={0.07} color={trim} roughness={0.44} metalness={0.4} />
</group>
))}
{[-0.3, 0.3].map((t) => (
<WallSconce
key={t}
wall={t < 0 ? 'left' : 'right'}
room={room}
along={t * room.halfD}
y={2.88}
armColor={trim}
glowColor="#8fd66a"
shadeColor="#e8ffd8"
/>
))}
<HangingFixture
position={[0, 4.0, 0]}
dropLength={0.5}
radius={0.44}
arms={7}
metalColor={trim}
flameColor="#f2ffe0"
glowColor="#a8d86a"
light
lightColor="#dff2c0"
lightIntensity={0.75}
lightDistance={9}
/>
</group>
);
}
@@ -0,0 +1,513 @@
import { useMemo } from 'react';
import { CEILING_Y, EMBED, WALL_INNER, shade, type RoomDims, type WallId } from './geometry';
/** Wall-flush building blocks shared by the period interiors in this folder. */
interface SurfaceProps {
color: string;
roughness?: number;
metalness?: number;
emissive?: string;
emissiveIntensity?: number;
}
/** A moulding running the length of one wall, flush against its inner face. */
export function WallBand({
wall,
room,
y,
height,
proud = 0.1,
span = 1,
color,
roughness = 0.7,
metalness = 0.06,
emissive,
emissiveIntensity,
}: SurfaceProps & {
wall: WallId;
room: RoomDims;
y: number;
height: number;
proud?: number;
span?: number;
}) {
const alongZ = wall === 'left' || wall === 'right';
const len = (alongZ ? room.depth : room.width) * span;
const thick = proud + EMBED * 2;
const off = WALL_INNER + proud / 2 - EMBED;
const args: [number, number, number] = alongZ ? [thick, height, len] : [len, height, thick];
const pos: [number, number, number] =
wall === 'back'
? [0, y, -room.halfD + off]
: wall === 'front'
? [0, y, room.halfD - off]
: wall === 'left'
? [-room.halfW + off, y, 0]
: [room.halfW - off, y, 0];
return (
<mesh position={pos}>
<boxGeometry args={args} />
<meshStandardMaterial
color={color}
roughness={roughness}
metalness={metalness}
emissive={emissive}
emissiveIntensity={emissiveIntensity}
/>
</mesh>
);
}
/** A moulding ring running right around the room at one height. */
export function CorniceRing({
room,
y,
height,
proud = 0.12,
walls = ['back', 'front', 'left', 'right'],
...surface
}: SurfaceProps & {
room: RoomDims;
y: number;
height: number;
proud?: number;
walls?: WallId[];
}) {
return (
<group>
{walls.map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={y} height={height} proud={proud} {...surface} />
))}
</group>
);
}
/** String course / picture rail on the two side walls only. */
export function SideRails({
room,
y,
height,
proud = 0.1,
...surface
}: SurfaceProps & { room: RoomDims; y: number; height: number; proud?: number }) {
return (
<group>
{(['left', 'right'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={y} height={height} proud={proud} {...surface} />
))}
</group>
);
}
/**
* Vertical shaft with carved flutes, read as alternating light stone and
* shadowed grooves. Sits flush: the caller places it against a wall face.
*/
export function FlutedShaft({
height,
radius,
color,
flutes = 12,
taper = 0.9,
facing = 'x',
}: {
height: number;
radius: number;
color: string;
flutes?: number;
taper?: number;
/** Wall normal axis — grooves are only cut on the exposed half. */
facing?: 'x' | 'z';
}) {
const groove = shade(color, -0.28);
const arcs = useMemo(
() => Array.from({ length: flutes }, (_, i) => (i / flutes) * Math.PI * 2),
[flutes]
);
return (
<group>
<mesh position={[0, height / 2, 0]}>
<cylinderGeometry args={[radius * taper, radius, height, 16]} />
<meshStandardMaterial color={color} roughness={0.74} metalness={0.05} />
</mesh>
{arcs.map((a, i) => {
const dx = Math.cos(a) * radius * 0.94;
const dz = Math.sin(a) * radius * 0.94;
// Skip the grooves buried in the wall behind the shaft.
if (facing === 'x' ? dx > 0 : dz > 0) return null;
return (
<mesh key={i} position={[dx, height / 2, dz]}>
<cylinderGeometry args={[radius * 0.16, radius * 0.16, height * 0.98, 6]} />
<meshStandardMaterial color={groove} roughness={0.88} metalness={0.03} />
</mesh>
);
})}
</group>
);
}
export function TuscanCapital({ y, size, color }: { y: number; size: number; color: string }) {
return (
<group position={[0, y, 0]}>
<mesh>
<cylinderGeometry args={[size * 1.15, size * 0.85, size * 0.42, 14]} />
<meshStandardMaterial color={color} roughness={0.7} metalness={0.05} />
</mesh>
<mesh position={[0, size * 0.36, 0]}>
<boxGeometry args={[size * 2.5, size * 0.28, size * 2.5]} />
<meshStandardMaterial color={shade(color, 0.08)} roughness={0.68} metalness={0.05} />
</mesh>
</group>
);
}
/** Ionic capital — abacus over a pair of spiral volutes. */
export function IonicCapital({
y,
size,
color,
axis = 'z',
}: {
y: number;
size: number;
color: string;
/** Axis the volute pair spreads along. */
axis?: 'x' | 'z';
}) {
return (
<group position={[0, y, 0]}>
<mesh position={[0, size * 0.1, 0]}>
<boxGeometry args={[size * 2.4, size * 0.34, size * 1.5]} />
<meshStandardMaterial color={color} roughness={0.66} metalness={0.06} />
</mesh>
{[-1, 1].map((s) => (
<mesh
key={s}
position={axis === 'z' ? [0, -size * 0.12, s * size * 0.85] : [s * size * 0.85, -size * 0.12, 0]}
rotation={axis === 'z' ? [0, Math.PI / 2, 0] : [0, 0, 0]}
>
<torusGeometry args={[size * 0.38, size * 0.14, 8, 16]} />
<meshStandardMaterial color={shade(color, 0.06)} roughness={0.62} metalness={0.07} />
</mesh>
))}
</group>
);
}
/** Moulded plinth under a shaft. */
export function ShaftBase({ size, color }: { size: number; color: string }) {
return (
<group>
<mesh position={[0, 0.08, 0]}>
<boxGeometry args={[size * 2.4, 0.16, size * 2.4]} />
<meshStandardMaterial color={color} roughness={0.78} metalness={0.04} />
</mesh>
<mesh position={[0, 0.21, 0]}>
<cylinderGeometry args={[size * 1.0, size * 1.15, 0.1, 14]} />
<meshStandardMaterial color={shade(color, 0.06)} roughness={0.72} metalness={0.05} />
</mesh>
</group>
);
}
/** Semicircular arch drawn as a chord of straight voussoir blocks. */
export function RoundArch({
span,
rise,
thickness = 0.14,
depth = 0.16,
color,
segments = 11,
roughness = 0.72,
metalness = 0.05,
}: {
span: number;
rise: number;
thickness?: number;
depth?: number;
color: string;
segments?: number;
roughness?: number;
metalness?: number;
}) {
const blocks = useMemo(() => {
const out: { x: number; y: number; len: number; rot: number }[] = [];
for (let s = 0; s < segments; s++) {
const t0 = s / segments;
const t1 = (s + 1) / segments;
const at = (t: number) => {
const a = Math.PI * (1 - t);
return { x: Math.cos(a) * (span / 2), y: Math.sin(a) * rise };
};
const p0 = at(t0);
const p1 = at(t1);
out.push({
x: (p0.x + p1.x) / 2,
y: (p0.y + p1.y) / 2,
len: Math.hypot(p1.x - p0.x, p1.y - p0.y) * 1.08,
rot: Math.atan2(p1.y - p0.y, p1.x - p0.x),
});
}
return out;
}, [span, rise, segments]);
return (
<group>
{blocks.map((b, i) => (
<mesh key={i} position={[b.x, b.y, 0]} rotation={[0, 0, b.rot]}>
<boxGeometry args={[b.len, thickness, depth]} />
<meshStandardMaterial color={color} roughness={roughness} metalness={metalness} />
</mesh>
))}
</group>
);
}
/** Recessed coffer grid under the ceiling plane. */
export function CofferedCeiling({
room,
y = CEILING_Y,
cell = 2.3,
panelColor,
ribColor,
ribWidth = 0.16,
ribDrop = 0.12,
rosette,
}: {
room: RoomDims;
y?: number;
cell?: number;
panelColor: string;
ribColor: string;
ribWidth?: number;
ribDrop?: number;
rosette?: string;
}) {
const grid = useMemo(() => {
const cols = Math.max(2, Math.min(6, Math.round(room.width / cell)));
const rows = Math.max(2, Math.min(8, Math.round(room.depth / cell)));
const cw = room.width / cols;
const cd = room.depth / rows;
const cells: { x: number; z: number }[] = [];
for (let c = 0; c < cols; c++) {
for (let r = 0; r < rows; r++) {
cells.push({ x: -room.halfW + cw * (c + 0.5), z: -room.halfD + cd * (r + 0.5) });
}
}
return { cells, cw, cd, cols, rows };
}, [room.width, room.depth, room.halfW, room.halfD, cell]);
return (
<group>
{grid.cells.map((c, i) => (
<group key={i} position={[c.x, y, c.z]}>
<mesh position={[0, 0.01, 0]}>
<boxGeometry args={[grid.cw - ribWidth, 0.03, grid.cd - ribWidth]} />
<meshStandardMaterial color={panelColor} roughness={0.86} metalness={0.04} />
</mesh>
{rosette && (
<mesh position={[0, -ribDrop * 0.35, 0]} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[grid.cw * 0.06, grid.cw * 0.15, 12]} />
<meshStandardMaterial color={rosette} roughness={0.4} metalness={0.5} side={2} />
</mesh>
)}
</group>
))}
{/* Ribs between the coffers */}
{Array.from({ length: grid.cols + 1 }, (_, c) => (
<mesh key={`c${c}`} position={[-room.halfW + grid.cw * c, y - ribDrop / 2, 0]}>
<boxGeometry args={[ribWidth, ribDrop, room.depth]} />
<meshStandardMaterial color={ribColor} roughness={0.7} metalness={0.08} />
</mesh>
))}
{Array.from({ length: grid.rows + 1 }, (_, r) => (
<mesh key={`r${r}`} position={[0, y - ribDrop / 2, -room.halfD + grid.cd * r]}>
<boxGeometry args={[room.width, ribDrop, ribWidth]} />
<meshStandardMaterial color={ribColor} roughness={0.7} metalness={0.08} />
</mesh>
))}
</group>
);
}
/** Exposed structural beams spanning the room. */
export function CeilingBeams({
room,
positions,
axis = 'x',
width = 0.22,
height = 0.3,
y = CEILING_Y - 0.16,
color,
roughness = 0.82,
metalness = 0.05,
}: SurfaceProps & {
room: RoomDims;
positions: number[];
/** Axis the beams run along. */
axis?: 'x' | 'z';
width?: number;
height?: number;
y?: number;
}) {
return (
<group>
{positions.map((p, i) => (
<mesh key={i} position={axis === 'x' ? [0, y, p] : [p, y, 0]}>
<boxGeometry
args={axis === 'x' ? [room.width, height, width] : [width, height, room.depth]}
/>
<meshStandardMaterial color={color} roughness={roughness} metalness={metalness} />
</mesh>
))}
</group>
);
}
/** Plaster ceiling rose, the anchor a salon chandelier hangs from. */
export function CeilingRose({
y = CEILING_Y,
radius = 1.0,
color,
rings = 3,
}: {
y?: number;
radius?: number;
color: string;
rings?: number;
}) {
return (
<group position={[0, y, 0]} rotation={[Math.PI / 2, 0, 0]}>
{Array.from({ length: rings }, (_, i) => {
const t = (i + 1) / rings;
return (
<mesh key={i} position={[0, 0, i * 0.012]}>
<ringGeometry args={[radius * t * 0.55, radius * t, 32]} />
<meshStandardMaterial color={shade(color, i * 0.06)} roughness={0.7} metalness={0.12} side={2} />
</mesh>
);
})}
</group>
);
}
/**
* Hanging light fixture. `light` adds a real point light — keep at most two per
* hall so the shared track lighting stays inside the WebGL light budget.
*/
export function HangingFixture({
position,
dropLength = 0.6,
radius = 0.42,
arms = 6,
metalColor,
flameColor = '#ffcf80',
glowColor = '#ff9830',
light = false,
lightColor = '#ffb060',
lightIntensity = 0.8,
lightDistance = 7,
}: {
position: [number, number, number];
dropLength?: number;
radius?: number;
arms?: number;
metalColor: string;
flameColor?: string;
glowColor?: string;
light?: boolean;
lightColor?: string;
lightIntensity?: number;
lightDistance?: number;
}) {
const ringY = position[1] - dropLength;
return (
<group position={[position[0], 0, position[2]]}>
<mesh position={[0, position[1] - dropLength / 2, 0]}>
<cylinderGeometry args={[0.012, 0.012, dropLength, 6]} />
<meshStandardMaterial color={shade(metalColor, -0.4)} roughness={0.42} metalness={0.7} />
</mesh>
<mesh position={[0, ringY, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[radius, 0.028, 8, 22]} />
<meshStandardMaterial color={metalColor} roughness={0.26} metalness={0.82} />
</mesh>
{Array.from({ length: arms }, (_, i) => {
const a = (i / arms) * Math.PI * 2;
return (
<mesh key={i} position={[Math.cos(a) * radius, ringY - 0.09, Math.sin(a) * radius]}>
<sphereGeometry args={[0.05, 8, 8]} />
<meshStandardMaterial
color={flameColor}
emissive={glowColor}
emissiveIntensity={0.9}
toneMapped={false}
/>
</mesh>
);
})}
{light && (
<pointLight
position={[0, ringY - 0.09, 0]}
intensity={lightIntensity}
distance={lightDistance}
color={lightColor}
/>
)}
</group>
);
}
/**
* Wall light mounted above the frame line. Emissive only — sconces are for
* period character, not for lighting the hall.
*/
export function WallSconce({
wall,
room,
along,
y = 2.86,
armColor,
glowColor = '#ffb45a',
shadeColor = '#ffdca8',
}: {
wall: 'left' | 'right' | 'back';
room: RoomDims;
/** Offset along the wall from its centre. */
along: number;
y?: number;
armColor: string;
glowColor?: string;
shadeColor?: string;
}) {
const pos: [number, number, number] =
wall === 'back'
? [along, y, -room.halfD + WALL_INNER + 0.02]
: wall === 'left'
? [-room.halfW + WALL_INNER + 0.02, y, along]
: [room.halfW - WALL_INNER - 0.02, y, along];
const rot: [number, number, number] =
wall === 'back' ? [0, 0, 0] : wall === 'left' ? [0, Math.PI / 2, 0] : [0, -Math.PI / 2, 0];
return (
<group position={pos} rotation={rot}>
<mesh position={[0, 0, 0.03]}>
<boxGeometry args={[0.16, 0.22, 0.05]} />
<meshStandardMaterial color={armColor} roughness={0.34} metalness={0.68} />
</mesh>
<mesh position={[0, 0.06, 0.09]} rotation={[Math.PI / 2.6, 0, 0]}>
<cylinderGeometry args={[0.012, 0.012, 0.16, 6]} />
<meshStandardMaterial color={armColor} roughness={0.34} metalness={0.68} />
</mesh>
<mesh position={[0, 0.15, 0.13]}>
<sphereGeometry args={[0.075, 10, 10]} />
<meshStandardMaterial
color={shadeColor}
emissive={glowColor}
emissiveIntensity={0.75}
toneMapped={false}
/>
</mesh>
</group>
);
}
+132 -42
View File
@@ -54,9 +54,42 @@ export interface MovementInteriorStyle {
trackLights: number; trackLights: number;
/** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */ /** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */
lightScale: number; lightScale: number;
details: 'palazzo' | 'baroque' | 'gothic' | 'byzantine' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier'; /**
* Which period architecture the hall is built from. Every movement has its
* own — the interiors are not shared between movements — and each kind maps
* to one component under `components/hall-details/`.
*/
details: MovementDetailKind;
} }
export type MovementDetailKind =
| 'roman'
| 'byzantine'
| 'gothic'
| 'quattrocento'
| 'cinquecento'
| 'flemish'
| 'mannerist'
| 'baroque'
| 'rococo'
| 'neoclassical'
| 'gothic-revival'
| 'bourgeois'
| 'north-light'
| 'paris-atelier'
| 'symbolist'
| 'art-nouveau'
| 'fauvist'
| 'expressionist'
| 'cubist-atelier'
| 'futurist'
| 'suprematist'
| 'constructivist'
| 'dada'
| 'surrealist'
| 'loft'
| 'white-cube';
function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] { function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] {
return specs; return specs;
} }
@@ -99,9 +132,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Roman atrium', 'Roman atrium',
'Marble-clad villa · mosaic floor · clerestory daylight', 'Marble-clad villa · mosaic floor · clerestory daylight',
{ wall: 'limestone', ceiling: 'plaster-warm', floor: 'mosaic-roman' }, { wall: 'limestone', ceiling: 'plaster-warm', floor: 'mosaic-roman' },
{ wall: '#e8e0d0', ceiling: '#f5f0e8', floor: '#c8b898', trim: '#a89878' }, { wall: '#8e8980', ceiling: '#9c9994', floor: '#c8b898', trim: '#a89878' },
{ {
details: 'classical', details: 'roman',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#5a4830', titleColor: '#5a4830',
ambient: 0.62, ambient: 0.62,
warmLight: '#fff8e8', warmLight: '#fff8e8',
@@ -175,9 +211,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Florentine palazzo', 'Florentine palazzo',
'Lime-washed walls · terracotta accents · arched windows', 'Lime-washed walls · terracotta accents · arched windows',
{ wall: 'painted-lime', wallSide: 'stucco-cream', ceiling: 'fresco-worn', floor: 'terracotta-tiles' }, { wall: 'painted-lime', wallSide: 'stucco-cream', ceiling: 'fresco-worn', floor: 'terracotta-tiles' },
{ wall: '#f4ebe0', ceiling: '#faf6ee', floor: '#c89070', trim: '#c9a227' }, { wall: '#89847e', ceiling: '#96948f', floor: '#c89070', trim: '#c9a227' },
{ {
details: 'palazzo', details: 'quattrocento',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#6b4423', titleColor: '#6b4423',
windows: windows( windows: windows(
{ wall: 'back', x: -2.2, y: 2.6, width: 1.3, height: 1.7, style: 'roman-arch', lightColor: '#fff4e0', lightIntensity: 3.0 }, { wall: 'back', x: -2.2, y: 2.6, width: 1.3, height: 1.7, style: 'roman-arch', lightColor: '#fff4e0', lightIntensity: 3.0 },
@@ -191,9 +230,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Roman palazzo', 'Roman palazzo',
'Marble and stucco · coffered ceiling · checker floor', 'Marble and stucco · coffered ceiling · checker floor',
{ wall: 'marble-veined-carrara', wallSide: 'stucco-cream', ceiling: 'plaster-warm', floor: 'marble-checker' }, { wall: 'marble-veined-carrara', wallSide: 'stucco-cream', ceiling: 'plaster-warm', floor: 'marble-checker' },
{ wall: '#f0ece4', wallSide: '#efe4d4', ceiling: '#faf6ee', floor: '#ddd8cc', trim: '#c9a227' }, { wall: '#918f8a', wallSide: '#8f8980', ceiling: '#9c9994', floor: '#ddd8cc', trim: '#c9a227' },
{ {
details: 'palazzo', details: 'cinquecento',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#6b4423', titleColor: '#6b4423',
windows: windows( windows: windows(
{ wall: 'back', x: 0, y: 2.8, width: 2.8, height: 1.8, style: 'baroque-pair', lightColor: '#fff8f0', lightIntensity: 3.8 }, { wall: 'back', x: 0, y: 2.8, width: 2.8, height: 1.8, style: 'baroque-pair', lightColor: '#fff8f0', lightIntensity: 3.8 },
@@ -208,9 +250,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Flemish panel hall', 'Flemish panel hall',
'Oak wainscoting · leaded glass · herringbone floor', 'Oak wainscoting · leaded glass · herringbone floor',
{ wall: 'oak-panel', wallSide: 'plaster-warm', ceiling: 'dark-wood-panel', floor: 'parquet-herringbone' }, { wall: 'oak-panel', wallSide: 'plaster-warm', ceiling: 'dark-wood-panel', floor: 'parquet-herringbone' },
{ wall: '#8a6848', wallSide: '#f0ebe3', ceiling: '#3a2818', floor: '#8a6848', trim: '#5c4030' }, { wall: '#8a6848', wallSide: '#8c8985', ceiling: '#3a2818', floor: '#8a6848', trim: '#5c4030' },
{ {
details: 'salon', details: 'flemish',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#f0e8d8', titleColor: '#f0e8d8',
warmLight: '#ffe8c8', warmLight: '#ffe8c8',
windows: windows( windows: windows(
@@ -226,9 +271,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Mannerist gallery', 'Mannerist gallery',
'Dramatic stucco · elongated windows · veined marble', 'Dramatic stucco · elongated windows · veined marble',
{ wall: 'stucco-terracotta', ceiling: 'gilded-stucco', floor: 'marble-veined-emerald' }, { wall: 'stucco-terracotta', ceiling: 'gilded-stucco', floor: 'marble-veined-emerald' },
{ wall: '#d8b898', ceiling: '#d8c070', floor: '#dce8e0', trim: '#b8860b' }, { wall: '#98816b', ceiling: '#a79456', floor: '#dce8e0', trim: '#b8860b' },
{ {
details: 'baroque', details: 'mannerist',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#4a2818', titleColor: '#4a2818',
windows: windows( windows: windows(
{ wall: 'back', x: -1.8, y: 2.7, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#fff0d8', lightIntensity: 2.8 }, { wall: 'back', x: -1.8, y: 2.7, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#fff0d8', lightIntensity: 2.8 },
@@ -264,9 +312,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Rococo salon', 'Rococo salon',
'Pastel painted walls · gilt trim · chevron parquet', 'Pastel painted walls · gilt trim · chevron parquet',
{ wall: 'painted-oil-satin', ceiling: 'silk-pale', floor: 'parquet-chevrons' }, { wall: 'painted-oil-satin', ceiling: 'silk-pale', floor: 'parquet-chevrons' },
{ wall: '#e8dce8', ceiling: '#faf6f0', floor: '#c8a882', trim: '#d4af37' }, { wall: '#918a91', ceiling: '#9b9995', floor: '#c8a882', trim: '#d4af37' },
{ {
details: 'salon', details: 'rococo',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#6a4858', titleColor: '#6a4858',
warmLight: '#fff0f0', warmLight: '#fff0f0',
windows: windows( windows: windows(
@@ -281,9 +332,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Neoclassical museum', 'Neoclassical museum',
'White painted walls · coffered ceiling · skylit salon', 'White painted walls · coffered ceiling · skylit salon',
{ wall: 'painted-flat', ceiling: 'plaster-white', floor: 'marble-veined-carrara' }, { wall: 'painted-flat', ceiling: 'plaster-white', floor: 'marble-veined-carrara' },
{ wall: '#f2f0ec', ceiling: '#fafafa', floor: '#eceae6', trim: '#b8b0a4' }, { wall: '#908f8d', ceiling: '#9e9e9e', floor: '#eceae6', trim: '#b8b0a4' },
{ {
details: 'neoclassical', details: 'neoclassical',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#4a4844', titleColor: '#4a4844',
ambient: 0.65, ambient: 0.65,
windows: windows( windows: windows(
@@ -301,7 +355,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
{ wall: 'walnut-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' }, { wall: 'walnut-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#5a4030', ceiling: '#2a2420', floor: '#6a5040', trim: '#8a7050' }, { wall: '#5a4030', ceiling: '#2a2420', floor: '#6a5040', trim: '#8a7050' },
{ {
details: 'salon', details: 'gothic-revival',
titleColor: '#e8dcc8', titleColor: '#e8dcc8',
ambient: 0.5, ambient: 0.5,
warmLight: '#ffe0c0', warmLight: '#ffe0c0',
@@ -317,9 +371,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Realist picture gallery', 'Realist picture gallery',
'Warm painted walls · bourgeois salon · oak parquet', 'Warm painted walls · bourgeois salon · oak parquet',
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' }, { wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
{ wall: '#e8e2d8', ceiling: '#f5f0e8', floor: '#a08060', trim: '#8a7050' }, { wall: '#88847e', ceiling: '#97948f', floor: '#a08060', trim: '#8a7050' },
{ {
details: 'salon', details: 'bourgeois',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
windows: windows( windows: windows(
{ wall: 'back', x: 0, y: 2.5, width: 2.4, height: 1.6, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 3.2 }, { wall: 'back', x: 0, y: 2.5, width: 2.4, height: 1.6, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 3.2 },
{ wall: 'left', x: 0, y: 2.7, width: 1.6, height: 1.3, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.4 } { wall: 'left', x: 0, y: 2.7, width: 1.6, height: 1.3, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.4 }
@@ -331,9 +388,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Impressionist salon', 'Impressionist salon',
'North-light skylight · pale painted walls · herringbone floor', 'North-light skylight · pale painted walls · herringbone floor',
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' }, { wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
{ wall: '#f0ebe3', ceiling: '#fafafa', floor: '#c8a882', trim: '#c9a96e' }, { wall: '#8f8c87', ceiling: '#9e9e9e', floor: '#c8a882', trim: '#c9a96e' },
{ {
details: 'salon', details: 'north-light',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
ambient: 0.68, ambient: 0.68,
windows: windows( windows: windows(
{ wall: 'ceiling', x: 0, y: 0, width: 5.0, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.0 }, { wall: 'ceiling', x: 0, y: 0, width: 5.0, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.0 },
@@ -348,9 +408,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Montmartre atelier', 'Montmartre atelier',
'Painted studio walls · large north window · worn floorboards', 'Painted studio walls · large north window · worn floorboards',
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' }, { wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
{ wall: '#e8e0d0', ceiling: '#f0ebe0', floor: '#9a7858', trim: '#8a6848' }, { wall: '#847f76', ceiling: '#918f88', floor: '#9a7858', trim: '#8a6848' },
{ {
details: 'atelier', details: 'paris-atelier',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
windows: windows( windows: windows(
{ wall: 'left', x: 0, y: 2.4, width: 2.8, height: 2.0, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 4.5 }, { wall: 'left', x: 0, y: 2.4, width: 2.8, height: 2.0, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 4.5 },
{ wall: 'back', x: 0, y: 2.8, width: 1.0, height: 1.2, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.0 } { wall: 'back', x: 0, y: 2.8, width: 1.0, height: 1.2, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.0 }
@@ -364,7 +427,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' }, { wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#1a4030', ceiling: '#1a1814', floor: '#4a3828', trim: '#8a7050' }, { wall: '#1a4030', ceiling: '#1a1814', floor: '#4a3828', trim: '#8a7050' },
{ {
details: 'salon', details: 'symbolist',
titleColor: '#d8e8c8', titleColor: '#d8e8c8',
ambient: 0.55, ambient: 0.55,
warmLight: '#ffd898', warmLight: '#ffd898',
@@ -380,9 +443,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Art Nouveau salon', 'Art Nouveau salon',
'Painted plaster · stained glass · terrazzo floor', 'Painted plaster · stained glass · terrazzo floor',
{ wall: 'painted-oil-satin', ceiling: 'silk-gold', floor: 'terrazzo' }, { wall: 'painted-oil-satin', ceiling: 'silk-gold', floor: 'terrazzo' },
{ wall: '#f0ece4', ceiling: '#d8c890', floor: '#d8d0c4', trim: '#6a9868' }, { wall: '#8f8c87', ceiling: '#a0946b', floor: '#d8d0c4', trim: '#6a9868' },
{ {
details: 'salon', details: 'art-nouveau',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#3a5840', titleColor: '#3a5840',
windows: windows( windows: windows(
{ wall: 'back', x: 0, y: 2.5, width: 2.2, height: 2.0, style: 'art-nouveau', lightColor: '#e8ffe8', lightIntensity: 3.2 }, { wall: 'back', x: 0, y: 2.5, width: 2.2, height: 2.0, style: 'art-nouveau', lightColor: '#e8ffe8', lightIntensity: 3.2 },
@@ -396,9 +462,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Fauvist studio', 'Fauvist studio',
'Bold painted walls · flooded with color and light', 'Bold painted walls · flooded with color and light',
{ wall: 'painted-oil-matte', wallSide: 'painted-oil-matte', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' }, { wall: 'painted-oil-matte', wallSide: 'painted-oil-matte', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
{ wall: '#e89060', wallSide: '#e8c860', ceiling: '#f8f0e0', floor: '#a07048', trim: '#c04020' }, { wall: '#e89060', wallSide: '#e8c860', ceiling: '#9e998e', floor: '#a07048', trim: '#c04020' },
{ {
details: 'atelier', details: 'fauvist',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#402010', titleColor: '#402010',
ambient: 0.7, ambient: 0.7,
windows: windows( windows: windows(
@@ -414,7 +483,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
{ wall: 'dark-wood-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' }, { wall: 'dark-wood-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#3a2818', ceiling: '#2a2018', floor: '#5a4030', trim: '#8a6040' }, { wall: '#3a2818', ceiling: '#2a2018', floor: '#5a4030', trim: '#8a6040' },
{ {
details: 'atelier', details: 'expressionist',
titleColor: '#f0d8b0', titleColor: '#f0d8b0',
ambient: 0.52, ambient: 0.52,
windows: windows( windows: windows(
@@ -428,9 +497,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Cubist studio', 'Cubist studio',
'Paris atelier · factory windows · painted plaster', 'Paris atelier · factory windows · painted plaster',
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' }, { wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
{ wall: '#e8e4dc', ceiling: '#f5f5f5', floor: '#9a8870', trim: '#888888' }, { wall: '#8c8a85', ceiling: '#999999', floor: '#9a8870', trim: '#888888' },
{ {
details: 'atelier', details: 'cubist-atelier',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
ambient: 0.65, ambient: 0.65,
windows: windows( windows: windows(
{ wall: 'left', x: 0, y: 2.5, width: 3.5, height: 2.4, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 5.5 }, { wall: 'left', x: 0, y: 2.5, width: 3.5, height: 2.4, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 5.5 },
@@ -443,9 +515,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Futurist loft', 'Futurist loft',
'Steel and glass · industrial concrete · sweeping daylight', 'Steel and glass · industrial concrete · sweeping daylight',
{ wall: 'concrete-raw', ceiling: 'concrete-raw', floor: 'industrial-floor' }, { wall: 'concrete-raw', ceiling: 'concrete-raw', floor: 'industrial-floor' },
{ wall: '#a8a8a8', ceiling: '#989898', floor: '#888880', trim: '#606060' }, { wall: '#858585', ceiling: '#7a7a7a', floor: '#888880', trim: '#606060' },
{ {
details: 'industrial', details: 'futurist',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#303030', titleColor: '#303030',
ambient: 0.62, ambient: 0.62,
fog: '#181818', fog: '#181818',
@@ -462,9 +537,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Suprematist white cube', 'Suprematist white cube',
'Matte white paint · geometric light · polished floor', 'Matte white paint · geometric light · polished floor',
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' }, { wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#e0e0e0', trim: '#cccccc' }, { wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#e0e0e0', trim: '#cccccc' },
{ {
details: 'modern', details: 'suprematist',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#222222', titleColor: '#222222',
ambient: 0.72, ambient: 0.72,
fog: '#1a1a1a', fog: '#1a1a1a',
@@ -482,9 +560,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Constructivist space', 'Constructivist space',
'Glass block · concrete · angular daylight', 'Glass block · concrete · angular daylight',
{ wall: 'concrete-block', ceiling: 'concrete-raw', floor: 'concrete-polished' }, { wall: 'concrete-block', ceiling: 'concrete-raw', floor: 'concrete-polished' },
{ wall: '#b0b0b0', ceiling: '#a0a0a0', floor: '#c0c0c0', trim: '#808080' }, { wall: '#858585', ceiling: '#7a7a7a', floor: '#c0c0c0', trim: '#808080' },
{ {
details: 'industrial', details: 'constructivist',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#303030', titleColor: '#303030',
ambient: 0.65, ambient: 0.65,
windows: windows( windows: windows(
@@ -500,9 +581,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Dada salon', 'Dada salon',
'Eclectic bourgeois room · painted walls and exposed brick', 'Eclectic bourgeois room · painted walls and exposed brick',
{ wall: 'painted-emulsion', wallSide: 'brick', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' }, { wall: 'painted-emulsion', wallSide: 'brick', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
{ wall: '#e8dcc8', wallSide: '#8a5040', ceiling: '#f0ebe0', floor: '#8a6848', trim: '#6a4830' }, { wall: '#8b8478', wallSide: '#8a5040', ceiling: '#97948d', floor: '#8a6848', trim: '#6a4830' },
{ {
details: 'salon', details: 'dada',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
windows: windows( windows: windows(
{ wall: 'back', x: -1.5, y: 2.5, width: 1.0, height: 1.4, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.5 }, { wall: 'back', x: -1.5, y: 2.5, width: 1.0, height: 1.4, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.5 },
{ wall: 'back', x: 1.5, y: 2.6, width: 0.8, height: 1.8, style: 'gothic-lancet', lightColor: '#ffe8c0', lightIntensity: 2.0 }, { wall: 'back', x: 1.5, y: 2.6, width: 0.8, height: 1.8, style: 'gothic-lancet', lightColor: '#ffe8c0', lightIntensity: 2.0 },
@@ -518,7 +602,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' }, { wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#1a2848', ceiling: '#2a2428', floor: '#5a4838', trim: '#8a7050' }, { wall: '#1a2848', ceiling: '#2a2428', floor: '#5a4838', trim: '#8a7050' },
{ {
details: 'salon', details: 'surrealist',
titleColor: '#e8dcc8', titleColor: '#e8dcc8',
ambient: 0.58, ambient: 0.58,
warmLight: '#ffd898', warmLight: '#ffd898',
@@ -535,9 +619,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'NYC loft', 'NYC loft',
'Raw brick and painted plaster · north-light factory windows', 'Raw brick and painted plaster · north-light factory windows',
{ wall: 'brick', wallSide: 'painted-flat', ceiling: 'concrete-raw', floor: 'industrial-floor' }, { wall: 'brick', wallSide: 'painted-flat', ceiling: 'concrete-raw', floor: 'industrial-floor' },
{ wall: '#8a5040', wallSide: '#f5f5f5', ceiling: '#989898', floor: '#888880', trim: '#606060' }, { wall: '#8a5040', wallSide: '#858585', ceiling: '#989898', floor: '#888880', trim: '#606060' },
{ {
details: 'industrial', details: 'loft',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#303030', titleColor: '#303030',
ambient: 0.65, ambient: 0.65,
windows: windows( windows: windows(
@@ -551,9 +638,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Pop Art gallery', 'Pop Art gallery',
'White painted walls · fluorescent daylight · polished concrete', 'White painted walls · fluorescent daylight · polished concrete',
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' }, { wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#d8d8d8', trim: '#cccccc' }, { wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#d8d8d8', trim: '#cccccc' },
{ {
details: 'modern', details: 'white-cube',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#222222', titleColor: '#222222',
ambient: 0.75, ambient: 0.75,
warmLight: '#ffffff', warmLight: '#ffffff',