105 lines
4.8 KiB
Python
105 lines
4.8 KiB
Python
|
|
# Rig-bait decimate: make an AccuRig-loadable FBX from a raw ~1.9M-tri Tripo GLB.
|
||
|
|
# Successor to male_mesh_decimate.py (July male lane) for the rig-graft lane
|
||
|
|
# (.agents/plans/rig-graft-lane-2026-08-04.md). Differences from July, both Jeremy calls:
|
||
|
|
# * NO scale / recenter — the source GLBs stay at Tripo's native ~0.98 m, feet at Z=0.
|
||
|
|
# * Hands budget 40k (was 10k): the bait's hand density bounds how crisply per-finger
|
||
|
|
# weights transfer back onto the 170k-tri full-res hands in the graft step.
|
||
|
|
# Region cuts are bbox-relative (works at any scale): head above 86.5% of height,
|
||
|
|
# hands beyond 75.6% of half-span — same proportions the July tool used at 1.85 m.
|
||
|
|
# The bait is DISPOSABLE: it exists to carry a skeleton out of AccuRig. Never ship it.
|
||
|
|
#
|
||
|
|
# blender --background --python tools/rigbait_decimate.py -- <src.glb> <out_dir> <basename> [body head hand]
|
||
|
|
import bpy, os, sys, math
|
||
|
|
from mathutils import Vector
|
||
|
|
|
||
|
|
args = sys.argv[sys.argv.index("--") + 1:]
|
||
|
|
SRC, OUT, BASE = args[0], args[1], args[2]
|
||
|
|
T_BODY, T_HEAD, T_HAND = (int(a) for a in args[3:6]) if len(args) >= 6 else (24000, 14000, 40000)
|
||
|
|
os.makedirs(OUT, exist_ok=True)
|
||
|
|
|
||
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||
|
|
bpy.ops.import_scene.gltf(filepath=SRC)
|
||
|
|
body = max((o for o in bpy.data.objects if o.type == "MESH"), key=lambda o: len(o.data.vertices))
|
||
|
|
me = body.data
|
||
|
|
bpy.ops.object.select_all(action="DESELECT")
|
||
|
|
body.select_set(True)
|
||
|
|
bpy.context.view_layer.objects.active = body
|
||
|
|
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||
|
|
|
||
|
|
bb = [body.matrix_world @ Vector(c) for c in body.bound_box]
|
||
|
|
mnz, mxz = min(v.z for v in bb), max(v.z for v in bb)
|
||
|
|
half_span = max(abs(v.x) for v in bb)
|
||
|
|
NECK_Z = mnz + 0.865 * (mxz - mnz)
|
||
|
|
HAND_X = 0.756 * half_span
|
||
|
|
print(f"[bait] {BASE}: height={mxz-mnz:.3f} half_span={half_span:.3f} neck_z={NECK_Z:.3f} hand_x={HAND_X:.3f}")
|
||
|
|
|
||
|
|
# anti-facet shading fixes (shading only, geometry untouched)
|
||
|
|
try: bpy.ops.mesh.customdata_custom_splitnormals_clear()
|
||
|
|
except Exception: pass
|
||
|
|
if "sharp_edge" in me.attributes:
|
||
|
|
me.attributes.remove(me.attributes["sharp_edge"])
|
||
|
|
|
||
|
|
def is_head(v): return v.co.z > NECK_Z
|
||
|
|
def is_hand(v): return abs(v.co.x) > HAND_X
|
||
|
|
|
||
|
|
def group(name, pred):
|
||
|
|
g = body.vertex_groups.new(name=name)
|
||
|
|
g.add([v.index for v in me.vertices if pred(v)], 1.0, "REPLACE")
|
||
|
|
return g
|
||
|
|
|
||
|
|
head_tris = sum(1 for p in me.polygons if all(is_head(me.vertices[vi]) for vi in p.vertices))
|
||
|
|
hand_tris = sum(1 for p in me.polygons if all(is_hand(me.vertices[vi]) for vi in p.vertices))
|
||
|
|
print(f"[bait] regions: head_tris={head_tris} hand_tris={hand_tris} total={len(me.polygons)}")
|
||
|
|
if hand_tris < 20000 or head_tris < 20000:
|
||
|
|
raise SystemExit(f"[bait] region cut looks wrong (head={head_tris}, hands={hand_tris}) — check pose/thresholds")
|
||
|
|
|
||
|
|
group("P_BODY", lambda v: is_head(v) or is_hand(v))
|
||
|
|
group("P_HEAD", lambda v: not is_head(v))
|
||
|
|
group("P_HAND", lambda v: not is_hand(v))
|
||
|
|
|
||
|
|
targets = {"P_BODY": T_BODY, "P_HEAD": T_HEAD, "P_HAND": T_HAND}
|
||
|
|
region_now = {"P_BODY": len(me.polygons) - head_tris - hand_tris,
|
||
|
|
"P_HEAD": head_tris, "P_HAND": hand_tris}
|
||
|
|
for pg in ("P_BODY", "P_HEAD", "P_HAND"):
|
||
|
|
other_now = len(me.polygons) - region_now[pg]
|
||
|
|
dec = body.modifiers.new("dec", "DECIMATE")
|
||
|
|
dec.ratio = min(1.0, (targets[pg] + other_now) / max(1, len(me.polygons)))
|
||
|
|
dec.vertex_group = pg
|
||
|
|
dec.invert_vertex_group = True
|
||
|
|
dec.delimit = {"UV"}
|
||
|
|
bpy.ops.object.modifier_apply(modifier="dec")
|
||
|
|
me = body.data
|
||
|
|
region_now[pg] = targets[pg]
|
||
|
|
print(f"[bait] after {pg} pass: tris={len(me.polygons)}")
|
||
|
|
|
||
|
|
bpy.ops.object.shade_smooth()
|
||
|
|
|
||
|
|
# QA renders: whole body + hand closeup (the region the budgets exist to protect)
|
||
|
|
def render(name, loc, look_z):
|
||
|
|
cam = bpy.data.objects.get("cam")
|
||
|
|
if cam is None:
|
||
|
|
camd = bpy.data.cameras.new("cam"); cam = bpy.data.objects.new("cam", camd)
|
||
|
|
bpy.context.scene.collection.objects.link(cam)
|
||
|
|
bpy.context.scene.camera = cam
|
||
|
|
cam.location = loc
|
||
|
|
cam.rotation_euler = (math.radians(90), 0, 0)
|
||
|
|
cam.location.z = look_z
|
||
|
|
sc = bpy.context.scene
|
||
|
|
sc.render.engine = "BLENDER_WORKBENCH"
|
||
|
|
sc.display.shading.light = "STUDIO"
|
||
|
|
sc.display.shading.color_type = "TEXTURE"
|
||
|
|
sc.render.resolution_x = 800; sc.render.resolution_y = 800
|
||
|
|
sc.render.filepath = os.path.join(OUT, name)
|
||
|
|
bpy.ops.render.render(write_still=True)
|
||
|
|
|
||
|
|
h = mxz - mnz
|
||
|
|
render(f"{BASE}_qa_front.png", Vector((0, -2.0 * h, 0)), mnz + 0.5 * h)
|
||
|
|
render(f"{BASE}_qa_hand.png", Vector((HAND_X + 0.08 * h, -0.35 * h, 0)), mnz + 0.72 * h)
|
||
|
|
|
||
|
|
bpy.ops.object.select_all(action="DESELECT"); body.select_set(True)
|
||
|
|
for g in list(body.vertex_groups): body.vertex_groups.remove(g)
|
||
|
|
fbx_out = os.path.join(OUT, f"{BASE}.fbx")
|
||
|
|
bpy.ops.export_scene.fbx(filepath=fbx_out, use_selection=True, path_mode="COPY", embed_textures=True)
|
||
|
|
print(f"[bait] wrote {fbx_out} ({os.path.getsize(fbx_out)/1e6:.1f} MB), final tris={len(me.polygons)}")
|
||
|
|
print("[bait] DONE")
|