Polish movement hall interiors: clear doors and bay columns, tone floors and windows.

Document hang keep-outs and period-module fixes; leave columns fixed while packing frames into gaps.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila KhodjaefandCursor committed 2026-09-15 12:02:10 +03:00
1 parent 1b760faf0d
commit 13f6f088b6
25 files changed
+1119 -290

No files matched your search

+1
View File
@@ -19,4 +19,5 @@ this file contains draft for future releases and features
10. ~~Tree of Art timeline (lineage as a growing tree)~~ — done: `MovementTree` + `movementTree.ts`, shareable `?layout=tree` — [movement-tree.md](movement-tree.md) 10. ~~Tree of Art timeline (lineage as a growing tree)~~ — done: `MovementTree` + `movementTree.ts`, shareable `?layout=tree` — [movement-tree.md](movement-tree.md)
11. ~~UI interaction and component standards~~ — done: shared doc + Cursor rule for client screens — [ui-interaction-and-component-standards.md](ui-interaction-and-component-standards.md) 11. ~~UI interaction and component standards~~ — done: shared doc + Cursor rule for client screens — [ui-interaction-and-component-standards.md](ui-interaction-and-component-standards.md)
12. ~~Movement lineage curator config (links + proof URLs + delete votes)~~ — done: `movement_lineage_links` / `movement_lineage_delete_votes`, `MovementLinksPage`, admin-only hard delete — [data-and-images.md](data-and-images.md#movement-lineage-timeline--tree-of-art) / [API.md](API.md#movement-lineage-links) 12. ~~Movement lineage curator config (links + proof URLs + delete votes)~~ — done: `movement_lineage_links` / `movement_lineage_delete_votes`, `MovementLinksPage`, admin-only hard delete — [data-and-images.md](data-and-images.md#movement-lineage-timeline--tree-of-art) / [API.md](API.md#movement-lineage-links)
13. ~~Movement hall interior polish (doors, columns, windows, floors)~~ — done: doorway `WallBand` clearance; painting↔bay-column keep-outs; no factory windows; floor luminance cap; period-module fixes (Dada/loft/Mannerism/Baroque/High Renaissance/…) — [data-and-images.md](data-and-images.md#movement-gallery-interiors-frontend) / [basics.md](basics.md#movement-galleries)
+6 -3
View File
@@ -40,7 +40,8 @@ Gallery/
│ │ ├── components/MovementHallDetails.tsx # Period architecture dispatcher (Gothic + Byzantine reference) │ │ ├── components/MovementHallDetails.tsx # Period architecture dispatcher (Gothic + Byzantine reference)
│ │ ├── components/hall-details/ # One period interior per movement + shared primitives │ │ ├── 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 + door/column-aware hang + window gaps
│ │ ├── utils/sideColumnKeepOuts.ts # Bay column Z keep-outs for side-wall hang
│ │ ├── utils/galleryProceduralTextures.ts # Hi-res wall/floor textures │ │ ├── utils/galleryProceduralTextures.ts # Hi-res wall/floor textures
│ │ ├── components/PaintingDetail.tsx # Detail view + debug panel │ │ ├── components/PaintingDetail.tsx # Detail view + debug panel
│ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel │ │ ├── components/ArtistBio.tsx # Biography + portrait debug panel
@@ -353,9 +354,11 @@ Enter from the home page by clicking a **movement name** on the movement flow. F
| Wings | Catalog split into wings of up to **55 works** (`movementHallLayout.ts`); large movements (e.g. Baroque) use multiple wings | | Wings | Catalog split into wings of up to **55 works** (`movementHallLayout.ts`); large movements (e.g. Baroque) use multiple wings |
| Paintings on walls | **Left, end, and right** — single-wing halls keep the far wall solid (full span); multi-wing halls hang end-wall works on panels beside the back exit | | Paintings on walls | **Left, end, and right** — single-wing halls keep the far wall solid (full span); multi-wing halls hang end-wall works on panels beside the back exit |
| Wall order | Same U-shaped hang as artist halls (left → end → right) | | Wall order | Same U-shaped hang as artist halls (left → end → right) |
| Column clearance | Side-wall frames **skip bay column/pilaster keep-outs** (`sideColumnKeepOuts.ts`); hall depth grows if needed — columns stay fixed. See [data-and-images.md](data-and-images.md#hang-layout-wings-doors-columns) |
| Door mouldings | Low end-wall `WallBand`s split around the exit (`DOOR_CLEAR_HALF` / `DOOR_CLEAR_Y` in `hall-details/geometry.ts`) so rails do not cross the door |
| 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 **1–26** → authored keys **27–52**, 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 **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` | | 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`. No `factory` (barred) windows — use `picture` / `ribbon` / etc. |
| 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 |
@@ -404,7 +407,7 @@ It defaults to **1**, but most halls now set it: Gothic uses **0.26**, Byzantine
**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. **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`). **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). Engaged / bay columns stay on the wall face; the hang **clears** their Z keep-outs rather than moving the shafts (`sideColumnKeepOuts.ts` + `movementHallLayout.ts`). Low mouldings clear exit doors via `WallBand` splits (`DOOR_CLEAR_HALF`).
**Surface textures:** Procedural colour maps use sRGB; **normal maps use `NoColorSpace`** (`galleryProceduralTextures.ts`). Wrong colour space on normals breaks wall shading. **Surface textures:** Procedural colour maps use sRGB; **normal maps use `NoColorSpace`** (`galleryProceduralTextures.ts`). Wrong colour space on normals breaks wall shading.
+18 -8
View File
@@ -292,31 +292,41 @@ Every movement is built from the architecture of **its own period** — no two m
| Module | Movements | | Module | Movements |
|--------|-----------| |--------|-----------|
| `MovementHallDetails.tsx` (reference implementations) | `gothic` — bay-spaced compound piers, vault springers, transverse ribs · `byzantine` — porphyry colonnettes, basket capitals, revetment dado, hanging polycandela | | `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/classical.tsx` | `roman` (Tuscan half-columns, dentil entablature, Pompeian socle) · `quattrocento` (pietra serena pilasters, blind arcade, tondi) · `cinquecento` (paired fluted Corinthian pilasters, plain coffered soffit — no gold rosettes; veined Carrara floor) |
| `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/courtly.tsx` | `flemish` (linenfold wainscot, beam-and-corbel ceiling; ribs skip the exit door) · `mannerist` (flush tapering herms, shallow rustication, broken cornice) · `baroque` (solomonic corner columns; gilt moulding rails around velvet panels, not solid slabs) · `rococo` (boiserie, rocaille cartouches, painted cove) · `neoclassical` (fluted Ionic order, meander frieze; cooler/darker floor tint) |
| `hall-details/nineteenth.tsx` | `gothic-revival` · `bourgeois` · `north-light` · `paris-atelier` · `symbolist` · `art-nouveau` | | `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/modernism.tsx` | `fauvist` · `expressionist` · `cubist-atelier` · `futurist` · `suprematist` · `constructivist` · `dada` (cream walls, collage panels, open `picture` windows — no factory bars) · `surrealist` (no freestanding “door to nowhere”) · `loft` (timber posts/joists, `picture` windows — no cast-iron columns or sprinkler pipe) · `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: `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 (`bayPositions` / `useBayZ`, `useWallClearance`, …) 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** - 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) - freestanding masses only inside the **0.34 m corner pocket**; nothing under 2.6 m within **`DOOR_CLEAR_HALF` (1.45 m)** of a wall centre (doorways). Low `WallBand`s on back/front walls **auto-split** around that clear zone (`DOOR_CLEAR_Y` ≈ 2.55 m)
- the end wall carries the hall title between **3.4 m and 3.9 m** — keep it clear - 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`) - 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 - at most **two added lights per hall**, so hall materials stay inside the WebGL light limits the shared track lighting already budgets for
**Window styles:** `GalleryWindows.tsx` includes open `picture` lights and thin-steel `ribbon` bands. Movement styles must **not** use `factory` (barred) windows — regression: `server/__tests__/no-factory-windows.test.js`.
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. 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 ### Wall / floor 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**: 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 wall brightness 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. - **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. - **`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.
**Floor tints** are capped at relative luminance **≤ 0.58** so checker/marble floors do not blow out under the same rig (`server/__tests__/floor-brightness.test.js`).
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. 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. ### Hang layout (wings, doors, columns)
Wing layout lives in `client/src/utils/movementHallLayout.ts` (up to 55 works per wing, U-shaped hang, window gap placement with packed-wall **clerestory** fallback). Visit order fills the **left wall** (first work at the entrance), then the **far/end wall**, then the **right** (last work at the entrance). Multi-wing halls keep a centre exit on the far wall and hang only on the door flanks.
**Paintings must not sit in front of mid-wall bay columns/pilasters.** Column Z centres use the same `bayPositions` formula as the period modules. Keep-out half-widths per `details` kind live in `client/src/utils/sideColumnKeepOuts.ts` (`SIDE_WALL_COLUMN_RHYTHMS`: gothic, roman, quattrocento, cinquecento, mannerist, neoclassical, gothic-revival, art-nouveau). Side-wall hang packs into the gaps between those keep-outs and **grows hall depth** when needed; columns are never moved. Windows also treat column keep-outs as occupied so they do not land on shafts. Regression: `server/__tests__/painting-column-clearance.test.js`.
The same U-hang applies to artist halls and guided tours (artist halls have no period bay columns). Hall lighting is **shared** (no per-painting spotlights) so `MeshStandardMaterial` walls stay within WebGL light limits — see [basics.md](basics.md) § Shared 3D behaviour. To change a movement’s look, edit its entry in `movement-interior-styles.ts` and rebuild the client.
## Historical event markers (frontend timeline) ## Historical event markers (frontend timeline)
+30 -1
View File
@@ -61,11 +61,13 @@ function WindowFrame({
return ( return (
<group> <group>
{/* Outer frame */} {/* Outer frame — picture/ribbon draw their own thin reveals */}
{style !== 'picture' && style !== 'ribbon' && (
<mesh position={[0, 0, -depth / 2]}> <mesh position={[0, 0, -depth / 2]}>
<boxGeometry args={[width + frameW * 2, height + frameW * 2, depth]} /> <boxGeometry args={[width + frameW * 2, height + frameW * 2, depth]} />
<meshStandardMaterial color={trimColor} roughness={0.35} metalness={0.45} /> <meshStandardMaterial color={trimColor} roughness={0.35} metalness={0.45} />
</mesh> </mesh>
)}
{arch && style === 'gothic-lancet' && ( {arch && style === 'gothic-lancet' && (
<> <>
@@ -180,6 +182,33 @@ function WindowFrame({
<meshStandardMaterial color={trimColor} roughness={0.3} metalness={0.55} /> <meshStandardMaterial color={trimColor} roughness={0.3} metalness={0.55} />
</mesh> </mesh>
)} )}
{/* Open picture window: thin dark reveal only — no mullion / iron-bar grid. */}
{style === 'picture' && (
<>
<mesh position={[0, 0, -depth / 2 + 0.015]}>
<boxGeometry args={[width + frameW * 1.2, height + frameW * 1.2, depth * 0.7]} />
<meshStandardMaterial color="#1a1a1a" roughness={0.42} metalness={0.55} />
</mesh>
{/* Single offset L-accent (collage frame), not a barred grid */}
<mesh position={[-width * 0.28, height * 0.22, -depth / 2 + 0.04]}>
<boxGeometry args={[frameW * 0.7, height * 0.42, depth * 0.5]} />
<meshStandardMaterial color="#c8302a" roughness={0.55} metalness={0.15} />
</mesh>
<mesh position={[-width * 0.08, height * 0.38, -depth / 2 + 0.04]}>
<boxGeometry args={[width * 0.36, frameW * 0.7, depth * 0.5]} />
<meshStandardMaterial color="#c8302a" roughness={0.55} metalness={0.15} />
</mesh>
</>
)}
{/* Open ribbon window: slim steel surround only — no bars. */}
{style === 'ribbon' && (
<mesh position={[0, 0, -depth / 2 + 0.015]}>
<boxGeometry args={[width + frameW * 1.1, height + frameW * 1.1, depth * 0.65]} />
<meshStandardMaterial color="#9aa0a8" roughness={0.35} metalness={0.72} />
</mesh>
)}
</group> </group>
); );
} }
@@ -1,6 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { MovementInteriorStyle, GalleryWindowSpec } from '../data/movement-interior-styles'; import type { MovementInteriorStyle, GalleryWindowSpec } from '../data/movement-interior-styles';
import type { RoomDims } from './hall-details/geometry'; import type { RoomDims } from './hall-details/geometry';
import { bayPositions } from './hall-details/geometry';
import { import {
CinquecentoDetails, CinquecentoDetails,
QuattrocentoDetails, QuattrocentoDetails,
@@ -48,12 +49,7 @@ function GothicDetails({ width, depth, halfW, halfD, trim }: Omit<Props, 'style'
// 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.
const bays = useMemo(() => { const bays = useMemo(() => bayPositions(depth, halfD, 4.2, 9), [depth, halfD]);
const spacing = 4.2;
const count = Math.max(2, Math.min(9, Math.floor(depth / spacing)));
const step = depth / (count + 1);
return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1));
}, [depth, halfD]);
const shaftHeight = 3.5; const shaftHeight = 3.5;
+2 -2
View File
@@ -2177,8 +2177,8 @@ export default function VirtualGallery(props: Props) {
); );
const movementHalls = useMemo( const movementHalls = useMemo(
() => (isWingedHall ? buildAllMovementHallLayouts(paintings) : []), () => (isWingedHall ? buildAllMovementHallLayouts(paintings, interiorStyle?.details) : []),
[isWingedHall, paintings] [isWingedHall, paintings, interiorStyle?.details]
); );
const [hallIndex, setHallIndex] = useState(0); const [hallIndex, setHallIndex] = useState(0);
@@ -204,22 +204,22 @@ export function QuattrocentoDetails({ room, trim, windows }: PeriodProps) {
* Roman palazzo — High Renaissance. * Roman palazzo — High Renaissance.
* *
* Bramante's cinquecento order: paired fluted Corinthian pilasters on a wide * Bramante's cinquecento order: paired fluted Corinthian pilasters on a wide
* bay, a full marble entablature, panelled dado, and a rosetted coffer field * bay, a full marble entablature, panelled dado, and a plain coffered soffit —
* modelled on the Pantheon soffit. * no gold ring in every cell.
*/ */
export function CinquecentoDetails({ room, trim }: PeriodProps) { export function CinquecentoDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 5.2, 6); const bays = useBayZ(room.depth, room.halfD, 5.2, 6);
const marble = '#efe9dd'; const marble = '#d8d0c0';
const grey = shade(marble, -0.16); const grey = shade(marble, -0.14);
return ( return (
<group> <group>
{/* Panelled marble dado */} {/* Panelled marble dado */}
{(['left', 'right', 'back'] as const).map((wall) => ( {(['left', 'right', 'back'] as const).map((wall) => (
<group key={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={0.58} height={1.16} proud={0.06} color={grey} roughness={0.5} metalness={0.08} />
<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={1.2} height={0.1} proud={0.09} color={marble} roughness={0.46} metalness={0.09} />
<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} /> <WallBand wall={wall} room={room} y={0.07} height={0.14} proud={0.1} color={shade(grey, -0.18)} roughness={0.48} metalness={0.09} />
</group> </group>
))} ))}
@@ -235,11 +235,11 @@ export function CinquecentoDetails({ room, trim }: PeriodProps) {
</group> </group>
<mesh position={[0, 3.66, 0]}> <mesh position={[0, 3.66, 0]}>
<cylinderGeometry args={[0.15, 0.1, 0.24, 12]} /> <cylinderGeometry args={[0.15, 0.1, 0.24, 12]} />
<meshStandardMaterial color={marble} roughness={0.42} metalness={0.1} /> <meshStandardMaterial color={marble} roughness={0.48} metalness={0.08} />
</mesh> </mesh>
<mesh position={[0, 3.82, 0]}> <mesh position={[0, 3.82, 0]}>
<boxGeometry args={[0.12, 0.1, 0.34]} /> <boxGeometry args={[0.12, 0.1, 0.34]} />
<meshStandardMaterial color={shade(marble, 0.06)} roughness={0.4} metalness={0.11} /> <meshStandardMaterial color={shade(marble, 0.06)} roughness={0.46} metalness={0.09} />
</mesh> </mesh>
</group> </group>
)) ))
@@ -250,21 +250,21 @@ export function CinquecentoDetails({ room, trim }: PeriodProps) {
{/* Full entablature on the pilastered walls, cornice all round */} {/* Full entablature on the pilastered walls, cornice all round */}
{(['left', 'right'] as const).map((wall) => ( {(['left', 'right'] as const).map((wall) => (
<group key={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={3.92} height={0.1} proud={0.15} color={marble} roughness={0.48} metalness={0.08} />
<WallBand wall={wall} room={room} y={4.02} height={0.1} proud={0.12} color={grey} roughness={0.46} metalness={0.1} /> <WallBand wall={wall} room={room} y={4.02} height={0.1} proud={0.12} color={grey} roughness={0.5} metalness={0.08} />
</group> </group>
))} ))}
<CorniceRing room={room} y={4.12} height={0.09} proud={0.18} color={trim} roughness={0.4} metalness={0.28} /> <CorniceRing room={room} y={4.12} height={0.09} proud={0.18} color={trim} roughness={0.42} metalness={0.24} />
{/* Plain coffers — larger cells, no gold rosettes */}
<CofferedCeiling <CofferedCeiling
room={room} room={room}
y={4.08} y={4.08}
cell={2.6} cell={3.4}
panelColor="#f2ede3" panelColor="#e6e0d4"
ribColor="#ded5c4" ribColor="#cfc4b2"
ribWidth={0.2} ribWidth={0.22}
ribDrop={0.18} ribDrop={0.16}
rosette={trim}
/> />
</group> </group>
); );
+80 -44
View File
@@ -15,6 +15,7 @@ import {
useCorners, useCorners,
useRepeat, useRepeat,
useWallClearance, useWallClearance,
DOOR_CLEAR_HALF,
type PeriodProps, type PeriodProps,
} from './geometry'; } from './geometry';
@@ -32,11 +33,11 @@ export function FlemishDetails({ room, trim }: PeriodProps) {
const folds = useRepeat(room.depth, 0.55, 34); const folds = useRepeat(room.depth, 0.55, 34);
const backFolds = useRepeat(room.width, 0.55, 34); const backFolds = useRepeat(room.width, 0.55, 34).filter((x) => Math.abs(x) > DOOR_CLEAR_HALF);
return ( return (
<group> <group>
{/* Wainscot ground, cap rail and skirting */} {/* Wainscot ground, cap rail and skirting — WallBand clears the exit door */}
{(['left', 'right', 'back'] as const).map((wall) => ( {(['left', 'right', 'back'] as const).map((wall) => (
<group key={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={0.66} height={1.24} proud={0.06} color={oak} roughness={0.66} metalness={0.08} />
@@ -98,17 +99,26 @@ export function FlemishDetails({ room, trim }: PeriodProps) {
/** /**
* Mannerist gallery — Mannerism. * Mannerist gallery — Mannerism.
* *
* The order used against itself: herm pilasters that taper the wrong way, * The order used against itself: herm pilasters that taper the wrong way
* banded rustication blocking the shafts, oversized scroll consoles, and a * (narrow foot, wide shoulder), shallow banded rustication on the shaft, and a
* cornice deliberately broken over each bay. * block capital with a broken cornice. Flush to the wall — no floating edge
* strips, no torus capitals that peel into the room.
*/ */
export function ManneristDetails({ room, trim, windows }: PeriodProps) { export function ManneristDetails({ room, trim, windows }: PeriodProps) {
const clearLeft = useWallClearance(windows, 'left'); const clearLeft = useWallClearance(windows, 'left');
const clearRight = useWallClearance(windows, 'right'); const clearRight = useWallClearance(windows, 'right');
const bays = useBayZ(room.depth, room.halfD, 4.4, 7); const bays = useBayZ(room.depth, room.halfD, 4.8, 6);
const stone = '#ddc9ae'; const stone = '#b89a78';
const dark = shade(stone, -0.34); const dark = shade(stone, -0.28);
const bands = [0.55, 1.55, 2.35, 3.05]; const band = shade(stone, -0.14);
// Inverted herm taper: foot → shoulder widths along the wall (Z).
const hermSegs = [
{ y: 1.15, h: 0.7, w: 0.18 },
{ y: 1.9, h: 0.8, w: 0.22 },
{ y: 2.7, h: 0.8, w: 0.26 },
{ y: 3.4, h: 0.6, w: 0.3 },
] as const;
const bandYs = [1.55, 2.35, 3.05];
return ( return (
<group> <group>
@@ -120,27 +130,33 @@ export function ManneristDetails({ room, trim, windows }: PeriodProps) {
{/* Herm pilasters — wider at the shoulder than at the foot */} {/* Herm pilasters — wider at the shoulder than at the foot */}
{bays.map((z, i) => ( {bays.map((z, i) => (
<group key={i}> <group key={i}>
{[-1, 1].map((side) => ( {([-1, 1] as const)
.filter((side) => (side < 0 ? clearLeft : clearRight)(z, 0.28))
.map((side) => (
<group key={side} position={[side * (room.halfW - 0.05), 0, z]}> <group key={side} position={[side * (room.halfW - 0.05), 0, z]}>
<mesh position={[0, 1.9, 0]}> <ShaftBase size={0.11} color={dark} />
<boxGeometry args={[0.09, 3.8, 0.3]} /> {/* Stacked tapering shaft (proud ≤ 0.10 m) */}
{hermSegs.map((seg) => (
<mesh key={seg.y} position={[0, seg.y, 0]}>
<boxGeometry args={[0.1, seg.h, seg.w]} />
<meshStandardMaterial color={stone} roughness={0.78} metalness={0.05} /> <meshStandardMaterial color={stone} roughness={0.78} metalness={0.05} />
</mesh> </mesh>
{/* Rustication blocks interrupting the shaft */} ))}
{bands.map((y) => ( {/* Shallow rustication bands — same depth as shaft, slightly wider */}
<mesh key={y} position={[side * 0.03, y, 0]}> {bandYs.map((y) => (
<boxGeometry args={[0.14, 0.3, 0.44]} /> <mesh key={y} position={[0, y, 0]}>
<meshStandardMaterial color={shade(stone, -0.16)} roughness={0.86} metalness={0.04} /> <boxGeometry args={[0.105, 0.16, 0.34]} />
<meshStandardMaterial color={band} roughness={0.86} metalness={0.04} />
</mesh> </mesh>
))} ))}
{/* Scroll console instead of a capital */} {/* Block capital / console — clean abacus, no peeling torus */}
<mesh position={[0, 3.86, 0]} rotation={[0, Math.PI / 2, 0]}> <mesh position={[0, 3.78, 0]}>
<torusGeometry args={[0.14, 0.055, 8, 16, Math.PI * 1.5]} /> <boxGeometry args={[0.11, 0.14, 0.36]} />
<meshStandardMaterial color={shade(stone, 0.12)} roughness={0.66} metalness={0.08} /> <meshStandardMaterial color={shade(stone, 0.06)} roughness={0.7} metalness={0.06} />
</mesh> </mesh>
<mesh position={[0, 4.03, 0]}> <mesh position={[0, 3.92, 0]}>
<boxGeometry args={[0.16, 0.12, 0.46]} /> <boxGeometry args={[0.12, 0.1, 0.42]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.24} /> <meshStandardMaterial color={trim} roughness={0.48} metalness={0.28} />
</mesh> </mesh>
</group> </group>
))} ))}
@@ -162,16 +178,16 @@ export function ManneristDetails({ room, trim, windows }: PeriodProps) {
return ( return (
<group key={i}> <group key={i}>
{([-1, 1] as const) {([-1, 1] as const)
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, 0.24)) .filter((side) => (side < 0 ? clearLeft : clearRight)(mid, 0.28))
.map((side) => ( .map((side) => (
<group key={side} position={[side * (room.halfW - 0.07), 3.6, mid]} rotation={[0, Math.PI / 2, 0]}> <group key={side} position={[side * (room.halfW - 0.06), 3.55, mid]}>
<mesh> <mesh>
<sphereGeometry args={[0.17, 12, 8]} /> <boxGeometry args={[0.06, 0.28, 0.28]} />
<meshStandardMaterial color={shade(stone, 0.16)} roughness={0.66} metalness={0.08} /> <meshStandardMaterial color={shade(stone, 0.1)} roughness={0.7} metalness={0.06} />
</mesh> </mesh>
<mesh position={[0, 0, 0.04]}> <mesh position={[side * -0.02, 0, 0]}>
<torusGeometry args={[0.19, 0.03, 6, 18]} /> <boxGeometry args={[0.04, 0.18, 0.18]} />
<meshStandardMaterial color={trim} roughness={0.44} metalness={0.4} /> <meshStandardMaterial color={trim} roughness={0.45} metalness={0.35} />
</mesh> </mesh>
</group> </group>
))} ))}
@@ -186,8 +202,8 @@ export function ManneristDetails({ room, trim, windows }: PeriodProps) {
* Baroque state gallery — Baroque. * Baroque state gallery — Baroque.
* *
* Twisted solomonic half-columns at the corners, gilt-framed velvet field * Twisted solomonic half-columns at the corners, gilt-framed velvet field
* panels between the bays, a modillion cornice, and a crystal-and-gilt * panels between the bays (true moulding rails, not solid gilt slabs), a
* chandelier over the centre of the room. * modillion cornice, and a crystal-and-gilt chandelier over the centre.
*/ */
export function BaroqueStateDetails({ room, trim, windows }: PeriodProps) { export function BaroqueStateDetails({ room, trim, windows }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 4.2, 8); const bays = useBayZ(room.depth, room.halfD, 4.2, 8);
@@ -195,28 +211,48 @@ export function BaroqueStateDetails({ room, trim, windows }: PeriodProps) {
const clearLeft = useWallClearance(windows, 'left'); const clearLeft = useWallClearance(windows, 'left');
const clearRight = useWallClearance(windows, 'right'); const clearRight = useWallClearance(windows, 'right');
const gilt = trim; const gilt = trim;
const velvet = '#4a1220';
const modillions = useRepeat(room.depth, 0.86, 26); const modillions = useRepeat(room.depth, 0.86, 26);
return ( return (
<group> <group>
{/* Gilt-edged velvet field panels between the bays, clear of the windows */} {/* Gilt-framed velvet field panels between the bays, clear of the windows */}
{bays.slice(0, -1).map((z, i) => { {bays.slice(0, -1).map((z, i) => {
const mid = (z + bays[i + 1]) / 2; const mid = (z + bays[i + 1]) / 2;
const w = Math.min(1.5, Math.max(0.7, (bays[i + 1] - z) * 0.4)); const gap = bays[i + 1] - z;
const fieldW = Math.min(1.15, Math.max(0.55, gap * 0.32));
const fieldH = 0.9;
const rail = 0.07;
// Extra margin so rails never crowd / clip window openings.
const halfClear = fieldW / 2 + rail + 0.35;
return ( return (
<group key={i}> <group key={i}>
{([-1, 1] as const) {([-1, 1] as const)
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, w / 2 + 0.1)) .filter((side) => (side < 0 ? clearLeft : clearRight)(mid, halfClear))
.map((side) => ( .map((side) => (
<group key={side} position={[side * (room.halfW - 0.055), 3.2, mid]}> <group key={side} position={[side * (room.halfW - 0.05), 3.15, mid]}>
{/* Velvet field — faces the room */}
<mesh> <mesh>
<boxGeometry args={[0.03, 1.0, w]} /> <boxGeometry args={[0.028, fieldH, fieldW]} />
<meshStandardMaterial color="#4a1220" roughness={0.9} metalness={0.04} /> <meshStandardMaterial color={velvet} roughness={0.92} metalness={0.03} />
</mesh> </mesh>
<mesh position={[-side * 0.015, 0, 0]}> {/* Gilt moulding rails around the field (not a covering slab) */}
<boxGeometry args={[0.04, 1.12, w + 0.12]} /> <mesh position={[0, fieldH / 2 + rail / 2, 0]}>
<meshStandardMaterial color={gilt} roughness={0.3} metalness={0.7} emissive="#3a2808" emissiveIntensity={0.06} /> <boxGeometry args={[0.036, rail, fieldW + rail * 2]} />
<meshStandardMaterial color={gilt} roughness={0.38} metalness={0.62} />
</mesh>
<mesh position={[0, -(fieldH / 2 + rail / 2), 0]}>
<boxGeometry args={[0.036, rail, fieldW + rail * 2]} />
<meshStandardMaterial color={gilt} roughness={0.38} metalness={0.62} />
</mesh>
<mesh position={[0, 0, fieldW / 2 + rail / 2]}>
<boxGeometry args={[0.036, fieldH + rail * 2, rail]} />
<meshStandardMaterial color={gilt} roughness={0.38} metalness={0.62} />
</mesh>
<mesh position={[0, 0, -(fieldW / 2 + rail / 2)]}>
<boxGeometry args={[0.036, fieldH + rail * 2, rail]} />
<meshStandardMaterial color={gilt} roughness={0.38} metalness={0.62} />
</mesh> </mesh>
</group> </group>
))} ))}
@@ -405,8 +441,8 @@ export function NeoclassicalDetails({ room, trim }: PeriodProps) {
room={room} room={room}
y={4.06} y={4.06}
cell={2.9} cell={2.9}
panelColor="#f7f6f2" panelColor="#e6e2da"
ribColor="#e2ded4" ribColor="#cfc8bc"
ribWidth={0.18} ribWidth={0.18}
ribDrop={0.13} ribDrop={0.13}
/> />
+11 -5
View File
@@ -1,5 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { GalleryWindowSpec } from '../../data/movement-interior-styles'; import type { GalleryWindowSpec } from '../../data/movement-interior-styles';
import { bayPositions } from '../../utils/sideColumnKeepOuts';
/** /**
* Shared geometry budget for every period interior. * Shared geometry budget for every period interior.
@@ -27,6 +28,13 @@ export const WALL_INNER = 0.09;
export const CEILING_Y = 4.13; export const CEILING_Y = 4.13;
/** Sink bands this far into the wall so they never z-fight with the surface. */ /** Sink bands this far into the wall so they never z-fight with the surface. */
export const EMBED = 0.02; export const EMBED = 0.02;
/**
* Half-width of the exit/entrance door clear zone on back/front walls.
* Mouldings below ~2.6 m must stop outside ±DOOR_CLEAR_HALF from wall centre.
*/
export const DOOR_CLEAR_HALF = 1.45;
/** Bands whose bottom edge is below this height are treated as crossing the door. */
export const DOOR_CLEAR_Y = 2.55;
export type WallId = 'back' | 'front' | 'left' | 'right'; export type WallId = 'back' | 'front' | 'left' | 'right';
@@ -57,13 +65,11 @@ export function shade(hex: string, amount: number): string {
/** Bay rhythm along the depth axis — the spacing that drives piers and beams. */ /** 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[] { export function useBayZ(depth: number, halfD: number, spacing = 4.2, max = 9): number[] {
return useMemo(() => { return useMemo(() => bayPositions(depth, halfD, spacing, max), [depth, halfD, spacing, max]);
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]);
} }
export { bayPositions };
/** /**
* Evenly spaced positions centred on a wall, for the repeating small members — * Evenly spaced positions centred on a wall, for the repeating small members —
* dentils, linenfold ribs, modillions, stencil motifs, quatrefoils. * dentils, linenfold ribs, modillions, stencil motifs, quatrefoils.
+128 -126
View File
@@ -11,6 +11,7 @@ import {
useBayZ, useBayZ,
useCorners, useCorners,
useRepeat, useRepeat,
useWallClearance,
type PeriodProps, type PeriodProps,
} from './geometry'; } from './geometry';
@@ -409,81 +410,103 @@ export function ConstructivistDetails({ room, trim }: PeriodProps) {
} }
/** /**
* Dada salon — Dada. * Dada cabaret — Dada.
* *
* A bourgeois room being dismantled: cornice fragments that stop and restart at * Modern Cabaret Voltaire: cream gallery shell with collage panels (newsprint,
* different heights, a picture rail hung visibly out of level, and a service * ink black, scarlet) hung off-axis, a thin black rail, and open picture
* pipe left running across the wall where the moulding should be. * windows. No iron bars, no service pipe, no prison cell.
*/ */
export function DadaDetails({ room, trim }: PeriodProps) { export function DadaDetails({ room, trim, windows }: PeriodProps) {
const fragments = useMemo( const clearLeft = useWallClearance(windows, 'left');
const clearRight = useWallClearance(windows, 'right');
const ink = '#1a1a1a';
const scarlet = '#c8302a';
const newsprint = '#e8dfc8';
const mustard = '#d4a84b';
const collage = useMemo(
() => () =>
[ [
{ wall: 'left' as const, y: 4.04, span: 0.3, proud: 0.14 }, { wall: 'left' as const, z: -0.42, y: 3.15, w: 0.9, h: 0.55, color: scarlet, tilt: 0.12 },
{ wall: 'left' as const, y: 3.78, span: 0.18, proud: 0.1 }, { wall: 'left' as const, z: -0.08, y: 3.55, w: 1.15, h: 0.28, color: ink, tilt: -0.06 },
{ wall: 'right' as const, y: 4.12, span: 0.44, proud: 0.16 }, { wall: 'left' as const, z: 0.35, y: 3.05, w: 0.7, h: 0.7, color: newsprint, tilt: 0.18 },
{ wall: 'right' as const, y: 3.9, span: 0.22, proud: 0.09 }, { wall: 'right' as const, z: -0.3, y: 3.4, w: 1.0, h: 0.4, color: mustard, tilt: -0.1 },
{ wall: 'back' as const, y: 4.06, span: 0.35, proud: 0.13 }, { wall: 'right' as const, z: 0.2, y: 2.95, w: 0.55, h: 0.85, color: ink, tilt: 0.08 },
], { wall: 'right' as const, z: 0.48, y: 3.5, w: 0.8, h: 0.32, color: scarlet, tilt: 0.14 },
{ wall: 'back' as const, z: -0.35, y: 3.55, w: 0.75, h: 0.35, color: mustard, tilt: -0.05 },
{ wall: 'back' as const, z: 0.4, y: 3.2, w: 0.5, h: 0.6, color: ink, tilt: 0.2 },
] as const,
[] []
); );
const plaster = '#e6dac4';
const pipe = '#6a6259';
return ( return (
<group> <group>
{/* Cornice, in pieces */} {/* Collage panels above the hang — off-axis, cabaret colour */}
{fragments.map((f, i) => ( {collage.map((p, i) => {
<WallBand const along = p.z * (p.wall === 'back' ? room.halfW : room.halfD);
key={i} const clear =
wall={f.wall} p.wall === 'left' ? clearLeft : p.wall === 'right' ? clearRight : () => true;
room={room} if (!clear(along, p.w / 2)) return null;
y={f.y} const off = room.halfW - 0.06;
height={0.13} const pos: [number, number, number] =
proud={f.proud} p.wall === 'left'
span={f.span} ? [-off, p.y, along]
color={i % 2 === 0 ? plaster : shade(plaster, -0.18)} : p.wall === 'right'
roughness={0.76} ? [off, p.y, along]
metalness={0.05} : [along, p.y, -room.halfD + 0.06];
const rot: [number, number, number] =
p.wall === 'left' || p.wall === 'right' ? [p.tilt, 0, 0] : [0, 0, p.tilt];
return (
<mesh key={i} position={pos} rotation={rot}>
<boxGeometry
args={
p.wall === 'back'
? [p.w, p.h, 0.04]
: [0.04, p.h, p.w]
}
/> />
))} <meshStandardMaterial color={p.color} roughness={0.72} metalness={0.04} />
</mesh>
);
})}
{/* Picture rail, visibly out of level */} {/* Thin modern black rail — slight intentional skew */}
{[-1, 1].map((side) => ( {([-1, 1] as const).map((side) => (
<mesh key={side} position={[side * (room.halfW - 0.07), 2.62, 0]} rotation={[side * 0.035, 0, 0]}> <mesh key={side} position={[side * (room.halfW - 0.05), 2.58, 0]} rotation={[side * 0.02, 0, 0]}>
<boxGeometry args={[0.1, 0.08, room.depth * 0.86]} /> <boxGeometry args={[0.05, 0.04, room.depth * 0.82]} />
<meshStandardMaterial color={trim} roughness={0.6} metalness={0.18} /> <meshStandardMaterial color={ink} roughness={0.45} metalness={0.35} />
</mesh> </mesh>
))} ))}
{/* Service pipe run, with a clumsy elbow into the ceiling */} {/* Clean modern skirting */}
<mesh position={[-room.halfW + 0.16, 3.34, 0]} rotation={[Math.PI / 2, 0, 0]}> {(['left', 'right', 'back'] as const).map((wall) => (
<cylinderGeometry args={[0.06, 0.06, room.depth * 0.8, 10]} /> <WallBand key={wall} wall={wall} room={room} y={0.08} height={0.16} proud={0.05} color={ink} roughness={0.55} metalness={0.2} />
<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 */} {/* Shadow-gap cornice instead of crumbling moulding */}
<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} /> <CorniceRing room={room} y={4.05} height={0.04} proud={0.03} color="#e6e0d6" roughness={0.85} metalness={0.02} />
<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} /> <CorniceRing room={room} y={4.14} height={0.06} proud={0.1} color={trim} roughness={0.5} metalness={0.25} />
<WallBand wall="back" room={room} y={0.11} height={0.22} proud={0.08} color={plaster} roughness={0.78} metalness={0.04} />
<HangingFixture {/* Geometric hanging mobile (box collage, not a pipe) */}
position={[room.halfW * 0.32, 3.9, room.halfD * 0.2]} <group position={[room.halfW * 0.28, 3.95, room.halfD * 0.15]}>
dropLength={0.7} <mesh position={[0, -0.15, 0]}>
radius={0.2} <boxGeometry args={[0.02, 0.35, 0.02]} />
arms={3} <meshStandardMaterial color={ink} roughness={0.4} metalness={0.5} />
metalColor={trim} </mesh>
flameColor="#fff0cc" <mesh position={[-0.18, -0.38, 0]} rotation={[0, 0.4, 0.15]}>
glowColor="#ffb45a" <boxGeometry args={[0.28, 0.08, 0.08]} />
light <meshStandardMaterial color={scarlet} roughness={0.6} metalness={0.08} />
lightColor="#ffd0a0" </mesh>
lightIntensity={0.7} <mesh position={[0.16, -0.48, 0.05]} rotation={[0.2, -0.3, 0]}>
lightDistance={8} <boxGeometry args={[0.2, 0.2, 0.05]} />
/> <meshStandardMaterial color={mustard} roughness={0.65} metalness={0.06} />
</mesh>
<mesh position={[0.02, -0.62, -0.08]} rotation={[0, 0.6, 0.1]}>
<boxGeometry args={[0.14, 0.14, 0.04]} />
<meshStandardMaterial color={ink} roughness={0.55} metalness={0.2} />
</mesh>
<pointLight position={[0, -0.4, 0.2]} intensity={0.55} distance={7} color="#fff0d8" />
</group>
</group> </group>
); );
} }
@@ -492,8 +515,8 @@ export function DadaDetails({ room, trim }: PeriodProps) {
* Surrealist interior — Surrealism. * Surrealist interior — Surrealism.
* *
* The 1938 Exposition Internationale: sacks slung under the ceiling, drapery * 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 * gathered along the cornice, and a brazier glow in one corner. The room is
* lead anywhere. The room is furnished as an image, not as a gallery. * furnished as an image, not as a gallery — no freestanding corner door.
*/ */
export function SurrealistDetails({ room, trim }: PeriodProps) { export function SurrealistDetails({ room, trim }: PeriodProps) {
const sacks = useBayZ(room.depth, room.halfD, 2.6, 9); const sacks = useBayZ(room.depth, room.halfD, 2.6, 9);
@@ -528,22 +551,6 @@ export function SurrealistDetails({ room, trim }: PeriodProps) {
)) ))
)} )}
{/* 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} /> <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) => ( {(['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} /> <WallBand key={wall} wall={wall} room={room} y={0.11} height={0.22} proud={0.08} color="#2e2822" roughness={0.82} metalness={0.05} />
@@ -566,83 +573,78 @@ export function SurrealistDetails({ room, trim }: PeriodProps) {
} }
/** /**
* NYC loft — Abstract Expressionism. * Painter loft — Abstract Expressionism.
* *
* A Tenth Street cast-iron building: fluted iron columns on their corbelled * A Tenth Street studio after the painters moved in: whitewashed walls, square
* caps, a pressed-tin ceiling field, and the sprinkler main running the length * timber posts, open wood joists, and wide north-light picture windows. Warm
* of the room. Industrial fabric left exactly as the painters found it. * and open — not cast-iron bars, not a sprinkler cage.
*/ */
export function LoftDetails({ room, trim }: PeriodProps) { export function LoftDetails({ room, trim }: PeriodProps) {
const corners = useCorners(room.halfW, room.halfD, 0.26); const corners = useCorners(room.halfW, room.halfD, 0.26);
const joists = useBayZ(room.depth, room.halfD, 1.7, 13); const joists = useBayZ(room.depth, room.halfD, 2.0, 11);
const iron = '#5c5852'; const timber = '#8a7358';
const timberLight = '#c4a882';
return ( return (
<group> <group>
{/* Cast-iron columns with corbelled caps */} {/* Square timber posts — studio structure, not iron bars */}
{corners.map(([x, z], i) => ( {corners.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}> <group key={i} position={[x, 0, z]}>
<mesh position={[0, 0.12, 0]}> <mesh position={[0, 0.1, 0]}>
<cylinderGeometry args={[0.2, 0.24, 0.24, 12]} /> <boxGeometry args={[0.34, 0.2, 0.34]} />
<meshStandardMaterial color={shade(iron, -0.2)} roughness={0.62} metalness={0.42} /> <meshStandardMaterial color={shade(timber, -0.15)} roughness={0.82} metalness={0.04} />
</mesh> </mesh>
<mesh position={[0, 2.05, 0]}> <mesh position={[0, 2.05, 0]}>
<cylinderGeometry args={[0.11, 0.13, 3.6, 12]} /> <boxGeometry args={[0.22, 3.7, 0.22]} />
<meshStandardMaterial color={iron} roughness={0.56} metalness={0.48} /> <meshStandardMaterial color={timber} roughness={0.78} metalness={0.05} />
</mesh> </mesh>
<mesh position={[0, 3.92, 0]}> <mesh position={[0, 3.98, 0]}>
<cylinderGeometry args={[0.22, 0.13, 0.2, 12]} /> <boxGeometry args={[0.38, 0.16, 0.38]} />
<meshStandardMaterial color={iron} roughness={0.54} metalness={0.5} /> <meshStandardMaterial color={shade(timber, 0.08)} roughness={0.76} metalness={0.05} />
</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> </mesh>
</group> </group>
))} ))}
{/* Pressed-tin ceiling field on close-set joists */} {/* Open timber joists — painter studio ceiling */}
<CeilingBeams <CeilingBeams
room={room} room={room}
positions={joists} positions={joists}
axis="x" axis="x"
width={0.08} width={0.12}
height={0.09} height={0.18}
y={4.05} y={4.0}
color="#b4b0a6" color={timberLight}
roughness={0.48} roughness={0.78}
metalness={0.42} metalness={0.04}
/> />
<CeilingBeams <CeilingBeams
room={room} room={room}
positions={[-room.width * 0.24, 0, room.width * 0.24]} positions={[-room.width * 0.22, room.width * 0.22]}
axis="z" axis="z"
width={0.14} width={0.16}
height={0.16} height={0.2}
y={3.96} y={3.88}
color={iron} color={timber}
roughness={0.6} roughness={0.8}
metalness={0.4} metalness={0.04}
/> />
{/* Sprinkler main with drops */} {/* Soft wood skirting */}
<mesh position={[0, 3.78, 0]} rotation={[Math.PI / 2, 0, 0]}> {(['left', 'right', 'back'] as const).map((wall) => (
<cylinderGeometry args={[0.055, 0.055, room.depth * 0.92, 10]} /> <WallBand
<meshStandardMaterial color="#7a3f2c" roughness={0.66} metalness={0.4} /> key={wall}
</mesh> wall={wall}
{joists room={room}
.filter((_, i) => i % 4 === 0) y={0.1}
.map((z, i) => ( height={0.2}
<mesh key={i} position={[0, 3.68, z]}> proud={0.06}
<cylinderGeometry args={[0.02, 0.025, 0.16, 6]} /> color={trim}
<meshStandardMaterial color="#8a4a34" roughness={0.6} metalness={0.45} /> roughness={0.78}
</mesh> metalness={0.05}
/>
))} ))}
{/* Steel skirting angle */} <CorniceRing room={room} y={4.12} height={0.06} proud={0.08} color={shade(timberLight, 0.12)} roughness={0.8} metalness={0.03} />
{(['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> </group>
); );
} }
@@ -1,5 +1,14 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { CEILING_Y, EMBED, WALL_INNER, shade, type RoomDims, type WallId } from './geometry'; import {
CEILING_Y,
DOOR_CLEAR_HALF,
DOOR_CLEAR_Y,
EMBED,
WALL_INNER,
shade,
type RoomDims,
type WallId,
} from './geometry';
/** Wall-flush building blocks shared by the period interiors in this folder. */ /** Wall-flush building blocks shared by the period interiors in this folder. */
interface SurfaceProps { interface SurfaceProps {
@@ -32,9 +41,40 @@ export function WallBand({
span?: number; span?: number;
}) { }) {
const alongZ = wall === 'left' || wall === 'right'; const alongZ = wall === 'left' || wall === 'right';
const len = (alongZ ? room.depth : room.width) * span;
const thick = proud + EMBED * 2; const thick = proud + EMBED * 2;
const off = WALL_INNER + proud / 2 - EMBED; const off = WALL_INNER + proud / 2 - EMBED;
const matProps = {
color,
roughness,
metalness,
emissive,
emissiveIntensity,
};
// End-wall bands that sit in the door height must leave a clear gap for exits.
const crossesDoor =
!alongZ && y - height / 2 < DOOR_CLEAR_Y && room.halfW > DOOR_CLEAR_HALF + 0.35;
if (crossesDoor) {
const z = wall === 'back' ? -room.halfD + off : room.halfD - off;
const sideLen = room.halfW - DOOR_CLEAR_HALF;
const leftX = (-room.halfW - DOOR_CLEAR_HALF) / 2;
const rightX = (room.halfW + DOOR_CLEAR_HALF) / 2;
return (
<group>
<mesh position={[leftX, y, z]}>
<boxGeometry args={[sideLen, height, thick]} />
<meshStandardMaterial {...matProps} />
</mesh>
<mesh position={[rightX, y, z]}>
<boxGeometry args={[sideLen, height, thick]} />
<meshStandardMaterial {...matProps} />
</mesh>
</group>
);
}
const len = (alongZ ? room.depth : room.width) * span;
const args: [number, number, number] = alongZ ? [thick, height, len] : [len, height, thick]; const args: [number, number, number] = alongZ ? [thick, height, len] : [len, height, thick];
const pos: [number, number, number] = const pos: [number, number, number] =
wall === 'back' wall === 'back'
@@ -48,13 +88,7 @@ export function WallBand({
return ( return (
<mesh position={pos}> <mesh position={pos}>
<boxGeometry args={args} /> <boxGeometry args={args} />
<meshStandardMaterial <meshStandardMaterial {...matProps} />
color={color}
roughness={roughness}
metalness={metalness}
emissive={emissive}
emissiveIntensity={emissiveIntensity}
/>
</mesh> </mesh>
); );
} }
+60 -46
View File
@@ -11,7 +11,11 @@ export type GalleryWindowStyle =
| 'art-nouveau' | 'art-nouveau'
| 'glass-block' | 'glass-block'
| 'clerestory' | 'clerestory'
| 'round-oculus'; | 'round-oculus'
/** Thin frame, open glass — no mullion grid (modern / Dada cabaret). */
| 'picture'
/** Thin steel ribbon frame, open glass — Futurist / modern loft daylight. */
| 'ribbon';
export interface GalleryWindowSpec { export interface GalleryWindowSpec {
wall: 'back' | 'left' | 'right' | 'ceiling'; wall: 'back' | 'left' | 'right' | 'ceiling';
@@ -132,7 +136,7 @@ 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: '#8e8980', ceiling: '#9c9994', floor: '#c8b898', trim: '#a89878' }, { wall: '#8e8980', ceiling: '#9c9994', floor: '#a08868', trim: '#a89878' },
{ {
details: 'roman', details: 'roman',
// Lit like the basilica: shared lights held back so the walls read as // Lit like the basilica: shared lights held back so the walls read as
@@ -211,7 +215,7 @@ 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: '#89847e', ceiling: '#96948f', floor: '#c89070', trim: '#c9a227' }, { wall: '#89847e', ceiling: '#96948f', floor: '#a07050', trim: '#c9a227' },
{ {
details: 'quattrocento', details: 'quattrocento',
// Lit like the basilica: shared lights held back so the walls read as // Lit like the basilica: shared lights held back so the walls read as
@@ -228,9 +232,9 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
31: mk( 31: mk(
'high-renaissance-palazzo', 'high-renaissance-palazzo',
'Roman palazzo', 'Roman palazzo',
'Marble and stucco · coffered ceiling · checker floor', 'Marble and stucco · calm coffered ceiling · veined stone 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-veined-carrara' },
{ wall: '#918f8a', wallSide: '#8f8980', ceiling: '#9c9994', floor: '#ddd8cc', trim: '#c9a227' }, { wall: '#918f8a', wallSide: '#8f8980', ceiling: '#9c9994', floor: '#9a9080', trim: '#c9a227' },
{ {
details: 'cinquecento', details: 'cinquecento',
// Lit like the basilica: shared lights held back so the walls read as // Lit like the basilica: shared lights held back so the walls read as
@@ -241,7 +245,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
{ 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 },
{ wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.5, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 }, { wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.5, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 },
{ wall: 'right', x: 0, y: 3.0, width: 2.0, height: 1.5, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 }, { wall: 'right', x: 0, y: 3.0, width: 2.0, height: 1.5, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.8 },
{ wall: 'ceiling', x: 0, y: 0, width: 3.0, height: 2.0, style: 'round-oculus', lightColor: '#ffffff', lightIntensity: 4.0 } { wall: 'ceiling', x: 0, y: 0, width: 3.0, height: 2.0, style: 'round-oculus', lightColor: '#ffffff', lightIntensity: 3.2 }
), ),
} }
), ),
@@ -271,7 +275,7 @@ 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: '#98816b', ceiling: '#a79456', floor: '#dce8e0', trim: '#b8860b' }, { wall: '#98816b', ceiling: '#a79456', floor: '#7a8a82', trim: '#b8860b' },
{ {
details: 'mannerist', details: 'mannerist',
// Lit like the basilica: shared lights held back so the walls read as // Lit like the basilica: shared lights held back so the walls read as
@@ -290,7 +294,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Baroque state gallery', 'Baroque state gallery',
'Crimson velvet · gilded stucco · grand windows', 'Crimson velvet · gilded stucco · grand windows',
{ wall: 'velvet-crimson', ceiling: 'gilded-stucco', floor: 'marble-veined-carrara' }, { wall: 'velvet-crimson', ceiling: 'gilded-stucco', floor: 'marble-veined-carrara' },
{ wall: '#5c1828', ceiling: '#d8c070', floor: '#ece8e0', trim: '#d4af37' }, { wall: '#5c1828', ceiling: '#d8c070', floor: '#8a8078', trim: '#d4af37' },
{ {
details: 'baroque', details: 'baroque',
titleColor: '#f0d890', titleColor: '#f0d890',
@@ -312,7 +316,7 @@ 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: '#918a91', ceiling: '#9b9995', floor: '#c8a882', trim: '#d4af37' }, { wall: '#918a91', ceiling: '#9b9995', floor: '#9a7858', trim: '#d4af37' },
{ {
details: 'rococo', details: 'rococo',
// Lit like the basilica: shared lights held back so the walls read as // Lit like the basilica: shared lights held back so the walls read as
@@ -330,22 +334,22 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
36: mk( 36: mk(
'neoclassical-museum', 'neoclassical-museum',
'Neoclassical museum', 'Neoclassical museum',
'White painted walls · coffered ceiling · skylit salon', 'Cool painted walls · coffered ceiling · subdued stone floor',
{ wall: 'painted-flat', ceiling: 'plaster-white', floor: 'marble-veined-carrara' }, { wall: 'painted-flat', ceiling: 'plaster-white', floor: 'marble-veined-carrara' },
{ wall: '#908f8d', ceiling: '#9e9e9e', floor: '#eceae6', trim: '#b8b0a4' }, { wall: '#908f8d', ceiling: '#9e9e9e', floor: '#8a8680', trim: '#a0988c' },
{ {
details: 'neoclassical', details: 'neoclassical',
// Lit like the basilica: shared lights held back so the walls read as // Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled. // surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3, lightScale: 0.3,
titleColor: '#4a4844', titleColor: '#4a4844',
ambient: 0.65, ambient: 0.58,
windows: windows( windows: windows(
{ wall: 'back', x: 0, y: 2.6, width: 2.8, height: 2.0, style: 'baroque-pair', lightColor: '#ffffff', lightIntensity: 4.0 }, { wall: 'back', x: 0, y: 2.6, width: 2.8, height: 2.0, style: 'baroque-pair', lightColor: '#f4f2ec', lightIntensity: 3.2 },
{ wall: 'ceiling', x: 0, y: 0, width: 4.0, height: 2.5, style: 'skylight', lightColor: '#ffffff', lightIntensity: 5.0 }, { wall: 'ceiling', x: 0, y: 0, width: 4.0, height: 2.5, style: 'skylight', lightColor: '#f0eee8', lightIntensity: 3.2 },
{ wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.2, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.5 } { wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.2, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.2 }
), ),
trackLights: 0.7, trackLights: 0.65,
} }
), ),
37: mk( 37: mk(
@@ -388,7 +392,7 @@ 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: '#8f8c87', ceiling: '#9e9e9e', floor: '#c8a882', trim: '#c9a96e' }, { wall: '#8f8c87', ceiling: '#9e9e9e', floor: '#9a7858', trim: '#c9a96e' },
{ {
details: 'north-light', details: 'north-light',
// Lit like the basilica: shared lights held back so the walls read as // Lit like the basilica: shared lights held back so the walls read as
@@ -415,7 +419,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
// surfaces rather than blowing out. Window shafts are not scaled. // surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3, 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: 'ribbon', 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 }
), ),
} }
@@ -443,7 +447,7 @@ 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: '#8f8c87', ceiling: '#a0946b', floor: '#d8d0c4', trim: '#6a9868' }, { wall: '#8f8c87', ceiling: '#a0946b', floor: '#9a9084', trim: '#6a9868' },
{ {
details: 'art-nouveau', details: 'art-nouveau',
// Lit like the basilica: shared lights held back so the walls read as // Lit like the basilica: shared lights held back so the walls read as
@@ -471,7 +475,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
titleColor: '#402010', titleColor: '#402010',
ambient: 0.7, ambient: 0.7,
windows: windows( windows: windows(
{ wall: 'left', x: 0, y: 2.5, width: 3.0, height: 2.2, style: 'factory', lightColor: '#fff8f0', lightIntensity: 5.0 }, { wall: 'left', x: 0, y: 2.5, width: 3.0, height: 2.2, style: 'ribbon', lightColor: '#fff8f0', lightIntensity: 5.0 },
{ wall: 'back', x: 0, y: 2.8, width: 1.6, height: 1.2, style: 'sash', lightColor: '#fff0e0', lightIntensity: 2.5 } { wall: 'back', x: 0, y: 2.8, width: 1.6, height: 1.2, style: 'sash', lightColor: '#fff0e0', lightIntensity: 2.5 }
), ),
} }
@@ -488,14 +492,14 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
ambient: 0.52, ambient: 0.52,
windows: windows( windows: windows(
{ wall: 'back', x: -1.5, y: 2.6, width: 1.0, height: 1.6, style: 'sash', lightColor: '#ffe8c0', lightIntensity: 2.8 }, { wall: 'back', x: -1.5, y: 2.6, width: 1.0, height: 1.6, style: 'sash', lightColor: '#ffe8c0', lightIntensity: 2.8 },
{ wall: 'right', x: 0, y: 2.4, width: 2.0, height: 1.8, style: 'factory', lightColor: '#fff0d8', lightIntensity: 3.5 } { wall: 'right', x: 0, y: 2.4, width: 2.0, height: 1.8, style: 'ribbon', lightColor: '#fff0d8', lightIntensity: 3.5 }
), ),
} }
), ),
45: mk( 45: mk(
'cubist-studio', 'cubist-studio',
'Cubist studio', 'Cubist studio',
'Paris atelier · factory windows · painted plaster', 'Paris atelier · open ribbon windows · painted plaster',
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' }, { wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
{ wall: '#8c8a85', ceiling: '#999999', floor: '#9a8870', trim: '#888888' }, { wall: '#8c8a85', ceiling: '#999999', floor: '#9a8870', trim: '#888888' },
{ {
@@ -505,7 +509,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
lightScale: 0.3, 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: 'ribbon', lightColor: '#f0f8ff', lightIntensity: 5.5 },
{ wall: 'back', x: 0, y: 3.0, width: 1.8, height: 1.0, style: 'clerestory', lightColor: '#ffffff', lightIntensity: 2.5 } { wall: 'back', x: 0, y: 3.0, width: 1.8, height: 1.0, style: 'clerestory', lightColor: '#ffffff', lightIntensity: 2.5 }
), ),
} }
@@ -513,7 +517,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
46: mk( 46: mk(
'futurist-loft', 'futurist-loft',
'Futurist loft', 'Futurist loft',
'Steel and glass · industrial concrete · sweeping daylight', 'Steel and open glass · ribbon windows · industrial concrete',
{ wall: 'concrete-raw', ceiling: 'concrete-raw', floor: 'industrial-floor' }, { wall: 'concrete-raw', ceiling: 'concrete-raw', floor: 'industrial-floor' },
{ wall: '#858585', ceiling: '#7a7a7a', floor: '#888880', trim: '#606060' }, { wall: '#858585', ceiling: '#7a7a7a', floor: '#888880', trim: '#606060' },
{ {
@@ -525,8 +529,9 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
ambient: 0.62, ambient: 0.62,
fog: '#181818', fog: '#181818',
windows: windows( windows: windows(
{ wall: 'back', x: 0, y: 2.6, width: 4.0, height: 2.4, style: 'factory', lightColor: '#ffffff', lightIntensity: 6.0 }, { wall: 'back', x: 0, y: 2.65, width: 4.2, height: 2.2, style: 'ribbon', lightColor: '#ffffff', lightIntensity: 5.8 },
{ wall: 'left', x: 0, y: 2.8, width: 2.5, height: 1.6, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 3.5 }, { wall: 'left', x: -1.15, y: 2.75, width: 1.9, height: 1.55, style: 'ribbon', lightColor: '#f0f8ff', lightIntensity: 3.4 },
{ wall: 'left', x: 1.15, y: 2.75, width: 1.9, height: 1.55, style: 'ribbon', lightColor: '#f0f8ff', lightIntensity: 3.4 },
{ wall: 'ceiling', x: 0, y: 0, width: 3.0, height: 2.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 4.0 } { wall: 'ceiling', x: 0, y: 0, width: 3.0, height: 2.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 4.0 }
), ),
doorWood: ['#606060', '#707070', '#505050'], doorWood: ['#606060', '#707070', '#505050'],
@@ -537,7 +542,7 @@ 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: '#a8a8a8', ceiling: '#b3b3b3', floor: '#e0e0e0', trim: '#cccccc' }, { wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#8e8e8e', trim: '#cccccc' },
{ {
details: 'suprematist', details: 'suprematist',
// Lit like the basilica: shared lights held back so the walls read as // Lit like the basilica: shared lights held back so the walls read as
@@ -560,7 +565,7 @@ 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: '#858585', ceiling: '#7a7a7a', floor: '#c0c0c0', trim: '#808080' }, { wall: '#858585', ceiling: '#7a7a7a', floor: '#888888', trim: '#808080' },
{ {
details: 'constructivist', details: 'constructivist',
// Lit like the basilica: shared lights held back so the walls read as // Lit like the basilica: shared lights held back so the walls read as
@@ -578,21 +583,26 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
), ),
49: mk( 49: mk(
'dada-salon', 'dada-salon',
'Dada salon', 'Dada cabaret',
'Eclectic bourgeois room · painted walls and exposed brick', 'Modern cabaret · collage panels · open picture windows',
{ wall: 'painted-emulsion', wallSide: 'brick', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' }, { wall: 'painted-flat', wallSide: 'painted-emulsion', ceiling: 'painted-flat', floor: 'concrete-polished' },
{ wall: '#8b8478', wallSide: '#8a5040', ceiling: '#97948d', floor: '#8a6848', trim: '#6a4830' }, { wall: '#f0ebe3', wallSide: '#e4d8c4', ceiling: '#f5f1ea', floor: '#949088', trim: '#1a1a1a' },
{ {
details: 'dada', details: 'dada',
// Lit like the basilica: shared lights held back so the walls read as // Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled. // surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3, lightScale: 0.3,
titleColor: '#1a1a1a',
ambient: 0.68,
warmLight: '#fff6ea',
sunLight: '#f4f8ff',
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.6, y: 2.55, width: 1.5, height: 1.7, style: 'picture', lightColor: '#eef4ff', lightIntensity: 3.4 },
{ 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.7, y: 2.7, width: 1.1, height: 1.35, style: 'picture', lightColor: '#fff0d8', lightIntensity: 2.8 },
{ wall: 'right', x: 0, y: 2.4, width: 1.6, height: 1.6, style: 'factory', lightColor: '#ffffff', lightIntensity: 3.0 } { wall: 'right', x: 0, y: 2.5, width: 2.2, height: 1.9, style: 'picture', lightColor: '#f5f9ff', lightIntensity: 3.8 }
), ),
trackLights: 1.0, trackLights: 1.0,
doorWood: ['#2a2a2a', '#3a3a3a', '#1a1a1a'],
} }
), ),
50: mk( 50: mk(
@@ -616,21 +626,25 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
), ),
51: mk( 51: mk(
'abstract-expressionist-loft', 'abstract-expressionist-loft',
'NYC loft', 'Painter loft',
'Raw brick and painted plaster · north-light factory windows', 'Whitewashed plaster · warm timber · open north-light picture windows',
{ wall: 'brick', wallSide: 'painted-flat', ceiling: 'concrete-raw', floor: 'industrial-floor' }, { wall: 'painted-flat', wallSide: 'painted-emulsion', ceiling: 'plaster-warm', floor: 'industrial-floor' },
{ wall: '#8a5040', wallSide: '#858585', ceiling: '#989898', floor: '#888880', trim: '#606060' }, { wall: '#e8e2d6', wallSide: '#ddd5c6', ceiling: '#f0ebe2', floor: '#9a9288', trim: '#6a5848' },
{ {
details: 'loft', details: 'loft',
// Lit like the basilica: shared lights held back so the walls read as // Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled. // surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3, lightScale: 0.3,
titleColor: '#303030', titleColor: '#3a3028',
ambient: 0.65, ambient: 0.72,
warmLight: '#fff6e8',
sunLight: '#f2f6ff',
windows: windows( windows: windows(
{ wall: 'left', x: 0, y: 2.5, width: 4.0, height: 2.6, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 6.5 }, { wall: 'left', x: -1.2, y: 2.55, width: 2.4, height: 2.3, style: 'picture', lightColor: '#f0f6ff', lightIntensity: 5.5 },
{ wall: 'ceiling', x: 0, y: 0, width: 3.5, height: 2.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 4.5 } { wall: 'left', x: 1.4, y: 2.55, width: 2.2, height: 2.3, style: 'picture', lightColor: '#f0f6ff', lightIntensity: 5.2 },
{ wall: 'ceiling', x: 0, y: 0, width: 3.2, height: 1.8, style: 'skylight', lightColor: '#ffffff', lightIntensity: 4.2 }
), ),
doorWood: ['#5a4838', '#6a5848', '#4a3828'],
} }
), ),
52: mk( 52: mk(
@@ -638,7 +652,7 @@ 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: '#a8a8a8', ceiling: '#b3b3b3', floor: '#d8d8d8', trim: '#cccccc' }, { wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#909090', trim: '#cccccc' },
{ {
details: 'white-cube', details: 'white-cube',
// Lit like the basilica: shared lights held back so the walls read as // Lit like the basilica: shared lights held back so the walls read as
@@ -650,7 +664,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
fog: '#1a1a1a', fog: '#1a1a1a',
background: '#101010', background: '#101010',
windows: windows( windows: windows(
{ wall: 'back', x: 0, y: 2.6, width: 3.0, height: 2.0, style: 'factory', lightColor: '#ffffff', lightIntensity: 5.5 }, { wall: 'back', x: 0, y: 2.6, width: 3.0, height: 2.0, style: 'ribbon', lightColor: '#ffffff', lightIntensity: 5.5 },
{ 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 }
), ),
trackLights: 0.6, trackLights: 0.6,
+140 -22
View File
@@ -1,5 +1,6 @@
import type { Painting } from '../types'; import type { Painting } from '../types';
import type { GalleryWindowSpec, MovementInteriorStyle } from '../data/movement-interior-styles'; import type { GalleryWindowSpec, MovementDetailKind, MovementInteriorStyle } from '../data/movement-interior-styles';
import { sideColumnKeepOutIntervals } from './sideColumnKeepOuts';
/** Target capacity per movement wing (50–60 works). */ /** Target capacity per movement wing (50–60 works). */
export const MOVEMENT_PAINTINGS_PER_HALL = 55; export const MOVEMENT_PAINTINGS_PER_HALL = 55;
@@ -69,7 +70,7 @@ function frameOuterW(w: number, reviewed: boolean) {
return w + matBorder * 2 + rail; return w + matBorder * 2 + rail;
} }
function layoutRow(paintings: Painting[], span: number) { function layoutRow(paintings: Painting[], span: number, endPad = WALL_PADDING) {
const count = paintings.length; const count = paintings.length;
if (count === 0) return { slots: [] as { offset: number; maxW: number; maxH: number }[], spanNeeded: span }; if (count === 0) return { slots: [] as { offset: number; maxW: number; maxH: number }[], spanNeeded: span };
@@ -80,7 +81,7 @@ function layoutRow(paintings: Painting[], span: number) {
total += frameOuterW(frameW, paintingIsReviewed(paintings[i])); total += frameOuterW(frameW, paintingIsReviewed(paintings[i]));
if (i < count - 1) total += FRAME_GAP; if (i < count - 1) total += FRAME_GAP;
} }
if (total <= span - WALL_PADDING) break; if (total <= span - endPad) break;
frameW -= 0.015; frameW -= 0.015;
} }
frameW = Math.max(MIN_FRAME_W, frameW); frameW = Math.max(MIN_FRAME_W, frameW);
@@ -93,7 +94,7 @@ function layoutRow(paintings: Painting[], span: number) {
slots.push({ offset: cursor + outers[i] / 2, maxW: frameW, maxH: frameH }); slots.push({ offset: cursor + outers[i] / 2, maxW: frameW, maxH: frameH });
cursor += outers[i] + FRAME_GAP; cursor += outers[i] + FRAME_GAP;
} }
return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) }; return { slots, spanNeeded: Math.max(span, rowWidth + endPad) };
} }
function formatYearLabel(paintings: Painting[]) { function formatYearLabel(paintings: Painting[]) {
@@ -140,21 +141,101 @@ function distributeAcrossWalls(paintings: Painting[]) {
}; };
} }
/** Usable runs along ±halfSpan after end padding and column keep-outs. */
function sideWallGaps(halfSpan: number, keepOuts: [number, number][]): [number, number][] {
const pad = WALL_PADDING / 2;
const occupied = mergeIntervals([
[-halfSpan - 1, -halfSpan + pad],
...keepOuts,
[halfSpan - pad, halfSpan + 1],
]);
const gaps: [number, number][] = [];
let cursor = -halfSpan;
for (const [a, b] of occupied) {
if (a > cursor) gaps.push([cursor, a]);
cursor = Math.max(cursor, b);
}
if (cursor < halfSpan) gaps.push([cursor, halfSpan]);
return gaps.filter(([a, b]) => b - a >= MIN_FRAME_W + 0.08);
}
/** Split paintings across gaps in visit order (first gap gets the earliest works). */
function distributeIntoGaps(paintings: Painting[], gaps: [number, number][]): Painting[][] {
if (paintings.length === 0 || gaps.length === 0) return gaps.map(() => []);
const lengths = gaps.map(([a, b]) => b - a);
const total = lengths.reduce((s, L) => s + L, 0);
const counts = lengths.map((L) => Math.floor((paintings.length * L) / total));
let assigned = counts.reduce((s, n) => s + n, 0);
// Give leftover works to the widest gaps first.
const order = lengths.map((L, i) => ({ i, L })).sort((a, b) => b.L - a.L);
let k = 0;
while (assigned < paintings.length) {
counts[order[k % order.length].i] += 1;
assigned += 1;
k += 1;
}
// Prefer empty-avoidance: if a gap got 0 but another has many, leave as-is
// (zero-count gaps simply stay empty).
const groups: Painting[][] = [];
let cursor = 0;
for (const n of counts) {
groups.push(paintings.slice(cursor, cursor + n));
cursor += n;
}
return groups;
}
function slotsFitInGaps(paintings: Painting[], gaps: [number, number][]): boolean {
if (paintings.length === 0) return true;
if (gaps.length === 0) return false;
const groups = distributeIntoGaps(paintings, gaps);
for (let i = 0; i < gaps.length; i++) {
const group = groups[i];
if (group.length === 0) continue;
const span = gaps[i][1] - gaps[i][0];
const { spanNeeded } = layoutRow(group, span, 0);
if (spanNeeded > span + 1e-6) return false;
}
return true;
}
/** Depth large enough that side-wall hang clears bay columns for this detail kind. */
function depthForSideHang(
left: Painting[],
right: Painting[],
details: MovementDetailKind | undefined
): number {
let depth = Math.max(
MIN_HALL_SIZE,
layoutRow(left, MIN_HALL_SIZE).spanNeeded,
layoutRow(right, MIN_HALL_SIZE).spanNeeded
);
if (!sideColumnKeepOutIntervals(details, depth, depth / 2).length) return depth;
for (let guard = 0; guard < 80; guard++) {
const halfD = depth / 2;
const keepOuts = sideColumnKeepOutIntervals(details, depth, halfD);
const gaps = sideWallGaps(halfD, keepOuts);
if (slotsFitInGaps(left, gaps) && slotsFitInGaps(right, gaps)) return depth;
depth += 0.35;
}
return depth;
}
function layoutSideSlots( function layoutSideSlots(
paintings: Painting[], paintings: Painting[],
span: number, span: number,
side: 'left' | 'right', side: 'left' | 'right',
halfW: number, halfW: number,
inset: number inset: number,
details?: MovementDetailKind
): FrameSlot[] { ): FrameSlot[] {
if (paintings.length === 0) return []; if (paintings.length === 0) return [];
const { slots: rowSlots } = layoutRow(paintings, span);
const y = EYE_HEIGHT; const y = EYE_HEIGHT;
return rowSlots.map((s) => { const halfD = span / 2;
// layoutRow places index 0 at negative offset. Flip on the left wall so const keepOuts = sideColumnKeepOutIntervals(details, span, halfD);
// the first painting sits at the entrance (+Z), left of the starting view.
const alongWall = side === 'left' ? -s.offset : s.offset; const mapSlot = (alongWall: number, s: { maxW: number; maxH: number }): FrameSlot => ({
return {
maxW: s.maxW, maxW: s.maxW,
maxH: s.maxH, maxH: s.maxH,
rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2, rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
@@ -163,8 +244,38 @@ function layoutSideSlots(
side === 'left' side === 'left'
? ([-halfW + inset + WALL_STANDOFF, y, alongWall] as [number, number, number]) ? ([-halfW + inset + WALL_STANDOFF, y, alongWall] as [number, number, number])
: ([halfW - inset - WALL_STANDOFF, y, alongWall] as [number, number, number]), : ([halfW - inset - WALL_STANDOFF, y, alongWall] as [number, number, number]),
};
}); });
if (keepOuts.length === 0) {
const { slots: rowSlots } = layoutRow(paintings, span);
return rowSlots.map((s) => {
// layoutRow places index 0 at negative offset. Flip on the left wall so
// the first painting sits at the entrance (+Z), left of the starting view.
const alongWall = side === 'left' ? -s.offset : s.offset;
return mapSlot(alongWall, s);
});
}
// Visit order along each wall: left entrance→back (+Z→−Z), right back→entrance (−Z→+Z).
let gaps = sideWallGaps(halfD, keepOuts);
gaps = side === 'left' ? [...gaps].sort((a, b) => b[0] - a[0]) : [...gaps].sort((a, b) => a[0] - b[0]);
const groups = distributeIntoGaps(paintings, gaps);
const out: FrameSlot[] = [];
for (let i = 0; i < gaps.length; i++) {
const group = groups[i];
if (group.length === 0) continue;
const [g0, g1] = gaps[i];
const gapSpan = g1 - g0;
const center = (g0 + g1) / 2;
const { slots: rowSlots } = layoutRow(group, gapSpan, 0);
for (const s of rowSlots) {
// Within a gap, keep the same entrance/back orientation as the continuous row.
const alongWall = side === 'left' ? center - s.offset : center + s.offset;
out.push(mapSlot(alongWall, s));
}
}
return out;
} }
/** Far wall ahead of the entrance — full span, or split across door flanks. */ /** Far wall ahead of the entrance — full span, or split across door flanks. */
@@ -225,12 +336,11 @@ function layoutBackSlots(
export function buildMovementHallLayout( export function buildMovementHallLayout(
paintings: Painting[], paintings: Painting[],
hallIndex: number, hallIndex: number,
hallCount: number hallCount: number,
details?: MovementDetailKind
): MovementHallLayout { ): MovementHallLayout {
const { left, back, right } = distributeAcrossWalls(paintings); const { left, back, right } = distributeAcrossWalls(paintings);
const leftSpan = layoutRow(left, MIN_HALL_SIZE); const depth = depthForSideHang(left, right, details);
const rightSpan = layoutRow(right, MIN_HALL_SIZE);
const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded);
// Single-wing halls put the exit on the entrance wall, so the far wall is solid // Single-wing halls put the exit on the entrance wall, so the far wall is solid
// and can use its full width for paintings. Multi-wing halls keep a back exit. // and can use its full width for paintings. Multi-wing halls keep a back exit.
@@ -261,13 +371,13 @@ export function buildMovementHallLayout(
side: 'left', side: 'left',
label: left.length > 0 ? `Wing ${hallIndex + 1} · Left wall` : '', label: left.length > 0 ? `Wing ${hallIndex + 1} · Left wall` : '',
paintings: left, paintings: left,
slots: layoutSideSlots(left, depth, 'left', halfW, inset), slots: layoutSideSlots(left, depth, 'left', halfW, inset, details),
}, },
{ {
side: 'right', side: 'right',
label: right.length > 0 ? `Wing ${hallIndex + 1} · Right wall` : '', label: right.length > 0 ? `Wing ${hallIndex + 1} · Right wall` : '',
paintings: right, paintings: right,
slots: layoutSideSlots(right, depth, 'right', halfW, inset), slots: layoutSideSlots(right, depth, 'right', halfW, inset, details),
}, },
]; ];
@@ -283,9 +393,12 @@ export function buildMovementHallLayout(
}; };
} }
export function buildAllMovementHallLayouts(paintings: Painting[]): MovementHallLayout[] { export function buildAllMovementHallLayouts(
paintings: Painting[],
details?: MovementDetailKind
): MovementHallLayout[] {
const chunks = splitPaintingsIntoMovementHalls(paintings); const chunks = splitPaintingsIntoMovementHalls(paintings);
return chunks.map((chunk, i) => buildMovementHallLayout(chunk, i, chunks.length)); return chunks.map((chunk, i) => buildMovementHallLayout(chunk, i, chunks.length, details));
} }
function mergeIntervals(intervals: [number, number][]): [number, number][] { function mergeIntervals(intervals: [number, number][]): [number, number][] {
@@ -336,15 +449,20 @@ export function computeSideWallWindows(
const specs: GalleryWindowSpec[] = []; const specs: GalleryWindowSpec[] = [];
const windowY = 3.15; const windowY = 3.15;
const minGap = tmpl.width + 0.6; const minGap = tmpl.width + 0.6;
const columnKeepOuts = sideColumnKeepOutIntervals(interiorStyle.details, layout.depth, halfD);
for (const side of ['left', 'right'] as const) { for (const side of ['left', 'right'] as const) {
const seg = layout.segments.find((s) => s.side === side); const seg = layout.segments.find((s) => s.side === side);
if (!seg) continue; if (!seg) continue;
const occupied = seg.slots.map((s): [number, number] => { const occupied = [
...seg.slots.map((s): [number, number] => {
const outerW = frameOuterW(s.maxW, false); const outerW = frameOuterW(s.maxW, false);
return [s.position[2] - outerW / 2 - 0.45, s.position[2] + outerW / 2 + 0.45]; return [s.position[2] - outerW / 2 - 0.45, s.position[2] + outerW / 2 + 0.45];
}); }),
// Keep windows off bay columns so period modules still draw their shafts.
...columnKeepOuts,
];
const gaps = findWallGaps(occupied, halfD, minGap); const gaps = findWallGaps(occupied, halfD, minGap);
const maxWindows = Math.min(3, gaps.length); const maxWindows = Math.min(3, gaps.length);
+58
View File
@@ -0,0 +1,58 @@
import type { MovementDetailKind } from '../data/movement-interior-styles';
/**
* Even bay centres along the depth axis — same formula as historic `useBayZ`.
* Pure (no React) so hang layout can reserve the same Z as period columns.
*/
export function bayPositions(depth: number, halfD: number, spacing = 4.2, max = 9): number[] {
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));
}
export type SideColumnRhythm = {
/** Bay spacing passed to bayPositions (matches the period module). */
spacing: number;
max: number;
/**
* Half-width along the wall (Z) of the keep-out around each bay centre.
* Covers shaft/base/capital plus a small gap so frames do not kiss the column.
*/
halfClear: number;
};
/**
* Mid-wall vertical members that share the hang line on left/right walls.
* Corner-only orders are omitted — WALL_PADDING already clears the pockets.
*/
export const SIDE_WALL_COLUMN_RHYTHMS = {
gothic: { spacing: 4.2, max: 9, halfClear: 0.42 },
roman: { spacing: 4.0, max: 9, halfClear: 0.3 },
quattrocento: { spacing: 3.8, max: 9, halfClear: 0.34 },
// Paired pilasters sit at bay±0.19 with r≈0.11
cinquecento: { spacing: 5.2, max: 6, halfClear: 0.44 },
mannerist: { spacing: 4.8, max: 6, halfClear: 0.36 },
neoclassical: { spacing: 4.8, max: 6, halfClear: 0.3 },
'gothic-revival': { spacing: 3.8, max: 8, halfClear: 0.24 },
'art-nouveau': { spacing: 3.0, max: 10, halfClear: 0.22 },
} as const satisfies Partial<Record<MovementDetailKind, SideColumnRhythm>>;
export type SideColumnDetailKind = keyof typeof SIDE_WALL_COLUMN_RHYTHMS;
export function sideColumnRhythm(details: MovementDetailKind | undefined): SideColumnRhythm | null {
if (!details) return null;
return (SIDE_WALL_COLUMN_RHYTHMS as Partial<Record<MovementDetailKind, SideColumnRhythm>>)[details] ?? null;
}
/** Merged [z0, z1] intervals that side-wall paintings (and windows) must avoid. */
export function sideColumnKeepOutIntervals(
details: MovementDetailKind | undefined,
depth: number,
halfD: number
): [number, number][] {
const rhythm = sideColumnRhythm(details);
if (!rhythm) return [];
return bayPositions(depth, halfD, rhythm.spacing, rhythm.max).map(
(z) => [z - rhythm.halfClear, z + rhythm.halfClear] as [number, number]
);
}
@@ -0,0 +1,46 @@
/**
* Abstract Expressionism loft must read as an open painter's loft, not a barred cell.
* node --test server/__tests__/abex-loft-interior.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/modernism.tsx'), 'utf8');
function loftStyleBlock() {
const start = STYLES.indexOf("'abstract-expressionist-loft'");
assert.ok(start >= 0, 'abstract-expressionist-loft style must exist');
const next = STYLES.indexOf('mk(', start + 1);
return STYLES.slice(start, next > start ? next : undefined);
}
function loftDetailsBlock() {
const start = DETAILS.indexOf('export function LoftDetails');
assert.ok(start >= 0, 'LoftDetails must exist');
const next = DETAILS.indexOf('export function ', start + 1);
return DETAILS.slice(start, next > start ? next : undefined);
}
describe('Abstract Expressionism loft — open studio, no prison vibe', () => {
it('does not use factory windows (iron-bar grid)', () => {
const block = loftStyleBlock();
assert.doesNotMatch(block, /style: 'factory'/);
assert.match(block, /style: 'picture'|style: 'clerestory'|style: 'skylight'/);
});
it('uses open painted / warm loft surfaces, not dingy cell brick-only', () => {
const block = loftStyleBlock();
assert.match(block, /north-light|painter|studio|loft/i);
assert.doesNotMatch(block, /north-light factory windows/);
});
it('LoftDetails has no cast-iron columns or sprinkler prison pipe', () => {
const block = loftDetailsBlock();
assert.doesNotMatch(block, /Cast-iron|Sprinkler main|cast-iron/);
assert.match(block, /timber|wood|joist|painter|studio/i);
});
});
+34
View File
@@ -0,0 +1,34 @@
/**
* Baroque upper-wall field panels must be framed velvet, not solid gilt slabs
* that wash out beside windows.
* node --test server/__tests__/baroque-panels.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/courtly.tsx'), 'utf8');
function baroqueBlock() {
const start = DETAILS.indexOf('export function BaroqueStateDetails');
assert.ok(start >= 0, 'BaroqueStateDetails must exist');
const next = DETAILS.indexOf('export function ', start + 1);
return DETAILS.slice(start, next > start ? next : undefined);
}
describe('Baroque field panels beside windows', () => {
it('does not draw a solid gilt slab covering the velvet field', () => {
const block = baroqueBlock();
assert.doesNotMatch(block, /boxGeometry args=\{\[0\.04, 1\.12, w \+ 0\.12\]\}/);
assert.match(block, /velvet|field/i);
assert.match(block, /frame|rail|moulding/i);
});
it('keeps a generous clearance around window openings', () => {
const block = baroqueBlock();
assert.match(block, /halfClear = fieldW \/ 2 \+ rail \+ 0\.35/);
assert.match(block, /\(mid, halfClear\)/);
});
});
+54
View File
@@ -0,0 +1,54 @@
/**
* Dada movement hall must read as a modern cabaret, not a barred cell.
* node --test server/__tests__/dada-interior.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
const WINDOWS = fs.readFileSync(path.join(ROOT, 'client/src/components/GalleryWindows.tsx'), 'utf8');
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/modernism.tsx'), 'utf8');
function dadaSalonBlock() {
const start = STYLES.indexOf("'dada-salon'");
assert.ok(start >= 0, 'dada-salon style must exist');
const next = STYLES.indexOf('mk(', start + 1);
return STYLES.slice(start, next > start ? next : undefined);
}
function dadaDetailsBlock() {
const start = DETAILS.indexOf('export function DadaDetails');
assert.ok(start >= 0, 'DadaDetails must exist');
const next = DETAILS.indexOf('export function ', start + 1);
return DETAILS.slice(start, next > start ? next : undefined);
}
describe('Dada interior — modern cabaret, no prison vibe', () => {
it('declares a picture window style (open glass, no iron grid)', () => {
assert.match(STYLES, /\| 'picture'/);
assert.match(WINDOWS, /style === 'picture'/);
});
it('does not use factory or gothic-lancet windows (barred / cell look)', () => {
const block = dadaSalonBlock();
assert.doesNotMatch(block, /style: 'factory'/);
assert.doesNotMatch(block, /style: 'gothic-lancet'/);
assert.match(block, /style: 'picture'/);
});
it('uses modern painted surfaces instead of dingy brick cell walls', () => {
const block = dadaSalonBlock();
assert.doesNotMatch(block, /wallSide: 'brick'/);
assert.match(block, /painted-flat|painted-emulsion/);
assert.match(block, /Modern|cabaret|collage/i);
});
it('DadaDetails has no service pipe / iron-bar geometry', () => {
const block = dadaDetailsBlock();
assert.doesNotMatch(block, /Service pipe|cylinderGeometry/);
assert.match(block, /collage|cabaret|panel/i);
});
});
@@ -0,0 +1,38 @@
/**
* Low wall mouldings must not run through exit doors on the end walls.
* node --test server/__tests__/doorway-clearance.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const PRIMITIVES = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/primitives.tsx'), 'utf8');
const GEOMETRY = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/geometry.ts'), 'utf8');
const COURTLY = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/courtly.tsx'), 'utf8');
function flemishBlock() {
const start = COURTLY.indexOf('export function FlemishDetails');
assert.ok(start >= 0);
const next = COURTLY.indexOf('export function ', start + 1);
return COURTLY.slice(start, next > start ? next : undefined);
}
describe('Doorway clearance for wall mouldings', () => {
it('exports a shared door half-clearance used by WallBand', () => {
assert.match(GEOMETRY, /DOOR_CLEAR_HALF\s*=\s*1\.45/);
assert.match(PRIMITIVES, /DOOR_CLEAR_HALF/);
});
it('WallBand splits back/front bands that sit in the door height', () => {
assert.match(PRIMITIVES, /crossesDoor/);
assert.match(PRIMITIVES, /DOOR_CLEAR_HALF/);
assert.match(PRIMITIVES, /leftX|rightX/);
});
it('Flemish linenfold ribs skip the back-wall doorway', () => {
const block = flemishBlock();
assert.match(block, /DOOR_CLEAR_HALF|Math\.abs\(x\)\s*>/);
});
});
+57
View File
@@ -0,0 +1,57 @@
/**
* No movement hall floor tint may blow out as near-white.
* node --test server/__tests__/floor-brightness.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
/** Soft ceiling — mid stone / warm wood, not gallery flood. */
const MAX_FLOOR_LUMINANCE = 0.58;
function luminance(hex) {
const n = parseInt(hex.slice(1), 16);
const r = (n >> 16) & 255;
const g = (n >> 8) & 255;
const b = n & 255;
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
}
function floorTints() {
// tints objects inside mk(): wall/ceiling/floor hex triples (optional wallSide)
const re =
/\{\s*wall:\s*'#[^']+',(?:\s*wallSide:\s*'#[^']+',)?\s*ceiling:\s*'#[^']+',\s*floor:\s*'(#[0-9a-fA-F]{6})'/g;
const out = [];
let m;
while ((m = re.exec(STYLES))) out.push(m[1]);
return out;
}
describe('Movement floors — no blown-out brightness', () => {
it('every authored floor tint stays at or below mid-stone luminance', () => {
const floors = floorTints();
assert.ok(floors.length >= 20, `expected many floor tints, got ${floors.length}`);
const offenders = floors
.map((hex) => ({ hex, L: luminance(hex) }))
.filter((f) => f.L > MAX_FLOOR_LUMINANCE);
assert.deepEqual(
offenders,
[],
offenders.map((f) => `${f.hex} L=${f.L.toFixed(3)}`).join(', ')
);
});
it('Baroque floor is subdued warm marble, not near-white', () => {
const start = STYLES.indexOf("'baroque-palace'");
assert.ok(start >= 0);
const block = STYLES.slice(start, STYLES.indexOf('mk(', start + 1));
const m = block.match(/ceiling:\s*'#[^']+',\s*floor:\s*'(#[0-9a-fA-F]{6})'/);
assert.ok(m);
assert.ok(luminance(m[1]) <= MAX_FLOOR_LUMINANCE);
assert.doesNotMatch(block, /floor: '#ece8e0'/);
});
});
@@ -0,0 +1,40 @@
/**
* High Renaissance (cinquecento) must not flood the hall with tiny contrast
* tiles or gold ring ornaments in every coffer.
* node --test server/__tests__/high-renaissance-interior.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/classical.tsx'), 'utf8');
function styleBlock() {
const start = STYLES.indexOf("'high-renaissance-palazzo'");
assert.ok(start >= 0);
return STYLES.slice(start, STYLES.indexOf('mk(', start + 1));
}
function cinquecentoBlock() {
const start = DETAILS.indexOf('export function CinquecentoDetails');
assert.ok(start >= 0);
const next = DETAILS.indexOf('export function ', start + 1);
return DETAILS.slice(start, next > start ? next : DETAILS.length);
}
describe('High Renaissance interior — quieter surfaces', () => {
it('does not use high-contrast marble-checker flooring', () => {
const block = styleBlock();
assert.doesNotMatch(block, /floor: 'marble-checker'/);
assert.match(block, /floor: 'marble-veined-carrara'|floor: 'terrazzo'|floor: 'parquet/);
});
it('Cinquecento coffers have no gold rosette rings', () => {
const block = cinquecentoBlock();
assert.doesNotMatch(block, /rosette=\{trim\}/);
assert.match(block, /CofferedCeiling/);
});
});
@@ -0,0 +1,35 @@
/**
* Mannerist herm pilasters must read as clean architecture, not flat white strips
* with floating edge artifacts.
* node --test server/__tests__/mannerist-columns.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/courtly.tsx'), 'utf8');
function manneristBlock() {
const start = DETAILS.indexOf('export function ManneristDetails');
assert.ok(start >= 0, 'ManneristDetails must exist');
const next = DETAILS.indexOf('export function ', start + 1);
return DETAILS.slice(start, next > start ? next : undefined);
}
describe('Mannerist herm columns', () => {
it('does not use the glitchy thin shaft + offset rustication + torus capital', () => {
const block = manneristBlock();
assert.doesNotMatch(block, /boxGeometry args=\{\[0\.09, 3\.8, 0\.3\]\}/);
assert.doesNotMatch(block, /position=\{\[side \* 0\.03, y, 0\]\}/);
assert.doesNotMatch(block, /torusGeometry args=\{\[0\.14, 0\.055/);
});
it('builds herm pilasters with clearance and a proper capital', () => {
const block = manneristBlock();
assert.match(block, /herm|pilaster/i);
assert.match(block, /clearLeft|clearRight|useWallClearance/);
assert.match(block, /capital|console|abacus/i);
});
});
@@ -0,0 +1,51 @@
/**
* Neoclassical museum floor must not blow out as near-white marble.
* node --test server/__tests__/neoclassical-interior.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
function neoclassicalBlock() {
const start = STYLES.indexOf("'neoclassical-museum'");
assert.ok(start >= 0, 'neoclassical-museum style must exist');
const next = STYLES.indexOf('mk(', start + 1);
return STYLES.slice(start, next > start ? next : undefined);
}
function floorTintHex(block) {
const m = block.match(/floor:\s*'#[0-9a-fA-F]{6}'/);
assert.ok(m, 'floor tint hex must be present in tints object');
// Prefer the tints line: `{ wall: '...', ceiling: '...', floor: '#......', trim: '...' }`
const tints = block.match(/\{\s*wall:\s*'#[^']+',\s*ceiling:\s*'#[^']+',\s*floor:\s*'(#[0-9a-fA-F]{6})'/);
assert.ok(tints, 'tints.floor must be a hex color');
return tints[1];
}
function luminance(hex) {
const n = parseInt(hex.slice(1), 16);
const r = (n >> 16) & 255;
const g = (n >> 8) & 255;
const b = n & 255;
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
}
describe('Neoclassical interior — subdued stone floor', () => {
it('floor tint is mid stone grey, not near-white', () => {
const block = neoclassicalBlock();
const hex = floorTintHex(block);
assert.ok(luminance(hex) < 0.62, `floor luminance ${luminance(hex).toFixed(2)} for ${hex} is still too bright`);
assert.doesNotMatch(block, /floor: '#eceae6'/);
});
it('skylight is not overdriven for the marble floor', () => {
const block = neoclassicalBlock();
const sky = block.match(/style: 'skylight',\s*lightColor: '[^']+',\s*lightIntensity:\s*([0-9.]+)/);
assert.ok(sky, 'skylight must exist');
assert.ok(Number(sky[1]) <= 3.6, `skylight intensity ${sky[1]} is still too strong`);
});
});
@@ -0,0 +1,34 @@
/**
* No movement hall may use factory (iron-bar grid) windows — they read as prison cells.
* node --test server/__tests__/no-factory-windows.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
function styleBlock(id) {
const start = STYLES.indexOf(`'${id}'`);
assert.ok(start >= 0, `${id} must exist`);
const next = STYLES.indexOf('mk(', start + 1);
return STYLES.slice(start, next > start ? next : undefined);
}
describe('Movement interiors — no factory / iron-bar windows', () => {
it('no authored movement style uses factory windows', () => {
// Only count style: 'factory' assignments inside BY_MOVEMENT_ID mk() blocks,
// not the GalleryWindowStyle union member.
const uses = [...STYLES.matchAll(/style:\s*'factory'/g)];
assert.equal(uses.length, 0, `found ${uses.length} factory window assignment(s)`);
});
it('Futurism uses open picture or clerestory daylight, not bars', () => {
const block = styleBlock('futurist-loft');
assert.doesNotMatch(block, /style: 'factory'/);
assert.match(block, /style: 'picture'|style: 'ribbon'|style: 'clerestory'|style: 'skylight'/);
assert.match(block, /open|ribbon|picture|glass/i);
});
});
@@ -0,0 +1,98 @@
/**
* Side-wall paintings must not hang in front of bay columns/pilasters.
* Columns stay fixed; the hang skips column keep-outs (and windows must too).
* node --test server/__tests__/painting-column-clearance.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const KEEP = fs.readFileSync(path.join(ROOT, 'client/src/utils/sideColumnKeepOuts.ts'), 'utf8');
const LAYOUT = fs.readFileSync(path.join(ROOT, 'client/src/utils/movementHallLayout.ts'), 'utf8');
const GEOMETRY = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/geometry.ts'), 'utf8');
const GALLERY = fs.readFileSync(path.join(ROOT, 'client/src/components/VirtualGallery.tsx'), 'utf8');
/** Mirror of bayPositions — must stay in lockstep with sideColumnKeepOuts.ts / geometry.ts */
function bayPositions(depth, halfD, spacing = 4.2, max = 9) {
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));
}
function parseRhythmTable(src) {
const start = src.indexOf('SIDE_WALL_COLUMN_RHYTHMS');
assert.ok(start >= 0, 'SIDE_WALL_COLUMN_RHYTHMS export missing');
const bodyStart = src.indexOf('{', start);
const bodyEnd = src.indexOf('} as const', bodyStart);
assert.ok(bodyEnd > bodyStart);
const body = src.slice(bodyStart, bodyEnd + 1);
const rhythms = {};
for (const m of body.matchAll(
/['"]?([a-z0-9-]+)['"]?:\s*\{\s*spacing:\s*([\d.]+),\s*max:\s*(\d+),\s*halfClear:\s*([\d.]+)\s*\}/g
)) {
rhythms[m[1]] = { spacing: Number(m[2]), max: Number(m[3]), halfClear: Number(m[4]) };
}
return rhythms;
}
describe('Painting ↔ column clearance', () => {
it('exports bayPositions shared by geometry and keep-outs', () => {
assert.match(KEEP, /export function bayPositions/);
assert.match(GEOMETRY, /bayPositions/);
assert.match(GEOMETRY, /useBayZ[\s\S]*bayPositions/);
});
it('registers keep-outs for every mid-wall bay column style', () => {
const rhythms = parseRhythmTable(KEEP);
for (const kind of [
'gothic',
'roman',
'quattrocento',
'cinquecento',
'mannerist',
'neoclassical',
'gothic-revival',
'art-nouveau',
]) {
assert.ok(rhythms[kind], `missing rhythm for ${kind}`);
assert.ok(rhythms[kind].halfClear >= 0.18, `${kind} halfClear too small`);
}
});
it('layout packs side walls around column keep-outs and grows depth if needed', () => {
assert.match(LAYOUT, /sideColumnKeepOutIntervals/);
assert.match(LAYOUT, /depthForSideHang|sideWallGaps/);
assert.match(LAYOUT, /details\?:\s*MovementDetailKind/);
assert.match(LAYOUT, /buildAllMovementHallLayouts\(\s*paintings:\s*Painting\[\],\s*details\?/);
});
it('windows treat column keep-outs as occupied so they do not land on columns', () => {
const fn = LAYOUT.slice(LAYOUT.indexOf('export function computeSideWallWindows'));
assert.match(fn, /sideColumnKeepOutIntervals/);
assert.match(fn, /columnKeepOuts/);
});
it('VirtualGallery passes the movement detail kind into hall layout', () => {
assert.match(GALLERY, /buildAllMovementHallLayouts\(\s*paintings\s*,\s*interiorStyle\?\.details\s*\)/);
});
it('bay keep-out intervals cover each column centre for a typical hall depth', () => {
const rhythms = parseRhythmTable(KEEP);
const depth = 14;
const halfD = depth / 2;
for (const [kind, r] of Object.entries(rhythms)) {
const zs = bayPositions(depth, halfD, r.spacing, r.max);
assert.ok(zs.length >= 2, kind);
for (const z of zs) {
const lo = z - r.halfClear;
const hi = z + r.halfClear;
assert.ok(lo < z && z < hi, `${kind} keep-out must contain bay centre`);
// A typical frame half-width centred on the column would collide
const halfOuter = 0.35;
assert.ok(z - halfOuter < hi && z + halfOuter > lo, `${kind} frame would overlap column`);
}
}
});
});
@@ -0,0 +1,35 @@
/**
* Surrealist hall must not place a freestanding decorative door in a corner.
* node --test server/__tests__/surrealist-interior.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/modernism.tsx'), 'utf8');
const COURTLY = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/courtly.tsx'), 'utf8');
const CLASSICAL = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/classical.tsx'), 'utf8');
const NINETEENTH = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/nineteenth.tsx'), 'utf8');
function surrealistBlock() {
const start = DETAILS.indexOf('export function SurrealistDetails');
assert.ok(start >= 0, 'SurrealistDetails must exist');
const next = DETAILS.indexOf('export function ', start + 1);
return DETAILS.slice(start, next > start ? next : undefined);
}
describe('Surrealist interior — no corner door', () => {
it('does not place a freestanding door in the corner', () => {
const block = surrealistBlock();
assert.doesNotMatch(block, /door that leads nowhere|standing in the corner pocket/i);
assert.doesNotMatch(block, /boxGeometry args=\{\[0\.72, 2\.1/);
assert.doesNotMatch(block, /boxGeometry args=\{\[0\.84, 2\.24/);
});
it('no other period module places a freestanding corner door prop', () => {
const sources = [DETAILS, COURTLY, CLASSICAL, NINETEENTH].join('\n');
assert.doesNotMatch(sources, /door that leads nowhere|leads nowhere|false door|freestanding door/i);
});
});