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>
This commit is contained in:
type-two 2026-08-01 10:29:06 +10:00
parent 9c172fcf70
commit 2854182cf9
4 changed files with 256 additions and 0 deletions

View File

@ -92,6 +92,7 @@ def _load_env(path):
_load_env(os.path.join(ROOT, '.env'))
_load_env(os.path.expanduser('~/Documents/backnforth/.env'))
KIT_LIB = os.path.expanduser('~/Documents/character_kit/modular/parts_library/library.json')
MB = os.environ.get('MB_HOST', 'http://100.89.131.57:8777')
MB_TOKEN = os.environ.get('MB_TOKEN', '')
CF_ACCT = os.environ.get('CLOUDFLARE_ACCOUNT_ID', '')
@ -525,6 +526,15 @@ class H(BaseHTTPRequestHandler):
return self.j({'error': 'not found'}, 404)
ctype = 'text/javascript' if f.endswith('.js') else (mimetypes.guess_type(f)[0] or 'application/octet-stream')
return self.send(200, open(f, 'rb').read(), ctype)
if u.path == '/api/kit': # socket-kit parts library (character_kit canonical)
try:
lib = json.load(open(KIT_LIB))
except Exception as e:
return self.j({'error': 'kit library unreadable: ' + str(e)[:80]}, 500)
parts = [{k: p.get(k) for k in ('id', 'slot', 'source_rig', 'style', 'tags')}
for p in lib.get('parts', [])]
return self.j({'slots': lib.get('slots', []), 'parts': parts})
if u.path == '/api/lib':
return self.j({'lib': scan(), 'mb': bool(MB_TOKEN), 'cf': bool(CF_ACCT and CF_TOKEN),
'blender': os.path.exists(BLENDER), 'doll': doll_catalogue()})
@ -1126,6 +1136,47 @@ class H(BaseHTTPRequestHandler):
job_thread(jid, fx)
return self.j({'job': jid})
if u.path == '/api/kit/assemble': # build a character from socket parts
choice = body.get('parts') or {}
if 'torso' not in choice:
return self.j({'error': 'torso is required — it sets the target rig'}, 400)
try:
lib = json.load(open(KIT_LIB))
except Exception as e:
return self.j({'error': 'kit library unreadable'}, 500)
by_id = {p['id']: p for p in lib.get('parts', [])}
spec_parts = {}
for slot, pid in choice.items():
p = by_id.get(pid)
if not p or p.get('slot') != slot:
return self.j({'error': f'unknown part {pid} for slot {slot}'}, 400)
blend = os.path.normpath(os.path.join(os.path.dirname(KIT_LIB), p['file']))
spec_parts[slot] = {'blend': blend, 'object': p['object'],
'source_rig': p['source_rig']}
target = spec_parts['torso']['source_rig']
g = spec_parts.get('genitals')
if g and g['source_rig'] != target:
return self.j({'error': 'genitals is an additive same-rig slot — pick the torso rig'}, 400)
name = 'kit-' + '-'.join(slug(spec_parts[s2]['object']) for s2 in ('torso',) )
name += '-' + str(int(time.time()) % 100000)
wd = os.path.join(DIRS['gen'], '_kit')
os.makedirs(wd, exist_ok=True)
spec_path = os.path.join(wd, name + '.json')
json.dump({'target_tag': target, 'parts': spec_parts}, open(spec_path, 'w'))
out_glb = os.path.join(DIRS['out'], name + '.glb')
out_png = os.path.join(DIRS['gen'], name + '.png')
jid = new_job('kit', 'assemble ' + name)
def fx(jid):
run_blender([spec_path, out_glb, out_png], jid,
script=os.path.join(TOOLS, 'kit_assemble.py'))
if not os.path.exists(out_glb):
raise RuntimeError('assembly produced no GLB')
JOBS[jid]['png'] = out_png
return out_glb
job_thread(jid, fx)
return self.j({'job': jid})
if u.path == '/api/hangable': # step 3b: cutout → hanging-garment texture pack
p = body.get('path', '')
if not allowed(p):

94
tools/kit_assemble.py Normal file
View File

