From 13f6f088b6715bdeef40d8435d438e4e50310a6f Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Tue, 15 Sep 2026 12:02:10 +0300 Subject: [PATCH] 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 --- Documentation/Plans.md | 1 + Documentation/basics.md | 9 +- Documentation/data-and-images.md | 26 +- client/src/components/GalleryWindows.tsx | 39 ++- client/src/components/MovementHallDetails.tsx | 8 +- client/src/components/VirtualGallery.tsx | 4 +- .../src/components/hall-details/classical.tsx | 36 +-- .../src/components/hall-details/courtly.tsx | 150 ++++++---- .../src/components/hall-details/geometry.ts | 16 +- .../src/components/hall-details/modernism.tsx | 256 +++++++++--------- .../components/hall-details/primitives.tsx | 52 +++- client/src/data/movement-interior-styles.ts | 106 ++++---- client/src/utils/movementHallLayout.ts | 182 ++++++++++--- client/src/utils/sideColumnKeepOuts.ts | 58 ++++ server/__tests__/abex-loft-interior.test.js | 46 ++++ server/__tests__/baroque-panels.test.js | 34 +++ server/__tests__/dada-interior.test.js | 54 ++++ server/__tests__/doorway-clearance.test.js | 38 +++ server/__tests__/floor-brightness.test.js | 57 ++++ .../high-renaissance-interior.test.js | 40 +++ server/__tests__/mannerist-columns.test.js | 35 +++ .../__tests__/neoclassical-interior.test.js | 51 ++++ server/__tests__/no-factory-windows.test.js | 34 +++ .../painting-column-clearance.test.js | 98 +++++++ server/__tests__/surrealist-interior.test.js | 35 +++ 25 files changed, 1147 insertions(+), 318 deletions(-) create mode 100644 client/src/utils/sideColumnKeepOuts.ts create mode 100644 server/__tests__/abex-loft-interior.test.js create mode 100644 server/__tests__/baroque-panels.test.js create mode 100644 server/__tests__/dada-interior.test.js create mode 100644 server/__tests__/doorway-clearance.test.js create mode 100644 server/__tests__/floor-brightness.test.js create mode 100644 server/__tests__/high-renaissance-interior.test.js create mode 100644 server/__tests__/mannerist-columns.test.js create mode 100644 server/__tests__/neoclassical-interior.test.js create mode 100644 server/__tests__/no-factory-windows.test.js create mode 100644 server/__tests__/painting-column-clearance.test.js create mode 100644 server/__tests__/surrealist-interior.test.js 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); + }); +});