# forge-assets: the Forge 3D asset library

Blueprint: dashboard docs `FORGE-3D-ASSET-LIBRARY-BLUEPRINT-2026-10-07.md` (v3). Progress spike:
https://dashboard.le-borgne.fr/spikes/asset-library-20261007/

## Where things live
| What | Where |
|---|---|
| Canonical host tree | gex44 `/srv/forge-assets/repo` (this repo) + `/srv/forge-assets/blobs/<sha256>.glb` |
| Dev copy | local `~/forge-build/forge-assets` (blobs in `store/blobs/`, git-ignored) |
| Cards | `cards/<kind>/<group>/<class>/<slug>/v<N>.json`, one per version |
| Index (what lookups read) | `index.json`, compiled by `tools/build_index.py` |
| Factory wiring (OMG) | `omg/factory/games_library.py` (copy lives in the Factory host tree), game loader `omg/kit/` |

## A card
Every number a builder relies on says where it came from: `measured` (from the file or the GPU pass), `authored` (the
kit's maker wrote it), `guessed`. `release` = hash of the fields a game reads + file hashes + dependencies: games pin it.
Frames: vehicles follow the Garage contract (+Z front, +X left, origin between the axles), buildings and props +Z
front with the origin on the ground, weapons origin at the grip with the muzzle on -Z, characters the Cast skeleton.
Imported toy-scale models keep their measured size plus a uniform `scale_to_class`; never stretch.

## Add or update models
1. Put the GLB somewhere stable, add a source rule in `tools/import_existing.py` (or, for new Astra assets, write the
   card from the recipe run).
2. `python3 tools/import_existing.py` (cards + blobs), then the GPU pass: `tools/gpu/run.sh tools/gpu/measure_probe.py
   site/out/measure` (exact sizes incl. skinned models, thumbnails, 0 errors expected).
3. `python3 tools/build_index.py --measured site/out/measure/measured.json`.
4. Publish to gex44: `rsync -a --delete --exclude store --exclude site/out --exclude browse/vendor ./
   root@5.9.10.114:/srv/forge-assets/repo/` and `rsync -a store/blobs/ root@5.9.10.114:/srv/forge-assets/blobs/`
   (claim `gex44:/srv/forge-assets/` first), then `ssh root@5.9.10.114 /srv/forge-assets/repo/tools/gcs_sync.sh` (cloud copy
   + hash check, gs://forge-assets-library-eu). The Factory reloads `index.json` when it changes: no Factory rebuild.
5. Spike: `python3 tools/build_site.py "<HH:MM CET>" && tools/gpu/check_site.sh site/out/check && tools/publish.sh`.
   Progress lines: `python3 tools/log.py "text"` (stamps the real clock).

## Lighter copies (fewer triangles, same look)
In the Browse viewer: **Lighter copy** (auto, or a triangle target) queues a job; the cron `tools/lighter_worker.py` runs
`tools/lighter.py` (glTF-Transform simplify, the original Draco/meshopt put back), checks every try against the original
on the gex44 GPU from 4 sides (`tools/gpu/lighter_compare.sh` + `browse/compare.html`) and keeps the lightest that changes
at most 5 % of the model's pixels AND no small spot by more than 50/255 in brightness (area alone missed a broken window).
The copy is a new **draft** version: games keep the active one until **Use vN in games** (`lighter.py --activate`).
Active version = the card marked `active`, else the newest that is not a draft or withdrawn (`card.active`).
Refused: rigged/people (Cast lane), models under 5,000 triangles. Typed targets stop where texture seams would tear.
Publishing for these jobs is NARROW (`tools/publish_lighter.sh`: index, cards, blobs, spike): never code or kits.
CLI: `python3 tools/lighter.py <id> [--target N]`, `python3 tools/lighter.py --activate <id> <version>`; test
`python3 test/test_lighter.py`. Queue API (prod `feedback/`): `/api/lighter`, `/api/activate`, lane `quick`.

## Rules
- Never delete a blob a game pins. A changed file = a new version, never an overwrite.
- A card without licence evidence does not validate; `approved` requires `game_use`.
- Lookups: an empty class reports a GAP, never a lookalike (the football player "Tank" is not a tank).

## Photo-made objects and semantic search

Photo imports keep the existing card, release, taxonomy and blob contracts. They do not create a second library.

```sh
python3 tools/ingest_photo_object.py object.glb \
  --photo source.jpg --thumbnail rendered-model.jpg --provenance provenance.json \
  --class prop.furniture.sofa --slug my-sofa --name 'Black leather sofa'
python3 tools/build_index.py
python3 tools/enrich_library.py
python3 tools/classify_library_reuse.py
python3 tools/build_index.py
asset-search 'black leather sofa' --json
asset-search --image photo.jpg --json
asset-search --like prop.furniture.sofa/my-sofa --reuse omu --json
```

`provenance.json` requires `method`, `models` (list), `source`, `date` and `licence_evidence`. State source rights,
not just the generator's software licence. Optional `third_party_artwork` or `person` explicitly blocks reuse.
The importer sees the rendered thumbnail through Gemini 4; jev makes the typed safety decision from that description.
Photographed paintings/posters/pictures and people default to reuse OFF. An ordinary cleared object defaults ON for
both `reuse.omg` and `reuse.omu`. `--no-reuse` opts out at import. A card can set either channel false independently.
The rights flag is separate from visual approval: ambiguous frames remain candidates, not Factory picks.
All default pickers check the reuse flags. `asset-search --all --reuse any` is catalogue review, not a game picker.

Input must be a self-contained GLB. `--metres-per-unit` applies one uniform scale; `--yaw` rotates the stated front to
+Z. Bounds are measured from the transformed file, origin is ground centre. Physical accuracy inherited from a room
rebuild is not a tape measurement. Native picture-to-3D scale is explicitly recorded as guessed.
Textures are capped at 1024 px; unused buffers and room-viewer material records are removed. Re-import is incremental.
`--existing-id ID` attaches a source sheet to a pinned, already-framed GLB without destroying its parts or changing its
release. The supplied GLB must match that pin. Use this for Blender+paint jobs, not a new unframed scan.

The room finish step is a new helper, not a change to `finish.py`:

```sh
python3 /home/claude-agent/forge-build/spatial-rebuild-20261013/tools/library_ingest.py \
  /absolute/out/ROOM/walk --library /absolute/forge-assets \
  --out /tmp/wlib-ROOM --licence-evidence 'State who owns and cleared these source photographs'
```

The helper exports clean `object:`/`item:` groups in the page with GLTFExporter, embedded textures, per-piece model
thumbnails and fully visible source-photo crops. It excludes chandeliers and attached building parts. Wall art is
indexed for reference with reuse OFF. `--no-reuse` opts out the room. `--skip-export` reuses an existing export manifest.
The helpers are also versioned under `tools/room/` for the root to install on another box.

### Model and vector contract

* Vision is always `forge:build-gemini-4` through the spatial rebuild's `tools/vlm.sh`, batched, actual thumbnails only.
* Vertex project `forge-dev-492809`, region `us-central1`. The newest tested joint model is
  `gemini-embedding-2-preview`, using **`:embedContent`**, not `:predict`. It accepts text and image together.
  An initial `:predict` probe failed even though the correct endpoint works. Do not confuse that with model absence.
* Active joint vectors use 768 dimensions. `gemini-embedding-001` description vectors provide another text signal.
  Earlier `multimodalembedding@001` probe/build vectors are retained separately, never mixed with the active space.
* `embeddings.json` records descriptions, models and bulk call counters. `vectors/gemini-embedding-2-preview/` stores
  compressed NumPy files, little-endian float16, named by SHA256 of `asset_id + '@' + release`. Each contains `image`,
  `text` (the same joint vector) and `description` (the separate text vector). Legacy vectors stay in `vectors/`.
* Rebuilds check release plus thumbnail hash. A no-change rebuild makes no vision or embedding calls. Model spaces
  have separate directories. `neighbours.json` is rebuilt from active joint vectors. Readers still recheck reuse flags.
* jev safety probabilities are cached by description hash under `safety/`. These are model judgements, not licence proof.
* Install `integrations/asset-search` in `~/bin/asset-search`. Set `FORGE_ASSET_LIBRARY` to override the checkout.
  `integrations/asset_search.py` and the narrow registration snippet wire the same `lib/search.py` into Forge MCP.
* Search filters: `--kind`, `--class`, `--reuse omg|omu`, `--min-size W L H`, `--max-size W L H`. Exact class and alias
  constraints are applied before vector ranking. An empty class is a GAP, never a visually similar different class.

### Local browse, never a live-app test

```sh
python3 tools/build_site.py "$(TZ=Europe/Paris date '+%H:%M CET')"
python3 tools/preview_library.py --port 8893
# Open http://127.0.0.1:8893/browse/index.html, a disposable local library preview.
```

The preview routes search to the same Python engine. Static published browse uses thumbnail descriptions in BM25 plus
precomputed similar items; arbitrary live vector queries need a server route to `lib/search.py`. No browser credentials
are shipped. The worker did not publish or deploy that route. Source-photo disclosures and Similar items work statically.

### Register existing OMG creatures without touching games

`tools/import_creatures.py` reads an attested catalog snapshot and a local GLB folder. It refuses unknown licences,
missing clip roles and attempts to replace a finalized v1 release. It preserves existing thumbnail descriptions on replay.

```sh
/tmp/fa-venv/bin/python tools/import_creatures.py --catalog W-LIBCARDS-evidence/source/catalog.json \
  --source-dir /path/to/copied-creature-glbs
# Run the existing GPU measuring pass on a disposable subset library with Three.js 0.180.0 or newer.
# FAL_ROOT, FAL_RUN_DIR and FAL_PORT let tools/gpu/run.sh use a separately claimed staging folder.
/tmp/fa-venv/bin/python tools/import_creatures.py --catalog W-LIBCARDS-evidence/source/catalog.json \
  --source-dir /path/to/copied-creature-glbs --measured W-LIBCARDS-evidence/measured.json
/tmp/fa-venv/bin/python tools/build_index.py
/tmp/fa-venv/bin/python tools/enrich_library.py --ids-file W-LIBCARDS-evidence/import.json
/tmp/fa-venv/bin/python tools/build_index.py
/tmp/fa-venv/bin/python tools/prove_creature_search.py
```

The scoped enrichment command only calls models for those IDs, but updates neighbours across the library. It also
stores Gemini descriptions and cached jev safety on the new cards. `--refresh` requires `--ids-file` and explicitly
re-describes that selection. Missing ignored legacy thumbnails do not invalidate an existing description/vector cache;
a new card still needs its actual thumbnail. An unchanged full rebuild makes zero model calls.
See `W-LIBCARDS-REPORT.md` for the 30-card handoff, proof, limits and ignored blob manifest. No command above publishes.
