- debt #1: the manifest music key IS the gigKey, always gig-<genreKey>. Renamed pubrock-live -> gig-pubrock (files + gen_audio + build_manifest); old files deleted. B/F switch readers, F deletes the GIG_BED bridge (both fail soft to silence til then). - two new gig beds: gig-grunge (band_room, quiet-loud dynamic) + gig-covers (RSL, mellow organ-led); procedural/seeded/loopable/$0. New organ() + grungechord() synth helpers. - decimate the 5 instrument GLBs to 14k tris each (was 43-96k; fleet 236k->70k, -70%) via collapse (thin features survive); _hi hi-poly originals kept for depot; thumbs re-rendered. - manifest regen (validates 0/0 incl --depot); app boots clean flags-off / ?gigs=1 (3-venue district, all 3 beds resolve) / ?noassets=1&gigs=1 (0 audio+GLB fetches). - depot re-publish (14k bytes + _hi originals) gated on John's OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
153 lines
6.5 KiB
Python
153 lines
6.5 KiB
Python
"""PROCITY prop decimation — collapse-decimate a normalized GLB to a tri budget while KEEPING its
|
||
original materials + UVs (no voxel-remesh, no re-bake). The detail-preserving path for props whose
|
||
thin features (mic pole, cymbal stands, guitar necks) a voxel-remesh would fatten.
|
||
|
||
Runs headless on this box's Blender:
|
||
BL=/Applications/Blender.app/Contents/MacOS/Blender
|
||
"$BL" --background --python decimate_props.py -- BATCH.json OUT_DIR [THUMB_DIR]
|
||
|
||
BATCH.json: [ {"src":"web/assets/models/procity_fit_guitar_amp_01.glb",
|
||
"id":"guitar_amp", "category":"fit", "tris":14000} , ... ]
|
||
|
||
vs bake_lowpoly.py: that one throws topology away (voxel remesh → bake) to beat the multi-shell floor
|
||
on boxy hero props; this one collapses edges in place to keep silhouettes on thin/among-shell props.
|
||
Pick per asset by eyeballing the thumbs — anything that floors above budget or reads wrong here goes
|
||
to bake_lowpoly.py instead. Same house GLB law out: metres, +Y up, base-origin, WebP, no Draco.
|
||
"""
|
||
import bpy, sys, os, json, math
|
||
from mathutils import Vector
|
||
|
||
ARGV = sys.argv[sys.argv.index("--") + 1:]
|
||
BATCH, OUT_DIR = ARGV[0], (ARGV[1] if len(ARGV) > 1 else "/tmp/procity_decim")
|
||
THUMB_DIR = ARGV[2] if len(ARGV) > 2 else os.path.join(OUT_DIR, "thumbs")
|
||
os.makedirs(OUT_DIR, exist_ok=True); os.makedirs(THUMB_DIR, exist_ok=True)
|
||
|
||
|
||
def wipe():
|
||
for o in list(bpy.data.objects):
|
||
bpy.data.objects.remove(o, do_unlink=True)
|
||
for coll in (bpy.data.meshes, bpy.data.materials, bpy.data.images):
|
||
for d in list(coll):
|
||
if d.users == 0:
|
||
coll.remove(d)
|
||
|
||
|
||
def import_join(src):
|
||
bpy.ops.import_scene.gltf(filepath=src)
|
||
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
|
||
bpy.ops.object.select_all(action='DESELECT')
|
||
for m in meshes:
|
||
m.select_set(True)
|
||
bpy.context.view_layer.objects.active = meshes[0]
|
||
if len(meshes) > 1:
|
||
bpy.ops.object.join()
|
||
obj = bpy.context.view_layer.objects.active
|
||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||
return obj
|
||
|
||
|
||
def bounds(obj):
|
||
bb = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
|
||
mn = Vector((min(v.x for v in bb), min(v.y for v in bb), min(v.z for v in bb)))
|
||
mx = Vector((max(v.x for v in bb), max(v.y for v in bb), max(v.z for v in bb)))
|
||
return mn, mx
|
||
|
||
|
||
def tri_count(obj):
|
||
"""Honest triangle count (n-gons split): sum of (loop_total - 2) per polygon."""
|
||
return sum(len(p.vertices) - 2 for p in obj.data.polygons)
|
||
|
||
|
||
def decimate(obj, target):
|
||
# triangulate first so the collapse ratio targets triangles honestly, then collapse-decimate.
|
||
bpy.ops.object.select_all(action='DESELECT')
|
||
obj.select_set(True); bpy.context.view_layer.objects.active = obj
|
||
bpy.ops.object.mode_set(mode='EDIT')
|
||
bpy.ops.mesh.select_all(action='SELECT')
|
||
bpy.ops.mesh.quads_convert_to_tris()
|
||
bpy.ops.object.mode_set(mode='OBJECT')
|
||
n = len(obj.data.polygons)
|
||
if n > target:
|
||
dm = obj.modifiers.new("dec", 'DECIMATE')
|
||
dm.decimate_type = 'COLLAPSE'
|
||
dm.ratio = max(0.01, target / n)
|
||
dm.use_collapse_triangulate = True
|
||
bpy.ops.object.modifier_apply(modifier=dm.name)
|
||
return n
|
||
|
||
|
||
def base_origin(obj):
|
||
mn, mx = bounds(obj)
|
||
obj.location -= Vector(((mn.x + mx.x) / 2, (mn.y + mx.y) / 2, mn.z))
|
||
bpy.ops.object.transform_apply(location=True)
|
||
|
||
|
||
def render_thumb(obj, png_name):
|
||
scn = bpy.context.scene
|
||
mn, mx = bounds(obj); ctr = (mn + mx) / 2
|
||
size = max((mx - mn).x, (mx - mn).y, (mx - mn).z) or 1.0
|
||
for o in list(bpy.data.objects):
|
||
if o.type in ('CAMERA', 'LIGHT'):
|
||
bpy.data.objects.remove(o, do_unlink=True)
|
||
w = bpy.data.worlds.get("tw") or bpy.data.worlds.new("tw")
|
||
scn.world = w; w.use_nodes = True
|
||
w.node_tree.nodes["Background"].inputs[1].default_value = 1.05
|
||
bpy.ops.object.light_add(type='AREA', location=(ctr.x + size, ctr.y - size, mx.z + size))
|
||
bpy.context.object.data.energy = 800 * size; bpy.context.object.data.size = 6
|
||
bpy.ops.object.camera_add(); cam = bpy.context.object; scn.camera = cam
|
||
r = size * 1.9
|
||
cam.location = Vector((ctr.x + r * 0.8, ctr.y - r, ctr.z + size * 0.6))
|
||
cam.rotation_euler = (ctr - cam.location).to_track_quat('-Z', 'Y').to_euler()
|
||
scn.render.engine = 'BLENDER_WORKBENCH'
|
||
scn.display.shading.light = 'STUDIO'; scn.display.shading.color_type = 'TEXTURE'
|
||
scn.display.shading.show_cavity = True
|
||
scn.view_settings.view_transform = 'Standard'
|
||
scn.render.film_transparent = True
|
||
scn.render.resolution_x = scn.render.resolution_y = 256
|
||
p = os.path.join(THUMB_DIR, png_name); scn.render.filepath = p
|
||
bpy.ops.render.render(write_still=True)
|
||
return p
|
||
|
||
|
||
def decimate_one(spec):
|
||
wipe()
|
||
src = spec["src"] if os.path.isabs(spec["src"]) else os.path.join(os.getcwd(), spec["src"])
|
||
obj = import_join(src)
|
||
tri_before = tri_count(obj)
|
||
decimate(obj, spec.get("tris", 14000))
|
||
base_origin(obj)
|
||
tri_after = tri_count(obj)
|
||
name = f"procity_{spec['category']}_{spec['id']}_01.glb"
|
||
out = os.path.join(OUT_DIR, name)
|
||
bpy.ops.object.select_all(action='DESELECT')
|
||
obj.select_set(True); bpy.context.view_layer.objects.active = obj
|
||
bpy.ops.export_scene.gltf(filepath=out, export_format='GLB', export_yup=True,
|
||
use_selection=True, export_image_format='WEBP', export_image_quality=88)
|
||
thumb = render_thumb(obj, name.replace(".glb", ".png"))
|
||
mn, mx = bounds(obj)
|
||
# Blender is Z-up: height is Z, the ground footprint is X×Y (matches bake_lowpoly.py + _gig_results).
|
||
return {"file": name, "tri_before": tri_before, "tri_after": tri_after,
|
||
"size_m": [round(mx.x - mn.x, 3), round(mx.z - mn.z, 3), round(mx.y - mn.y, 3)],
|
||
"footprint": [round(mx.x - mn.x, 2), round(mx.y - mn.y, 2)], "thumb": thumb, "out": out}
|
||
|
||
|
||
def main():
|
||
specs = json.load(open(BATCH))
|
||
results, errors = [], []
|
||
for spec in specs:
|
||
try:
|
||
r = decimate_one(spec)
|
||
results.append(r)
|
||
print(f"OK {r['file']:40} {r['tri_before']:>6}→{r['tri_after']:>5} tris "
|
||
f"footprint {r['footprint']} h {r['size_m'][1]}")
|
||
except Exception as e:
|
||
import traceback; traceback.print_exc()
|
||
errors.append({"id": spec.get("id"), "error": str(e)})
|
||
print(f"ERR {spec.get('id')}: {e}")
|
||
json.dump({"results": results, "errors": errors},
|
||
open(os.path.join(OUT_DIR, "_decim_results.json"), "w"), indent=2)
|
||
print(f"\nDECIM_DONE ok={len(results)} err={len(errors)} → {OUT_DIR}")
|
||
|
||
|
||
main()
|