Add production deployment, photorealistic movement walls, and fix duplicate influence links.

Configure hosting for gallery.mysuperlab.netcraze.pro and LAN access, enhance movement gallery textures with period-appropriate painted materials, dedupe influenced-by API responses via painting_influence_sources, and refresh several painting image files.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-06-21 23:00:13 +03:00
co-authored by Cursor
parent df29848d89
commit 4eead54062
161 changed files with 668 additions and 187 deletions
+7 -1
View File
@@ -3,7 +3,13 @@ DB_PORT=5432
DB_USER=gallery
DB_PASSWORD=
DB_NAME=Gallery
PORT=3001
# Production: LAN http://192.168.10.70:3520 and reverse-proxy at gallery.mysuperlab.netcraze.pro
PORT=3520
HOST=0.0.0.0
PUBLIC_URL=http://gallery.mysuperlab.netcraze.pro
TRUST_PROXY=true
IMAGE_DIR=./data/images
# Optional — enable extra museum search in fetch-missing-images / search-missing-paintings
+11 -7
View File
@@ -1,9 +1,11 @@
# Art Gallery — REST API
Base URL in development:
Base URL:
- **Direct:** `http://localhost:3001`
- **Via Vite proxy:** `http://localhost:5173` (same paths)
- **Production (public):** `http://gallery.mysuperlab.netcraze.pro`
- **Production (LAN):** `http://192.168.10.70:3520`
- **Development (direct):** `http://localhost:3520` or `http://localhost:3001` depending on `PORT` in `.env`
- **Development (Vite proxy):** `http://localhost:5173` (same paths)
All JSON responses use `Content-Type: application/json`. Errors return `{ "error": "message" }` with an appropriate HTTP status.
@@ -262,12 +264,12 @@ Max decoded size 15 MB (JSON body limit 20 MB on the server). **Response** — s
## `GET /api/artists/:id/navigation`
Related artists for hall-to-hall navigation at the exit doorway. Derived from `painting_influences`:
Related artists for hall-to-hall navigation at the exit doorway. Derived from **`painting_influence_sources`**:
- **Predecessors** — artists whose works influenced this artists paintings.
- **Successors** — artists whose works were influenced by this artists paintings.
- **Predecessors** — artists whose works (or artist records) influenced this artists paintings.
- **Successors** — artists whose paintings were influenced by this artists works (via painting sources), or whose paintings cite this artist as a source (via artist sources).
Both lists are grouped by art movement and exclude the current artist.
Both lists are grouped by art movement and exclude the current artist. Each artist appears once even when multiple influence edges exist.
**Response**
@@ -357,6 +359,8 @@ Painting detail with influence graph neighbours.
`painting.has_influence_links` matches the flag on `GET /api/artists/:id` paintings (see above).
Both **`influencedBy`** and **`influenced`** are read from **`painting_influence_sources`** only (one row per edge). Painting-type sources appear in *Influenced By* when they are predecessors; in *Influenced* when this painting is the source of a later work. Artist and movement sources appear only in *Influenced By*. The legacy `painting_influences` table is not merged into these responses — it mirrors painting edges for scripts only.
---
## `GET /api/paintings/:id/image`
+2 -2
View File
@@ -147,7 +147,7 @@ Directed edges: *this painting* was influenced by *that painting*.
Unique constraint on `(painting_id, influenced_by_painting_id)`.
Used by the 3D gallery exit panel: predecessors are artists of `influenced_by_painting_id` works; successors are artists of paintings influenced by this artists works (see [API.md](API.md#get-apiartistsidnavigation)). Legacy table; new polymorphic links live in `painting_influence_sources`.
**Legacy mirror table.** `npm run update-influences` still inserts painting-to-painting rows here when curating data. The same edges are copied into `painting_influence_sources`. The **REST API does not read this table** for painting detail or hall navigation — use `painting_influence_sources` as the source of truth for display.
### `painting_influence_sources`
@@ -175,7 +175,7 @@ Polymorphic influence links: *this painting* was influenced by a **painting**, *
Unique index on `(painting_id, source_type, source_painting_id, source_artist_id, source_movement_id)` with COALESCE for null FKs.
Used by painting detail API (`influencedBy`). Painting-type rows also feed `has_influence_links` and hall navigation (with legacy `painting_influences`).
**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`.
## Indexes
+13 -7
View File
@@ -58,9 +58,11 @@ Gallery/
│ ├── famous-paintings-data.js
│ ├── fetch-missing-images.js
│ ├── find-duplicate-paintings.js
│ ├── audit-influence-duplicates.js
│ └── image-fetcher.js
├── data/images/ # Local portraits and paintings (+ thumbs/)
├── db/ # SQL schema and migrations (when present)
├── deploy/ # Production nginx + systemd examples
├── Documentation/ # This folder
└── .env # DB and port config (not committed)
```
@@ -70,16 +72,19 @@ Gallery/
### Production-style (single process)
```bash
npm run server # http://localhost:3001
npm run start:prod # build client + serve on PORT (default 3520)
# or: npm run build && npm run start
```
Serves `/api/*`, `/images/*`, and the built SPA from `client/dist` if it exists.
**Deployed URLs:** public http://gallery.mysuperlab.netcraze.pro · LAN http://192.168.10.70:3520 — see [setup.md](setup.md#production-deployment).
### Development (two processes)
```bash
npm run dev:server # API on :3001
npm run dev:client # Vite on :5173, proxies /api and /images
npm run dev:server # API on PORT from .env (3520 production, 3001 typical dev)
npm run dev:client # Vite on :5173, proxies /api and /images to PORT
```
Use the Vite URL during frontend work for HMR.
@@ -181,7 +186,7 @@ Each artist appears as a **portrait circle** on their movements stream row:
Artist portraits stop wheel/drag propagation so zooming over a face does not fight portrait clicks. Hovering a portrait highlights the artists 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 (`painting_influence_sources`, plus legacy `painting_influences` for hall navigation) are separate and drive the 3D exit picker and detail panels.
**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.
## Virtual gallery (3D halls)
@@ -222,7 +227,7 @@ Each artist has **exactly one hall**. The hall is a rectangular room sized to fi
| Click painting | Open detail view |
| Exit doorway / `E` / **Exit →** header button | Open path picker |
Predecessors and successors come from the **painting influence graph** (`painting_influences` → other artists). Empty lists mean no influence edges are recorded yet for that artist — run `npm run update-influences` or extend seed data.
Predecessors and successors come from **`painting_influence_sources`** (painting and artist sources). Empty lists mean no influence edges are recorded yet for that artist — run `npm run update-influences` or extend `art-influences-data.js`.
### Movement galleries
@@ -236,7 +241,8 @@ Enter from the home page by clicking a **movement name** on the movement flow (`
| Wall order | Along each side wall: **later works on the left**, **earlier on the right** (same convention as artist halls) |
| 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, NYC loft, white cube, etc.) |
| Textures | Hi-res procedural wall/floor/ceiling maps with normal maps (`galleryProceduralTextures.ts`) |
| Wall materials | Hi-res **procedural textures** with normal maps (`galleryProceduralTextures.ts`): real-world stone, marble, wood panelling, brick, velvet, stucco — 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 |
| Textures | Wall/floor/ceiling maps applied via `useTexturedMaterial`; movement **tints** drive painted-wall hue |
| Windows | **Side walls only** — placed in gaps between frames (high on the wall, no overlap with paintings); style matches the movement era |
| Lighting | Daylight from windows + ceiling track lights + ambient/sun fill |
| Back wall | **Exit double doors****Wing navigator** (jump to any wing) or **Exit to Timeline** |
@@ -301,7 +307,7 @@ When **Debug mode** is enabled from the home header, painting detail and artist
- **Movement galleries** complement artist halls: full movement corpus in period-themed wings, entered from the flow diagram.
- **Influence-based hall links** connect artists through documented painting relationships, grouped by movement at the exit.
- **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering.
- **Influence data** is stored as directed links from paintings to sources (another painting, an artist, or a movement), with optional period fields and citation metadata (source author, quote, URL).
- **Influence data** is stored in **`painting_influence_sources`** (directed links from paintings to source paintings, artists, or movements), with optional period fields and citation metadata. Legacy `painting_influences` mirrors painting-to-painting edges for scripts only.
## Developer tools (image audit)
+15 -5
View File
@@ -58,9 +58,9 @@ migrate → seed → fetch-artist-bios → expand-catalog → update-influences
2. For each curated **artist**:
- Creates **artist periods** and **paintings**.
- May download portraits and painting images (depending on seed script version).
3. Writes **painting_influences** edges from curated scholarship references.
3. Writes **painting_influences** edges from curated scholarship references (mirrored into `painting_influence_sources` when you run `npm run migrate:influence-sources` and `npm run update-influences`).
Those influence edges power **3D hall navigation**: predecessors and successors at each artists exit doorway are computed from this table (see `GET /api/artists/:id/navigation` in [API.md](API.md)).
Those influence edges power **3D hall navigation** and painting detail panels via **`painting_influence_sources`** (see `GET /api/artists/:id/navigation` and `GET /api/paintings/:id` in [API.md](API.md)).
Artists are grouped by movement and century; the seed list targets at most ~100 artists per century.
@@ -117,14 +117,24 @@ Renaissance and medieval masters with large museum catalog dumps (e.g. Raphael,
Directed influence links are stored in **`painting_influence_sources`**. Each row connects a painting to a **source** of type `painting`, `artist`, or `movement`, with optional period context (e.g. influence during the works creation year).
The legacy **`painting_influences`** table (painting-to-painting only) is still written for hall navigation compatibility and is backfilled into `painting_influence_sources` on migration.
The legacy **`painting_influences`** table (painting-to-painting only) is still written alongside sources when running `npm run update-influences` — it keeps script compatibility and matches the backfill migration. **The API reads only `painting_influence_sources`**, so each edge appears once in the UI.
Influence data drives:
- **Painting detail** — *Influenced By* (left) and *Influenced* (right) panels: painting thumbnails, artist portraits, or movement colour swatches, plus notes, aspects, period labels, and citations
- **3D hall exit** — predecessor and successor artists grouped by movement (painting edges only)
- **3D hall exit** — predecessor and successor artists grouped by movement (from painting and artist sources)
- **3D gallery lamps** — golden picture light above frames with any influence edge (`has_influence_links` on painting API responses)
### Audit duplicate influence links
Painting-to-painting edges exist in both tables by design. To confirm the database has no stray duplicates and that the API model is clean:
```bash
npm run audit-influence-duplicates
```
Reports: edges present in both tables, duplicate rows within either table (should be 0), and legacy-only / sources-only mismatches. If *Influenced* ever shows the same successor twice, restart the server after pulling API fixes — responses must not union legacy and sources tables.
### One-time migration
```bash
@@ -310,7 +320,7 @@ These live in `client/public/` (and `client/dist/` after build).
3. Run `npm run fetch-artist-bios` for the new artists biography.
4. Add entries to `famous-paintings-data.js` and run `npm run expand-catalog` if needed.
5. Run `npm run fetch-images -- --artist="…"` or rely on preload / on-demand sync.
6. Add influence rows to `painting_influences` with citation fields where possible.
6. Add influence rows via `npm run update-influences` / `art-influences-data.js` (writes both `painting_influence_sources` and legacy painting edges).
## Image fetcher overrides
+59 -7
View File
@@ -21,7 +21,10 @@ cp .env.example .env
| `DB_USER` | Database user |
| `DB_PASSWORD` | Database password |
| `DB_NAME` | Database name (`Gallery`) |
| `PORT` | API listen port (default `3001`) |
| `PORT` | API listen port (default `3001`; production uses `3520`) |
| `HOST` | Bind address (default `0.0.0.0` — required for LAN access) |
| `PUBLIC_URL` | Optional public URL shown at startup (e.g. `http://gallery.mysuperlab.netcraze.pro`) |
| `TRUST_PROXY` | Set to `true` when behind nginx/reverse proxy (honours `X-Forwarded-*`) |
| `IMAGE_DIR` | Root for cached images (default `./data/images`) |
Optional script tuning:
@@ -80,19 +83,65 @@ Image fetch can take hours if you run it for the entire catalog. The first line
| Command | Purpose |
|---------|---------|
| `npm run server` | API + static SPA on `PORT` |
| `npm run build` | Build production SPA into `client/dist` |
| `npm run start` | API + static SPA on `HOST`:`PORT` |
| `npm run start:prod` | Build client, then start server |
| `npm run server` | Alias for `start` |
| `npm run dev:server` | API with nodemon reload |
| `npm run dev:client` | Vite dev server on :5173 |
| `npm run dev` | Alias for `server` |
| `npm run dev` | Alias for `start` |
**Production frontend:** build the client, then start the server:
```bash
cd client && npm run build && cd ..
npm run server
npm run build
# or: cd client && npm run build && cd ..
npm run start
```
Open http://localhost:3001 (or your configured `PORT`).
Open http://localhost:3520 (or your configured `HOST`/`PORT`).
## Production deployment
This install is intended to run at:
| Access | URL |
|--------|-----|
| Public (reverse proxy) | http://gallery.mysuperlab.netcraze.pro |
| LAN direct | http://192.168.10.70:3520 |
### 1. Configure `.env`
Copy `.env.example``.env` and set at least:
```env
PORT=3520
HOST=0.0.0.0
PUBLIC_URL=http://gallery.mysuperlab.netcraze.pro
TRUST_PROXY=true
```
`HOST=0.0.0.0` lets the app accept connections on the machines LAN IP (`192.168.10.70`). The client uses relative `/api` and `/images` paths, so no frontend URL changes are needed.
### 2. Build and start
```bash
npm install
cd client && npm install && cd ..
npm run start:prod
```
Or use the systemd unit in [`deploy/gallery.service`](../deploy/gallery.service) (adjust `User`, `WorkingDirectory`, and `EnvironmentFile`).
### 3. Reverse proxy (public domain)
Point `gallery.mysuperlab.netcraze.pro` at the host running the app. Example nginx config: [`deploy/nginx-gallery.conf`](../deploy/nginx-gallery.conf).
The proxy forwards to `127.0.0.1:3520`. Keep `TRUST_PROXY=true` in `.env` so Express sees the correct client IP and scheme.
### 4. Firewall
Allow inbound **TCP 3520** on the gallery host if clients reach it directly on the LAN (`192.168.10.70:3520`). The public hostname only needs **80/443** on the reverse-proxy host.
## Maintenance scripts
@@ -123,6 +172,7 @@ Open http://localhost:3001 (or your configured `PORT`).
| `npm run update-painting-annotations -- --wikipedia` | ↑ | Add intro sentences from each works Wikipedia page |
| `npm run update-painting-annotations -- --wikipedia --wiki-delay=3000` | ↑ | Slower Wikipedia pass when rate-limited (429) |
| `npm run find-duplicates` | `scripts/find-duplicate-paintings.js` | Report duplicate and near-duplicate painting rows |
| `npm run audit-influence-duplicates` | `scripts/audit-influence-duplicates.js` | Report mirrored legacy/sources edges and duplicate influence rows |
| `npm run update-influences` | `scripts/update-influences.js` | Insert influence links (painting / artist / movement) from `art-influences-data.js` |
| `npm run update-influences -- --fetch-images` | ↑ | Also download images for newly created works |
| `npm run update-influences -- --discover` | ↑ | Curated pass + web discovery (Wikipedia, Wikidata, Met, art-history sites) |
@@ -139,6 +189,7 @@ These are checked in and maintained:
- `influence-discovery.js` + `influence-resolver.js` — web discovery and polymorphic source resolution
- `fetch-missing-images.js` — batch image backfill
- `find-duplicate-paintings.js` — duplicate catalog audit
- `audit-influence-duplicates.js` — influence graph duplicate / mirror audit
- `migrate-checkup-flags.js` — checkup workflow columns (paintings)
- `migrate-artist-checkup-flags.js` — checkup workflow columns (artist portraits)
- `migrate-painting-annotations.js`, `update-painting-annotations.js`, `painting-annotations-data.js` — art-history notes on painting detail
@@ -171,8 +222,9 @@ After clone: copy `.env.example` → `.env`, install dependencies, run `npm run
| White/grey flicker on frames | Texture loading or z-fighting with wall | Rebuild client (`cd client && npm run build`); ensure latest `VirtualGallery.tsx` |
| Wrong painting in 3D gallery frame | Stale or mismatched thumbnail file | `npm run regenerate-thumbnails`; gallery prefers full `image_path` |
| Wrong painting image (fetch) | Bad museum / search match on first download | Add entry to `DIRECT_IMAGE_OVERRIDES` in `scripts/image-fetcher.js`, re-fetch file |
| Empty exit navigation lists | No `painting_influences` edges for artist | `npm run update-influences` |
| Empty exit navigation lists | No influence edges for artist | `npm run update-influences`; check `painting_influence_sources` |
| Empty *Influenced By* / *Influenced* on painting detail | No edges for that work | `npm run migrate:influence-sources` then `npm run update-influences`; extend `art-influences-data.js` or run `--discover` |
| Same work listed twice under *Influenced* | Stale server merging legacy + sources tables | Restart server; API reads `painting_influence_sources` only — run `npm run audit-influence-duplicates` to verify DB |
| No golden lamps above frames in 3D hall | Stale API process or no influence edges | Restart server after API changes; run `npm run update-influences` |
| Default Vite page instead of gallery | `client/dist` missing or stale | `cd client && npm run build` |
| Permission denied creating tables | `gallery` user lacks CREATE | Run admin grants, then migrate |
+8 -5
View File
@@ -1,6 +1,6 @@
# Art Gallery
Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — period-themed wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**), Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies.
Interactive virtual art gallery: zoomable historical timeline with era click-to-zoom, major event markers (vertical guides into the movement flow), branching art-movement streams (click a movement name to enter its **3D movement gallery** — photorealistic period interiors with painted walls, stone, and wood textures; chronological wings with up to ~55 works each, side-wall windows, wing navigator), one 3D hall per artist (parquet floor, movement-tinted walls, black/gold frames by review status, corridor layout for large catalogs, museum-style exit doors, golden influence lamps, canvas placeholders for missing works, influence-linked exits), painting detail with art-history annotations, prev/next catalog browsing and fullscreen lightbox, debug-mode image audit on painting detail and artist bio (**Checked** / **Fix it** / **More** / **Clear** / **Upload**), Checkup page, preserved gallery camera on return, and Wikipedia-sourced artist biographies.
## Documentation
@@ -51,17 +51,20 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to-
5. Build the client and start the server:
```bash
cd client && npm run build && cd ..
npm run server
npm run start:prod
```
Open http://localhost:3001
**Production URLs:**
- Public: http://gallery.mysuperlab.netcraze.pro (via reverse proxy)
- LAN: http://192.168.10.70:3520
See [Documentation/setup.md](Documentation/setup.md#production-deployment) for nginx/systemd configs in `deploy/`.
## Development
Run API and Vite dev server separately:
```bash
npm run dev:server # http://localhost:3001
npm run dev:server # API — PORT from .env (3520 or 3001)
npm run dev:client # http://localhost:5173 (proxies /api and /images)
```
+1
View File
@@ -1165,6 +1165,7 @@ function GalleryFloor({
<meshStandardMaterial
map={texture.map}
normalMap={texture.normalMap}
normalScale={new THREE.Vector2(texture.normalScale, texture.normalScale)}
roughness={texture.roughness}
metalness={texture.metalness}
envMapIntensity={0.65}
+30 -30
View File
@@ -160,8 +160,8 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
30: mk(
'early-renaissance-palazzo',
'Florentine palazzo',
'Stucco salone · terracotta accents · arched windows',
{ wall: 'stucco-cream', ceiling: 'fresco-worn', floor: 'terracotta-tiles' },
'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' },
{
details: 'palazzo',
@@ -249,8 +249,8 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
35: mk(
'rococo-salon',
'Rococo salon',
'Pastel stucco · gilt trim · chevron parquet',
{ wall: 'pastel-plaster', ceiling: 'silk-pale', floor: 'parquet-chevrons' },
'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' },
{
details: 'salon',
@@ -266,8 +266,8 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
36: mk(
'neoclassical-museum',
'Neoclassical museum',
'White marble · coffered ceiling · skylit salon',
{ wall: 'marble-white', ceiling: 'plaster-white', floor: 'marble-veined-carrara' },
'White painted walls · coffered ceiling · skylit salon',
{ wall: 'painted-flat', ceiling: 'plaster-white', floor: 'marble-veined-carrara' },
{ wall: '#f2f0ec', ceiling: '#fafafa', floor: '#eceae6', trim: '#b8b0a4' },
{
details: 'neoclassical',
@@ -302,8 +302,8 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
38: mk(
'realist-bourgeois',
'Realist picture gallery',
'Warm plaster · bourgeois salon · oak parquet',
{ wall: 'plaster-warm', ceiling: 'plaster-warm', floor: 'parquet-herringbone' },
'Warm painted walls · bourgeois salon · oak parquet',
{ wall: 'painted-emulsion', ceiling: 'painted-emulsion', floor: 'parquet-herringbone' },
{ wall: '#e8e2d8', ceiling: '#f5f0e8', floor: '#a08060', trim: '#8a7050' },
{
details: 'salon',
@@ -316,8 +316,8 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
39: mk(
'impressionist-salon',
'Impressionist salon',
'North-light skylight · pale walls · herringbone floor',
{ wall: 'plaster-warm', ceiling: 'plaster-white', floor: 'parquet-herringbone' },
'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' },
{
details: 'salon',
@@ -333,8 +333,8 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
40: mk(
'post-impressionist-atelier',
'Montmartre atelier',
'Studio walls · large north window · worn floorboards',
{ wall: 'plaster-warm', ceiling: 'plaster-warm', floor: 'parquet-herringbone' },
'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' },
{
details: 'atelier',
@@ -347,8 +347,8 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
41: mk(
'symbolist-chamber',
'Symbolist chamber',
'Deep emerald walls · amber window glow · dark parquet',
{ wall: 'velvet-emerald', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
'Deep green painted walls · amber window glow · dark parquet',
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#1a4030', ceiling: '#1a1814', floor: '#4a3828', trim: '#8a7050' },
{
details: 'salon',
@@ -365,8 +365,8 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
42: mk(
'art-nouveau-salon',
'Art Nouveau salon',
'Organic plaster · stained glass · terrazzo floor',
{ wall: 'silk-pale', ceiling: 'silk-gold', floor: 'terrazzo' },
'Painted plaster · stained glass · terrazzo floor',
{ wall: 'painted-oil-satin', ceiling: 'silk-gold', floor: 'terrazzo' },
{ wall: '#f0ece4', ceiling: '#d8c890', floor: '#d8d0c4', trim: '#6a9868' },
{
details: 'salon',
@@ -381,8 +381,8 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
43: mk(
'fauvist-studio',
'Fauvist studio',
'Bold warm plaster · flooded with color and light',
{ wall: 'stucco-terracotta', wallSide: 'silk-gold', ceiling: 'plaster-warm', floor: 'parquet-herringbone' },
'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' },
{
details: 'atelier',
@@ -413,8 +413,8 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
45: mk(
'cubist-studio',
'Cubist studio',
'Paris atelier · factory windows · raw plaster',
{ wall: 'plaster-warm', ceiling: 'plaster-white', floor: 'parquet-herringbone' },
'Paris atelier · factory windows · painted plaster',
{ wall: 'painted-emulsion', ceiling: 'painted-flat', floor: 'parquet-herringbone' },
{ wall: '#e8e4dc', ceiling: '#f5f5f5', floor: '#9a8870', trim: '#888888' },
{
details: 'atelier',
@@ -447,8 +447,8 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
47: mk(
'suprematist-gallery',
'Suprematist white cube',
'Pure white volume · geometric light · polished floor',
{ wall: 'white-plaster', ceiling: 'white-plaster', floor: 'concrete-polished' },
'Matte white paint · geometric light · polished floor',
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#e0e0e0', trim: '#cccccc' },
{
details: 'modern',
@@ -485,8 +485,8 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
49: mk(
'dada-salon',
'Dada salon',
'Eclectic bourgeois room · mismatched light sources',
{ wall: 'plaster-warm', wallSide: 'brick', ceiling: 'plaster-warm', floor: 'parquet-herringbone' },
'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' },
{
details: 'salon',
@@ -501,8 +501,8 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
50: mk(
'surrealist-interior',
'Surrealist interior',
'Bourgeois wallpaper tones · uncanny warm light',
{ wall: 'velvet-navy', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
'Dark painted walls · uncanny warm light',
{ wall: 'painted-oil-matte', ceiling: 'dark-plaster', floor: 'parquet-herringbone' },
{ wall: '#1a2848', ceiling: '#2a2428', floor: '#5a4838', trim: '#8a7050' },
{
details: 'salon',
@@ -520,8 +520,8 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
51: mk(
'abstract-expressionist-loft',
'NYC loft',
'Raw brick and plaster · north-light factory windows',
{ wall: 'brick', wallSide: 'plaster-white', ceiling: 'concrete-raw', floor: 'industrial-floor' },
'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' },
{
details: 'industrial',
@@ -536,8 +536,8 @@ const BY_MOVEMENT_ID: Record<number, MovementInteriorStyle> = {
52: mk(
'pop-art-gallery',
'Pop Art gallery',
'White cube · fluorescent daylight · polished concrete',
{ wall: 'studio-white', ceiling: 'studio-white', floor: 'concrete-polished' },
'White painted walls · fluorescent daylight · polished concrete',
{ wall: 'painted-flat', ceiling: 'painted-flat', floor: 'concrete-polished' },
{ wall: '#ffffff', ceiling: '#ffffff', floor: '#d8d8d8', trim: '#cccccc' },
{
details: 'modern',
+2 -1
View File
@@ -18,10 +18,11 @@ export function useTexturedMaterial(
return new THREE.MeshStandardMaterial({
map: surf.map,
normalMap: surf.normalMap,
normalScale: new THREE.Vector2(surf.normalScale, surf.normalScale),
color: tint,
roughness: surf.roughness,
metalness: surf.metalness,
envMapIntensity: 0.65,
envMapIntensity: kind.startsWith('painted-') ? 0.45 : 0.65,
});
}, [kind, tint, spanW, spanH]);
+298 -72
View File
@@ -41,7 +41,12 @@ export type SurfaceTextureKind =
| 'dark-plaster'
| 'pastel-plaster'
| 'white-plaster'
| 'studio-white';
| 'studio-white'
| 'painted-lime'
| 'painted-oil-matte'
| 'painted-oil-satin'
| 'painted-emulsion'
| 'painted-flat';
export interface SurfaceTextureSet {
map: THREE.CanvasTexture;
@@ -49,6 +54,7 @@ export interface SurfaceTextureSet {
roughness: number;
metalness: number;
metersPerRepeat: number;
normalScale: number;
}
const cache = new Map<string, SurfaceTextureSet>();
@@ -94,28 +100,207 @@ function noiseOverlay(ctx: CanvasRenderingContext2D, size: number, alpha: number
ctx.putImageData(img, 0, 0);
}
/** Fractal value noise for organic surface variation. */
function fbm(x: number, y: number, seed: number, octaves = 5): number {
let value = 0;
let amp = 0.5;
let freq = 1;
for (let i = 0; i < octaves; i++) {
value +=
amp *
(Math.sin(x * freq * 12.9898 + seed * 0.137 + i * 1.7) *
Math.cos(y * freq * 78.233 + seed * 0.311 + i * 2.3) *
0.5 +
0.5);
amp *= 0.52;
freq *= 2.05;
}
return value;
}
function paintNeutralBase(ctx: CanvasRenderingContext2D, size: number) {
fill(ctx, size, '#f8f8f6');
}
type PaintFinish = 'lime' | 'oil-matte' | 'oil-satin' | 'emulsion' | 'flat';
/** Single-colour painted wall — neutral base so movement tint drives the hue. */
function paintedWallSurface(
ctx: CanvasRenderingContext2D,
size: number,
finish: PaintFinish,
seed: number
) {
paintNeutralBase(ctx, size);
const img = ctx.getImageData(0, 0, size, size);
const d = img.data;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const i = (y * size + x) * 4;
const u = x / size;
const v = y / size;
let lum = 0;
if (finish === 'lime') {
lum += (fbm(u * 3.2, v * 3.2, seed) - 0.5) * 0.14;
lum += (fbm(u * 8, v * 8, seed + 17) - 0.5) * 0.06;
lum += Math.sin(v * 40 + fbm(u, 0, seed + 3) * 8) * 0.012;
} else if (finish === 'oil-matte') {
lum += Math.sin(v * 55 + fbm(u * 2, 0, seed) * 4) * 0.018;
lum += (fbm(u * 18, v * 18, seed + 5) - 0.5) * 0.04;
lum += (fbm(u * 60, v * 60, seed + 9) - 0.5) * 0.025;
} else if (finish === 'oil-satin') {
lum += Math.sin(v * 48 + fbm(u, 0, seed) * 3) * 0.014;
const sheen = Math.pow(Math.max(0, Math.sin(u * 28 + v * 22 + seed)), 3) * 0.06;
lum += sheen;
} else if (finish === 'emulsion') {
lum += Math.sin(v * (Math.PI * 2 * size) / 105) * 0.022;
lum += (fbm(u * 22, v * 22, seed + 11) - 0.5) * 0.035;
lum += (fbm(u * 70, v * 70, seed + 13) - 0.5) * 0.018;
} else {
lum += (fbm(u * 35, v * 35, seed + 19) - 0.5) * 0.028;
lum += (fbm(u * 90, v * 90, seed + 21) - 0.5) * 0.012;
}
const delta = lum * 255;
d[i] = Math.min(255, Math.max(0, d[i] + delta));
d[i + 1] = Math.min(255, Math.max(0, d[i + 1] + delta));
d[i + 2] = Math.min(255, Math.max(0, d[i + 2] + delta));
}
}
ctx.putImageData(img, 0, 0);
noiseOverlay(ctx, size, finish === 'flat' ? 0.012 : 0.022, seed + 99);
}
function plasterSurface(ctx: CanvasRenderingContext2D, size: number, base: string, seed: number) {
fill(ctx, size, base);
const rand = seeded(seed);
for (let pass = 0; pass < 55; pass++) {
const angle = rand() * Math.PI;
const len = size * (0.08 + rand() * 0.22);
const x0 = rand() * size;
const y0 = rand() * size;
ctx.strokeStyle = `rgba(0,0,0,${0.015 + rand() * 0.025})`;
ctx.lineWidth = 6 + rand() * 18;
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x0 + Math.cos(angle) * len, y0 + Math.sin(angle) * len);
ctx.stroke();
}
noiseOverlay(ctx, size, 0.045, seed + 1);
}
function stuccoSurface(ctx: CanvasRenderingContext2D, size: number, base: string, seed: number) {
fill(ctx, size, base);
const rand = seeded(seed);
for (let i = 0; i < 80; i++) {
const cx = rand() * size;
const cy = rand() * size;
const r = 20 + rand() * 60;
ctx.strokeStyle = `rgba(0,0,0,${0.02 + rand() * 0.04})`;
ctx.lineWidth = 2 + rand() * 4;
ctx.beginPath();
ctx.arc(cx, cy, r, rand() * Math.PI, rand() * Math.PI + Math.PI * 0.8);
ctx.stroke();
}
noiseOverlay(ctx, size, 0.07, seed + 2);
}
function marbleVeined(ctx: CanvasRenderingContext2D, size: number, base: string, vein: string, seed: number) {
fill(ctx, size, base);
const rand = seeded(seed);
for (let i = 0; i < 28; i++) {
ctx.strokeStyle = vein.replace(')', `,${0.06 + rand() * 0.14})`).replace('rgb', 'rgba');
if (vein.startsWith('#')) {
const [r, g, b] = hexToRgb(vein);
ctx.strokeStyle = `rgba(${r},${g},${b},${0.05 + rand() * 0.12})`;
}
ctx.lineWidth = 1 + rand() * 5;
for (let i = 0; i < 42; i++) {
const [r, g, b] = hexToRgb(vein.startsWith('#') ? vein : '#a8a098');
ctx.strokeStyle = `rgba(${r},${g},${b},${0.04 + rand() * 0.16})`;
ctx.lineWidth = 0.8 + rand() * 6;
ctx.beginPath();
let x = rand() * size;
let y = rand() * size;
ctx.moveTo(x, y);
for (let s = 0; s < 10; s++) {
x += (rand() - 0.5) * size * 0.18;
y += (rand() - 0.5) * size * 0.12;
ctx.lineTo(x, y);
for (let s = 0; s < 14; s++) {
x += (rand() - 0.5) * size * 0.14;
y += (rand() - 0.5) * size * 0.1;
const cx = (x + rand() * size * 0.05) / 2;
const cy = (y + rand() * size * 0.05) / 2;
ctx.quadraticCurveTo(cx, cy, x, y);
}
ctx.stroke();
}
noiseOverlay(ctx, size, 0.04, seed + 1);
{
const img = ctx.getImageData(0, 0, size, size);
const d = img.data;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const i = (y * size + x) * 4;
const n = (fbm(x / 180, y / 180, seed + 50) - 0.5) * 12;
d[i] += n;
d[i + 1] += n;
d[i + 2] += n;
}
}
ctx.putImageData(img, 0, 0);
}
noiseOverlay(ctx, size, 0.035, seed + 1);
}
function woodPanelSurface(
ctx: CanvasRenderingContext2D,
size: number,
base: string,
seed: number,
plankCount = 14
) {
const plankH = size / plankCount;
for (let i = 0; i < plankCount; i++) {
const grad = ctx.createLinearGradient(0, i * plankH, size, i * plankH + plankH);
grad.addColorStop(0, shadeHex(base, -18));
grad.addColorStop(0.35, base);
grad.addColorStop(0.65, shadeHex(base, 8));
grad.addColorStop(1, shadeHex(base, -12));
ctx.fillStyle = grad;
ctx.fillRect(0, i * plankH, size, plankH - 2);
for (let g = 0; g < 16; g++) {
const gx = g * (size / 16);
ctx.strokeStyle = 'rgba(0,0,0,0.06)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(gx, i * plankH);
const wave = Math.sin(g * 0.8 + i + seed * 0.01) * size * 0.04;
ctx.bezierCurveTo(gx + size * 0.03, i * plankH + plankH * 0.3, gx - size * 0.02 + wave, i * plankH + plankH * 0.7, gx + wave, i * plankH + plankH);
ctx.stroke();
}
ctx.strokeStyle = 'rgba(0,0,0,0.28)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, i * plankH);
ctx.lineTo(size, i * plankH);
ctx.stroke();
}
noiseOverlay(ctx, size, 0.035, seed);
}
function velvetSurface(ctx: CanvasRenderingContext2D, size: number, base: string, seed: number) {
fill(ctx, size, base);
const img = ctx.getImageData(0, 0, size, size);
const d = img.data;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const i = (y * size + x) * 4;
const weave =
Math.sin(x * 0.35) * Math.sin(y * 0.35) * 0.04 +
Math.sin(x * 0.12 + y * 0.08 + seed) * 0.025;
const pile = fbm(x / 40, y / 40, seed) * 0.08;
const delta = (weave + pile) * 255;
d[i] += delta;
d[i + 1] += delta;
d[i + 2] += delta;
}
}
ctx.putImageData(img, 0, 0);
noiseOverlay(ctx, size, 0.12, seed + 1);
}
function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
@@ -125,6 +310,21 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
const ctx = canvas.getContext('2d')!;
switch (kind) {
case 'painted-lime':
paintedWallSurface(ctx, size, 'lime', 501);
break;
case 'painted-oil-matte':
paintedWallSurface(ctx, size, 'oil-matte', 502);
break;
case 'painted-oil-satin':
paintedWallSurface(ctx, size, 'oil-satin', 503);
break;
case 'painted-emulsion':
paintedWallSurface(ctx, size, 'emulsion', 504);
break;
case 'painted-flat':
paintedWallSurface(ctx, size, 'flat', 505);
break;
case 'marble-white':
case 'marble-veined-carrara':
marbleVeined(ctx, size, '#ece8e0', '#a8a098', 101);
@@ -148,7 +348,14 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
}
case 'limestone':
fill(ctx, size, '#ddd4c4');
noiseOverlay(ctx, size, 0.08, 104);
for (let y = 0; y < size; y += 4) {
for (let x = 0; x < size; x += 4) {
const n = (fbm(x / 120, y / 120, 104) - 0.5) * 18;
ctx.fillStyle = rgb(221 + n, 212 + n, 196 + n);
ctx.fillRect(x, y, 4, 4);
}
}
noiseOverlay(ctx, size, 0.06, 104);
break;
case 'sandstone':
fill(ctx, size, '#c8b090');
@@ -171,42 +378,31 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
noiseOverlay(ctx, size, 0.15, 107);
break;
case 'stucco-cream':
fill(ctx, size, '#f0e8d8');
noiseOverlay(ctx, size, 0.06, 108);
stuccoSurface(ctx, size, '#f0e8d8', 108);
break;
case 'stucco-terracotta':
fill(ctx, size, '#c89070');
noiseOverlay(ctx, size, 0.08, 109);
stuccoSurface(ctx, size, '#c89070', 109);
break;
case 'plaster-white':
case 'white-plaster':
case 'studio-white':
fill(ctx, size, '#f8f8f6');
noiseOverlay(ctx, size, 0.035, 110);
plasterSurface(ctx, size, '#f8f8f6', 110);
break;
case 'plaster-warm':
fill(ctx, size, '#f2ebe0');
noiseOverlay(ctx, size, 0.045, 111);
case 'pastel-plaster':
plasterSurface(ctx, size, kind === 'pastel-plaster' ? '#ece4ec' : '#f2ebe0', 111);
break;
case 'dark-plaster':
fill(ctx, size, '#3a3230');
noiseOverlay(ctx, size, 0.07, 112);
break;
case 'pastel-plaster':
fill(ctx, size, '#e8dce8');
noiseOverlay(ctx, size, 0.05, 113);
plasterSurface(ctx, size, '#3a3230', 112);
break;
case 'velvet-crimson':
fill(ctx, size, '#5c1828');
noiseOverlay(ctx, size, 0.18, 114);
velvetSurface(ctx, size, '#5c1828', 114);
break;
case 'velvet-navy':
fill(ctx, size, '#1a2848');
noiseOverlay(ctx, size, 0.18, 115);
velvetSurface(ctx, size, '#1a2848', 115);
break;
case 'velvet-emerald':
fill(ctx, size, '#1a4030');
noiseOverlay(ctx, size, 0.18, 116);
velvetSurface(ctx, size, '#1a4030', 116);
break;
case 'silk-gold':
fill(ctx, size, '#d8c890');
@@ -217,34 +413,14 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
noiseOverlay(ctx, size, 0.05, 118);
break;
case 'oak-panel':
woodPanelSurface(ctx, size, '#8a6848', 119);
break;
case 'walnut-panel':
case 'dark-wood-panel': {
const base = kind === 'oak-panel' ? '#8a6848' : kind === 'walnut-panel' ? '#5a4030' : '#3a2818';
const plankH = size / 14;
for (let i = 0; i < 14; i++) {
const grad = ctx.createLinearGradient(0, i * plankH, size, i * plankH);
grad.addColorStop(0, shadeHex(base, -15));
grad.addColorStop(0.5, base);
grad.addColorStop(1, shadeHex(base, -10));
ctx.fillStyle = grad;
ctx.fillRect(0, i * plankH, size, plankH - 2);
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, i * plankH);
ctx.lineTo(size, i * plankH);
ctx.stroke();
for (let g = 0; g < 12; g++) {
ctx.strokeStyle = 'rgba(0,0,0,0.08)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(g * (size / 12), i * plankH);
ctx.lineTo(g * (size / 12) + size * 0.08, i * plankH + plankH);
ctx.stroke();
}
}
woodPanelSurface(ctx, size, '#5a4030', 120);
break;
case 'dark-wood-panel':
woodPanelSurface(ctx, size, '#3a2818', 121);
break;
}
case 'parquet-herringbone':
case 'parquet-chevrons': {
const tones = ['#6b4a2e', '#735234', '#624428', '#7a5638'];
@@ -267,7 +443,7 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
const tile = size / 8;
for (let row = 0; row < 8; row++) {
for (let col = 0; col < 8; col++) {
ctx.fillStyle = ['#b87050', '#c88058', '#a86048'][ (row + col) % 3];
ctx.fillStyle = ['#b87050', '#c88058', '#a86048'][(row + col) % 3];
ctx.fillRect(col * tile + 2, row * tile + 2, tile - 4, tile - 4);
}
}
@@ -289,7 +465,7 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
const colors = ['#d4af37', '#8a3020', '#2060a0', '#f0ece0', '#408040'];
for (let y = 0; y < size; y += cell) {
for (let x = 0; x < size; x += cell) {
ctx.fillStyle = colors[((x / cell) + (y / cell)) % colors.length];
ctx.fillStyle = colors[(x / cell + y / cell) % colors.length];
ctx.fillRect(x + 1, y + 1, cell - 2, cell - 2);
}
}
@@ -301,7 +477,7 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
const colors = ['#8a7060', '#a88870', '#706050', '#d0c0a0'];
for (let y = 0; y < size; y += cell) {
for (let x = 0; x < size; x += cell) {
ctx.fillStyle = colors[((x / cell) + (y / cell)) % colors.length];
ctx.fillStyle = colors[(x / cell + y / cell) % colors.length];
ctx.fillRect(x + 1, y + 1, cell - 2, cell - 2);
}
}
@@ -347,22 +523,23 @@ function paintSurface(kind: SurfaceTextureKind, size: number): ImageData {
noiseOverlay(ctx, size, 0.12, 124);
break;
case 'gilded-stucco':
fill(ctx, size, '#d8c070');
noiseOverlay(ctx, size, 0.05, 125);
stuccoSurface(ctx, size, '#d8c070', 125);
break;
case 'fresco-worn':
fill(ctx, size, '#d0c8b8');
noiseOverlay(ctx, size, 0.09, 126);
plasterSurface(ctx, size, '#d0c8b8', 126);
break;
case 'brick': {
const bh = size / 16;
const bw = size / 8;
fill(ctx, size, '#6a4030');
fill(ctx, size, '#5a4030');
for (let row = 0; row < 16; row++) {
const offset = row % 2 ? bw / 2 : 0;
for (let col = -1; col < 9; col++) {
const rand = seeded(130 + row * 9 + col);
ctx.fillStyle = ['#8a5040', '#7a4838', '#9a5848'][(row + col) % 3];
ctx.fillRect(col * bw + offset, row * bh, bw - 3, bh - 3);
const inset = 2 + rand() * 2;
ctx.fillRect(col * bw + offset + inset, row * bh + inset, bw - inset * 2, bh - inset * 2);
}
}
break;
@@ -379,7 +556,7 @@ function imageDataToNormalMap(data: ImageData, size: number, strength = 2.5): Im
const out = new ImageData(size, size);
const src = data.data;
const dst = out.data;
const idx = (x: number, y: number) => ((y * size + x) * 4);
const idx = (x: number, y: number) => (y * size + x) * 4;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
@@ -419,6 +596,16 @@ function imageDataToTexture(data: ImageData): THREE.CanvasTexture {
return tex;
}
function normalStrengthFor(kind: SurfaceTextureKind): number {
if (kind.startsWith('painted-')) return 2.2;
if (kind.includes('velvet')) return 1.6;
if (kind.includes('marble')) return 3.4;
if (kind.includes('stucco') || kind.includes('plaster')) return 2.6;
if (kind.includes('panel') || kind.includes('oak') || kind.includes('walnut')) return 2.8;
if (kind === 'brick' || kind === 'rough-stone') return 3.2;
return 2.5;
}
const ROUGHNESS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-white': 0.18,
'marble-veined-carrara': 0.16,
@@ -431,7 +618,21 @@ const ROUGHNESS: Partial<Record<SurfaceTextureKind, number>> = {
'concrete-polished': 0.28,
'concrete-raw': 0.72,
'oak-panel': 0.55,
'walnut-panel': 0.58,
'dark-wood-panel': 0.62,
'parquet-herringbone': 0.42,
'painted-lime': 0.88,
'painted-oil-matte': 0.82,
'painted-oil-satin': 0.68,
'painted-emulsion': 0.86,
'painted-flat': 0.94,
'limestone': 0.78,
'stucco-cream': 0.8,
'stucco-terracotta': 0.78,
'plaster-white': 0.9,
'plaster-warm': 0.88,
'white-plaster': 0.92,
'studio-white': 0.94,
};
const METALNESS: Partial<Record<SurfaceTextureKind, number>> = {
@@ -440,17 +641,41 @@ const METALNESS: Partial<Record<SurfaceTextureKind, number>> = {
'gilded-stucco': 0.65,
'concrete-polished': 0.12,
'terrazzo': 0.15,
'painted-oil-satin': 0.06,
'painted-flat': 0.02,
};
const METERS: Partial<Record<SurfaceTextureKind, number>> = {
'marble-checker': 2.4,
'flagstone': 3.0,
flagstone: 3.0,
'mosaic-byzantine': 1.8,
'mosaic-roman': 2.0,
'parquet-herringbone': 1.8,
'parquet-chevrons': 1.8,
'concrete-raw': 4.0,
'brick': 2.5,
brick: 2.5,
'painted-lime': 3.2,
'painted-oil-matte': 3.0,
'painted-oil-satin': 3.0,
'painted-emulsion': 3.5,
'painted-flat': 4.0,
};
const NORMAL_SCALE: Partial<Record<SurfaceTextureKind, number>> = {
'painted-flat': 0.32,
'painted-emulsion': 0.48,
'painted-oil-satin': 0.42,
'painted-oil-matte': 0.55,
'painted-lime': 0.62,
'marble-white': 0.75,
'marble-veined-carrara': 0.8,
'velvet-crimson': 0.5,
'velvet-navy': 0.5,
'velvet-emerald': 0.5,
'oak-panel': 0.7,
'brick': 0.85,
'stucco-cream': 0.65,
'plaster-warm': 0.58,
};
export function getSurfaceTexture(kind: SurfaceTextureKind): SurfaceTextureSet {
@@ -458,7 +683,7 @@ export function getSurfaceTexture(kind: SurfaceTextureKind): SurfaceTextureSet {
if (cached) return cached;
const colorData = paintSurface(kind, TEXTURE_SIZE);
const normalData = imageDataToNormalMap(colorData, TEXTURE_SIZE, kind.includes('velvet') ? 1.2 : 2.8);
const normalData = imageDataToNormalMap(colorData, TEXTURE_SIZE, normalStrengthFor(kind));
const set: SurfaceTextureSet = {
map: imageDataToTexture(colorData),
@@ -466,6 +691,7 @@ export function getSurfaceTexture(kind: SurfaceTextureKind): SurfaceTextureSet {
roughness: ROUGHNESS[kind] ?? 0.75,
metalness: METALNESS[kind] ?? 0.04,
metersPerRepeat: METERS[kind] ?? 2.8,
normalScale: NORMAL_SCALE[kind] ?? 0.6,
};
cache.set(kind, set);
return set;
+15 -9
View File
@@ -1,13 +1,19 @@
import { defineConfig } from 'vite'
import path from 'node:path'
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3001',
'/images': 'http://localhost:3001',
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, path.resolve(__dirname, '..'), '')
const apiTarget = `http://localhost:${env.PORT || '3001'}`
return {
plugins: [react()],
server: {
proxy: {
'/api': apiTarget,
'/images': apiTarget,
},
},
},
})
}
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 799 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 447 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 481 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 MiB

After

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 830 KiB

After

Width:  |  Height:  |  Size: 542 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 837 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 KiB

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1023 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 705 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 855 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 651 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 9.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 KiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 911 KiB

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 911 KiB

After

Width:  |  Height:  |  Size: 8.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 871 KiB

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Some files were not shown because too many files have changed in this diff Show More