diff --git a/Documentation/basics.md b/Documentation/basics.md
index e301693..f014b5b 100644
--- a/Documentation/basics.md
+++ b/Documentation/basics.md
@@ -37,7 +37,8 @@ Gallery/
│ │ ├── components/GalleryLoadingMarker.tsx # Loading spinner overlay/banner (catalog, portraits, halls)
│ │ ├── components/GalleryWindows.tsx # Side-wall daylight windows (movement)
│ │ ├── 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
│ │ ├── utils/movementHallLayout.ts # Wing split + window gap placement
│ │ ├── 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) |
| 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 **1–26** → authored keys **27–52**, 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 |
| 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 |
| 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 |
| 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** |
| 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) |
@@ -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 ~100–200 lights — walls, floor, and ceiling then rendered as a black void while painting canvases (`MeshBasicMaterial`) still showed. Halls therefore use **shared lighting only**: ambient / hemisphere / directional fill, a small set of ceiling track spots (`GalleryTrackLights`, capped), and **one** window spot each. Painting canvases stay unlit (`MeshBasicMaterial`); frames and influence fixtures use **emissive** accents instead of per-frame lights. Intensities are scaled for three.js physical lights (post-r155).
-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.15–0.27 luminance.
+
+**Wall brightness is albedo × `lightScale`.** Gallery walls are authored well below white for that reason: the reworked interiors sit around **0.50–0.56** wall luminance (0.66 for the two white-cube halls), landing at an effective 0.14–0.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`).
diff --git a/Documentation/data-and-images.md b/Documentation/data-and-images.md
index 65b18f6..62ee326 100644
--- a/Documentation/data-and-images.md
+++ b/Documentation/data-and-images.md
@@ -278,9 +278,38 @@ To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the cli
## Movement gallery interiors (frontend)
-Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (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.50–0.56** relative luminance (0.66 for the Suprematist and Pop white cubes, 0.15–0.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 movement’s look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
diff --git a/client/src/components/MovementHallDetails.tsx b/client/src/components/MovementHallDetails.tsx
index 9dfef1a..5500a07 100644
--- a/client/src/components/MovementHallDetails.tsx
+++ b/client/src/components/MovementHallDetails.tsx
@@ -1,6 +1,38 @@
import { useMemo } from 'react';
-import * as THREE from 'three';
-import type { MovementInteriorStyle } from '../data/movement-interior-styles';
+import type { MovementInteriorStyle, GalleryWindowSpec } 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 {
style: MovementInteriorStyle;
@@ -8,99 +40,11 @@ interface Props {
depth: number;
halfW: 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 }) {
- 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 (
-
- {pilasterPositions.map(([x, z], i) => (
-
-
-
-
-
-
-
-
-
-
- ))}
-
- {/* 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) => (
-
-
-
-
- ))}
-
- {/* 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 (
-
-
-
-
- );
- })
- )}
-
- );
-}
-
-function BaroqueDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
- return (
-
- {/* 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) => (
-
-
-
-
- ))}
-
- {/* Wall panels */}
- {[-halfW + 0.12, halfW - 0.12].map((x, i) => (
-
-
-
-
- ))}
-
-
-
-
-
-
-
- );
-}
-
-function GothicDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
+function GothicDetails({ width, depth, halfW, halfD, trim }: Omit & { trim: string }) {
// 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
// never intrude on the ~0.7 m frame hang margin.
@@ -292,102 +236,75 @@ function ByzantineDetails({ halfW, halfD, trim }: Pick
);
}
-function NeoclassicalDetails({ halfW, halfD, trim }: Pick & { trim: string }) {
- // Shallow engaged corner pilasters only. Freestanding columns (~0.5m into the
- // room) overlap the outermost frames — hang margin is only ~0.7m from each end.
- const pilasters = useMemo(
- () =>
- [
- [-halfW + 0.06, -halfD + 0.06],
- [halfW - 0.06, -halfD + 0.06],
- [-halfW + 0.06, halfD - 0.06],
- [halfW - 0.06, halfD - 0.06],
- ] as [number, number][],
- [halfW, halfD]
- );
-
- return (
-
- {pilasters.map(([x, z], i) => (
-
-
-
-
-
-
-
-
-
-
- ))}
- {/* Cornice above wall title (title sits ~y 3.75) */}
-
-
-
-
-
- );
-}
-
-function ClassicalDetails({ halfW, halfD, trim }: Pick & { trim: string }) {
- // Engaged corner shafts only — anything proud of the corner pocket covers frames.
- const columns = useMemo(
- () =>
- [
- [-halfW + 0.06, -halfD + 0.06],
- [halfW - 0.06, -halfD + 0.06],
- [-halfW + 0.06, halfD - 0.06],
- [halfW - 0.06, halfD - 0.06],
- ] as [number, number][],
- [halfW, halfD]
- );
-
- return (
-
- {columns.map(([x, z], i) => (
-
-
-
-
-
-
-
-
-
-
- ))}
-
- );
-}
-
-function SalonDetails({ trim }: { trim: string }) {
- return (
-
-
-
-
- );
-}
-
-export default function MovementHallDetails({ style, width, depth, halfW, halfD }: Props) {
+/**
+ * Period architecture for a movement hall.
+ *
+ * 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
+ * `hall-details/`, sharing the geometry budget documented in
+ * `hall-details/primitives.tsx`.
+ */
+export default function MovementHallDetails({ style, width, depth, halfW, halfD, windows }: Props) {
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) {
- case 'palazzo':
- return ;
- case 'baroque':
- return ;
case 'gothic':
- return ;
+ return ;
case 'byzantine':
return ;
+ case 'roman':
+ return ;
+ case 'quattrocento':
+ return ;
+ case 'cinquecento':
+ return ;
+ case 'flemish':
+ return ;
+ case 'mannerist':
+ return ;
+ case 'baroque':
+ return ;
+ case 'rococo':
+ return ;
case 'neoclassical':
- return ;
- case 'classical':
- return ;
- case 'salon':
- return ;
- default:
- return null;
+ return ;
+ case 'gothic-revival':
+ return ;
+ case 'bourgeois':
+ return ;
+ case 'north-light':
+ return ;
+ case 'paris-atelier':
+ return ;
+ case 'symbolist':
+ return ;
+ case 'art-nouveau':
+ return ;
+ case 'fauvist':
+ return ;
+ case 'expressionist':
+ return ;
+ case 'cubist-atelier':
+ return ;
+ case 'futurist':
+ return ;
+ case 'suprematist':
+ return ;
+ case 'constructivist':
+ return ;
+ case 'dada':
+ return ;
+ case 'surrealist':
+ return ;
+ case 'loft':
+ return ;
+ case 'white-cube':
+ return ;
}
}
diff --git a/client/src/components/VirtualGallery.tsx b/client/src/components/VirtualGallery.tsx
index d7c4529..34e311f 100644
--- a/client/src/components/VirtualGallery.tsx
+++ b/client/src/components/VirtualGallery.tsx
@@ -15,7 +15,12 @@ import type {
import { galleryImageUrlCandidates, imageUrl, api } from '../api/client';
import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
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 MovementHallDetails from './MovementHallDetails';
import GalleryWindows, { GalleryTrackLights } from './GalleryWindows';
@@ -84,6 +89,15 @@ interface TourGalleryProps extends BaseGalleryProps {
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([
+ 'futurist',
+ 'suprematist',
+ 'constructivist',
+ 'loft',
+ 'white-cube',
+]);
+
const WALL_HEIGHT = 4.2;
const WALL_THICKNESS = 0.18;
const MOUNT_OFFSET = 0.16;
@@ -1739,7 +1753,14 @@ function ArtistHall({
{interiorStyle && (
-
+
)}
diff --git a/client/src/components/hall-details/classical.tsx b/client/src/components/hall-details/classical.tsx
new file mode 100644
index 0000000..c2343c8
--- /dev/null
+++ b/client/src/components/hall-details/classical.tsx
@@ -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 (
+
+ {/* Pompeian socle with a black skirting course */}
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+
+
+
+
+ ))}
+
+ {/* Engaged half-columns on the bay rhythm */}
+ {bays.map((z, i) => (
+
+ {[-1, 1].map((side) => (
+
+
+
+
+
+
+
+ ))}
+
+ ))}
+
+ {/* Entablature: architrave, frieze and dentils along the colonnaded walls */}
+ {(['left', 'right'] as const).map((wall) => (
+
+
+
+ {dentils.map((z, i) => (
+
+
+
+
+ ))}
+
+ ))}
+
+ {/* Projecting cornice returning around the whole room */}
+
+
+ {/* Corner antae — square pilasters closing the order at each corner */}
+ {corners.map(([x, z], i) => (
+
+
+
+
+ ))}
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* Pietra serena dado with a moulded cap */}
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+
+
+
+ ))}
+
+ {/* Flat pilasters carrying the arcade */}
+ {bays.map((z, i) => (
+
+ {[-1, 1].map((side) => (
+
+
+
+
+
+
+
+
+
+ {/* Simplified Corinthian capital */}
+
+
+
+
+
+
+
+
+
+ ))}
+
+ ))}
+
+ {/* Blind arcade between the pilasters, plus a roundel in each spandrel */}
+ {bays.slice(0, -1).map((z, i) => {
+ const mid = z + step / 2;
+ return (
+
+ {([-1, 1] as const).map((side) => (
+
+
+ {/* Glazed tondo, only where no window claims the bay */}
+ {(side < 0 ? clearLeft : clearRight)(mid, 0.22) && (
+
+
+
+
+ )}
+
+ ))}
+
+ );
+ })}
+
+ {/* Painted beam ceiling over the arcade */}
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* Panelled marble dado */}
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+
+
+
+
+ ))}
+
+ {/* Paired fluted pilasters */}
+ {bays.map((z, i) => (
+
+ {[-1, 1].map((side) =>
+ [-0.19, 0.19].map((dz) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))
+ )}
+
+ ))}
+
+ {/* Full entablature on the pilastered walls, cornice all round */}
+ {(['left', 'right'] as const).map((wall) => (
+
+
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/client/src/components/hall-details/courtly.tsx b/client/src/components/hall-details/courtly.tsx
new file mode 100644
index 0000000..706f923
--- /dev/null
+++ b/client/src/components/hall-details/courtly.tsx
@@ -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 (
+
+ {/* Wainscot ground, cap rail and skirting */}
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+
+
+
+
+ ))}
+
+ {/* Linenfold ribs across the wainscot */}
+ {(['left', 'right'] as const).map((wall) =>
+ folds.map((z, i) => (
+
+
+
+
+ ))
+ )}
+ {backFolds.map((x, i) => (
+
+
+
+
+ ))}
+
+ {/* Carved corbels carrying the beams */}
+ {beams.map((z, i) => (
+
+ {[-1, 1].map((side) => (
+
+
+
+
+ ))}
+
+ ))}
+
+
+
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* Heavy blocked socle */}
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+ ))}
+
+ {/* Herm pilasters — wider at the shoulder than at the foot */}
+ {bays.map((z, i) => (
+
+ {[-1, 1].map((side) => (
+
+
+
+
+
+ {/* Rustication blocks interrupting the shaft */}
+ {bands.map((y) => (
+
+
+
+
+ ))}
+ {/* Scroll console instead of a capital */}
+
+
+
+
+
+
+
+
+
+ ))}
+
+ ))}
+
+ {/* Broken cornice — segments that stop short of each bay */}
+ {(['left', 'right'] as const).map((wall) => (
+
+
+
+
+ ))}
+
+
+ {/* 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 (
+
+ {([-1, 1] as const)
+ .filter((side) => (side < 0 ? clearLeft : clearRight)(mid, 0.24))
+ .map((side) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+ })}
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* 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 (
+
+ {([-1, 1] as const)
+ .filter((side) => (side < 0 ? clearLeft : clearRight)(mid, w / 2 + 0.1))
+ .map((side) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+ })}
+
+ {/* Solomonic corner columns — twist read as stacked, rotated drums */}
+ {corners.map(([x, z], i) => (
+
+
+ {Array.from({ length: 14 }, (_, d) => (
+
+
+
+
+ ))}
+
+
+
+
+
+ ))}
+
+ {/* Modillion brackets under a heavy gilt cornice */}
+ {(['left', 'right'] as const).map((wall) =>
+ modillions.map((z, i) => (
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* Boiserie dado with a moulded chair rail */}
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+
+
+
+
+ ))}
+
+ {/* Panel styles with quarter-round corners, and a rocaille cartouche above */}
+ {bays.map((z, i) => (
+
+ {[-1, 1].map((side) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ ))}
+
+ {/* Painted cove: two stacked, slightly stepped bands reading as a curve */}
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* Plinth course under the hang */}
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+
+
+
+ ))}
+
+ {/* Fluted Ionic pilasters */}
+ {bays.map((z, i) => (
+
+ {[-1, 1].map((side) => (
+
+
+
+
+
+
+
+ ))}
+
+ ))}
+
+ {/* Meander frieze — alternating key blocks read as a running fret */}
+ {(['left', 'right'] as const).map((wall) => (
+
+
+ {meander.map((z, i) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/hall-details/geometry.ts b/client/src/components/hall-details/geometry.ts
new file mode 100644
index 0000000..a1f0ec0
--- /dev/null
+++ b/client/src/components/hall-details/geometry.ts
@@ -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]
+ );
+}
diff --git a/client/src/components/hall-details/modernism.tsx b/client/src/components/hall-details/modernism.tsx
new file mode 100644
index 0000000..6b6626c
--- /dev/null
+++ b/client/src/components/hall-details/modernism.tsx
@@ -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 (
+
+
+
+
+ {/* Folded-back plank shutters flanking the windows */}
+ {shutters.map((s, i) => (
+
+
+
+
+
+ {[-0.3, 0, 0.3].map((t) => (
+
+
+
+
+ ))}
+
+ ))}
+
+
+
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* Canted battens, alternating lean */}
+ {bays.map((z, i) => (
+
+ {[-1, 1].map((side) => (
+
+
+
+
+ ))}
+
+ ))}
+
+ {/* Zig-zag frieze under the ceiling */}
+ {(['left', 'right'] as const).map((wall) =>
+ bays.map((z, i) => (
+
+
+
+
+ ))
+ )}
+
+ {/* Ceiling members deliberately off-square */}
+ {bays
+ .filter((_, i) => i % 3 === 0)
+ .map((z, i) => (
+
+
+
+
+ ))}
+
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* Cast-iron columns in the corner pockets */}
+ {corners.map(([x, z], i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ {/* Riveted tie rods spanning the room, with a turnbuckle at mid-span */}
+ {ties.map((z, i) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ {/* Faceted cornice: flat planes tilted alternately */}
+ {(['left', 'right'] as const).map((wall) =>
+ facets.map((z, i) => (
+
+
+
+
+ ))
+ )}
+
+
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* Riveted stanchions */}
+ {bays.map((z, i) => (
+
+ {[-1, 1].map((side) => (
+
+
+
+
+
+ {[0.6, 1.7, 2.8, 3.8].map((y) => (
+
+
+
+
+ ))}
+
+ ))}
+
+ ))}
+
+ {/* 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 (
+
+ {[-1, 1].map((side) =>
+ [-1, 1].map((dir) => (
+
+
+
+
+ ))
+ )}
+
+ );
+ })}
+
+ {/* Gantry rail and its brackets */}
+
+
+
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* Icon-corner shelf, carried on two plain brackets */}
+
+
+
+
+
+ {[-0.4, 0.4].map((t) => (
+
+
+
+
+ ))}
+
+
+ {/* Drifting bands above the hang */}
+ {(['left', 'right'] as const).map((wall) =>
+ bars.map((b, i) => (
+
+
+
+
+ ))
+ )}
+
+ {/* Shadow-gap reveal instead of a cornice */}
+
+
+
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* Lattice truss under the ceiling — top chord, bottom chord, diagonals */}
+
+
+ {bays.map((z, i) =>
+ [-0.9, 0.9].map((x) => (
+
+
+
+
+ ))
+ )}
+
+ {/* Proun panels folded into the corners */}
+ {corners.map(([x, z], i) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ {/* Steel tie rails at the hang line and the skirting */}
+
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+ ))}
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* Cornice, in pieces */}
+ {fragments.map((f, i) => (
+
+ ))}
+
+ {/* Picture rail, visibly out of level */}
+ {[-1, 1].map((side) => (
+
+
+
+
+ ))}
+
+ {/* Service pipe run, with a clumsy elbow into the ceiling */}
+
+
+
+
+
+
+
+
+
+ {/* Skirting that changes its mind halfway along */}
+
+
+
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* Sacks hanging under the ceiling */}
+ {sacks.map((z, i) =>
+ [-1, 0, 1].map((c) => (
+
+
+
+
+ ))
+ )}
+
+ {/* Drapery gathered along the top of the side walls */}
+ {(['left', 'right'] as const).map((wall) =>
+ sacks.map((z, i) => (
+
+
+
+
+ ))
+ )}
+
+ {/* A door that leads nowhere, standing in the corner pocket */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+ ))}
+
+ {/* A brazier glow in the opposite corner */}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* Cast-iron columns with corbelled caps */}
+ {corners.map(([x, z], i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ {/* Pressed-tin ceiling field on close-set joists */}
+
+
+
+ {/* Sprinkler main with drops */}
+
+
+
+
+ {joists
+ .filter((_, i) => i % 4 === 0)
+ .map((z, i) => (
+
+
+
+
+ ))}
+
+ {/* Steel skirting angle */}
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* Recessed fluorescent troffers */}
+ {troffers.map(([x, z], i) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ {/* Ceiling grid tees between the fittings */}
+ 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 */}
+
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/client/src/components/hall-details/nineteenth.tsx b/client/src/components/hall-details/nineteenth.tsx
new file mode 100644
index 0000000..dd36caa
--- /dev/null
+++ b/client/src/components/hall-details/nineteenth.tsx
@@ -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 (
+
+ {/* Panelled dado with a heavy moulded rail */}
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+
+
+
+
+ ))}
+
+ {/* Colonnettes between the bays, carrying an applied pointed arcade */}
+ {bays.map((z, i) => (
+
+ {[-1, 1].map((side) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ ))}
+
+ {/* 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 (
+
+ {([-1, 1] as const)
+ .filter((side) => (side < 0 ? clearLeft : clearRight)(mid, half))
+ .map((side) => (
+
+ {[-1, 1].map((dir) => (
+
+
+
+
+ ))}
+
+
+
+
+
+ ))}
+
+ );
+ })}
+
+ {/* Quatrefoil frieze under the cornice */}
+ {(['left', 'right'] as const).map((wall) =>
+ quatrefoils.map((z, i) => (
+
+ {[0, 1, 2, 3].map((q) => {
+ const a = (q / 4) * Math.PI * 2 + Math.PI / 4;
+ return (
+
+
+
+
+ );
+ })}
+
+ ))
+ )}
+
+
+
+ {[-1, 1].map((side) => (
+
+ ))}
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* Dado, chair rail, skirting */}
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+
+
+
+
+ ))}
+
+ {/* Picture rail just above the hang, as the period actually hung its walls */}
+
+
+ {/* Run cornice: cavetto, bead and a moulded frieze */}
+
+
+
+
+
+
+ {[-1, 1].map((side) => (
+
+ ))}
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* Skylight lantern: glazing bars across a shallow raised centre */}
+
+
+
+
+
+ {[-1, 1].map((side) => (
+
+
+
+
+ ))}
+
+ {/* Picture rail with hanging rods dropping to the frame line */}
+
+
+
+ {/* Coved cornice */}
+
+
+
+ {/* Dark skirting so the pale walls sit on something */}
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* 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 (
+
+
+
+
+
+
+
+
+
+ {[-1, 1].map((side) => (
+
+
+
+
+ ))}
+
+ );
+ })}
+
+ {/* Stove flue running up the end wall and out along the ceiling */}
+
+
+
+
+
+
+
+
+
+ {/* Board skirting and a rough shelf rail above the hang */}
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+
+
+
+ ))}
+
+ {/* A single bare bulb on a flex, the way the ateliers were actually lit */}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* Lacquered dado under a gilt bead */}
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+
+
+
+ ))}
+
+ {/* Stencilled frieze — a repeating lotus/flame motif under the cornice */}
+ {(['left', 'right'] as const).map((wall) => (
+
+
+ {motifs.map((z, i) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ ))}
+
+
+
+
+ {/* Portière rail across the end wall */}
+
+
+ {[-0.36, 0.36].map((t) => (
+
+ ))}
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ {/* Whiplash brackets: a stem up the wall curling into the ceiling */}
+ {bays.map((z, i) => (
+
+ {[-1, 1].map((side) => (
+
+
+
+
+
+ {[0, 1, 2, 3].map((s) => {
+ const t = (s + 1) / 4;
+ return (
+
+
+
+
+ );
+ })}
+ {/* Bud terminal */}
+
+
+
+
+
+ ))}
+
+ ))}
+
+ {/* Tendril cornice — a wave riding the top of every wall */}
+
+
+
+ {/* Sinuous dado line */}
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+
+
+
+ ))}
+
+ {[-0.3, 0.3].map((t) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/client/src/components/hall-details/primitives.tsx b/client/src/components/hall-details/primitives.tsx
new file mode 100644
index 0000000..3907d7c
--- /dev/null
+++ b/client/src/components/hall-details/primitives.tsx
@@ -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 (
+
+
+
+
+ );
+}
+
+/** 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 (
+
+ {walls.map((wall) => (
+
+ ))}
+
+ );
+}
+
+/** 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 (
+
+ {(['left', 'right'] as const).map((wall) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * 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 (
+
+
+
+
+
+ {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 (
+
+
+
+
+ );
+ })}
+
+ );
+}
+
+export function TuscanCapital({ y, size, color }: { y: number; size: number; color: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** 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 (
+
+
+
+
+
+ {[-1, 1].map((s) => (
+
+
+
+
+ ))}
+
+ );
+}
+
+/** Moulded plinth under a shaft. */
+export function ShaftBase({ size, color }: { size: number; color: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** 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 (
+
+ {blocks.map((b, i) => (
+
+
+
+
+ ))}
+
+ );
+}
+
+/** 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 (
+
+ {grid.cells.map((c, i) => (
+
+
+
+
+
+ {rosette && (
+
+
+
+
+ )}
+
+ ))}
+ {/* Ribs between the coffers */}
+ {Array.from({ length: grid.cols + 1 }, (_, c) => (
+
+
+
+
+ ))}
+ {Array.from({ length: grid.rows + 1 }, (_, r) => (
+
+
+
+
+ ))}
+
+ );
+}
+
+/** 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 (
+
+ {positions.map((p, i) => (
+
+
+
+
+ ))}
+
+ );
+}
+
+/** 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 (
+
+ {Array.from({ length: rings }, (_, i) => {
+ const t = (i + 1) / rings;
+ return (
+
+
+
+
+ );
+ })}
+
+ );
+}
+
+/**
+ * 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 (
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: arms }, (_, i) => {
+ const a = (i / arms) * Math.PI * 2;
+ return (
+
+
+
+
+ );
+ })}
+ {light && (
+
+ )}
+
+ );
+}
+
+/**
+ * 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/data/movement-interior-styles.ts b/client/src/data/movement-interior-styles.ts
index c2b4f69..2b7675a 100644
--- a/client/src/data/movement-interior-styles.ts
+++ b/client/src/data/movement-interior-styles.ts
@@ -54,9 +54,42 @@ export interface MovementInteriorStyle {
trackLights: number;
/** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */
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[] {
return specs;
}
@@ -99,9 +132,12 @@ const BY_MOVEMENT_ID: Record = {
'Roman atrium',
'Marble-clad villa · mosaic floor · clerestory daylight',
{ 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',
ambient: 0.62,
warmLight: '#fff8e8',
@@ -175,9 +211,12 @@ const BY_MOVEMENT_ID: Record = {
'Florentine palazzo',
'Lime-washed walls · terracotta accents · arched windows',
{ 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',
windows: windows(
{ 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 = {
'Roman palazzo',
'Marble and stucco · coffered ceiling · checker floor',
{ 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',
windows: windows(
{ 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 = {
'Flemish panel hall',
'Oak wainscoting · leaded glass · herringbone floor',
{ 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',
warmLight: '#ffe8c8',
windows: windows(
@@ -226,9 +271,12 @@ const BY_MOVEMENT_ID: Record = {
'Mannerist gallery',
'Dramatic stucco · elongated windows · veined marble',
{ 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',
windows: windows(
{ 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 = {
'Rococo salon',
'Pastel painted walls · gilt trim · chevron parquet',
{ 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',
warmLight: '#fff0f0',
windows: windows(
@@ -281,9 +332,12 @@ const BY_MOVEMENT_ID: Record = {
'Neoclassical museum',
'White painted walls · coffered ceiling · skylit salon',
{ 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',
+ // 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',
ambient: 0.65,
windows: windows(
@@ -301,7 +355,7 @@ const BY_MOVEMENT_ID: Record = {
{ wall: 'walnut-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#5a4030', ceiling: '#2a2420', floor: '#6a5040', trim: '#8a7050' },
{
- details: 'salon',
+ details: 'gothic-revival',
titleColor: '#e8dcc8',
ambient: 0.5,
warmLight: '#ffe0c0',
@@ -317,9 +371,12 @@ const BY_MOVEMENT_ID: Record = {
'Realist picture gallery',
'Warm painted walls · bourgeois salon · oak parquet',
{ 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(
{ 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 }
@@ -331,9 +388,12 @@ const BY_MOVEMENT_ID: Record = {
'Impressionist salon',
'North-light skylight · pale painted walls · herringbone floor',
{ 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,
windows: windows(
{ 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 = {
'Montmartre atelier',
'Painted studio walls · large north window · worn floorboards',
{ 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(
{ 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 }
@@ -364,7 +427,7 @@ const BY_MOVEMENT_ID: Record = {
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#1a4030', ceiling: '#1a1814', floor: '#4a3828', trim: '#8a7050' },
{
- details: 'salon',
+ details: 'symbolist',
titleColor: '#d8e8c8',
ambient: 0.55,
warmLight: '#ffd898',
@@ -380,9 +443,12 @@ const BY_MOVEMENT_ID: Record = {
'Art Nouveau salon',
'Painted plaster · stained glass · terrazzo floor',
{ 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',
windows: windows(
{ 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 = {
'Fauvist studio',
'Bold painted walls · flooded with color and light',
{ 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',
ambient: 0.7,
windows: windows(
@@ -414,7 +483,7 @@ const BY_MOVEMENT_ID: Record = {
{ wall: 'dark-wood-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#3a2818', ceiling: '#2a2018', floor: '#5a4030', trim: '#8a6040' },
{
- details: 'atelier',
+ details: 'expressionist',
titleColor: '#f0d8b0',
ambient: 0.52,
windows: windows(
@@ -428,9 +497,12 @@ const BY_MOVEMENT_ID: Record = {
'Cubist studio',
'Paris atelier · factory windows · painted plaster',
{ 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,
windows: windows(
{ 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 = {
'Futurist loft',
'Steel and glass · industrial concrete · sweeping daylight',
{ 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',
ambient: 0.62,
fog: '#181818',
@@ -462,9 +537,12 @@ const BY_MOVEMENT_ID: Record = {
'Suprematist white cube',
'Matte white paint · geometric light · polished floor',
{ 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',
ambient: 0.72,
fog: '#1a1a1a',
@@ -482,9 +560,12 @@ const BY_MOVEMENT_ID: Record = {
'Constructivist space',
'Glass block · concrete · angular daylight',
{ 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',
ambient: 0.65,
windows: windows(
@@ -500,9 +581,12 @@ const BY_MOVEMENT_ID: Record = {
'Dada salon',
'Eclectic bourgeois room · painted walls and exposed brick',
{ 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(
{ 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 },
@@ -518,7 +602,7 @@ const BY_MOVEMENT_ID: Record = {
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#1a2848', ceiling: '#2a2428', floor: '#5a4838', trim: '#8a7050' },
{
- details: 'salon',
+ details: 'surrealist',
titleColor: '#e8dcc8',
ambient: 0.58,
warmLight: '#ffd898',
@@ -535,9 +619,12 @@ const BY_MOVEMENT_ID: Record = {
'NYC loft',
'Raw brick and painted plaster · north-light factory windows',
{ 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',
ambient: 0.65,
windows: windows(
@@ -551,9 +638,12 @@ const BY_MOVEMENT_ID: Record = {
'Pop Art gallery',
'White painted walls · fluorescent daylight · polished concrete',
{ 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',
ambient: 0.75,
warmLight: '#ffffff',