Author SHA1 Message Date
Danila KhodjaefandClaude Opus 5 72fe45fa52 Document the test suite in the command and setup docs.
`package.json` has had a `test` script since the interior-polish work landed,
and 12 files under `server/__tests__/` now guard hall geometry, clearance,
brightness, window styles and the movement-links validators. Nothing outside
`data-and-images.md` said the suite existed, and no document said how to run
it — despite Bible.md requiring tests to be written before the code.

FAC.md gains a Tests section between Verify and Build frontend: the command,
what the suite covers by area, and the two properties that make it cheap to
run — no test framework (Node's built-in runner) and no database or server,
since the tests read the client's style and geometry modules from disk.

setup.md lists `npm test` first in the Run table and notes on the Node
prerequisite that the script depends on `node --test` expanding `**` itself.
README.md gets the command beside the dev-server ones, and the interiors
section of data-and-images.md now points at the suite that enforces its rules.

Docs only; no source changes. Verified by running the suite (15 suites, 41
tests, all passing) and by confirming from PowerShell — which does not expand
globs — that the runner does its own pattern matching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 22:04:59 +03:00
Danila KhodjaefandCursor 9f96b3835d Add seven painting images and matching hall thumbnails.
Covers Picabia, Miró, Vlaminck, Cézanne, Renoir, van Gogh, and Kandinsky works newly on disk.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:08:00 +03:00
Danila KhodjaefandCursor 13f6f088b6 Polish movement hall interiors: clear doors and bay columns, tone floors and windows.
Document hang keep-outs and period-module fixes; leave columns fixed while packing frames into gaps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:02:10 +03:00
Danila KhodjaefandCursor 1b760faf0d Add curator Movement links config with proof URLs and admin-gated delete votes.
Persist lineage in PostgreSQL, seed documented edges, and keep timeline/tree consumers API-backed with a static fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 14:37:20 +03:00
Danila Khodjaef 2b4cdc5a9f Merge branch 'movement-hall-interiors' 2026-09-02 14:13:28 +03:00
Danila KhodjaefandCursor 2960af35bc Document agent instructions and link Bible.md in README.
Adds AGENTS.md and Documentation/Bible.md for AI assistants, and lists Bible in the project documentation index.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 14:13:07 +03:00
Danila KhodjaefandClaude Opus 5 f2a256132c Give every movement its own period interior.
Movement halls routed 24 of the 26 movements through 8 generic detail kinds,
four of which (modern, industrial, atelier, museum) rendered nothing at all,
and `salon` was a single ceiling ring shared by eight movements.

`details` is now a 26-value MovementDetailKind, one per movement, each built
from the architecture of the same period as the movement itself. Gothic and
Byzantine are unchanged and stay in MovementHallDetails.tsx as the reference
implementations; the rest live under components/hall-details/, split by era,
over shared wall-flush primitives.

hall-details/geometry.ts records the budget the Gothic and Byzantine
components established, since frames hang 0.23 m proud with a 0.7 m clear
margin at each wall end: relief limits above and below the frame line, corner
pockets for freestanding masses, doorway and title-band clearance, and a cap
of two added lights per hall. Halls now pass their computed windows down so
upper-wall panels, arches and roundels step around the openings.

Also holds the pale interiors back from blowing out: gallery wall tints are
authored around 0.50-0.56 luminance instead of near-white, and the 19 halls
that read too bright take lightScale 0.30, matching the basilica.

Verified by rendering all 26 interiors offline at two hall sizes and
measuring the scene graph: nothing through walls, floor or ceiling, maximum
0.17 m intrusion into the hang zone, at most two added lights per hall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 18:44:05 +03:00
76 changed files with 5854 additions and 358 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
Use @Documentation/Bible.md as the primary source of instructions. It cannot be overridden.
+21 -1
View File
@@ -125,11 +125,14 @@ Each list entry includes `created_at`, `username`, `user_role`, `action`, `resou
| `POST /api/artists/:id/upload-portrait` | `images` | `artist.upload_portrait` |
| `/api/translations/*` | `translations` | `translation.*` |
| `/api/influences/*` | `influences` | `influence.*` |
| `POST` `/api/movement-links` | `influences` | `movement_link.create` |
| `POST`/`DELETE` `/api/movement-links` votes | `influences` | `movement_link.delete_vote*` |
| `DELETE` `/api/movement-links/:id` | admin role | `movement_link.delete` |
| Tour admin (`/api/tours/admin`, POST/PATCH/DELETE, stops) | `tours` | `tour.*` |
| `/api/users/*` | `users` | `user.*` |
| `/api/audit/*` | admin role | — (read) |
**Public** (no login): all catalog `GET` routes, `POST /api/artists/:id/preload-images`, `POST /api/movements/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static.
**Public** (no login): all catalog `GET` routes, `GET /api/movement-links`, `POST /api/artists/:id/preload-images`, `POST /api/movements/:id/preload-images` (local file linking for 3D halls), `/images`, SPA static.
Staff mutations are recorded in `curator_audit_log` with `user_id` (see [DB_structure.md](DB_structure.md)). Browse via admin **Activity** UI or `/api/audit`.
@@ -261,6 +264,23 @@ Public painting detail still exposes read-only `influencedBy` / `influenced` (un
---
## Movement lineage links
Timeline / Tree of Art predecessor→successor edges. Base path: `/api/movement-links`. See [data-and-images.md](data-and-images.md#movement-lineage-timeline--tree-of-art).
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| `GET` | `/api/movement-links` | public | List links (`q`, pagination); seeds defaults when empty; includes `delete_vote_count` |
| `POST` | `/api/movement-links` | curator (`influences`) | Create `{ sourceMovementId, targetMovementId, proofUrl, proofLabel?, linkYearApprox?, notes? }` — **proofUrl required** |
| `GET` | `/api/movement-links/:id/delete-votes` | curator (`influences`) | List delete votes (username + reason) |
| `POST` | `/api/movement-links/:id/delete-votes` | curator (`influences`) | Vote to delete `{ reason }` (min 10 characters) |
| `DELETE` | `/api/movement-links/:id/delete-votes/me` | curator (`influences`) | Withdraw own delete vote |
| `DELETE` | `/api/movement-links/:id` | **admin** | Hard delete (curators vote instead) |
Audit: `movement_link.create` / `movement_link.delete_vote` / `movement_link.delete_vote_withdraw` / `movement_link.delete`.
---
## Tours
Base path: `/api/tours`. Full guide: [tours.md](tours.md).
+29
View File
@@ -0,0 +1,29 @@
This is Bible for agents. Obey what is written here.
there are two parts - Old testament You can never override what is written in it.
New testament - you can override rules from it only when user dekliberatly asks to do it. it can be done only once at a time, never in any auto mode.
---
**Old testament**
"No", "I do not know", "I have no information" etc are valid answers.
Try to be more professional, if asked to discuss search for the arguments both pro and contra thesis. User encouragement is not required.
---
**New testament**
Never halucinate, make up facts, refrences, links etc untill user asks you directly.
When coding and ask to commit, update documentation first.
Gite has a bug, always repeat pull, push or other commands twice if failed first time.
When coding write tests, **before ** writing code.
If possible, check context window usage, warn user if it is above 70%.
---
+32
View File
@@ -33,6 +33,8 @@ erDiagram
paintings ||--o{ painting_influence_sources : influenced_by
artists ||--o{ painting_influence_sources : artist_source
art_movements ||--o{ painting_influence_sources : movement_source
art_movements ||--o{ movement_lineage_links : source
art_movements ||--o{ movement_lineage_links : target
```
## Tables
@@ -213,6 +215,36 @@ Unique index on `(painting_id, source_type, source_painting_id, source_artist_id
**Canonical influence store.** Used by all API influence queries: painting detail (`influencedBy`, `influenced`), `has_influence_links`, and artist hall navigation (predecessors / successors). Legacy `painting_influences` rows are backfilled here on migration; new curated painting edges are written to both tables by `update-influences`.
### `movement_lineage_links`
Art-movement predecessor → successor edges for the timeline flow and Tree of Art. Migration: `db/migrate-movement-links.sql`.
| Column | Type | Notes |
|--------|------|-------|
| `id` | SERIAL PK | |
| `source_movement_id` | FK → `art_movements` | Predecessor |
| `target_movement_id` | FK → `art_movements` | Successor (must differ from source) |
| `link_year_approx` | INTEGER | Approximate transition year |
| `proof_url` | TEXT NOT NULL | Required literature / museum / web citation URL |
| `proof_label` | TEXT | Short display label for the proof |
| `notes` | TEXT | Optional curator note |
| `created_by` | FK → `users` | Curator who added the row |
| `created_at` / `updated_at` | TIMESTAMPTZ | |
Unique on `(source_movement_id, target_movement_id)`.
### `movement_lineage_delete_votes`
Curator votes requesting deletion of a lineage link. Only **admins** may hard-delete the link. Migration: `db/migrate-movement-link-delete-votes.sql`.
| Column | Type | Notes |
|--------|------|-------|
| `id` | SERIAL PK | |
| `link_id` | FK → `movement_lineage_links` | Cascade on link delete |
| `user_id` | FK → `users` | One vote per user per link |
| `reason` | TEXT NOT NULL | Min 10 characters (trimmed) |
| `created_at` | TIMESTAMPTZ | |
### `users`
Staff accounts (named logins). Anonymous site visitors do not have rows here. Migration: `db/migrate-auth.sql` + `db/migrate-user-roles.sql`.
+26 -4
View File
@@ -95,7 +95,7 @@ CURATOR_USERNAME=curator
CURATOR_PASSWORD=your-secure-password
```
Then open the gallery → **Curator login** (top-right) → use tools allowed by your role/permissions (debug, Checkup, Translations, Influences, Tour editor, **Users**, **Activity**). Mutations are logged in `curator_audit_log` per user.
Then open the gallery → **Curator login** (top-right) → use tools allowed by your role/permissions (debug, Checkup, Translations, Influences, **Movement links** top-left, Tour editor, **Users**, **Activity**). Mutations are logged in `curator_audit_log` per user.
If login fails after changing `CURATOR_PASSWORD` in `.env`, run `npm run dev:reset-curator` (bootstrap only runs when `users` is empty; reset upserts the env account as **admin**).
@@ -103,9 +103,9 @@ If login fails after changing `CURATOR_PASSWORD` in `.env`, run `npm run dev:res
| Role | Access |
|------|--------|
| Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios |
| Curator | Public browse + assigned permission flags (`images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`) |
| Admin | All curator tools + **Users** + **Activity** audit reports |
| Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios; can open **Movement links** login gate |
| Curator | Public browse + assigned permission flags (`images`, `checkup`, `curator_notes`, `translations`, `influences`, `tours`, `users`). With `influences`: Influences + Movement links create/vote-to-delete |
| Admin | All curator tools + **Users** + **Activity** + hard-delete movement links |
**Users page:** after admin login, header → **Users** — create/edit staff, reset passwords, disable accounts. Prod and dev keep **separate** `users` tables: `devtoprod:db:restore` and harmonize never copy staff accounts. If create fails with a confusing “already exists” after a restore, serial sequences may be lagging — current restore syncs them to `MAX(id)`, and user create re-syncs `users_id_seq` before insert.
@@ -286,6 +286,28 @@ docker login gitea.mysuperlab.netcraze.pro
---
## Tests
```powershell
npm test # whole suite
node --test server/__tests__/floor-brightness.test.js # one file
```
Runs `node --test server/__tests__/**/*.test.js` — **15 suites, 41 tests, ~1 s**. No test framework is installed: this is Node's built-in runner, and `node --test` expands the `**` pattern itself, so the command behaves the same from PowerShell, cmd and bash.
The suite needs **no database and no running server** — the tests read the client's style and geometry modules from disk and call the server's validators directly.
| Area | Test files |
|---|---|
| Hang geometry and clearance | `painting-column-clearance`, `doorway-clearance` |
| Period interiors | `abex-loft-interior`, `baroque-panels`, `dada-interior`, `high-renaissance-interior`, `mannerist-columns`, `neoclassical-interior`, `surrealist-interior` |
| Brightness and windows | `floor-brightness`, `no-factory-windows` |
| Movement links and lineage | `movement-lineage-service` |
**Write the test before the code it covers** — see [Bible.md](Bible.md). The interior rules this suite enforces are described in [data-and-images.md](data-and-images.md).
---
## Build frontend
```powershell
+2
View File
@@ -18,4 +18,6 @@ this file contains draft for future releases and features
9. ~~vertical bottom-up timeline (alternative start page)~~ — done: `VerticalTimeline` + `VerticalMovementBands`, header layout switch — [basics.md](basics.md#timeline-and-movement-flow)
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)
+19 -9
View File
@@ -37,9 +37,11 @@ Gallery/
│ │ ├── components/GalleryLoadingMarker.tsx # Loading spinner overlay/banner (catalog, portraits, halls)
│ │ ├── components/GalleryWindows.tsx # Side-wall daylight windows (movement)
│ │ ├── components/HallPassage.tsx # Open archway between movement wings
│ │ ├── components/MovementHallDetails.tsx # Period architectural details
│ │ ├── components/MovementHallDetails.tsx # Period architecture dispatcher (Gothic + Byzantine reference)
│ │ ├── components/hall-details/ # One period interior per movement + shared primitives
│ │ ├── data/movement-interior-styles.ts # Per-movement interior themes
│ │ ├── utils/movementHallLayout.ts # Wing split + window gap placement
│ │ ├── utils/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
@@ -55,6 +57,7 @@ Gallery/
│ │ ├── pages/CheckupPage.tsx # Image audit table
│ │ ├── pages/TranslationsPage.tsx # Russian translation review
│ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard
│ │ ├── pages/MovementLinksPage.tsx # Movement lineage CRUD + delete votes
│ │ ├── pages/ToursPage.tsx # Guided tour editor
│ │ ├── pages/UsersPage.tsx # Staff accounts
│ │ ├── pages/AuditPage.tsx # Admin curator activity reports
@@ -62,7 +65,7 @@ Gallery/
│ │ ├── i18n/ # react-i18next bootstrap
│ │ ├── locales/{en,ru}/ # UI chrome strings (incl. timeline layout + captions)
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
│ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI)
│ │ ├── data/movement-lineage.ts # Fallback movement predecessor links (UI)
│ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor
│ │ ├── utils/debugMode.ts # Debug mode + “Show more” localStorage prefs
│ │ ├── utils/timelineView.ts # Shared zoom/pan math for timeline + movements
@@ -262,7 +265,7 @@ Each visible movement is drawn as a **portrait-width curved stream** (~54 px str
| Feature | Implementation |
|---------|----------------|
| Lineage layout | `client/src/data/movement-lineage.ts` — curated predecessor→successor pairs (Met / ArtStory / museum essays); multiple parents allowed |
| Lineage layout | `movement_lineage_links` (DB) via `GET /api/movement-links`; client fallback `movement-lineage.ts`; **Movement links** page (top-left header control; create/vote needs `influences`; hard delete is **admin-only**); multiple parents allowed |
| Vertical lanes | Movements share a horizontal lane only when one ends at least **10 years** before the next starts (in the current zoom); closer or overlapping spans stack into extra rows (`assignTemporalLanes`). Lane choice **minimizes vertical branch length**: children prefer parent lanes (same row when years allow), corridor clearing (up to 20 passes) evicts unrelated streams preferring **rows above** the parent/child strip, then re-attracts the child toward the parent |
| Branch connectors | Smooth curves from fan-out points along a parent stream to the **left edge (start)** of each child stream; origin X is always strictly left of the target (time-forward only — never right→left); stroke thickness matches the **target** (child) band height; color gradients from parent → child at constant opacity; a stream-shaped mask hides branch ink under movements so translucent overlaps do not brighten the bands; pan/zoom layout changes **animate** (streams + branches chase new geometry) so shifts stay trackable |
| Band thickness | Stream height is **proportional to `influence_link_count`** (edges on paintings by artists in that movement): thicker bands for denser influence graphs; lane rows share vertical space weighted by the thickest band in each lane |
@@ -295,7 +298,7 @@ Each artist appears as a **portrait circle** on their movement’s stream row:
Only **mousedown** on portraits and movement labels stops propagation (so drag-to-pan does not start when clicking them). Hovering a portrait highlights the artist’s lifespan on the era bar and brightens their segment on the movement stream.
**Note:** Movement lineage is **frontend curation** for layout and labels — it is not stored in PostgreSQL. Painting influence links live in **`painting_influence_sources`** (paintings, artists, or movements as sources). The API reads that table for detail panels, hall navigation, and `has_influence_links`. The legacy **`painting_influences`** table is still written in parallel when curators add painting-to-painting edges but is not queried for display.
**Note:** Movement lineage lives in **`movement_lineage_links`** (source/target movements, approx year, required `proof_url`). Timeline / Tree of Art load it from `GET /api/movement-links` (static fallback in `movement-lineage.ts` if the API is empty). Curators with `influences` can add links and **vote to delete** (with reason); only **admins** hard-delete. Votes are stored in `movement_lineage_delete_votes`. Painting influence links live in **`painting_influence_sources`** (paintings, artists, or movements as sources). The API reads that table for detail panels, hall navigation, and `has_influence_links`. The legacy **`painting_influences`** table is still written in parallel when curators add painting-to-painting edges but is not queried for display.
## Virtual gallery (3D halls)
@@ -351,15 +354,18 @@ 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`. 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 |
| Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue |
| Windows | **Side walls only** — placed in gaps between frames when possible; if a wall is packed, high **clerestory** windows are still added so the hall keeps daylight (`computeSideWallWindows`). Styles are drawn in `GalleryWindows.tsx`; `gothic-lancet` builds a two-centred arch head from chords with a glazed spandrel, mullions, and transoms |
| Lighting | Shared hall lights only (ambient / hemisphere / directional + capped ceiling track spots + one fill per window). See **light budget** below |
| Period details | `MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** (never freestanding mid-hall or proud corner shafts that cover frames); `byzantine` adds engaged porphyry colonnettes with basket capitals, a marble revetment dado, and hanging brass polycandela; `gothic` adds bay-spaced compound piers with vault springers, transverse ribs arching across the nave, and a moulded string course — all flush to the side walls |
| Period details | `MovementHallDetails.tsx` dispatches to one module per movement. `byzantine` adds engaged porphyry colonnettes with basket capitals, a marble revetment dado, and hanging brass polycandela; `gothic` adds bay-spaced compound piers with vault springers, transverse ribs arching across the nave, and a moulded string course. Every other movement follows the same rules in `components/hall-details/`: orders and piers stay **flush to the side walls** (≤ 0.17 m proud), freestanding masses only in the corner pockets, and no more than two added lights per hall — see `hall-details/geometry.ts` |
| Back wall | Single wing: solid display wall. Multi-wing: **Exit double doors** → **Wing navigator** or **Exit to Timeline** |
| Front / entrance wall | Single wing: **Exit double doors** (leave the way you entered). Multi-wing: **“Next wing →”** archway when a later wing exists |
| Influence lamps | Same golden upside-down emissive fixtures as artist halls when `has_influence_links` is true (shared rail: 40 cm above the tallest frame in the wing) |
@@ -395,9 +401,13 @@ Full guide: [tours.md](tours.md).
**Light budget (critical):** Three.js forward shading cannot compile `MeshStandardMaterial` with dozens of dynamic lights. Large wings used to add **one `spotLight` per painting** (plus influence lamps), which pushed the scene to ~100–200 lights — walls, floor, and ceiling then rendered as a black void while painting canvases (`MeshBasicMaterial`) still showed. Halls therefore use **shared lighting only**: ambient / hemisphere / directional fill, a small set of ceiling track spots (`GalleryTrackLights`, capped), and **one** window spot each. Painting canvases stay unlit (`MeshBasicMaterial`); frames and influence fixtures use **emissive** accents instead of per-frame lights. Intensities are scaled for three.js physical lights (post-r155).
Shared lights carry a **brightness floor** so dark period styles never render as a black void. A style that is *meant* to be dim (a basilica lit by window shafts, not flooded) sets **`lightScale`** in `movement-interior-styles.ts` — it multiplies the scene ambient/hemisphere/directional/fill lights, the hall point lights, the ceiling track spots, and the HDR `environmentIntensity`. It defaults to **1**, so only styles that opt in are affected. Window fill lights are deliberately **not** scaled, so the daylight shafts still read against the darker room.
Shared lights carry a **brightness floor** so dark period styles never render as a black void. That floor is high enough to blow out a pale wall, so **`lightScale`** in `movement-interior-styles.ts` is the knob that holds a hall back — it multiplies the scene ambient/hemisphere/directional/fill lights, the hall point lights, the ceiling track spots, and the HDR `environmentIntensity`. Window fill lights are deliberately **not** scaled, so daylight shafts still read against the darker room.
**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`).
It defaults to **1**, but most halls now set it: Gothic uses **0.26**, Byzantine **0.30**, and the 19 formerly pale interiors (Roman through Pop Art) also use **0.30**. Only the five halls that are already dark by albedo — Symbolist, Expressionist, Surrealist, Baroque, Romantic — stay at 1, since scaling their lights would crush walls that already sit at 0.15–0.27 luminance.
**Wall brightness is albedo × `lightScale`.** Gallery walls are authored well below white for that reason: the reworked interiors sit around **0.50–0.56** wall luminance (0.66 for the two white-cube halls), landing at an effective 0.14–0.20 — the range Gothic and Byzantine established. Reach for `lightScale` before darkening a tint further; the two multiply, and dimming the hall dims the paintings with it.
**Period architecture:** Freestanding columns must not sit in the walkway or in the corner pocket in front of frames (hang margin is only ~0.7 m from each wall end). 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.
@@ -453,7 +463,7 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens
- **Timeline catalog** loads once from the API; pan/zoom is client-side only, with per-frame batching via `createViewChangeScheduler()`.
- **Movement flow interaction** uses simplified SVG and hides portrait DOM during active scroll/drag so zoom stays responsive over dense portrait fields.
- **Movement filtering** on zoom shows movements whose span overlaps the visible year range and that have at least one catalogued artist; a movement (e.g. Byzantine or Gothic viewed at 311–1231 CE) still appears even when all its artists lived outside the current window.
- **Movement lineage** (`movement-lineage.ts`) documents art-historical predecessor→successor links for the flow diagram; extend that file to add or correct branches.
- **Movement lineage** — top-left **Movement links** opens `MovementLinksPage` (login if needed). Create requires `influences` + proof URL; curators vote to delete with a reason; admins review votes and delete. Data: `movement_lineage_links` / `movement_lineage_delete_votes`; timeline uses `GET /api/movement-links` with fallback `movement-lineage.ts`. After code changes that affect the SPA on port `3451`, run `npm run build --prefix client` (that port serves `client/dist`).
- **One hall per artist** keeps navigation predictable: enter from the timeline or bio, leave via the single exit or back button.
- **Movement galleries** complement artist halls: full movement corpus in period-themed wings, entered from the flow diagram.
- **Guided tours** add curator-ordered winged halls with stop text on painting detail — [tours.md](tours.md).
+56 -10
View File
@@ -263,26 +263,72 @@ Discovered rows are stored with `confidence: discovered` and `discovered_via` (e
Extend `art-influences-data.js` for high-quality curated chains; use discovery to suggest additional artist and movement links for manual review.
## Movement lineage (frontend flow diagram)
## Movement lineage (timeline / Tree of Art)
Separate from the painting influence graph, `client/src/data/movement-lineage.ts` lists **art-movement** predecessor→successor pairs used only by `MovementBands.tsx` to position streams and draw branch connectors (e.g. Impressionism → Post-Impressionism → Fauvism / Cubism / Expressionism).
Separate from the painting influence graph, **`movement_lineage_links`** stores art-movement predecessor→successor pairs used by `MovementBands`, `VerticalMovementBands`, and `MovementTree` (e.g. Impressionism → Post-Impressionism → Fauvism / Cubism / Expressionism).
| Aspect | Detail |
|--------|--------|
| Storage | TypeScript module in the client — **not** a database table |
| Format | `[parentMovementName, childMovementName]` tuples; names must match `art_movements.name` from the seed |
| Multiple parents | Allowed (e.g. Post-Impressionism feeding several modern paths) |
| Sources | Curator notes in the file reference Met essays, TheArtStory, and similar |
| Storage | PostgreSQL `movement_lineage_links` (`db/migrate-movement-links.sql`) |
| Required proof | `proof_url` must be a valid `http(s)` URL (museum essay, literature, web reference) |
| Optional | `link_year_approx`, `proof_label`, `notes` |
| Format | FK pairs to `art_movements`; multiple parents allowed |
| API | Public `GET /api/movement-links`; curator `POST` create + delete votes; **admin-only** hard `DELETE` |
| UI | Top-left **Movement links** (timeline header); also linked from Influences. Curators create + vote-to-delete (reason); admins review votes and hard-delete |
| Fallback | `client/src/data/movement-lineage.ts` if the API returns no rows |
| Defaults | Seeded on migrate / first GET from Met / TheArtStory / Christie's essays |
| Delete votes | `movement_lineage_delete_votes` (`db/migrate-movement-link-delete-votes.sql`); one vote per curator per link |
To add or fix a branch, edit `MOVEMENT_LINEAGE` in that file and rebuild the client. No migration or API change is required.
To add a branch, use **Movement links** (proof URL required). To remove one, curators vote with a reason; an admin deletes after review. No client rebuild is required for data changes; rebuild `client/dist` only when shipping SPA changes to the API-static port (`3451` / prod).
## Movement gallery interiors (frontend)
Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and architectural details (pilasters, coffered ceilings, etc.). Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts` (colour maps in sRGB, normal maps in linear/`NoColorSpace`). Period mesh details live in `client/src/components/MovementHallDetails.tsx` — classical / neoclassical use **shallow engaged corner pilasters** so shafts never cover frames; Byzantine adds porphyry colonnettes / polycandela; Gothic (`details: 'gothic'`) adds bay-spaced compound piers with vault springers and transverse ribs flush to the side walls.
Separate from movement lineage layout, `client/src/data/movement-interior-styles.ts` defines a **unique 3D interior** for each seeded art movement (26 styles): wall/floor/ceiling textures, trim colours, window style, and the `details` kind that selects its architecture. Textures are generated procedurally in `client/src/utils/galleryProceduralTextures.ts` (colour maps in sRGB, normal maps in linear/`NoColorSpace`).
Gothic surfaces include **`gothic-ashlar`** (full-storey blind arcade mapped once vertically via `metersPerRepeat === WALL_HEIGHT`), **`gothic-vault`** (quadripartite rib bay), and **`gothic-stone-floor`**. Door-flanking wall panels use the hall wall texture via `GalleryWall` `kind`/`tint` so they match the textured walls. Deliberately dim naves set **`lightScale`** (Byzantine / Gothic) so shared lights stay soft while window shafts stay bright.
### Period architecture per movement
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.
Every movement is built from the architecture of **its own period** — no two movements share an interior. `client/src/components/MovementHallDetails.tsx` dispatches on `style.details` (`MovementDetailKind`, 26 values) to one component:
| Module | Movements |
|--------|-----------|
| `MovementHallDetails.tsx` (reference implementations) | `gothic` — bay-spaced compound piers, vault springers, transverse ribs · `byzantine` — porphyry colonnettes, basket capitals, revetment dado, hanging polycandela |
| `hall-details/classical.tsx` | `roman` (Tuscan half-columns, dentil entablature, Pompeian socle) · `quattrocento` (pietra serena pilasters, blind arcade, tondi) · `cinquecento` (paired fluted Corinthian pilasters, 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` (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 (`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 **`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 / 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 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.
### 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`.
Run `npm test` after touching any interior module, hall layout or tint: the rules above are covered by 15 suites under `server/__tests__/` that parse these source files directly — see [FAC.md](FAC.md) § Tests.
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)
+17
View File
@@ -82,6 +82,23 @@ Audit: `influence.create` / `update` / `delete` / `import` in `curator_audit_log
---
## Movement lineage links (related curator tool)
Top-left timeline control **Movement links** (also from Influences → **Movement links →**). Create/vote need `influences`; hard delete is **admin-only**. Tables `movement_lineage_links` + `movement_lineage_delete_votes`.
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/movement-links` | Public list (timeline / tree); includes `delete_vote_count` |
| `POST` | `/api/movement-links` | Create (`sourceMovementId`, `targetMovementId`, `proofUrl` required) |
| `POST` | `/api/movement-links/:id/delete-votes` | Curator vote to delete (`reason` required, min 10 chars) |
| `GET` | `/api/movement-links/:id/delete-votes` | List votes (username + reason) for admin review |
| `DELETE` | `/api/movement-links/:id/delete-votes/me` | Withdraw own vote |
| `DELETE` | `/api/movement-links/:id` | **Admin only** — hard delete |
See [data-and-images.md](data-and-images.md#movement-lineage-timeline--tree-of-art).
---
## CLI still available
- `npm run dev:update-influences` — curated [`scripts/art-influences-data.js`](../scripts/art-influences-data.js)
+5 -3
View File
@@ -102,6 +102,8 @@ All constants sit at the top of the two files and are safe to tune:
| `FULL_VIEW_WIDTH_SHARE`, `ZOOM_SPREAD_EXPONENT`, `MAX_FIT_BOOST` | `MovementTree.tsx` | crown spread vs. zoom |
| `MIN_LIMB_RISE_PX`, `MIN_JUNCTION_RISE_PX`, `MAX_THICKNESS_OF_LENGTH` | `MovementTree.tsx` | crown legibility at full zoom-out |
New lineage edges only need adding to
[`client/src/data/movement-lineage.ts`](../client/src/data/movement-lineage.ts) — the tree
picks up parents, grafts, thickness and spacing from there automatically.
New lineage edges: top-left **Movement links** (required proof URL) →
`movement_lineage_links` / `GET /api/movement-links`. Curators vote to delete;
admins hard-delete. Static fallback remains in
[`client/src/data/movement-lineage.ts`](../client/src/data/movement-lineage.ts).
The tree picks up parents, grafts, thickness and spacing from the active lineage automatically.
+3 -2
View File
@@ -2,7 +2,7 @@
## Prerequisites
- **Node.js** 20+
- **Node.js** 20+ (verified on 24.19; `npm test` relies on `node --test` expanding `**` itself, which older majors may not do)
- **PostgreSQL** reachable from the app host
- Network access to Wikipedia / Wikimedia (for biographies, seeding, and on-demand image fetch)
@@ -72,7 +72,7 @@ If migration fails with permission errors, grant schema rights to the app user f
`npm run dev:migrate` applies `db/migrate-auth.sql` (`users`, `curator_audit_log`, `session` tables). When the `users` table is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in `.env`, the first curator account is created automatically. To sync the password from `.env` later, run `npm run dev:reset-curator`.
After migrate, sign in from the site header (**Curator login**). Debug mode, Checkup, Translations, Influences, Tour editor, and all mutating debug APIs require an active curator session. Anonymous visitors browse the timeline, published Tours, and 3D halls without logging in.
After migrate, sign in from the site header (**Curator login**). Debug mode, Checkup, Translations, Influences, **Movement links** (top-left), Tour editor, and all mutating debug APIs require an active curator session (Movement links opens a login gate for guests). Anonymous visitors browse the timeline, published Tours, and 3D halls without logging in. `dev:migrate` also creates `movement_lineage_links` / `movement_lineage_delete_votes` and seeds default lineage when the table is empty.
See [API.md — Authentication](API.md#authentication) and [basics.md — Developer tools](basics.md#developer-tools-image-audit).
@@ -107,6 +107,7 @@ Image fetch can take hours if you run it for the entire catalog. The first line
| Command | Purpose |
|---------|---------|
| `npm test` | **Test suite** — 15 suites / 41 tests on Node’s built-in runner; no database or server needed |
| `npm run dev:web` | **Public dev stack** — Vite `:5173`, API `:3451` (Keenetic → devgallery…) |
| `npm run prod:docker:publish` | Build + push prod image to Gitea |
| `npm run devtoprod:images` | Copy `data/images/` → TrueNAS via SMB `Gallery` share |
+9
View File
@@ -6,6 +6,7 @@ Interactive virtual art gallery: zoomable historical timeline (classic left→ri
| Document | Purpose |
|----------|---------|
| [Documentation/Bible.md](Documentation/Bible.md) | Agent instructions (primary source for AI assistants) |
| [Documentation/basics.md](Documentation/basics.md) | Architecture, layout, user flow |
| [Documentation/ui-interaction-and-component-standards.md](Documentation/ui-interaction-and-component-standards.md) | UI interaction, navigation, and component standards |
| [Documentation/movement-tree.md](Documentation/movement-tree.md) | **Tree of Art** start page — tree layout rules |
@@ -85,6 +86,14 @@ Operator guide: [Documentation/environments.md](Documentation/environments.md).
npm run dev:web # Vite :5173, API :3451
```
**Tests** (Node’s built-in runner — no database or server needed):
```bash
npm test # 15 suites, 41 tests
```
Per [Documentation/Bible.md](Documentation/Bible.md), write the test before the code it covers.
**Local HMR:**
```bash
+102
View File
@@ -773,6 +773,81 @@ export const api = {
);
},
listMovementLinks: (params: { q?: string; limit?: number; offset?: number } = {}) => {
const qs = new URLSearchParams();
if (params.q) qs.set('q', params.q);
if (params.limit) qs.set('limit', String(params.limit));
if (params.offset) qs.set('offset', String(params.offset));
const q = qs.toString();
return fetchJson<{ items: MovementLineageLinkItem[]; total: number }>(
`${API}/movement-links${q ? `?${q}` : ''}`,
);
},
createMovementLink: (payload: {
sourceMovementId: number;
targetMovementId: number;
proofUrl: string;
proofLabel?: string;
linkYearApprox?: number | null;
notes?: string;
}) =>
fetch(`${API}/movement-links`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Create failed: ${res.status}`);
}
return res.json() as Promise<{ item: MovementLineageLinkItem }>;
}),
listMovementLinkDeleteVotes: (id: number) =>
fetchJson<{ linkId: number; votes: MovementLineageDeleteVote[] }>(
`${API}/movement-links/${id}/delete-votes`,
),
voteDeleteMovementLink: (id: number, reason: string) =>
fetch(`${API}/movement-links/${id}/delete-votes`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason }),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Vote failed: ${res.status}`);
}
return res.json() as Promise<{ vote: MovementLineageDeleteVote }>;
}),
withdrawDeleteMovementLinkVote: (id: number) =>
fetch(`${API}/movement-links/${id}/delete-votes/me`, {
...fetchCredentials,
method: 'DELETE',
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Withdraw failed: ${res.status}`);
}
return res.json() as Promise<{ ok: boolean; id: number; linkId: number }>;
}),
deleteMovementLink: (id: number) =>
fetch(`${API}/movement-links/${id}`, {
...fetchCredentials,
method: 'DELETE',
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Delete failed: ${res.status}`);
}
return res.json() as Promise<{ ok: boolean; id: number }>;
}),
getInfluenceGraph: (params: { artistId?: number; paintingId?: number }) => {
const qs = new URLSearchParams();
if (params.artistId) qs.set('artistId', String(params.artistId));
@@ -1032,6 +1107,33 @@ export interface InfluenceEdgeItem {
updatedAt: string | null;
}
export interface MovementLineageLinkItem {
id: number;
source_movement_id: number;
target_movement_id: number;
link_year_approx: number | null;
proof_url: string;
proof_label: string | null;
notes: string | null;
created_at: string;
updated_at: string;
source_name: string;
source_start_year: number;
target_name: string;
target_start_year: number;
delete_vote_count?: number;
viewer_has_delete_vote?: boolean;
}
export interface MovementLineageDeleteVote {
id: number;
link_id: number;
user_id: number;
reason: string;
created_at: string;
username?: string;
}
export interface InfluenceGraph {
focus: { artistId: number; paintingId: number | null; label: string };
nodes: Array<{ id: string; type: string; label: string; focus?: boolean; artistId?: number; paintingId?: number; movementId?: number }>;
+34 -5
View File
@@ -61,11 +61,13 @@ function WindowFrame({
return (
<group>
{/* Outer frame */}
<mesh position={[0, 0, -depth / 2]}>
<boxGeometry args={[width + frameW * 2, height + frameW * 2, depth]} />
<meshStandardMaterial color={trimColor} roughness={0.35} metalness={0.45} />
</mesh>
{/* Outer frame — picture/ribbon draw their own thin reveals */}
{style !== 'picture' && style !== 'ribbon' && (
<mesh position={[0, 0, -depth / 2]}>
<boxGeometry args={[width + frameW * 2, height + frameW * 2, depth]} />
<meshStandardMaterial color={trimColor} roughness={0.35} metalness={0.45} />
</mesh>
)}
{arch && style === 'gothic-lancet' && (
<>
@@ -180,6 +182,33 @@ function WindowFrame({
<meshStandardMaterial color={trimColor} roughness={0.3} metalness={0.55} />
</mesh>
)}
{/* Open picture window: thin dark reveal only — no mullion / iron-bar grid. */}
{style === 'picture' && (
<>
<mesh position={[0, 0, -depth / 2 + 0.015]}>
<boxGeometry args={[width + frameW * 1.2, height + frameW * 1.2, depth * 0.7]} />
<meshStandardMaterial color="#1a1a1a" roughness={0.42} metalness={0.55} />
</mesh>
{/* Single offset L-accent (collage frame), not a barred grid */}
<mesh position={[-width * 0.28, height * 0.22, -depth / 2 + 0.04]}>
<boxGeometry args={[frameW * 0.7, height * 0.42, depth * 0.5]} />
<meshStandardMaterial color="#c8302a" roughness={0.55} metalness={0.15} />
</mesh>
<mesh position={[-width * 0.08, height * 0.38, -depth / 2 + 0.04]}>
<boxGeometry args={[width * 0.36, frameW * 0.7, depth * 0.5]} />
<meshStandardMaterial color="#c8302a" roughness={0.55} metalness={0.15} />
</mesh>
</>
)}
{/* Open ribbon window: slim steel surround only — no bars. */}
{style === 'ribbon' && (
<mesh position={[0, 0, -depth / 2 + 0.015]}>
<boxGeometry args={[width + frameW * 1.1, height + frameW * 1.1, depth * 0.65]} />
<meshStandardMaterial color="#9aa0a8" roughness={0.35} metalness={0.72} />
</mesh>
)}
</group>
);
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import type { ArtMovement, Artist } from '../types';
import { portraitThumbUrl } from '../api/client';
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
import { getMovementLineage } from '../data/movement-lineage';
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
import { parseHexColor, rgbToHsl, hslToHex, vividMovementColor } from '../utils/movementColor';
import './MovementBands.css';
@@ -269,7 +269,7 @@ function buildLineageParentMap(
const visibleIds = new Set(visible.map((m) => m.id));
const childToParents = new Map<number, number[]>();
for (const [parentName, childName] of MOVEMENT_LINEAGE) {
for (const [parentName, childName] of getMovementLineage()) {
const parentId = nameToId.get(parentName);
const childId = nameToId.get(childName);
if (!parentId || !childId) continue;
+102 -189
View File
@@ -1,6 +1,39 @@
import { useMemo } from 'react';
import * as THREE from 'three';
import type { MovementInteriorStyle } from '../data/movement-interior-styles';
import type { MovementInteriorStyle, GalleryWindowSpec } from '../data/movement-interior-styles';
import type { RoomDims } from './hall-details/geometry';
import { bayPositions } from './hall-details/geometry';
import {
CinquecentoDetails,
QuattrocentoDetails,
RomanAtriumDetails,
} from './hall-details/classical';
import {
BaroqueStateDetails,
FlemishDetails,
ManneristDetails,
NeoclassicalDetails,
RococoDetails,
} from './hall-details/courtly';
import {
ArtNouveauDetails,
BourgeoisSalonDetails,
GothicRevivalDetails,
NorthLightSalonDetails,
ParisAtelierDetails,
SymbolistDetails,
} from './hall-details/nineteenth';
import {
ConstructivistDetails,
CubistAtelierDetails,
DadaDetails,
ExpressionistDetails,
FauvistDetails,
FuturistDetails,
LoftDetails,
SuprematistDetails,
SurrealistDetails,
WhiteCubeDetails,
} from './hall-details/modernism';
interface Props {
style: MovementInteriorStyle;
@@ -8,108 +41,15 @@ interface Props {
depth: number;
halfW: number;
halfD: number;
/** Runtime window placement, so upper-wall relief can step around openings. */
windows?: GalleryWindowSpec[];
}
function PalazzoDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
const pilasterPositions = useMemo(
() =>
[
[-halfW + 0.35, -halfD + 0.35],
[halfW - 0.35, -halfD + 0.35],
[-halfW + 0.35, halfD - 0.35],
[halfW - 0.35, halfD - 0.35],
] as [number, number][],
[halfW, halfD]
);
return (
<group>
{pilasterPositions.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 1.8, 0]}>
<boxGeometry args={[0.22, 3.6, 0.22]} />
<meshStandardMaterial color="#e8dcc8" roughness={0.85} metalness={0.05} />
</mesh>
<mesh position={[0, 3.65, 0]}>
<boxGeometry args={[0.28, 0.14, 0.28]} />
<meshStandardMaterial color={trim} roughness={0.35} metalness={0.55} />
</mesh>
</group>
))}
{/* Wainscoting rail */}
{[
[0, -halfD + 0.09, width, 0.12] as const,
[0, halfD - 0.09, width, 0.12] as const,
[-halfW + 0.09, 0, 0.12, depth] as const,
[halfW - 0.09, 0, 0.12, depth] as const,
].map(([x, z, w, d], i) => (
<mesh key={i} position={[x, 1.05, z]}>
<boxGeometry args={[w, 0.08, d]} />
<meshStandardMaterial color="#ddd0b8" roughness={0.78} metalness={0.08} />
</mesh>
))}
{/* Coffered ceiling */}
{Array.from({ length: Math.min(6, Math.floor(width / 2.5)) }, (_, col) =>
Array.from({ length: Math.min(5, Math.floor(depth / 2.8)) }, (_, row) => {
const cx = -halfW + 1.4 + col * 2.4;
const cz = -halfD + 1.5 + row * 2.6;
return (
<mesh key={`${col}-${row}`} position={[cx, 4.12, cz]} rotation={[Math.PI / 2, 0, 0]}>
<boxGeometry args={[2.0, 2.2, 0.06]} />
<meshStandardMaterial color="#f5efe4" roughness={0.88} />
</mesh>
);
})
)}
</group>
);
}
function BaroqueDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
return (
<group>
{/* Gilded cornice ring */}
{[
[0, -halfD + 0.06, width, 0.1] as const,
[0, halfD - 0.06, width, 0.1] as const,
[-halfW + 0.06, 0, 0.1, depth] as const,
[halfW - 0.06, 0, 0.1, depth] as const,
].map(([x, z, w, d], i) => (
<mesh key={i} position={[x, 3.95, z]}>
<boxGeometry args={[w, 0.18, d]} />
<meshStandardMaterial color={trim} roughness={0.25} metalness={0.75} emissive="#3a2808" emissiveIntensity={0.08} />
</mesh>
))}
{/* Wall panels */}
{[-halfW + 0.12, halfW - 0.12].map((x, i) => (
<mesh key={i} position={[x, 2.1, 0]} rotation={[0, Math.PI / 2, 0]}>
<boxGeometry args={[depth * 0.85, 2.8, 0.04]} />
<meshStandardMaterial color="#3a1820" roughness={0.88} metalness={0.06} />
</mesh>
))}
<pointLight position={[0, 3.6, 0]} intensity={0.9} distance={Math.max(width, depth)} color="#ffd080" />
<mesh position={[0, 3.5, 0]}>
<torusGeometry args={[0.55, 0.04, 8, 24]} />
<meshStandardMaterial color={trim} roughness={0.2} metalness={0.85} emissive="#5a4010" emissiveIntensity={0.15} />
</mesh>
</group>
);
}
function GothicDetails({ width, depth, halfW, halfD, trim }: Props & { trim: string }) {
function GothicDetails({ width, depth, halfW, halfD, trim }: Omit<Props, 'style' | 'windows'> & { trim: string }) {
// Compound piers along the side walls, spaced by bay, each rising into the
// vault springer. Shafts stay flush against the wall (0.16 m proud) so they
// never intrude on the ~0.7 m frame hang margin.
const bays = useMemo(() => {
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;
@@ -292,102 +232,75 @@ function ByzantineDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'>
);
}
function NeoclassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
// Shallow engaged corner pilasters only. Freestanding columns (~0.5m into the
// room) overlap the outermost frames — hang margin is only ~0.7m from each end.
const pilasters = useMemo(
() =>
[
[-halfW + 0.06, -halfD + 0.06],
[halfW - 0.06, -halfD + 0.06],
[-halfW + 0.06, halfD - 0.06],
[halfW - 0.06, halfD - 0.06],
] as [number, number][],
[halfW, halfD]
);
return (
<group>
{pilasters.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 1.85, 0]}>
<boxGeometry args={[0.12, 3.7, 0.12]} />
<meshStandardMaterial color="#f0ece8" roughness={0.72} metalness={0.12} />
</mesh>
<mesh position={[0, 3.78, 0]}>
<boxGeometry args={[0.18, 0.1, 0.18]} />
<meshStandardMaterial color={trim} roughness={0.4} metalness={0.35} />
</mesh>
</group>
))}
{/* Cornice above wall title (title sits ~y 3.75) */}
<mesh position={[0, 4.08, -halfD + 0.1]}>
<boxGeometry args={[Math.max(2.4, halfW * 2 - 0.5), 0.1, 0.16]} />
<meshStandardMaterial color={trim} roughness={0.45} metalness={0.3} />
</mesh>
</group>
);
}
function ClassicalDetails({ halfW, halfD, trim }: Pick<Props, 'halfW' | 'halfD'> & { trim: string }) {
// Engaged corner shafts only — anything proud of the corner pocket covers frames.
const columns = useMemo(
() =>
[
[-halfW + 0.06, -halfD + 0.06],
[halfW - 0.06, -halfD + 0.06],
[-halfW + 0.06, halfD - 0.06],
[halfW - 0.06, halfD - 0.06],
] as [number, number][],
[halfW, halfD]
);
return (
<group>
{columns.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 2, 0]}>
<cylinderGeometry args={[0.08, 0.09, 4, 10]} />
<meshStandardMaterial color="#e0d8c8" roughness={0.88} />
</mesh>
<mesh position={[0, 4.05, 0]}>
<boxGeometry args={[0.18, 0.1, 0.18]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.25} />
</mesh>
</group>
))}
</group>
);
}
function SalonDetails({ trim }: { trim: string }) {
return (
<mesh position={[0, 4.05, 0]} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.3, 1.2, 32]} />
<meshStandardMaterial color={trim} roughness={0.35} metalness={0.5} side={THREE.DoubleSide} />
</mesh>
);
}
export default function MovementHallDetails({ style, width, depth, halfW, halfD }: Props) {
/**
* Period architecture for a movement hall.
*
* Every movement has its own interior, built from the architecture of the same
* period as the movement itself. The Gothic nave and Byzantine basilica below
* are the reference implementations; everything else lives in
* `hall-details/`, sharing the geometry budget documented in
* `hall-details/primitives.tsx`.
*/
export default function MovementHallDetails({ style, width, depth, halfW, halfD, windows }: Props) {
const trim = style.tints.trim;
const room: RoomDims = useMemo(
() => ({ width, depth, halfW, halfD }),
[width, depth, halfW, halfD]
);
const period = { room, trim, windows };
switch (style.details) {
case 'palazzo':
return <PalazzoDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'baroque':
return <BaroqueDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'gothic':
return <GothicDetails style={style} width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
return <GothicDetails width={width} depth={depth} halfW={halfW} halfD={halfD} trim={trim} />;
case 'byzantine':
return <ByzantineDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'roman':
return <RomanAtriumDetails {...period} />;
case 'quattrocento':
return <QuattrocentoDetails {...period} />;
case 'cinquecento':
return <CinquecentoDetails {...period} />;
case 'flemish':
return <FlemishDetails {...period} />;
case 'mannerist':
return <ManneristDetails {...period} />;
case 'baroque':
return <BaroqueStateDetails {...period} />;
case 'rococo':
return <RococoDetails {...period} />;
case 'neoclassical':
return <NeoclassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'classical':
return <ClassicalDetails halfW={halfW} halfD={halfD} trim={trim} />;
case 'salon':
return <SalonDetails trim={trim} />;
default:
return null;
return <NeoclassicalDetails {...period} />;
case 'gothic-revival':
return <GothicRevivalDetails {...period} />;
case 'bourgeois':
return <BourgeoisSalonDetails {...period} />;
case 'north-light':
return <NorthLightSalonDetails {...period} />;
case 'paris-atelier':
return <ParisAtelierDetails {...period} />;
case 'symbolist':
return <SymbolistDetails {...period} />;
case 'art-nouveau':
return <ArtNouveauDetails {...period} />;
case 'fauvist':
return <FauvistDetails {...period} />;
case 'expressionist':
return <ExpressionistDetails {...period} />;
case 'cubist-atelier':
return <CubistAtelierDetails {...period} />;
case 'futurist':
return <FuturistDetails {...period} />;
case 'suprematist':
return <SuprematistDetails {...period} />;
case 'constructivist':
return <ConstructivistDetails {...period} />;
case 'dada':
return <DadaDetails {...period} />;
case 'surrealist':
return <SurrealistDetails {...period} />;
case 'loft':
return <LoftDetails {...period} />;
case 'white-cube':
return <WhiteCubeDetails {...period} />;
}
}
@@ -1,7 +1,7 @@
import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ArtMovement } from '../types';
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
import { getMovementLineage } from '../data/movement-lineage';
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
import { vividMovementColor } from '../utils/movementColor';
import './VerticalMovementBands.css';
@@ -59,7 +59,7 @@ function buildLineageParentMap(
): Map<number, number[]> {
const parents = new Map<number, number[]>();
const visibleIds = new Set(visible.map((m) => m.id));
for (const [parentName, childName] of MOVEMENT_LINEAGE) {
for (const [parentName, childName] of getMovementLineage()) {
const parentId = nameToId.get(parentName);
const childId = nameToId.get(childName);
if (parentId == null || childId == null) continue;
+26 -5
View File
@@ -15,7 +15,12 @@ import type {
import { galleryImageUrlCandidates, imageUrl, api } from '../api/client';
import { comparePaintingsChronological, paintingHasCuratorNotes, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
import { cloneSurfaceTexture, getSurfaceTexture, type SurfaceTextureKind } from '../utils/galleryProceduralTextures';
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
import {
resolveMovementInteriorStyle,
type MovementDetailKind,
type MovementInteriorStyle,
type GalleryWindowSpec,
} from '../data/movement-interior-styles';
import { useTexturedMaterial } from '../hooks/useTexturedMaterial';
import MovementHallDetails from './MovementHallDetails';
import GalleryWindows, { GalleryTrackLights } from './GalleryWindows';
@@ -84,6 +89,15 @@ interface TourGalleryProps extends BaseGalleryProps {
type Props = ArtistGalleryProps | MovementGalleryProps | TourGalleryProps;
/** Period interiors built from steel, concrete or a white cube read better under the city HDR. */
const CITY_ENVIRONMENT_DETAILS = new Set<MovementDetailKind>([
'futurist',
'suprematist',
'constructivist',
'loft',
'white-cube',
]);
const WALL_HEIGHT = 4.2;
const WALL_THICKNESS = 0.18;
const MOUNT_OFFSET = 0.16;
@@ -1739,7 +1753,14 @@ function ArtistHall({
</mesh>
{interiorStyle && (
<MovementHallDetails style={interiorStyle} width={width} depth={depth} halfW={halfW} halfD={halfD} />
<MovementHallDetails
style={interiorStyle}
width={width}
depth={depth}
halfW={halfW}
halfD={halfD}
windows={computedWindows}
/>
)}
<Text
@@ -2156,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);
@@ -2635,7 +2656,7 @@ export default function VirtualGallery(props: Props) {
>
<Suspense fallback={null}>
<Environment
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
preset={interiorStyle && CITY_ENVIRONMENT_DETAILS.has(interiorStyle.details) ? 'city' : 'warehouse'}
environmentIntensity={(interiorStyle ? 0.55 : 0.25) * lightScale}
/>
</Suspense>
@@ -0,0 +1,271 @@
import {
CofferedCeiling,
CorniceRing,
FlutedShaft,
HangingFixture,
RoundArch,
ShaftBase,
TuscanCapital,
WallBand,
} from './primitives';
import {
shade,
useBayZ,
useCorners,
useRepeat,
useWallClearance,
type PeriodProps,
} from './geometry';
/**
* Roman atrium — Ancient / Classical.
*
* Engaged Tuscan half-columns on a bay rhythm carry a compressed but complete
* entablature (architrave, plain frieze, dentil course, projecting cornice).
* Below the frame line the walls carry the Pompeian red socle of a Fourth Style
* room; a bronze polycandelon hangs over the centre in place of ceiling lights.
*/
export function RomanAtriumDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 4.0, 9);
const corners = useCorners(room.halfW, room.halfD, 0.1);
const stone = '#e6dcc6';
const shadowStone = shade(stone, -0.14);
const socle = '#7c3227';
const dentils = useRepeat(room.depth, 0.46, 30);
return (
<group>
{/* Pompeian socle with a black skirting course */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.62} height={1.16} proud={0.07} color={socle} roughness={0.8} metalness={0.04} />
<WallBand wall={wall} room={room} y={0.09} height={0.18} proud={0.09} color="#241a14" roughness={0.7} metalness={0.06} />
<WallBand wall={wall} room={room} y={1.24} height={0.08} proud={0.1} color={stone} roughness={0.66} metalness={0.06} />
</group>
))}
{/* Engaged half-columns on the bay rhythm */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.115), 0, z]}>
<ShaftBase size={0.15} color={shadowStone} />
<group position={[0, 0.26, 0]}>
<FlutedShaft height={3.34} radius={0.135} color={stone} flutes={10} facing="x" />
</group>
<TuscanCapital y={3.72} size={0.15} color={stone} />
</group>
))}
</group>
))}
{/* Entablature: architrave, frieze and dentils along the colonnaded walls */}
{(['left', 'right'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={3.9} height={0.1} proud={0.16} color={shadowStone} roughness={0.7} />
<WallBand wall={wall} room={room} y={3.99} height={0.11} proud={0.13} color={stone} roughness={0.74} />
{dentils.map((z, i) => (
<mesh
key={i}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.16), 4.07, z]}
>
<boxGeometry args={[0.16, 0.07, 0.16]} />
<meshStandardMaterial color={shade(stone, 0.06)} roughness={0.7} metalness={0.05} />
</mesh>
))}
</group>
))}
{/* Projecting cornice returning around the whole room */}
<CorniceRing room={room} y={4.13} height={0.09} proud={0.19} color={trim} roughness={0.62} metalness={0.12} />
{/* Corner antae — square pilasters closing the order at each corner */}
{corners.map(([x, z], i) => (
<mesh key={i} position={[x, 1.95, z]}>
<boxGeometry args={[0.2, 3.9, 0.2]} />
<meshStandardMaterial color={shade(stone, 0.04)} roughness={0.76} metalness={0.04} />
</mesh>
))}
<HangingFixture
position={[0, 4.05, 0]}
dropLength={0.72}
radius={0.46}
arms={8}
metalColor={trim}
light
lightColor="#ffb862"
lightIntensity={0.9}
lightDistance={9}
/>
</group>
);
}
/**
* Florentine palazzo — Early Renaissance.
*
* Brunelleschi's grammar: flat pietra serena pilasters set against lime plaster,
* a blind arcade of semicircular arches springing between them, glazed roundels
* in the spandrels, and a painted-and-beamed quattrocento ceiling.
*/
export function QuattrocentoDetails({ room, trim, windows }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 3.8, 9);
const clearLeft = useWallClearance(windows, 'left');
const clearRight = useWallClearance(windows, 'right');
const serena = '#8b8f8a';
const serenaLight = shade(serena, 0.16);
const step = bays.length > 1 ? bays[1] - bays[0] : room.depth / 2;
return (
<group>
{/* Pietra serena dado with a moulded cap */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.6} height={1.2} proud={0.05} color={shade(serena, 0.24)} roughness={0.72} metalness={0.05} />
<WallBand wall={wall} room={room} y={1.24} height={0.09} proud={0.08} color={serena} roughness={0.62} metalness={0.07} />
</group>
))}
{/* Flat pilasters carrying the arcade */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.05), 0, z]}>
<mesh position={[0, 1.78, 0]}>
<boxGeometry args={[0.08, 3.56, 0.34]} />
<meshStandardMaterial color={serena} roughness={0.66} metalness={0.07} />
</mesh>
<mesh position={[0, 0.11, 0]}>
<boxGeometry args={[0.11, 0.22, 0.44]} />
<meshStandardMaterial color={shade(serena, -0.08)} roughness={0.7} metalness={0.06} />
</mesh>
{/* Simplified Corinthian capital */}
<mesh position={[0, 3.68, 0]}>
<boxGeometry args={[0.1, 0.2, 0.44]} />
<meshStandardMaterial color={serenaLight} roughness={0.6} metalness={0.08} />
</mesh>
<mesh position={[0, 3.82, 0]}>
<boxGeometry args={[0.12, 0.09, 0.5]} />
<meshStandardMaterial color={serenaLight} roughness={0.58} metalness={0.08} />
</mesh>
</group>
))}
</group>
))}
{/* Blind arcade between the pilasters, plus a roundel in each spandrel */}
{bays.slice(0, -1).map((z, i) => {
const mid = z + step / 2;
return (
<group key={i}>
{([-1, 1] as const).map((side) => (
<group key={side} position={[side * (room.halfW - 0.05), 3.88, mid]} rotation={[0, Math.PI / 2, 0]}>
<RoundArch
span={step - 0.34}
rise={0.24}
thickness={0.12}
depth={0.09}
color={serena}
segments={9}
roughness={0.64}
metalness={0.07}
/>
{/* Glazed tondo, only where no window claims the bay */}
{(side < 0 ? clearLeft : clearRight)(mid, 0.22) && (
<mesh position={[0, -0.5, 0]}>
<torusGeometry args={[0.17, 0.035, 8, 20]} />
<meshStandardMaterial color={trim} roughness={0.42} metalness={0.35} />
</mesh>
)}
</group>
))}
</group>
);
})}
{/* Painted beam ceiling over the arcade */}
<CorniceRing room={room} y={4.11} height={0.1} proud={0.14} color={serena} roughness={0.64} metalness={0.07} />
<CofferedCeiling
room={room}
y={4.09}
cell={2.0}
panelColor="#e2d3ba"
ribColor="#8a5a30"
ribWidth={0.14}
ribDrop={0.14}
/>
</group>
);
}
/**
* Roman palazzo — High Renaissance.
*
* Bramante's cinquecento order: paired fluted Corinthian pilasters on a wide
* 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 = '#d8d0c0';
const grey = shade(marble, -0.14);
return (
<group>
{/* Panelled marble dado */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.58} height={1.16} proud={0.06} color={grey} roughness={0.5} metalness={0.08} />
<WallBand wall={wall} room={room} y={1.2} height={0.1} proud={0.09} color={marble} roughness={0.46} metalness={0.09} />
<WallBand wall={wall} room={room} y={0.07} height={0.14} proud={0.1} color={shade(grey, -0.18)} roughness={0.48} metalness={0.09} />
</group>
))}
{/* Paired fluted pilasters */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) =>
[-0.19, 0.19].map((dz) => (
<group key={`${side}-${dz}`} position={[side * (room.halfW - 0.13), 0, z + dz]}>
<ShaftBase size={0.12} color={grey} />
<group position={[0, 0.24, 0]}>
<FlutedShaft height={3.32} radius={0.11} color={marble} flutes={8} facing="x" />
</group>
<mesh position={[0, 3.66, 0]}>
<cylinderGeometry args={[0.15, 0.1, 0.24, 12]} />
<meshStandardMaterial color={marble} roughness={0.48} metalness={0.08} />
</mesh>
<mesh position={[0, 3.82, 0]}>
<boxGeometry args={[0.12, 0.1, 0.34]} />
<meshStandardMaterial color={shade(marble, 0.06)} roughness={0.46} metalness={0.09} />
</mesh>
</group>
))
)}
</group>
))}
{/* Full entablature on the pilastered walls, cornice all round */}
{(['left', 'right'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={3.92} height={0.1} proud={0.15} color={marble} roughness={0.48} metalness={0.08} />
<WallBand wall={wall} room={room} y={4.02} height={0.1} proud={0.12} color={grey} roughness={0.5} metalness={0.08} />
</group>
))}
<CorniceRing room={room} y={4.12} height={0.09} proud={0.18} color={trim} roughness={0.42} metalness={0.24} />
{/* Plain coffers — larger cells, no gold rosettes */}
<CofferedCeiling
room={room}
y={4.08}
cell={3.4}
panelColor="#e6e0d4"
ribColor="#cfc4b2"
ribWidth={0.22}
ribDrop={0.16}
/>
</group>
);
}
@@ -0,0 +1,451 @@
import {
CeilingBeams,
CeilingRose,
CofferedCeiling,
CorniceRing,
FlutedShaft,
HangingFixture,
IonicCapital,
ShaftBase,
WallBand,
} from './primitives';
import {
shade,
useBayZ,
useCorners,
useRepeat,
useWallClearance,
DOOR_CLEAR_HALF,
type PeriodProps,
} from './geometry';
/**
* Flemish panel hall — Northern Renaissance.
*
* Oak linenfold wainscot under a moulded cap rail, carved corbels carrying an
* exposed beam-and-joist ceiling, and the brass ring chandelier that hangs in
* the background of half the period's interiors.
*/
export function FlemishDetails({ room, trim }: PeriodProps) {
const beams = useBayZ(room.depth, room.halfD, 2.3, 10);
const oak = '#6f4a2c';
const oakLight = shade(oak, 0.2);
const folds = useRepeat(room.depth, 0.55, 34);
const backFolds = useRepeat(room.width, 0.55, 34).filter((x) => Math.abs(x) > DOOR_CLEAR_HALF);
return (
<group>
{/* Wainscot ground, cap rail and skirting — WallBand clears the exit door */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.66} height={1.24} proud={0.06} color={oak} roughness={0.66} metalness={0.08} />
<WallBand wall={wall} room={room} y={1.32} height={0.12} proud={0.1} color={oakLight} roughness={0.54} metalness={0.1} />
<WallBand wall={wall} room={room} y={0.08} height={0.16} proud={0.09} color={shade(oak, -0.3)} roughness={0.6} metalness={0.09} />
</group>
))}
{/* Linenfold ribs across the wainscot */}
{(['left', 'right'] as const).map((wall) =>
folds.map((z, i) => (
<mesh
key={`${wall}-${i}`}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.07), 0.68, z]}
>
<cylinderGeometry args={[0.035, 0.035, 1.18, 6]} />
<meshStandardMaterial color={oakLight} roughness={0.6} metalness={0.09} />
</mesh>
))
)}
{backFolds.map((x, i) => (
<mesh key={`b-${i}`} position={[x, 0.68, -room.halfD + 0.07]}>
<cylinderGeometry args={[0.035, 0.035, 1.18, 6]} />
<meshStandardMaterial color={oakLight} roughness={0.6} metalness={0.09} />
</mesh>
))}
{/* Carved corbels carrying the beams */}
{beams.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * (room.halfW - 0.11), 3.84, z]}>
<boxGeometry args={[0.2, 0.24, 0.2]} />
<meshStandardMaterial color={shade(oak, -0.12)} roughness={0.66} metalness={0.08} />
</mesh>
))}
</group>
))}
<CeilingBeams room={room} positions={beams} axis="x" width={0.18} height={0.26} y={3.98} color={shade(oak, -0.2)} roughness={0.72} metalness={0.06} />
<CorniceRing room={room} y={4.14} height={0.08} proud={0.12} color={oakLight} roughness={0.56} metalness={0.1} />
<HangingFixture
position={[0, 3.94, 0]}
dropLength={0.5}
radius={0.4}
arms={8}
metalColor={trim}
flameColor="#ffe0a0"
light
lightColor="#ffca80"
lightIntensity={0.85}
lightDistance={8}
/>
</group>
);
}
/**
* Mannerist gallery — Mannerism.
*
* 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.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 (
<group>
{/* Heavy blocked socle */}
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.5} height={1.0} proud={0.08} color={dark} roughness={0.82} metalness={0.05} />
))}
{/* Herm pilasters — wider at the shoulder than at the foot */}
{bays.map((z, i) => (
<group key={i}>
{([-1, 1] as const)
.filter((side) => (side < 0 ? clearLeft : clearRight)(z, 0.28))
.map((side) => (
<group key={side} position={[side * (room.halfW - 0.05), 0, z]}>
<ShaftBase size={0.11} color={dark} />
{/* Stacked tapering shaft (proud ≤ 0.10 m) */}
{hermSegs.map((seg) => (
<mesh key={seg.y} position={[0, seg.y, 0]}>
<boxGeometry args={[0.1, seg.h, seg.w]} />
<meshStandardMaterial color={stone} roughness={0.78} metalness={0.05} />
</mesh>
))}
{/* Shallow rustication bands — same depth as shaft, slightly wider */}
{bandYs.map((y) => (
<mesh key={y} position={[0, y, 0]}>
<boxGeometry args={[0.105, 0.16, 0.34]} />
<meshStandardMaterial color={band} roughness={0.86} metalness={0.04} />
</mesh>
))}
{/* Block capital / console — clean abacus, no peeling torus */}
<mesh position={[0, 3.78, 0]}>
<boxGeometry args={[0.11, 0.14, 0.36]} />
<meshStandardMaterial color={shade(stone, 0.06)} roughness={0.7} metalness={0.06} />
</mesh>
<mesh position={[0, 3.92, 0]}>
<boxGeometry args={[0.12, 0.1, 0.42]} />
<meshStandardMaterial color={trim} roughness={0.48} metalness={0.28} />
</mesh>
</group>
))}
</group>
))}
{/* Broken cornice — segments that stop short of each bay */}
{(['left', 'right'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={3.96} height={0.12} proud={0.14} span={0.62} color={stone} roughness={0.72} metalness={0.06} />
<WallBand wall={wall} room={room} y={4.1} height={0.08} proud={0.17} span={0.86} color={trim} roughness={0.52} metalness={0.26} />
</group>
))}
<CorniceRing room={room} y={4.1} height={0.08} proud={0.15} walls={['back', 'front']} color={trim} roughness={0.52} metalness={0.26} />
{/* Cartouches slung between the bays, clear of the window openings */}
{bays.slice(0, -1).map((z, i) => {
const mid = (z + bays[i + 1]) / 2;
return (
<group key={i}>
{([-1, 1] as const)
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, 0.28))
.map((side) => (
<group key={side} position={[side * (room.halfW - 0.06), 3.55, mid]}>
<mesh>
<boxGeometry args={[0.06, 0.28, 0.28]} />
<meshStandardMaterial color={shade(stone, 0.1)} roughness={0.7} metalness={0.06} />
</mesh>
<mesh position={[side * -0.02, 0, 0]}>
<boxGeometry args={[0.04, 0.18, 0.18]} />
<meshStandardMaterial color={trim} roughness={0.45} metalness={0.35} />
</mesh>
</group>
))}
</group>
);
})}
</group>
);
}
/**
* Baroque state gallery — Baroque.
*
* Twisted solomonic half-columns at the corners, gilt-framed velvet field
* 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);
const corners = useCorners(room.halfW, room.halfD, 0.16);
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 (
<group>
{/* 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 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 (
<group key={i}>
{([-1, 1] as const)
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, halfClear))
.map((side) => (
<group key={side} position={[side * (room.halfW - 0.05), 3.15, mid]}>
{/* Velvet field — faces the room */}
<mesh>
<boxGeometry args={[0.028, fieldH, fieldW]} />
<meshStandardMaterial color={velvet} roughness={0.92} metalness={0.03} />
</mesh>
{/* Gilt moulding rails around the field (not a covering slab) */}
<mesh position={[0, fieldH / 2 + rail / 2, 0]}>
<boxGeometry args={[0.036, rail, fieldW + rail * 2]} />
<meshStandardMaterial color={gilt} roughness={0.38} metalness={0.62} />
</mesh>
<mesh position={[0, -(fieldH / 2 + rail / 2), 0]}>
<boxGeometry args={[0.036, rail, fieldW + rail * 2]} />
<meshStandardMaterial color={gilt} roughness={0.38} metalness={0.62} />
</mesh>
<mesh position={[0, 0, fieldW / 2 + rail / 2]}>
<boxGeometry args={[0.036, fieldH + rail * 2, rail]} />
<meshStandardMaterial color={gilt} roughness={0.38} metalness={0.62} />
</mesh>
<mesh position={[0, 0, -(fieldW / 2 + rail / 2)]}>
<boxGeometry args={[0.036, fieldH + rail * 2, rail]} />
<meshStandardMaterial color={gilt} roughness={0.38} metalness={0.62} />
</mesh>
</group>
))}
</group>
);
})}
{/* Solomonic corner columns — twist read as stacked, rotated drums */}
{corners.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<ShaftBase size={0.13} color="#2a1a12" />
{Array.from({ length: 14 }, (_, d) => (
<mesh key={d} position={[0, 0.34 + d * 0.24, 0]} rotation={[0, d * 0.42, 0]}>
<boxGeometry args={[0.2, 0.24, 0.13]} />
<meshStandardMaterial color={gilt} roughness={0.32} metalness={0.68} emissive="#2e2006" emissiveIntensity={0.05} />
</mesh>
))}
<mesh position={[0, 3.86, 0]}>
<cylinderGeometry args={[0.2, 0.13, 0.28, 10]} />
<meshStandardMaterial color={gilt} roughness={0.28} metalness={0.75} />
</mesh>
</group>
))}
{/* Modillion brackets under a heavy gilt cornice */}
{(['left', 'right'] as const).map((wall) =>
modillions.map((z, i) => (
<mesh key={`${wall}-${i}`} position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.17), 3.92, z]}>
<boxGeometry args={[0.18, 0.16, 0.12]} />
<meshStandardMaterial color={shade(gilt, 0.1)} roughness={0.34} metalness={0.62} />
</mesh>
))
)}
<CorniceRing room={room} y={4.06} height={0.18} proud={0.19} color={gilt} roughness={0.24} metalness={0.78} emissive="#3a2808" emissiveIntensity={0.08} />
<CeilingRose y={4.1} radius={1.15} color={gilt} rings={4} />
<HangingFixture
position={[0, 4.05, 0]}
dropLength={0.5}
radius={0.6}
arms={10}
metalColor={gilt}
flameColor="#fff0c0"
glowColor="#ffbe58"
light
lightColor="#ffd08a"
lightIntensity={1.0}
lightDistance={11}
/>
</group>
);
}
/**
* Rococo salon — Rococo.
*
* Boiserie panelling with rounded corner arcs, asymmetric rocaille cartouches,
* a deep painted cove instead of a cornice, and a light gilt lustre. Everything
* is kept thin and pale: the period's relief is shallow by design.
*/
export function RococoDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 3.4, 9);
const gilt = trim;
const boiserie = '#efe6ee';
return (
<group>
{/* Boiserie dado with a moulded chair rail */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.55} height={1.04} proud={0.045} color={boiserie} roughness={0.6} metalness={0.08} />
<WallBand wall={wall} room={room} y={1.12} height={0.07} proud={0.08} color={gilt} roughness={0.34} metalness={0.52} />
<WallBand wall={wall} room={room} y={0.07} height={0.14} proud={0.08} color={boiserie} roughness={0.62} metalness={0.07} />
</group>
))}
{/* Panel styles with quarter-round corners, and a rocaille cartouche above */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.06), 0, z]}>
<mesh position={[0, 2.05, 0]}>
<boxGeometry args={[0.04, 4.0, 0.07]} />
<meshStandardMaterial color={gilt} roughness={0.36} metalness={0.5} />
</mesh>
<group position={[0, 3.5, 0]} rotation={[0, Math.PI / 2, 0]}>
<mesh>
<torusGeometry args={[0.2, 0.028, 6, 18, Math.PI * 1.35]} />
<meshStandardMaterial color={gilt} roughness={0.3} metalness={0.6} />
</mesh>
<mesh position={[0.1, -0.14, 0]} rotation={[0, 0, 0.7]}>
<torusGeometry args={[0.12, 0.022, 6, 14, Math.PI * 1.1]} />
<meshStandardMaterial color={gilt} roughness={0.3} metalness={0.6} />
</mesh>
</group>
</group>
))}
</group>
))}
{/* Painted cove: two stacked, slightly stepped bands reading as a curve */}
<CorniceRing room={room} y={3.86} height={0.16} proud={0.08} color={shade(boiserie, 0.04)} roughness={0.72} metalness={0.05} />
<CorniceRing room={room} y={4.0} height={0.16} proud={0.14} color={boiserie} roughness={0.7} metalness={0.05} />
<CorniceRing room={room} y={4.13} height={0.07} proud={0.17} color={gilt} roughness={0.3} metalness={0.6} />
<CeilingRose y={4.09} radius={0.9} color={gilt} rings={3} />
<HangingFixture
position={[0, 4.02, 0]}
dropLength={0.42}
radius={0.42}
arms={8}
metalColor={gilt}
flameColor="#fff4dc"
glowColor="#ffd58c"
light
lightColor="#ffe6c0"
lightIntensity={0.8}
lightDistance={10}
/>
</group>
);
}
/**
* Neoclassical museum — Neoclassicism.
*
* Schinkel's museum grammar: fluted Ionic pilasters on a wide bay, a Greek-key
* meander frieze, a dentilled cornice, and a plain coffered soffit around the
* lantern. Deliberately cool and flat next to the Baroque hall.
*/
export function NeoclassicalDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 4.8, 6);
const stone = '#f1eee8';
const shadowStone = shade(stone, -0.14);
const meander = useRepeat(room.depth, 0.72, 24);
return (
<group>
{/* Plinth course under the hang */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.36} height={0.72} proud={0.05} color={shadowStone} roughness={0.72} metalness={0.08} />
<WallBand wall={wall} room={room} y={0.75} height={0.08} proud={0.08} color={stone} roughness={0.62} metalness={0.1} />
</group>
))}
{/* Fluted Ionic pilasters */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.13), 0, z]}>
<ShaftBase size={0.13} color={shadowStone} />
<group position={[0, 0.26, 0]}>
<FlutedShaft height={3.3} radius={0.12} color={stone} flutes={10} facing="x" />
</group>
<IonicCapital y={3.68} size={0.15} color={stone} axis="z" />
</group>
))}
</group>
))}
{/* Meander frieze — alternating key blocks read as a running fret */}
{(['left', 'right'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={3.9} height={0.24} proud={0.1} color={shade(stone, 0.03)} roughness={0.66} metalness={0.09} />
{meander.map((z, i) => (
<group key={i} position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.14), 3.9, z]}>
<mesh position={[0, i % 2 === 0 ? 0.06 : -0.06, 0]}>
<boxGeometry args={[0.06, 0.05, 0.34]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.28} />
</mesh>
<mesh position={[0, 0, 0.15]}>
<boxGeometry args={[0.06, 0.16, 0.05]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.28} />
</mesh>
</group>
))}
</group>
))}
<CorniceRing room={room} y={4.07} height={0.1} proud={0.16} color={stone} roughness={0.6} metalness={0.1} />
<CorniceRing room={room} y={4.15} height={0.06} proud={0.19} color={trim} roughness={0.48} metalness={0.24} />
<CofferedCeiling
room={room}
y={4.06}
cell={2.9}
panelColor="#e6e2da"
ribColor="#cfc8bc"
ribWidth={0.18}
ribDrop={0.13}
/>
</group>
);
}
@@ -0,0 +1,115 @@
import { useMemo } from 'react';
import type { GalleryWindowSpec } from '../../data/movement-interior-styles';
import { bayPositions } from '../../utils/sideColumnKeepOuts';
/**
* Shared geometry budget for every period interior.
*
* Movement halls hang frames 0.23 m proud of the inner wall face
* (`MOUNT_OFFSET` + `WALL_STANDOFF`), centred at eye height with tops around
* 2.4 m, and each wall keeps a 0.7 m clear margin at both ends
* (`WALL_PADDING` in `movementHallLayout.ts`). Period architecture therefore
* has to stay inside these limits, exactly as the Gothic nave and Byzantine
* basilica do:
*
* - relief below the frame line: at most 0.11 m proud of the wall face
* - friezes and cornices: 2.6 m and above, at most 0.17 m proud
* - freestanding masses: only inside the 0.34 m corner pocket at a corner
* - doorways: nothing below 2.6 m within 1.45 m of a wall centre
* - the end wall carries the hall title from 3.4 m to 3.9 m — keep it clear
* - side-wall windows are placed at runtime between 2.4 m and 3.9 m, so
* anything wider than a mullion up there goes through `useWallClearance`
* - added lights: at most two per hall, so hall materials stay inside the
* WebGL light limits the shared track lighting already budgets for
*/
/** Half of WALL_THICKNESS — distance from a wall's centre plane to its inner face. */
export const WALL_INNER = 0.09;
/** Highest a detail can sit before it breaks the ceiling plane at 4.2 m. */
export const CEILING_Y = 4.13;
/** Sink bands this far into the wall so they never z-fight with the surface. */
export const EMBED = 0.02;
/**
* Half-width of the exit/entrance door clear zone on back/front walls.
* Mouldings below ~2.6 m must stop outside ±DOOR_CLEAR_HALF from wall centre.
*/
export const DOOR_CLEAR_HALF = 1.45;
/** Bands whose bottom edge is below this height are treated as crossing the door. */
export const DOOR_CLEAR_Y = 2.55;
export type WallId = 'back' | 'front' | 'left' | 'right';
export interface RoomDims {
width: number;
depth: number;
halfW: number;
halfD: number;
}
/** Every period module takes the same three inputs. */
export interface PeriodProps {
room: RoomDims;
/** The movement's trim colour, already blended with its accent. */
trim: string;
/** Windows the hall placed at runtime, so upper-wall relief can avoid them. */
windows?: GalleryWindowSpec[];
}
/** Lighten (amount > 0) or darken (amount < 0) a hex colour. */
export function shade(hex: string, amount: number): string {
const n = parseInt(hex.replace('#', ''), 16);
const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((c) =>
Math.max(0, Math.min(255, Math.round(amount >= 0 ? c + (255 - c) * amount : c * (1 + amount))))
);
return `#${((ch[0] << 16) | (ch[1] << 8) | ch[2]).toString(16).padStart(6, '0')}`;
}
/** Bay rhythm along the depth axis — the spacing that drives piers and beams. */
export function useBayZ(depth: number, halfD: number, spacing = 4.2, max = 9): number[] {
return useMemo(() => 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.
*/
export function useRepeat(span: number, step: number, max: number): number[] {
return useMemo(() => {
const count = Math.min(max, Math.max(3, Math.floor((span - 0.6) / step)));
const run = (count - 1) * step;
return Array.from({ length: count }, (_, i) => -run / 2 + i * step);
}, [span, step, max]);
}
/**
* Side-wall windows are placed at runtime in whatever gaps the hang leaves, so
* any panel, arch or roundel a period module paints across the upper wall has
* to step around them. Returns a predicate: true when the span is clear.
*/
export function useWallClearance(
windows: GalleryWindowSpec[] | undefined,
wall: 'left' | 'right' | 'back'
) {
return useMemo(() => {
const spans = (windows ?? [])
.filter((w) => w.wall === wall)
.map((w) => [w.x - w.width / 2 - 0.18, w.x + w.width / 2 + 0.18] as [number, number]);
return (pos: number, halfSpan = 0.2) =>
!spans.some(([a, b]) => pos + halfSpan > a && pos - halfSpan < b);
}, [windows, wall]);
}
/** The four corner columns of the room, each inside its hang-margin pocket. */
export function useCorners(halfW: number, halfD: number, inset = 0.08): [number, number][] {
return useMemo(
() =>
[
[-halfW + inset, -halfD + inset],
[halfW - inset, -halfD + inset],
[-halfW + inset, halfD - inset],
[halfW - inset, halfD - inset],
] as [number, number][],
[halfW, halfD, inset]
);
}
@@ -0,0 +1,719 @@
import { useMemo } from 'react';
import {
CeilingBeams,
CorniceRing,
HangingFixture,
SideRails,
WallBand,
} from './primitives';
import {
shade,
useBayZ,
useCorners,
useRepeat,
useWallClearance,
type PeriodProps,
} from './geometry';
/**
* Fauvist studio — Fauvism.
*
* The Collioure summer room: close-set whitewashed rafters, plank shutters
* folded back against the wall beside every window, and a plain painted rail.
* The architecture stays vernacular so the wall colour does the work.
*/
export function FauvistDetails({ room, trim, windows }: PeriodProps) {
const rafters = useBayZ(room.depth, room.halfD, 1.5, 14);
const timber = '#d8c8a8';
const shutters = useMemo(
() =>
(windows ?? [])
.filter((w) => w.wall === 'left' || w.wall === 'right')
.flatMap((w) =>
[-1, 1].map((dir) => ({
wall: w.wall as 'left' | 'right',
z: w.x + dir * (w.width / 2 + w.width * 0.28),
y: w.y,
h: w.height,
w: w.width * 0.5,
}))
),
[windows]
);
return (
<group>
<CeilingBeams
room={room}
positions={rafters}
axis="x"
width={0.1}
height={0.16}
y={4.02}
color={timber}
roughness={0.88}
metalness={0.02}
/>
<CeilingBeams
room={room}
positions={[0]}
axis="z"
width={0.2}
height={0.24}
y={3.86}
color={shade(timber, -0.24)}
roughness={0.88}
metalness={0.02}
/>
{/* Folded-back plank shutters flanking the windows */}
{shutters.map((s, i) => (
<group
key={i}
position={[(s.wall === 'left' ? -1 : 1) * (room.halfW - 0.13), s.y, s.z]}
>
<mesh>
<boxGeometry args={[0.06, s.h, s.w]} />
<meshStandardMaterial color={trim} roughness={0.8} metalness={0.04} />
</mesh>
{[-0.3, 0, 0.3].map((t) => (
<mesh key={t} position={[0.04, s.h * t, 0]}>
<boxGeometry args={[0.02, s.h * 0.22, s.w * 0.86]} />
<meshStandardMaterial color={shade(trim, -0.22)} roughness={0.84} metalness={0.03} />
</mesh>
))}
</group>
))}
<SideRails room={room} y={2.6} height={0.08} proud={0.11} color={timber} roughness={0.82} metalness={0.03} />
<WallBand wall="back" room={room} y={2.6} height={0.08} proud={0.11} color={timber} roughness={0.82} metalness={0.03} />
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.12} height={0.24} proud={0.07} color={trim} roughness={0.8} metalness={0.04} />
))}
</group>
);
}
/**
* Expressionist room — Expressionism.
*
* Die Brücke's carved-plank interior pushed towards the raked sets of the
* period's cinema: battens canted at alternating angles above the hang, a
* zig-zag frieze, and ceiling members that refuse to run square.
*/
export function ExpressionistDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 2.2, 11);
const plank = '#5a3f27';
const light = shade(plank, 0.3);
return (
<group>
{/* Canted battens, alternating lean */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<mesh
key={side}
position={[side * (room.halfW - 0.07), 3.28, z]}
rotation={[i % 2 === 0 ? 0.3 : -0.3, 0, 0]}
>
<boxGeometry args={[0.08, 1.5, 0.17]} />
<meshStandardMaterial color={i % 2 === 0 ? plank : light} roughness={0.84} metalness={0.04} />
</mesh>
))}
</group>
))}
{/* Zig-zag frieze under the ceiling */}
{(['left', 'right'] as const).map((wall) =>
bays.map((z, i) => (
<mesh
key={`${wall}-${i}`}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.08), 4.06, z]}
rotation={[i % 2 === 0 ? 0.62 : -0.62, 0, 0]}
>
<boxGeometry args={[0.1, 0.42, 0.1]} />
<meshStandardMaterial color={trim} roughness={0.7} metalness={0.1} />
</mesh>
))
)}
{/* Ceiling members deliberately off-square */}
{bays
.filter((_, i) => i % 3 === 0)
.map((z, i) => (
<mesh key={i} position={[0, 3.98, z]} rotation={[0, i % 2 === 0 ? 0.14 : -0.14, 0]}>
<boxGeometry args={[room.width * 0.97, 0.2, 0.15]} />
<meshStandardMaterial color={shade(plank, -0.24)} roughness={0.88} metalness={0.03} />
</mesh>
))}
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.13} height={0.26} proud={0.09} color={shade(plank, -0.3)} roughness={0.86} metalness={0.03} />
))}
</group>
);
}
/**
* Cubist studio — Cubism.
*
* The Bateau-Lavoir atelier: slim cast-iron columns in the corners, riveted tie
* rods across the room, and a cornice broken into faceted plaster planes — the
* building itself analysed into flat facets.
*/
export function CubistAtelierDetails({ room, trim }: PeriodProps) {
const corners = useCorners(room.halfW, room.halfD, 0.24);
const ties = useBayZ(room.depth, room.halfD, 3.6, 8);
const iron = '#4d4a45';
const facet = '#dedad2';
const facets = useRepeat(room.depth, 1.15, 22);
return (
<group>
{/* Cast-iron columns in the corner pockets */}
{corners.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 0.1, 0]}>
<boxGeometry args={[0.3, 0.2, 0.3]} />
<meshStandardMaterial color={shade(iron, -0.2)} roughness={0.6} metalness={0.45} />
</mesh>
<mesh position={[0, 2.1, 0]}>
<cylinderGeometry args={[0.09, 0.11, 3.8, 10]} />
<meshStandardMaterial color={iron} roughness={0.52} metalness={0.55} />
</mesh>
<mesh position={[0, 4.02, 0]}>
<boxGeometry args={[0.34, 0.18, 0.34]} />
<meshStandardMaterial color={iron} roughness={0.5} metalness={0.58} />
</mesh>
</group>
))}
{/* Riveted tie rods spanning the room, with a turnbuckle at mid-span */}
{ties.map((z, i) => (
<group key={i} position={[0, 3.8, z]}>
<mesh rotation={[0, 0, Math.PI / 2]}>
<cylinderGeometry args={[0.035, 0.035, room.width - 0.2, 8]} />
<meshStandardMaterial color={iron} roughness={0.46} metalness={0.62} />
</mesh>
<mesh rotation={[0, 0, Math.PI / 2]}>
<cylinderGeometry args={[0.06, 0.06, 0.3, 8]} />
<meshStandardMaterial color={shade(iron, -0.2)} roughness={0.4} metalness={0.7} />
</mesh>
</group>
))}
{/* Faceted cornice: flat planes tilted alternately */}
{(['left', 'right'] as const).map((wall) =>
facets.map((z, i) => (
<mesh
key={`${wall}-${i}`}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.1), 4.02, z]}
rotation={[0, 0, i % 2 === 0 ? 0.34 : -0.34]}
>
<boxGeometry args={[0.2, 0.22, 0.86]} />
<meshStandardMaterial
color={i % 3 === 0 ? shade(facet, -0.14) : facet}
roughness={0.82}
metalness={0.05}
/>
</mesh>
))
)}
<CorniceRing room={room} y={4.15} height={0.08} proud={0.13} color={trim} roughness={0.6} metalness={0.2} />
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.1} height={0.2} proud={0.07} color={shade(iron, 0.24)} roughness={0.7} metalness={0.16} />
))}
</group>
);
}
/**
* Futurist loft — Futurism.
*
* Sant'Elia's Città Nuova reduced to one room: riveted steel stanchions,
* diagonal wind bracing across the upper wall, and a gantry rail running the
* length of the hall. Structure celebrated, nothing concealed.
*/
export function FuturistDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 4.0, 8);
const steel = '#8f9298';
const dark = shade(steel, -0.42);
return (
<group>
{/* Riveted stanchions */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.06), 0, z]}>
<mesh position={[0, 2.05, 0]}>
<boxGeometry args={[0.09, 4.1, 0.36]} />
<meshStandardMaterial color={steel} roughness={0.42} metalness={0.72} />
</mesh>
{[0.6, 1.7, 2.8, 3.8].map((y) => (
<mesh key={y} position={[side * -0.03, y, 0]}>
<boxGeometry args={[0.06, 0.1, 0.44]} />
<meshStandardMaterial color={dark} roughness={0.38} metalness={0.8} />
</mesh>
))}
</group>
))}
</group>
))}
{/* Diagonal wind bracing between the stanchions, above the hang */}
{bays.slice(0, -1).map((z, i) => {
const mid = (z + bays[i + 1]) / 2;
const run = bays[i + 1] - z;
return (
<group key={i}>
{[-1, 1].map((side) =>
[-1, 1].map((dir) => (
<mesh
key={`${side}-${dir}`}
position={[side * (room.halfW - 0.1), 3.4, mid]}
rotation={[dir * Math.atan2(run - 0.4, 0.9), 0, 0]}
>
<boxGeometry args={[0.06, Math.hypot(run - 0.4, 0.9), 0.06]} />
<meshStandardMaterial color={steel} roughness={0.4} metalness={0.75} />
</mesh>
))
)}
</group>
);
})}
{/* Gantry rail and its brackets */}
<SideRails room={room} y={2.72} height={0.12} proud={0.15} color={dark} roughness={0.36} metalness={0.82} />
<CorniceRing room={room} y={4.13} height={0.1} proud={0.16} color={trim} roughness={0.34} metalness={0.7} />
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.11} height={0.22} proud={0.09} color={dark} roughness={0.4} metalness={0.7} />
))}
</group>
);
}
/**
* Suprematist white cube — Suprematism.
*
* The 0,10 room: a bare white shell, a shelf slung across the upper corner
* where Malevich hung the Black Square in the icon position, and thin black and
* red geometric bands drifting off the axis of the walls.
*/
export function SuprematistDetails({ room, trim }: PeriodProps) {
const bars = useMemo(() => {
const seeds = [-0.62, -0.24, 0.18, 0.55];
return seeds.map((t, i) => ({
z: t,
y: 3.0 + (i % 3) * 0.28,
len: 0.9 + (i % 2) * 0.7,
tilt: i % 2 === 0 ? 0.22 : -0.34,
color: i % 3 === 0 ? '#c8302a' : '#141414',
}));
}, []);
return (
<group>
{/* Icon-corner shelf, carried on two plain brackets */}
<group position={[room.halfW - 0.46, 3.3, -room.halfD + 0.46]} rotation={[0, Math.PI / 4, 0]}>
<mesh>
<boxGeometry args={[1.16, 0.05, 0.34]} />
<meshStandardMaterial color="#f2f2f2" roughness={0.72} metalness={0.04} />
</mesh>
{[-0.4, 0.4].map((t) => (
<mesh key={t} position={[t, -0.16, 0.02]} rotation={[0, 0, 0]}>
<boxGeometry args={[0.05, 0.28, 0.24]} />
<meshStandardMaterial color="#e2e2e2" roughness={0.74} metalness={0.04} />
</mesh>
))}
</group>
{/* Drifting bands above the hang */}
{(['left', 'right'] as const).map((wall) =>
bars.map((b, i) => (
<mesh
key={`${wall}-${i}`}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.07), b.y, b.z * room.halfD]}
rotation={[b.tilt, 0, 0]}
>
<boxGeometry args={[0.04, 0.14, b.len]} />
<meshStandardMaterial color={b.color} roughness={0.66} metalness={0.06} />
</mesh>
))
)}
{/* Shadow-gap reveal instead of a cornice */}
<CorniceRing room={room} y={4.04} height={0.05} proud={0.03} color="#d2d2d2" roughness={0.8} metalness={0.04} />
<CorniceRing room={room} y={4.16} height={0.06} proud={0.1} color={trim} roughness={0.7} metalness={0.08} />
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.07} height={0.14} proud={0.06} color="#dcdcdc" roughness={0.76} metalness={0.05} />
))}
</group>
);
}
/**
* Constructivist space — Constructivism.
*
* Rodchenko's lattice made architecture: red-painted diagonal trusses under the
* ceiling, angled Proun panels folded into the corners, and exposed steel ties.
* Every member is doing structural work and says so.
*/
export function ConstructivistDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 2.8, 10);
const corners = useCorners(room.halfW, room.halfD, 0.3);
const red = '#b8342a';
const steel = '#9a9a96';
return (
<group>
{/* Lattice truss under the ceiling — top chord, bottom chord, diagonals */}
<CeilingBeams room={room} positions={[-0.9, 0.9]} axis="z" width={0.1} height={0.12} y={4.06} color={steel} roughness={0.44} metalness={0.6} />
<CeilingBeams room={room} positions={[-0.9, 0.9]} axis="z" width={0.09} height={0.1} y={3.66} color={steel} roughness={0.44} metalness={0.6} />
{bays.map((z, i) =>
[-0.9, 0.9].map((x) => (
<mesh key={`${i}-${x}`} position={[x, 3.86, z]} rotation={[i % 2 === 0 ? 0.62 : -0.62, 0, 0]}>
<boxGeometry args={[0.07, 0.62, 0.07]} />
<meshStandardMaterial color={red} roughness={0.56} metalness={0.3} />
</mesh>
))
)}
{/* Proun panels folded into the corners */}
{corners.map(([x, z], i) => (
<group key={i} position={[x, 2.9, z]} rotation={[0, Math.PI / 4 + (i % 2) * 0.2, 0]}>
<mesh rotation={[0, 0, 0.18]}>
<boxGeometry args={[0.66, 1.5, 0.05]} />
<meshStandardMaterial color={i % 2 === 0 ? red : '#141414'} roughness={0.66} metalness={0.12} />
</mesh>
<mesh position={[0.1, -0.3, 0.05]} rotation={[0, 0, -0.5]}>
<boxGeometry args={[0.5, 0.14, 0.04]} />
<meshStandardMaterial color={steel} roughness={0.42} metalness={0.6} />
</mesh>
</group>
))}
{/* Steel tie rails at the hang line and the skirting */}
<SideRails room={room} y={2.64} height={0.07} proud={0.12} color={steel} roughness={0.4} metalness={0.68} />
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.1} height={0.2} proud={0.08} color={red} roughness={0.62} metalness={0.2} />
))}
<CorniceRing room={room} y={4.16} height={0.06} proud={0.11} color={trim} roughness={0.5} metalness={0.4} />
</group>
);
}
/**
* Dada cabaret — Dada.
*
* 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, 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, 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,
[]
);
return (
<group>
{/* 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 (
<mesh key={i} position={pos} rotation={rot}>
<boxGeometry
args={
p.wall === 'back'
? [p.w, p.h, 0.04]
: [0.04, p.h, p.w]
}
/>
<meshStandardMaterial color={p.color} roughness={0.72} metalness={0.04} />
</mesh>
);
})}
{/* Thin modern black rail — slight intentional skew */}
{([-1, 1] as const).map((side) => (
<mesh key={side} position={[side * (room.halfW - 0.05), 2.58, 0]} rotation={[side * 0.02, 0, 0]}>
<boxGeometry args={[0.05, 0.04, room.depth * 0.82]} />
<meshStandardMaterial color={ink} roughness={0.45} metalness={0.35} />
</mesh>
))}
{/* Clean modern skirting */}
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.08} height={0.16} proud={0.05} color={ink} roughness={0.55} metalness={0.2} />
))}
{/* Shadow-gap cornice instead of crumbling moulding */}
<CorniceRing room={room} y={4.05} height={0.04} proud={0.03} color="#e6e0d6" roughness={0.85} metalness={0.02} />
<CorniceRing room={room} y={4.14} height={0.06} proud={0.1} color={trim} roughness={0.5} metalness={0.25} />
{/* Geometric hanging mobile (box collage, not a pipe) */}
<group position={[room.halfW * 0.28, 3.95, room.halfD * 0.15]}>
<mesh position={[0, -0.15, 0]}>
<boxGeometry args={[0.02, 0.35, 0.02]} />
<meshStandardMaterial color={ink} roughness={0.4} metalness={0.5} />
</mesh>
<mesh position={[-0.18, -0.38, 0]} rotation={[0, 0.4, 0.15]}>
<boxGeometry args={[0.28, 0.08, 0.08]} />
<meshStandardMaterial color={scarlet} roughness={0.6} metalness={0.08} />
</mesh>
<mesh position={[0.16, -0.48, 0.05]} rotation={[0.2, -0.3, 0]}>
<boxGeometry args={[0.2, 0.2, 0.05]} />
<meshStandardMaterial color={mustard} roughness={0.65} metalness={0.06} />
</mesh>
<mesh position={[0.02, -0.62, -0.08]} rotation={[0, 0.6, 0.1]}>
<boxGeometry args={[0.14, 0.14, 0.04]} />
<meshStandardMaterial color={ink} roughness={0.55} metalness={0.2} />
</mesh>
<pointLight position={[0, -0.4, 0.2]} intensity={0.55} distance={7} color="#fff0d8" />
</group>
</group>
);
}
/**
* Surrealist interior — Surrealism.
*
* The 1938 Exposition Internationale: sacks slung under the ceiling, drapery
* 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);
return (
<group>
{/* Sacks hanging under the ceiling */}
{sacks.map((z, i) =>
[-1, 0, 1].map((c) => (
<mesh
key={`${i}-${c}`}
position={[c * room.width * 0.26, 3.86 - ((i + c) % 3) * 0.09, z]}
rotation={[0, ((i + c) % 4) * 0.4, 0.1 * ((i % 2) * 2 - 1)]}
>
<sphereGeometry args={[0.34, 10, 8]} />
<meshStandardMaterial color={i % 2 === 0 ? '#3a3128' : '#2c261f'} roughness={0.96} metalness={0.02} />
</mesh>
))
)}
{/* Drapery gathered along the top of the side walls */}
{(['left', 'right'] as const).map((wall) =>
sacks.map((z, i) => (
<mesh
key={`${wall}-${i}`}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.13), 3.7, z]}
rotation={[0, 0, 0]}
>
<cylinderGeometry args={[0.13, 0.09, 0.86, 8]} />
<meshStandardMaterial color="#38304a" roughness={0.92} metalness={0.03} />
</mesh>
))
)}
<CorniceRing room={room} y={4.14} height={0.09} proud={0.12} color={trim} roughness={0.62} metalness={0.2} />
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.11} height={0.22} proud={0.08} color="#2e2822" roughness={0.82} metalness={0.05} />
))}
{/* A brazier glow in the opposite corner */}
<group position={[room.halfW - 0.44, 0, room.halfD - 0.44]}>
<mesh position={[0, 0.34, 0]}>
<cylinderGeometry args={[0.22, 0.15, 0.28, 10]} />
<meshStandardMaterial color="#2a2622" roughness={0.7} metalness={0.35} />
</mesh>
<mesh position={[0, 0.5, 0]}>
<sphereGeometry args={[0.16, 10, 8]} />
<meshStandardMaterial color="#ff9a40" emissive="#ff6a12" emissiveIntensity={1.1} toneMapped={false} />
</mesh>
<pointLight position={[0, 0.7, 0]} intensity={0.8} distance={7} color="#ff8a3c" />
</group>
</group>
);
}
/**
* Painter loft — Abstract Expressionism.
*
* 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, 2.0, 11);
const timber = '#8a7358';
const timberLight = '#c4a882';
return (
<group>
{/* Square timber posts — studio structure, not iron bars */}
{corners.map(([x, z], i) => (
<group key={i} position={[x, 0, z]}>
<mesh position={[0, 0.1, 0]}>
<boxGeometry args={[0.34, 0.2, 0.34]} />
<meshStandardMaterial color={shade(timber, -0.15)} roughness={0.82} metalness={0.04} />
</mesh>
<mesh position={[0, 2.05, 0]}>
<boxGeometry args={[0.22, 3.7, 0.22]} />
<meshStandardMaterial color={timber} roughness={0.78} metalness={0.05} />
</mesh>
<mesh position={[0, 3.98, 0]}>
<boxGeometry args={[0.38, 0.16, 0.38]} />
<meshStandardMaterial color={shade(timber, 0.08)} roughness={0.76} metalness={0.05} />
</mesh>
</group>
))}
{/* Open timber joists — painter studio ceiling */}
<CeilingBeams
room={room}
positions={joists}
axis="x"
width={0.12}
height={0.18}
y={4.0}
color={timberLight}
roughness={0.78}
metalness={0.04}
/>
<CeilingBeams
room={room}
positions={[-room.width * 0.22, room.width * 0.22]}
axis="z"
width={0.16}
height={0.2}
y={3.88}
color={timber}
roughness={0.8}
metalness={0.04}
/>
{/* Soft wood skirting */}
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand
key={wall}
wall={wall}
room={room}
y={0.1}
height={0.2}
proud={0.06}
color={trim}
roughness={0.78}
metalness={0.05}
/>
))}
<CorniceRing room={room} y={4.12} height={0.06} proud={0.08} color={shade(timberLight, 0.12)} roughness={0.8} metalness={0.03} />
</group>
);
}
/**
* Pop Art gallery — Pop Art.
*
* The 1960s dealer's room: a flat white shell, recessed fluorescent troffers in
* a ceiling grid, an aluminium reveal at the floor, and a shadow gap where a
* cornice used to be. The architecture is engineered to disappear.
*/
export function WhiteCubeDetails({ room, trim }: PeriodProps) {
const troffers = useMemo(() => {
const cols = Math.max(2, Math.min(3, Math.round(room.width / 4.5)));
const rows = Math.max(2, Math.min(6, Math.round(room.depth / 3.4)));
const out: [number, number][] = [];
for (let c = 0; c < cols; c++) {
for (let r = 0; r < rows; r++) {
out.push([
-room.halfW + (room.width / (cols + 1)) * (c + 1),
-room.halfD + (room.depth / (rows + 1)) * (r + 1),
]);
}
}
return out;
}, [room.width, room.depth, room.halfW, room.halfD]);
return (
<group>
{/* Recessed fluorescent troffers */}
{troffers.map(([x, z], i) => (
<group key={i} position={[x, 4.08, z]}>
<mesh>
<boxGeometry args={[1.24, 0.06, 0.62]} />
<meshStandardMaterial color="#c8c8c8" roughness={0.4} metalness={0.5} />
</mesh>
<mesh position={[0, -0.04, 0]}>
<boxGeometry args={[1.1, 0.03, 0.5]} />
<meshStandardMaterial
color="#ffffff"
emissive="#f4f8ff"
emissiveIntensity={0.85}
toneMapped={false}
/>
</mesh>
</group>
))}
{/* Ceiling grid tees between the fittings */}
<CeilingBeams
room={room}
positions={troffers.map(([, z]) => z).filter((z, i, a) => a.indexOf(z) === i)}
axis="x"
width={0.05}
height={0.04}
y={4.1}
color="#d6d6d6"
roughness={0.5}
metalness={0.36}
/>
{/* Shadow gap and aluminium skirting reveal */}
<CorniceRing room={room} y={4.02} height={0.06} proud={0.02} color="#c0c0c0" roughness={0.66} metalness={0.16} />
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.06} height={0.12} proud={0.05} color={trim} roughness={0.42} metalness={0.5} />
<WallBand wall={wall} room={room} y={0.14} height={0.03} proud={0.02} color="#9a9a9a" roughness={0.6} metalness={0.3} />
</group>
))}
</group>
);
}
@@ -0,0 +1,507 @@
import {
CeilingBeams,
CeilingRose,
CorniceRing,
HangingFixture,
SideRails,
WallBand,
WallSconce,
} from './primitives';
import {
shade,
useBayZ,
useRepeat,
useWallClearance,
type PeriodProps,
} from './geometry';
/**
* Romantic gallery — Romanticism (Gothic Revival).
*
* Pugin's revival vocabulary rather than a real nave: dark walnut linenfold
* panelling, a cusped blind arcade of pointed arches, a quatrefoil frieze, and
* gasoliers. The pointed arch here is applied ornament, which is exactly what
* separates the revival from the Gothic hall it quotes.
*/
export function GothicRevivalDetails({ room, trim, windows }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 3.8, 8);
const clearLeft = useWallClearance(windows, 'left');
const clearRight = useWallClearance(windows, 'right');
const walnut = '#4c3323';
const walnutLight = shade(walnut, 0.22);
const quatrefoils = useRepeat(room.depth, 0.95, 24);
return (
<group>
{/* Panelled dado with a heavy moulded rail */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.62} height={1.2} proud={0.06} color={walnut} roughness={0.68} metalness={0.08} />
<WallBand wall={wall} room={room} y={1.28} height={0.13} proud={0.11} color={walnutLight} roughness={0.52} metalness={0.12} />
<WallBand wall={wall} room={room} y={0.09} height={0.18} proud={0.1} color={shade(walnut, -0.35)} roughness={0.62} metalness={0.08} />
</group>
))}
{/* Colonnettes between the bays, carrying an applied pointed arcade */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.06), 0, z]}>
<mesh position={[0, 2.0, 0]}>
<cylinderGeometry args={[0.055, 0.065, 3.9, 8]} />
<meshStandardMaterial color={walnutLight} roughness={0.6} metalness={0.1} />
</mesh>
<mesh position={[0, 3.98, 0]}>
<boxGeometry args={[0.13, 0.16, 0.22]} />
<meshStandardMaterial color={trim} roughness={0.5} metalness={0.2} />
</mesh>
</group>
))}
</group>
))}
{/* Cusped pointed arches over each bay, stepping around the windows */}
{bays.slice(0, -1).map((z, i) => {
const mid = (z + bays[i + 1]) / 2;
// Capped so the raking limbs stay under the ceiling in deep halls.
const half = Math.min(1.1, (bays[i + 1] - z) / 2 - 0.1);
return (
<group key={i}>
{([-1, 1] as const)
.filter((side) => (side < 0 ? clearLeft : clearRight)(mid, half))
.map((side) => (
<group key={side} position={[side * (room.halfW - 0.06), 0, mid]}>
{[-1, 1].map((dir) => (
<mesh
key={dir}
position={[0, 3.62, (dir * half) / 2]}
rotation={[dir * 0.42, 0, 0]}
>
<boxGeometry args={[0.09, half * 1.12, 0.09]} />
<meshStandardMaterial color={walnutLight} roughness={0.58} metalness={0.1} />
</mesh>
))}
<mesh position={[0, 3.9, 0]}>
<sphereGeometry args={[0.07, 8, 8]} />
<meshStandardMaterial color={trim} roughness={0.44} metalness={0.28} />
</mesh>
</group>
))}
</group>
);
})}
{/* Quatrefoil frieze under the cornice */}
{(['left', 'right'] as const).map((wall) =>
quatrefoils.map((z, i) => (
<group
key={`${wall}-${i}`}
position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.1), 4.02, z]}
rotation={[0, Math.PI / 2, 0]}
>
{[0, 1, 2, 3].map((q) => {
const a = (q / 4) * Math.PI * 2 + Math.PI / 4;
return (
<mesh key={q} position={[Math.cos(a) * 0.07, Math.sin(a) * 0.07, 0]}>
<sphereGeometry args={[0.055, 8, 6]} />
<meshStandardMaterial color={shade(walnut, 0.34)} roughness={0.6} metalness={0.1} />
</mesh>
);
})}
</group>
))
)}
<CorniceRing room={room} y={4.14} height={0.1} proud={0.15} color={walnutLight} roughness={0.5} metalness={0.14} />
{[-1, 1].map((side) => (
<WallSconce
key={side}
wall={side < 0 ? 'left' : 'right'}
room={room}
along={side * room.halfD * 0.3}
y={2.9}
armColor={trim}
glowColor="#ff9c46"
shadeColor="#ffd8a0"
/>
))}
<HangingFixture
position={[0, 4.0, 0]}
dropLength={0.55}
radius={0.36}
arms={6}
metalColor={trim}
flameColor="#ffdca0"
glowColor="#ff9a3c"
light
lightColor="#ffb268"
lightIntensity={0.8}
lightDistance={9}
/>
</group>
);
}
/**
* Realist picture gallery — Realism.
*
* The mid-century bourgeois interior the Realists exhibited against: a plaster
* dado and picture rail, run cornice with a moulded frieze, a ceiling rose, and
* gas brackets. Restrained, machine-made mouldings — no carving anywhere.
*/
export function BourgeoisSalonDetails({ room, trim }: PeriodProps) {
const plaster = '#efe8dc';
const shadow = shade(plaster, -0.12);
return (
<group>
{/* Dado, chair rail, skirting */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.5} height={0.96} proud={0.04} color={shadow} roughness={0.78} metalness={0.05} />
<WallBand wall={wall} room={room} y={1.03} height={0.09} proud={0.09} color={plaster} roughness={0.7} metalness={0.06} />
<WallBand wall={wall} room={room} y={0.09} height={0.18} proud={0.1} color={plaster} roughness={0.74} metalness={0.05} />
</group>
))}
{/* Picture rail just above the hang, as the period actually hung its walls */}
<SideRails room={room} y={2.52} height={0.07} proud={0.09} color={trim} roughness={0.55} metalness={0.2} />
{/* Run cornice: cavetto, bead and a moulded frieze */}
<CorniceRing room={room} y={3.9} height={0.1} proud={0.07} color={shadow} roughness={0.76} metalness={0.05} />
<CorniceRing room={room} y={4.0} height={0.12} proud={0.13} color={plaster} roughness={0.72} metalness={0.05} />
<CorniceRing room={room} y={4.13} height={0.07} proud={0.17} color={shade(plaster, 0.06)} roughness={0.7} metalness={0.06} />
<CeilingRose y={4.09} radius={0.85} color={shade(plaster, 0.04)} rings={3} />
{[-1, 1].map((side) => (
<WallSconce
key={side}
wall={side < 0 ? 'left' : 'right'}
room={room}
along={side * room.halfD * 0.34}
y={2.78}
armColor={trim}
/>
))}
<HangingFixture
position={[0, 4.04, 0]}
dropLength={0.62}
radius={0.3}
arms={4}
metalColor={trim}
flameColor="#fff0cc"
glowColor="#ffbe6a"
light
lightColor="#ffd8a8"
lightIntensity={0.75}
lightDistance={9}
/>
</group>
);
}
/**
* Impressionist salon — Impressionism.
*
* The 1874 Boulevard des Capucines idea of a hung room: an iron-and-glass
* lantern overhead, a coved plaster cornice, and a velvet-faced picture rail
* with visible hanging rods above the frames. Nothing carved, everything pale.
*/
export function NorthLightSalonDetails({ room, trim }: PeriodProps) {
const iron = '#6d6a63';
const plaster = '#f4f1ea';
const trusses = useBayZ(room.depth, room.halfD, 2.4, 11);
return (
<group>
{/* Skylight lantern: glazing bars across a shallow raised centre */}
<mesh position={[0, 4.11, 0]}>
<boxGeometry args={[Math.min(room.width - 2.2, 6.4), 0.06, Math.min(room.depth - 2.2, 14)]} />
<meshStandardMaterial
color="#ffffff"
emissive="#eaf2ff"
emissiveIntensity={0.55}
roughness={0.4}
metalness={0.05}
/>
</mesh>
<CeilingBeams
room={room}
positions={trusses}
axis="x"
width={0.07}
height={0.1}
y={4.05}
color={iron}
roughness={0.5}
metalness={0.55}
/>
{[-1, 1].map((side) => (
<mesh key={side} position={[(side * Math.min(room.width - 2.2, 6.4)) / 2, 4.03, 0]}>
<boxGeometry args={[0.1, 0.18, Math.min(room.depth - 2.2, 14)]} />
<meshStandardMaterial color={iron} roughness={0.48} metalness={0.6} />
</mesh>
))}
{/* Picture rail with hanging rods dropping to the frame line */}
<SideRails room={room} y={2.6} height={0.09} proud={0.1} color={trim} roughness={0.52} metalness={0.24} />
<WallBand wall="back" room={room} y={2.6} height={0.09} proud={0.1} color={trim} roughness={0.52} metalness={0.24} />
{/* Coved cornice */}
<CorniceRing room={room} y={3.94} height={0.14} proud={0.06} color={shade(plaster, -0.06)} roughness={0.8} metalness={0.04} />
<CorniceRing room={room} y={4.07} height={0.1} proud={0.12} color={plaster} roughness={0.78} metalness={0.04} />
{/* Dark skirting so the pale walls sit on something */}
{(['left', 'right', 'back'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={0.1} height={0.2} proud={0.09} color="#b6a88f" roughness={0.7} metalness={0.06} />
))}
</group>
);
}
/**
* Montmartre atelier — Post-Impressionism.
*
* A working painter's room rather than a gallery: a king-post roof truss under
* the slope, a cast-iron stove flue climbing the end wall, plain board skirting
* and a rough shelf rail carrying the studio's clutter line.
*/
export function ParisAtelierDetails({ room, trim }: PeriodProps) {
const trusses = useBayZ(room.depth, room.halfD, 3.1, 8);
const timber = '#8a6a48';
const iron = '#3a3632';
return (
<group>
{/* Roof trusses: tie beam, king post and raking struts */}
{trusses.map((z, i) => {
// Struts run from the head of the king post down to the tie beam, and
// have to stay inside the 0.3 m the ceiling leaves above the beam.
const run = room.halfW - 0.6;
const rise = 0.16;
const strut = Math.hypot(run, rise);
return (
<group key={i} position={[0, 0, z]}>
<mesh position={[0, 3.9, 0]}>
<boxGeometry args={[room.width - 0.1, 0.16, 0.16]} />
<meshStandardMaterial color={timber} roughness={0.86} metalness={0.03} />
</mesh>
<mesh position={[0, 4.02, 0]}>
<boxGeometry args={[0.13, 0.24, 0.13]} />
<meshStandardMaterial color={timber} roughness={0.86} metalness={0.03} />
</mesh>
{[-1, 1].map((side) => (
<mesh
key={side}
position={[(side * run) / 2, 4.06, 0]}
rotation={[0, 0, side * Math.atan2(-rise, run)]}
>
<boxGeometry args={[strut, 0.09, 0.11]} />
<meshStandardMaterial color={shade(timber, -0.16)} roughness={0.88} metalness={0.03} />
</mesh>
))}
</group>
);
})}
{/* Stove flue running up the end wall and out along the ceiling */}
<mesh position={[room.halfW - 0.28, 2.1, -room.halfD + 0.28]}>
<cylinderGeometry args={[0.09, 0.09, 4.0, 10]} />
<meshStandardMaterial color={iron} roughness={0.62} metalness={0.42} />
</mesh>
<mesh position={[room.halfW - 0.28, 4.02, -room.halfD + 0.28]}>
<cylinderGeometry args={[0.12, 0.12, 0.14, 10]} />
<meshStandardMaterial color={shade(iron, 0.14)} roughness={0.58} metalness={0.5} />
</mesh>
{/* Board skirting and a rough shelf rail above the hang */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.12} height={0.24} proud={0.06} color={shade(timber, -0.2)} roughness={0.88} metalness={0.03} />
<WallBand wall={wall} room={room} y={2.66} height={0.06} proud={0.13} color={timber} roughness={0.84} metalness={0.04} />
</group>
))}
{/* A single bare bulb on a flex, the way the ateliers were actually lit */}
<mesh position={[0, 3.7, 0]}>
<cylinderGeometry args={[0.008, 0.008, 0.5, 6]} />
<meshStandardMaterial color={iron} roughness={0.6} metalness={0.3} />
</mesh>
<mesh position={[0, 3.42, 0]}>
<sphereGeometry args={[0.09, 12, 12]} />
<meshStandardMaterial color="#fff2d0" emissive="#ffc061" emissiveIntensity={0.95} toneMapped={false} />
</mesh>
<pointLight position={[0, 3.4, 0]} intensity={0.9} distance={9} color={trim} />
</group>
);
}
/**
* Symbolist chamber — Symbolism.
*
* The aesthetic interior: a stencilled frieze of repeating motifs, a heavy
* portière rail, dark lacquered dado, and low amber sconces. Light is kept
* pooled and low, which is the whole point of the period's rooms.
*/
export function SymbolistDetails({ room, trim }: PeriodProps) {
const lacquer = '#12281f';
const stencil = shade(trim, 0.1);
const motifs = useRepeat(room.depth, 0.8, 26);
return (
<group>
{/* Lacquered dado under a gilt bead */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.54} height={1.04} proud={0.05} color={lacquer} roughness={0.42} metalness={0.16} />
<WallBand wall={wall} room={room} y={1.1} height={0.06} proud={0.09} color={trim} roughness={0.38} metalness={0.44} />
</group>
))}
{/* Stencilled frieze — a repeating lotus/flame motif under the cornice */}
{(['left', 'right'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={3.86} height={0.34} proud={0.04} color={shade(lacquer, 0.1)} roughness={0.6} metalness={0.12} />
{motifs.map((z, i) => (
<group key={i} position={[(wall === 'left' ? -1 : 1) * (room.halfW - 0.11), 3.86, z]} rotation={[0, Math.PI / 2, 0]}>
<mesh>
<coneGeometry args={[0.09, 0.24, 5]} />
<meshStandardMaterial color={stencil} roughness={0.44} metalness={0.36} />
</mesh>
<mesh position={[0, -0.16, 0]}>
<sphereGeometry args={[0.045, 8, 6]} />
<meshStandardMaterial color={stencil} roughness={0.44} metalness={0.36} />
</mesh>
</group>
))}
</group>
))}
<CorniceRing room={room} y={4.08} height={0.12} proud={0.13} color={shade(lacquer, 0.16)} roughness={0.5} metalness={0.18} />
<CorniceRing room={room} y={4.16} height={0.05} proud={0.16} color={trim} roughness={0.36} metalness={0.5} />
{/* Portière rail across the end wall */}
<WallBand wall="back" room={room} y={2.72} height={0.08} proud={0.12} color={trim} roughness={0.4} metalness={0.44} />
{[-0.36, 0.36].map((t) => (
<WallSconce
key={t}
wall={t < 0 ? 'left' : 'right'}
room={room}
along={t * room.halfD}
y={2.94}
armColor={trim}
glowColor="#ff8a2e"
shadeColor="#ffcf94"
/>
))}
<HangingFixture
position={[0, 4.04, 0]}
dropLength={0.66}
radius={0.26}
arms={5}
metalColor={trim}
flameColor="#ffd9a0"
glowColor="#ff8f2c"
light
lightColor="#ffa050"
lightIntensity={0.7}
lightDistance={8}
/>
</group>
);
}
/**
* Art Nouveau salon — Art Nouveau.
*
* Horta's structural line: whiplash brackets springing from the wall into the
* ceiling, tendrils running the cornice, and organic sconces. The curve is
* carried by the structure itself rather than applied as ornament.
*/
export function ArtNouveauDetails({ room, trim }: PeriodProps) {
const bays = useBayZ(room.depth, room.halfD, 3.0, 10);
const iron = shade(trim, -0.24);
return (
<group>
{/* Whiplash brackets: a stem up the wall curling into the ceiling */}
{bays.map((z, i) => (
<group key={i}>
{[-1, 1].map((side) => (
<group key={side} position={[side * (room.halfW - 0.07), 0, z]}>
<mesh position={[0, 2.05, 0]}>
<cylinderGeometry args={[0.045, 0.06, 4.0, 8]} />
<meshStandardMaterial color={iron} roughness={0.44} metalness={0.42} />
</mesh>
{[0, 1, 2, 3].map((s) => {
const t = (s + 1) / 4;
return (
<mesh
key={s}
position={[-side * (0.1 + t * 0.32), 4.02 - t * t * 0.22, 0]}
rotation={[0, 0, side * (0.35 + t * 0.7)]}
>
<cylinderGeometry args={[0.035, 0.045, 0.34, 6]} />
<meshStandardMaterial color={iron} roughness={0.44} metalness={0.42} />
</mesh>
);
})}
{/* Bud terminal */}
<mesh position={[-side * 0.52, 3.78, 0]}>
<sphereGeometry args={[0.07, 10, 8]} />
<meshStandardMaterial color={trim} roughness={0.36} metalness={0.5} />
</mesh>
</group>
))}
</group>
))}
{/* Tendril cornice — a wave riding the top of every wall */}
<CorniceRing room={room} y={4.05} height={0.11} proud={0.11} color={shade(trim, 0.18)} roughness={0.5} metalness={0.3} />
<CorniceRing room={room} y={4.15} height={0.06} proud={0.15} color={trim} roughness={0.42} metalness={0.42} />
{/* Sinuous dado line */}
{(['left', 'right', 'back'] as const).map((wall) => (
<group key={wall}>
<WallBand wall={wall} room={room} y={0.11} height={0.22} proud={0.08} color={shade(trim, -0.3)} roughness={0.52} metalness={0.28} />
<WallBand wall={wall} room={room} y={0.86} height={0.05} proud={0.07} color={trim} roughness={0.44} metalness={0.4} />
</group>
))}
{[-0.3, 0.3].map((t) => (
<WallSconce
key={t}
wall={t < 0 ? 'left' : 'right'}
room={room}
along={t * room.halfD}
y={2.88}
armColor={trim}
glowColor="#8fd66a"
shadeColor="#e8ffd8"
/>
))}
<HangingFixture
position={[0, 4.0, 0]}
dropLength={0.5}
radius={0.44}
arms={7}
metalColor={trim}
flameColor="#f2ffe0"
glowColor="#a8d86a"
light
lightColor="#dff2c0"
lightIntensity={0.75}
lightDistance={9}
/>
</group>
);
}
@@ -0,0 +1,547 @@
import { useMemo } from 'react';
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 {
color: string;
roughness?: number;
metalness?: number;
emissive?: string;
emissiveIntensity?: number;
}
/** A moulding running the length of one wall, flush against its inner face. */
export function WallBand({
wall,
room,
y,
height,
proud = 0.1,
span = 1,
color,
roughness = 0.7,
metalness = 0.06,
emissive,
emissiveIntensity,
}: SurfaceProps & {
wall: WallId;
room: RoomDims;
y: number;
height: number;
proud?: number;
span?: number;
}) {
const alongZ = wall === 'left' || wall === 'right';
const 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 (
<group>
<mesh position={[leftX, y, z]}>
<boxGeometry args={[sideLen, height, thick]} />
<meshStandardMaterial {...matProps} />
</mesh>
<mesh position={[rightX, y, z]}>
<boxGeometry args={[sideLen, height, thick]} />
<meshStandardMaterial {...matProps} />
</mesh>
</group>
);
}
const len = (alongZ ? room.depth : room.width) * span;
const args: [number, number, number] = alongZ ? [thick, height, len] : [len, height, thick];
const pos: [number, number, number] =
wall === 'back'
? [0, y, -room.halfD + off]
: wall === 'front'
? [0, y, room.halfD - off]
: wall === 'left'
? [-room.halfW + off, y, 0]
: [room.halfW - off, y, 0];
return (
<mesh position={pos}>
<boxGeometry args={args} />
<meshStandardMaterial {...matProps} />
</mesh>
);
}
/** A moulding ring running right around the room at one height. */
export function CorniceRing({
room,
y,
height,
proud = 0.12,
walls = ['back', 'front', 'left', 'right'],
...surface
}: SurfaceProps & {
room: RoomDims;
y: number;
height: number;
proud?: number;
walls?: WallId[];
}) {
return (
<group>
{walls.map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={y} height={height} proud={proud} {...surface} />
))}
</group>
);
}
/** String course / picture rail on the two side walls only. */
export function SideRails({
room,
y,
height,
proud = 0.1,
...surface
}: SurfaceProps & { room: RoomDims; y: number; height: number; proud?: number }) {
return (
<group>
{(['left', 'right'] as const).map((wall) => (
<WallBand key={wall} wall={wall} room={room} y={y} height={height} proud={proud} {...surface} />
))}
</group>
);
}
/**
* Vertical shaft with carved flutes, read as alternating light stone and
* shadowed grooves. Sits flush: the caller places it against a wall face.
*/
export function FlutedShaft({
height,
radius,
color,
flutes = 12,
taper = 0.9,
facing = 'x',
}: {
height: number;
radius: number;
color: string;
flutes?: number;
taper?: number;
/** Wall normal axis — grooves are only cut on the exposed half. */
facing?: 'x' | 'z';
}) {
const groove = shade(color, -0.28);
const arcs = useMemo(
() => Array.from({ length: flutes }, (_, i) => (i / flutes) * Math.PI * 2),
[flutes]
);
return (
<group>
<mesh position={[0, height / 2, 0]}>
<cylinderGeometry args={[radius * taper, radius, height, 16]} />
<meshStandardMaterial color={color} roughness={0.74} metalness={0.05} />
</mesh>
{arcs.map((a, i) => {
const dx = Math.cos(a) * radius * 0.94;
const dz = Math.sin(a) * radius * 0.94;
// Skip the grooves buried in the wall behind the shaft.
if (facing === 'x' ? dx > 0 : dz > 0) return null;
return (
<mesh key={i} position={[dx, height / 2, dz]}>
<cylinderGeometry args={[radius * 0.16, radius * 0.16, height * 0.98, 6]} />
<meshStandardMaterial color={groove} roughness={0.88} metalness={0.03} />
</mesh>
);
})}
</group>
);
}
export function TuscanCapital({ y, size, color }: { y: number; size: number; color: string }) {
return (
<group position={[0, y, 0]}>
<mesh>
<cylinderGeometry args={[size * 1.15, size * 0.85, size * 0.42, 14]} />
<meshStandardMaterial color={color} roughness={0.7} metalness={0.05} />
</mesh>
<mesh position={[0, size * 0.36, 0]}>
<boxGeometry args={[size * 2.5, size * 0.28, size * 2.5]} />
<meshStandardMaterial color={shade(color, 0.08)} roughness={0.68} metalness={0.05} />
</mesh>
</group>
);
}
/** Ionic capital — abacus over a pair of spiral volutes. */
export function IonicCapital({
y,
size,
color,
axis = 'z',
}: {
y: number;
size: number;
color: string;
/** Axis the volute pair spreads along. */
axis?: 'x' | 'z';
}) {
return (
<group position={[0, y, 0]}>
<mesh position={[0, size * 0.1, 0]}>
<boxGeometry args={[size * 2.4, size * 0.34, size * 1.5]} />
<meshStandardMaterial color={color} roughness={0.66} metalness={0.06} />
</mesh>
{[-1, 1].map((s) => (
<mesh
key={s}
position={axis === 'z' ? [0, -size * 0.12, s * size * 0.85] : [s * size * 0.85, -size * 0.12, 0]}
rotation={axis === 'z' ? [0, Math.PI / 2, 0] : [0, 0, 0]}
>
<torusGeometry args={[size * 0.38, size * 0.14, 8, 16]} />
<meshStandardMaterial color={shade(color, 0.06)} roughness={0.62} metalness={0.07} />
</mesh>
))}
</group>
);
}
/** Moulded plinth under a shaft. */
export function ShaftBase({ size, color }: { size: number; color: string }) {
return (
<group>
<mesh position={[0, 0.08, 0]}>
<boxGeometry args={[size * 2.4, 0.16, size * 2.4]} />
<meshStandardMaterial color={color} roughness={0.78} metalness={0.04} />
</mesh>
<mesh position={[0, 0.21, 0]}>
<cylinderGeometry args={[size * 1.0, size * 1.15, 0.1, 14]} />
<meshStandardMaterial color={shade(color, 0.06)} roughness={0.72} metalness={0.05} />
</mesh>
</group>
);
}
/** Semicircular arch drawn as a chord of straight voussoir blocks. */
export function RoundArch({
span,
rise,
thickness = 0.14,
depth = 0.16,
color,
segments = 11,
roughness = 0.72,
metalness = 0.05,
}: {
span: number;
rise: number;
thickness?: number;
depth?: number;
color: string;
segments?: number;
roughness?: number;
metalness?: number;
}) {
const blocks = useMemo(() => {
const out: { x: number; y: number; len: number; rot: number }[] = [];
for (let s = 0; s < segments; s++) {
const t0 = s / segments;
const t1 = (s + 1) / segments;
const at = (t: number) => {
const a = Math.PI * (1 - t);
return { x: Math.cos(a) * (span / 2), y: Math.sin(a) * rise };
};
const p0 = at(t0);
const p1 = at(t1);
out.push({
x: (p0.x + p1.x) / 2,
y: (p0.y + p1.y) / 2,
len: Math.hypot(p1.x - p0.x, p1.y - p0.y) * 1.08,
rot: Math.atan2(p1.y - p0.y, p1.x - p0.x),
});
}
return out;
}, [span, rise, segments]);
return (
<group>
{blocks.map((b, i) => (
<mesh key={i} position={[b.x, b.y, 0]} rotation={[0, 0, b.rot]}>
<boxGeometry args={[b.len, thickness, depth]} />
<meshStandardMaterial color={color} roughness={roughness} metalness={metalness} />
</mesh>
))}
</group>
);
}
/** Recessed coffer grid under the ceiling plane. */
export function CofferedCeiling({
room,
y = CEILING_Y,
cell = 2.3,
panelColor,
ribColor,
ribWidth = 0.16,
ribDrop = 0.12,
rosette,
}: {
room: RoomDims;
y?: number;
cell?: number;
panelColor: string;
ribColor: string;
ribWidth?: number;
ribDrop?: number;
rosette?: string;
}) {
const grid = useMemo(() => {
const cols = Math.max(2, Math.min(6, Math.round(room.width / cell)));
const rows = Math.max(2, Math.min(8, Math.round(room.depth / cell)));
const cw = room.width / cols;
const cd = room.depth / rows;
const cells: { x: number; z: number }[] = [];
for (let c = 0; c < cols; c++) {
for (let r = 0; r < rows; r++) {
cells.push({ x: -room.halfW + cw * (c + 0.5), z: -room.halfD + cd * (r + 0.5) });
}
}
return { cells, cw, cd, cols, rows };
}, [room.width, room.depth, room.halfW, room.halfD, cell]);
return (
<group>
{grid.cells.map((c, i) => (
<group key={i} position={[c.x, y, c.z]}>
<mesh position={[0, 0.01, 0]}>
<boxGeometry args={[grid.cw - ribWidth, 0.03, grid.cd - ribWidth]} />
<meshStandardMaterial color={panelColor} roughness={0.86} metalness={0.04} />
</mesh>
{rosette && (
<mesh position={[0, -ribDrop * 0.35, 0]} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[grid.cw * 0.06, grid.cw * 0.15, 12]} />
<meshStandardMaterial color={rosette} roughness={0.4} metalness={0.5} side={2} />
</mesh>
)}
</group>
))}
{/* Ribs between the coffers */}
{Array.from({ length: grid.cols + 1 }, (_, c) => (
<mesh key={`c${c}`} position={[-room.halfW + grid.cw * c, y - ribDrop / 2, 0]}>
<boxGeometry args={[ribWidth, ribDrop, room.depth]} />
<meshStandardMaterial color={ribColor} roughness={0.7} metalness={0.08} />
</mesh>
))}
{Array.from({ length: grid.rows + 1 }, (_, r) => (
<mesh key={`r${r}`} position={[0, y - ribDrop / 2, -room.halfD + grid.cd * r]}>
<boxGeometry args={[room.width, ribDrop, ribWidth]} />
<meshStandardMaterial color={ribColor} roughness={0.7} metalness={0.08} />
</mesh>
))}
</group>
);
}
/** Exposed structural beams spanning the room. */
export function CeilingBeams({
room,
positions,
axis = 'x',
width = 0.22,
height = 0.3,
y = CEILING_Y - 0.16,
color,
roughness = 0.82,
metalness = 0.05,
}: SurfaceProps & {
room: RoomDims;
positions: number[];
/** Axis the beams run along. */
axis?: 'x' | 'z';
width?: number;
height?: number;
y?: number;
}) {
return (
<group>
{positions.map((p, i) => (
<mesh key={i} position={axis === 'x' ? [0, y, p] : [p, y, 0]}>
<boxGeometry
args={axis === 'x' ? [room.width, height, width] : [width, height, room.depth]}
/>
<meshStandardMaterial color={color} roughness={roughness} metalness={metalness} />
</mesh>
))}
</group>
);
}
/** Plaster ceiling rose, the anchor a salon chandelier hangs from. */
export function CeilingRose({
y = CEILING_Y,
radius = 1.0,
color,
rings = 3,
}: {
y?: number;
radius?: number;
color: string;
rings?: number;
}) {
return (
<group position={[0, y, 0]} rotation={[Math.PI / 2, 0, 0]}>
{Array.from({ length: rings }, (_, i) => {
const t = (i + 1) / rings;
return (
<mesh key={i} position={[0, 0, i * 0.012]}>
<ringGeometry args={[radius * t * 0.55, radius * t, 32]} />
<meshStandardMaterial color={shade(color, i * 0.06)} roughness={0.7} metalness={0.12} side={2} />
</mesh>
);
})}
</group>
);
}
/**
* Hanging light fixture. `light` adds a real point light — keep at most two per
* hall so the shared track lighting stays inside the WebGL light budget.
*/
export function HangingFixture({
position,
dropLength = 0.6,
radius = 0.42,
arms = 6,
metalColor,
flameColor = '#ffcf80',
glowColor = '#ff9830',
light = false,
lightColor = '#ffb060',
lightIntensity = 0.8,
lightDistance = 7,
}: {
position: [number, number, number];
dropLength?: number;
radius?: number;
arms?: number;
metalColor: string;
flameColor?: string;
glowColor?: string;
light?: boolean;
lightColor?: string;
lightIntensity?: number;
lightDistance?: number;
}) {
const ringY = position[1] - dropLength;
return (
<group position={[position[0], 0, position[2]]}>
<mesh position={[0, position[1] - dropLength / 2, 0]}>
<cylinderGeometry args={[0.012, 0.012, dropLength, 6]} />
<meshStandardMaterial color={shade(metalColor, -0.4)} roughness={0.42} metalness={0.7} />
</mesh>
<mesh position={[0, ringY, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[radius, 0.028, 8, 22]} />
<meshStandardMaterial color={metalColor} roughness={0.26} metalness={0.82} />
</mesh>
{Array.from({ length: arms }, (_, i) => {
const a = (i / arms) * Math.PI * 2;
return (
<mesh key={i} position={[Math.cos(a) * radius, ringY - 0.09, Math.sin(a) * radius]}>
<sphereGeometry args={[0.05, 8, 8]} />
<meshStandardMaterial
color={flameColor}
emissive={glowColor}
emissiveIntensity={0.9}
toneMapped={false}
/>
</mesh>
);
})}
{light && (
<pointLight
position={[0, ringY - 0.09, 0]}
intensity={lightIntensity}
distance={lightDistance}
color={lightColor}
/>
)}
</group>
);
}
/**
* Wall light mounted above the frame line. Emissive only — sconces are for
* period character, not for lighting the hall.
*/
export function WallSconce({
wall,
room,
along,
y = 2.86,
armColor,
glowColor = '#ffb45a',
shadeColor = '#ffdca8',
}: {
wall: 'left' | 'right' | 'back';
room: RoomDims;
/** Offset along the wall from its centre. */
along: number;
y?: number;
armColor: string;
glowColor?: string;
shadeColor?: string;
}) {
const pos: [number, number, number] =
wall === 'back'
? [along, y, -room.halfD + WALL_INNER + 0.02]
: wall === 'left'
? [-room.halfW + WALL_INNER + 0.02, y, along]
: [room.halfW - WALL_INNER - 0.02, y, along];
const rot: [number, number, number] =
wall === 'back' ? [0, 0, 0] : wall === 'left' ? [0, Math.PI / 2, 0] : [0, -Math.PI / 2, 0];
return (
<group position={pos} rotation={rot}>
<mesh position={[0, 0, 0.03]}>
<boxGeometry args={[0.16, 0.22, 0.05]} />
<meshStandardMaterial color={armColor} roughness={0.34} metalness={0.68} />
</mesh>
<mesh position={[0, 0.06, 0.09]} rotation={[Math.PI / 2.6, 0, 0]}>
<cylinderGeometry args={[0.012, 0.012, 0.16, 6]} />
<meshStandardMaterial color={armColor} roughness={0.34} metalness={0.68} />
</mesh>
<mesh position={[0, 0.15, 0.13]}>
<sphereGeometry args={[0.075, 10, 10]} />
<meshStandardMaterial
color={shadeColor}
emissive={glowColor}
emissiveIntensity={0.75}
toneMapped={false}
/>
</mesh>
</group>
);
}
+179 -75
View File
@@ -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';
@@ -54,9 +58,42 @@ export interface MovementInteriorStyle {
trackLights: number;
/** Scales the shared hall/scene lights. <1 for deliberately dim period interiors. */
lightScale: number;
details: 'palazzo' | 'baroque' | 'gothic' | 'byzantine' | 'neoclassical' | 'salon' | 'modern' | 'classical' | 'museum' | 'industrial' | 'atelier';
/**
* Which period architecture the hall is built from. Every movement has its
* own — the interiors are not shared between movements — and each kind maps
* to one component under `components/hall-details/`.
*/
details: MovementDetailKind;
}
export type MovementDetailKind =
| 'roman'
| 'byzantine'
| 'gothic'
| 'quattrocento'
| 'cinquecento'
| 'flemish'
| 'mannerist'
| 'baroque'
| 'rococo'
| 'neoclassical'
| 'gothic-revival'
| 'bourgeois'
| 'north-light'
| 'paris-atelier'
| 'symbolist'
| 'art-nouveau'
| 'fauvist'
| 'expressionist'
| 'cubist-atelier'
| 'futurist'
| 'suprematist'
| 'constructivist'
| 'dada'
| 'surrealist'
| 'loft'
| 'white-cube';
function windows(...specs: GalleryWindowSpec[]): GalleryWindowSpec[] {
return specs;
}
@@ -99,9 +136,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Roman atrium',
'Marble-clad villa · mosaic floor · clerestory daylight',
{ wall: 'limestone', ceiling: 'plaster-warm', floor: 'mosaic-roman' },
{ wall: '#e8e0d0', ceiling: '#f5f0e8', floor: '#c8b898', trim: '#a89878' },
{ wall: '#8e8980', ceiling: '#9c9994', floor: '#a08868', trim: '#a89878' },
{
details: 'classical',
details: 'roman',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#5a4830',
ambient: 0.62,
warmLight: '#fff8e8',
@@ -175,9 +215,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Florentine palazzo',
'Lime-washed walls · terracotta accents · arched windows',
{ wall: 'painted-lime', wallSide: 'stucco-cream', ceiling: 'fresco-worn', floor: 'terracotta-tiles' },
{ wall: '#f4ebe0', ceiling: '#faf6ee', floor: '#c89070', trim: '#c9a227' },
{ wall: '#89847e', ceiling: '#96948f', floor: '#a07050', trim: '#c9a227' },
{
details: 'palazzo',
details: 'quattrocento',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#6b4423',
windows: windows(
{ wall: 'back', x: -2.2, y: 2.6, width: 1.3, height: 1.7, style: 'roman-arch', lightColor: '#fff4e0', lightIntensity: 3.0 },
@@ -189,17 +232,20 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
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: '#f0ece4', wallSide: '#efe4d4', ceiling: '#faf6ee', 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: 'palazzo',
details: 'cinquecento',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#6b4423',
windows: windows(
{ wall: 'back', x: 0, y: 2.8, width: 2.8, height: 1.8, style: 'baroque-pair', lightColor: '#fff8f0', lightIntensity: 3.8 },
{ 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 }
),
}
),
@@ -208,9 +254,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Flemish panel hall',
'Oak wainscoting · leaded glass · herringbone floor',
{ wall: 'oak-panel', wallSide: 'plaster-warm', ceiling: 'dark-wood-panel', floor: 'parquet-herringbone' },
{ wall: '#8a6848', wallSide: '#f0ebe3', ceiling: '#3a2818', floor: '#8a6848', trim: '#5c4030' },
{ wall: '#8a6848', wallSide: '#8c8985', ceiling: '#3a2818', floor: '#8a6848', trim: '#5c4030' },
{
details: 'salon',
details: 'flemish',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#f0e8d8',
warmLight: '#ffe8c8',
windows: windows(
@@ -226,9 +275,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Mannerist gallery',
'Dramatic stucco · elongated windows · veined marble',
{ wall: 'stucco-terracotta', ceiling: 'gilded-stucco', floor: 'marble-veined-emerald' },
{ wall: '#d8b898', ceiling: '#d8c070', floor: '#dce8e0', trim: '#b8860b' },
{ wall: '#98816b', ceiling: '#a79456', floor: '#7a8a82', trim: '#b8860b' },
{
details: 'baroque',
details: 'mannerist',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#4a2818',
windows: windows(
{ wall: 'back', x: -1.8, y: 2.7, width: 1.0, height: 2.2, style: 'roman-arch', lightColor: '#fff0d8', lightIntensity: 2.8 },
@@ -242,7 +294,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'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',
@@ -264,9 +316,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Rococo salon',
'Pastel painted walls · gilt trim · chevron parquet',
{ wall: 'painted-oil-satin', ceiling: 'silk-pale', floor: 'parquet-chevrons' },
{ wall: '#e8dce8', ceiling: '#faf6f0', floor: '#c8a882', trim: '#d4af37' },
{ wall: '#918a91', ceiling: '#9b9995', floor: '#9a7858', trim: '#d4af37' },
{
details: 'salon',
details: 'rococo',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#6a4858',
warmLight: '#fff0f0',
windows: windows(
@@ -279,19 +334,22 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
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: '#f2f0ec', ceiling: '#fafafa', 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(
@@ -301,7 +359,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
{ wall: 'walnut-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#5a4030', ceiling: '#2a2420', floor: '#6a5040', trim: '#8a7050' },
{
details: 'salon',
details: 'gothic-revival',
titleColor: '#e8dcc8',
ambient: 0.5,
warmLight: '#ffe0c0',
@@ -317,9 +375,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Realist picture gallery',
'Warm painted walls · bourgeois salon · oak parquet',
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
{ wall: '#e8e2d8', ceiling: '#f5f0e8', floor: '#a08060', trim: '#8a7050' },
{ wall: '#88847e', ceiling: '#97948f', floor: '#a08060', trim: '#8a7050' },
{
details: 'salon',
details: 'bourgeois',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
windows: windows(
{ wall: 'back', x: 0, y: 2.5, width: 2.4, height: 1.6, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 3.2 },
{ wall: 'left', x: 0, y: 2.7, width: 1.6, height: 1.3, style: 'sash', lightColor: '#f0f4ff', lightIntensity: 2.4 }
@@ -331,9 +392,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Impressionist salon',
'North-light skylight · pale painted walls · herringbone floor',
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
{ wall: '#f0ebe3', ceiling: '#fafafa', floor: '#c8a882', trim: '#c9a96e' },
{ wall: '#8f8c87', ceiling: '#9e9e9e', floor: '#9a7858', trim: '#c9a96e' },
{
details: 'salon',
details: 'north-light',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
ambient: 0.68,
windows: windows(
{ wall: 'ceiling', x: 0, y: 0, width: 5.0, height: 3.0, style: 'skylight', lightColor: '#ffffff', lightIntensity: 6.0 },
@@ -348,11 +412,14 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Montmartre atelier',
'Painted studio walls · large north window · worn floorboards',
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
{ wall: '#e8e0d0', ceiling: '#f0ebe0', floor: '#9a7858', trim: '#8a6848' },
{ wall: '#847f76', ceiling: '#918f88', floor: '#9a7858', trim: '#8a6848' },
{
details: 'atelier',
details: 'paris-atelier',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
windows: windows(
{ wall: 'left', x: 0, y: 2.4, width: 2.8, height: 2.0, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 4.5 },
{ wall: '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 }
),
}
@@ -364,7 +431,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#1a4030', ceiling: '#1a1814', floor: '#4a3828', trim: '#8a7050' },
{
details: 'salon',
details: 'symbolist',
titleColor: '#d8e8c8',
ambient: 0.55,
warmLight: '#ffd898',
@@ -380,9 +447,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Art Nouveau salon',
'Painted plaster · stained glass · terrazzo floor',
{ wall: 'painted-oil-satin', ceiling: 'silk-gold', floor: 'terrazzo' },
{ wall: '#f0ece4', ceiling: '#d8c890', floor: '#d8d0c4', trim: '#6a9868' },
{ wall: '#8f8c87', ceiling: '#a0946b', floor: '#9a9084', trim: '#6a9868' },
{
details: 'salon',
details: 'art-nouveau',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#3a5840',
windows: windows(
{ wall: 'back', x: 0, y: 2.5, width: 2.2, height: 2.0, style: 'art-nouveau', lightColor: '#e8ffe8', lightIntensity: 3.2 },
@@ -396,13 +466,16 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Fauvist studio',
'Bold painted walls · flooded with color and light',
{ wall: 'painted-oil-matte', wallSide: 'painted-oil-matte', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
{ wall: '#e89060', wallSide: '#e8c860', ceiling: '#f8f0e0', floor: '#a07048', trim: '#c04020' },
{ wall: '#e89060', wallSide: '#e8c860', ceiling: '#9e998e', floor: '#a07048', trim: '#c04020' },
{
details: 'atelier',
details: 'fauvist',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#402010',
ambient: 0.7,
windows: windows(
{ 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 }
),
}
@@ -414,26 +487,29 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
{ wall: 'dark-wood-panel', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#3a2818', ceiling: '#2a2018', floor: '#5a4030', trim: '#8a6040' },
{
details: 'atelier',
details: 'expressionist',
titleColor: '#f0d8b0',
ambient: 0.52,
windows: windows(
{ 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: '#e8e4dc', ceiling: '#f5f5f5', floor: '#9a8870', trim: '#888888' },
{ wall: '#8c8a85', ceiling: '#999999', floor: '#9a8870', trim: '#888888' },
{
details: 'atelier',
details: 'cubist-atelier',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
ambient: 0.65,
windows: windows(
{ wall: 'left', x: 0, y: 2.5, width: 3.5, height: 2.4, style: 'factory', lightColor: '#f0f8ff', lightIntensity: 5.5 },
{ 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 }
),
}
@@ -441,17 +517,21 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
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: '#a8a8a8', ceiling: '#989898', floor: '#888880', trim: '#606060' },
{ wall: '#858585', ceiling: '#7a7a7a', floor: '#888880', trim: '#606060' },
{
details: 'industrial',
details: 'futurist',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#303030',
ambient: 0.62,
fog: '#181818',
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'],
@@ -462,9 +542,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Suprematist white cube',
'Matte white paint · geometric light · polished floor',
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#e0e0e0', trim: '#cccccc' },
{ wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#8e8e8e', trim: '#cccccc' },
{
details: 'modern',
details: 'suprematist',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#222222',
ambient: 0.72,
fog: '#1a1a1a',
@@ -482,9 +565,12 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Constructivist space',
'Glass block · concrete · angular daylight',
{ wall: 'concrete-block', ceiling: 'concrete-raw', floor: 'concrete-polished' },
{ wall: '#b0b0b0', ceiling: '#a0a0a0', floor: '#c0c0c0', trim: '#808080' },
{ wall: '#858585', ceiling: '#7a7a7a', floor: '#888888', trim: '#808080' },
{
details: 'industrial',
details: 'constructivist',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#303030',
ambient: 0.65,
windows: windows(
@@ -497,18 +583,26 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
),
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: '#e8dcc8', wallSide: '#8a5040', ceiling: '#f0ebe0', 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: 'salon',
details: 'dada',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
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(
@@ -518,7 +612,7 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#1a2848', ceiling: '#2a2428', floor: '#5a4838', trim: '#8a7050' },
{
details: 'salon',
details: 'surrealist',
titleColor: '#e8dcc8',
ambient: 0.58,
warmLight: '#ffd898',
@@ -532,18 +626,25 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
),
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: '#f5f5f5', 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: 'industrial',
titleColor: '#303030',
ambient: 0.65,
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: '#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(
@@ -551,16 +652,19 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
'Pop Art gallery',
'White painted walls · fluorescent daylight · polished concrete',
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#d8d8d8', trim: '#cccccc' },
{ wall: '#a8a8a8', ceiling: '#b3b3b3', floor: '#909090', trim: '#cccccc' },
{
details: 'modern',
details: 'white-cube',
// Lit like the basilica: shared lights held back so the walls read as
// surfaces rather than blowing out. Window shafts are not scaled.
lightScale: 0.3,
titleColor: '#222222',
ambient: 0.75,
warmLight: '#ffffff',
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,
+37 -10
View File
@@ -1,10 +1,12 @@
/**
* Documented art-historical movement lineage (predecessor → successor).
* Sources: Met Museum essays, TheArtStory, Sotheby's movement guides.
*
* Art-historical movement lineage (predecessor → successor).
* Defaults match museum essays; live data comes from GET /api/movement-links.
* Multiple parents are allowed (e.g. Post-Impressionism feeding several modern paths).
*/
export const MOVEMENT_LINEAGE: ReadonlyArray<readonly [parent: string, child: string]> = [
export type MovementLineageEdge = readonly [parent: string, child: string];
export const FALLBACK_MOVEMENT_LINEAGE: ReadonlyArray<MovementLineageEdge> = [
// Classical & medieval
['Ancient Greek & Roman', 'Byzantine'],
['Byzantine', 'Gothic'],
@@ -21,17 +23,17 @@ export const MOVEMENT_LINEAGE: ReadonlyArray<readonly [parent: string, child: st
['Rococo', 'Neoclassicism'],
['Neoclassicism', 'Romanticism'],
['Romanticism', 'Realism'],
['Romanticism', 'Impressionism'], // Turner, Delacroix → light and atmosphere
['Romanticism', 'Impressionism'],
['Realism', 'Impressionism'],
// Modern origins (Met / ArtStory: Impressionism → Post-Impressionism → …)
// Modern origins
['Impressionism', 'Post-Impressionism'],
['Post-Impressionism', 'Symbolism'],
['Post-Impressionism', 'Fauvism'],
['Post-Impressionism', 'Expressionism'],
['Post-Impressionism', 'Cubism'], // Cézanne's structure → Picasso/Braque
['Post-Impressionism', 'Cubism'],
['Symbolism', 'Art Nouveau'],
['Fauvism', 'Cubism'], // Braque, Matisse → geometric turn
['Fauvism', 'Cubism'],
['Cubism', 'Futurism'],
['Cubism', 'Suprematism'],
['Cubism', 'Constructivism'],
@@ -43,10 +45,35 @@ export const MOVEMENT_LINEAGE: ReadonlyArray<readonly [parent: string, child: st
['Abstract Expressionism', 'Pop Art'],
];
let activeLineage: ReadonlyArray<MovementLineageEdge> = FALLBACK_MOVEMENT_LINEAGE;
/** Active edges for timeline / tree layout (API-backed when loaded). */
export function getMovementLineage(): ReadonlyArray<MovementLineageEdge> {
return activeLineage;
}
/** Apply curator DB rows; empty list keeps the static fallback. */
export function setMovementLineageFromApi(
items: ReadonlyArray<{ source_name: string; target_name: string }>
): void {
if (!items.length) {
activeLineage = FALLBACK_MOVEMENT_LINEAGE;
return;
}
activeLineage = items.map((row) => [row.source_name, row.target_name] as const);
}
/** @deprecated Use getMovementLineage() — alias for older imports. */
export const MOVEMENT_LINEAGE = FALLBACK_MOVEMENT_LINEAGE;
export function lineageParentsByName(childName: string): string[] {
return MOVEMENT_LINEAGE.filter(([, child]) => child === childName).map(([parent]) => parent);
return getMovementLineage()
.filter(([, child]) => child === childName)
.map(([parent]) => parent);
}
export function lineageChildrenByName(parentName: string): string[] {
return MOVEMENT_LINEAGE.filter(([parent]) => parent === parentName).map(([, child]) => child);
return getMovementLineage()
.filter(([parent]) => parent === parentName)
.map(([, child]) => child);
}
+5 -1
View File
@@ -12,6 +12,7 @@ import enAnnotations from '../locales/en/annotations.json';
import enDebug from '../locales/en/debug.json';
import enTranslations from '../locales/en/translations.json';
import enInfluences from '../locales/en/influences.json';
import enMovementLinks from '../locales/en/movementLinks.json';
import enTours from '../locales/en/tours.json';
import enUsers from '../locales/en/users.json';
import enAudit from '../locales/en/audit.json';
@@ -26,6 +27,7 @@ import ruAnnotations from '../locales/ru/annotations.json';
import ruDebug from '../locales/ru/debug.json';
import ruTranslations from '../locales/ru/translations.json';
import ruInfluences from '../locales/ru/influences.json';
import ruMovementLinks from '../locales/ru/movementLinks.json';
import ruTours from '../locales/ru/tours.json';
import ruUsers from '../locales/ru/users.json';
import ruAudit from '../locales/ru/audit.json';
@@ -37,7 +39,7 @@ void i18n.use(initReactI18next).init({
lng: initialLocale,
fallbackLng: 'en',
supportedLngs: ['en', 'ru'],
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours', 'users', 'audit'],
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'movementLinks', 'tours', 'users', 'audit'],
defaultNS: 'common',
resources: {
en: {
@@ -51,6 +53,7 @@ void i18n.use(initReactI18next).init({
debug: enDebug,
translations: enTranslations,
influences: enInfluences,
movementLinks: enMovementLinks,
tours: enTours,
users: enUsers,
audit: enAudit,
@@ -66,6 +69,7 @@ void i18n.use(initReactI18next).init({
debug: ruDebug,
translations: ruTranslations,
influences: ruInfluences,
movementLinks: ruMovementLinks,
tours: ruTours,
users: ruUsers,
audit: ruAudit,
+1
View File
@@ -15,6 +15,7 @@
"checkup": "Checkup",
"translations": "Translations",
"influences": "Influences",
"movementLinks": "Movement links",
"tours": "Tours",
"toursEditor": "Tour editor",
"users": "Users",
+1
View File
@@ -1,6 +1,7 @@
{
"title": "Influence links",
"back": "← Back to gallery",
"openMovementLinks": "Movement links →",
"tabList": "List",
"tabImport": "Import",
"tabGraph": "Graph",
+52
View File
@@ -0,0 +1,52 @@
{
"title": "Movement links",
"back": "← Back to gallery",
"intro": "Predecessor → successor edges used by the timeline flow and Tree of Art. Every link needs a proof URL (museum essay, literature, or other web reference).",
"introCurator": "Curators can vote to delete a link and must give a reason. Only an admin can perform the final delete after reviewing votes.",
"introAdmin": "As admin you can delete links directly. Review curator delete votes (reasons) before confirming.",
"loadFailed": "Failed to load movement links",
"saveFailed": "Failed to save movement link",
"deleteFailed": "Failed to delete movement link",
"voteFailed": "Failed to cast delete vote",
"withdrawFailed": "Failed to withdraw vote",
"searchPlaceholder": "Search source, target, proof…",
"refresh": "Refresh",
"loading": "Loading…",
"loadingVotes": "Loading votes…",
"votesLoadFailed": "Could not load votes.",
"total": "{{count}} links",
"noLinks": "No movement links yet.",
"addLink": "Add link",
"cancelAdd": "Cancel",
"addTitle": "New movement link",
"source": "Source (from)",
"target": "Target (to)",
"pickMovement": "Select movement…",
"proofUrl": "Proof URL",
"proofLabel": "Proof label",
"proofLabelPlaceholder": "e.g. Met — Cubism",
"linkYear": "Link date ≈",
"linkYearPlaceholder": "e.g. 1907",
"notes": "Notes",
"saveLink": "Save link",
"saving": "Saving…",
"addRequiresMovements": "Pick both source and target movements",
"proofRequired": "Proof URL is required",
"voteTitle": "Vote to delete",
"voteReason": "Reason",
"voteReasonPlaceholder": "Explain why this link should be removed (min. 10 characters)…",
"voteReasonRequired": "Reason must be at least 10 characters",
"submitVote": "Submit vote",
"cancelVote": "Cancel",
"voteDelete": "Vote to delete",
"withdrawVote": "Withdraw vote",
"voteCount": "{{count}} vote(s)",
"colSource": "Source",
"colTarget": "Target",
"colYear": "Link date ≈",
"colProof": "Reference / proof",
"colVotes": "Delete votes",
"colActions": "Actions",
"delete": "Delete",
"confirmDelete": "Delete this movement link? Curator vote reasons will be recorded in the audit log."
}
+1 -1
View File
@@ -25,7 +25,7 @@
"perm_checkup": "Checkup",
"perm_curator_notes": "Curator notes",
"perm_translations": "Translations",
"perm_influences": "Influences",
"perm_influences": "Influences & movement links",
"perm_tours": "Tours",
"perm_users": "Users",
"adminAllPerms": "Admins have all permissions automatically.",
+1
View File
@@ -15,6 +15,7 @@
"checkup": "Проверка",
"translations": "Переводы",
"influences": "Влияния",
"movementLinks": "Связи движений",
"tours": "Экскурсии",
"toursEditor": "Редактор экскурсий",
"users": "Пользователи",
+1
View File
@@ -1,6 +1,7 @@
{
"title": "Связи влияния",
"back": "← Назад в галерею",
"openMovementLinks": "Связи движений →",
"tabList": "Список",
"tabImport": "Импорт",
"tabGraph": "Граф",
+52
View File
@@ -0,0 +1,52 @@
{
"title": "Связи движений",
"back": "← Назад в галерею",
"intro": "Связи предшественник → преемник для ленты и «Дерева искусства». Для каждой связи нужна ссылка-доказательство (музейный очерк, литература или интернет).",
"introCurator": "Кураторы могут голосовать за удаление связи с указанием причины. Окончательное удаление выполняет только администратор после просмотра голосов.",
"introAdmin": "Как администратор вы можете удалять связи напрямую. Перед подтверждением просмотрите голоса кураторов и их причины.",
"loadFailed": "Не удалось загрузить связи движений",
"saveFailed": "Не удалось сохранить связь",
"deleteFailed": "Не удалось удалить связь",
"voteFailed": "Не удалось отправить голос",
"withdrawFailed": "Не удалось отозвать голос",
"searchPlaceholder": "Поиск: источник, цель, ссылка…",
"refresh": "Обновить",
"loading": "Загрузка…",
"loadingVotes": "Загрузка голосов…",
"votesLoadFailed": "Не удалось загрузить голоса.",
"total": "{{count}} связей",
"noLinks": "Связей движений пока нет.",
"addLink": "Добавить связь",
"cancelAdd": "Отмена",
"addTitle": "Новая связь движений",
"source": "Источник (от)",
"target": "Цель (к)",
"pickMovement": "Выберите движение…",
"proofUrl": "Ссылка-доказательство",
"proofLabel": "Подпись ссылки",
"proofLabelPlaceholder": "напр. Met — Cubism",
"linkYear": "Дата связи ≈",
"linkYearPlaceholder": "напр. 1907",
"notes": "Заметки",
"saveLink": "Сохранить",
"saving": "Сохранение…",
"addRequiresMovements": "Выберите исходное и целевое движение",
"proofRequired": "Ссылка-доказательство обязательна",
"voteTitle": "Голос за удаление",
"voteReason": "Причина",
"voteReasonPlaceholder": "Почему связь нужно удалить (мин. 10 символов)…",
"voteReasonRequired": "Причина должна быть не короче 10 символов",
"submitVote": "Отправить голос",
"cancelVote": "Отмена",
"voteDelete": "Голосовать за удаление",
"withdrawVote": "Отозвать голос",
"voteCount": "{{count}} голос(ов)",
"colSource": "Источник",
"colTarget": "Цель",
"colYear": "Дата ≈",
"colProof": "Ссылка / доказательство",
"colVotes": "Голоса за удаление",
"colActions": "Действия",
"delete": "Удалить",
"confirmDelete": "Удалить эту связь движений? Причины голосов кураторов будут записаны в журнал аудита."
}
+1 -1
View File
@@ -25,7 +25,7 @@
"perm_checkup": "Проверка",
"perm_curator_notes": "Заметки куратора",
"perm_translations": "Переводы",
"perm_influences": "Влияния",
"perm_influences": "Влияния и связи движений",
"perm_tours": "Экскурсии",
"perm_users": "Пользователи",
"adminAllPerms": "У администраторов все права автоматически.",
+4
View File
@@ -116,8 +116,12 @@
top: 16px;
right: 16px;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
align-items: center;
max-width: min(780px, calc(100vw - 24px));
z-index: 120;
}
.debug-mode-toggle,
+50 -3
View File
@@ -12,6 +12,7 @@ import ArtistBio from '../components/ArtistBio';
import CheckupPage from '../pages/CheckupPage';
import TranslationsPage from '../pages/TranslationsPage';
import InfluencesPage from '../pages/InfluencesPage';
import MovementLinksPage from '../pages/MovementLinksPage';
import ToursPage from '../pages/ToursPage';
import UsersPage from '../pages/UsersPage';
import AuditPage from '../pages/AuditPage';
@@ -28,11 +29,13 @@ import '../components/ToursPopup.css';
import '../components/LocaleSwitcher.css';
import '../pages/TranslationsPage.css';
import '../pages/InfluencesPage.css';
import '../pages/MovementLinksPage.css';
import '../pages/ToursPage.css';
import '../pages/UsersPage.css';
import '../pages/AuditPage.css';
import { useAuth } from '../context/AuthContext';
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
import { setMovementLineageFromApi } from '../data/movement-lineage';
import type {
TimelineData,
Artist,
@@ -55,6 +58,7 @@ type View =
| { type: 'checkup' }
| { type: 'translations' }
| { type: 'influences' }
| { type: 'movement-links' }
| { type: 'tours' }
| { type: 'users' }
| { type: 'audit' }
@@ -69,7 +73,7 @@ type GallerySession =
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
| { kind: 'tour'; tourId: number; data: TourGalleryDetail };
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | 'users' | 'audit' | null;
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'movement-links' | 'tours' | 'users' | 'audit' | null;
function patchPaintingInMovementDetail(
detail: MovementGalleryDetail,
@@ -259,9 +263,14 @@ export default function HomePage() {
(async () => {
try {
setLoading(true);
const catalog = await api.getCatalogBootstrap();
const [catalog, lineage] = await Promise.all([
api.getCatalogBootstrap(),
api.listMovementLinks({ limit: 500 }).catch(() => ({ items: [] as Awaited<ReturnType<typeof api.listMovementLinks>>['items'] })),
]);
if (cancelled) return;
setMovementLineageFromApi(lineage.items);
const min = catalog.bounds.min_year ?? -800;
const max = catalog.bounds.max_year ?? 2025;
setBounds({ min, max });
@@ -363,6 +372,8 @@ export default function HomePage() {
setView({ type: 'translations' });
} else if (loginRedirect === 'influences') {
setView({ type: 'influences' });
} else if (loginRedirect === 'movement-links') {
setView({ type: 'movement-links' });
} else if (loginRedirect === 'tours') {
setView({ type: 'tours' });
} else if (loginRedirect === 'users') {
@@ -381,6 +392,7 @@ export default function HomePage() {
view.type === 'checkup' ||
view.type === 'translations' ||
view.type === 'influences' ||
view.type === 'movement-links' ||
view.type === 'tours' ||
view.type === 'users' ||
view.type === 'audit'
@@ -413,6 +425,14 @@ export default function HomePage() {
setView({ type: 'influences' });
};
const openMovementLinks = () => {
if (!canInfluences) {
openCuratorLogin('movement-links');
return;
}
setView({ type: 'movement-links' });
};
const openToursEditor = () => {
if (!canTours) {
openCuratorLogin('tours');
@@ -1128,7 +1148,7 @@ export default function HomePage() {
{view.type === 'influences' && (
canInfluences ? (
<InfluencesPage onBack={goToTimelineHome} />
<InfluencesPage onBack={goToTimelineHome} onOpenMovementLinks={openMovementLinks} />
) : (
<div className="curator-login-gate">
<h2>{t('curatorRequiredTitle')}</h2>
@@ -1145,6 +1165,25 @@ export default function HomePage() {
)
)}
{view.type === 'movement-links' && (
canInfluences ? (
<MovementLinksPage onBack={goToTimelineHome} movements={timelineData.movements} />
) : (
<div className="curator-login-gate">
<h2>{t('curatorRequiredTitle')}</h2>
<p>{t('curatorRequiredBody')}</p>
<div className="curator-login-gate-actions">
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('movement-links')}>
{t('curatorLogin')}
</button>
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
{t('backToGalleryBtn')}
</button>
</div>
</div>
)
)}
{view.type === 'tours' && (
canTours ? (
<ToursPage onBack={goToTimelineHome} />
@@ -1273,6 +1312,14 @@ export default function HomePage() {
<div className="home-page">
<header className="site-header">
<div className="site-layout-switch">
<button
type="button"
className="site-layout-link site-layout-link-feature"
onClick={openMovementLinks}
title="Manage art-movement predecessor → successor links (curator)"
>
{t('movementLinks')}
</button>
{!isTreeTimeline && (
<button
type="button"
+7 -1
View File
@@ -16,6 +16,7 @@ type WizardStep = 'upload' | 'mapping' | 'preview' | 'done';
interface Props {
onBack: () => void;
onOpenMovementLinks?: () => void;
}
const ROLE_OPTIONS = [
@@ -29,7 +30,7 @@ const ROLE_OPTIONS = [
'source_url',
] as const;
export default function InfluencesPage({ onBack }: Props) {
export default function InfluencesPage({ onBack, onOpenMovementLinks }: Props) {
const { t } = useTranslation('influences');
const [tab, setTab] = useState<Tab>('list');
const [error, setError] = useState<string | null>(null);
@@ -335,6 +336,11 @@ export default function InfluencesPage({ onBack }: Props) {
{t('back')}
</button>
<h1>{t('title')}</h1>
{onOpenMovementLinks && (
<button type="button" className="influences-back" onClick={onOpenMovementLinks}>
{t('openMovementLinks')}
</button>
)}
<nav className="influences-tabs">
<button type="button" className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>
{t('tabList')}
+265
View File
@@ -0,0 +1,265 @@
.movement-links-page {
max-width: 1100px;
margin: 0 auto;
padding: 1.25rem 1.5rem 3rem;
color: #e8d5b5;
min-height: 100vh;
}
.movement-links-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
margin-bottom: 0.5rem;
}
.movement-links-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 650;
flex: 1;
color: #e8d5b5;
}
.movement-links-back,
.movement-links-add-btn,
.movement-links-refresh,
.movement-links-save,
.movement-links-delete {
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(15, 15, 26, 0.85);
color: #c9a96e;
border-radius: 6px;
padding: 0.4rem 0.75rem;
cursor: pointer;
}
.movement-links-back:hover,
.movement-links-add-btn:hover,
.movement-links-refresh:hover,
.movement-links-save:hover {
border-color: #c9a96e;
color: #e8d5b5;
}
.movement-links-intro {
margin: 0 0 0.35rem;
color: rgba(232, 213, 181, 0.75);
font-size: 0.95rem;
max-width: 52rem;
}
.movement-links-intro-secondary {
margin: 0 0 1rem;
color: rgba(232, 213, 181, 0.55);
font-size: 0.88rem;
max-width: 52rem;
}
.movement-links-error {
background: rgba(139, 0, 0, 0.3);
border: 1px solid rgba(255, 170, 170, 0.4);
color: #ffaaaa;
padding: 0.65rem 0.85rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.movement-links-form {
border: 1px solid rgba(201, 169, 110, 0.28);
border-radius: 8px;
padding: 1rem 1.1rem 1.15rem;
margin-bottom: 1.25rem;
background: rgba(15, 15, 26, 0.55);
}
.movement-links-form h2 {
margin: 0 0 0.85rem;
font-size: 1.1rem;
font-weight: 600;
}
.movement-links-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1rem;
margin-bottom: 0.9rem;
}
.movement-links-form-grid label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.85rem;
color: rgba(232, 213, 181, 0.85);
}
.movement-links-span-2 {
grid-column: 1 / -1;
}
.movement-links-form-grid select,
.movement-links-form-grid input,
.movement-links-form-grid textarea,
.movement-links-vote-form textarea,
.movement-links-search {
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(10, 10, 20, 0.9);
color: #e8d5b5;
border-radius: 6px;
padding: 0.45rem 0.6rem;
font: inherit;
}
.movement-links-vote-form label {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 0.75rem;
font-size: 0.85rem;
color: rgba(232, 213, 181, 0.85);
}
.movement-links-vote-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.movement-links-vote-btn,
.movement-links-votes-toggle {
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(15, 15, 26, 0.85);
color: #c9a96e;
border-radius: 6px;
padding: 0.35rem 0.65rem;
cursor: pointer;
}
.movement-links-vote-btn:hover,
.movement-links-votes-toggle:hover {
border-color: #c9a96e;
color: #e8d5b5;
}
.movement-links-row-flagged {
background: rgba(232, 160, 64, 0.08);
}
.movement-links-vote-list {
margin: 0.45rem 0 0;
padding-left: 1.1rem;
color: rgba(232, 213, 181, 0.7);
font-size: 0.82rem;
}
.movement-links-actions {
white-space: nowrap;
}
.movement-links-save {
background: rgba(232, 160, 64, 0.18);
border-color: #e8a040;
color: #e8d5b5;
}
.movement-links-save:disabled,
.movement-links-delete:disabled,
.movement-links-refresh:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.movement-links-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.movement-links-search {
flex: 1;
min-width: 12rem;
}
.movement-links-count {
color: rgba(232, 213, 181, 0.65);
font-size: 0.9rem;
}
.movement-links-muted {
color: rgba(232, 213, 181, 0.55);
}
.movement-links-table-wrap {
overflow-x: auto;
border: 1px solid rgba(201, 169, 110, 0.28);
border-radius: 8px;
}
.movement-links-table {
width: 100%;
border-collapse: collapse;
font-size: 0.92rem;
}
.movement-links-table th,
.movement-links-table td {
text-align: left;
padding: 0.55rem 0.75rem;
border-bottom: 1px solid rgba(201, 169, 110, 0.18);
vertical-align: top;
}
.movement-links-table th {
background: rgba(15, 15, 26, 0.85);
color: #c9a96e;
font-weight: 600;
white-space: nowrap;
}
.movement-links-table tbody tr:nth-child(even) {
background: rgba(15, 15, 26, 0.35);
}
.movement-links-table a {
color: #e8a040;
word-break: break-word;
}
.movement-links-table a:hover {
color: #f0c070;
}
.movement-links-year {
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.movement-links-notes {
margin-top: 0.25rem;
color: rgba(232, 213, 181, 0.55);
font-size: 0.82rem;
}
.movement-links-delete {
color: #ffaaaa;
border-color: rgba(255, 170, 170, 0.35);
}
.movement-links-delete:hover:not(:disabled) {
border-color: #ffaaaa;
color: #ffcccc;
}
@media (max-width: 700px) {
.movement-links-form-grid {
grid-template-columns: 1fr;
}
.movement-links-span-2 {
grid-column: auto;
}
}
+439
View File
@@ -0,0 +1,439 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
api,
type MovementLineageDeleteVote,
type MovementLineageLinkItem,
} from '../api/client';
import { useAuth } from '../context/AuthContext';
import { setMovementLineageFromApi } from '../data/movement-lineage';
import type { ArtMovement } from '../types';
import './MovementLinksPage.css';
interface Props {
onBack: () => void;
movements: ArtMovement[];
}
export default function MovementLinksPage({ onBack, movements }: Props) {
const { t } = useTranslation('movementLinks');
const { isAdmin } = useAuth();
const [items, setItems] = useState<MovementLineageLinkItem[]>([]);
const [total, setTotal] = useState(0);
const [q, setQ] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [sourceId, setSourceId] = useState<number | ''>('');
const [targetId, setTargetId] = useState<number | ''>('');
const [proofUrl, setProofUrl] = useState('');
const [proofLabel, setProofLabel] = useState('');
const [linkYear, setLinkYear] = useState('');
const [notes, setNotes] = useState('');
const [voteLinkId, setVoteLinkId] = useState<number | null>(null);
const [voteReason, setVoteReason] = useState('');
const [expandedVotes, setExpandedVotes] = useState<Record<number, MovementLineageDeleteVote[] | 'loading' | 'error'>>({});
const sortedMovements = useMemo(
() => [...movements].sort((a, b) => a.start_year - b.start_year || a.name.localeCompare(b.name)),
[movements],
);
const syncLocalLineage = useCallback((rows: MovementLineageLinkItem[]) => {
setMovementLineageFromApi(rows);
}, []);
const loadList = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [data, all] = await Promise.all([
api.listMovementLinks({ q: q || undefined, limit: 500 }),
q ? api.listMovementLinks({ limit: 500 }) : Promise.resolve(null),
]);
setItems(data.items);
setTotal(data.total);
syncLocalLineage((all ?? data).items);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setLoading(false);
}
}, [q, syncLocalLineage, t]);
useEffect(() => {
void loadList();
}, [loadList]);
const resetForm = () => {
setSourceId('');
setTargetId('');
setProofUrl('');
setProofLabel('');
setLinkYear('');
setNotes('');
};
const submitAdd = async () => {
if (sourceId === '' || targetId === '') {
setError(t('addRequiresMovements'));
return;
}
if (!proofUrl.trim()) {
setError(t('proofRequired'));
return;
}
setSaving(true);
setError(null);
try {
const yearRaw = linkYear.trim();
await api.createMovementLink({
sourceMovementId: sourceId,
targetMovementId: targetId,
proofUrl: proofUrl.trim(),
proofLabel: proofLabel.trim() || undefined,
linkYearApprox: yearRaw === '' ? null : Number(yearRaw),
notes: notes.trim() || undefined,
});
resetForm();
setShowAdd(false);
await loadList();
} catch (err) {
setError(err instanceof Error ? err.message : t('saveFailed'));
} finally {
setSaving(false);
}
};
const submitVote = async () => {
if (voteLinkId == null) return;
if (voteReason.trim().length < 10) {
setError(t('voteReasonRequired'));
return;
}
setSaving(true);
setError(null);
try {
await api.voteDeleteMovementLink(voteLinkId, voteReason.trim());
setVoteLinkId(null);
setVoteReason('');
setExpandedVotes((prev) => {
const next = { ...prev };
delete next[voteLinkId];
return next;
});
await loadList();
} catch (err) {
setError(err instanceof Error ? err.message : t('voteFailed'));
} finally {
setSaving(false);
}
};
const withdrawVote = async (id: number) => {
setSaving(true);
setError(null);
try {
await api.withdrawDeleteMovementLinkVote(id);
setExpandedVotes((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
await loadList();
} catch (err) {
setError(err instanceof Error ? err.message : t('withdrawFailed'));
} finally {
setSaving(false);
}
};
const loadVotes = async (id: number) => {
if (expandedVotes[id] && expandedVotes[id] !== 'error') {
setExpandedVotes((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
return;
}
setExpandedVotes((prev) => ({ ...prev, [id]: 'loading' }));
try {
const data = await api.listMovementLinkDeleteVotes(id);
setExpandedVotes((prev) => ({ ...prev, [id]: data.votes }));
} catch {
setExpandedVotes((prev) => ({ ...prev, [id]: 'error' }));
}
};
const onAdminDelete = async (id: number) => {
if (!window.confirm(t('confirmDelete'))) return;
setSaving(true);
setError(null);
try {
await api.deleteMovementLink(id);
setExpandedVotes((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
await loadList();
} catch (err) {
setError(err instanceof Error ? err.message : t('deleteFailed'));
} finally {
setSaving(false);
}
};
return (
<div className="movement-links-page">
<header className="movement-links-header">
<button type="button" className="movement-links-back" onClick={onBack}>
{t('back')}
</button>
<h1>{t('title')}</h1>
<button
type="button"
className="movement-links-add-btn"
onClick={() => {
setShowAdd((v) => !v);
setError(null);
}}
>
{showAdd ? t('cancelAdd') : t('addLink')}
</button>
<button type="button" className="movement-links-refresh" onClick={() => void loadList()} disabled={loading}>
{t('refresh')}
</button>
</header>
<p className="movement-links-intro">{t('intro')}</p>
<p className="movement-links-intro-secondary">
{isAdmin ? t('introAdmin') : t('introCurator')}
</p>
{error && <div className="movement-links-error">{error}</div>}
{voteLinkId != null && (
<section className="movement-links-form movement-links-vote-form">
<h2>{t('voteTitle')}</h2>
<label className="movement-links-span-2">
{t('voteReason')} *
<textarea
rows={3}
value={voteReason}
onChange={(e) => setVoteReason(e.target.value)}
placeholder={t('voteReasonPlaceholder')}
/>
</label>
<div className="movement-links-vote-actions">
<button type="button" className="movement-links-save" onClick={() => void submitVote()} disabled={saving}>
{saving ? t('saving') : t('submitVote')}
</button>
<button
type="button"
className="movement-links-refresh"
onClick={() => {
setVoteLinkId(null);
setVoteReason('');
}}
disabled={saving}
>
{t('cancelVote')}
</button>
</div>
</section>
)}
{showAdd && (
<section className="movement-links-form">
<h2>{t('addTitle')}</h2>
<div className="movement-links-form-grid">
<label>
{t('source')}
<select
value={sourceId === '' ? '' : String(sourceId)}
onChange={(e) => setSourceId(e.target.value ? Number(e.target.value) : '')}
>
<option value="">{t('pickMovement')}</option>
{sortedMovements.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.start_year})
</option>
))}
</select>
</label>
<label>
{t('target')}
<select
value={targetId === '' ? '' : String(targetId)}
onChange={(e) => setTargetId(e.target.value ? Number(e.target.value) : '')}
>
<option value="">{t('pickMovement')}</option>
{sortedMovements.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.start_year})
</option>
))}
</select>
</label>
<label className="movement-links-span-2">
{t('proofUrl')} *
<input
type="url"
required
placeholder="https://…"
value={proofUrl}
onChange={(e) => setProofUrl(e.target.value)}
/>
</label>
<label>
{t('proofLabel')}
<input
type="text"
value={proofLabel}
onChange={(e) => setProofLabel(e.target.value)}
placeholder={t('proofLabelPlaceholder')}
/>
</label>
<label>
{t('linkYear')}
<input
type="number"
value={linkYear}
onChange={(e) => setLinkYear(e.target.value)}
placeholder={t('linkYearPlaceholder')}
/>
</label>
<label className="movement-links-span-2">
{t('notes')}
<input type="text" value={notes} onChange={(e) => setNotes(e.target.value)} />
</label>
</div>
<button type="button" className="movement-links-save" onClick={() => void submitAdd()} disabled={saving}>
{saving ? t('saving') : t('saveLink')}
</button>
</section>
)}
<div className="movement-links-toolbar">
<input
type="search"
className="movement-links-search"
placeholder={t('searchPlaceholder')}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<span className="movement-links-count">{t('total', { count: total })}</span>
</div>
{loading ? (
<p className="movement-links-muted">{t('loading')}</p>
) : items.length === 0 ? (
<p className="movement-links-muted">{t('noLinks')}</p>
) : (
<div className="movement-links-table-wrap">
<table className="movement-links-table">
<thead>
<tr>
<th>{t('colSource')}</th>
<th>{t('colTarget')}</th>
<th>{t('colYear')}</th>
<th>{t('colProof')}</th>
<th>{t('colVotes')}</th>
<th>{t('colActions')}</th>
</tr>
</thead>
<tbody>
{items.map((row) => {
const voteCount = row.delete_vote_count ?? 0;
const votesState = expandedVotes[row.id];
return (
<tr key={row.id} className={voteCount > 0 ? 'movement-links-row-flagged' : undefined}>
<td>{row.source_name}</td>
<td>{row.target_name}</td>
<td className="movement-links-year">
{row.link_year_approx != null ? `~${row.link_year_approx}` : '—'}
</td>
<td>
<a href={row.proof_url} target="_blank" rel="noopener noreferrer">
{row.proof_label || row.proof_url}
</a>
{row.notes ? <div className="movement-links-notes">{row.notes}</div> : null}
{Array.isArray(votesState) && votesState.length > 0 && (
<ul className="movement-links-vote-list">
{votesState.map((v) => (
<li key={v.id}>
<strong>{v.username || `#${v.user_id}`}</strong>: {v.reason}
</li>
))}
</ul>
)}
{votesState === 'loading' && (
<div className="movement-links-notes">{t('loadingVotes')}</div>
)}
{votesState === 'error' && (
<div className="movement-links-notes">{t('votesLoadFailed')}</div>
)}
</td>
<td className="movement-links-year">
{voteCount > 0 ? (
<button
type="button"
className="movement-links-votes-toggle"
onClick={() => void loadVotes(row.id)}
>
{t('voteCount', { count: voteCount })}
</button>
) : (
'0'
)}
</td>
<td className="movement-links-actions">
{isAdmin ? (
<button
type="button"
className="movement-links-delete"
onClick={() => void onAdminDelete(row.id)}
disabled={saving}
>
{t('delete')}
</button>
) : row.viewer_has_delete_vote ? (
<button
type="button"
className="movement-links-refresh"
onClick={() => void withdrawVote(row.id)}
disabled={saving}
>
{t('withdrawVote')}
</button>
) : (
<button
type="button"
className="movement-links-vote-btn"
onClick={() => {
setVoteLinkId(row.id);
setVoteReason('');
setError(null);
setShowAdd(false);
}}
disabled={saving}
>
{t('voteDelete')}
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
+150 -32
View File
@@ -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);
+2 -2
View File
@@ -34,7 +34,7 @@
* widest subtree first, so the main trunk keeps x = 0 (canvas centre).
*/
import type { ArtMovement } from '../types';
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
import { getMovementLineage } from '../data/movement-lineage';
/** All values are tree-space pixels; the renderer scales them to the canvas. */
export const TREE_LAYOUT = {
@@ -103,7 +103,7 @@ function influenceCount(m: ArtMovement): number {
function buildParentMap(movements: ArtMovement[]): Map<number, number[]> {
const nameToId = new Map(movements.map((m) => [m.name, m.id]));
const parents = new Map<number, number[]>();
for (const [parentName, childName] of MOVEMENT_LINEAGE) {
for (const [parentName, childName] of getMovementLineage()) {
const parentId = nameToId.get(parentName);
const childId = nameToId.get(childName);
if (parentId == null || childId == null || parentId === childId) continue;
+58
View File
@@ -0,0 +1,58 @@
import type { MovementDetailKind } from '../data/movement-interior-styles';
/**
* Even bay centres along the depth axis — same formula as historic `useBayZ`.
* Pure (no React) so hang layout can reserve the same Z as period columns.
*/
export function bayPositions(depth: number, halfD: number, spacing = 4.2, max = 9): number[] {
const count = Math.max(2, Math.min(max, Math.floor(depth / spacing)));
const step = depth / (count + 1);
return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1));
}
export type SideColumnRhythm = {
/** Bay spacing passed to bayPositions (matches the period module). */
spacing: number;
max: number;
/**
* Half-width along the wall (Z) of the keep-out around each bay centre.
* Covers shaft/base/capital plus a small gap so frames do not kiss the column.
*/
halfClear: number;
};
/**
* Mid-wall vertical members that share the hang line on left/right walls.
* Corner-only orders are omitted — WALL_PADDING already clears the pockets.
*/
export const SIDE_WALL_COLUMN_RHYTHMS = {
gothic: { spacing: 4.2, max: 9, halfClear: 0.42 },
roman: { spacing: 4.0, max: 9, halfClear: 0.3 },
quattrocento: { spacing: 3.8, max: 9, halfClear: 0.34 },
// Paired pilasters sit at bay±0.19 with r≈0.11
cinquecento: { spacing: 5.2, max: 6, halfClear: 0.44 },
mannerist: { spacing: 4.8, max: 6, halfClear: 0.36 },
neoclassical: { spacing: 4.8, max: 6, halfClear: 0.3 },
'gothic-revival': { spacing: 3.8, max: 8, halfClear: 0.24 },
'art-nouveau': { spacing: 3.0, max: 10, halfClear: 0.22 },
} as const satisfies Partial<Record<MovementDetailKind, SideColumnRhythm>>;
export type SideColumnDetailKind = keyof typeof SIDE_WALL_COLUMN_RHYTHMS;
export function sideColumnRhythm(details: MovementDetailKind | undefined): SideColumnRhythm | null {
if (!details) return null;
return (SIDE_WALL_COLUMN_RHYTHMS as Partial<Record<MovementDetailKind, SideColumnRhythm>>)[details] ?? null;
}
/** Merged [z0, z1] intervals that side-wall paintings (and windows) must avoid. */
export function sideColumnKeepOutIntervals(
details: MovementDetailKind | undefined,
depth: number,
halfD: number
): [number, number][] {
const rhythm = sideColumnRhythm(details);
if (!rhythm) return [];
return bayPositions(depth, halfD, rhythm.spacing, rhythm.max).map(
(z) => [z - rhythm.halfClear, z + rhythm.halfClear] as [number, number]
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 871 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+16
View File
@@ -0,0 +1,16 @@
-- Curator votes to delete a movement lineage link (admin performs the actual delete).
CREATE TABLE IF NOT EXISTS movement_lineage_delete_votes (
id SERIAL PRIMARY KEY,
link_id INTEGER NOT NULL REFERENCES movement_lineage_links(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reason TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT movement_lineage_delete_votes_unique UNIQUE (link_id, user_id),
CONSTRAINT movement_lineage_delete_votes_reason_len CHECK (char_length(trim(reason)) >= 10)
);
CREATE INDEX IF NOT EXISTS movement_lineage_delete_votes_link_idx
ON movement_lineage_delete_votes (link_id);
CREATE INDEX IF NOT EXISTS movement_lineage_delete_votes_user_idx
ON movement_lineage_delete_votes (user_id);
+37
View File
@@ -0,0 +1,37 @@
-- Art-movement predecessor → successor edges for timeline / Tree of Art.
-- Curators manage these with a required proof URL (literature or web essay).
CREATE TABLE IF NOT EXISTS movement_lineage_links (
id SERIAL PRIMARY KEY,
source_movement_id INTEGER NOT NULL REFERENCES art_movements(id) ON DELETE CASCADE,
target_movement_id INTEGER NOT NULL REFERENCES art_movements(id) ON DELETE CASCADE,
link_year_approx INTEGER,
proof_url TEXT NOT NULL,
proof_label TEXT,
notes TEXT,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT movement_lineage_links_distinct CHECK (source_movement_id <> target_movement_id),
CONSTRAINT movement_lineage_links_unique UNIQUE (source_movement_id, target_movement_id)
);
CREATE INDEX IF NOT EXISTS movement_lineage_links_source_idx
ON movement_lineage_links (source_movement_id);
CREATE INDEX IF NOT EXISTS movement_lineage_links_target_idx
ON movement_lineage_links (target_movement_id);
CREATE INDEX IF NOT EXISTS movement_lineage_links_year_idx
ON movement_lineage_links (link_year_approx);
CREATE OR REPLACE FUNCTION movement_lineage_links_touch_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS movement_lineage_links_updated_at ON movement_lineage_links;
CREATE TRIGGER movement_lineage_links_updated_at
BEFORE UPDATE ON movement_lineage_links
FOR EACH ROW EXECUTE PROCEDURE movement_lineage_links_touch_updated_at();
+1
View File
@@ -4,6 +4,7 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "node --test server/__tests__/**/*.test.js",
"dev:web": "node scripts/dev-web.js",
"dev:server": "nodemon server/index.js",
"dev:client": "npm run dev --prefix client",
@@ -0,0 +1,46 @@
/**
* Abstract Expressionism loft must read as an open painter's loft, not a barred cell.
* node --test server/__tests__/abex-loft-interior.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/modernism.tsx'), 'utf8');
function loftStyleBlock() {
const start = STYLES.indexOf("'abstract-expressionist-loft'");
assert.ok(start >= 0, 'abstract-expressionist-loft style must exist');
const next = STYLES.indexOf('mk(', start + 1);
return STYLES.slice(start, next > start ? next : undefined);
}
function loftDetailsBlock() {
const start = DETAILS.indexOf('export function LoftDetails');
assert.ok(start >= 0, 'LoftDetails must exist');
const next = DETAILS.indexOf('export function ', start + 1);
return DETAILS.slice(start, next > start ? next : undefined);
}
describe('Abstract Expressionism loft — open studio, no prison vibe', () => {
it('does not use factory windows (iron-bar grid)', () => {
const block = loftStyleBlock();
assert.doesNotMatch(block, /style: 'factory'/);
assert.match(block, /style: 'picture'|style: 'clerestory'|style: 'skylight'/);
});
it('uses open painted / warm loft surfaces, not dingy cell brick-only', () => {
const block = loftStyleBlock();
assert.match(block, /north-light|painter|studio|loft/i);
assert.doesNotMatch(block, /north-light factory windows/);
});
it('LoftDetails has no cast-iron columns or sprinkler prison pipe', () => {
const block = loftDetailsBlock();
assert.doesNotMatch(block, /Cast-iron|Sprinkler main|cast-iron/);
assert.match(block, /timber|wood|joist|painter|studio/i);
});
});
+34
View File
@@ -0,0 +1,34 @@
/**
* Baroque upper-wall field panels must be framed velvet, not solid gilt slabs
* that wash out beside windows.
* node --test server/__tests__/baroque-panels.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/courtly.tsx'), 'utf8');
function baroqueBlock() {
const start = DETAILS.indexOf('export function BaroqueStateDetails');
assert.ok(start >= 0, 'BaroqueStateDetails must exist');
const next = DETAILS.indexOf('export function ', start + 1);
return DETAILS.slice(start, next > start ? next : undefined);
}
describe('Baroque field panels beside windows', () => {
it('does not draw a solid gilt slab covering the velvet field', () => {
const block = baroqueBlock();
assert.doesNotMatch(block, /boxGeometry args=\{\[0\.04, 1\.12, w \+ 0\.12\]\}/);
assert.match(block, /velvet|field/i);
assert.match(block, /frame|rail|moulding/i);
});
it('keeps a generous clearance around window openings', () => {
const block = baroqueBlock();
assert.match(block, /halfClear = fieldW \/ 2 \+ rail \+ 0\.35/);
assert.match(block, /\(mid, halfClear\)/);
});
});
+54
View File
@@ -0,0 +1,54 @@
/**
* Dada movement hall must read as a modern cabaret, not a barred cell.
* node --test server/__tests__/dada-interior.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
const WINDOWS = fs.readFileSync(path.join(ROOT, 'client/src/components/GalleryWindows.tsx'), 'utf8');
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/modernism.tsx'), 'utf8');
function dadaSalonBlock() {
const start = STYLES.indexOf("'dada-salon'");
assert.ok(start >= 0, 'dada-salon style must exist');
const next = STYLES.indexOf('mk(', start + 1);
return STYLES.slice(start, next > start ? next : undefined);
}
function dadaDetailsBlock() {
const start = DETAILS.indexOf('export function DadaDetails');
assert.ok(start >= 0, 'DadaDetails must exist');
const next = DETAILS.indexOf('export function ', start + 1);
return DETAILS.slice(start, next > start ? next : undefined);
}
describe('Dada interior — modern cabaret, no prison vibe', () => {
it('declares a picture window style (open glass, no iron grid)', () => {
assert.match(STYLES, /\| 'picture'/);
assert.match(WINDOWS, /style === 'picture'/);
});
it('does not use factory or gothic-lancet windows (barred / cell look)', () => {
const block = dadaSalonBlock();
assert.doesNotMatch(block, /style: 'factory'/);
assert.doesNotMatch(block, /style: 'gothic-lancet'/);
assert.match(block, /style: 'picture'/);
});
it('uses modern painted surfaces instead of dingy brick cell walls', () => {
const block = dadaSalonBlock();
assert.doesNotMatch(block, /wallSide: 'brick'/);
assert.match(block, /painted-flat|painted-emulsion/);
assert.match(block, /Modern|cabaret|collage/i);
});
it('DadaDetails has no service pipe / iron-bar geometry', () => {
const block = dadaDetailsBlock();
assert.doesNotMatch(block, /Service pipe|cylinderGeometry/);
assert.match(block, /collage|cabaret|panel/i);
});
});
@@ -0,0 +1,38 @@
/**
* Low wall mouldings must not run through exit doors on the end walls.
* node --test server/__tests__/doorway-clearance.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const PRIMITIVES = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/primitives.tsx'), 'utf8');
const GEOMETRY = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/geometry.ts'), 'utf8');
const COURTLY = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/courtly.tsx'), 'utf8');
function flemishBlock() {
const start = COURTLY.indexOf('export function FlemishDetails');
assert.ok(start >= 0);
const next = COURTLY.indexOf('export function ', start + 1);
return COURTLY.slice(start, next > start ? next : undefined);
}
describe('Doorway clearance for wall mouldings', () => {
it('exports a shared door half-clearance used by WallBand', () => {
assert.match(GEOMETRY, /DOOR_CLEAR_HALF\s*=\s*1\.45/);
assert.match(PRIMITIVES, /DOOR_CLEAR_HALF/);
});
it('WallBand splits back/front bands that sit in the door height', () => {
assert.match(PRIMITIVES, /crossesDoor/);
assert.match(PRIMITIVES, /DOOR_CLEAR_HALF/);
assert.match(PRIMITIVES, /leftX|rightX/);
});
it('Flemish linenfold ribs skip the back-wall doorway', () => {
const block = flemishBlock();
assert.match(block, /DOOR_CLEAR_HALF|Math\.abs\(x\)\s*>/);
});
});
+57
View File
@@ -0,0 +1,57 @@
/**
* No movement hall floor tint may blow out as near-white.
* node --test server/__tests__/floor-brightness.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
/** Soft ceiling — mid stone / warm wood, not gallery flood. */
const MAX_FLOOR_LUMINANCE = 0.58;
function luminance(hex) {
const n = parseInt(hex.slice(1), 16);
const r = (n >> 16) & 255;
const g = (n >> 8) & 255;
const b = n & 255;
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
}
function floorTints() {
// tints objects inside mk(): wall/ceiling/floor hex triples (optional wallSide)
const re =
/\{\s*wall:\s*'#[^']+',(?:\s*wallSide:\s*'#[^']+',)?\s*ceiling:\s*'#[^']+',\s*floor:\s*'(#[0-9a-fA-F]{6})'/g;
const out = [];
let m;
while ((m = re.exec(STYLES))) out.push(m[1]);
return out;
}
describe('Movement floors — no blown-out brightness', () => {
it('every authored floor tint stays at or below mid-stone luminance', () => {
const floors = floorTints();
assert.ok(floors.length >= 20, `expected many floor tints, got ${floors.length}`);
const offenders = floors
.map((hex) => ({ hex, L: luminance(hex) }))
.filter((f) => f.L > MAX_FLOOR_LUMINANCE);
assert.deepEqual(
offenders,
[],
offenders.map((f) => `${f.hex} L=${f.L.toFixed(3)}`).join(', ')
);
});
it('Baroque floor is subdued warm marble, not near-white', () => {
const start = STYLES.indexOf("'baroque-palace'");
assert.ok(start >= 0);
const block = STYLES.slice(start, STYLES.indexOf('mk(', start + 1));
const m = block.match(/ceiling:\s*'#[^']+',\s*floor:\s*'(#[0-9a-fA-F]{6})'/);
assert.ok(m);
assert.ok(luminance(m[1]) <= MAX_FLOOR_LUMINANCE);
assert.doesNotMatch(block, /floor: '#ece8e0'/);
});
});
@@ -0,0 +1,40 @@
/**
* High Renaissance (cinquecento) must not flood the hall with tiny contrast
* tiles or gold ring ornaments in every coffer.
* node --test server/__tests__/high-renaissance-interior.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/classical.tsx'), 'utf8');
function styleBlock() {
const start = STYLES.indexOf("'high-renaissance-palazzo'");
assert.ok(start >= 0);
return STYLES.slice(start, STYLES.indexOf('mk(', start + 1));
}
function cinquecentoBlock() {
const start = DETAILS.indexOf('export function CinquecentoDetails');
assert.ok(start >= 0);
const next = DETAILS.indexOf('export function ', start + 1);
return DETAILS.slice(start, next > start ? next : DETAILS.length);
}
describe('High Renaissance interior — quieter surfaces', () => {
it('does not use high-contrast marble-checker flooring', () => {
const block = styleBlock();
assert.doesNotMatch(block, /floor: 'marble-checker'/);
assert.match(block, /floor: 'marble-veined-carrara'|floor: 'terrazzo'|floor: 'parquet/);
});
it('Cinquecento coffers have no gold rosette rings', () => {
const block = cinquecentoBlock();
assert.doesNotMatch(block, /rosette=\{trim\}/);
assert.match(block, /CofferedCeiling/);
});
});
@@ -0,0 +1,35 @@
/**
* Mannerist herm pilasters must read as clean architecture, not flat white strips
* with floating edge artifacts.
* node --test server/__tests__/mannerist-columns.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/courtly.tsx'), 'utf8');
function manneristBlock() {
const start = DETAILS.indexOf('export function ManneristDetails');
assert.ok(start >= 0, 'ManneristDetails must exist');
const next = DETAILS.indexOf('export function ', start + 1);
return DETAILS.slice(start, next > start ? next : undefined);
}
describe('Mannerist herm columns', () => {
it('does not use the glitchy thin shaft + offset rustication + torus capital', () => {
const block = manneristBlock();
assert.doesNotMatch(block, /boxGeometry args=\{\[0\.09, 3\.8, 0\.3\]\}/);
assert.doesNotMatch(block, /position=\{\[side \* 0\.03, y, 0\]\}/);
assert.doesNotMatch(block, /torusGeometry args=\{\[0\.14, 0\.055/);
});
it('builds herm pilasters with clearance and a proper capital', () => {
const block = manneristBlock();
assert.match(block, /herm|pilaster/i);
assert.match(block, /clearLeft|clearRight|useWallClearance/);
assert.match(block, /capital|console|abacus/i);
});
});
@@ -0,0 +1,129 @@
/**
* Tests for movement lineage validation (run before / with the service module).
* node --test server/__tests__/movement-lineage-service.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const {
isValidProofUrl,
validateCreatePayload,
validateDeleteVotePayload,
DEFAULT_MOVEMENT_LINEAGE,
} = require('../movement-lineage-service');
describe('isValidProofUrl', () => {
it('accepts http and https URLs', () => {
assert.equal(isValidProofUrl('https://www.metmuseum.org/toah/hd/byza/hd_byza.htm'), true);
assert.equal(isValidProofUrl('http://example.com/essay'), true);
});
it('rejects empty, non-http, and garbage', () => {
assert.equal(isValidProofUrl(''), false);
assert.equal(isValidProofUrl(' '), false);
assert.equal(isValidProofUrl(null), false);
assert.equal(isValidProofUrl('ftp://files.example/a'), false);
assert.equal(isValidProofUrl('not-a-url'), false);
assert.equal(isValidProofUrl('www.metmuseum.org/page'), false);
});
});
describe('validateCreatePayload', () => {
it('requires source, target, and proof URL', () => {
const r = validateCreatePayload({});
assert.equal(r.ok, false);
assert.match(r.error, /sourceMovementId/i);
});
it('rejects same source and target', () => {
const r = validateCreatePayload({
sourceMovementId: 3,
targetMovementId: 3,
proofUrl: 'https://www.theartstory.org/movement/cubism/',
});
assert.equal(r.ok, false);
assert.match(r.error, /different/i);
});
it('requires a valid proof URL', () => {
const r = validateCreatePayload({
sourceMovementId: 1,
targetMovementId: 2,
proofUrl: 'see Wikipedia',
});
assert.equal(r.ok, false);
assert.match(r.error, /proof/i);
});
it('accepts a valid payload and normalizes fields', () => {
const r = validateCreatePayload({
sourceMovementId: '10',
targetMovementId: 20,
proofUrl: ' https://www.metmuseum.org/essays/cubism ',
proofLabel: ' Met Cubism ',
linkYearApprox: '1907',
notes: ' Cézanne → Picasso ',
});
assert.equal(r.ok, true);
assert.deepEqual(r.value, {
sourceMovementId: 10,
targetMovementId: 20,
proofUrl: 'https://www.metmuseum.org/essays/cubism',
proofLabel: 'Met Cubism',
linkYearApprox: 1907,
notes: 'Cézanne → Picasso',
});
});
it('allows null optional year and blank optional label/notes', () => {
const r = validateCreatePayload({
sourceMovementId: 1,
targetMovementId: 2,
proofUrl: 'https://example.org/a',
proofLabel: '',
linkYearApprox: '',
notes: null,
});
assert.equal(r.ok, true);
assert.equal(r.value.proofLabel, null);
assert.equal(r.value.linkYearApprox, null);
assert.equal(r.value.notes, null);
});
});
describe('validateDeleteVotePayload', () => {
it('requires a non-empty reason', () => {
assert.equal(validateDeleteVotePayload({}).ok, false);
assert.equal(validateDeleteVotePayload({ reason: ' ' }).ok, false);
assert.match(validateDeleteVotePayload({ reason: '' }).error, /reason/i);
});
it('rejects reasons that are too short', () => {
const r = validateDeleteVotePayload({ reason: 'no' });
assert.equal(r.ok, false);
assert.match(r.error, /reason/i);
});
it('accepts and trims a valid reason', () => {
const r = validateDeleteVotePayload({
reason: ' Incorrect lineage: Cubism did not derive from Fauvism. ',
});
assert.equal(r.ok, true);
assert.equal(r.value.reason, 'Incorrect lineage: Cubism did not derive from Fauvism.');
});
});
describe('DEFAULT_MOVEMENT_LINEAGE', () => {
it('has unique source→target pairs with proof URLs', () => {
assert.ok(DEFAULT_MOVEMENT_LINEAGE.length >= 20);
const keys = new Set();
for (const row of DEFAULT_MOVEMENT_LINEAGE) {
assert.ok(row.sourceName);
assert.ok(row.targetName);
assert.notEqual(row.sourceName, row.targetName);
assert.equal(isValidProofUrl(row.proofUrl), true, row.proofUrl);
const key = `${row.sourceName}→${row.targetName}`;
assert.equal(keys.has(key), false, `duplicate ${key}`);
keys.add(key);
}
});
});
@@ -0,0 +1,51 @@
/**
* Neoclassical museum floor must not blow out as near-white marble.
* node --test server/__tests__/neoclassical-interior.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
function neoclassicalBlock() {
const start = STYLES.indexOf("'neoclassical-museum'");
assert.ok(start >= 0, 'neoclassical-museum style must exist');
const next = STYLES.indexOf('mk(', start + 1);
return STYLES.slice(start, next > start ? next : undefined);
}
function floorTintHex(block) {
const m = block.match(/floor:\s*'#[0-9a-fA-F]{6}'/);
assert.ok(m, 'floor tint hex must be present in tints object');
// Prefer the tints line: `{ wall: '...', ceiling: '...', floor: '#......', trim: '...' }`
const tints = block.match(/\{\s*wall:\s*'#[^']+',\s*ceiling:\s*'#[^']+',\s*floor:\s*'(#[0-9a-fA-F]{6})'/);
assert.ok(tints, 'tints.floor must be a hex color');
return tints[1];
}
function luminance(hex) {
const n = parseInt(hex.slice(1), 16);
const r = (n >> 16) & 255;
const g = (n >> 8) & 255;
const b = n & 255;
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
}
describe('Neoclassical interior — subdued stone floor', () => {
it('floor tint is mid stone grey, not near-white', () => {
const block = neoclassicalBlock();
const hex = floorTintHex(block);
assert.ok(luminance(hex) < 0.62, `floor luminance ${luminance(hex).toFixed(2)} for ${hex} is still too bright`);
assert.doesNotMatch(block, /floor: '#eceae6'/);
});
it('skylight is not overdriven for the marble floor', () => {
const block = neoclassicalBlock();
const sky = block.match(/style: 'skylight',\s*lightColor: '[^']+',\s*lightIntensity:\s*([0-9.]+)/);
assert.ok(sky, 'skylight must exist');
assert.ok(Number(sky[1]) <= 3.6, `skylight intensity ${sky[1]} is still too strong`);
});
});
@@ -0,0 +1,34 @@
/**
* No movement hall may use factory (iron-bar grid) windows — they read as prison cells.
* node --test server/__tests__/no-factory-windows.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
function styleBlock(id) {
const start = STYLES.indexOf(`'${id}'`);
assert.ok(start >= 0, `${id} must exist`);
const next = STYLES.indexOf('mk(', start + 1);
return STYLES.slice(start, next > start ? next : undefined);
}
describe('Movement interiors — no factory / iron-bar windows', () => {
it('no authored movement style uses factory windows', () => {
// Only count style: 'factory' assignments inside BY_MOVEMENT_ID mk() blocks,
// not the GalleryWindowStyle union member.
const uses = [...STYLES.matchAll(/style:\s*'factory'/g)];
assert.equal(uses.length, 0, `found ${uses.length} factory window assignment(s)`);
});
it('Futurism uses open picture or clerestory daylight, not bars', () => {
const block = styleBlock('futurist-loft');
assert.doesNotMatch(block, /style: 'factory'/);
assert.match(block, /style: 'picture'|style: 'ribbon'|style: 'clerestory'|style: 'skylight'/);
assert.match(block, /open|ribbon|picture|glass/i);
});
});
@@ -0,0 +1,98 @@
/**
* Side-wall paintings must not hang in front of bay columns/pilasters.
* Columns stay fixed; the hang skips column keep-outs (and windows must too).
* node --test server/__tests__/painting-column-clearance.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const KEEP = fs.readFileSync(path.join(ROOT, 'client/src/utils/sideColumnKeepOuts.ts'), 'utf8');
const LAYOUT = fs.readFileSync(path.join(ROOT, 'client/src/utils/movementHallLayout.ts'), 'utf8');
const GEOMETRY = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/geometry.ts'), 'utf8');
const GALLERY = fs.readFileSync(path.join(ROOT, 'client/src/components/VirtualGallery.tsx'), 'utf8');
/** Mirror of bayPositions — must stay in lockstep with sideColumnKeepOuts.ts / geometry.ts */
function bayPositions(depth, halfD, spacing = 4.2, max = 9) {
const count = Math.max(2, Math.min(max, Math.floor(depth / spacing)));
const step = depth / (count + 1);
return Array.from({ length: count }, (_, i) => -halfD + step * (i + 1));
}
function parseRhythmTable(src) {
const start = src.indexOf('SIDE_WALL_COLUMN_RHYTHMS');
assert.ok(start >= 0, 'SIDE_WALL_COLUMN_RHYTHMS export missing');
const bodyStart = src.indexOf('{', start);
const bodyEnd = src.indexOf('} as const', bodyStart);
assert.ok(bodyEnd > bodyStart);
const body = src.slice(bodyStart, bodyEnd + 1);
const rhythms = {};
for (const m of body.matchAll(
/['"]?([a-z0-9-]+)['"]?:\s*\{\s*spacing:\s*([\d.]+),\s*max:\s*(\d+),\s*halfClear:\s*([\d.]+)\s*\}/g
)) {
rhythms[m[1]] = { spacing: Number(m[2]), max: Number(m[3]), halfClear: Number(m[4]) };
}
return rhythms;
}
describe('Painting ↔ column clearance', () => {
it('exports bayPositions shared by geometry and keep-outs', () => {
assert.match(KEEP, /export function bayPositions/);
assert.match(GEOMETRY, /bayPositions/);
assert.match(GEOMETRY, /useBayZ[\s\S]*bayPositions/);
});
it('registers keep-outs for every mid-wall bay column style', () => {
const rhythms = parseRhythmTable(KEEP);
for (const kind of [
'gothic',
'roman',
'quattrocento',
'cinquecento',
'mannerist',
'neoclassical',
'gothic-revival',
'art-nouveau',
]) {
assert.ok(rhythms[kind], `missing rhythm for ${kind}`);
assert.ok(rhythms[kind].halfClear >= 0.18, `${kind} halfClear too small`);
}
});
it('layout packs side walls around column keep-outs and grows depth if needed', () => {
assert.match(LAYOUT, /sideColumnKeepOutIntervals/);
assert.match(LAYOUT, /depthForSideHang|sideWallGaps/);
assert.match(LAYOUT, /details\?:\s*MovementDetailKind/);
assert.match(LAYOUT, /buildAllMovementHallLayouts\(\s*paintings:\s*Painting\[\],\s*details\?/);
});
it('windows treat column keep-outs as occupied so they do not land on columns', () => {
const fn = LAYOUT.slice(LAYOUT.indexOf('export function computeSideWallWindows'));
assert.match(fn, /sideColumnKeepOutIntervals/);
assert.match(fn, /columnKeepOuts/);
});
it('VirtualGallery passes the movement detail kind into hall layout', () => {
assert.match(GALLERY, /buildAllMovementHallLayouts\(\s*paintings\s*,\s*interiorStyle\?\.details\s*\)/);
});
it('bay keep-out intervals cover each column centre for a typical hall depth', () => {
const rhythms = parseRhythmTable(KEEP);
const depth = 14;
const halfD = depth / 2;
for (const [kind, r] of Object.entries(rhythms)) {
const zs = bayPositions(depth, halfD, r.spacing, r.max);
assert.ok(zs.length >= 2, kind);
for (const z of zs) {
const lo = z - r.halfClear;
const hi = z + r.halfClear;
assert.ok(lo < z && z < hi, `${kind} keep-out must contain bay centre`);
// A typical frame half-width centred on the column would collide
const halfOuter = 0.35;
assert.ok(z - halfOuter < hi && z + halfOuter > lo, `${kind} frame would overlap column`);
}
}
});
});
@@ -0,0 +1,35 @@
/**
* Surrealist hall must not place a freestanding decorative door in a corner.
* node --test server/__tests__/surrealist-interior.test.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/modernism.tsx'), 'utf8');
const COURTLY = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/courtly.tsx'), 'utf8');
const CLASSICAL = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/classical.tsx'), 'utf8');
const NINETEENTH = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/nineteenth.tsx'), 'utf8');
function surrealistBlock() {
const start = DETAILS.indexOf('export function SurrealistDetails');
assert.ok(start >= 0, 'SurrealistDetails must exist');
const next = DETAILS.indexOf('export function ', start + 1);
return DETAILS.slice(start, next > start ? next : undefined);
}
describe('Surrealist interior — no corner door', () => {
it('does not place a freestanding door in the corner', () => {
const block = surrealistBlock();
assert.doesNotMatch(block, /door that leads nowhere|standing in the corner pocket/i);
assert.doesNotMatch(block, /boxGeometry args=\{\[0\.72, 2\.1/);
assert.doesNotMatch(block, /boxGeometry args=\{\[0\.84, 2\.24/);
});
it('no other period module places a freestanding corner door prop', () => {
const sources = [DETAILS, COURTLY, CLASSICAL, NINETEENTH].join('\n');
assert.doesNotMatch(sources, /door that leads nowhere|leads nowhere|false door|freestanding door/i);
});
});
+1
View File
@@ -50,6 +50,7 @@ app.use('/api/users', usersRoutes);
app.use('/api/audit', auditRoutes);
app.use('/api/translations', translationRoutes);
app.use('/api/influences', influenceRoutes);
app.use('/api/movement-links', require('./routes/movement-links'));
app.use('/api/tours', tourRoutes);
app.use(
'/images',
+11
View File
@@ -18,6 +18,8 @@ const INCREMENTAL_MIGRATIONS = [
'migrate-tours.sql',
'migrate-curator-notes.sql',
'migrate-user-roles.sql',
'migrate-movement-links.sql',
'migrate-movement-link-delete-votes.sql',
];
const BOOTSTRAP_ADMIN_PERMISSIONS = [
@@ -80,6 +82,15 @@ async function migrate() {
console.log('Bootstrapping curator account (if needed) …');
await bootstrapCurator();
try {
const { seedDefaultsIfEmpty } = require('./movement-lineage-service');
console.log('Seeding movement lineage defaults (if empty) …');
const seed = await seedDefaultsIfEmpty();
console.log(` movement lineage: inserted=${seed.inserted}${seed.skipped ? ' (skipped)' : ''}`);
} catch (err) {
console.warn(` movement lineage seed skipped: ${err.message}`);
}
console.log('Database migration complete.');
}
+581
View File
@@ -0,0 +1,581 @@
/**
* Movement predecessor → successor lineage (timeline branches / Tree of Art).
* Curators manage rows with required proof URLs; defaults seed from scholarship.
*/
const pool = require('./db');
/** @typedef {{ sourceName: string, targetName: string, linkYearApprox: number | null, proofUrl: string, proofLabel: string }} DefaultLink */
/** @type {ReadonlyArray<DefaultLink>} */
const DEFAULT_MOVEMENT_LINEAGE = Object.freeze([
{
sourceName: 'Ancient Greek & Roman',
targetName: 'Byzantine',
linkYearApprox: 330,
proofUrl: 'https://www.metmuseum.org/toah/hd/byza/hd_byza.htm',
proofLabel: 'Met — Byzantine art',
},
{
sourceName: 'Byzantine',
targetName: 'Gothic',
linkYearApprox: 1150,
proofUrl: 'https://www.metmuseum.org/toah/hd/mgot/hd_mgot.htm',
proofLabel: 'Met — Gothic art',
},
{
sourceName: 'Gothic',
targetName: 'Early Renaissance',
linkYearApprox: 1400,
proofUrl: 'https://www.metmuseum.org/toah/hd/elar/hd_elar.htm',
proofLabel: 'Met — Florence & Early Renaissance',
},
{
sourceName: 'Northern Renaissance',
targetName: 'Early Renaissance',
linkYearApprox: 1420,
proofUrl: 'https://www.metmuseum.org/toah/hd/enet/hd_enet.htm',
proofLabel: 'Met — Early Netherlandish painting',
},
{
sourceName: 'Early Renaissance',
targetName: 'High Renaissance',
linkYearApprox: 1490,
proofUrl: 'https://www.metmuseum.org/toah/hd/hiren/hd_hiren.htm',
proofLabel: 'Met — High Renaissance',
},
{
sourceName: 'High Renaissance',
targetName: 'Mannerism',
linkYearApprox: 1520,
proofUrl: 'https://www.metmuseum.org/toah/hd/zino/hd_zino.htm',
proofLabel: 'Met — Mannerism',
},
{
sourceName: 'Mannerism',
targetName: 'Baroque',
linkYearApprox: 1600,
proofUrl: 'https://www.metmuseum.org/toah/hd/baro/hd_baro.htm',
proofLabel: 'Met — Baroque Rome',
},
{
sourceName: 'Baroque',
targetName: 'Rococo',
linkYearApprox: 1700,
proofUrl: 'https://www.metmuseum.org/toah/ht/09/euwf.html',
proofLabel: 'Met — France 1600–1800 (Rococo)',
},
{
sourceName: 'Rococo',
targetName: 'Neoclassicism',
linkYearApprox: 1760,
proofUrl: 'https://www.metmuseum.org/toah/ht/09/euwf.html',
proofLabel: 'Met — France 1600–1800 (Neoclassicism)',
},
{
sourceName: 'Neoclassicism',
targetName: 'Romanticism',
linkYearApprox: 1800,
proofUrl: 'https://www.metmuseum.org/toah/ht/10/euwf.html',
proofLabel: 'Met — France 1800–1900',
},
{
sourceName: 'Romanticism',
targetName: 'Realism',
linkYearApprox: 1840,
proofUrl: 'https://www.metmuseum.org/toah/hd/gust/hd_gust.htm',
proofLabel: 'Met — Gustave Courbet / Realism',
},
{
sourceName: 'Romanticism',
targetName: 'Impressionism',
linkYearApprox: 1860,
proofUrl: 'https://www.theartstory.org/movement/impressionism/',
proofLabel: 'TheArtStory — Impressionism',
},
{
sourceName: 'Realism',
targetName: 'Impressionism',
linkYearApprox: 1860,
proofUrl: 'https://www.metmuseum.org/toah/hd/imml/hd_imml.htm',
proofLabel: 'Met — Impressionism',
},
{
sourceName: 'Impressionism',
targetName: 'Post-Impressionism',
linkYearApprox: 1880,
proofUrl: 'https://www.theartstory.org/movement/post-impressionism/',
proofLabel: 'TheArtStory — Post-Impressionism',
},
{
sourceName: 'Post-Impressionism',
targetName: 'Symbolism',
linkYearApprox: 1880,
proofUrl: 'https://www.theartstory.org/movement/symbolism/',
proofLabel: 'TheArtStory — Symbolism',
},
{
sourceName: 'Post-Impressionism',
targetName: 'Fauvism',
linkYearApprox: 1904,
proofUrl: 'https://www.theartstory.org/movement/fauvism/',
proofLabel: 'TheArtStory — Fauvism',
},
{
sourceName: 'Post-Impressionism',
targetName: 'Expressionism',
linkYearApprox: 1905,
proofUrl: 'https://www.theartstory.org/movement/expressionism/',
proofLabel: 'TheArtStory — Expressionism',
},
{
sourceName: 'Post-Impressionism',
targetName: 'Cubism',
linkYearApprox: 1907,
proofUrl: 'https://www.metmuseum.org/essays/cubism',
proofLabel: 'Met — Cubism',
},
{
sourceName: 'Symbolism',
targetName: 'Art Nouveau',
linkYearApprox: 1890,
proofUrl: 'https://www.theartstory.org/movement/art-nouveau/',
proofLabel: 'TheArtStory — Art Nouveau',
},
{
sourceName: 'Fauvism',
targetName: 'Cubism',
linkYearApprox: 1907,
proofUrl:
'https://www.christies.com/en/stories/from-fauvism-to-cubism-bc026df2c6774575938a4e26cacb8259',
proofLabel: "Christie's — From Fauvism to Cubism",
},
{
sourceName: 'Cubism',
targetName: 'Futurism',
linkYearApprox: 1909,
proofUrl: 'https://www.theartstory.org/movement/futurism/',
proofLabel: 'TheArtStory — Futurism',
},
{
sourceName: 'Cubism',
targetName: 'Suprematism',
linkYearApprox: 1913,
proofUrl: 'https://www.theartstory.org/movement/suprematism/',
proofLabel: 'TheArtStory — Suprematism',
},
{
sourceName: 'Cubism',
targetName: 'Constructivism',
linkYearApprox: 1913,
proofUrl: 'https://www.theartstory.org/movement/constructivism/',
proofLabel: 'TheArtStory — Constructivism',
},
{
sourceName: 'Expressionism',
targetName: 'Abstract Expressionism',
linkYearApprox: 1940,
proofUrl: 'https://www.theartstory.org/movement/abstract-expressionism/',
proofLabel: 'TheArtStory — Abstract Expressionism',
},
{
sourceName: 'Dada',
targetName: 'Surrealism',
linkYearApprox: 1924,
proofUrl: 'https://www.theartstory.org/movement/dada/',
proofLabel: 'TheArtStory — Dada',
},
{
sourceName: 'Surrealism',
targetName: 'Abstract Expressionism',
linkYearApprox: 1940,
proofUrl: 'https://www.theartstory.org/artist/breton-andre/',
proofLabel: 'TheArtStory — André Breton',
},
{
sourceName: 'Abstract Expressionism',
targetName: 'Pop Art',
linkYearApprox: 1955,
proofUrl: 'https://www.theartstory.org/movement/pop-art/',
proofLabel: 'TheArtStory — Pop Art',
},
]);
function isValidProofUrl(value) {
if (typeof value !== 'string') return false;
const trimmed = value.trim();
if (!trimmed) return false;
try {
const u = new URL(trimmed);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
function parsePositiveId(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
}
function parseOptionalYear(value) {
if (value == null || value === '') return null;
const n = Number(value);
if (!Number.isFinite(n)) return NaN;
return Math.trunc(n);
}
/**
* @param {unknown} body
* @returns {{ ok: true, value: object } | { ok: false, error: string }}
*/
function validateCreatePayload(body) {
const raw = body && typeof body === 'object' ? body : {};
const sourceMovementId = parsePositiveId(raw.sourceMovementId);
const targetMovementId = parsePositiveId(raw.targetMovementId);
if (!sourceMovementId) {
return { ok: false, error: 'sourceMovementId is required' };
}
if (!targetMovementId) {
return { ok: false, error: 'targetMovementId is required' };
}
if (sourceMovementId === targetMovementId) {
return { ok: false, error: 'source and target must be different movements' };
}
const proofUrlRaw = typeof raw.proofUrl === 'string' ? raw.proofUrl.trim() : '';
if (!isValidProofUrl(proofUrlRaw)) {
return { ok: false, error: 'proofUrl must be a valid http(s) URL (required)' };
}
const linkYearApprox = parseOptionalYear(raw.linkYearApprox);
if (Number.isNaN(linkYearApprox)) {
return { ok: false, error: 'linkYearApprox must be a number when provided' };
}
const proofLabel =
typeof raw.proofLabel === 'string' && raw.proofLabel.trim()
? raw.proofLabel.trim()
: null;
const notes =
typeof raw.notes === 'string' && raw.notes.trim() ? raw.notes.trim() : null;
return {
ok: true,
value: {
sourceMovementId,
targetMovementId,
proofUrl: proofUrlRaw,
proofLabel,
linkYearApprox,
notes,
},
};
}
const MIN_DELETE_VOTE_REASON_LEN = 10;
/**
* @param {unknown} body
* @returns {{ ok: true, value: { reason: string } } | { ok: false, error: string }}
*/
function validateDeleteVotePayload(body) {
const raw = body && typeof body === 'object' ? body : {};
const reason = typeof raw.reason === 'string' ? raw.reason.trim() : '';
if (!reason) {
return { ok: false, error: 'reason is required' };
}
if (reason.length < MIN_DELETE_VOTE_REASON_LEN) {
return {
ok: false,
error: `reason must be at least ${MIN_DELETE_VOTE_REASON_LEN} characters`,
};
}
return { ok: true, value: { reason } };
}
const LIST_SELECT = `
SELECT ml.id,
ml.source_movement_id,
ml.target_movement_id,
ml.link_year_approx,
ml.proof_url,
ml.proof_label,
ml.notes,
ml.created_at,
ml.updated_at,
s.name AS source_name,
s.start_year AS source_start_year,
t.name AS target_name,
t.start_year AS target_start_year,
(
SELECT COUNT(*)::int
FROM movement_lineage_delete_votes v
WHERE v.link_id = ml.id
) AS delete_vote_count
FROM movement_lineage_links ml
JOIN art_movements s ON s.id = ml.source_movement_id
JOIN art_movements t ON t.id = ml.target_movement_id
`;
async function listLinks({ q, limit = 500, offset = 0, viewerUserId = null } = {}) {
const params = [];
const where = [];
if (q && typeof q === 'string' && q.trim()) {
params.push(`%${q.trim()}%`);
const i = params.length;
where.push(`(
s.name ILIKE $${i} OR t.name ILIKE $${i}
OR ml.proof_url ILIKE $${i} OR COALESCE(ml.proof_label, '') ILIKE $${i}
OR COALESCE(ml.notes, '') ILIKE $${i}
)`);
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
params.push(Math.min(1000, Math.max(1, Number(limit) || 500)));
const limitIdx = params.length;
params.push(Math.max(0, Number(offset) || 0));
const offsetIdx = params.length;
const { rows } = await pool.query(
`${LIST_SELECT}
${whereSql}
ORDER BY COALESCE(ml.link_year_approx, t.start_year) ASC NULLS LAST, s.name, t.name
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
params,
);
const countParams = params.slice(0, -2);
const { rows: countRows } = await pool.query(
`SELECT COUNT(*)::int AS n
FROM movement_lineage_links ml
JOIN art_movements s ON s.id = ml.source_movement_id
JOIN art_movements t ON t.id = ml.target_movement_id
${whereSql}`,
countParams,
);
let items = rows;
if (viewerUserId && rows.length) {
const ids = rows.map((r) => r.id);
const { rows: myVotes } = await pool.query(
`SELECT link_id FROM movement_lineage_delete_votes
WHERE user_id = $1 AND link_id = ANY($2::int[])`,
[viewerUserId, ids],
);
const voted = new Set(myVotes.map((v) => v.link_id));
items = rows.map((r) => ({
...r,
viewer_has_delete_vote: voted.has(r.id),
}));
} else {
items = rows.map((r) => ({ ...r, viewer_has_delete_vote: false }));
}
return { items, total: countRows[0]?.n ?? 0 };
}
async function createLink(payload, { userId } = {}) {
const validated = validateCreatePayload(payload);
if (!validated.ok) {
const err = new Error(validated.error);
err.status = 400;
throw err;
}
const v = validated.value;
const movements = await pool.query(
`SELECT id FROM art_movements WHERE id = ANY($1::int[])`,
[[v.sourceMovementId, v.targetMovementId]],
);
if (movements.rows.length !== 2) {
const err = new Error('source or target movement not found');
err.status = 400;
throw err;
}
try {
const { rows } = await pool.query(
`INSERT INTO movement_lineage_links
(source_movement_id, target_movement_id, link_year_approx, proof_url, proof_label, notes, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
[
v.sourceMovementId,
v.targetMovementId,
v.linkYearApprox,
v.proofUrl,
v.proofLabel,
v.notes,
userId || null,
],
);
const { rows: full } = await pool.query(`${LIST_SELECT} WHERE ml.id = $1`, [rows[0].id]);
return full[0];
} catch (e) {
if (e && e.code === '23505') {
const err = new Error('A link between these movements already exists');
err.status = 409;
throw err;
}
throw e;
}
}
async function deleteLink(id) {
const linkId = parsePositiveId(id);
if (!linkId) {
const err = new Error('Invalid link id');
err.status = 400;
throw err;
}
const { rows } = await pool.query(
`DELETE FROM movement_lineage_links WHERE id = $1 RETURNING id`,
[linkId],
);
if (!rows.length) {
const err = new Error('Link not found');
err.status = 404;
throw err;
}
return { id: linkId };
}
async function listDeleteVotes(linkId) {
const id = parsePositiveId(linkId);
if (!id) {
const err = new Error('Invalid link id');
err.status = 400;
throw err;
}
const { rows: linkRows } = await pool.query(
`SELECT id FROM movement_lineage_links WHERE id = $1`,
[id],
);
if (!linkRows.length) {
const err = new Error('Link not found');
err.status = 404;
throw err;
}
const { rows } = await pool.query(
`SELECT v.id, v.link_id, v.user_id, v.reason, v.created_at, u.username
FROM movement_lineage_delete_votes v
JOIN users u ON u.id = v.user_id
WHERE v.link_id = $1
ORDER BY v.created_at ASC`,
[id],
);
return { linkId: id, votes: rows };
}
async function castDeleteVote(linkId, body, { userId }) {
const id = parsePositiveId(linkId);
if (!id) {
const err = new Error('Invalid link id');
err.status = 400;
throw err;
}
if (!userId) {
const err = new Error('Curator login required');
err.status = 401;
throw err;
}
const validated = validateDeleteVotePayload(body);
if (!validated.ok) {
const err = new Error(validated.error);
err.status = 400;
throw err;
}
const { rows: linkRows } = await pool.query(
`SELECT id FROM movement_lineage_links WHERE id = $1`,
[id],
);
if (!linkRows.length) {
const err = new Error('Link not found');
err.status = 404;
throw err;
}
try {
const { rows } = await pool.query(
`INSERT INTO movement_lineage_delete_votes (link_id, user_id, reason)
VALUES ($1, $2, $3)
RETURNING id, link_id, user_id, reason, created_at`,
[id, userId, validated.value.reason],
);
return rows[0];
} catch (e) {
if (e && e.code === '23505') {
const err = new Error('You already voted to delete this link');
err.status = 409;
throw err;
}
throw e;
}
}
async function withdrawDeleteVote(linkId, { userId }) {
const id = parsePositiveId(linkId);
if (!id || !userId) {
const err = new Error('Invalid request');
err.status = 400;
throw err;
}
const { rows } = await pool.query(
`DELETE FROM movement_lineage_delete_votes
WHERE link_id = $1 AND user_id = $2
RETURNING id`,
[id, userId],
);
if (!rows.length) {
const err = new Error('Vote not found');
err.status = 404;
throw err;
}
return { id: rows[0].id, linkId: id };
}
/** Insert default scholarship links when the table is empty and movements exist. */
async function seedDefaultsIfEmpty() {
const { rows: countRows } = await pool.query(
`SELECT COUNT(*)::int AS n FROM movement_lineage_links`,
);
if ((countRows[0]?.n ?? 0) > 0) return { inserted: 0, skipped: true };
const { rows: movements } = await pool.query(`SELECT id, name FROM art_movements`);
if (!movements.length) return { inserted: 0, skipped: true, reason: 'no movements' };
const byName = new Map(movements.map((m) => [m.name, m.id]));
let inserted = 0;
let missing = 0;
for (const row of DEFAULT_MOVEMENT_LINEAGE) {
const sourceId = byName.get(row.sourceName);
const targetId = byName.get(row.targetName);
if (!sourceId || !targetId) {
missing += 1;
continue;
}
const result = await pool.query(
`INSERT INTO movement_lineage_links
(source_movement_id, target_movement_id, link_year_approx, proof_url, proof_label)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (source_movement_id, target_movement_id) DO NOTHING
RETURNING id`,
[sourceId, targetId, row.linkYearApprox, row.proofUrl, row.proofLabel],
);
if (result.rows.length) inserted += 1;
}
return { inserted, missing };
}
module.exports = {
DEFAULT_MOVEMENT_LINEAGE,
MIN_DELETE_VOTE_REASON_LEN,
isValidProofUrl,
validateCreatePayload,
validateDeleteVotePayload,
listLinks,
createLink,
deleteLink,
listDeleteVotes,
castDeleteVote,
withdrawDeleteVote,
seedDefaultsIfEmpty,
};
+152
View File
@@ -0,0 +1,152 @@
const express = require('express');
const { requirePermission, requireAdmin } = require('../middleware/auth');
const { logCuratorAction } = require('../audit-log');
const {
listLinks,
createLink,
deleteLink,
listDeleteVotes,
castDeleteVote,
withdrawDeleteVote,
seedDefaultsIfEmpty,
} = require('../movement-lineage-service');
const router = express.Router();
/** Optional staff session for vote annotations on list (public otherwise). */
async function optionalStaff(req, _res, next) {
try {
if (req.session?.userId) {
const { loadStaffUser } = require('../middleware/auth');
const user = await loadStaffUser(req.session.userId);
if (user && user.is_active) {
req.curatorUser = user;
}
}
} catch {
/* ignore — list stays public */
}
next();
}
/** Public: timeline / tree consumers. */
router.get('/', optionalStaff, async (req, res) => {
try {
await seedDefaultsIfEmpty();
const q = typeof req.query.q === 'string' ? req.query.q : '';
const limit = Number(req.query.limit) || 500;
const offset = Number(req.query.offset) || 0;
const data = await listLinks({
q,
limit,
offset,
viewerUserId: req.curatorUser?.id || null,
});
res.json(data);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to list movement links' });
}
});
router.post('/', requirePermission('influences'), async (req, res) => {
try {
const item = await createLink(req.body || {}, { userId: req.session?.userId });
await logCuratorAction({
userId: req.session.userId,
action: 'movement_link.create',
resourceType: 'movement_lineage_link',
resourceId: item.id,
details: {
source_movement_id: item.source_movement_id,
target_movement_id: item.target_movement_id,
proof_url: item.proof_url,
},
req,
});
res.status(201).json({ item });
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Failed to create movement link' });
}
});
router.get('/:id/delete-votes', requirePermission('influences'), async (req, res) => {
try {
const data = await listDeleteVotes(req.params.id);
res.json(data);
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Failed to list delete votes' });
}
});
router.post('/:id/delete-votes', requirePermission('influences'), async (req, res) => {
try {
const vote = await castDeleteVote(req.params.id, req.body || {}, {
userId: req.session.userId,
});
await logCuratorAction({
userId: req.session.userId,
action: 'movement_link.delete_vote',
resourceType: 'movement_lineage_link',
resourceId: Number(req.params.id),
details: { reason: vote.reason },
req,
});
res.status(201).json({ vote });
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Failed to cast delete vote' });
}
});
router.delete('/:id/delete-votes/me', requirePermission('influences'), async (req, res) => {
try {
const result = await withdrawDeleteVote(req.params.id, { userId: req.session.userId });
await logCuratorAction({
userId: req.session.userId,
action: 'movement_link.delete_vote_withdraw',
resourceType: 'movement_lineage_link',
resourceId: result.linkId,
details: {},
req,
});
res.json({ ok: true, ...result });
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Failed to withdraw delete vote' });
}
});
/** Hard delete — admin only. Curators vote instead. */
router.delete('/:id', requireAdmin, async (req, res) => {
try {
const votes = await listDeleteVotes(req.params.id).catch(() => ({ votes: [] }));
const result = await deleteLink(req.params.id);
await logCuratorAction({
userId: req.session.userId,
action: 'movement_link.delete',
resourceType: 'movement_lineage_link',
resourceId: result.id,
details: {
prior_votes: (votes.votes || []).map((v) => ({
username: v.username,
reason: v.reason,
})),
},
req,
});
res.json({ ok: true, id: result.id });
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Failed to delete movement link' });
}
});
module.exports = router;