MEASURED FIRST (pipeline/arcade_measure.mjs, over 5 seeds, off Lane A's own generatePlan):
the arcade is ONE 42.00 m x 5 m edge, 2 blocks, 16-17 shops (17 at the default seed), frontage
3.00-4.94 m (mean 3.87-4.05), depth 5.1-8.0 m, 4-11 of them two-storey. Walkable 5.00 m between
lot faces. AWNING_DEPTH 2.2 from each face means the two slabs already meet 0.60 m apart over the
centreline — the arcade IS roofed, and the roof is an accident.
⚠ AND THE POSTS ARE IN THE WRONG PLACE: buildings.js:643 puts them 2.00 m out from each lot face,
i.e. +/-0.50 m from the centreline — two rows of posts 1.00 m apart down a 5.00 m lane. No
collider, so it walks; it just reads as a colonnade planted in its own doorway. Lane B ask filed.
SHIPPED (all riding existing shared materials or stating their draw):
facade-shuttered 768x595 (the 1.29:1 arcade lot aspect, not the pool's 1.78:1) — the dead
tenant. +0 draws, +0 tris, 1 of the 14 FREE facade-atlas slots (22 of 36 used).
ground-arcade-floor 512^2 terrazzo — +1 draw TOWN-WIDE, +0 tris (the 504 m2 lane quad moves out
of footGeos). use:'arcade-floor' names a slot that does not exist yet, on purpose.
ground-arcade-roof 512^2 pressed-metal + wired glass — +1 draw in the 1-2 arcade chunks, +2 tris.
pipeline/seamless_tile.py: cut a regular-pattern skin to a whole number of its own periods instead
of feathering the joint. It has a guard, because on this pair the naive version made things WORSE:
the roof's x seam went 26.7 -> 8.3 (kept), the diffusion-drawn terrazzo floor is not periodic enough
to cut on (autocorr r=0.26) and 27.9 -> 31.9 was rejected and passed through. Recorded, not hidden.
manifest: facades 48->49, grounds 14->16. validate_manifest.py 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
382 lines
23 KiB
Python
382 lines
23 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") # library GLBs
|
|
PROPS_RESULTS = os.path.join(ROOT, "pipeline", "_props_results.json") # round-3 hero props
|
|
GIG_RESULTS = os.path.join(ROOT, "pipeline", "_gig_results.json") # round-12 gig instruments
|
|
# Round-38 wave-1 props. WIRED BUT NOT YET LIVE, deliberately: the three shipped GLBs (magpie,
|
|
# paling_fence, water_tank) are normalized, baked to budget and staged in _normalized/, but they
|
|
# have NOT been pushed to the 3GOD depot — that is an outward-facing publish and Lane E did not
|
|
# have in-session authorization for it this round. `validate_manifest.py` correctly FAILS any
|
|
# manifest GLB with no `_published.json` record, so the results file is held at
|
|
# `_r38_results.PENDING_PUBLISH.json`. ONE STEP lands them: publish, then rename it to
|
|
# `_r38_results.json` and re-run this script. See AUDIT.md R38 §4 and LANE_E_NOTES R38.
|
|
R38_RESULTS = os.path.join(ROOT, "pipeline", "_r38_results.json") # round-38 wave-1 props
|
|
OUT = os.path.join(ROOT, "web", "assets", "manifest.json")
|
|
AUDIO = os.path.join(ROOT, "web", "assets", "audio") # round-11 audio pack
|
|
DEPOT = "https://digalot.fyi/3god"
|
|
|
|
# ── curated audio contract (files from pipeline/gen_audio.py). Interior beds/music carry types[]
|
|
# — shop types that use them — so Lane C/F seed-pick from the matching pool, exactly like facades. ──
|
|
AUDIO_STREET = {"street-day": 0.5, "street-night": 0.5, "rain": 0.45} # Lane B street beds
|
|
AUDIO_ROOMTONE = { # interior room-tone key -> shop types it suits
|
|
"retail": ["record", "opshop", "book", "toy", "pawn", "stall", "general"],
|
|
"milkbar": ["milkbar"],
|
|
"video": ["video", "dept", "arcade"],
|
|
}
|
|
AUDIO_MUSIC = { # interior music key -> (gain, shop types, note)
|
|
"record-shop": (0.55, ["record"], "record shop plays music"),
|
|
"milkbar": (0.50, ["milkbar", "general"], "milk bar radio"),
|
|
"video-synth": (0.50, ["video"], "video store synth hum"),
|
|
"arcade": (0.50, ["arcade", "dept"], "arcade cabinet"),
|
|
# ── gig beds: the manifest key IS the gigKey, ALWAYS `gig-<genreKey>` (R13 debt #1; one key,
|
|
# zero mapping). C emits room.audio.gigKey='gig-'+genreKey, F/B read this key directly. ──
|
|
"gig-pubrock": (0.60, ["pub"], "gig live bed — pub-rock (pub); F plays via room.audio gigKey"),
|
|
"gig-grunge": (0.60, ["band_room"], "gig live bed — grunge (band_room)"),
|
|
"gig-covers": (0.50, ["rsl"], "gig live bed — covers (RSL); mellower, organ-led"),
|
|
}
|
|
AUDIO_INTERIOR_AMB = {"crowd-walla": (0.4, "gig crowd layer (interior; F plays when gig on)")} # R12
|
|
AUDIO_SFX = {"doorbell": 0.7, "door-open": 0.6, "till": 0.7, "riffle": 0.6, # one-shots
|
|
"toast": 0.5, "tram-bell": 0.7, "applause": 0.7} # applause = R12 gig
|
|
AUDIO_SFX_LOOP = {"tram-rumble": 0.5}
|
|
AUDIO_FOOTSTEPS = {"pavement": 3, "timber": 3} # surface -> variants
|
|
# ── R38 / v8 wave 1.1 — THE INHABITATION LAYER. Six point sources for Lane B's seeded anchors:
|
|
# key -> (category, gain, loop, anchor-hint, note). They land in the normal ambience/sfx groups so
|
|
# every existing consumer keeps working untouched, and each carries `life: true` — the same flag
|
|
# shape R12 used for `gig: true`. `anchor` is a HINT, not a contract: Lane B owns placement and is
|
|
# free to re-anchor. Gains are deliberately low because `gen_audio.encode()` runs loudnorm over
|
|
# every ambience asset, so "distant" cannot be shipped as a quiet render — it is shipped as
|
|
# spectrum in the synth, and as gain here.
|
|
AUDIO_LIFE = {
|
|
"mower-distant": ("ambience", 0.30, True, "town", "a mower two streets over"),
|
|
"radio-through-window": ("ambience", 0.25, True, "house", "someone's radio through a window"),
|
|
"sprinkler": ("ambience", 0.35, True, "yard", "impulse sprinkler on a lawn"),
|
|
"dog-bark-fence": ("sfx", 0.55, False, "yard", "the dog going off behind a paling fence"),
|
|
"roller-door": ("sfx", 0.50, False, "shop", "a roller door run up"),
|
|
"magpie-carol": ("sfx", 0.45, False, "tree", "the carol — the warble, not the clack"),
|
|
}
|
|
|
|
# ── 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
|
|
# 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
|
|
# ── R39 / v9 39.4 — the arcade's dead tenant. PLACED BY KEY, never drawn from a type pool:
|
|
# `shuttered` is deliberately absent from registry.js's SHOP_TYPES[*].facades, so no shop can
|
|
# roll it, and a shop that is OPEN must never wear a shutter. It is shot at 768x595 (1.29:1),
|
|
# the measured arcade lot aspect (mean frontage 3.87-4.05 m against FACADE_H 3.0), not the
|
|
# main-street pool's 1.78:1 — skins.js paints every facade into a SQUARE atlas slot, so a
|
|
# 1.78:1 source on a 1.29:1 quad is a 38% horizontal stretch. Costs +0 draws, +0 tris and one
|
|
# of the 14 FREE facade-atlas slots (the synthetic uses 22 of 36; skins.js:facadeAtlasUV warns
|
|
# and REUSES a slot past 36 — that is the real budget for a facade skin, not bytes).
|
|
"shuttered": ["general"],
|
|
# 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",
|
|
# ── R38 / v8 wave 1.2 — the per-town ground palette, so 23 towns can look like 23 towns.
|
|
# ground.js merges each surface class into ONE town-wide mesh, so a palette swap is +0 draws
|
|
# / +0 tris. NOTE FOR LANE B (measured, R38): ground.js:177 hardcodes exactly FOUR slots —
|
|
# road / footpath / yard (the base plane) / plaza. `gravel` and `reddust` are selected by
|
|
# nothing partly because their `use` values ("verge", "outback") name no slot that exists.
|
|
# These four deliberately use live slot vocabulary so the new selector can consume them
|
|
# without a vocabulary change.
|
|
"grass-dry": "yard", "grass-temperate": "yard", "coastal-sand": "yard",
|
|
"bitumen-reddust": "road",
|
|
# ── R39 / v9 39.4 — THE ARCADE'S TWO SURFACES. These two `use` values name slots that DO NOT
|
|
# EXIST YET, and unlike gravel→"verge" / reddust→"outback" (R38: selected by nothing, by
|
|
# accident) that is DELIBERATE and costed. The arcade cannot ride an existing slot: `footpath`
|
|
# is one town-wide merged mesh, so re-using it would lay terrazzo over every footpath in town.
|
|
# Each therefore needs its own class, and the price is stated:
|
|
# arcade-floor +1 draw TOWN-WIDE (ground.js builds once, not per chunk: a 6th merged mesh),
|
|
# +0 tris — the 42.00 x 12.00 m = 504 m2 lane quad MOVES out of footGeos.
|
|
# arcade-ceiling +1 draw in the 1-2 chunks that hold arcade lots, +2 tris (one 42 x 5 m quad).
|
|
# BOTH are synthetic-only by construction: plan_osm.js:305 is one unconditional
|
|
# addDistrict('mainstreet'), so no cache town has an arcade DISTRICT. Key on district.kind and
|
|
# never on edge.kind — the corpus has 1,102 arcade-KIND edges = 39 km of ordinary suburban
|
|
# footpath (v9 cut list). And both must be classic-gated: ?classic=1 does not move on dressing.
|
|
"arcade-floor": "arcade-floor", "arcade-roof": "arcade-ceiling",
|
|
}
|
|
# ── 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_audio():
|
|
"""The audio section: ambience (street beds + interior roomtone), sfx (one-shots + footstep
|
|
variant arrays), music. Only assets present on disk are listed — silent-happy by construction."""
|
|
def ref(n):
|
|
return ({"file": f"audio/{n}.ogg", "fallback": f"audio/{n}.m4a"}
|
|
if os.path.isfile(os.path.join(AUDIO, n + ".ogg")) else None)
|
|
ambience = {}
|
|
for k, g in AUDIO_STREET.items():
|
|
r = ref("ambience-" + k)
|
|
if r:
|
|
ambience[k] = {**r, "loop": True, "gain": g, "scope": "street"}
|
|
for k, types in AUDIO_ROOMTONE.items():
|
|
r = ref("roomtone-" + k)
|
|
if r:
|
|
ambience["roomtone-" + k] = {**r, "loop": True, "gain": 0.35, "scope": "interior", "types": types}
|
|
for k, (g, note) in AUDIO_INTERIOR_AMB.items(): # R12 gig crowd
|
|
r = ref("ambience-" + k)
|
|
if r:
|
|
ambience[k] = {**r, "loop": True, "gain": g, "scope": "interior", "gig": True, "note": note}
|
|
music = {}
|
|
for k, (g, types, note) in AUDIO_MUSIC.items():
|
|
r = ref("music-" + k)
|
|
if r:
|
|
music[k] = {**r, "loop": True, "gain": g, "types": types, "note": note}
|
|
sfx = {}
|
|
for k, g in AUDIO_SFX.items():
|
|
r = ref("sfx-" + k)
|
|
if r:
|
|
sfx[k] = {**r, "gain": g}
|
|
for k, g in AUDIO_SFX_LOOP.items():
|
|
r = ref("sfx-" + k)
|
|
if r:
|
|
sfx[k] = {**r, "gain": g, "loop": True}
|
|
footsteps = {}
|
|
for surf, n in AUDIO_FOOTSTEPS.items():
|
|
vs = [ref(f"step-{surf}-{i}") for i in range(1, n + 1)]
|
|
vs = [v for v in vs if v]
|
|
if vs:
|
|
footsteps[surf] = vs
|
|
if footsteps:
|
|
sfx["footstep"] = footsteps
|
|
for k, (cat, g, lp, anchor, note) in AUDIO_LIFE.items(): # R38 inhabitation layer
|
|
r = ref(f"{cat}-{k}")
|
|
if not r:
|
|
continue
|
|
e = {**r, "gain": g, "scope": "street", "life": True, "anchor": anchor, "note": note}
|
|
if lp:
|
|
e["loop"] = True
|
|
(ambience if cat == "ambience" else sfx)[k] = e
|
|
for k in music: # flag gig-only beds for F (any 'gig-*' key)
|
|
if k.startswith("gig-"):
|
|
music[k]["gig"] = True
|
|
if "applause" in sfx:
|
|
sfx["applause"]["gig"] = True
|
|
return {"ambience": ambience, "sfx": sfx, "music": music}
|
|
|
|
|
|
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)]
|
|
|
|
# ---- gig posters (round 12): blank-name-band flyers. B/C overprint the seeded band name in
|
|
# nameZone = [x,y,w,h] fractions of the image (the top banner) — like blank-signboard facades ----
|
|
POSTER_NAME_ZONE = [0.08, 0.04, 0.84, 0.23]
|
|
poster = {}
|
|
for name in sorted(have):
|
|
if name.startswith("poster-"):
|
|
poster[name[len("poster-"):]] = {"file": "gen/" + name + ".jpg",
|
|
"nameZone": POSTER_NAME_ZONE, "signboard": "blank"}
|
|
|
|
skins = {
|
|
"facade": facade,
|
|
"sky": sky,
|
|
"ground": ground,
|
|
"wall": wall,
|
|
"interior": {"floor": floor, "surface": surface},
|
|
"awning": awning,
|
|
"poster": poster,
|
|
}
|
|
|
|
# ---- 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"}
|
|
for results_file in (RESULTS, PROPS_RESULTS, GIG_RESULTS, R38_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", "")
|
|
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
|
|
# 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
|
|
# resolve to a GLB. Lane C's glb.js already maps its own kinds straight to the physical ids below.
|
|
fitting_aliases = {
|
|
# registry 'counter' → the ~2m counter-with-till (counter_till), NOT the 4m balcao 'counter'
|
|
"record_bins": "record_crate", "crates": "box_crate", "counter": "counter_till",
|
|
"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",
|
|
# round-5 fal/MODELBEAST hero props → their registry semantic ids
|
|
"glass_case": "glass_case", "glass_cabinets": "glass_case", "magazine_rack": "magazine_rack",
|
|
"fridge": "drinks_fridge", "listening_corner": "listening_booth",
|
|
}
|
|
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), WebP <=1024, no Draco; "
|
|
"tris <=5k for library props, with documented exceptions for focal/LOD-able assets: "
|
|
"baked hero props <=8k (TRELLIS shell-soup, texture-intact) and gig instruments <=14k "
|
|
"(v3.0; hi-poly _hi originals on the depot) — see AUDIT.md + LANE_E_NOTES",
|
|
"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>",
|
|
"audio": "OGG/Opus primary + M4A/AAC fallback; loop:true beds are seamless; gain is a "
|
|
"suggested 0-1 playback level; interior roomtone/music carry types[] (seed-pick "
|
|
"per shop, like facades); footstep is {surface:[variants]}. Silent-happy: any "
|
|
"missing file => silence, never an error; ?mute=1 / ?noassets=1 => no audio fetch.",
|
|
"audio_life": "entries tagged life:true are the v8 wave-1.1 INHABITATION LAYER — point "
|
|
"sources for a life happening off-screen, played at a seeded anchor so it "
|
|
"is the same dog on the same street every time. `anchor` is a placement "
|
|
"HINT (town/house/yard/shop/tree), not a contract. playSfx is MONO with a "
|
|
"gain multiplier, so distance reads and direction does not: 'distance' is "
|
|
"baked into each asset's spectrum, not its level (loudnorm would undo a "
|
|
"quiet render). Zero draws, zero tris.",
|
|
},
|
|
"skins": skins,
|
|
"fittings": fittings,
|
|
"furniture": furniture,
|
|
"fittingAliases": fitting_aliases,
|
|
"audio": build_audio(),
|
|
}
|
|
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)}")
|
|
au = manifest["audio"]
|
|
print(f" audio: ambience {len(au['ambience'])} sfx {len(au['sfx'])} music {len(au['music'])}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
build()
|