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:
parent
9c172fcf70
commit
2854182cf9
51
server.py
51
server.py
@ -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
94
tools/kit_assemble.py
Normal 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
59
tools/lod_pass.py
Normal 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")}))
|
||||
@ -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 — 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="">— none —</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('');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user