# The Avatar Paint Studio

- Status: v1 built 2026-08-04 (owner direction, same day); **persistence
  built 2026-08-06** — a save is login-only and writes `avatar_paintings`
  (ADR-0007), with the relay cache still in front as the fast read path.
- Related: `docs/SPACE-MUSEUM-DIRECTION.md` §10 (presence),
  `docs/PERFORMANCE.md` (avatar import; texture sizes), ADR-0005 (where
  data lives).

## What it is

Pick an avatar on the landing page, press **Paint**, and color the actual
3D body full screen: brush, spray, dot and eraser, a kid-sized palette plus
a free color, size slider, undo and clear. There is deliberately **no live
painting** here — strokes are plain pigment on the avatar's own texture,
nothing animates. One finger (or the left button) paints; two fingers, the
right button, or the Move tool turn and zoom the model. The studio opens
**in front of** the avatar — the camera starts along the body's own forward
axis, including the mount trim, so every avatar greets the painter face-on
(owner 2026-08-06; it used to open behind the model).

**The eraser** (owner 2026-08-06) is the brush's stroke with a different
stamp: instead of laying pigment it redraws the **factory coat** inside each
disc — one small Clear, walked along the drag. Erasing to transparent would
punch holes in an opaque body, so on a model that already wears a skin,
rubbing the paint off means the skin comes back. Erasing a part nobody
painted does nothing and, deliberately, does not add that part to the save
payload.