@ -0,0 +1,94 @@
"""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)

59
tools/lod_pass.py Normal file
View File

@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""LOD post-pass: run the remesh op over every *-rigid.glb hero in the
garments library. LODs land in library/garments/lod/<name>-lod.glb a
subdirectory so wardrobegod's scan() doesn't double-list every item.
Heroes are never touched. Heartbeat: ~/.jobs/lod-pass.json
"""
import json, os, subprocess, time
LIB = os.path.expanduser("~/Documents/wardrobegod/library/garments")
LOD = os.path.join(LIB, "lod")
OPS = os.path.expanduser("~/Documents/wardrobegod/blender_ops.py")
BLENDER = "/Applications/Blender.app/Contents/MacOS/Blender"
TARGET = "6000"
HB = os.path.expanduser("~/.jobs/lod-pass.json")
os.makedirs(LOD, exist_ok=True)
os.makedirs(os.path.dirname(HB), exist_ok=True)
heroes = sorted(f for f in os.listdir(LIB)
if f.endswith("-rigid.glb") and not f.endswith(".raw.glb"))
results = []
state = {"job": "lod-pass", "status": "running", "total": len(heroes),
"done": 0, "failed": 0, "skipped": 0, "current": "", "results": results}
def hb():
state["updated"] = time.strftime("%Y-%m-%d %H:%M:%S")
json.dump(state, open(HB, "w"), indent=1)
for f in heroes:
out = os.path.join(LOD, f.replace("-rigid.glb", "-lod.glb"))
state["current"] = f
hb()
if os.path.exists(out) and os.path.getsize(out) > 1000:
state["skipped"] += 1
continue
row = {"file": f}
results.append(row)
try:
r = subprocess.run([BLENDER, "-b", "--python", OPS, "--",
"remesh", os.path.join(LIB, f), out, TARGET],
capture_output=True, text=True, timeout=1200)
line = [l for l in r.stdout.splitlines() if "remeshed" in l]
ok = os.path.exists(out) and os.path.getsize(out) > 1000
row["note"] = line[0] if line else (r.stdout[-150:] + r.stderr[-100:])
if ok:
row["kb"] = os.path.getsize(out) // 1024
state["done"] += 1
else:
state["failed"] += 1
except Exception as e:
row["error"] = str(e)[:200]
state["failed"] += 1
hb()
state["status"] = "done"
state["current"] = ""
hb()
print(json.dumps({k: state[k] for k in ("done", "failed", "skipped", "total")}))

View File

@ -149,6 +149,15 @@
<button data-tab="doll">doll 2D</button>
<button data-tab="generate">generate</button>
<button data-tab="out">outfits</button>
<button data-tab="kit">socket kit</button>
</div>
<div id="tab-kit" style="display:none">
<h2>socket kit &mdash; build a character from parts</h2>
<div class="note">torso picks the rig; other parts auto-fit cross-rig. genitals slot is same-rig only.</div>
<div id="kitSlots"></div>
<div class="row"><button id="kitAssemble">assemble</button>
<span class="note" id="kitStatus"></span></div>
</div>
<div id="tab-doll" style="display:none">
@ -523,6 +532,49 @@ function renderGarmentGrid() {
});
}
// ---------- socket kit ----------
let KIT = null;
async function loadKit() {
try { KIT = await fetch('api/kit').then(r => r.json()); } catch (e) { return; }
const box = $('kitSlots');
if (!box || !KIT.slots) return;
box.innerHTML = KIT.slots.map(sl => {
const opts = KIT.parts.filter(p => p.slot === sl)
.map(p => '<option value="' + p.id + '">' + p.id + ' (' + p.source_rig + ')</option>').join('');
const none = sl === 'torso' ? '' : '<option value="">&mdash; none &mdash;</option>';
return '<div class="row"><label>' + sl + '</label><select data-kitslot="' + sl + '">' + none + opts + '</select></div>';
}).join('');
}
async function kitAssemble() {
const parts = {};
document.querySelectorAll('[data-kitslot]').forEach(sel => {
if (sel.value) parts[sel.dataset.kitslot] = sel.value;
});
$('kitStatus').textContent = 'assembling\u2026';
const r = await fetch('api/kit/assemble', {method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({parts})}).then(r => r.json());
if (r.error) { $('kitStatus').textContent = r.error; return; }
const t = setInterval(async () => {
const j = await fetch('api/job/' + r.job).then(r => r.json());
$('kitStatus').textContent = j.status + (j.note ? ' \u00b7 ' + j.note : '');
if (j.status === 'done') {
clearInterval(t);
await refresh();
if (j.out) setBody({name: j.out.split('/').pop(), path: j.out, kb: 0,
home: 'out', nsfw: false, kind: 'model'});
}
if (j.status === 'error') {
clearInterval(t);
$('kitStatus').textContent = 'error: ' + (j.log || '').slice(-160);
}
}, 3000);
}
const _kitBtnHook = () => { const b = $('kitAssemble'); if (b) b.onclick = kitAssemble; };
if (document.readyState !== 'loading') _kitBtnHook();
else document.addEventListener('DOMContentLoaded', _kitBtnHook);
loadKit();
function showMeta(g) {
$('metaSlot').innerHTML = '<option value="">— unset —</option>' +
SLOTS.map(s => `<option value="${s}" ${g.slot === s ? 'selected' : ''}>${s}</option>`).join('');