# The object library: ownership, R2, and how an object gets in

- Status: **complete end to end 2026-08-07.** Ownership (ADR-0007 `objects`,
  migration 3), R2 serving, and the GLB prop renderer are all built; the bucket
  exists, holds reviewed objects, and the museum draws them. What remains is content:
  more objects, and the editor control for placing them per room.
- Related: ADR-0007 (the registry and its amendment), ADR-0005 (documents in
  Postgres, blobs out of it), `docs/DEPLOY-RENDER.md` (the services),
  `content/inception/assets/PROVENANCE.md` (where an object's licence lives),
  and `docs/archive/OBJECT-STORAGE-BUILDING.md` — the room this library is now
  BROWSED in, and the item bar a person fills there (issue #5, 2026-08-17).

## The two questions, kept apart

**Placement** — which objects a room holds and where they stand — has always
been owned: it is `props[]` inside the room document, and only that room's
owners may write it.

**The library** — which objects a person may BUILD with — is owned as of
2026-08-07. A row in `objects` carries `owners[]` and `visibility`, and for an
object `visibility: 'private'` means one thing only: **not yours to build
with**. It is not a rule about sight (owner, same day: "all objects are
visible by all"). `server/rooms.js` refuses a room document that names an
archetype its writer may not use (`object_not_yours`), because the filtered
dropdown is only a courtesy.

**Seeing is not using.** A public room serves everything it contains, whoever
made it. That is why the bucket is public-read: signed URLs would pretend to
protect bytes that every visitor's browser downloads anyway.

That principle was stated here before the code kept it. `GET /api/objects`
listed only the objects the viewer could USE, and the renderer learns where a
GLB object's bytes are from exactly that list — so a guest walking into
room-a on the deployed museum saw no easel and no painted horse, while the
owner saw both (owner, 2026-08-07). The parametric objects beside them were
fine, because those need no bytes. **The listing now names every object and
carries `usable`**: the address for anyone who might have to draw it, the
permission only for its owners. The write path is unchanged and is still the
boundary — `server/rooms.js` refuses a document naming an archetype its writer
may not use, computed from `listObjectsFor`, never from what was listed.
`visibility` is no longer published at all: the column stays, the word was
wrong (ADR-0007's 2026-08-07 amendment).

**The avatars registry took the same shape the same day**, for the same reason
twice over — presence draws other people, so an avatar a visitor cannot list
is a peer a visitor cannot see. `GET /api/avatars` lists every avatar with
`usable`, and because the picker's filter is no longer the rule,
`POST /api/paintings` now refuses `avatar_not_yours`: painting an avatar is
the write that wearing one amounts to.

## Why there is no upload endpoint

The same reason avatars have none (ADR-0007): a museum that accepts geometry
from the web is an unmoderated content channel, and this one is for children.
An object arrives through the repository's provenance and budget review, and
an operator puts it in the bucket. That is a deliberate bottleneck, not a
missing feature.

## What each piece does

| piece | state |
| --- | --- |
| `objects` table with `source` ('code' \| 'r2') and `ref` | migration 3, applied on the API's next boot |
| `GET /api/objects` | every object as `{ id, source, url, usable }`, never an email |
| ref drift | boot re-points a `source: 'r2'` row at the file the build names, so a re-export needs no SQL |
| URL resolution | `src/inception/world/objectAssets.js`, server-side from `OBJECT_CDN_BASE` |
| ref shape checking | `objectRefIsSafe` — one `objects/` prefix, `.glb`, no climbing out |
| content naming | `objectRefFor(id, sha256)` → `objects/<id>-<hash12>.glb` |
| budget | `OBJECT_BUDGET` — 25,000 triangles / 4 MB / 2048 px |
| measuring an export | `npm run objects:plan <id> <file.glb>` |
| GLB prop rendering | `threeMuseum` loads a template once per archetype and clones it per placement |

`OBJECT_CDN_BASE` **defaults to the live bucket**
(`https://objects.lucasacademy.org`), so a laptop API with no env resolves
every GLB object with zero setup; set the variable to point somewhere else.
It used to default to "nothing", which was right while the bucket did not
exist and a trap ever after — a local museum quietly lost its easel and
nativity while the deployed one drew them (owner 2026-08-07). The fallback is
`||`, not `??`, so an accidentally *empty* value (the `render.yaml` `value: ""`
incident) also lands on the default instead of going dark.

## The cloud half — DONE 2026-08-07, and what it took

The bucket `inception-space-objects` is live on `objects.lucasacademy.org`,
public-read, with CORS answering for `is.lucasacademy.org` and
`localhost:5173`, and `OBJECT_CDN_BASE` set on `inception-space-api`. Verified
by fetching both objects with the museum's own GLTFLoader (~360 ms each,
cross-origin, byte counts matching the reviewed exports).

### Writing to it: room-pack import (ADR-0009)

Since ADR-0009 there is ONE writer: a server-verified super admin importing a
`.ispack`. It writes into **this same bucket**, under the same `objects/`
prefix, with a content-addressed key (`objects/<sha256>.png|jpg|glb`) — so an
imported texture is served by the same public-read domain, with no second
bucket, no second CDN and no extra configuration.

To turn it on, set these on `inception-space-api` (dashboard, not
`render.yaml` — the same rule `OBJECT_CDN_BASE` follows):

| Variable | Value |
| --- | --- |
| `R2_ACCOUNT_ID` | the Cloudflare account id (or set `R2_S3_ENDPOINT` instead) |
| `R2_ACCESS_KEY_ID` | an R2 API token with **object read/write on this bucket only** |
| `R2_SECRET_ACCESS_KEY` | its secret |
| `R2_BUCKET` | *optional* — defaults to `inception-space-objects` |

Until they are set, importing refuses with `blob_store_unconfigured` (503) and
nothing is written; reviewing a pack still works and reports what an import
would store. That is the expected state on a laptop.

Nothing below is something this repository does for you: AGENTS.md forbids
creating hosted resources, and no code here holds R2 credentials. This is the
sequence to repeat for the next bucket, or to check when one misbehaves.

1. **Create the bucket** — Cloudflare dashboard or
   `npx wrangler r2 bucket create inception-space-objects`.
2. **Give it a domain.** R2 → Settings → Public access → either a custom
   domain (`objects.lucasacademy.org`, the tidy option) or the `r2.dev`
   development URL. Public READ only.
3. **Set CORS on the bucket.** This is the step that always bites: three.js
   fetches a GLB with XHR, so a cross-origin bucket without CORS fails with a
   blocked request and an empty room. Allow `GET` and `HEAD` from
   `https://is.lucasacademy.org`, `https://inception-space.onrender.com` and
   `http://localhost:5173`.
4. **Keep `no-transform` in mind.** Cloudflare Polish rewriting image bytes
   already took the transit chamber down once (2026-08-05). It does not touch
   `model/gltf-binary`, but upload with that content type so nothing at the
   edge is tempted.
5. **Set `OBJECT_CDN_BASE`** on `inception-space-api` to that domain, and
   redeploy. Until then the rows are inert. Set it in the DASHBOARD:
   `render.yaml` declares the key with `sync: false` precisely so a push
   cannot overwrite it (it did once, on 2026-08-07 — see DEPLOY-RENDER.md).
   To check it from the deployed museum while signed in:

   ```js
   fetch("https://inception-space-api.onrender.com/api/objects", {
     headers: { authorization: "Bearer " + localStorage.getItem("inception.authToken") },
   }).then(r => r.json()).then(d => console.table(d.objects));
   ```

   A `url` of `null` on an `r2` row means the variable is unset.
6. **Then, per object:** `npm run objects:plan <id> <file.glb> --owner <you>`
   and follow the four steps it prints — provenance first, then the upload,
   then the row, then the check.

## What an export has to satisfy

The standing spec. Both objects in the library were re-exported to it after
first measuring over budget:

- **Feet at y = 0.** Props stand on a room floor and are placed by `x, z,
  rotationY` only. The easel first arrived spanning −1.00…1.00 m — origin at
  its middle — which would have buried it to the waist.
- **Y-up, metres, facing +Z.** Same convention as the avatars; a mount trim
  can be declared per archetype if an export is crooked, as `my-angel` does.
- **≤ 25,000 triangles.** The nativity scan arrived at 82,587 and was
  decimated to 20,000. A room places props several times over.
- **≤ 2048 px textures, ≤ 4 MB total.** JPEG for photographic texture, PNG
  only where transparency is real. The easel was 6.18 MB, of which 3.7 MB was
  one 1600 px PNG of a painting; as a quality-85 JPEG at 1024 px the whole file
  came to 0.60 MB and looks identical on a canvas that size.
- **Animation is allowed, and must be deliberate.** A clip loops, the rig
  stays in the file (do not apply-and-strip the armature), and the joint count
  stays under `OBJECT_BUDGET.joints` = 64 — a mixer runs per PLACEMENT, every
  frame, so an animated object costs more than a still one. Name the clip in
  the archetype (`clip`) when a file has several, and set `animationSpeed` if
  the authored rate is wrong. The painted horse lost its 3.08 s gallop on
  2026-08-07 because this line used to say "no animation clips"; the clip and
  its 49 joints were still in the Blender working file.
- One mesh where possible.
- **A real-world size** you intend. The explorer is about 0.3 m tall at the
  museum's 1/6 scale, so the 0.31 m nativity is avatar-height — deliberate or
  not, it should be a decision.

`npm run objects:plan` reports every one of those numbers, so an export can be
checked before anyone pays to store it.

## Two rules a GLB object obeys

**The manifest declares what a room needs without the file.** Collision,
document validation and the editor's list all run before a byte is fetched, so
`PROP_ARCHETYPES` carries the label, the footprint and the height, and
`customRenderer: "glb"` marks where the geometry comes from. `isGlbArchetype`
is the one predicate everything asks.

**An id belongs to one object.** A registry id is a primary key AND an
archetype name; reusing one the build already ships makes the row mean two
different objects. That is not hypothetical — registering the easel GLB as
`easel` on 2026-08-07 turned the museum's own parametric easel into a private
R2 object. `npm run objects:plan` now refuses such an id, boot resets a
code-built object that has acquired a bucket ref, and the GLB easel is
registered as **`easelArt`**.

A clone shares its template's geometry and materials, so both room teardown
paths skip anything marked `userData.sharedAsset` — disposing them with the
first room would empty every later one.

## In the library (owner 2026-08-07)

These are registered **private** to the owner's account, so they appear in that
account's editor and nobody else's. R2 remains their canonical public object
store. The three objects used by committed rooms also have byte-identical,
content-addressed copies under `content/inception/assets/objects/` so those
rooms render offline; their licences and final measurements are in
`content/inception/assets/PROVENANCE.md`.

**`easelArt`** — "Easel" by **Olivercharlton11** (Sketchfab), **CC BY 4.0**,
wearing the owner's child's painting. Attribution is a licence condition, not
optional credit. Final export: 210 triangles, four 1024 px JPEGs, 0.60 MB,
standing 0.00…2.00 m on a 1.02 × 1.05 m footprint. Registered as `easelArt`
and not `easel`, for the reason given above.

**`nativity`** — the owner's own **Kiri Engine** photogrammetry scan of a
nativity, cleaned in Blender, textured with the owner's child's painting.
Final export: 20,000 triangles, one 2048 px JPEG, 0.88 MB, standing
0.00…0.31 m on a 0.47 × 0.50 m footprint — about the height of an explorer at
the museum's 1/6 scale.

**`paintedHorse`** — “лошадь horse” by **Paleo Modelist** (Sketchfab),
**CC BY 4.0**, retextured from a student artwork with owner-authorized image
generation. Final export: 4,476 triangles, one 1024 px JPEG, 0.32 MB, 49 joints
and one 3.08 s gallop clip; grounded, facing +Z, and standing 1.70 m high.

## Animated objects, and resizing a placement

**Animation.** The template keeps `gltf.animations`; each PLACEMENT gets its
own `AnimationMixer`, started at a phase derived from the prop's key so two of
the same object never move in lockstep. A skinned model is cloned with
`SkeletonUtils.clone`, never `Object3D.clone` — a plain clone keeps pointing at
the template's skeleton, so every copy would move as one. Mixers advance on the
museum's own clock and are dropped with the room that owns them.

**Size is per placement, not per object.** `props[].scale` in the room document
(absent means natural size, so older documents are untouched), bounded by
`PROP_SCALE` to 0.1–10 (owner 2026-08-07). It scales the drawing AND the collision box, including
the exact rotated footprint the flight resolver pushes against — a shrunken
object with a full-size box is an invisible wall. In the editor, select a
placement and press **-** / **=**; the list shows `×1.5` next to a resized one.
One room can hold a big horse and a small one, and both still share one file.

## Render styles, and solid interiors

**Render style is per placement.** `props[].renderStyle` (absent means
`solid` — the object exactly as authored). The accepted values are `solid`,
`hologram`, `silver`, `copper`, `gold`, `diamond`, `wireframe`, plus the
legacy `metal`, which is what the first shipped documents wrote for the one
metal treatment there used to be: it still validates and still renders, as
silver, so no saved room needs migrating. The editor offers the others.

Every styled placement OWNS its materials. An archetype's cached material and
a GLB template's are shared by every placement in the museum, so a style
rebuilds rather than mutates, and the room frees exactly what it built
(`disposeRoomNode`). A metal keeps a model's relief maps and drops its colour
map — turning something to gold has to make it read as gold — except where a
cut-out hides its silhouette in that map's alpha.

Anything that MOVES is a shader injection driven by `setWorldTime`, the same
pause-aware clock the holograms, the paint scheduler and the object mixers
use. Gold carries a slow travelling luster; the wireframe runs small sparks
along its wires. Both take their phase and rate from the placement's key, so
two gold statues never flow in lockstep, and neither adds geometry, a
particle system, or an animation loop of its own — a handful of uniforms per
placement per frame, and the GPU does the rest.

Metals and diamond reflect a small prefiltered probe built once per session
(`styleEnvironment`) and shared by every such placement; a PBR metal with
nothing to reflect renders black, and the museum deliberately sets no
`scene.environment`, which would repaint every other surface.

**Diamond has one deliberate limitation.** It is not `transmission`: real
refraction costs Three a second render of the whole scene into a transmission
target every frame, and it is the part of the physical model that misbehaves
on a self-overlapping GLB. Diamond is alpha blending with `depthWrite` kept
on plus a fresnel edge, so the nearest face still owns its pixels and the
object cannot dissolve into the room behind it. It reads as cut crystal; it
does not bend what is behind it.

**Solid interior.** `props[].collisionMode` — `"default"` (absent) or
`"solid"`. Default is the archetype's own collision, which is what every room
written before this option has. `"solid"` replaces it with ONE conservative
box around everything the placement occupies — the union of its footprint,
its natural height and any compound parts — because a compound object is
authored with gaps, and a gap is exactly what makes a big object enterable.
It follows the placement's position, Y rotation, width scale, height scale
and elevation like any other obstacle, and it is a bounding volume, never
per-triangle collision. A room can turn this on for ALL of its buildings at
once with `solidInteriors` (the L-City does); a placement's own field still
wins in both directions. The editor offers the per-placement checkbox for the
objects big enough to walk into (`archetypeMaySealInterior`); validation
accepts the field on any placement, on the client and the server alike.

**Sealed means solid to the EYE as well.** A wall you can lean your head
through is the thing this option exists to stop, and there are two separate
ways a camera gets through one:

- the third-person boom is 1.9 m and a shop block is a 6 m cube, so backing
  towards one puts the eye deep inside it; and
- the near plane cuts at 0.1 m while the body stops 0.05 m from a surface
  (under 0.02 m in the L-city), so a wall right in front of a first-person eye
  is clipped away and seen through.

`flight.cameraEyeFor` answers both: it SWEEPS the segment from the body to the
wanted eye and stops short of the first surface it would cross, then holds the
eye 0.14 m clear of anything it is still beside. A sweep, not a push: a push
only knows how deep inside a box a point is, so an eye a metre inside a
building is as easily shoved out the far side as the near one — and from out
there you are looking straight through the wall.

## Seeing them on localhost and offline

The exact `easelArt`, `nativity`, and `paintedHorse` GLBs used by committed
rooms are bundled into the Vite build. Those rooms therefore render on a local
or offline-served build without the API and without `objects.lucasacademy.org`.

Ownership still controls who may ADD these private objects in the editor. To
test that write-side behavior on a laptop, run the API and sign in:

```bash
OBJECT_CDN_BASE=https://objects.lucasacademy.org npm run api
```

and then **sign in with an emailed code**, not the local dev shortcut: a dev
session carries no signed token, so the API cannot know whose account it is and
answers as a guest. The bucket's CORS rule already allows `localhost:5173`.

The bucket refs live in the manifest beside the archetypes (`blobRef`), which
is what lets an in-memory store — or any fresh database — resolve these objects
with no hand-written SQL. Both the R2 and repository copies are
content-addressed; the runtime prefers the repository copy when one is bundled
and uses registry URLs for other reviewed objects.

## Editor scope

Adding one of these to a room works through the normal "add an object…" list
for its owner. Placement and render style are room data; the reviewed object
bytes themselves are never editable or uploadable through the child-facing UI.