There is deliberately **no line tool**: dragging the brush straight already
draws a line, and a press-then-release tool needed its own path rules —
a second way for a stroke to go wrong, for no new ability (owner
2026-08-05, after the line's first version arrived broken).

## Who may paint, and who is who (owner 2026-08-05)

The sign-in form itself is **one implementation**,
`src/inception/auth/signInPanel.ts`, mounted by both the museum's Esc menu
and this studio — including the dev shortcut, which is only offered on a
development origin (`auth/localOrigin.js`: loopback, private LAN addresses
and `.local` names, since `npm run dev` serves on 0.0.0.0 for iPads).

**Painting requires signing in.** Not as a paywall — because a painting has
to belong to somebody. Without an account, every tab mints its own painting
id and one child ends up with several avatars competing to be the latest,
which is exactly the mess this rule ends. Guests may still open the studio,
turn the model and look; the tools, Save and Enter are disabled behind a
sign-in card, and a guest's drag turns the body instead of painting it.

**Every explorer has an identity, guest or not** (`src/inception/net/identity.js`):

| Who | Identity | Why |
|---|---|---|
| Signed in | `sha256("account:" + email)`, first 16 hex | The same person on any device or tab is ONE explorer |
| Guest | a random 16-hex id kept in this browser profile | Multiple tabs still resolve to one avatar |

**Where to look when it seems not to work** (owner 2026-08-05):

- The browser prints `presence-identity: <kind> <id>` at boot. **Two tabs of
  one person must print the SAME id.** The guest id is stored at
  `localStorage["inception.guestId"]`; an account's id is derived from the
  address every time and never stored.
- The API prints `presence: retired <socket> — identity <prefix> opened a
  newer tab` when the rule fires. **No such line means the rule never ran**:
  the API is a plain Node process, so `npm run api` must be RESTARTED after
  a server change, and the deployed API needs a deploy.
- Plain http on a LAN address is not a secure context, so the browser has no
  `crypto.subtle` there. An account then falls back to this profile's guest
  id (the boot line says so). Multi-tab still collapses to one explorer;
  only "the same account on two devices is one person" waits for https.

The identity travels only on `join`, only as that opaque hash — never an
email — and is never relayed to other clients. The server keeps **the latest
connection per identity**: a new tab makes the older one retire (it is told
`replaced`, stops publishing, and everyone else sees it leave), so three
tabs of one child are no longer three avatars in the corridor.

A private window is a different browser profile and therefore a different
guest. That is not something a web app can or should try to prevent.

**A painting belongs to one avatar.** The record names its `avatarId`, and a
painting is only ever worn by that body: material keys are positions in
THAT avatar's material list, so putting an angel's painting on a baby
astronaut smears unrelated textures over unrelated parts. Presence therefore
also carries `av`, the explorer's chosen avatar slug, and a mismatch is
refused on both sides (the puller skips it, and the renderer refuses it
anyway).

### Data relations (for the persistent layer, ADR-0007)

```text
users (email)                      ← identity comes from Lucas Account
  ├─ avatar_paintings  1..n        one per email today; up to 3 per avatar later
  │     └─ avatar_id              WHICH body it was painted for (never mixed)
  └─ avatars (ownership) 0..n     the avatars this account may use
        · public avatars          everyone sees them in the catalog
        · private avatars         only their owners see them ("自己用自己 own 的")
                                  and assets still arrive through the repo's
                                  provenance review — there is NO upload path
```

Read as sentences: an **account owns avatars** and **owns paintings**; a
**painting is for exactly one avatar**; a **world owns rooms** (ADR-0007) and
none of that is per-world — your painted body follows you into every world.
Since 2026-08-06 the wiring exists: `POST /api/paintings` verifies the
account token, writes the `users` row, and stores the painting, while
`GET /api/paintings/:id` reads the cache first and the database second. The
landing page asks `GET /api/avatars` for the ownership registry and offers
only the avatars it names (public plus your own private ones).

Painting happens in texture space: the pointer ray hits the mesh, the hit's
UV names a pixel on that material's albedo canvas, and the tool stamps
there (`src/inception/studio/paintTools.js` is the pure geometry;
`avatarStudio.ts` is the app).

**A paintable surface is one MESH, not one material** — measured the hard
way (owner 2026-08-05: "one line came out as many"). The baby astronaut's
seven suit parts share a single fabric texture AND the same full 0..1 UV
square, so a stroke in that texture appeared on both arms, both legs, the
torso and both boots at once. Each mesh is therefore its own surface, and at
its first stroke it takes a **copy** of the material it was sharing
(`isolateSurface`, copy-on-write). Consequences worth knowing:

- Nothing is duplicated for parts nobody paints, so painting one arm costs
  one texture — and a saved painting carries **only the parts that were
  painted**, which keeps the payload small.
- **Clear** puts the factory textures back and drops the paint canvases, so
  it now clears every part rather than only the ones that happened to have a
  canvas. It cannot be undone, but nothing was saved either: reloading the
  studio brings back the last SAVED painting.

**Strokes are walked across the SCREEN, not across the texture.** A scanned
body's UVs are many small islands, so a straight path between two texture
positions leaves the surface and the stroke arrives in pieces. The brush
therefore samples its path every few screen pixels and traces each sample
back onto the model; consecutive samples are joined in texture space only
when they land on the same material and close together, and at a UV seam
the stroke stamps instead of smearing across the atlas. The result reads as
one continuous mark on the body, which is the only place continuity matters.

## How a painting travels (built)

The design goal (owner): the WebSocket must never carry image bytes — only
an id — and peers fetch the actual painting once.

1. **Save** flattens each painted material to a small **lossy JPEG**
   (512², quality 0.82 — visual loss accepted by design) and stores the
   painting locally per avatar (`localStorage`, `paintStore.js`).
2. The painting's **id is its content hash** (SHA-256 prefix). The same
   painting has the same id on every machine, so uploads are idempotent
   and caches can never go stale.
3. Save (and every museum boot) **uploads the painting** —
   `POST /api/paintings` with the account token → id. The upload lands in
   two places: the in-memory LRU relay cache (`server/paintings.js`,
   validated by shape and capped in size) and the `avatar_paintings` row
   keyed by the verified email. A guest, or a laptop dev session, has no
   token to send and skips the upload; the paint still applies locally.
   The response says `{ id, persisted }` — `persisted:false` means the
   cache took it but the database refused, which is a warning, never a
   failed Save.
4. **Presence carries only `pt`**, the id, as a strictly format-checked
   field (`presenceProtocol.js` — a hex hash, never free text).
5. A peer seeing an unknown `pt` checks its **local painting cache**
   first, then pulls `GET /api/paintings/:id` once (the response is
   immutable and cacheable), stores it locally (last 10 kept), and dresses
   that explorer's rig. Remote rigs wear **cloned materials**, so one
   player's paint can never bleed onto another's body. A cold cache — a
   restarted server — is served from the database instead: the row is
   re-serialized by the studio's own canonical rule (v, avatarId, sorted
   materials), so the bytes still hash to the id the peer asked for.

