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:
1 parent
1b760faf0d
commit
13f6f088b6
25 files changed
+1147
-318
No files matched your search
@@ -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)
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -61,11 +61,13 @@ function WindowFrame({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{/* Outer frame */}
|
{/* Outer frame — picture/ribbon draw their own thin reveals */}
|
||||||
<mesh position={[0, 0, -depth / 2]}>
|
{style !== 'picture' && style !== 'ribbon' && (
|
||||||
<boxGeometry args={[width + frameW * 2, height + frameW * 2, depth]} />
|
<mesh position={[0, 0, -depth / 2]}>
|
||||||
<meshStandardMaterial color={trimColor} roughness={0.35} metalness={0.45} />
|
<boxGeometry args={[width + frameW * 2, height + frameW * 2, depth]} />
|
||||||
</mesh>
|
<meshStandardMaterial color={trimColor} roughness={0.35} metalness={0.45} />
|
||||||
|
</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;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,30 +130,36 @@ 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)
|
||||||
<group key={side} position={[side * (room.halfW - 0.05), 0, z]}>
|
.filter((side) => (side < 0 ? clearLeft : clearRight)(z, 0.28))
|
||||||
<mesh position={[0, 1.9, 0]}>
|
.map((side) => (
|
||||||
<boxGeometry args={[0.09, 3.8, 0.3]} />
|
<group key={side} position={[side * (room.halfW - 0.05), 0, z]}>
|
||||||
<meshStandardMaterial color={stone} roughness={0.78} metalness={0.05} />
|
<ShaftBase size={0.11} color={dark} />
|
||||||
</mesh>
|
{/* Stacked tapering shaft (proud ≤ 0.10 m) */}
|
||||||
{/* Rustication blocks interrupting the shaft */}
|
{hermSegs.map((seg) => (
|
||||||
{bands.map((y) => (
|
<mesh key={seg.y} position={[0, seg.y, 0]}>
|
||||||
<mesh key={y} position={[side * 0.03, y, 0]}>
|
<boxGeometry args={[0.1, seg.h, seg.w]} />
|
||||||
<boxGeometry args={[0.14, 0.3, 0.44]} />
|
<meshStandardMaterial color={stone} roughness={0.78} metalness={0.05} />
|
||||||
<meshStandardMaterial color={shade(stone, -0.16)} roughness={0.86} metalness={0.04} />
|
</mesh>
|
||||||
|
))}
|
||||||
|
{/* Shallow rustication bands — same depth as shaft, slightly wider */}
|
||||||
|
{bandYs.map((y) => (
|
||||||
|
<mesh key={y} position={[0, y, 0]}>
|
||||||
|
<boxGeometry args={[0.105, 0.16, 0.34]} />
|
||||||
|
<meshStandardMaterial color={band} roughness={0.86} metalness={0.04} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
{/* Block capital / console — clean abacus, no peeling torus */}
|
||||||
|
<mesh position={[0, 3.78, 0]}>
|
||||||
|
<boxGeometry args={[0.11, 0.14, 0.36]} />
|
||||||
|
<meshStandardMaterial color={shade(stone, 0.06)} roughness={0.7} metalness={0.06} />
|
||||||
</mesh>
|
</mesh>
|
||||||
))}
|
<mesh position={[0, 3.92, 0]}>
|
||||||
{/* Scroll console instead of a capital */}
|
<boxGeometry args={[0.12, 0.1, 0.42]} />
|
||||||
<mesh position={[0, 3.86, 0]} rotation={[0, Math.PI / 2, 0]}>
|
<meshStandardMaterial color={trim} roughness={0.48} metalness={0.28} />
|
||||||
<torusGeometry args={[0.14, 0.055, 8, 16, Math.PI * 1.5]} />
|
</mesh>
|
||||||
<meshStandardMaterial color={shade(stone, 0.12)} roughness={0.66} metalness={0.08} />
|
</group>
|
||||||
</mesh>
|
))}
|
||||||
<mesh position={[0, 4.03, 0]}>
|
|
||||||
<boxGeometry args={[0.16, 0.12, 0.46]} />
|
|
||||||
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.24} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
|
||||||
))}
|
|
||||||
</group>
|
</group>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -162,19 +178,19 @@ 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>
|
||||||
))}
|
))}
|
||||||
</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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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]}>
|
|
||||||
<cylinderGeometry args={[0.055, 0.055, room.depth * 0.92, 10]} />
|
|
||||||
<meshStandardMaterial color="#7a3f2c" roughness={0.66} metalness={0.4} />
|
|
||||||
</mesh>
|
|
||||||
{joists
|
|
||||||
.filter((_, i) => i % 4 === 0)
|
|
||||||
.map((z, i) => (
|
|
||||||
<mesh key={i} position={[0, 3.68, z]}>
|
|
||||||
<cylinderGeometry args={[0.02, 0.025, 0.16, 6]} />
|
|
||||||
<meshStandardMaterial color="#8a4a34" roughness={0.6} metalness={0.45} />
|
|
||||||
</mesh>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Steel skirting angle */}
|
|
||||||
{(['left', 'right', 'back'] as const).map((wall) => (
|
{(['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} />
|
<WallBand
|
||||||
|
key={wall}
|
||||||
|
wall={wall}
|
||||||
|
room={room}
|
||||||
|
y={0.1}
|
||||||
|
height={0.2}
|
||||||
|
proud={0.06}
|
||||||
|
color={trim}
|
||||||
|
roughness={0.78}
|
||||||
|
metalness={0.05}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<CorniceRing room={room} y={4.12} height={0.06} proud={0.08} color={shade(timberLight, 0.12)} roughness={0.8} metalness={0.03} />
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,31 +141,141 @@ 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,
|
side,
|
||||||
side,
|
position:
|
||||||
position:
|
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 = [
|
||||||
const outerW = frameOuterW(s.maxW, false);
|
...seg.slots.map((s): [number, number] => {
|
||||||
return [s.position[2] - outerW / 2 - 0.45, s.position[2] + outerW / 2 + 0.45];
|
const outerW = frameOuterW(s.maxW, false);
|
||||||
});
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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*>/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user