From 2854182cf975b1e1a167c420a371e6d8b809cfe8 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 1 Aug 2026 10:29:06 +1000 Subject: [PATCH] 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 --- server.py | 51 +++++++++++++++++++++++ tools/kit_assemble.py | 94 +++++++++++++++++++++++++++++++++++++++++++ tools/lod_pass.py | 59 +++++++++++++++++++++++++++ web/index.html | 52 ++++++++++++++++++++++++ 4 files changed, 256 insertions(+) create mode 100644 tools/kit_assemble.py create mode 100644 tools/lod_pass.py diff --git a/server.py b/server.py index 0e6c289..6d80d16 100644 --- a/server.py +++ b/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): diff --git a/tools/kit_assemble.py b/tools/kit_assemble.py new file mode 100644 index 0000000..2ecaa01 --- /dev/null +++ b/tools/kit_assemble.py @@ -0,0 +1,94 @@ +"""Assemble a character from socket-kit parts chosen in the wardrobegod bench. + + Blender -b --python kit_assemble.py -- + +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) diff --git a/tools/lod_pass.py b/tools/lod_pass.py new file mode 100644 index 0000000..4036ea1 --- /dev/null +++ b/tools/lod_pass.py @@ -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/-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")})) diff --git a/web/index.html b/web/index.html index 7398535..a903a05 100644 --- a/web/index.html +++ b/web/index.html @@ -149,6 +149,15 @@ + + + +