Color your explorer's real 3D body; paintings travel by content id, never over the socket. · Rendered from docs/archive/AVATAR-STUDIO.md in the project repository · view as Markdown

The Avatar Paint Studio

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):

WhoIdentityWhy
Signed insha256("account:" + email), first 16 hexThe same person on any device or tab is ONE explorer
Guesta random 16-hex id kept in this browser profileMultiple tabs still resolve to one avatar

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

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)

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:

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 paintingPOST /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)

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

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

  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.

Watching it work

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

Performance notes