Failure is soft everywhere: no API → the paint still applies locally and
the museum is simply a solo museum; a 404 id → the peer keeps the factory
coat and the pull is **retried with backoff** (see below).

### Two rules this cost us a bug to learn (2026-08-05)

- **A painting names materials by position, so that rule may exist only
  once.** `studio/paintTargets.js` is it: walk the scene, every textured
  material not yet seen takes the next number, and a material shared by
  several meshes counts ONCE. The baby astronaut's suit is worn by seven
  meshes; a remote clone that cloned materials per-mesh turned one
  paintable surface into seven, shifted every key after it, and showed a
  peer's paint on one patch of suit with the face and hair left bare.
  Remote rigs now clone one material per SOURCE material, preserving the
  sharing the studio saw.
- **A 404 from the relay is a "not yet", not an answer.** A peer announces
  its painting id over presence the instant it joins, while its own upload
  is still in flight (and Render's free API may be waking up). Treating
  that first 404 as permanent left peers' paint invisible for the whole
  session. `net/paintPull.js` retries with exponential backoff (2s → 20s,
  six tries) and only then stops asking. A pulled painting is applied
  immediately and held in memory, so correctness never depends on
  localStorage having room — the device cache is a pure optimization and
  its quota errors are swallowed.

## The persistence plan (owner 2026-08-04) — BUILT 2026-08-06

Recorded verbatim as product intent; the checked items now exist in code.

- ✅ **Signing in is required to paint and save.** The studio itself
  stays open for trying, but persisting a painting requires a Lucas
  Account session, and the painting is saved to the **database**, not just
  the relay cache.
- ✅ **One painting per email for now** (a save replaces, it never piles
  up). The schema already allows several: later, an account may keep **up
  to 3 paintings per avatar** and choose which one to wear.
- Sketch (per ADR-0005: documents in Postgres, blobs out of the database):

  ```sql
  CREATE TABLE avatar_paintings (
    id          TEXT PRIMARY KEY,     -- content hash, same as the relay id
    email       TEXT NOT NULL,        -- verified JWT subject
    avatar_id   TEXT NOT NULL,
    painting    JSONB NOT NULL,       -- v1 record; move to R2 if it grows
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
  );
  -- "one per email now": UNIQUE (email); relax later to
  -- UNIQUE (email, avatar_id, slot) with slot < 3 per avatar.
  ```

- ✅ The write endpoint re-checks the session server-side (the same
  verifyBearer the rooms API uses) and **rejects dev sessions**, exactly
  like the room-save rule. The relay cache stays in front as the fast
  path; the database is the durable copy that survives restarts.
- Still to come: **your paint on a NEW device.** The row is keyed by email
  and `store.paintingFor(email)` already answers the question, but there is
  no endpoint that hands you your own painting back yet — today the studio
  reads it from this device's `localStorage`. Signing in on a fresh iPad
  therefore still shows the factory coat.
- Moderation lever (school reality): a painting is only ever visible to
  others through presence, so disabling an account's `pt` publishing is a
  complete kill switch.

## Watching it work

Painting bytes never travel over the WebSocket — only the id — so the two
halves are inspected in different places:

- **Console**, filtered to `presence-paint`: one line per event — which
  peer now wears which painting and how many surfaces it covers, or that an
  id is not in the relay yet and will be retried.
- **Network**, filtered to `/api/paintings`: the POST that publishes your
  painting (200 + `{id}`) and the GET each peer makes once (200, or 404
  while an upload is still in flight).
- **WS frames**: select the `/presence` socket → Messages, filter
  `"pt":"[0-9a-f]{16}"` to see exactly which peers are announcing a
  painting id.

## Performance notes

- A painting is a few tens of KB (lossy JPEG per material), pulled at most
  once per peer per session and cached locally.
- In the museum, applying a painting swaps a material's map texture once —
  no per-frame cost, no paint scheduler involvement.
- The studio paints on canvases capped at 1024² and exports at 512²; the
  avatar's own oversized scan textures are already capped at load
  (`docs/PERFORMANCE.md`, "Importing an avatar").
