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>
@@ -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
|
||||
|
||||
@@ -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 artist’s paintings.
|
||||
- **Successors** — artists whose works were influenced by this artist’s paintings.
|
||||
- **Predecessors** — artists whose works (or artist records) influenced this artist’s paintings.
|
||||
- **Successors** — artists whose paintings were influenced by this artist’s 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`
|
||||
|
||||
@@ -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 artist’s 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
|
||||
|
||||
|
||||
@@ -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 movement’s stream row:
|
||||
|
||||
Artist portraits stop wheel/drag propagation so zooming over a face does not fight portrait clicks. 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 (`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)
|
||||
|
||||
|
||||
@@ -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 artist’s 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 work’s 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 artist’s 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
|
||||
|
||||
|
||||
@@ -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 machine’s 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 work’s 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 |
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 500 KiB |
|
Before Width: | Height: | Size: 386 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 799 KiB |
|
Before Width: | Height: | Size: 459 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 459 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 3.1 MiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 459 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 535 KiB |
|
Before Width: | Height: | Size: 447 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 481 KiB |
|
Before Width: | Height: | Size: 11 MiB After Width: | Height: | Size: 635 KiB |
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 830 KiB After Width: | Height: | Size: 542 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 837 KiB |
|
Before Width: | Height: | Size: 670 KiB After Width: | Height: | Size: 972 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 1023 KiB |
|
Before Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 705 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 855 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 2.9 MiB After Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 413 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 651 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 325 KiB |
|
Before Width: | Height: | Size: 763 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 9.7 MiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 763 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 763 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 763 KiB After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 763 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 763 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 911 KiB After Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 911 KiB After Width: | Height: | Size: 8.7 MiB |
|
Before Width: | Height: | Size: 871 KiB After Width: | Height: | Size: 448 KiB |
|
Before Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 66 KiB |