PROCITY/pipeline/build_manifest.py
m3ultra 063a959e29 Lane E (assets): pipeline, manifest + 23 on-device facade skins
Audit -> normalize (16 GLBs + thumbs) -> web/assets/manifest.json (the
contract Lanes B/C/D read) -> facade gap-fill.

Step 4 facades generated locally on the M3 Ultra via MODELBEAST
flux_local (FLUX.2-klein-4B / MLX on MPS): 23 skins, free, ~5.7s each,
blank signboard, eyeballed, harvested, mapped to shop types. Every
registry type now has >=3 facades (stall 2->4, closing the Lane A gap).
Corner-lot -side walls tagged face:"side" with empty types so no shop
front ever selects a windowless wall. manifest validates green.

Still gated on John (both optional -- the game runs asset-free):
publish the 16 GLBs to 3GOD (needs GOD3_PW), MeshGod hero props (~$2.70).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:48:25 +10:00

200 lines
11 KiB
Python

#!/usr/bin/env python3
"""Generate web/assets/manifest.json — the single contract Lanes B/C/D read.
Mechanical parts (skin listing, fitting footprints) are derived from the filesystem +
the normalizer's _normalize_results.json; the curated parts (which facade suits which
shop type) are the maps below, seeded from CITY_SPEC's registry table. Re-run any time
skins or normalized GLBs change: python3 pipeline/build_manifest.py
Everything here is OPTIONAL at runtime — the game runs asset-free (CITY_SPEC house law).
The manifest just tells the other lanes what nicer assets exist and how big they are.
"""
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")
OUT = os.path.join(ROOT, "web", "assets", "manifest.json")
DEPOT = "https://digalot.fyi/3god"
# ── 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
# facade registry leaves unreferenced), corrugated→stall (registry's stall pool has only `market`;
# this is stall's 2nd skin — flagged for Lane A), djsim-pub→general.
FACADE_TYPES = {
"timber-teal": ["record"], "arcade-tile": ["record"], "djsim-record": ["record"],
"weatherboard": ["opshop"], "fibro-blue": ["opshop"], "djsim-opshop": ["opshop"],
"stucco-pink": ["toy"], "deco-pastel": ["toy"], "boutique": ["toy"],
"federation": ["book"], "sandstone": ["book"], "redbrick": ["book"],
"stripmall": ["video"], "djsim-video": ["video"],
"besser": ["pawn"], "grimy": ["pawn"], "djsim-pawn": ["pawn"],
"corrugated": ["pawn", "stall"], "cashtheists": ["pawn"],
"djsim-milkbar": ["milkbar"], "brickveneer": ["milkbar"],
"terrazzo": ["dept"], "djsim-dept": ["dept"],
"market": ["stall"],
"djsim-pub": ["general"],
# ── Step-4 gap-fill fronts, generated on-device (MODELBEAST flux2-klein-4b, free) ──────
# docs/shots/laneE/facades_gen_local.png. Additive to registry.js: each fattens a thin
# pool so no type leans on one skin (video 2→4, milkbar 2→5, dept 2→4, stall 1→3).
"toy-brights": ["toy"], "toy-lolly": ["toy"],
"book-barn": ["book"], "book-scholarly": ["book"],
"video-neon": ["video"], "video-suburban": ["video"],
"milkbar-corner": ["milkbar"], "milkbar-fibro": ["milkbar"], "milkbar-deco": ["milkbar"],
"dept-anchor": ["dept"], "dept-emporium": ["dept"],
"market-fruit": ["stall"], "market-edge": ["stall"],
"warehouse-roller": ["pawn", "general"], "warehouse-tin": ["pawn", "general"],
"arcade-entry": ["general"], # arcade portal, not a shop — Lane B places by key
# residential fronts for use:'house' lots (incl. the milkbar corner-shop's street)
"res-terrace": ["house"], "res-weatherboard": ["house"],
"res-fibro": ["house"], "res-brickveneer": ["house"],
}
# facades that already carry BAKED sign text (mostly the 90sDJsim Flux ones) — Lane B should NOT
# overlay a shop name on these; the blank-signboard ones are the clean overlay targets. Verified
# by eye against docs/shots/laneE/facades_style_anchor.png.
BAKED_SIGN = {"djsim-milkbar", "djsim-video", "djsim-pub", "market", "grimy"}
# ── curated: ground skin → where it belongs ─────────────────────────────────────────────
GROUND_USE = {
"asphalt1": "road", "asphalt2": "road", "djsim-road": "road",
"footpath1": "footpath", "footpath2": "footpath", "djsim-footpath": "footpath",
"grass": "yard", "brickpave": "plaza", "gravel": "verge", "reddust": "outback",
}
# ── curated: interior floor / surface split of the tex-* skins ───────────────────────────
FLOOR_TEX = ["carpet-swirl", "carpet-mustard", "carpet-greygreen", "lino-check", "lino-cork",
"boards-polished"]
SURFACE_TEX = { # interior wall / counter surfaces → biased shop types (hint only)
"wood-counter": ["record", "milkbar", "pawn"], "boutique-wall": ["toy", "opshop"],
"pegboard": ["pawn", "video"], "brick": ["general"], "render": ["general"],
"grimy-wall": ["pawn"], "velvet-curtain": ["toy", "record"],
}
AWNING_TEX = ["awning-red", "awning-green", "awning-blue"]
def rel(p):
return "gen/" + os.path.basename(p)
def build():
have = {os.path.splitext(os.path.basename(f))[0] for f in glob.glob(GEN + "/*.jpg")}
def f(cat, key):
name = f"{cat}-{key}"
return f"gen/{name}.jpg" if name in have else None
# ---- facades ----
facade = {}
for key, types in FACADE_TYPES.items():
file = f("facade", key)
if file:
facade[key] = {"file": file, "types": types,
"signboard": "baked" if key in BAKED_SIGN else "blank"}
# any facade-*.jpg not in the curated map → catalogue it so nothing is orphaned.
# `-side` skins are corner-lot SIDE walls (windowless): face:"side" + empty types so no
# shop-front pool ever selects one — Lane B picks them by the facade-<x>-side.jpg name.
for name in sorted(have):
if not name.startswith("facade-") or name[len("facade-"):] in facade:
continue
key = name[len("facade-"):]
is_side = key.endswith("-side")
entry = {"file": "gen/" + name + ".jpg",
"types": [] if is_side else ["general"],
"signboard": "baked" if key in BAKED_SIGN else "blank"}
if is_side:
entry["face"] = "side"
facade[key] = entry
# ---- skies (array; not type-specific) ----
sky = [{"id": n[len("sky-"):], "file": "gen/" + n + ".jpg"}
for n in sorted(have) if n.startswith("sky-")]
# ---- grounds (object keyed by id, with a use tag) ----
ground = {}
for name in sorted(have):
if name.startswith("ground-"):
key = name[len("ground-"):]
ground[key] = {"file": "gen/" + name + ".jpg", "use": GROUND_USE.get(key, "road")}
# ---- interior wallpapers (array) ----
wall = [{"id": n[len("wall-"):], "file": "gen/" + n + ".jpg"}
for n in sorted(have) if n.startswith("wall-")]
# ---- interior floors + surfaces + street awnings (from tex-*) ----
floor = [{"id": k, "file": f("tex", k)} for k in FLOOR_TEX if f("tex", k)]
surface = [{"id": k, "file": f("tex", k), "types": t}
for k, t in SURFACE_TEX.items() if f("tex", k)]
awning = [{"id": k, "file": f("tex", k)} for k in AWNING_TEX if f("tex", k)]
skins = {
"facade": facade,
"sky": sky,
"ground": ground,
"wall": wall,
"interior": {"floor": floor, "surface": surface},
"awning": awning,
}
# ---- fittings / furniture from the normalizer results ----
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:
if r["file"] in DROP:
continue
fid = r["file"].replace("procity_fit_", "").replace("procity_street_", "").replace("_01.glb", "")
entry = {
"file": r["file"],
"footprint": r["footprint"], # [w, d] metres, on the ground
"height": r["size_m"][1], # metres
"thumb": "thumbs/" + r["file"].replace(".glb", ".png"),
}
(furniture if r["file"].startswith("procity_street_") else fittings)[fid] = entry
# 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
# resolve to a GLB. Lane C's glb.js already maps its own kinds straight to the physical ids below.
fitting_aliases = {
"record_bins": "record_crate", "crates": "box_crate", "counter": "counter",
"clothes_racks": "clothes_rack", "bricabrac_shelving": "wire_shelf", "book_wall": "bookshelf",
"cube_shelves": "cube_shelf_wide", "display_tables": "coffee_table",
"bookshelves": "bookshelf", "vhs_shelving": "wire_shelf", "trestle_tables": "work_table",
}
fitting_aliases = {k: v for k, v in fitting_aliases.items() if v in fittings}
manifest = {
"version": 1,
"depot": DEPOT,
"conventions": {
"glb_law": "metres, +Y up, origin at base (minY=0, centred X/Z), <=5k tris, WebP <=1024, no Draco",
"facing": "symmetric props are orientation-agnostic; normalize.py applies no automatic "
"facing-correction (per-asset yaw is available in batch.json) — Lane B/C rotate "
"directional props (counter, food_cart) to taste when placing",
"facade_blank_signboard": "facades tagged signboard:'blank' ship a blank sign panel — "
"Lane B overlays the shop name there. signboard:'baked' facades "
"(some djsim-* Flux skins) already have sign text: use as-is, don't overlay",
"facade_face": "facade entries without a 'face' key are front elevations (shop or "
"house fronts); face:'side' entries are windowless corner-lot side "
"walls with empty types — never select one as a shop front",
"corner_lots": "corner lots expose a side wall: dedicated facade-<x>-side.jpg skins "
"now exist for brick/render/weatherboard (face:'side') — Lane B maps "
"these to the exposed side face; other types reuse the front skin",
"fallbacks": "every asset is optional — missing file => primitive/flat-colour, never a crash",
"load": "fittings/furniture load from depot as depot:<file>; skins are local gen/<file>",
},
"skins": skins,
"fittings": fittings,
"furniture": furniture,
"fittingAliases": fitting_aliases,
}
with open(OUT, "w") as fh:
json.dump(manifest, fh, indent=2)
print(f"wrote {OUT}")
print(f" facades {len(facade)} skies {len(sky)} grounds {len(ground)} "
f"wallpapers {len(wall)} floors {len(floor)} surfaces {len(surface)} awnings {len(awning)}")
print(f" fittings {len(fittings)} furniture {len(furniture)}")
if __name__ == "__main__":
build()