wardrobegod/tools/kit_assemble.py
type-two 2854182cf9 socket-kit slot picker: /api/kit + /api/kit/assemble + bench tab; LOD post-pass runner
Assemble any of the 53 kit rigs parts cross-rig from the UI; genitals slot
same-rig additive only. LODs land in library/garments/lod/ (subdir keeps
scan() from double-listing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:29:06 +10:00

95 lines
3.7 KiB
Python

"""Assemble a character from socket-kit parts chosen in the wardrobegod bench.
Blender -b --python kit_assemble.py -- <spec.json> <out.glb> <render.png>
spec.json:
{"target_tag": "kachujin", # torso's rig defines the character
"parts": {"torso": {"blend": "/abs/path.blend", "object": "kachujin_torso", "source_rig": "kachujin"},
"head": {...}, "hand_L": {...}, ... ,
"genitals": {...}}} # additive slot, same-rig only
Cross-rig parts are auto-fitted by assemble.py fit_part (cut-bone length
ratio). The genitals slot has no cut bone — it is attach_part only, and the
server refuses cross-rig genitals before we ever get here.
"""
import bpy, sys, os, json, math
from mathutils import Vector
KIT = os.path.expanduser("~/Documents/character_kit/modular")
argv = sys.argv[sys.argv.index("--") + 1:]
spec = json.load(open(argv[0]))
out_glb, out_png = argv[1], argv[2]
target_tag = spec["target_tag"]
parts = spec["parts"]
bpy.ops.wm.read_factory_settings(use_empty=True)
# group appends per blend file: each part needs its object + its rig (for fit)
by_blend = {}
for slot, p in parts.items():
by_blend.setdefault(p["blend"], set()).add(p["object"])
by_blend[p["blend"]].add(p["source_rig"] + "_rig")
for blend, names in by_blend.items():
with bpy.data.libraries.load(blend) as (src, dst):
dst.objects = [n for n in src.objects if n in names]
for o in dst.objects:
if o is not None and o.name not in bpy.context.scene.collection.objects:
bpy.context.scene.collection.objects.link(o)
for a in [o for o in bpy.data.objects if o.type == 'ARMATURE']:
a.data.pose_position = 'REST'
ns = {}
exec(open(os.path.join(KIT, "scripts", "assemble.py")).read(), ns)
std = {s: p["object"] for s, p in parts.items() if s != "genitals"}
source_arms = {p["object"]: p["source_rig"] + "_rig"
for s, p in parts.items()
if s != "genitals" and p["source_rig"] != target_tag}
ns["assemble"](f"{target_tag}_rig", std, source_arms=source_arms)
gen = parts.get("genitals")
if gen:
if gen["source_rig"] != target_tag:
raise SystemExit("genitals slot is same-rig only (additive part)")
ns["attach_part"](bpy.data.objects[gen["object"]],
bpy.data.objects[f"{target_tag}_rig"])
# drop donor rigs — cross-rig parts are baked into the target's space by fit_part
for o in list(bpy.data.objects):
if o.type == 'ARMATURE' and o.name != f"{target_tag}_rig":
bpy.data.objects.remove(o, do_unlink=True)
bpy.ops.export_scene.gltf(filepath=out_glb, export_apply=False)
def bbox(o):
pts = [o.matrix_world @ Vector(c) for c in o.bound_box]
return (Vector((min(p[i] for p in pts) for i in range(3))),
Vector((max(p[i] for p in pts) for i in range(3))))
sun = bpy.data.objects.new("sun", bpy.data.lights.new("sun", 'SUN'))
sun.data.energy = 3.5
sun.rotation_euler = (math.radians(52), 0, math.radians(35))
bpy.context.scene.collection.objects.link(sun)
cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam"))
bpy.context.scene.collection.objects.link(cam)
sc = bpy.context.scene
sc.camera = cam
sc.render.engine = 'BLENDER_EEVEE'
sc.render.resolution_x = sc.render.resolution_y = 800
sc.world = bpy.data.worlds.new("w")
sc.world.color = (0.15, 0.15, 0.17)
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
lo = Vector((min(bbox(o)[0][i] for o in meshes) for i in range(3)))
hi = Vector((max(bbox(o)[1][i] for o in meshes) for i in range(3)))
c, h = (lo + hi) / 2, (hi - lo).length
cam.location = c + Vector((0, -h * 1.05, h * 0.05))
cam.rotation_euler = (c - cam.location).to_track_quat('-Z', 'Y').to_euler()
sc.render.filepath = out_png
bpy.ops.render.render(write_still=True)
print("KIT_ASSEMBLE_OK", out_glb)