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>
477 lines
21 KiB
Python
477 lines
21 KiB
Python
"""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()
|