"""WARDROBEGOD blender ops — headless Blender does the heavy lifting the browser can't. blender -b --python blender_ops.py -- ops: convert any format Blender reads → GLB (textures packed) scale uniform scale so bbox height = height_m, applied decimate Decimate modifier — PRESERVES vertex weights/rig fit [inflate_mm] weight-transfer the body's skinning onto the garment (nearest-face interpolated), parent to the body's armature. mode=merge → one GLB (dressed body); mode=garment → garment+skeleton only (a wardrobe item, reusable in assemble) harvest [keep_skin_regex] split a garment off an ALREADY-RIGGED clothed character by material. Blender copies the vertex groups onto the separated mesh, so the garment lands already skinned to the donor's skeleton — no fit, no weight transfer, and it bends correctly because it was authored to. Trade-off: it inherits the donor's proportions. assemble ... dressed combo: garments must have been fitted against the same skeleton (bone names match) House rules: never fit against an unrigged body (we abort); rigged meshes are never sent to the farm /finish (this file is the safe local path for them). """ import bpy, mathutils, re, sys, os argv = sys.argv[sys.argv.index('--') + 1:] OP, ARGS = argv[0], argv[1:] def clean(): bpy.ops.wm.read_factory_settings(use_empty=True) def load(path): """Import path, return the set of objects it brought in.""" before = set(bpy.data.objects) ext = os.path.splitext(path)[1].lower() if ext == '.fbx': bpy.ops.import_scene.fbx(filepath=path) elif ext in ('.glb', '.gltf'): bpy.ops.import_scene.gltf(filepath=path) elif ext == '.obj': bpy.ops.wm.obj_import(filepath=path) else: raise SystemExit(f'unsupported format: {ext}') return list(set(bpy.data.objects) - before) def export(out): os.makedirs(os.path.dirname(out), exist_ok=True) bpy.ops.export_scene.gltf(filepath=out, export_format='GLB') print(f'WROTE {out}') def apply_mod(obj, name): with bpy.context.temp_override(object=obj, active_object=obj, selected_editable_objects=[obj]): bpy.ops.object.modifier_apply(modifier=name) def meshes(objs): return [o for o in objs if o.type == 'MESH'] def is_helper(o): """Bone-widget / marker mesh: no material and a handful of verts. These ride along in character_kit and NPCFACTORY rigs (a radius-1 42-vert 'Icosphere', so exactly 2.0 units tall) and they poison every bbox measurement — height readouts, camera auto-framing, and scale normalisation all silently measure the widget instead of the character. NPCFACTORY's render_plates.py hit this too and worked around it by framing on the dominant mesh only. """ return (o.type == 'MESH' and not [m for m in o.data.materials if m] and len(o.data.vertices) < 100) def real_meshes(objs): """Meshes that are actually the model — helpers excluded. Falls back to all meshes so a genuinely material-less model still measures rather than returning nothing.""" ms = meshes(objs) return [o for o in ms if not is_helper(o)] or ms def apply_scale(roots, objs): """Scale a model by setting the ROOT transform — deliberately without transform_apply. Blender 5.1.2 SEGFAULTS inside object_transform_apply_exec when the selection contains both an armature and its own parented children (reproducible on character_kit's hum_character, both with temp_override and with a plain select-then-apply). A segfault kills the process, so it has to be avoided rather than caught. Not baking costs us nothing here: glTF encodes node scale natively, so the exported file is the right size and every downstream measurement (bbox, height, the viewer) reads it correctly. Only the roots are scaled — children inherit, and scaling them too would double-apply. """ # The caller has already set r.scale; there is deliberately nothing else to do. An earlier # view_layer.update() here was itself crashing on rigs with parented children. return def bbox_of(objs): import mathutils mn = mathutils.Vector((1e9,) * 3); mx = mathutils.Vector((-1e9,) * 3) for o in real_meshes(objs): for c in o.bound_box: w = o.matrix_world @ mathutils.Vector(c) mn = mathutils.Vector(map(min, mn, w)); mx = mathutils.Vector(map(max, mx, w)) return mn, mx def armatures(objs): return [o for o in objs if o.type == 'ARMATURE'] def total_tris(objs): return sum(sum(len(p.vertices) - 2 for p in o.data.polygons) for o in meshes(objs)) if OP == 'convert': clean(); load(ARGS[0]); export(ARGS[1]) elif OP == 'scale': clean() objs = load(ARGS[0]); target = float(ARGS[2]) mn, mx = bbox_of(objs) # same helper-widget exclusion — this op measured them too h = mx.z - mn.z if h <= 0: raise SystemExit('flat object — no height to scale') s = target / h roots = [o for o in objs if not o.parent] for r in roots: r.scale = [c * s for c in r.scale] apply_scale(roots, objs) print(f'scaled ×{s:.3f} → {target}m') export(ARGS[1]) elif OP == 'decimate': clean() objs = load(ARGS[0]); target = int(ARGS[2]) tris = total_tris(objs) ratio = min(1.0, target / max(tris, 1)) for o in meshes(objs): m = o.modifiers.new('dec', 'DECIMATE') m.ratio = ratio # keep the Armature modifier LAST so skinning still evaluates after the cut while o.modifiers[0].name != 'dec': with bpy.context.temp_override(object=o): bpy.ops.object.modifier_move_up(modifier='dec') apply_mod(o, 'dec') print(f'decimated {tris:,} → {total_tris(objs):,} tris (ratio {ratio:.3f}) — weights kept') export(ARGS[1]) elif OP == 'fit': clean() body_objs = load(ARGS[0]) arms = armatures(body_objs) if not arms: raise SystemExit('body has no armature — rig it first (MIRPAMO/Mixamo), then fit') arm = arms[0] body = max(meshes(body_objs), key=lambda o: len(o.vertex_groups), default=None) if body is None or not body.vertex_groups: raise SystemExit('body has no skin weights to copy') garm_objs = load(ARGS[1]) mode = ARGS[3] if len(ARGS) > 3 else 'merge' inflate = float(ARGS[4]) / 1000.0 if len(ARGS) > 4 else 0.0 for g in meshes(garm_objs): if inflate: # a few mm of air so the body doesn't poke through d = g.modifiers.new('puff', 'DISPLACE'); d.strength = inflate; d.mid_level = 0 apply_mod(g, 'puff') with bpy.context.temp_override(object=body, active_object=body, selected_editable_objects=[g, body], selected_objects=[g, body]): bpy.ops.object.data_transfer(data_type='VGROUP_WEIGHTS', use_create=True, vert_mapping='POLYINTERP_NEAREST', layers_select_src='ALL', layers_select_dst='NAME') g.parent = arm am = g.modifiers.new('arm', 'ARMATURE'); am.object = arm # garment armatures that came along with the import are dupes — drop them for a in armatures(garm_objs): bpy.data.objects.remove(a, do_unlink=True) if mode == 'garment': bpy.data.objects.remove(body, do_unlink=True) for o in meshes(body_objs): if o.name in bpy.data.objects: bpy.data.objects.remove(o, do_unlink=True) print(f'fitted {len(meshes(garm_objs))} garment mesh(es), mode={mode}, inflate={inflate * 1000:.0f}mm') export(ARGS[2]) elif OP == 'graft': # graft [radius_m] [--keep] # # Swap a crude AI-generated body part (hands, feet, head) for a detailed one, WITHOUT the # cut-and-bridge workflow every tutorial recommends. Bridge Edge Loops needs matching vertex # counts on both boundary loops; a TRELLIS cut edge is arbitrary unstructured soup, so that # step is where the evening goes. Instead: # · never cut — MASK the body's geometry near the target bone (a vertex group + MASK # modifier), so the old hand simply stops being drawn and is reversible # · align the part to the bone, normalising the cross-rig scale # · transfer weights from the body to the part (the same data_transfer `fit` uses) rather # than Ctrl+P Automatic Weights, which would re-weight the WHOLE character and destroy # the skinning Mixamo just produced # · parent to the body's existing armature; never join two mixamorig armatures, because # the name collision silently rewrites the bones the weights point at clean() body_objs = load(ARGS[0]) arms = armatures(body_objs) if not arms: raise SystemExit('body has no armature — rig it first, then graft') arm = arms[0] # Pick the body by VERTEX COUNT, not vertex-group count. A previously grafted part inherits # every one of the body's groups from the weight transfer, so counting groups can select the # hand as "the body" on a second pass — after which masking the other wrist finds nothing and # the old geometry silently survives under the new part. skinned = [o for o in real_meshes(body_objs) if o.vertex_groups] body = max(skinned or real_meshes(body_objs), key=lambda o: len(o.data.vertices), default=None) if body is None: raise SystemExit('no body mesh found') print(f'graft: body mesh = {body.name} ({len(body.data.vertices):,} verts)') bone_name = ARGS[3] bl = {b.name.lower(): b for b in arm.data.bones} bone = bl.get(bone_name.lower()) or next( (b for n, b in bl.items() if bone_name.lower() in n), None) if bone is None: raise SystemExit(f'no bone matching {bone_name!r}. have: {sorted(bl)[:12]}…') # Hold the NAME, not the Bone. bpy.ops.object.join() reallocates arm.data.bones, after which # a held Bone pointer silently refers to a different entry — that is how a graft aimed at # mixamorig:RightHand ended up parented under mixamorig:RightHandIndex4. TARGET_BONE = bone.name radius = float(ARGS[4]) if len(ARGS) > 4 else 0.12 import mathutils anchor = arm.matrix_world @ bone.head_local part_objs = load(ARGS[1]) # A "part" file is often a whole donor character (character_kit's rigged/hand1.glb carries a # prop, a full body AND a widget). Let the caller name the mesh; otherwise take the largest # real mesh and SAY which one was chosen rather than silently grafting a torso. want_mesh, bind, mirror = None, 'body', False for a in ARGS[5:]: if a.startswith('--mesh='): want_mesh = a.split('=', 1)[1].lower() elif a.startswith('--bind='): bind = a.split('=', 1)[1].lower() elif a == '--mirror': mirror = True # a .R hand asset becomes the .L one cands = real_meshes(part_objs) if not cands: raise SystemExit('part file has no mesh') if want_mesh: part_meshes = [o for o in cands if want_mesh in o.name.lower()] if not part_meshes: raise SystemExit(f'no part mesh matching {want_mesh!r}; have ' f'{[o.name for o in cands]}') else: part_meshes = [max(cands, key=lambda o: len(o.data.vertices))] print(f'graft: part file holds {[o.name for o in cands]}; using ' f'{[o.name for o in part_meshes]}') drop = [o for o in cands if o not in part_meshes] part_arms = armatures(part_objs) # capture BEFORE removing, or the list goes stale # Resolve NAMES up front: after bpy.data.objects.remove(), even reading o.name on a dropped # object raises ReferenceError: StructRNA has been removed. keep_names = [o.name for o in part_objs if o not in drop] for o in drop: # drop the meshes we are not grafting bpy.data.objects.remove(o, do_unlink=True) part_objs = [bpy.data.objects[n] for n in keep_names if n in bpy.data.objects] # bind=body discards the donor rig, so detach first (keeping world position) or the transform # below fights the donor armature's scale. bind=body only — under bind=part the mesh must stay # bound to its own rig, and we move that rig instead so the two never double-apply. if bind in ('body', 'none'): for g in part_meshes: if g.parent: wm = g.matrix_world.copy() g.parent = None g.matrix_world = wm bpy.context.view_layer.update() # One transform, applied to the meshes themselves: scale about the part's own bbox centre to # match the bone's length, then translate that centre onto the bone. Nudging root objects' # .location instead compounds with whatever the donor's armature/empty already carried. blen = (bone.tail_local - bone.head_local).length * (arm.matrix_world.to_scale().x or 1.0) blen = blen or 0.1 pmn, pmx = bbox_of(part_meshes) psize = max((pmx - pmn).length, 1e-6) ratio = (blen * 2.5) / psize centre = (pmn + pmx) / 2 M = mathutils.Matrix.Scale(-1.0, 4, (1, 0, 0)) if mirror else mathutils.Matrix.Identity(4) T = (mathutils.Matrix.Translation(anchor) @ mathutils.Matrix.Scale(ratio, 4) @ M @ mathutils.Matrix.Translation(-centre)) if bind in ('body', 'none'): for g in part_meshes: g.matrix_world = T @ g.matrix_world bpy.context.view_layer.update() # under bind=part the armature carries the transform (applied below) and the mesh rides along pmn2, pmx2 = bbox_of(part_meshes) print(f'graft: scaled x{ratio:.4f}, part bbox now ' f'{tuple(round(c, 3) for c in pmn2)}..{tuple(round(c, 3) for c in pmx2)} ' f'around anchor {tuple(round(c, 3) for c in anchor)}') # MASK the body's old geometry around the bone instead of deleting it — reversible, and it # sidesteps needing a clean boundary loop entirely. grp = body.vertex_groups.new(name='graft_hide_' + TARGET_BONE.split(':')[-1]) # Compare in WORLD space. Testing `v.co` against a world radius silently mis-masks whenever # the object carries a scale: the same body measured 8126 verts as FBX (mesh scale 100) and # ZERO after a GLB round-trip, because 0.1 world metres is 0.001 local units at that scale. mw = body.matrix_world hidden = [v.index for v in body.data.vertices if ((mw @ v.co) - anchor).length < radius] if hidden: grp.add(hidden, 1.0, 'REPLACE') m = body.modifiers.new('graft_mask', 'MASK') m.vertex_group = grp.name m.invert_vertex_group = True # keep everything EXCEPT the masked region print(f'graft: masked {len(hidden)} body verts within {radius}m of {TARGET_BONE}') # --bind=part: bring the part's OWN skeleton in and hang it off the target bone. This is the # mode that actually articulates — a detailed hand carries ~65 finger bones where Mixamo's # reduced rig has 8 across both hands, so binding to the body's wrist gives nice geometry that # cannot move its fingers. Joining is only safe because the part's bones are NOT mixamorig- # named (_rootJoint / thumb_base.R_03 …); two mixamorig rigs would collide and silently # repoint every weight, which is why `assemble` refuses to join and remaps instead. if bind == 'part' and part_arms: parm = part_arms[0] # Transform the TOP-MOST ancestor of everything we keep. Sketchfab-style exports nest the # rig and mesh several empties deep (RootNode > Sketchfab_model > *.fbx > ...), so # "objects with no parent" finds the wrong node and the mesh stays at the origin — a # stray sliver on the floor while the bones land correctly on the wrist. def top(o): while o.parent: o = o.parent return o for r in {top(o).name for o in list(part_meshes) + [parm]}: obj = bpy.data.objects[r] obj.matrix_world = T @ obj.matrix_world bpy.context.view_layer.update() root_bones = [b.name for b in parm.data.bones if b.parent is None] clash = {b.name for b in parm.data.bones} & {b.name for b in arm.data.bones} if clash: raise SystemExit(f'part rig shares {len(clash)} bone name(s) with the body ' f'({sorted(clash)[:3]}…). Joining would silently repoint weights — ' f'use --bind=body instead.') # join() deletes the part's armature OBJECT. Measured consequence: the part mesh's # ARMATURE modifier is left pointing at None and the mesh gets re-parented to whatever # empty sat above it (Sketchfab exports nest one), so it collapses from the wrist back to # rest position at the origin. Save the placement and re-bind explicitly afterwards. placed = {g.name: g.matrix_world.copy() for g in part_meshes} bpy.ops.object.select_all(action='DESELECT') parm.select_set(True); arm.select_set(True) bpy.context.view_layer.objects.active = arm # active survives the join bpy.ops.object.join() bpy.context.view_layer.update() for name, wm in placed.items(): g = bpy.data.objects.get(name) if not g: continue for m in g.modifiers: if m.type == 'ARMATURE' and m.object is None: m.object = arm # re-bind the dangling modifier g.parent = arm g.matrix_world = wm # restore the placement join destroyed bpy.context.view_layer.update() bpy.context.view_layer.objects.active = arm bpy.ops.object.mode_set(mode='EDIT') eb = arm.data.edit_bones tgt = eb.get(TARGET_BONE) for rb in root_bones: if rb in eb and tgt: eb[rb].parent = tgt eb[rb].use_connect = False # keep offset; connecting would snap it bpy.ops.object.mode_set(mode='OBJECT') print(f'graft: joined {len(root_bones)} part-rig root(s) under {TARGET_BONE} ' f'— body rig now {len(arm.data.bones)} bones, fingers articulate') export(ARGS[2]) raise SystemExit(0) # --bind=none: position only. Correct choice when the result is going straight back to Mixamo # for a fresh auto-rig, because the binding is about to be thrown away anyway — and skipping # it avoids the armature modifier deforming the part from a bind pose it never had. if bind == 'none': for g in part_meshes: # Drop the donor's ARMATURE modifier first — left attached it keeps deforming the # mesh from a bind pose whose armature we are about to discard, which is what turned # the hand into a spike. Then unparent KEEPING the world matrix, or the accumulated # parent transform vanishes and the mesh jumps back to its local coordinates. # APPLY the donor's armature modifier, do not remove it. This asset's stored rest # geometry is not hand-shaped — it is the armature that poses it into a hand (bbox # measured 84 units tall with the modifier off, ~0.08 with it on). Removing the # modifier therefore exports the spiky rest mesh; applying it bakes the real shape # into the vertices, which is what we want before discarding the rig. for m in list(g.modifiers): if m.type == 'ARMATURE': apply_mod(g, m.name) wm = g.matrix_world.copy() g.parent = None g.matrix_world = wm for a in part_arms: if a.name in bpy.data.objects: bpy.data.objects.remove(a, do_unlink=True) bpy.context.view_layer.update() mn3, mx3 = bbox_of(part_meshes) print(f'graft: positioned {len(part_meshes)} mesh(es) at {TARGET_BONE}, no binding ' f'(re-rig downstream); part now {tuple(round(c, 3) for c in mn3)}..' f'{tuple(round(c, 3) for c in mx3)}') export(ARGS[2]) raise SystemExit(0) # --bind=body (default): weights from the body, NOT Automatic Weights on a merged mesh. for g in part_meshes: with bpy.context.temp_override(object=body, active_object=body, selected_editable_objects=[g, body], selected_objects=[g, body]): bpy.ops.object.data_transfer(data_type='VGROUP_WEIGHTS', use_create=True, vert_mapping='POLYINTERP_NEAREST', layers_select_src='ALL', layers_select_dst='NAME') g.parent = arm am = g.modifiers.new('arm', 'ARMATURE'); am.object = arm if mirror: # A negative-scale mirror leaves every face wound backwards, so the part renders # inside-out. Recalculate outward normals on the mirrored geometry. for g in part_meshes: bpy.ops.object.select_all(action='DESELECT') g.select_set(True) bpy.context.view_layer.objects.active = g bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') bpy.ops.mesh.normals_make_consistent(inside=False) bpy.ops.object.mode_set(mode='OBJECT') print('graft: mirrored — normals recalculated outward') for a in part_arms: # never join two mixamorig rigs if a.name in bpy.data.objects: bpy.data.objects.remove(a, do_unlink=True) print(f'grafted {len(part_meshes)} mesh(es) onto {TARGET_BONE}') export(ARGS[2]) elif OP == 'mixamoprep': # mixamoprep [height_m] # # Mixamo's auto-rigger wants ONE mesh and no existing skeleton — hand it several objects or a # rig and it either refuses or rigs the wrong thing. So: bake any graft MASK modifiers (or the # geometry we "removed" comes back in the export), join every real mesh into one, drop the # armature entirely, and write FBX. clean() objs = load(ARGS[0]) keep = real_meshes(objs) if not keep: raise SystemExit('nothing to export') for o in keep: # bake masks first — a modifier is not a deletion for m in list(o.modifiers): if m.type == 'MASK': apply_mod(o, m.name) elif m.type == 'ARMATURE': o.modifiers.remove(m) # re-rigging from scratch; old binding is noise for o in list(bpy.data.objects): # armatures and helper widgets both confuse the rigger if o.type == 'ARMATURE' or (o.type == 'MESH' and o not in keep): bpy.data.objects.remove(o, do_unlink=True) keep = [o for o in keep if o.name in bpy.data.objects] bpy.ops.object.select_all(action='DESELECT') for o in keep: o.select_set(True) bpy.context.view_layer.objects.active = keep[0] if len(keep) > 1: bpy.ops.object.join() merged = bpy.context.view_layer.objects.active for vg in list(merged.vertex_groups): # stale groups name bones that no longer exist merged.vertex_groups.remove(vg) if len(ARGS) > 2: mn, mx = bbox_of([merged]) h = mx.z - mn.z if h > 0: s = float(ARGS[2]) / h merged.scale = [c * s for c in merged.scale] print(f'mixamoprep: scaled x{s:.4f} to {ARGS[2]}m') tris = sum(len(p.vertices) - 2 for p in merged.data.polygons) print(f'mixamoprep: one mesh, {len(merged.data.vertices):,} verts / {tris:,} tris, no armature') if tris > 1500000: print('mixamoprep: WARNING — Mixamo rejects very dense meshes; decimate first') os.makedirs(os.path.dirname(ARGS[1]), exist_ok=True) bpy.ops.export_scene.fbx(filepath=ARGS[1], path_mode='COPY', embed_textures=True) print(f'WROTE {ARGS[1]}') elif OP == 'thumb': # thumb [px] # Fixed front ortho camera on the real mesh (helpers excluded, or a bone widget decides the # framing). EEVEE + transparent film so thumbs composite onto any panel background. clean() objs = load(ARGS[0]) px = int(ARGS[2]) if len(ARGS) > 2 else 256 import mathutils for o in bpy.data.objects: # bind pose — a mid-clip thumb is unreadable if o.animation_data: o.animation_data_clear() bpy.context.view_layer.update() mn, mx = bbox_of(objs) c = (mn + mx) / 2 size = max((mx - mn).x, (mx - mn).z) or 1.0 sc = bpy.context.scene sc.render.engine = 'BLENDER_EEVEE' sc.render.resolution_x = sc.render.resolution_y = px sc.render.film_transparent = True w = bpy.data.worlds.new('w'); sc.world = w; w.use_nodes = True w.node_tree.nodes['Background'].inputs[0].default_value = (1, 1, 1, 1) w.node_tree.nodes['Background'].inputs[1].default_value = 1.0 cam = bpy.data.objects.new('cam', bpy.data.cameras.new('c')) cam.data.type = 'ORTHO'; cam.data.ortho_scale = size * 1.15 bpy.context.collection.objects.link(cam) cam.location = c + mathutils.Vector((0, -size * 3, 0)) cam.rotation_euler = mathutils.Vector((0, 1, 0)).to_track_quat('-Z', 'Y').to_euler() cam.rotation_euler = (1.5708, 0, 0) # look down +Y at the front of the model sc.camera = cam sun = bpy.data.objects.new('sun', bpy.data.lights.new('l', 'SUN')) sun.data.energy = 3.0; sun.rotation_euler = (0.9, 0.2, 0.5) bpy.context.collection.objects.link(sun) sc.render.filepath = ARGS[1] bpy.ops.render.render(write_still=True) print(f'THUMB {ARGS[1]} ({px}px)') elif OP == 'unitfix': # unitfix [target_m] # # Deliberately NOT scale-to-height. A 0.06m human is a UNIT error (character_kit rigs are # 6cm, NPCFACTORY banks 1.0m, TRELLIS output varies) and silently breaks assemble — that's # what produced a 27x oversized garment. But normalising every body to one height would # erase the small/medium/large/obese range the library is meant to carry, so we only correct # scales that are physically impossible for a human and leave real proportions alone. clean() objs = load(ARGS[0]) target = float(ARGS[2]) if len(ARGS) > 2 else 1.72 mn, mx = bbox_of(objs) # helper widgets excluded, or we'd measure a 2.0-unit Icosphere h = mx.z - mn.z if h <= 0: raise SystemExit('flat object — no height to measure') PLAUSIBLE = (0.5, 3.0) # any human outside this is a broken unit scale, not a body type if PLAUSIBLE[0] <= h <= PLAUSIBLE[1]: print(f'height {h:.3f}m is plausible — left alone (body-type variation is data, not an error)') s = 1.0 else: s = target / h roots = [o for o in objs if not o.parent] for r in roots: r.scale = [c * s for c in r.scale] apply_scale(roots, objs) print(f'UNIT FIX: {h:.4f}m is impossible for a human -> scaled x{s:.4f} to {target}m') print(f'unitfix done (scale {s:.4f})') export(ARGS[1]) elif OP == 'harvest': # harvest [keep_skin_regex] # # The trick this whole tier rests on: bpy.ops.mesh.separate(type='MATERIAL') carries the # vertex groups onto the separated piece. So a shirt split off a mixamorig donor arrives # ALREADY skinned to the exact skeleton every clip in the bank drives — no weight transfer, # no fitting, no shrinkwrap. It deforms correctly at the elbow because it was authored to. # # Cost, stated honestly: the garment is captive to that donor's proportions. On a slimmer # body it will clip or float; there is no cloth sim here to save it. clean() objs = load(ARGS[0]) want = ARGS[2].lower() skin_re = re.compile(ARGS[3] if len(ARGS) > 3 else r'skin|body|face|eye|hair|teeth|tongue|lash|brow|nail|mouth|head|hand|beard', re.I) arms = armatures(objs) if not arms: raise SystemExit('donor has no armature — it cannot donate a skinned garment') arm = arms[0] kept = [] for m in list(meshes(objs)): names = [ms.name for ms in m.data.materials if ms] if not any(want in n.lower() for n in names): continue if len(names) > 1: # split this mesh along its material seams bpy.ops.object.select_all(action='DESELECT') m.select_set(True) bpy.context.view_layer.objects.active = m bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') bpy.ops.mesh.separate(type='MATERIAL') bpy.ops.object.mode_set(mode='OBJECT') # after separating, keep only pieces whose material matches and which aren't skin for m in list(meshes(list(bpy.data.objects))): names = [ms.name for ms in m.data.materials if ms] hit = any(want in n.lower() for n in names) skin = any(skin_re.search(n) for n in names) if hit and not skin: kept.append(m) if not kept: raise SystemExit(f'no garment mesh matched material {ARGS[2]!r} ' f'(donor may bake skin+cloth onto one atlas — nothing to separate)') # purge everything else, keeping the armature so the weights stay meaningful for o in list(bpy.data.objects): if o.type == 'MESH' and o not in kept: bpy.data.objects.remove(o, do_unlink=True) elif o.type == 'ARMATURE' and o is not arm: bpy.data.objects.remove(o, do_unlink=True) for g in kept: g.parent = arm for mod in list(g.modifiers): if mod.type == 'ARMATURE': mod.object = arm if not any(mod.type == 'ARMATURE' for mod in g.modifiers): am = g.modifiers.new('arm', 'ARMATURE'); am.object = arm # drop vertex groups the garment no longer uses — a separated sleeve keeps the whole # donor's group list, which bloats the GLB and confuses later merges used = {gi.group for v in g.data.vertices for gi in v.groups if gi.weight > 0.0001} for vg in list(g.vertex_groups): if vg.index not in used: g.vertex_groups.remove(vg) print(f'harvested {len(kept)} mesh(es) for {ARGS[2]!r}: ' f'{total_tris(kept):,} tris, {len(kept[0].vertex_groups)} bones driving') export(ARGS[1]) elif OP == 'assemble': clean() body_objs = load(ARGS[0]) arms = armatures(body_objs) if not arms: raise SystemExit('body has no armature') arm = arms[0] def bare(n): """'mixamorig:LeftArm.001' -> 'leftarm'. The fleet is NOT uniform: character_kit and NPCFACTORY rigs use the 'mixamorig:' prefix, Ready Player Me donors use bare 'Hips', and Blender suffixes '.001' on any import collision. Matching on the bare name is what actually makes a garment harvested off one rig drive on another.""" n = re.sub(r'\.\d{3}$', '', n) return re.sub(r'^.*[:_]', '', n).lower() body_bones = {b.name for b in arm.data.bones} bare_map = {bare(b.name): b.name for b in arm.data.bones} for gpath in ARGS[2:]: gobjs = load(gpath) # Both rigs are mixamorig, so importing the garment collides on EVERY bone name and # Blender silently suffixes the newcomer '.001'. Re-parenting then leaves the garment's # vertex groups pointing at bones that no longer exist, and the result is a mesh frozen # in bind pose while the body animates — no error, just a garment that refuses to move. # Map each group back onto the body's real bone name before dropping the dupe armature. # Donors are not authored at a common scale — character_kit rigs are ~0.06m tall while a # Ready Player Me donor is ~1.5m, so a harvested garment can arrive 25x oversized and # swallow the whole scene. Rescale by the ratio of a shared bone chain (hips->head) # before parenting, the same trick character_kit's assemble.fit_part uses. garm_arms = armatures(gobjs) def span(a): bb = {bare(b.name): b for b in a.data.bones} h, d = bb.get('hips'), bb.get('head') if h and d: return (a.matrix_world @ d.head_local - a.matrix_world @ h.head_local).length zs = [(a.matrix_world @ b.head_local).z for b in a.data.bones] return (max(zs) - min(zs)) if zs else 0.0 ratio = 1.0 if garm_arms: gs, bs = span(garm_arms[0]), span(arm) if gs > 1e-9 and bs > 1e-9: ratio = bs / gs if abs(ratio - 1.0) > 0.02: print(f' rescaling garment x{ratio:.4f} (donor rig {1 / ratio:.2f}x the body rig)') for g in meshes(gobjs): for vg in g.vertex_groups: if vg.name in body_bones: continue tgt = bare_map.get(bare(vg.name)) if tgt: vg.name = tgt else: print(f' WARN: {g.name} group {vg.name!r} has no bone on the body rig') if abs(ratio - 1.0) > 1e-6: g.scale = [c * ratio for c in g.scale] with bpy.context.temp_override(object=g, active_object=g, selected_editable_objects=[g]): bpy.ops.object.transform_apply(location=True, rotation=False, scale=True) g.parent = arm g.matrix_parent_inverse = arm.matrix_world.inverted() for m in g.modifiers: if m.type == 'ARMATURE': m.object = arm if not any(m.type == 'ARMATURE' for m in g.modifiers): am = g.modifiers.new('arm', 'ARMATURE'); am.object = arm for a in armatures(gobjs): bpy.data.objects.remove(a, do_unlink=True) # the body armature may itself have been renamed by a collision — put it back, or the # exported clips reference a node name that no longer exists for b in arm.data.bones: base = re.sub(r'\.\d{3}$', '', b.name) if base != b.name and base not in {x.name for x in arm.data.bones}: b.name = base # Drop bone-widget / marker meshes: no material and a handful of verts. These ride along in # character_kit rigs (a 42-vert 'Icosphere'), contribute nothing visually, and wreck the # scene bbox — which drives both the HUD height and the viewer's auto-framing, so one stray # widget makes the camera frame the widget and the character render as a speck. for o in list(bpy.data.objects): if is_helper(o): print(f' dropped helper mesh {o.name} ({len(o.data.vertices)} verts, no material)') bpy.data.objects.remove(o, do_unlink=True) print(f'assembled body + {len(ARGS) - 2} garment file(s)') export(ARGS[1]) elif OP == 'remesh': # remesh [tex_size] # # Game-LOD a TRELLIS hero mesh. Plain Decimate moth-eats TRELLIS output at # ANY ratio (verified 25k and 50k on the baseball cap) because the surface # is a paper-thin double shell — front and back faces collapse into each # other. So: voxel-remesh into one solid watertight skin first (kills the # UVs), decimate THAT, give it fresh smart-project UVs, and Cycles-bake the # original's textured surface back onto it (selected->active). Rigid props # only — vertex weights do not survive; use decimate for rigged meshes. src, out, target = ARGS[0], ARGS[1], int(ARGS[2]) tex_size = int(ARGS[3]) if len(ARGS) > 3 else 2048 load(src) meshes = [o for o in bpy.data.objects if o.type == 'MESH'] orig = max(meshes, key=lambda o: len(o.data.vertices)) for o in meshes: if o is not orig: bpy.data.objects.remove(o, do_unlink=True) for o in [o for o in bpy.data.objects if o.type in ('EMPTY', 'ARMATURE')]: bpy.data.objects.remove(o, do_unlink=True) orig.rotation_mode = 'XYZ' lod = orig.copy() lod.data = orig.data.copy() lod.name = 'lod' bpy.context.scene.collection.objects.link(lod) # Voxel-remesh into ONE solid skin at a voxel fine enough to keep thin # features (brims, straps): 0.8% of bbox — 39mm voxels ate the cap brim # entirely, 8mm kept it. Then decimate no lower than 1/3: below that the # thin solid slab's two surfaces collapse into each other (the same # moth-eating plain Decimate causes). The target is therefore a WISH — # the shell's physics set the floor, and the op reports what it kept. bpy.context.view_layer.objects.active = lod vox = max(orig.dimensions) * 0.008 # SOLIDIFY FIRST: TRELLIS fabric is ~2mm thick — anywhere the shell is # thinner than the voxel, the remesh itself perforates (the cap crown # tore at 8mm voxel in every variant while the double-folded brim held). # Thickening the shell past the voxel size guarantees a watertight solid. sol = lod.modifiers.new('sol', 'SOLIDIFY') sol.thickness = vox * 1.6 sol.offset = 0.0 bpy.ops.object.modifier_apply(modifier='sol') rm = lod.modifiers.new('remesh', 'REMESH') rm.mode = 'VOXEL' rm.voxel_size = vox bpy.ops.object.modifier_apply(modifier='remesh') tris = sum(len(pg.vertices) - 2 for pg in lod.data.polygons) if tris > target: dec = lod.modifiers.new('dec', 'DECIMATE') dec.ratio = max(target / max(tris, 1), 0.33) bpy.ops.object.modifier_apply(modifier='dec') bpy.ops.object.select_all(action='DESELECT') lod.select_set(True) bpy.context.view_layer.objects.active = lod bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') bpy.ops.uv.smart_project(angle_limit=1.15, island_margin=0.003) bpy.ops.object.mode_set(mode='OBJECT') img = bpy.data.images.new('lod_bake', tex_size, tex_size, alpha=False) mat = bpy.data.materials.new('lod_mat') mat.use_nodes = True nt = mat.node_tree bsdf = next(n for n in nt.nodes if n.type == 'BSDF_PRINCIPLED') tn = nt.nodes.new('ShaderNodeTexImage') tn.image = img nt.nodes.active = tn lod.data.materials.clear() lod.data.materials.append(mat) sc = bpy.context.scene sc.render.engine = 'CYCLES' sc.cycles.samples = 16 # flat diffuse transfer needs few samples sc.cycles.device = 'CPU' sc.render.bake.use_selected_to_active = True sc.render.bake.cage_extrusion = max(orig.dimensions) * 0.03 sc.render.bake.max_ray_distance = max(orig.dimensions) * 0.08 sc.render.bake.use_pass_direct = False sc.render.bake.use_pass_indirect = False bpy.ops.object.select_all(action='DESELECT') orig.select_set(True) lod.select_set(True) bpy.context.view_layer.objects.active = lod bpy.ops.object.bake(type='DIFFUSE') nt.links.new(tn.outputs['Color'], bsdf.inputs['Base Color']) img.pack() bpy.data.objects.remove(orig, do_unlink=True) final_tris = sum(len(pg.vertices) - 2 for pg in lod.data.polygons) print(f'remeshed: voxel {vox * 1000:.1f}mm -> {tris:,} tris -> decimated {final_tris:,} tris, baked {tex_size}px') export(out) else: raise SystemExit(f'unknown op {OP}')