PROCITY/pipeline/normalize.py
m3ultra 8ad446a48a Lane E R41 (§41.1+§41.2): 46 clips and 110 fittings — and every handover number was the wrong unit
MOTION (§41.1). The clip library goes 8 -> 46: ten idles, eight browse, eight sit/lean,
eight social, six locomotion, six venue, in SIX grouped GLBs (one fetch each), 3.35 MB —
LESS than the 4.29 MB the old eight cost, via lossless dedup + int16 rotations (worst
error 0.0034 deg). All six verified skeleton-only (tris 0, meshes 0, nodes 66): ZERO DRAW,
which is what makes this round affordable. No retarget was run and none was wanted — the
bank and the peds are the same mixamorig skeleton, so retargeting would add error AND bake
the ped mesh into the clip, ending zero-draw. MIRPAMO 'make smoke' green to prove the tool,
then deliberately unused. Three seeds in the brief were duds and got substituted: the whole
*_Degree_Turn set is RIFLE-AIMING, and two 'examine' clips are 262 KB static poses, not
motion. R16 flat-body trap re-checked on all 46 (spine tilt 24 samples/clip, 0 frames >75
deg); turn_in_place auto-demoted from loopable on a 36.7 deg seam.

RULING 1 — the Bandai hazard, closed non-destructively and better than specified. 3,077 CC
BY-NC clips sat unzoned in a neutrally-named path. Renamed PER FILE (3,077/3,077) not just
the directory, because mirpamo names output <rig>@<clip>.glb — the _NC-research marker now
PROPAGATES into any retargeted GLB automatically. Nothing deleted; ultra's red/bandai
re-verified as canonical. Ledger corrected: the manifest claimed CC-BY-NC-ND, the bundled
licence text says CC BY-NC, no ND.

PROPS (§41.2). 110 assets from three libraries, published, sha1-verified, 0 validator errors
both modes. THE FINDING: every handover number was a TRIANGLE count, and triangles were
never the binding constraint — DRAW CALLS were. As handed over this cargo cost 14,140 draws
against a 162-draw margin; it ships at 117. '3dstore passes as-is' was true on tris/metres/
Draco/textures and FALSE on draws — 30 of 46 files carried up to 16 materials on one mesh
(one per record sleeve), so a 1,020-tri tub cost 16 draws; baked to COLOR_0, 288 -> 40, zero
tri drift, A/B identical. dj-gear's own manifest claimed median 6,288 tris / 18 under budget;
measured 51,528 and 12, with draws to 2,145. Pub props were NOT metre-correct (every source
unit-normalised to max dim 1.00 m) and 40x decimation was impossible as specified (loungeChair
is 94% non-manifold).

Ruling 5 vs the draw budget was a real conflict (one mixer = 4.6x a whole room); resolved by
joining SCENERY selectively while every control node keeps its object/name/pivot — verified
by parsing the SHIPPED GLB, not the tool that wrote it: PASS 4 / STATIC 10 / N/A 17 / FAIL 0.
deck_1200_rigged = 5 draws with Platter_SPIN, Arm_YAW, Fader_PITCH, Btn_STARTSTOP intact.

The R40 four-surface transmission gate FIRED ON LIVE CARGO: 5 genuinely transmissive
materials (cartridge dust-covers, a mixer meter window) that would each have doubled every
opaque draw in the room. 9 assets rejected on eyeball after decimation rather than shipping
mush. Two existing-tooling bugs fixed: normalize.py's yaw/up were silent no-ops (the jukebox
exported 0.40 m instead of 0.97 m), and footprints now measure the shipped GLB.

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

