PROCITY/pipeline/normalize.py
m3ultra bd40ec7e7a Lane E round 3 (landed by Fable after review): 5 hero props gen'd local + published, local-depot mode, manifest 14 fittings + 8 furniture
- gen_props.py: flux_local -> trellis_mac -> normalize pipeline; 5/8 props kept
  (arcade-cabinet, listening-booth, drinks-fridge, milkshake-mixer, novelty-record)
- magazine-rack -> primitive (thin-wire gotcha); glass-case + bus-shelter -> fal.ai fallback
- cash-register reused from depot; all 22 GLBs live, validate --depot 0/0
- loaders.js ?localdepot=1 + stage_local_depot.py for network-free GLB validation
- qa.sh --strict GREEN 4/4 at commit time

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:03:03 +10:00

305 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""PROCITY GLB normalizer — enforce the house GLB law on library assets.
Runs headless on ultra (Blender at /Applications/Blender.app/Contents/MacOS/Blender):
BL=/Applications/Blender.app/Contents/MacOS/Blender
"$BL" --background --python normalize.py -- BATCH.json OUT_DIR [THUMB_DIR]
Processes every entry in BATCH.json in ONE Blender session (clean scene between
assets — no per-asset relaunch). For each asset it:
* imports (Blender applies the glTF Y-up convention → correct world orientation)
* joins to a single mesh, applies transforms
* optional up-axis correction + yaw so the asset stands up and faces -Z
* base-origin: centre in X/Y, sit on the floor (min Z = 0) [house law]
* height-normalises to a real-world metre height [house law]
* decimates to a triangle budget (props <=5k) [house law]
* optional self-illuminate (TRELLIS/dark-bake assets) (finish_glb.py:88)
* downsizes every texture to <=1024px and exports them as WebP [house law]
* exports GLB, +Y up, embedded textures, NO Draco [house law]
* renders a 256px hero thumbnail (workbench, textured) [step 2]
Output names: procity_<category>_<id>_01.glb (e.g. procity_fit_record_crate_01.glb)
The heavy finish logic (solidify/decimate/bright/base-origin) is lifted verbatim
from MESHGOD/scripts/finish_glb.py — this is the same law, extended for the
PROCITY batch + texture-webp + thumbnail, per the lane rule "extend, don't fork".
BATCH.json schema (list of):
{ "src": "shop-fittings/balcao.glb", # relative to --root (default ~/Documents/3D=models)
"id": "counter", "category": "fit", # → procity_fit_counter_01.glb
"height_m": 1.1, # target height (0 = keep source scale)
"tris": 3000, # triangle budget
"up": "none", # none|x+90|x-90|y+90|y-90 (stand a lying asset up)
"yaw": 0, # degrees about up-axis, so front faces -Z
"solidify": false, # cap open bottoms (TRELLIS scans only)
"bright": false } # self-illuminate dark-baked albedo
"""
import bpy, sys, os, math, json
from mathutils import Vector
ARGV = sys.argv[sys.argv.index("--") + 1:]
BATCH = ARGV[0]
OUT_DIR = ARGV[1] if len(ARGV) > 1 else "/tmp/procity_norm"
THUMB_DIR = ARGV[2] if len(ARGV) > 2 else os.path.join(OUT_DIR, "thumbs")
ROOT = os.environ.get("PROCITY_MODELS_ROOT", os.path.expanduser("~/Documents/3D=models"))
os.makedirs(OUT_DIR, exist_ok=True)
os.makedirs(THUMB_DIR, exist_ok=True)
UP_ROT = { # extra rotation to stand a mis-authored asset upright
"none": (0, 0, 0), "x+90": (math.radians(90), 0, 0), "x-90": (math.radians(-90), 0, 0),
"y+90": (0, math.radians(90), 0), "y-90": (0, math.radians(-90), 0),
"z+90": (0, 0, math.radians(90)), "z-90": (0, 0, math.radians(-90)),
}
def wipe():
for o in list(bpy.data.objects):
bpy.data.objects.remove(o, do_unlink=True)
for blk in (bpy.data.meshes, bpy.data.materials, bpy.data.images):
for d in list(blk):
if d.users == 0:
blk.remove(d)
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 import_join(src):
ext = os.path.splitext(src)[1].lower()
if ext in (".glb", ".gltf"):
bpy.ops.import_scene.gltf(filepath=src)
elif ext == ".fbx":
bpy.ops.import_scene.fbx(filepath=src)
elif ext == ".obj":
bpy.ops.wm.obj_import(filepath=src)
else:
raise ValueError("unsupported: " + ext)
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
if not meshes:
raise ValueError("no meshes")
# BAKE ancestor transforms into each mesh, then drop non-mesh nodes. glТF imports often park
# the mesh under a parent empty carrying unit-conversion scale / pivot offset — joining +
# transform_apply alone leaves that parent node in the export (asset ships off-origin & scaled,
# violating "origin at base"). parent_clear KEEP_TRANSFORM folds matrix_world into each basis.
bpy.ops.object.select_all(action='DESELECT')
for m in meshes:
m.select_set(True)
bpy.context.view_layer.objects.active = meshes[0]
bpy.ops.object.parent_clear(type='CLEAR_KEEP_TRANSFORM')
for o in list(bpy.data.objects):
if o.type != 'MESH': # empties, armatures, cameras, lights
bpy.data.objects.remove(o, do_unlink=True)
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
obj.modifiers.clear() # drop armature/etc modifiers (static props)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
return obj
def clean_mesh(obj):
"""Drop stray/loose geometry that inflates the bbox (dumped assets are full of it):
weld doubles, delete loose verts/edges, then clip statistical outlier verts
(|coord-median| > 6*MAD on any axis) — the stray points that make a table read 55m."""
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.remove_doubles(threshold=0.0001)
bpy.ops.mesh.delete_loose(use_verts=True, use_edges=True, use_faces=False)
bpy.ops.mesh.quads_convert_to_tris() # so len(polygons)==triangle count: the ≤5k budget
bpy.ops.object.mode_set(mode='OBJECT') # gate + reported tri counts stay honest (glTF triangulates on export anyway)
vs = obj.data.vertices
n = len(vs)
if n < 20:
return 0
outliers = []
for axis in range(3):
vals = sorted(v.co[axis] for v in vs)
med = vals[n // 2]
mad = sorted(abs(x - med) for x in vals)[n // 2] or 1e-6
lo, hi = med - 6 * mad * 1.4826, med + 6 * mad * 1.4826
for v in vs:
if v.co[axis] < lo or v.co[axis] > hi:
outliers.append(v.index)
outliers = set(outliers)
if outliers and len(outliers) < n * 0.03: # only if they are a tiny minority
for v in vs:
v.select = v.index in outliers
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.delete(type='VERT')
bpy.ops.object.mode_set(mode='OBJECT')
return len(outliers)
return 0
def resize_textures(max_px=1024):
for img in bpy.data.images:
if img.size[0] > max_px or img.size[1] > max_px:
w, h = img.size
s = max_px / max(w, h)
img.scale(max(1, int(w * s)), max(1, int(h * s)))
def normalize_one(spec):
wipe()
src = os.path.join(ROOT, spec["src"]) if not os.path.isabs(spec["src"]) else spec["src"]
obj = import_join(src)
clipped = clean_mesh(obj) # welds, drops loose geo, TRIANGULATES
tri_before = len(obj.data.polygons) # true source triangle count (post-triangulate)
# stand upright (if mis-authored), then face -Z
rx, ry, rz = UP_ROT.get(spec.get("up", "none"), (0, 0, 0))
obj.rotation_euler = (rx, ry, rz)
if spec.get("yaw"):
obj.rotation_euler[2] += math.radians(spec["yaw"])
bpy.ops.object.transform_apply(rotation=True)
# solidify: cap open bottoms → watertight, white sole (finish_glb.py:54)
if spec.get("solidify"):
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.remove_doubles(threshold=0.0002)
n0 = len(obj.data.polygons)
bpy.ops.mesh.fill_holes(sides=0)
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
n1 = len(obj.data.polygons)
if n1 > n0:
white = bpy.data.materials.new("sole_white"); white.use_nodes = True
b = white.node_tree.nodes.get("Principled BSDF")
if b:
b.inputs["Base Color"].default_value = (1, 1, 1, 1)
obj.data.materials.append(white)
widx = len(obj.data.materials) - 1
for i in range(n0, n1):
obj.data.polygons[i].material_index = widx
# 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)
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.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)
if spec.get("bright"):
for m in bpy.data.materials:
if not m.use_nodes:
continue
b = m.node_tree.nodes.get("Principled BSDF")
if not b:
continue
bc = b.inputs.get("Base Color"); ec = b.inputs.get("Emission Color")
es = b.inputs.get("Emission Strength")
if ec is None:
continue
if bc and bc.is_linked:
m.node_tree.links.new(bc.links[0].from_socket, ec)
elif bc:
ec.default_value = bc.default_value
if es is not None:
es.default_value = 0.32
# base-origin + height-normalise (finish_glb.py:109)
mn, mx = bounds(obj)
h = spec.get("height_m", 0)
if h and h > 0:
cur = (mx.z - mn.z) or 1.0
obj.scale *= (h / cur)
bpy.ops.object.transform_apply(scale=True)
mn, mx = bounds(obj)
base = Vector(((mn.x + mx.x) / 2, (mn.y + mx.y) / 2, mn.z))
obj.location -= base
bpy.ops.object.transform_apply(location=True)
mn, mx = bounds(obj)
resize_textures(1024)
name = f"procity_{spec['category']}_{spec['id']}_01.glb"
out = os.path.join(OUT_DIR, name)
bpy.ops.export_scene.gltf(
filepath=out, export_format='GLB', export_yup=True, export_apply=True,
export_image_format='WEBP', export_image_quality=85,
)
thumb = render_thumb(name.replace(".glb", ".png"))
return {
"src": spec["src"], "file": name, "out": out, "thumb": thumb,
"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)],
}
def render_thumb(png_name):
"""One 3/4 hero shot, 256px, textured workbench (render_glb.py lighting)."""
scn = bpy.context.scene
obj = bpy.context.view_layer.objects.active
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))
d = ctr - cam.location
cam.rotation_euler = d.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 main():
specs = json.load(open(BATCH))
results, errors = [], []
for spec in specs:
try:
r = normalize_one(spec)
results.append(r)
print(f"OK {r['file']:34} tris {r['tri_before']}{r['tri_after']:>5} "
f"size {r['size_m']} footprint {r['footprint']}")
except Exception as e:
errors.append({"src": spec.get("src"), "id": spec.get("id"), "error": str(e)})
print(f"ERR {spec.get('src')}: {e}")
json.dump({"results": results, "errors": errors},
open(os.path.join(OUT_DIR, "_results.json"), "w"), indent=2)
print(f"\nNORMALIZE_DONE ok={len(results)} err={len(errors)}{OUT_DIR}/_results.json")
main()