"""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()