477 lines
21 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 nonmanifold_ratio(obj):
"""Fraction of vertices on a non-manifold edge — the number that predicts whether COLLAPSE
decimation can work at all. Measured, not guessed: see remesh_shellsoup()."""
n = len(obj.data.vertices)
if not n:
return 0.0
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='DESELECT')
bpy.ops.mesh.select_non_manifold()
bpy.ops.object.mode_set(mode='OBJECT')
return sum(1 for v in obj.data.vertices if v.select) / n
def remesh_shellsoup(obj, budget, voxel=None, thick=0.0):
"""Voxel-remesh a shell-soup mesh to a manifold surface, decimate to budget, and carry the
source's vertex colours across.
[R41 §41.2] `art_incoming`'s 48 pub props are trimesh output at ~200k tris that COLLAPSE
decimation cannot touch: loungeChair is 94% non-manifold (376,076 of 399,542 verts) and a
ratio-0.01 decimate moved it 1,319,836 -> 1,274,119, i.e. 3%. topiary behaved the same at 93%
non-manifold; pokie, at 29%, decimated 198k -> 47k normally. Decimate cannot merge across
overlapping shells, so the fix is to stop trying: rebuild the topology with a voxel remesh
(OpenVDB, always manifold), then decimate THAT, then transfer the colour back.
This is the same "throw the topology away" move as bake_lowpoly.py, minus the Cycles bake —
these props carry COLOR_0 and no textures, so a vertex-colour transfer keeps them at one
material and zero texture bytes instead of adding a 1024px atlas each.
"""
src = obj
bpy.ops.object.select_all(action='DESELECT')
src.select_set(True)
bpy.context.view_layer.objects.active = src
bpy.ops.object.duplicate()
lp = bpy.context.view_layer.objects.active # the copy becomes the low-poly
lp.name = "LP"
dims = lp.dimensions
v = voxel or max(max(dims) / 150.0, 1e-4)
# A voxel remesh is a signed-distance field: it needs a VOLUME. Feed it a thin open shell and
# it returns a lattice of holes — the keg (a thin-walled scan) came back as a wire cage at
# every voxel size tried, while solid props like the couch remeshed cleanly. Giving the shell
# a wall thicker than one voxel first is what makes the field well-defined.
if thick:
sm = lp.modifiers.new("solid", 'SOLIDIFY')
sm.thickness = thick if thick > 0 else v * 2.5
sm.offset = 0.0
sm.use_even_offset = False
bpy.ops.object.modifier_apply(modifier=sm.name)
rm = lp.modifiers.new("remesh", 'REMESH')
rm.mode = 'VOXEL'
rm.voxel_size = v
rm.adaptivity = 0.0
bpy.ops.object.modifier_apply(modifier=rm.name)
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(lp.data.polygons)
if n > budget:
dm = lp.modifiers.new("dec", 'DECIMATE')
dm.ratio = max(0.002, budget / n)
bpy.ops.object.modifier_apply(modifier=dm.name)
# Carry colour (and UVs, if the source is textured) across from the dense original by
# nearest-face interpolation. The destination layers must be CREATED first with
# `datalayout_transfer` — a Data Transfer modifier applied without it silently transfers
# nothing and the prop exports colourless, which is exactly how the first run of this came
# back "colour LOST" on all six.
transferred = False
src_cols = list(src.data.color_attributes)
src_uvs = list(src.data.uv_layers)
if src_cols or src_uvs:
try:
dt = lp.modifiers.new("xfer", 'DATA_TRANSFER')
dt.object = src
loops, verts = set(), set()
if src_cols:
dom = (src.data.color_attributes.active_color or src_cols[0]).domain
(loops if dom == 'CORNER' else verts).add(
'COLOR_CORNER' if dom == 'CORNER' else 'COLOR_VERTEX')
if src_uvs:
loops.add('UV')
if loops:
dt.use_loop_data = True
dt.data_types_loops = loops
dt.loop_mapping = 'POLYINTERP_NEAREST'
if verts:
dt.use_vert_data = True
dt.data_types_verts = verts
dt.vert_mapping = 'POLYINTERP_NEAREST'
bpy.ops.object.datalayout_transfer(modifier=dt.name) # create dest layers
bpy.ops.object.modifier_apply(modifier=dt.name)
transferred = bool(lp.data.color_attributes) if src_cols else bool(lp.data.uv_layers)
except Exception as e:
print(f" colour/UV transfer failed: {e}")
# the voxel remesh can leave degenerate faces; Blender warns "mesh is not valid" on export
lp.data.validate(verbose=False)
lp_mesh_name = lp.data.name
bpy.data.objects.remove(src, do_unlink=True)
bpy.ops.object.select_all(action='DESELECT')
lp.select_set(True)
bpy.context.view_layer.objects.active = lp
print(f" remesh voxel={v:.4f} -> {n} tris -> {len(lp.data.polygons)} "
f"(colour {'transferred' if transferred else 'LOST'}) [{lp_mesh_name}]")
return lp, transferred
def wire_vertex_colour(obj):
"""Route the active colour attribute into Base Color on every material.
The glTF exporter only writes COLOR_0 when a material actually reads the attribute
(`export_vertex_color='MATERIAL'`), and these sources arrive with NO material block at all —
Blender invents a plain grey one on import. Without this the remeshed props would export
colourless and every thumbnail would be grey.
"""
if not obj.data.color_attributes:
return False
cname = obj.data.color_attributes.active_color.name \
if obj.data.color_attributes.active_color else obj.data.color_attributes[0].name
if not obj.data.materials:
m = bpy.data.materials.new("vcol")
m.use_nodes = True
obj.data.materials.append(m)
for m in obj.data.materials:
if not m or not m.use_nodes:
continue
nt = m.node_tree
bsdf = nt.nodes.get("Principled BSDF")
if not bsdf:
continue
n = nt.nodes.new("ShaderNodeVertexColor")
n.layer_name = cname
nt.links.new(n.outputs["Color"], bsdf.inputs["Base Color"])
if "Specular IOR Level" in bsdf.inputs:
bsdf.inputs["Specular IOR Level"].default_value = 0.2
bsdf.inputs["Roughness"].default_value = 0.72
return True
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)
# shell-soup path: rebuild topology before the decimate loop, which cannot touch it
nm = 0.0
remeshed = vcol = False
if spec.get("remesh"):
nm = nonmanifold_ratio(obj)
obj, vcol = remesh_shellsoup(obj, spec.get("tris", 5000), spec.get("voxel"),
spec.get("shell_thickness", 0.0))
remeshed = True # 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))
if rx or ry or rz or spec.get("yaw"):
# Select+activate explicitly: transform_apply acts on the SELECTION, and after clean_mesh
# (or a remesh) the object we mean is not guaranteed to still be it. A silently-skipped
# yaw is invisible in the log and ships a prop facing the wrong way.
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
obj.rotation_mode = 'XYZ'
before = tuple(round(v, 4) for v in obj.dimensions)
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)
after = tuple(round(v, 4) for v in obj.dimensions)
if before == after and (spec.get("yaw", 0) % 180) not in (0,):
print(f" WARNING: yaw/up requested but dimensions unchanged {before} — not applied")
else:
print(f" oriented {before} -> {after}")
# 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)
if spec.get("remesh") or spec.get("vertex_colour"):
vcol = wire_vertex_colour(obj) or vcol
name = f"procity_{spec['category']}_{spec['id']}_01.glb"
out = os.path.join(OUT_DIR, name)
exp = dict(filepath=out, export_format='GLB', export_yup=True, export_apply=True,
export_image_format='WEBP', export_image_quality=85)
try: # Blender >=4.2 gates COLOR_0 behind this enum
bpy.ops.export_scene.gltf(**exp, export_vertex_color='ACTIVE')
except TypeError:
bpy.ops.export_scene.gltf(**exp)
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,
"remeshed": remeshed, "nonmanifold": round(nm, 3), "vertex_colour": vcol,
"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()