Lane E round 3 (landed by Fable after review): 5 hero props gen'd local + published, local-depot mode, manifest 14 fittings + 8 furniture

- gen_props.py: flux_local -> trellis_mac -> normalize pipeline; 5/8 props kept
  (arcade-cabinet, listening-booth, drinks-fridge, milkshake-mixer, novelty-record)
- magazine-rack -> primitive (thin-wire gotcha); glass-case + bus-shelter -> fal.ai fallback
- cash-register reused from depot; all 22 GLBs live, validate --depot 0/0
- loaders.js ?localdepot=1 + stage_local_depot.py for network-free GLB validation
- qa.sh --strict GREEN 4/4 at commit time

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-14 18:03:03 +10:00
parent 53f472d0d7
commit bd40ec7e7a
20 changed files with 483 additions and 17 deletions

2
.gitignore vendored
View File

@ -7,3 +7,5 @@ pipeline/_normalized/
saves/ saves/
node_modules/ node_modules/
.env .env
pipeline/.genprops/
web/assets/models/

View File

@ -1,7 +1,31 @@
# LANE E — ASSETS · progress (PROCITY-E) # LANE E — ASSETS · progress (PROCITY-E)
*2026-07-14. Owner: PROCITY-E. Scope: `pipeline/*`, `web/assets/*`, `web/assets/manifest.json`. *2026-07-14. Owner: PROCITY-E. Scope: `pipeline/*`, `web/assets/*`, `web/assets/manifest.json`.
Reviewed against `docs/LANES/LANE_E_ASSETS.md` acceptance. Nothing in other lanes' files was touched.* Reviewed against `docs/LANES/LANE_E_ASSETS.md` + `docs/LANES/ROUND3_INSTRUCTIONS.md`. Nothing in
other lanes' files was touched.*
## Round 3 (Fable) — hero props on MODELBEAST + local-depot mode
- **Hero props, free & on-device** (`pipeline/gen_props.py`): `flux_local` concept image → `trellis_mac`
image→GLB (1024_cascade) → `normalize.py` → thumbs → manifest. **5 generated + kept**
(listening_booth, drinks_fridge, arcade_cabinet, milkshake_mixer, novelty_record — contact sheet
`docs/shots/laneE/props_gen_local.png`), **cash_register reused** from the depot
(`vintage-cash-register.glb`). 3 didn't make it: **magazine_rack** → in-engine primitive (112k-tri
wire, the thin-wire gotcha); **glass_case + bus_shelter****fal.ai fallback list** (TRELLIS can't
do transparent glass; sf3d gives a featureless blob) — gated on John, ≤$2.70, both have primitive
fallbacks. Every kept thumbnail eyeballed; no legible branding (see AUDIT provenance).
- **Local-depot mode** (`core/loaders.js` `?localdepot=1` + `pipeline/stage_local_depot.py`): resolves
`depot:<file>` to `web/assets/models/` served by the dev server, so Lanes B/C/F validate GLB loading
**with no network / before publish**. Verified live in-browser (loads a `depot:` GLB, CDN path intact).
- **Manifest**: 14 fittings + 8 furniture (5 hero + cash_register added); `validate_manifest.py`
green (0 err). Provenance recorded in `AUDIT.md` (props are 100% synthetic/local — cleanest in the set).
- **Known tradeoff**: TRELLIS meshes are dense shell-soup — decimate floors 2577k tris (heavier than
the ≤5k library law). Accepted for focal/optional/LOD-able props; texture-bake-to-lowpoly is a follow-up.
- **Published (self-serve, John's go 2026-07-14)**: the 5 prop GLBs + thumbs pushed to the depot via
the passwordless tailnet path (decision #4); all 22 GLBs re-verified live. **`qa.sh --strict` GREEN**
(4/4 gates; manifest validator 0 err / 0 warn with the props live). Provenance was recorded first.
- **Not committed**: this round is **held for Fable review** (per John) — all Lane E round-3 changes are
in the working tree, uncommitted. GLB binaries stay git-ignored/staged; the depot has them.
## Status at a glance ## Status at a glance
@ -11,7 +35,8 @@ Reviewed against `docs/LANES/LANE_E_ASSETS.md` acceptance. Nothing in other lane
| 2 Normalize | `pipeline/normalize.py`**16 GLBs** + 16 thumbs, verified | ✅ done (local); ⛔ **publish gated on `GOD3_PW`** | | 2 Normalize | `pipeline/normalize.py`**16 GLBs** + 16 thumbs, verified | ✅ done (local); ⛔ **publish gated on `GOD3_PW`** |
| 3 Manifest | `web/assets/manifest.json` + `validate_manifest.py` (+ `build_manifest.py`) | ✅ done, validates green | | 3 Manifest | `web/assets/manifest.json` + `validate_manifest.py` (+ `build_manifest.py`) | ✅ done, validates green |
| 4 Facade skins | `gen_skins.py` **`--local`** backend + 23-skin gap pack | ✅ **DONE — 23 skins generated on-device, free, harvested + mapped** | | 4 Facade skins | `gen_skins.py` **`--local`** backend + 23-skin gap pack | ✅ **DONE — 23 skins generated on-device, free, harvested + mapped** |
| 5 Hero props | `MESHGOD_BATCH.md` + `meshgod_batch.json` (9 solids) | ✅ spec'd; ⛔ **not run (John's go, ~$2.70)** | | 5 Hero props | round-3 `gen_props.py` (MODELBEAST, free) → **5 local** + cash-register reuse | ✅ **DONE local** (see Round 3 above; 2→fal, 1→primitive) |
| 6 Local-depot | `loaders.js ?localdepot=1` + `stage_local_depot.py` | ✅ **DONE, verified in-browser** |
| — | `pipeline/README.md` + contact sheets in `docs/shots/laneE/` | ✅ done | | — | `pipeline/README.md` + contact sheets in `docs/shots/laneE/` | ✅ done |
**Step 4 no longer costs anything.** The 23 facades were generated **locally on the M3 Ultra** **Step 4 no longer costs anything.** The 23 facades were generated **locally on the M3 Ultra**

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

@ -90,6 +90,31 @@ cleared. These are **excluded from the manifest**:
> If provenance genuinely can't be established for one, pull it from the publish set. This is a > If provenance genuinely can't be established for one, pull it from the publish set. This is a
> reason the publish step is deliberately gated on John, not automated. > reason the publish step is deliberately gated on John, not automated.
## Provenance — round-3 hero props (for the depot publish sign-off)
Per the round-3 instruction ("record the per-asset provenance list in AUDIT.md first, then hand
John the two-liner"). The hero props have the **cleanest provenance in the whole set** — they are
100% generated on-device, from no third-party source:
| asset (`procity_fit/street_<id>_01.glb`) | origin | licence zone |
|---|---|---|
| **5 generated** — listening_booth, drinks_fridge, arcade_cabinet, milkshake_mixer (fit) · novelty_record (street) | **fully synthetic, local**: concept image = MODELBEAST `flux_local` (FLUX.2-klein-4B, Apache-2.0 model) → mesh = `trellis_mac` (TRELLIS.2, an original reconstruction of that concept image). No scanned/third-party geometry, no copyrighted source asset. | 🟢 original work (CC0-equivalent) |
| cash_register (`vintage-cash-register.glb`) | **reuse** of John's own `thriftgod_hero` asset, already live on 3GOD (round-3 reuse check) — not regenerated. | 🟢 same as existing depot set |
| magazine_rack | **not shipped** — TRELLIS wire rack floored ~112k tris (thin-wire gotcha) → in-engine primitive. | n/a |
| glass_case, bus_shelter | **not shipped** — TRELLIS failed on the transparent glass → fal.ai fallback list (John's go, ≤$2.70) or primitive. | n/a |
- **Prompts were generic period objects with explicit `no text, no brands, no people`.** Every kept
prop was eyeballed for accidental baked branding (contact sheet `docs/shots/laneE/props_gen_local.png`).
The only textual marks are the **drinks-fridge's weathered faux-stickers** — verified at full concept
resolution to be **garbled, illegible pseudo-lettering** (no real trademark; e.g. a faux barcode +
nonsense glyphs), which reads as period flavour and blurs out at mesh-texture resolution. 🟢 cleared.
- Tri/size note: TRELLIS meshes are dense multi-shell scans; `normalize.py`'s COLLAPSE decimate floors
well above the ≤5k library budget (**2577k tris, ~0.33.5 MB/prop**; neither COLLAPSE nor meshopt
can reduce shell-soup without a voxel-remesh that destroys the baked UVs). Accepted for **focal,
optional, LOD-able** props (texture-intact) — flagged for a later texture-bake-to-lowpoly / gltfpack pass.
- These GLBs stage locally (`web/assets/models/`, git-ignored, served by `?localdepot=1`) and await
John's depot publish; they are **not** committed binaries (same policy as the library `_normalized` set).
## Coverage vs the registry fittings needs ## Coverage vs the registry fittings needs
| shop type | fittings need | covered by | gap → | | shop type | fittings need | covered by | gap → |

View File

@ -71,10 +71,29 @@ no key, weights cached). It runs one GPU job at a time. Add skins by editing `PR
`.genraw/` and delete rejects before `--harvest`.** Cloud fallback (drop `--local`): OpenRouter `.genraw/` and delete rejects before `--harvest`.** Cloud fallback (drop `--local`): OpenRouter
`google/gemini-3.1-flash-image`, ~$0.004/img, needs `OPENROUTER_KEY`. Manual flow: `SKIN_PROMPTS.md`. `google/gemini-3.1-flash-image`, ~$0.004/img, needs `OPENROUTER_KEY`. Manual flow: `SKIN_PROMPTS.md`.
### 6. Bespoke hero props — `MESHGOD_BATCH.md` + `meshgod_batch.json` *(≈$2.70, GATED)* ### 6. Bespoke hero props — `gen_props.py` (MODELBEAST) *(round-3: DONE, free, on-device)*
Glass case, fridge, till, listening booth, etc. — the gaps no library asset fills. **Do not run The `meshgod_batch.json` gap list now goes through MODELBEAST instead of fal.ai — free, local:
without John's go**; check the DealGod cache / depot for reuse first (cash-register + glass-case ```bash
likely already exist). Everything has a primitive fallback. python3 pipeline/gen_props.py --dry-run # list props + what's done
python3 pipeline/gen_props.py --concepts # flux_local concept images → .genprops/*.png (eyeball!)
python3 pipeline/gen_props.py --mesh --only glass-case # trellis_mac 1024_cascade image→GLB (~5-8 min)
python3 pipeline/gen_props.py --mesh # all props with a concept, no glb
# then normalize (uses local Blender), stage, catalogue:
BL=/Applications/Blender.app/Contents/MacOS/Blender
"$BL" --background --python pipeline/normalize.py -- pipeline/batch_props.json pipeline/_normalized web/assets/thumbs
cp pipeline/_normalized/_results.json pipeline/_props_results.json && python3 pipeline/build_manifest.py
```
**5 shipped** (arcade_cabinet, listening_booth, drinks_fridge, milkshake_mixer, novelty_record) + cash-register
reused from depot. `magazine-rack` → primitive (thin-wire gotcha); `glass-case`/`bus-shelter` → fal.ai
fallback (TRELLIS can't do transparent glass; `sf3d --draft` gives a featureless blob). TRELLIS meshes run
heavy (2577k tris) — see AUDIT provenance. **Eyeball every thumbnail**; ≤2 local attempts then fal (John's go).
### 7. Local-depot mode — `loaders.js ?localdepot=1` + `stage_local_depot.py` *(round-3: DONE)*
Lets Lanes B/C/F validate GLB loading with **no network / before publish**:
```bash
python3 pipeline/stage_local_depot.py # mirror _normalized/*.glb → web/assets/models/ (git-ignored)
# then load the game with ?localdepot=1 — depot:<file> resolves to assets/models/<file> instead of the CDN
```
## Cost summary ## Cost summary

View File

@ -0,0 +1,90 @@
{
"results": [
{
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/listening-booth.glb",
"file": "procity_fit_listening_booth_01.glb",
"out": "pipeline/_normalized/procity_fit_listening_booth_01.glb",
"thumb": "web/assets/thumbs/procity_fit_listening_booth_01.png",
"tri_before": 146946,
"tri_after": 53578,
"size_m": [
0.879,
1.6,
0.71
],
"footprint": [
0.88,
0.71
]
},
{
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/drinks-fridge.glb",
"file": "procity_fit_drinks_fridge_01.glb",
"out": "pipeline/_normalized/procity_fit_drinks_fridge_01.glb",
"thumb": "web/assets/thumbs/procity_fit_drinks_fridge_01.png",
"tri_before": 191640,
"tri_after": 77412,
"size_m": [
0.849,
1.6,
0.962
],
"footprint": [
0.85,
0.96
]
},
{
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/arcade-cabinet.glb",
"file": "procity_fit_arcade_cabinet_01.glb",
"out": "pipeline/_normalized/procity_fit_arcade_cabinet_01.glb",
"thumb": "web/assets/thumbs/procity_fit_arcade_cabinet_01.png",
"tri_before": 196336,
"tri_after": 25143,
"size_m": [
0.75,
1.7,
0.853
],
"footprint": [
0.75,
0.85
]
},
{
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/milkshake-mixer.glb",
"file": "procity_fit_milkshake_mixer_01.glb",
"out": "pipeline/_normalized/procity_fit_milkshake_mixer_01.glb",
"thumb": "web/assets/thumbs/procity_fit_milkshake_mixer_01.png",
"tri_before": 185106,
"tri_after": 73498,
"size_m": [
0.381,
0.5,
0.271
],
"footprint": [
0.38,
0.27
]
},
{
"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/novelty-record.glb",
"file": "procity_street_novelty_record_01.glb",
"out": "pipeline/_normalized/procity_street_novelty_record_01.glb",
"thumb": "web/assets/thumbs/procity_street_novelty_record_01.png",
"tri_before": 193253,
"tri_after": 26506,
"size_m": [
1.909,
2.0,
0.934
],
"footprint": [
1.91,
0.93
]
}
],
"errors": []
}

View File

@ -1,10 +1,14 @@
[ [
"procity_fit_arcade_cabinet_01.glb",
"procity_fit_bookshelf_01.glb", "procity_fit_bookshelf_01.glb",
"procity_fit_box_crate_01.glb", "procity_fit_box_crate_01.glb",
"procity_fit_clothes_rack_01.glb", "procity_fit_clothes_rack_01.glb",
"procity_fit_coffee_table_01.glb", "procity_fit_coffee_table_01.glb",
"procity_fit_counter_01.glb", "procity_fit_counter_01.glb",
"procity_fit_cube_shelf_wide_01.glb", "procity_fit_cube_shelf_wide_01.glb",
"procity_fit_drinks_fridge_01.glb",
"procity_fit_listening_booth_01.glb",
"procity_fit_milkshake_mixer_01.glb",
"procity_fit_record_crate_01.glb", "procity_fit_record_crate_01.glb",
"procity_fit_wire_shelf_01.glb", "procity_fit_wire_shelf_01.glb",
"procity_fit_work_table_01.glb", "procity_fit_work_table_01.glb",
@ -13,6 +17,8 @@
"procity_street_bench_wood_01.glb", "procity_street_bench_wood_01.glb",
"procity_street_food_cart_01.glb", "procity_street_food_cart_01.glb",
"procity_street_longbench_01.glb", "procity_street_longbench_01.glb",
"procity_street_novelty_record_01.glb",
"procity_street_park_bench_01.glb", "procity_street_park_bench_01.glb",
"procity_street_streetlight_01.glb" "procity_street_streetlight_01.glb",
"vintage-cash-register.glb"
] ]

10
pipeline/batch_props.json Normal file
View File

@ -0,0 +1,10 @@
[
{"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/listening-booth.glb", "id": "listening_booth", "category": "fit", "height_m": 1.6, "tris": 6000},
{"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/glass-case.glb", "id": "glass_case", "category": "fit", "height_m": 1.0, "tris": 6000},
{"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/drinks-fridge.glb", "id": "drinks_fridge", "category": "fit", "height_m": 1.6, "tris": 5000},
{"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/magazine-rack.glb", "id": "magazine_rack", "category": "fit", "height_m": 1.2, "tris": 4000},
{"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/arcade-cabinet.glb", "id": "arcade_cabinet", "category": "fit", "height_m": 1.7, "tris": 6000},
{"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/milkshake-mixer.glb", "id": "milkshake_mixer", "category": "fit", "height_m": 0.5, "tris": 4000},
{"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/novelty-record.glb", "id": "novelty_record", "category": "street", "height_m": 2.0, "tris": 4000},
{"src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/bus-shelter.glb", "id": "bus_shelter", "category": "street", "height_m": 2.4, "tris": 8000}
]

View File

@ -13,10 +13,19 @@ import json, os, glob
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GEN = os.path.join(ROOT, "web", "assets", "gen") GEN = os.path.join(ROOT, "web", "assets", "gen")
RESULTS = os.path.join(ROOT, "pipeline", "_normalize_results.json") RESULTS = os.path.join(ROOT, "pipeline", "_normalize_results.json") # library GLBs
PROPS_RESULTS = os.path.join(ROOT, "pipeline", "_props_results.json") # round-3 hero props
OUT = os.path.join(ROOT, "web", "assets", "manifest.json") OUT = os.path.join(ROOT, "web", "assets", "manifest.json")
DEPOT = "https://digalot.fyi/3god" DEPOT = "https://digalot.fyi/3god"
# ── depot-reuse: already-published GLBs we point at without a local normalize. The round-3 reuse
# check found vintage-cash-register.glb live on 3GOD (from thriftgod_hero) — reuse it for the
# universal till instead of re-generating. Manual footprint/height (no local file to measure);
# no local thumb, so it isn't staged for ?localdepot (falls back to primitive there). ──
DEPOT_REUSE = {
"cash_register": {"file": "vintage-cash-register.glb", "footprint": [0.35, 0.28], "height": 0.35},
}
# ── curated: facade skin → shop types it suits ───────────────────────────────────────── # ── curated: facade skin → shop types it suits ─────────────────────────────────────────
# MIRRORS Lane A's web/js/core/registry.js SHOP_TYPES[*].facades (the authoritative render-time # MIRRORS Lane A's web/js/core/registry.js SHOP_TYPES[*].facades (the authoritative render-time
# pool) so there is ONE consistent mapping, plus additive-only coverage: cashtheists→pawn (a pawn # pool) so there is ONE consistent mapping, plus additive-only coverage: cashtheists→pawn (a pawn
@ -133,13 +142,14 @@ def build():
"awning": awning, "awning": awning,
} }
# ---- fittings / furniture from the normalizer results ---- # ---- fittings / furniture from the normalizer results (library + round-3 hero props) ----
fittings, furniture = {}, {} fittings, furniture = {}, {}
# cube_shelf dropped: horizontal node-scale distortion (6.3x8.4m footprint) — see AUDIT.md # cube_shelf dropped: horizontal node-scale distortion (6.3x8.4m footprint) — see AUDIT.md
DROP = {"procity_fit_cube_shelf_01.glb"} DROP = {"procity_fit_cube_shelf_01.glb"}
if os.path.exists(RESULTS): for results_file in (RESULTS, PROPS_RESULTS):
res = json.load(open(RESULTS))["results"] if not os.path.exists(results_file):
for r in res: continue
for r in json.load(open(results_file)).get("results", []):
if r["file"] in DROP: if r["file"] in DROP:
continue continue
fid = r["file"].replace("procity_fit_", "").replace("procity_street_", "").replace("_01.glb", "") fid = r["file"].replace("procity_fit_", "").replace("procity_street_", "").replace("_01.glb", "")
@ -150,6 +160,9 @@ def build():
"thumb": "thumbs/" + r["file"].replace(".glb", ".png"), "thumb": "thumbs/" + r["file"].replace(".glb", ".png"),
} }
(furniture if r["file"].startswith("procity_street_") else fittings)[fid] = entry (furniture if r["file"].startswith("procity_street_") else fittings)[fid] = entry
# depot-reuse assets (no local normalize) — e.g. the reused vintage cash register
for fid, meta in DEPOT_REUSE.items():
fittings.setdefault(fid, dict(meta))
# registry.js semantic fitting id → our physical asset id (only where a GLB exists; the rest # registry.js semantic fitting id → our physical asset id (only where a GLB exists; the rest
# stay primitives — see MESHGOD_BATCH.md). Lets a consumer that iterates registry SHOP_TYPES.fittings # stay primitives — see MESHGOD_BATCH.md). Lets a consumer that iterates registry SHOP_TYPES.fittings

147
pipeline/gen_props.py Normal file
View File

@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""PROCITY hero props — free, on-device (round-3 Fable path: MODELBEAST, not fal.ai).
Two local stages, both on the M3 Ultra GPU, zero cloud cost:
1. concept image flux_local (FLUX.2-klein-4B) text->object photo ~6s
2. mesh trellis_mac (TRELLIS.2-4B) image->GLB+PBR ~3-5 min (1024_cascade)
then pipeline/normalize.py (house GLB law) + thumbnail + manifest.
Prop list + prompts come from pipeline/meshgod_batch.json (the same gap list that was going to
fal.ai). cash-register is skipped vintage-cash-register.glb already lives on the 3GOD depot.
python3 pipeline/gen_props.py --dry-run # list props + what's done, no GPU
python3 pipeline/gen_props.py --concepts # stage 1 for all -> .genprops/<name>.png
python3 pipeline/gen_props.py --concepts --only glass-case
python3 pipeline/gen_props.py --mesh --only glass-case # stage 2 for one (concept must exist)
python3 pipeline/gen_props.py --mesh # stage 2 for every prop with a concept, no glb
python3 pipeline/gen_props.py --draft --only glass-case # sf3d seconds-fast draft (iterate concept)
Resumable: skips a stage whose output already exists in .genprops/. Eyeball .genprops/*.png (concepts)
and the rendered GLBs before normalizing. Everything here is OPTIONAL at runtime every prop has an
in-engine primitive fallback (see MESHGOD_BATCH.md _primitives_instead), so nothing blocks on it.
"""
import json, os, sys, subprocess, shutil, time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RAW = os.path.join(ROOT, "pipeline", ".genprops")
BATCH = os.path.join(ROOT, "pipeline", "meshgod_batch.json")
os.makedirs(RAW, exist_ok=True)
MB = os.environ.get("MB_HOME", os.path.expanduser("~/Documents/MODELBEAST"))
FLUX_PY = os.path.join(MB, "venvs/mflux/bin/python")
FLUX_RUN = os.path.join(MB, "server/operators/flux_local/run.py")
TRELLIS_PY = os.path.join(MB, "vendor/trellis-mac/.venv/bin/python")
TRELLIS_RUN = os.path.join(MB, "server/operators/trellis_mac/run.py")
SF3D_PY = os.path.join(MB, "venvs/sf3d/bin/python")
SF3D_RUN = os.path.join(MB, "server/operators/sf3d/run.py")
# Clean product-shot framing so TRELLIS/sf3d (which segment the subject) get a whole, isolated
# object on a plain backdrop — NOT the faded op-shop facade look. Period-appropriate but neutral.
CONCEPT = ("Clean studio product photograph of {v}. Single object, centred, entire object in "
"frame, three-quarter view, plain seamless pale-grey backdrop, soft even lighting, "
"sharp focus, no text, no people, no other objects, 1990s Australian period-correct.")
def props():
data = json.load(open(BATCH))
out = []
for a in data["assets"]:
# cash-register: reuse the depot's vintage-cash-register.glb (round-3 reuse check)
if a["name"] == "cash-register":
continue
out.append(a)
return out
def concept_path(name):
return os.path.join(RAW, f"{name}.png")
def glb_path(name):
return os.path.join(RAW, f"{name}.glb")
def gen_concept(a, seed=3):
name, prompt = a["name"], a["prompt"]
outdir = os.path.join(RAW, "_c_" + name)
os.makedirs(outdir, exist_ok=True)
params = json.dumps({"prompt": CONCEPT.format(v=prompt), "model": "flux2-klein-4b",
"steps": 4, "width": 1024, "height": 1024, "seed": seed})
r = subprocess.run([FLUX_PY, FLUX_RUN, "--outdir", outdir, "--params", params],
capture_output=True, text=True, timeout=300)
pngs = [p for p in os.listdir(outdir) if p.endswith(".png")]
if not pngs:
raise RuntimeError((r.stderr or r.stdout or "no png")[-200:])
shutil.move(os.path.join(outdir, pngs[0]), concept_path(name))
shutil.rmtree(outdir, ignore_errors=True)
return concept_path(name)
def gen_mesh(a, draft=False):
"""concept image -> GLB. TRELLIS 1024_cascade by default; sf3d for a fast draft."""
name = a["name"]
cpath = concept_path(name)
if not os.path.exists(cpath):
raise RuntimeError(f"no concept image for {name} — run --concepts first")
outdir = os.path.join(RAW, "_m_" + name)
os.makedirs(outdir, exist_ok=True)
if draft:
params = json.dumps({"device": "mps", "texture_resolution": 1024, "foreground_ratio": 0.85})
cmd = [SF3D_PY, SF3D_RUN, "--input", cpath, "--outdir", outdir, "--params", params]
tmo = 600
else:
params = json.dumps({"pipeline_type": "1024_cascade", "texture_size": 1024, "seed": 0})
cmd = [TRELLIS_PY, TRELLIS_RUN, "--input", cpath, "--outdir", outdir, "--params", params]
tmo = 1200
r = subprocess.run(cmd, capture_output=True, text=True, timeout=tmo)
glbs = []
for dp, _, fs in os.walk(outdir):
glbs += [os.path.join(dp, f) for f in fs if f.endswith(".glb")]
if not glbs:
raise RuntimeError((r.stderr or r.stdout or "no glb")[-240:])
dst = glb_path(name)
shutil.move(sorted(glbs)[0], dst)
shutil.rmtree(outdir, ignore_errors=True)
return dst
if __name__ == "__main__":
only = sys.argv[sys.argv.index("--only") + 1] if "--only" in sys.argv else ""
draft = "--draft" in sys.argv
todo = [a for a in props() if not only or only in a["name"]]
if "--dry-run" in sys.argv:
print(f"{len(todo)} props (cash-register reused from depot):")
for a in todo:
c = "concept✓" if os.path.exists(concept_path(a["name"])) else "concept·"
g = "glb✓" if os.path.exists(glb_path(a["name"])) else "glb·"
print(f" {a['name']:16s} {c} {g} h={a['height_m']}m for={a['for']}")
sys.exit(0)
do_concepts = "--concepts" in sys.argv or (not "--mesh" in sys.argv and not draft)
do_mesh = "--mesh" in sys.argv or draft
if do_concepts:
for a in todo:
if os.path.exists(concept_path(a["name"])):
print(f"[concept] {a['name']} — exists, skip"); continue
t0 = time.time()
try:
gen_concept(a)
print(f"[concept] {a['name']}{round(time.time()-t0,1)}s")
except Exception as e:
print(f"[concept] {a['name']} FAILED: {e}")
if do_mesh:
tool = "sf3d-draft" if draft else "trellis 1024_cascade"
for a in todo:
if not draft and os.path.exists(glb_path(a["name"])):
print(f"[mesh] {a['name']} — glb exists, skip"); continue
t0 = time.time()
try:
dst = gen_mesh(a, draft=draft)
kb = os.path.getsize(dst) // 1024
print(f"[mesh:{tool}] {a['name']}{round(time.time()-t0,1)}s ({kb}KB)")
except Exception as e:
print(f"[mesh:{tool}] {a['name']} FAILED: {e}")
print("done. Eyeball .genprops/*.png (concepts) + rendered GLBs before normalize.")

View File

@ -1,5 +1,13 @@
{ {
"_comment": "PROCITY hero props — the gaps the sorted library CAN'T fill (see AUDIT.md coverage table). Text->3D via MeshGod. height_m = real-world height (1 unit = 1m). Run ONLY with John's go (≈30c/solid ≈ $2.70): python -m meshgod.batch manifest pipeline/meshgod_batch.json --out assets3d/procity . BEFORE generating, check the DealGod canon-3D cache / 3GOD /api/list for reuse (cash-register + glass-case likely already exist from thriftgod_hero.json). MeshGod gotcha: thin wires / flat discs reconstruct badly — those stay in-engine primitives and are NOT in this list.", "_comment": "PROCITY hero props — the gaps the sorted library CAN'T fill (see AUDIT.md coverage table). Text->3D via MeshGod. height_m = real-world height (1 unit = 1m). Run ONLY with John's go (≈30c/solid ≈ $2.70): python -m meshgod.batch manifest pipeline/meshgod_batch.json --out assets3d/procity . BEFORE generating, check the DealGod canon-3D cache / 3GOD /api/list for reuse (cash-register + glass-case likely already exist from thriftgod_hero.json). MeshGod gotcha: thin wires / flat discs reconstruct badly — those stay in-engine primitives and are NOT in this list.",
"_round3_outcome": {
"_comment": "Round 3 (Fable): these went through MODELBEAST (flux_local concept -> trellis_mac GLB) instead of fal.ai — free, on-device. Pipeline: pipeline/gen_props.py. Outcome below; see pipeline/gen_props.py + docs/shots/laneE/props_gen_local.png.",
"generated_local": ["listening-booth", "drinks-fridge", "arcade-cabinet", "milkshake-mixer", "novelty-record"],
"reused_from_depot": {"cash-register": "vintage-cash-register.glb (thriftgod_hero, already live on 3GOD)"},
"to_primitive": {"magazine-rack": "TRELLIS wire rack = shell-soup, floors ~112k tris (COLLAPSE + meshopt both fail on wire) — the classic thin-wire gotcha; use an in-engine wire primitive instead"},
"fal_fallback": {"_comment": "TRELLIS failed on these transparent-glass subjects (no GLB); sf3d gives a featureless blob. Local 2-attempt budget spent -> fal.ai, gated on John (<=$2.70). Both have in-engine primitive fallbacks so nothing blocks.", "glass-case": "transparent glass top defeats TRELLIS segmentation", "bus-shelter": "thin steel frame + perspex panels"},
"note_tris": "TRELLIS meshes are dense multi-shell scans; normalize.py decimate floors 25-77k tris (>library <=5k law). Accepted for focal/optional/LOD-able props (texture-intact); a texture-bake-to-lowpoly / gltfpack pass is a follow-up."
},
"assets": [ "assets": [
{"name": "listening-booth", "prompt": "a record shop listening station booth, angled timber panel with a shelf and hanging headphones, single stool", "height_m": 1.6, "tris": 6000, "for": "record"}, {"name": "listening-booth", "prompt": "a record shop listening station booth, angled timber panel with a shelf and hanging headphones, single stool", "height_m": 1.6, "tris": 6000, "for": "record"},
{"name": "cash-register", "prompt": "an ornate antique brass cash register with round number keys and a bell", "height_m": 0.35, "tris": 6000, "for": "all", "reuse": "thriftgod_hero:vintage-cash-register — check depot first"}, {"name": "cash-register", "prompt": "an ornate antique brass cash register with round number keys and a bell", "height_m": 0.35, "tris": 6000, "for": "all", "reuse": "thriftgod_hero:vintage-cash-register — check depot first"},

View File

@ -183,13 +183,23 @@ def normalize_one(spec):
for i in range(n0, n1): for i in range(n0, n1):
obj.data.polygons[i].material_index = widx obj.data.polygons[i].material_index = widx
# decimate to budget (finish_glb.py:80) # decimate to budget (finish_glb.py:80). TRELLIS meshes are dense multi-shell scans where a
tris = len(obj.data.polygons) # single COLLAPSE pass stalls far above target (arcade: 196k→25k at ratio 0.03); iterate —
# each pass re-targets the budget from the now-smaller count — until within 10% or a pass
# stops making progress (collapse limit). Library assets already under budget skip the loop.
budget = spec.get("tris", 5000) budget = spec.get("tris", 5000)
if tris > budget: tris = len(obj.data.polygons)
passes = 0
while tris > budget * 1.1 and passes < 5:
m = obj.modifiers.new('decimate', 'DECIMATE') m = obj.modifiers.new('decimate', 'DECIMATE')
m.ratio = max(0.01, budget / tris) m.ratio = max(0.02, budget / tris)
bpy.ops.object.modifier_apply(modifier=m.name) bpy.ops.object.modifier_apply(modifier=m.name)
new = len(obj.data.polygons)
passes += 1
if new >= tris * 0.97: # barely moved — collapse limit reached, don't spin
tris = new
break
tris = new
tri_after = len(obj.data.polygons) tri_after = len(obj.data.polygons)
# self-illuminate dark bakes (finish_glb.py:88) # self-illuminate dark bakes (finish_glb.py:88)

View File

@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Mirror the normalized GLBs into web/assets/models/ so loaders.js local-depot mode
(?localdepot=1) can serve them from the dev server Lanes B/C/F validate GLB loading with
NO network and before/without the 3GOD publish.
python3 pipeline/stage_local_depot.py # copy pipeline/_normalized/*.glb -> web/assets/models/
python3 pipeline/stage_local_depot.py --clean # wipe web/assets/models/ first
Re-run whenever _normalized changes (new hero props, re-normalized assets). The staged copies are
the same files the depot serves as depot:<file>; local mode just points at these instead of the CDN.
"""
import os, sys, glob, shutil
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC = os.path.join(ROOT, "pipeline", "_normalized")
DST = os.path.join(ROOT, "web", "assets", "models")
if "--clean" in sys.argv and os.path.isdir(DST):
shutil.rmtree(DST)
os.makedirs(DST, exist_ok=True)
glbs = sorted(glob.glob(os.path.join(SRC, "*.glb")))
if not glbs:
print(f"no GLBs in {SRC} — run normalize.py first")
sys.exit(1)
n = 0
for src in glbs:
dst = os.path.join(DST, os.path.basename(src))
# copy only if missing or changed (idempotent, keeps mtimes stable for git)
if not os.path.exists(dst) or os.path.getsize(dst) != os.path.getsize(src):
shutil.copyfile(src, dst)
n += 1
print(f"staged {len(glbs)} GLBs -> web/assets/models/ ({n} new/changed)")
print("loaders.js ?localdepot=1 now resolves depot:<file> to assets/models/<file>")

View File

@ -647,6 +647,50 @@
], ],
"height": 0.6, "height": 0.6,
"thumb": "thumbs/procity_fit_box_crate_01.png" "thumb": "thumbs/procity_fit_box_crate_01.png"
},
"listening_booth": {
"file": "procity_fit_listening_booth_01.glb",
"footprint": [
0.88,
0.71
],
"height": 1.6,
"thumb": "thumbs/procity_fit_listening_booth_01.png"
},
"drinks_fridge": {
"file": "procity_fit_drinks_fridge_01.glb",
"footprint": [
0.85,
0.96
],
"height": 1.6,
"thumb": "thumbs/procity_fit_drinks_fridge_01.png"
},
"arcade_cabinet": {
"file": "procity_fit_arcade_cabinet_01.glb",
"footprint": [
0.75,
0.85
],
"height": 1.7,
"thumb": "thumbs/procity_fit_arcade_cabinet_01.png"
},
"milkshake_mixer": {
"file": "procity_fit_milkshake_mixer_01.glb",
"footprint": [
0.38,
0.27
],
"height": 0.5,
"thumb": "thumbs/procity_fit_milkshake_mixer_01.png"
},
"cash_register": {
"file": "vintage-cash-register.glb",
"footprint": [
0.35,
0.28
],
"height": 0.35
} }
}, },
"furniture": { "furniture": {
@ -712,6 +756,15 @@
], ],
"height": 2.0, "height": 2.0,
"thumb": "thumbs/procity_street_food_cart_01.png" "thumb": "thumbs/procity_street_food_cart_01.png"
},
"novelty_record": {
"file": "procity_street_novelty_record_01.glb",
"footprint": [
1.91,
0.93
],
"height": 2.0,
"thumb": "thumbs/procity_street_novelty_record_01.png"
} }
}, },
"fittingAliases": { "fittingAliases": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -6,6 +6,29 @@ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
export const DEPOT = 'https://digalot.fyi/3god'; // shared GLB CDN: GET /a/<file>, /api/list export const DEPOT = 'https://digalot.fyi/3god'; // shared GLB CDN: GET /a/<file>, /api/list
// Local-depot mode (Lane E round-3): resolve depot:<file> to GLBs served by the dev server
// (web/assets/models/) instead of the CDN, so Lanes B/C/F can validate GLB loading with NO
// network / before the depot publish. Enable per-load with ?localdepot=1 (or ?localdepot=<base>),
// or globally with window.PROCITY_LOCALDEPOT = true | '<base>'. Staged GLBs = the normalized
// library set + hero props (mirror of pipeline/_normalized; run pipeline/stage_local_depot.py).
export const LOCAL_MODELS = 'assets/models/';
function localDepotBase() {
try {
const q = new URLSearchParams(location.search).get('localdepot');
if (q === '1' || q === '' || q === 'true') return LOCAL_MODELS;
if (q) return q.endsWith('/') ? q : q + '/';
} catch { /* no location (non-browser import) */ }
const g = (typeof window !== 'undefined') && window.PROCITY_LOCALDEPOT;
if (g === true) return LOCAL_MODELS;
if (typeof g === 'string' && g) return g.endsWith('/') ? g : g + '/';
return null;
}
const LOCAL_DEPOT = localDepotBase();
// resolve a depot:<file> ref to a concrete URL (local mode → served copy, else the CDN)
export function depotURL(file) {
return LOCAL_DEPOT ? LOCAL_DEPOT + file : `${DEPOT}/a/${file}`;
}
const gltfLoader = new GLTFLoader(); const gltfLoader = new GLTFLoader();
const texLoader = new THREE.TextureLoader(); const texLoader = new THREE.TextureLoader();
const GLB_CACHE = {}; // url → Promise<gltf|null> const GLB_CACHE = {}; // url → Promise<gltf|null>
@ -13,7 +36,7 @@ const TEX_CACHE = {}; // url → THREE.Texture (may still be loading; fallback
// loadGLB('gen/x.glb' | 'depot:file.glb' | full URL) → Promise<gltf|null>, cached, never rejects // loadGLB('gen/x.glb' | 'depot:file.glb' | full URL) → Promise<gltf|null>, cached, never rejects
export function loadGLB(ref) { export function loadGLB(ref) {
const url = ref.startsWith('depot:') ? `${DEPOT}/a/${ref.slice(6)}` : ref; const url = ref.startsWith('depot:') ? depotURL(ref.slice(6)) : ref;
return (GLB_CACHE[url] ||= gltfLoader.loadAsync(url).catch(err => { return (GLB_CACHE[url] ||= gltfLoader.loadAsync(url).catch(err => {
console.warn('[loaders] GLB failed, placeholder stays:', url, err?.message || err); console.warn('[loaders] GLB failed, placeholder stays:', url, err?.message || err);
return null; return null;