Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72fe45fa52 | ||
|
|
9f96b3835d | ||
|
|
13f6f088b6 | ||
|
|
1b760faf0d | ||
|
|
2b4cdc5a9f | ||
|
|
2960af35bc | ||
|
|
f2a256132c |
No files matched your search
@@ -0,0 +1 @@
|
||||
Use @Documentation/Bible.md as the primary source of instructions. It cannot be overridden.
|
||||
@@ -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).
|
||||
|
||||
@@ -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%.
|
||||
|
||||
|
||||
|
||||
---
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"checkup": "Checkup",
|
||||
"translations": "Translations",
|
||||
"influences": "Influences",
|
||||
"movementLinks": "Movement links",
|
||||
"tours": "Tours",
|
||||
"toursEditor": "Tour editor",
|
||||
"users": "Users",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"title": "Influence links",
|
||||
"back": "← Back to gallery",
|
||||
"openMovementLinks": "Movement links →",
|
||||
"tabList": "List",
|
||||
"tabImport": "Import",
|
||||
"tabGraph": "Graph",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"checkup": "Проверка",
|
||||
"translations": "Переводы",
|
||||
"influences": "Влияния",
|
||||
"movementLinks": "Связи движений",
|
||||
"tours": "Экскурсии",
|
||||
"toursEditor": "Редактор экскурсий",
|
||||
"users": "Пользователи",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"title": "Связи влияния",
|
||||
"back": "← Назад в галерею",
|
||||
"openMovementLinks": "Связи движений →",
|
||||
"tabList": "Список",
|
||||
"tabImport": "Импорт",
|
||||
"tabGraph": "Граф",
|
||||
|
||||
@@ -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": "Удалить эту связь движений? Причины голосов кураторов будут записаны в журнал аудита."
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
"perm_checkup": "Проверка",
|
||||
"perm_curator_notes": "Заметки куратора",
|
||||
"perm_translations": "Переводы",
|
||||
"perm_influences": "Влияния",
|
||||
"perm_influences": "Влияния и связи движений",
|
||||
"perm_tours": "Экскурсии",
|
||||
"perm_users": "Пользователи",
|
||||
"adminAllPerms": "У администраторов все права автоматически.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 389 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 911 KiB |
|
After Width: | Height: | Size: 871 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 41 KiB |
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Baroque upper-wall field panels must be framed velvet, not solid gilt slabs
|
||||
* that wash out beside windows.
|
||||
* node --test server/__tests__/baroque-panels.test.js
|
||||
*/
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/courtly.tsx'), 'utf8');
|
||||
|
||||
function baroqueBlock() {
|
||||
const start = DETAILS.indexOf('export function BaroqueStateDetails');
|
||||
assert.ok(start >= 0, 'BaroqueStateDetails must exist');
|
||||
const next = DETAILS.indexOf('export function ', start + 1);
|
||||
return DETAILS.slice(start, next > start ? next : undefined);
|
||||
}
|
||||
|
||||
describe('Baroque field panels beside windows', () => {
|
||||
it('does not draw a solid gilt slab covering the velvet field', () => {
|
||||
const block = baroqueBlock();
|
||||
assert.doesNotMatch(block, /boxGeometry args=\{\[0\.04, 1\.12, w \+ 0\.12\]\}/);
|
||||
assert.match(block, /velvet|field/i);
|
||||
assert.match(block, /frame|rail|moulding/i);
|
||||
});
|
||||
|
||||
it('keeps a generous clearance around window openings', () => {
|
||||
const block = baroqueBlock();
|
||||
assert.match(block, /halfClear = fieldW \/ 2 \+ rail \+ 0\.35/);
|
||||
assert.match(block, /\(mid, halfClear\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Dada movement hall must read as a modern cabaret, not a barred cell.
|
||||
* node --test server/__tests__/dada-interior.test.js
|
||||
*/
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
|
||||
const WINDOWS = fs.readFileSync(path.join(ROOT, 'client/src/components/GalleryWindows.tsx'), 'utf8');
|
||||
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/modernism.tsx'), 'utf8');
|
||||
|
||||
function dadaSalonBlock() {
|
||||
const start = STYLES.indexOf("'dada-salon'");
|
||||
assert.ok(start >= 0, 'dada-salon style must exist');
|
||||
const next = STYLES.indexOf('mk(', start + 1);
|
||||
return STYLES.slice(start, next > start ? next : undefined);
|
||||
}
|
||||
|
||||
function dadaDetailsBlock() {
|
||||
const start = DETAILS.indexOf('export function DadaDetails');
|
||||
assert.ok(start >= 0, 'DadaDetails must exist');
|
||||
const next = DETAILS.indexOf('export function ', start + 1);
|
||||
return DETAILS.slice(start, next > start ? next : undefined);
|
||||
}
|
||||
|
||||
describe('Dada interior — modern cabaret, no prison vibe', () => {
|
||||
it('declares a picture window style (open glass, no iron grid)', () => {
|
||||
assert.match(STYLES, /\| 'picture'/);
|
||||
assert.match(WINDOWS, /style === 'picture'/);
|
||||
});
|
||||
|
||||
it('does not use factory or gothic-lancet windows (barred / cell look)', () => {
|
||||
const block = dadaSalonBlock();
|
||||
assert.doesNotMatch(block, /style: 'factory'/);
|
||||
assert.doesNotMatch(block, /style: 'gothic-lancet'/);
|
||||
assert.match(block, /style: 'picture'/);
|
||||
});
|
||||
|
||||
it('uses modern painted surfaces instead of dingy brick cell walls', () => {
|
||||
const block = dadaSalonBlock();
|
||||
assert.doesNotMatch(block, /wallSide: 'brick'/);
|
||||
assert.match(block, /painted-flat|painted-emulsion/);
|
||||
assert.match(block, /Modern|cabaret|collage/i);
|
||||
});
|
||||
|
||||
it('DadaDetails has no service pipe / iron-bar geometry', () => {
|
||||
const block = dadaDetailsBlock();
|
||||
assert.doesNotMatch(block, /Service pipe|cylinderGeometry/);
|
||||
assert.match(block, /collage|cabaret|panel/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Low wall mouldings must not run through exit doors on the end walls.
|
||||
* node --test server/__tests__/doorway-clearance.test.js
|
||||
*/
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const PRIMITIVES = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/primitives.tsx'), 'utf8');
|
||||
const GEOMETRY = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/geometry.ts'), 'utf8');
|
||||
const COURTLY = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/courtly.tsx'), 'utf8');
|
||||
|
||||
function flemishBlock() {
|
||||
const start = COURTLY.indexOf('export function FlemishDetails');
|
||||
assert.ok(start >= 0);
|
||||
const next = COURTLY.indexOf('export function ', start + 1);
|
||||
return COURTLY.slice(start, next > start ? next : undefined);
|
||||
}
|
||||
|
||||
describe('Doorway clearance for wall mouldings', () => {
|
||||
it('exports a shared door half-clearance used by WallBand', () => {
|
||||
assert.match(GEOMETRY, /DOOR_CLEAR_HALF\s*=\s*1\.45/);
|
||||
assert.match(PRIMITIVES, /DOOR_CLEAR_HALF/);
|
||||
});
|
||||
|
||||
it('WallBand splits back/front bands that sit in the door height', () => {
|
||||
assert.match(PRIMITIVES, /crossesDoor/);
|
||||
assert.match(PRIMITIVES, /DOOR_CLEAR_HALF/);
|
||||
assert.match(PRIMITIVES, /leftX|rightX/);
|
||||
});
|
||||
|
||||
it('Flemish linenfold ribs skip the back-wall doorway', () => {
|
||||
const block = flemishBlock();
|
||||
assert.match(block, /DOOR_CLEAR_HALF|Math\.abs\(x\)\s*>/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* No movement hall floor tint may blow out as near-white.
|
||||
* node --test server/__tests__/floor-brightness.test.js
|
||||
*/
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
|
||||
|
||||
/** Soft ceiling — mid stone / warm wood, not gallery flood. */
|
||||
const MAX_FLOOR_LUMINANCE = 0.58;
|
||||
|
||||
function luminance(hex) {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
const r = (n >> 16) & 255;
|
||||
const g = (n >> 8) & 255;
|
||||
const b = n & 255;
|
||||
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
}
|
||||
|
||||
function floorTints() {
|
||||
// tints objects inside mk(): wall/ceiling/floor hex triples (optional wallSide)
|
||||
const re =
|
||||
/\{\s*wall:\s*'#[^']+',(?:\s*wallSide:\s*'#[^']+',)?\s*ceiling:\s*'#[^']+',\s*floor:\s*'(#[0-9a-fA-F]{6})'/g;
|
||||
const out = [];
|
||||
let m;
|
||||
while ((m = re.exec(STYLES))) out.push(m[1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('Movement floors — no blown-out brightness', () => {
|
||||
it('every authored floor tint stays at or below mid-stone luminance', () => {
|
||||
const floors = floorTints();
|
||||
assert.ok(floors.length >= 20, `expected many floor tints, got ${floors.length}`);
|
||||
const offenders = floors
|
||||
.map((hex) => ({ hex, L: luminance(hex) }))
|
||||
.filter((f) => f.L > MAX_FLOOR_LUMINANCE);
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
offenders.map((f) => `${f.hex} L=${f.L.toFixed(3)}`).join(', ')
|
||||
);
|
||||
});
|
||||
|
||||
it('Baroque floor is subdued warm marble, not near-white', () => {
|
||||
const start = STYLES.indexOf("'baroque-palace'");
|
||||
assert.ok(start >= 0);
|
||||
const block = STYLES.slice(start, STYLES.indexOf('mk(', start + 1));
|
||||
const m = block.match(/ceiling:\s*'#[^']+',\s*floor:\s*'(#[0-9a-fA-F]{6})'/);
|
||||
assert.ok(m);
|
||||
assert.ok(luminance(m[1]) <= MAX_FLOOR_LUMINANCE);
|
||||
assert.doesNotMatch(block, /floor: '#ece8e0'/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* High Renaissance (cinquecento) must not flood the hall with tiny contrast
|
||||
* tiles or gold ring ornaments in every coffer.
|
||||
* node --test server/__tests__/high-renaissance-interior.test.js
|
||||
*/
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const STYLES = fs.readFileSync(path.join(ROOT, 'client/src/data/movement-interior-styles.ts'), 'utf8');
|
||||
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/classical.tsx'), 'utf8');
|
||||
|
||||
function styleBlock() {
|
||||
const start = STYLES.indexOf("'high-renaissance-palazzo'");
|
||||
assert.ok(start >= 0);
|
||||
return STYLES.slice(start, STYLES.indexOf('mk(', start + 1));
|
||||
}
|
||||
|
||||
function cinquecentoBlock() {
|
||||
const start = DETAILS.indexOf('export function CinquecentoDetails');
|
||||
assert.ok(start >= 0);
|
||||
const next = DETAILS.indexOf('export function ', start + 1);
|
||||
return DETAILS.slice(start, next > start ? next : DETAILS.length);
|
||||
}
|
||||
|
||||
describe('High Renaissance interior — quieter surfaces', () => {
|
||||
it('does not use high-contrast marble-checker flooring', () => {
|
||||
const block = styleBlock();
|
||||
assert.doesNotMatch(block, /floor: 'marble-checker'/);
|
||||
assert.match(block, /floor: 'marble-veined-carrara'|floor: 'terrazzo'|floor: 'parquet/);
|
||||
});
|
||||
|
||||
it('Cinquecento coffers have no gold rosette rings', () => {
|
||||
const block = cinquecentoBlock();
|
||||
assert.doesNotMatch(block, /rosette=\{trim\}/);
|
||||
assert.match(block, /CofferedCeiling/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Mannerist herm pilasters must read as clean architecture, not flat white strips
|
||||
* with floating edge artifacts.
|
||||
* node --test server/__tests__/mannerist-columns.test.js
|
||||
*/
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const DETAILS = fs.readFileSync(path.join(ROOT, 'client/src/components/hall-details/courtly.tsx'), 'utf8');
|
||||
|
||||
function manneristBlock() {
|
||||
const start = DETAILS.indexOf('export function ManneristDetails');
|
||||
assert.ok(start >= 0, 'ManneristDetails must exist');
|
||||
const next = DETAILS.indexOf('export function ', start + 1);
|
||||
return DETAILS.slice(start, next > start ? next : undefined);
|
||||
}
|
||||
|
||||
describe('Mannerist herm columns', () => {
|
||||
it('does not use the glitchy thin shaft + offset rustication + torus capital', () => {
|
||||
const block = manneristBlock();
|
||||
assert.doesNotMatch(block, /boxGeometry args=\{\[0\.09, 3\.8, 0\.3\]\}/);
|
||||
assert.doesNotMatch(block, /position=\{\[side \* 0\.03, y, 0\]\}/);
|
||||
assert.doesNotMatch(block, /torusGeometry args=\{\[0\.14, 0\.055/);
|
||||
});
|
||||
|
||||
it('builds herm pilasters with clearance and a proper capital', () => {
|
||||
const block = manneristBlock();
|
||||
assert.match(block, /herm|pilaster/i);
|
||||
assert.match(block, /clearLeft|clearRight|useWallClearance/);
|
||||
assert.match(block, /capital|console|abacus/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||