Who owns a 3D object, where its bytes live, and what an export has to satisfy. · Rendered from docs/OBJECT-LIBRARY.md in the project repository · view as Markdown

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

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

piecestate
objects table with source ('code' \'r2') and refmigration 3, applied on the API's next boot
GET /api/objectsevery object as { id, source, url, usable }, never an email
ref driftboot re-points a source: 'r2' row at the file the build names, so a re-export needs no SQL
URL resolutionsrc/inception/world/objectAssets.js, server-side from OBJECT_CDN_BASE
ref shape checkingobjectRefIsSafe — one objects/ prefix, .glb, no climbing out
content namingobjectRefFor(id, sha256)objects/<id>-<hash12>.glb
budgetOBJECT_BUDGET — 25,000 triangles / 4 MB / 2048 px
measuring an exportnpm run objects:plan <id> <file.glb>
GLB prop renderingthreeMuseum 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):

VariableValue
R2_ACCOUNT_IDthe Cloudflare account id (or set R2_S3_ENDPOINT instead)
R2_ACCESS_KEY_IDan R2 API token with object read/write on this bucket only
R2_SECRET_ACCESS_KEYits secret
R2_BUCKEToptional — 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:
   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.

  1. 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:

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:

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:

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.