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.join(ROOT, '.env'))
|
||||||
_load_env(os.path.expanduser('~/Documents/backnforth/.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 = os.environ.get('MB_HOST', 'http://100.89.131.57:8777')
|
||||||
MB_TOKEN = os.environ.get('MB_TOKEN', '')
|
MB_TOKEN = os.environ.get('MB_TOKEN', '')
|
||||||
CF_ACCT = os.environ.get('CLOUDFLARE_ACCOUNT_ID', '')
|
CF_ACCT = os.environ.get('CLOUDFLARE_ACCOUNT_ID', '')
|
||||||
@ -525,6 +526,15 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.j({'error': 'not found'}, 404)
|
return self.j({'error': 'not found'}, 404)
|
||||||
ctype = 'text/javascript' if f.endswith('.js') else (mimetypes.guess_type(f)[0] or 'application/octet-stream')
|
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)
|
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':
|
if u.path == '/api/lib':
|
||||||
return self.j({'lib': scan(), 'mb': bool(MB_TOKEN), 'cf': bool(CF_ACCT and CF_TOKEN),
|
return self.j({'lib': scan(), 'mb': bool(MB_TOKEN), 'cf': bool(CF_ACCT and CF_TOKEN),
|
||||||
'blender': os.path.exists(BLENDER), 'doll': doll_catalogue()})
|
'blender': os.path.exists(BLENDER), 'doll': doll_catalogue()})
|
||||||
@ -1126,6 +1136,47 @@ class H(BaseHTTPRequestHandler):
|
|||||||
job_thread(jid, fx)
|
job_thread(jid, fx)
|
||||||
return self.j({'job': jid})
|
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
|
if u.path == '/api/hangable': # step 3b: cutout → hanging-garment texture pack
|
||||||
p = body.get('path', '')
|
p = body.get('path', '')
|
||||||
if not allowed(p):
|
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="doll">doll 2D</button>
|
||||||
<button data-tab="generate">generate</button>
|
<button data-tab="generate">generate</button>
|
||||||
<button data-tab="out">outfits</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>
|
||||||
|
|
||||||
<div id="tab-doll" style="display:none">
|
<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) {
|
function showMeta(g) {
|
||||||
$('metaSlot').innerHTML = '<option value="">— unset —</option>' +
|
$('metaSlot').innerHTML = '<option value="">— unset —</option>' +
|
||||||
SLOTS.map(s => `<option value="${s}" ${g.slot === s ? 'selected' : ''}>${s}</option>`).join('');
|
SLOTS.map(s => `<option value="${s}" ${g.slot === s ? 'selected' : ''}>${s}</option>`).join('');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user