diff --git a/Documentation/Plans.md b/Documentation/Plans.md
index 676d8cc..09283e7 100644
--- a/Documentation/Plans.md
+++ b/Documentation/Plans.md
@@ -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)
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)
+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)
diff --git a/Documentation/basics.md b/Documentation/basics.md
index c633f2e..a11f02f 100644
--- a/Documentation/basics.md
+++ b/Documentation/basics.md
@@ -40,7 +40,8 @@ Gallery/
│ │ ├── components/MovementHallDetails.tsx # Period architecture dispatcher (Gothic + Byzantine reference)
│ │ ├── components/hall-details/ # One period interior per movement + shared primitives
│ │ ├── data/movement-interior-styles.ts # Per-movement interior themes
-│ │ ├── utils/movementHallLayout.ts # Wing split + window gap placement
+│ │ ├── utils/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
│ │ ├── components/PaintingDetail.tsx # Detail view + 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 |
| 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) |
+| 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 |
| 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 |
| 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 |
@@ -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.
-**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.
diff --git a/Documentation/data-and-images.md b/Documentation/data-and-images.md
index da87cee..040f2ec 100644
--- a/Documentation/data-and-images.md
+++ b/Documentation/data-and-images.md
@@ -292,31 +292,41 @@ Every movement is built from the architecture of **its own period** — no two m
| Module | Movements |
|--------|-----------|
| `MovementHallDetails.tsx` (reference implementations) | `gothic` — bay-spaced compound piers, vault springers, transverse ribs · `byzantine` — porphyry colonnettes, basket capitals, revetment dado, hanging polycandela |
-| `hall-details/classical.tsx` | `roman` (Tuscan half-columns, dentil entablature, Pompeian socle) · `quattrocento` (pietra serena pilasters, blind arcade, tondi) · `cinquecento` (paired fluted Corinthian pilasters, Pantheon coffers) |
-| `hall-details/courtly.tsx` | `flemish` (linenfold wainscot, beam-and-corbel ceiling) · `mannerist` (herm pilasters, banded rustication, broken cornice) · `baroque` (solomonic corner columns, modillions, velvet field panels) · `rococo` (boiserie, rocaille cartouches, painted cove) · `neoclassical` (fluted Ionic order, meander frieze) |
+| `hall-details/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; 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/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**
-- 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
- 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
+**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.
-### 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.
- **`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.
-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)
diff --git a/client/src/components/GalleryWindows.tsx b/client/src/components/GalleryWindows.tsx
index 994eeef..ccef285 100644
--- a/client/src/components/GalleryWindows.tsx
+++ b/client/src/components/GalleryWindows.tsx
@@ -61,11 +61,13 @@ function WindowFrame({
return (
- {/* Outer frame */}
-
-
-
-
+ {/* Outer frame — picture/ribbon draw their own thin reveals */}
+ {style !== 'picture' && style !== 'ribbon' && (
+
+
+
+
+ )}
{arch && style === 'gothic-lancet' && (
<>
@@ -180,6 +182,33 @@ function WindowFrame({
)}
+
+ {/* Open picture window: thin dark reveal only — no mullion / iron-bar grid. */}
+ {style === 'picture' && (
+ <>
+
+
+
+
+ {/* Single offset L-accent (collage frame), not a barred grid */}
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ {/* Open ribbon window: slim steel surround only — no bars. */}
+ {style === 'ribbon' && (
+
+
+
+
+ )}
);
}
diff --git a/client/src/components/MovementHallDetails.tsx b/client/src/components/MovementHallDetails.tsx
index 5500a07..16b22f7 100644
--- a/client/src/components/MovementHallDetails.tsx
+++ b/client/src/components/MovementHallDetails.tsx
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import type { MovementInteriorStyle, GalleryWindowSpec } from '../data/movement-interior-styles';
import type { RoomDims } from './hall-details/geometry';
+import { bayPositions } from './hall-details/geometry';
import {
CinquecentoDetails,
QuattrocentoDetails,
@@ -48,12 +49,7 @@ function GothicDetails({ width, depth, halfW, halfD, trim }: Omit {
- 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 bays = useMemo(() => bayPositions(depth, halfD, 4.2, 9), [depth, halfD]);
const shaftHeight = 3.5;
diff --git a/client/src/components/VirtualGallery.tsx b/client/src/components/VirtualGallery.tsx
index 34e311f..d6fa5cc 100644
--- a/client/src/components/VirtualGallery.tsx
+++ b/client/src/components/VirtualGallery.tsx
@@ -2177,8 +2177,8 @@ export default function VirtualGallery(props: Props) {
);
const movementHalls = useMemo(
- () => (isWingedHall ? buildAllMovementHallLayouts(paintings) : []),
- [isWingedHall, paintings]
+ () => (isWingedHall ? buildAllMovementHallLayouts(paintings, interiorStyle?.details) : []),
+ [isWingedHall, paintings, interiorStyle?.details]
);
const [hallIndex, setHallIndex] = useState(0);
diff --git a/client/src/components/hall-details/classical.tsx b/client/src/components/hall-details/classical.tsx
index c2343c8..3081824 100644
--- a/client/src/components/hall-details/classical.tsx
+++ b/client/src/components/hall-details/classical.tsx
@@ -204,22 +204,22 @@ export function QuattrocentoDetails({ room, trim, windows }: PeriodProps) {
* Roman palazzo — High Renaissance.
*
* Bramante's cinquecento order: paired fluted Corinthian pilasters on a wide
- * bay, a full marble entablature, panelled dado, and a rosetted coffer field
- * modelled on the Pantheon soffit.
+ * bay, a full marble entablature, panelled dado, and a plain coffered soffit —
+ * no gold ring in every cell.
*/
export function CinquecentoDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 5.2, 6);
- const marble = '#efe9dd';
- const grey = shade(marble, -0.16);
+ const marble = '#d8d0c0';
+ const grey = shade(marble, -0.14);
return (
{/* Panelled marble dado */}
{(['left', 'right', 'back'] as const).map((wall) => (
-
-
-
+
+
+
))}
@@ -235,11 +235,11 @@ export function CinquecentoDetails({ room, trim }: PeriodProps) {
-
+
-
+
))
@@ -250,21 +250,21 @@ export function CinquecentoDetails({ room, trim }: PeriodProps) {
{/* Full entablature on the pilastered walls, cornice all round */}
{(['left', 'right'] as const).map((wall) => (
-
-
+
+
))}
-
+
+ {/* Plain coffers — larger cells, no gold rosettes */}
);
diff --git a/client/src/components/hall-details/courtly.tsx b/client/src/components/hall-details/courtly.tsx
index 706f923..f0213bb 100644
--- a/client/src/components/hall-details/courtly.tsx
+++ b/client/src/components/hall-details/courtly.tsx
@@ -15,6 +15,7 @@ import {
useCorners,
useRepeat,
useWallClearance,
+ DOOR_CLEAR_HALF,
type PeriodProps,
} from './geometry';
@@ -32,11 +33,11 @@ export function FlemishDetails({ room, trim }: PeriodProps) {
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 (
- {/* Wainscot ground, cap rail and skirting */}
+ {/* Wainscot ground, cap rail and skirting — WallBand clears the exit door */}
{(['left', 'right', 'back'] as const).map((wall) => (
@@ -98,17 +99,26 @@ export function FlemishDetails({ room, trim }: PeriodProps) {
/**
* Mannerist gallery — Mannerism.
*
- * The order used against itself: herm pilasters that taper the wrong way,
- * banded rustication blocking the shafts, oversized scroll consoles, and a
- * cornice deliberately broken over each bay.
+ * The order used against itself: herm pilasters that taper the wrong way
+ * (narrow foot, wide shoulder), shallow banded rustication on the shaft, and a
+ * 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) {
const clearLeft = useWallClearance(windows, 'left');
const clearRight = useWallClearance(windows, 'right');
- const bays = useBayZ(room.depth, room.halfD, 4.4, 7);
- const stone = '#ddc9ae';
- const dark = shade(stone, -0.34);
- const bands = [0.55, 1.55, 2.35, 3.05];
+ const bays = useBayZ(room.depth, room.halfD, 4.8, 6);
+ const stone = '#b89a78';
+ const dark = shade(stone, -0.28);
+ 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 (
@@ -120,30 +130,36 @@ export function ManneristDetails({ room, trim, windows }: PeriodProps) {
{/* Herm pilasters — wider at the shoulder than at the foot */}
{bays.map((z, i) => (
- {[-1, 1].map((side) => (
-
-
-
-
-
- {/* Rustication blocks interrupting the shaft */}
- {bands.map((y) => (
-
-
-
+ {([-1, 1] as const)
+ .filter((side) => (side < 0 ? clearLeft : clearRight)(z, 0.28))
+ .map((side) => (
+
+
+ {/* Stacked tapering shaft (proud ≤ 0.10 m) */}
+ {hermSegs.map((seg) => (
+
+
+
+
+ ))}
+ {/* Shallow rustication bands — same depth as shaft, slightly wider */}
+ {bandYs.map((y) => (
+
+
+
+
+ ))}
+ {/* Block capital / console — clean abacus, no peeling torus */}
+
+
+
- ))}
- {/* Scroll console instead of a capital */}
-
-
-
-
-
-
-
-
-
- ))}
+
+
+
+
+
+ ))}
))}
@@ -162,19 +178,19 @@ export function ManneristDetails({ room, trim, windows }: PeriodProps) {
return (
{([-1, 1] as const)
- .filter((side) => (side < 0 ? clearLeft : clearRight)(mid, 0.24))
+ .filter((side) => (side < 0 ? clearLeft : clearRight)(mid, 0.28))
.map((side) => (
-
-
-
-
-
-
-
-
-
-
- ))}
+
+
+
+
+
+
+
+
+
+
+ ))}
);
})}
@@ -186,8 +202,8 @@ export function ManneristDetails({ room, trim, windows }: PeriodProps) {
* Baroque state gallery — Baroque.
*
* Twisted solomonic half-columns at the corners, gilt-framed velvet field
- * panels between the bays, a modillion cornice, and a crystal-and-gilt
- * chandelier over the centre of the room.
+ * panels between the bays (true moulding rails, not solid gilt slabs), a
+ * modillion cornice, and a crystal-and-gilt chandelier over the centre.
*/
export function BaroqueStateDetails({ room, trim, windows }: PeriodProps) {
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 clearRight = useWallClearance(windows, 'right');
const gilt = trim;
+ const velvet = '#4a1220';
const modillions = useRepeat(room.depth, 0.86, 26);
return (
- {/* 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) => {
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 (
{([-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) => (
-
+
+ {/* Velvet field — faces the room */}
-
-
+
+
-
-
-
+ {/* Gilt moulding rails around the field (not a covering slab) */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
))}
@@ -405,8 +441,8 @@ export function NeoclassicalDetails({ room, trim }: PeriodProps) {
room={room}
y={4.06}
cell={2.9}
- panelColor="#f7f6f2"
- ribColor="#e2ded4"
+ panelColor="#e6e2da"
+ ribColor="#cfc8bc"
ribWidth={0.18}
ribDrop={0.13}
/>
diff --git a/client/src/components/hall-details/geometry.ts b/client/src/components/hall-details/geometry.ts
index a1f0ec0..ee21ce4 100644
--- a/client/src/components/hall-details/geometry.ts
+++ b/client/src/components/hall-details/geometry.ts
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import type { GalleryWindowSpec } from '../../data/movement-interior-styles';
+import { bayPositions } from '../../utils/sideColumnKeepOuts';
/**
* Shared geometry budget for every period interior.
@@ -27,6 +28,13 @@ export const WALL_INNER = 0.09;
export const CEILING_Y = 4.13;
/** Sink bands this far into the wall so they never z-fight with the surface. */
export const EMBED = 0.02;
+/**
+ * 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';
@@ -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. */
export function useBayZ(depth: number, halfD: number, spacing = 4.2, max = 9): number[] {
- return useMemo(() => {
- const count = Math.max(2, Math.min(max, Math.floor(depth / spacing)));
- const step = depth / (count + 1);
- return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1));
- }, [depth, halfD, spacing, max]);
+ return useMemo(() => bayPositions(depth, halfD, spacing, max), [depth, halfD, spacing, max]);
}
+export { bayPositions };
+
/**
* Evenly spaced positions centred on a wall, for the repeating small members —
* dentils, linenfold ribs, modillions, stencil motifs, quatrefoils.
diff --git a/client/src/components/hall-details/modernism.tsx b/client/src/components/hall-details/modernism.tsx
index 6b6626c..bd1d449 100644
--- a/client/src/components/hall-details/modernism.tsx
+++ b/client/src/components/hall-details/modernism.tsx
@@ -11,6 +11,7 @@ import {
useBayZ,
useCorners,
useRepeat,
+ useWallClearance,
type PeriodProps,
} 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
- * different heights, a picture rail hung visibly out of level, and a service
- * pipe left running across the wall where the moulding should be.
+ * Modern Cabaret Voltaire: cream gallery shell with collage panels (newsprint,
+ * ink black, scarlet) hung off-axis, a thin black rail, and open picture
+ * windows. No iron bars, no service pipe, no prison cell.
*/
-export function DadaDetails({ room, trim }: PeriodProps) {
- const fragments = useMemo(
+export function DadaDetails({ room, trim, windows }: PeriodProps) {
+ 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, y: 3.78, span: 0.18, proud: 0.1 },
- { wall: 'right' as const, y: 4.12, span: 0.44, proud: 0.16 },
- { wall: 'right' as const, y: 3.9, span: 0.22, proud: 0.09 },
- { wall: 'back' as const, y: 4.06, span: 0.35, proud: 0.13 },
- ],
+ { 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, z: -0.08, y: 3.55, w: 1.15, h: 0.28, color: ink, tilt: -0.06 },
+ { 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, z: -0.3, y: 3.4, w: 1.0, h: 0.4, color: mustard, tilt: -0.1 },
+ { 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 (
- {/* Cornice, in pieces */}
- {fragments.map((f, i) => (
-
- ))}
+ {/* Collage panels above the hang — off-axis, cabaret colour */}
+ {collage.map((p, i) => {
+ const along = p.z * (p.wall === 'back' ? room.halfW : room.halfD);
+ const clear =
+ p.wall === 'left' ? clearLeft : p.wall === 'right' ? clearRight : () => true;
+ if (!clear(along, p.w / 2)) return null;
+ const off = room.halfW - 0.06;
+ const pos: [number, number, number] =
+ p.wall === 'left'
+ ? [-off, p.y, along]
+ : p.wall === 'right'
+ ? [off, p.y, along]
+ : [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 (
+
+
+
+
+ );
+ })}
- {/* Picture rail, visibly out of level */}
- {[-1, 1].map((side) => (
-
-
-
+ {/* Thin modern black rail — slight intentional skew */}
+ {([-1, 1] as const).map((side) => (
+
+
+
))}
- {/* Service pipe run, with a clumsy elbow into the ceiling */}
-
-
-
-
-
-
-
-
+ {/* Clean modern skirting */}
+ {(['left', 'right', 'back'] as const).map((wall) => (
+
+ ))}
- {/* Skirting that changes its mind halfway along */}
-
-
-
+ {/* Shadow-gap cornice instead of crumbling moulding */}
+
+
-
+ {/* Geometric hanging mobile (box collage, not a pipe) */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
@@ -492,8 +515,8 @@ export function DadaDetails({ room, trim }: PeriodProps) {
* Surrealist interior — Surrealism.
*
* The 1938 Exposition Internationale: sacks slung under the ceiling, drapery
- * pulled across the cornice, and a door standing in a corner where no door can
- * lead anywhere. The room is furnished as an image, not as a gallery.
+ * gathered along the cornice, and a brazier glow in one corner. The room is
+ * furnished as an image, not as a gallery — no freestanding corner door.
*/
export function SurrealistDetails({ room, trim }: PeriodProps) {
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 */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{(['left', 'right', 'back'] as const).map((wall) => (
@@ -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
- * caps, a pressed-tin ceiling field, and the sprinkler main running the length
- * of the room. Industrial fabric left exactly as the painters found it.
+ * A Tenth Street studio after the painters moved in: whitewashed walls, square
+ * timber posts, open wood joists, and wide north-light picture windows. Warm
+ * and open — not cast-iron bars, not a sprinkler cage.
*/
export function LoftDetails({ room, trim }: PeriodProps) {
const corners = useCorners(room.halfW, room.halfD, 0.26);
- const joists = useBayZ(room.depth, room.halfD, 1.7, 13);
- const iron = '#5c5852';
+ const joists = useBayZ(room.depth, room.halfD, 2.0, 11);
+ const timber = '#8a7358';
+ const timberLight = '#c4a882';
return (
- {/* Cast-iron columns with corbelled caps */}
+ {/* Square timber posts — studio structure, not iron bars */}
{corners.map(([x, z], i) => (
-
-
-
+
+
+
-
-
+
+
-
-
-
-
-
-
-
+
+
+
))}
- {/* Pressed-tin ceiling field on close-set joists */}
+ {/* Open timber joists — painter studio ceiling */}
- {/* Sprinkler main with drops */}
-
-
-
-
- {joists
- .filter((_, i) => i % 4 === 0)
- .map((z, i) => (
-
-
-
-
- ))}
-
- {/* Steel skirting angle */}
+ {/* Soft wood skirting */}
{(['left', 'right', 'back'] as const).map((wall) => (
-
+
))}
+
+
);
}
diff --git a/client/src/components/hall-details/primitives.tsx b/client/src/components/hall-details/primitives.tsx
index 3907d7c..3eb0e47 100644
--- a/client/src/components/hall-details/primitives.tsx
+++ b/client/src/components/hall-details/primitives.tsx
@@ -1,5 +1,14 @@
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. */
interface SurfaceProps {
@@ -32,9 +41,40 @@ export function WallBand({
span?: number;
}) {
const alongZ = wall === 'left' || wall === 'right';
- const len = (alongZ ? room.depth : room.width) * span;
const thick = proud + EMBED * 2;
const off = WALL_INNER + proud / 2 - EMBED;
+ const 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 (
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ const len = (alongZ ? room.depth : room.width) * span;
const args: [number, number, number] = alongZ ? [thick, height, len] : [len, height, thick];
const pos: [number, number, number] =
wall === 'back'
@@ -48,13 +88,7 @@ export function WallBand({
return (
-
+
);
}
diff --git a/client/src/data/movement-interior-styles.ts b/client/src/data/movement-interior-styles.ts
index 2b7675a..40b2c71 100644
--- a/client/src/data/movement-interior-styles.ts
+++ b/client/src/data/movement-interior-styles.ts
@@ -11,7 +11,11 @@ export type GalleryWindowStyle =
| 'art-nouveau'
| 'glass-block'
| '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 {
wall: 'back' | 'left' | 'right' | 'ceiling';
@@ -132,7 +136,7 @@ const BY_MOVEMENT_ID: Record = {
'Roman atrium',
'Marble-clad villa · mosaic floor · clerestory daylight',
{ 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',
// Lit like the basilica: shared lights held back so the walls read as
@@ -211,7 +215,7 @@ const BY_MOVEMENT_ID: Record = {
'Florentine palazzo',
'Lime-washed walls · terracotta accents · arched windows',
{ wall: 'painted-lime', wallSide: 'stucco-cream', ceiling: 'fresco-worn', floor: 'terracotta-tiles' },
- { wall: '#89847e', ceiling: '#96948f', floor: '#c89070', trim: '#c9a227' },
+ { wall: '#89847e', ceiling: '#96948f', floor: '#a07050', trim: '#c9a227' },
{
details: 'quattrocento',
// Lit like the basilica: shared lights held back so the walls read as
@@ -228,9 +232,9 @@ const BY_MOVEMENT_ID: Record = {
31: mk(
'high-renaissance-palazzo',
'Roman palazzo',
- 'Marble and stucco · coffered ceiling · checker floor',
- { wall: 'marble-veined-carrara', wallSide: 'stucco-cream', ceiling: 'plaster-warm', floor: 'marble-checker' },
- { wall: '#918f8a', wallSide: '#8f8980', ceiling: '#9c9994', floor: '#ddd8cc', trim: '#c9a227' },
+ 'Marble and stucco · calm coffered ceiling · veined stone floor',
+ { wall: 'marble-veined-carrara', wallSide: 'stucco-cream', ceiling: 'plaster-warm', floor: 'marble-veined-carrara' },
+ { wall: '#918f8a', wallSide: '#8f8980', ceiling: '#9c9994', floor: '#9a9080', trim: '#c9a227' },
{
details: 'cinquecento',
// Lit like the basilica: shared lights held back so the walls read as
@@ -241,7 +245,7 @@ const BY_MOVEMENT_ID: Record = {
{ 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: '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 = {
'Mannerist gallery',
'Dramatic stucco · elongated windows · veined marble',
{ 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',
// Lit like the basilica: shared lights held back so the walls read as
@@ -290,7 +294,7 @@ const BY_MOVEMENT_ID: Record = {
'Baroque state gallery',
'Crimson velvet · gilded stucco · grand windows',
{ 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',
titleColor: '#f0d890',
@@ -312,7 +316,7 @@ const BY_MOVEMENT_ID: Record = {
'Rococo salon',
'Pastel painted walls · gilt trim · chevron parquet',
{ wall: 'painted-oil-satin', ceiling: 'silk-pale', floor: 'parquet-chevrons' },
- { wall: '#918a91', ceiling: '#9b9995', floor: '#c8a882', trim: '#d4af37' },
+ { wall: '#918a91', ceiling: '#9b9995', floor: '#9a7858', trim: '#d4af37' },
{
details: 'rococo',
// Lit like the basilica: shared lights held back so the walls read as
@@ -330,22 +334,22 @@ const BY_MOVEMENT_ID: Record = {
36: mk(
'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: '#908f8d', ceiling: '#9e9e9e', floor: '#eceae6', trim: '#b8b0a4' },
+ { wall: '#908f8d', ceiling: '#9e9e9e', floor: '#8a8680', trim: '#a0988c' },
{
details: 'neoclassical',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#4a4844',
- ambient: 0.65,
+ ambient: 0.58,
windows: windows(
- { wall: 'back', x: 0, y: 2.6, width: 2.8, height: 2.0, style: 'baroque-pair', lightColor: '#ffffff', lightIntensity: 4.0 },
- { wall: 'ceiling', x: 0, y: 0, width: 4.0, height: 2.5, style: 'skylight', lightColor: '#ffffff', lightIntensity: 5.0 },
- { wall: 'left', x: 0, y: 3.0, width: 2.0, height: 1.2, style: 'clerestory', lightColor: '#fffaf0', lightIntensity: 2.5 }
+ { 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: '#f0eee8', lightIntensity: 3.2 },
+ { 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(
@@ -388,7 +392,7 @@ const BY_MOVEMENT_ID: Record = {
'Impressionist salon',
'North-light skylight · pale painted walls · herringbone floor',
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
- { wall: '#8f8c87', ceiling: '#9e9e9e', floor: '#c8a882', trim: '#c9a96e' },
+ { wall: '#8f8c87', ceiling: '#9e9e9e', floor: '#9a7858', trim: '#c9a96e' },
{
details: 'north-light',
// Lit like the basilica: shared lights held back so the walls read as
@@ -415,7 +419,7 @@ const BY_MOVEMENT_ID: Record = {
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
windows: windows(
- { wall: 'left', x: 0, y: 2.4, width: 2.8, height: 2.0, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 4.5 },
+ { wall: '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 }
),
}
@@ -443,7 +447,7 @@ const BY_MOVEMENT_ID: Record = {
'Art Nouveau salon',
'Painted plaster · stained glass · terrazzo floor',
{ 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',
// Lit like the basilica: shared lights held back so the walls read as
@@ -471,7 +475,7 @@ const BY_MOVEMENT_ID: Record = {
titleColor: '#402010',
ambient: 0.7,
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 }
),
}
@@ -488,14 +492,14 @@ const BY_MOVEMENT_ID: Record = {
ambient: 0.52,
windows: windows(
{ 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(
'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: '#8c8a85', ceiling: '#999999', floor: '#9a8870', trim: '#888888' },
{
@@ -505,7 +509,7 @@ const BY_MOVEMENT_ID: Record = {
lightScale: 0.3,
ambient: 0.65,
windows: windows(
- { wall: 'left', x: 0, y: 2.5, width: 3.5, height: 2.4, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 5.5 },
+ { 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 }
),
}
@@ -513,7 +517,7 @@ const BY_MOVEMENT_ID: Record = {
46: mk(
'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: '#858585', ceiling: '#7a7a7a', floor: '#888880', trim: '#606060' },
{
@@ -525,8 +529,9 @@ const BY_MOVEMENT_ID: Record = {
ambient: 0.62,
fog: '#181818',
windows: windows(
- { wall: 'back', x: 0, y: 2.6, width: 4.0, height: 2.4, style: 'factory', lightColor: '#ffffff', lightIntensity: 6.0 },
- { wall: 'left', x: 0, y: 2.8, width: 2.5, height: 1.6, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 3.5 },
+ { wall: 'back', x: 0, y: 2.65, width: 4.2, height: 2.2, style: 'ribbon', lightColor: '#ffffff', lightIntensity: 5.8 },
+ { 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 }
),
doorWood: ['#606060', '#707070', '#505050'],
@@ -537,7 +542,7 @@ const BY_MOVEMENT_ID: Record = {
'Suprematist white cube',
'Matte white paint · geometric light · polished floor',
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
- { wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#e0e0e0', trim: '#cccccc' },
+ { wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#8e8e8e', trim: '#cccccc' },
{
details: 'suprematist',
// Lit like the basilica: shared lights held back so the walls read as
@@ -560,7 +565,7 @@ const BY_MOVEMENT_ID: Record = {
'Constructivist space',
'Glass block · concrete · angular daylight',
{ 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',
// Lit like the basilica: shared lights held back so the walls read as
@@ -578,21 +583,26 @@ const BY_MOVEMENT_ID: Record = {
),
49: mk(
'dada-salon',
- 'Dada salon',
- 'Eclectic bourgeois room · painted walls and exposed brick',
- { wall: 'painted-emulsion', wallSide: 'brick', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
- { wall: '#8b8478', wallSide: '#8a5040', ceiling: '#97948d', floor: '#8a6848', trim: '#6a4830' },
+ 'Dada cabaret',
+ 'Modern cabaret · collage panels · open picture windows',
+ { wall: 'painted-flat', wallSide: 'painted-emulsion', ceiling: 'painted-flat', floor: 'concrete-polished' },
+ { wall: '#f0ebe3', wallSide: '#e4d8c4', ceiling: '#f5f1ea', floor: '#949088', trim: '#1a1a1a' },
{
details: 'dada',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
+ titleColor: '#1a1a1a',
+ ambient: 0.68,
+ warmLight: '#fff6ea',
+ sunLight: '#f4f8ff',
windows: windows(
- { wall: 'back', x: -1.5, y: 2.5, width: 1.0, height: 1.4, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.5 },
- { wall: 'back', x: 1.5, y: 2.6, width: 0.8, height: 1.8, style: 'gothic-lancet', lightColor: '#ffe8c0', lightIntensity: 2.0 },
- { wall: 'right', x: 0, y: 2.4, width: 1.6, height: 1.6, style: 'factory', lightColor: '#ffffff', lightIntensity: 3.0 }
+ { 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.7, y: 2.7, width: 1.1, height: 1.35, style: 'picture', lightColor: '#fff0d8', lightIntensity: 2.8 },
+ { wall: 'right', x: 0, y: 2.5, width: 2.2, height: 1.9, style: 'picture', lightColor: '#f5f9ff', lightIntensity: 3.8 }
),
trackLights: 1.0,
+ doorWood: ['#2a2a2a', '#3a3a3a', '#1a1a1a'],
}
),
50: mk(
@@ -616,21 +626,25 @@ const BY_MOVEMENT_ID: Record = {
),
51: mk(
'abstract-expressionist-loft',
- 'NYC loft',
- 'Raw brick and painted plaster · north-light factory windows',
- { wall: 'brick', wallSide: 'painted-flat', ceiling: 'concrete-raw', floor: 'industrial-floor' },
- { wall: '#8a5040', wallSide: '#858585', ceiling: '#989898', floor: '#888880', trim: '#606060' },
+ 'Painter loft',
+ 'Whitewashed plaster · warm timber · open north-light picture windows',
+ { wall: 'painted-flat', wallSide: 'painted-emulsion', ceiling: 'plaster-warm', floor: 'industrial-floor' },
+ { wall: '#e8e2d6', wallSide: '#ddd5c6', ceiling: '#f0ebe2', floor: '#9a9288', trim: '#6a5848' },
{
details: 'loft',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
- titleColor: '#303030',
- ambient: 0.65,
+ titleColor: '#3a3028',
+ ambient: 0.72,
+ warmLight: '#fff6e8',
+ sunLight: '#f2f6ff',
windows: windows(
- { wall: 'left', x: 0, y: 2.5, width: 4.0, height: 2.6, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 6.5 },
- { wall: 'ceiling', x: 0, y: 0, width: 3.5, height: 2.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 4.5 }
+ { wall: 'left', x: -1.2, y: 2.55, width: 2.4, height: 2.3, style: 'picture', lightColor: '#f0f6ff', lightIntensity: 5.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(
@@ -638,7 +652,7 @@ const BY_MOVEMENT_ID: Record = {
'Pop Art gallery',
'White painted walls · fluorescent daylight · polished concrete',
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
- { wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#d8d8d8', trim: '#cccccc' },
+ { wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#909090', trim: '#cccccc' },
{
details: 'white-cube',
// Lit like the basilica: shared lights held back so the walls read as
@@ -650,7 +664,7 @@ const BY_MOVEMENT_ID: Record = {
fog: '#1a1a1a',
background: '#101010',
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 }
),
trackLights: 0.6,
diff --git a/client/src/utils/movementHallLayout.ts b/client/src/utils/movementHallLayout.ts
index 2245673..cf82df3 100644
--- a/client/src/utils/movementHallLayout.ts
+++ b/client/src/utils/movementHallLayout.ts
@@ -1,5 +1,6 @@
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). */
export const MOVEMENT_PAINTINGS_PER_HALL = 55;
@@ -69,7 +70,7 @@ function frameOuterW(w: number, reviewed: boolean) {
return w + matBorder * 2 + rail;
}
-function layoutRow(paintings: Painting[], span: number) {
+function layoutRow(paintings: Painting[], span: number, endPad = WALL_PADDING) {
const count = paintings.length;
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]));
if (i < count - 1) total += FRAME_GAP;
}
- if (total <= span - WALL_PADDING) break;
+ if (total <= span - endPad) break;
frameW -= 0.015;
}
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 });
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[]) {
@@ -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(
paintings: Painting[],
span: number,
side: 'left' | 'right',
halfW: number,
- inset: number
+ inset: number,
+ details?: MovementDetailKind
): FrameSlot[] {
if (paintings.length === 0) return [];
- const { slots: rowSlots } = layoutRow(paintings, span);
const y = EYE_HEIGHT;
- 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 {
- maxW: s.maxW,
- maxH: s.maxH,
- rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
- side,
- position:
- side === 'left'
- ? ([-halfW + inset + WALL_STANDOFF, y, alongWall] as [number, number, number])
- : ([halfW - inset - WALL_STANDOFF, y, alongWall] as [number, number, number]),
- };
+ const halfD = span / 2;
+ const keepOuts = sideColumnKeepOutIntervals(details, span, halfD);
+
+ const mapSlot = (alongWall: number, s: { maxW: number; maxH: number }): FrameSlot => ({
+ maxW: s.maxW,
+ maxH: s.maxH,
+ rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
+ side,
+ position:
+ side === 'left'
+ ? ([-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. */
@@ -225,12 +336,11 @@ function layoutBackSlots(
export function buildMovementHallLayout(
paintings: Painting[],
hallIndex: number,
- hallCount: number
+ hallCount: number,
+ details?: MovementDetailKind
): MovementHallLayout {
const { left, back, right } = distributeAcrossWalls(paintings);
- const leftSpan = layoutRow(left, MIN_HALL_SIZE);
- const rightSpan = layoutRow(right, MIN_HALL_SIZE);
- const depth = Math.max(MIN_HALL_SIZE, leftSpan.spanNeeded, rightSpan.spanNeeded);
+ const depth = depthForSideHang(left, right, details);
// 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.
@@ -261,13 +371,13 @@ export function buildMovementHallLayout(
side: 'left',
label: left.length > 0 ? `Wing ${hallIndex + 1} · Left wall` : '',
paintings: left,
- slots: layoutSideSlots(left, depth, 'left', halfW, inset),
+ slots: layoutSideSlots(left, depth, 'left', halfW, inset, details),
},
{
side: 'right',
label: right.length > 0 ? `Wing ${hallIndex + 1} · Right wall` : '',
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);
- 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][] {
@@ -336,15 +449,20 @@ export function computeSideWallWindows(
const specs: GalleryWindowSpec[] = [];
const windowY = 3.15;
const minGap = tmpl.width + 0.6;
+ const columnKeepOuts = sideColumnKeepOutIntervals(interiorStyle.details, layout.depth, halfD);
for (const side of ['left', 'right'] as const) {
const seg = layout.segments.find((s) => s.side === side);
if (!seg) continue;
- const occupied = seg.slots.map((s): [number, number] => {
- const outerW = frameOuterW(s.maxW, false);
- return [s.position[2] - outerW / 2 - 0.45, s.position[2] + outerW / 2 + 0.45];
- });
+ const occupied = [
+ ...seg.slots.map((s): [number, number] => {
+ 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 maxWindows = Math.min(3, gaps.length);
diff --git a/client/src/utils/sideColumnKeepOuts.ts b/client/src/utils/sideColumnKeepOuts.ts
new file mode 100644
index 0000000..06fbb2a
--- /dev/null
+++ b/client/src/utils/sideColumnKeepOuts.ts
@@ -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>;
+
+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>)[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]
+ );
+}
diff --git a/server/__tests__/abex-loft-interior.test.js b/server/__tests__/abex-loft-interior.test.js
new file mode 100644
index 0000000..73c29ed
--- /dev/null
+++ b/server/__tests__/abex-loft-interior.test.js
@@ -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);
+ });
+});
diff --git a/server/__tests__/baroque-panels.test.js b/server/__tests__/baroque-panels.test.js
new file mode 100644
index 0000000..6a67669
--- /dev/null
+++ b/server/__tests__/baroque-panels.test.js
@@ -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\)/);
+ });
+});
diff --git a/server/__tests__/dada-interior.test.js b/server/__tests__/dada-interior.test.js
new file mode 100644
index 0000000..4f0a89c
--- /dev/null
+++ b/server/__tests__/dada-interior.test.js
@@ -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);
+ });
+});
diff --git a/server/__tests__/doorway-clearance.test.js b/server/__tests__/doorway-clearance.test.js
new file mode 100644
index 0000000..4e0c92e
--- /dev/null
+++ b/server/__tests__/doorway-clearance.test.js
@@ -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*>/);
+ });
+});
diff --git a/server/__tests__/floor-brightness.test.js b/server/__tests__/floor-brightness.test.js
new file mode 100644
index 0000000..a8f4162
--- /dev/null
+++ b/server/__tests__/floor-brightness.test.js
@@ -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'/);
+ });
+});
diff --git a/server/__tests__/high-renaissance-interior.test.js b/server/__tests__/high-renaissance-interior.test.js
new file mode 100644
index 0000000..02d498d
--- /dev/null
+++ b/server/__tests__/high-renaissance-interior.test.js
@@ -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/);
+ });
+});
diff --git a/server/__tests__/mannerist-columns.test.js b/server/__tests__/mannerist-columns.test.js
new file mode 100644
index 0000000..7cbaecf
--- /dev/null
+++ b/server/__tests__/mannerist-columns.test.js
@@ -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);
+ });
+});
diff --git a/server/__tests__/neoclassical-interior.test.js b/server/__tests__/neoclassical-interior.test.js
new file mode 100644
index 0000000..e3713fc
--- /dev/null
+++ b/server/__tests__/neoclassical-interior.test.js
@@ -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`);
+ });
+});
diff --git a/server/__tests__/no-factory-windows.test.js b/server/__tests__/no-factory-windows.test.js
new file mode 100644
index 0000000..dcea365
--- /dev/null
+++ b/server/__tests__/no-factory-windows.test.js
@@ -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);
+ });
+});
diff --git a/server/__tests__/painting-column-clearance.test.js b/server/__tests__/painting-column-clearance.test.js
new file mode 100644
index 0000000..46424ae
--- /dev/null
+++ b/server/__tests__/painting-column-clearance.test.js
@@ -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`);
+ }
+ }
+ });
+});
diff --git a/server/__tests__/surrealist-interior.test.js b/server/__tests__/surrealist-interior.test.js
new file mode 100644
index 0000000..e7e9137
--- /dev/null
+++ b/server/__tests__/surrealist-interior.test.js
@@ -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);
+ });
+});