diff --git a/.gitignore b/.gitignore index a507c0e..b180dc9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ pipeline/_normalized/ saves/ node_modules/ .env +pipeline/.genprops/ +web/assets/models/ diff --git a/E-progress.md b/E-progress.md index 8e397f3..5b1fe06 100644 --- a/E-progress.md +++ b/E-progress.md @@ -1,7 +1,31 @@ # LANE E — ASSETS · progress (PROCITY-E) *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:` 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 25–77k 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 @@ -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`** | | 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** | -| 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 | **Step 4 no longer costs anything.** The 23 facades were generated **locally on the M3 Ultra** diff --git a/docs/shots/laneE/props_gen_local.png b/docs/shots/laneE/props_gen_local.png new file mode 100644 index 0000000..da27984 Binary files /dev/null and b/docs/shots/laneE/props_gen_local.png differ diff --git a/pipeline/AUDIT.md b/pipeline/AUDIT.md index 037c8c8..893afca 100644 --- a/pipeline/AUDIT.md +++ b/pipeline/AUDIT.md @@ -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 > 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__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 (**25–77k tris, ~0.3–3.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 | shop type | fittings need | covered by | gap → | diff --git a/pipeline/README.md b/pipeline/README.md index dfe67cf..7e2a596 100644 --- a/pipeline/README.md +++ b/pipeline/README.md @@ -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 `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)* -Glass case, fridge, till, listening booth, etc. — the gaps no library asset fills. **Do not run -without John's go**; check the DealGod cache / depot for reuse first (cash-register + glass-case -likely already exist). Everything has a primitive fallback. +### 6. Bespoke hero props — `gen_props.py` (MODELBEAST) *(round-3: DONE, free, on-device)* +The `meshgod_batch.json` gap list now goes through MODELBEAST instead of fal.ai — free, local: +```bash +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 (25–77k 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: resolves to assets/models/ instead of the CDN +``` ## Cost summary diff --git a/pipeline/_props_results.json b/pipeline/_props_results.json new file mode 100644 index 0000000..060e85b --- /dev/null +++ b/pipeline/_props_results.json @@ -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": [] +} \ No newline at end of file diff --git a/pipeline/_published.json b/pipeline/_published.json index 7d626cf..c4bac4a 100644 --- a/pipeline/_published.json +++ b/pipeline/_published.json @@ -1,10 +1,14 @@ [ + "procity_fit_arcade_cabinet_01.glb", "procity_fit_bookshelf_01.glb", "procity_fit_box_crate_01.glb", "procity_fit_clothes_rack_01.glb", "procity_fit_coffee_table_01.glb", "procity_fit_counter_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_wire_shelf_01.glb", "procity_fit_work_table_01.glb", @@ -13,6 +17,8 @@ "procity_street_bench_wood_01.glb", "procity_street_food_cart_01.glb", "procity_street_longbench_01.glb", + "procity_street_novelty_record_01.glb", "procity_street_park_bench_01.glb", - "procity_street_streetlight_01.glb" + "procity_street_streetlight_01.glb", + "vintage-cash-register.glb" ] \ No newline at end of file diff --git a/pipeline/batch_props.json b/pipeline/batch_props.json new file mode 100644 index 0000000..c88e645 --- /dev/null +++ b/pipeline/batch_props.json @@ -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} +] diff --git a/pipeline/build_manifest.py b/pipeline/build_manifest.py index 303ff4c..757d8b8 100644 --- a/pipeline/build_manifest.py +++ b/pipeline/build_manifest.py @@ -13,10 +13,19 @@ import json, os, glob ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 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") 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 ───────────────────────────────────────── # 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 @@ -133,13 +142,14 @@ def build(): "awning": awning, } - # ---- fittings / furniture from the normalizer results ---- + # ---- fittings / furniture from the normalizer results (library + round-3 hero props) ---- fittings, furniture = {}, {} # cube_shelf dropped: horizontal node-scale distortion (6.3x8.4m footprint) — see AUDIT.md DROP = {"procity_fit_cube_shelf_01.glb"} - if os.path.exists(RESULTS): - res = json.load(open(RESULTS))["results"] - for r in res: + for results_file in (RESULTS, PROPS_RESULTS): + if not os.path.exists(results_file): + continue + for r in json.load(open(results_file)).get("results", []): if r["file"] in DROP: continue 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"), } (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 # stay primitives — see MESHGOD_BATCH.md). Lets a consumer that iterates registry SHOP_TYPES.fittings diff --git a/pipeline/gen_props.py b/pipeline/gen_props.py new file mode 100644 index 0000000..1369f45 --- /dev/null +++ b/pipeline/gen_props.py @@ -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/.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.") diff --git a/pipeline/meshgod_batch.json b/pipeline/meshgod_batch.json index f14e206..ecbb87a 100644 --- a/pipeline/meshgod_batch.json +++ b/pipeline/meshgod_batch.json @@ -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.", + "_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": [ {"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"}, diff --git a/pipeline/normalize.py b/pipeline/normalize.py index da0e97c..9517fa5 100644 --- a/pipeline/normalize.py +++ b/pipeline/normalize.py @@ -183,13 +183,23 @@ def normalize_one(spec): for i in range(n0, n1): obj.data.polygons[i].material_index = widx - # decimate to budget (finish_glb.py:80) - tris = len(obj.data.polygons) + # decimate to budget (finish_glb.py:80). TRELLIS meshes are dense multi-shell scans where a + # 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) - 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.ratio = max(0.01, budget / tris) + m.ratio = max(0.02, budget / tris) 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) # self-illuminate dark bakes (finish_glb.py:88) diff --git a/pipeline/stage_local_depot.py b/pipeline/stage_local_depot.py new file mode 100644 index 0000000..b9ae352 --- /dev/null +++ b/pipeline/stage_local_depot.py @@ -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:; 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: to assets/models/") diff --git a/web/assets/manifest.json b/web/assets/manifest.json index 313776e..e608100 100644 --- a/web/assets/manifest.json +++ b/web/assets/manifest.json @@ -647,6 +647,50 @@ ], "height": 0.6, "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": { @@ -712,6 +756,15 @@ ], "height": 2.0, "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": { diff --git a/web/assets/thumbs/procity_fit_arcade_cabinet_01.png b/web/assets/thumbs/procity_fit_arcade_cabinet_01.png new file mode 100644 index 0000000..9fa849f Binary files /dev/null and b/web/assets/thumbs/procity_fit_arcade_cabinet_01.png differ diff --git a/web/assets/thumbs/procity_fit_drinks_fridge_01.png b/web/assets/thumbs/procity_fit_drinks_fridge_01.png new file mode 100644 index 0000000..89442fe Binary files /dev/null and b/web/assets/thumbs/procity_fit_drinks_fridge_01.png differ diff --git a/web/assets/thumbs/procity_fit_listening_booth_01.png b/web/assets/thumbs/procity_fit_listening_booth_01.png new file mode 100644 index 0000000..6bd4bab Binary files /dev/null and b/web/assets/thumbs/procity_fit_listening_booth_01.png differ diff --git a/web/assets/thumbs/procity_fit_milkshake_mixer_01.png b/web/assets/thumbs/procity_fit_milkshake_mixer_01.png new file mode 100644 index 0000000..a2638ad Binary files /dev/null and b/web/assets/thumbs/procity_fit_milkshake_mixer_01.png differ diff --git a/web/assets/thumbs/procity_street_novelty_record_01.png b/web/assets/thumbs/procity_street_novelty_record_01.png new file mode 100644 index 0000000..3451835 Binary files /dev/null and b/web/assets/thumbs/procity_street_novelty_record_01.png differ diff --git a/web/js/core/loaders.js b/web/js/core/loaders.js index 7d30c17..975e2c0 100644 --- a/web/js/core/loaders.js +++ b/web/js/core/loaders.js @@ -6,6 +6,29 @@ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; export const DEPOT = 'https://digalot.fyi/3god'; // shared GLB CDN: GET /a/, /api/list +// Local-depot mode (Lane E round-3): resolve depot: 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=), +// or globally with window.PROCITY_LOCALDEPOT = true | ''. 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: 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 texLoader = new THREE.TextureLoader(); const GLB_CACHE = {}; // url → Promise @@ -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, cached, never rejects 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 => { console.warn('[loaders] GLB failed, placeholder stays:', url, err?.message || err); return null;