From 037f6ef0af736f34097c3ce9c0f559b1d243e973 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 24 Jul 2026 19:36:03 +1000 Subject: [PATCH] Fold NPCFACTORY in: vendor three.js, take the banks, and fix units without flattening body types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NPCFACTORY and wardrobegod were the same product — wardrobegod had already absorbed its reskin engine, and keeping two benches means two half-libraries and two export paths. Folded, keeping the wardrobegod name and codebase (370 lines there vs ~1,100 here). · Vendored three.js r175 from NPCFACTORY, replacing the unpkg CDN importmap. This was real drift, not tidiness: a CDN import breaks offline and can't be lifted into a game build. NPCFACTORY lacked OrbitControls (it used PointerLock), so that one addon was fetched at the matching revision. Added a guarded /vendor/ static route. Verified in-browser: zero CDN requests, 5 vendor files, model still loads. · Took the 17 rigged walk-animated NPCs and 6 parts. Bodies 4 -> 20. · New `unitfix` op. Deliberately NOT scale-to-height: a 0.06m human is a UNIT error, but normalising everything to 1.72m would erase the small/medium/large/obese range the library is meant to carry. So it only corrects heights outside 0.5-3.0m — physically impossible for a human — and leaves real proportions alone as data. Verified both ways: hum_character 0.0576m -> 1.72m, tradie 1.000m left untouched. Three bugs found while building it, two of them pre-existing: · `is_helper`/`real_meshes`/`bbox_of` factored out. Material-less bone widgets (a radius-1 42-vert Icosphere, so exactly 2.0 units tall) were being measured INSTEAD of the character — every body reported 2.000m. This poisoned the `scale` op too, which has been measuring widgets all along; NPCFACTORY's render_plates.py had independently worked around the same thing by framing on the dominant mesh. · `transform_apply` under temp_override(selected_editable_objects=...) SEGFAULTS Blender 5.1.2 on rigs with parented children. A segfault can't be caught, so it's avoided rather than handled: glTF encodes node scale natively, so setting the root transform is sufficient and every downstream measurement still reads correctly. Confirmed `scale` still round-trips (1.00m -> 1.72m, re-measured). · My own bulk edit replaced only ONE of the two crash-prone call sites and reported "replaced 1" — I didn't check for a second, which is why `scale` worked while `unitfix` kept crashing. Co-Authored-By: Claude Opus 4.8 --- blender_ops.py | 89 +- server.py | 7 + web/index.html | 7 +- web/vendor/addons/controls/OrbitControls.js | 1859 + .../addons/controls/PointerLockControls.js | 270 + web/vendor/addons/loaders/GLTFLoader.js | 4958 ++ .../addons/postprocessing/EffectComposer.js | 361 + web/vendor/addons/postprocessing/MaskPass.js | 194 + .../addons/postprocessing/OutputPass.js | 138 + web/vendor/addons/postprocessing/Pass.js | 189 + .../addons/postprocessing/RenderPass.js | 182 + .../addons/postprocessing/ShaderPass.js | 134 + .../addons/postprocessing/UnrealBloomPass.js | 491 + web/vendor/addons/shaders/CopyShader.js | 49 + .../shaders/LuminosityHighPassShader.js | 65 + web/vendor/addons/shaders/OutputShader.js | 100 + web/vendor/addons/shaders/VignetteShader.js | 53 + .../addons/utils/BufferGeometryUtils.js | 1432 + web/vendor/addons/utils/SkeletonUtils.js | 490 + web/vendor/three.core.js | 57362 ++++++++++++++++ web/vendor/three.module.js | 17990 +++++ 21 files changed, 86409 insertions(+), 11 deletions(-) create mode 100644 web/vendor/addons/controls/OrbitControls.js create mode 100644 web/vendor/addons/controls/PointerLockControls.js create mode 100644 web/vendor/addons/loaders/GLTFLoader.js create mode 100644 web/vendor/addons/postprocessing/EffectComposer.js create mode 100644 web/vendor/addons/postprocessing/MaskPass.js create mode 100644 web/vendor/addons/postprocessing/OutputPass.js create mode 100644 web/vendor/addons/postprocessing/Pass.js create mode 100644 web/vendor/addons/postprocessing/RenderPass.js create mode 100644 web/vendor/addons/postprocessing/ShaderPass.js create mode 100644 web/vendor/addons/postprocessing/UnrealBloomPass.js create mode 100644 web/vendor/addons/shaders/CopyShader.js create mode 100644 web/vendor/addons/shaders/LuminosityHighPassShader.js create mode 100644 web/vendor/addons/shaders/OutputShader.js create mode 100644 web/vendor/addons/shaders/VignetteShader.js create mode 100644 web/vendor/addons/utils/BufferGeometryUtils.js create mode 100644 web/vendor/addons/utils/SkeletonUtils.js create mode 100644 web/vendor/three.core.js create mode 100644 web/vendor/three.module.js diff --git a/blender_ops.py b/blender_ops.py index 83cc2bc..e9375c6 100644 --- a/blender_ops.py +++ b/blender_ops.py @@ -62,6 +62,54 @@ def meshes(objs): return [o for o in objs if o.type == 'MESH'] +def is_helper(o): + """Bone-widget / marker mesh: no material and a handful of verts. + + These ride along in character_kit and NPCFACTORY rigs (a radius-1 42-vert 'Icosphere', so + exactly 2.0 units tall) and they poison every bbox measurement — height readouts, camera + auto-framing, and scale normalisation all silently measure the widget instead of the + character. NPCFACTORY's render_plates.py hit this too and worked around it by framing on + the dominant mesh only. + """ + return (o.type == 'MESH' and not [m for m in o.data.materials if m] + and len(o.data.vertices) < 100) + + +def real_meshes(objs): + """Meshes that are actually the model — helpers excluded. Falls back to all meshes so a + genuinely material-less model still measures rather than returning nothing.""" + ms = meshes(objs) + return [o for o in ms if not is_helper(o)] or ms + + +def apply_scale(roots, objs): + """Scale a model by setting the ROOT transform — deliberately without transform_apply. + + Blender 5.1.2 SEGFAULTS inside object_transform_apply_exec when the selection contains both + an armature and its own parented children (reproducible on character_kit's hum_character, + both with temp_override and with a plain select-then-apply). A segfault kills the process, so + it has to be avoided rather than caught. + + Not baking costs us nothing here: glTF encodes node scale natively, so the exported file is + the right size and every downstream measurement (bbox, height, the viewer) reads it + correctly. Only the roots are scaled — children inherit, and scaling them too would + double-apply. + """ + # The caller has already set r.scale; there is deliberately nothing else to do. An earlier + # view_layer.update() here was itself crashing on rigs with parented children. + return + + +def bbox_of(objs): + import mathutils + mn = mathutils.Vector((1e9,) * 3); mx = mathutils.Vector((-1e9,) * 3) + for o in real_meshes(objs): + for c in o.bound_box: + w = o.matrix_world @ mathutils.Vector(c) + mn = mathutils.Vector(map(min, mn, w)); mx = mathutils.Vector(map(max, mx, w)) + return mn, mx + + def armatures(objs): return [o for o in objs if o.type == 'ARMATURE'] @@ -76,12 +124,7 @@ if OP == 'convert': elif OP == 'scale': clean() objs = load(ARGS[0]); target = float(ARGS[2]) - import mathutils - mn = mathutils.Vector((1e9,) * 3); mx = mathutils.Vector((-1e9,) * 3) - for o in meshes(objs): - for c in o.bound_box: - w = o.matrix_world @ mathutils.Vector(c) - mn = mathutils.Vector(map(min, mn, w)); mx = mathutils.Vector(map(max, mx, w)) + mn, mx = bbox_of(objs) # same helper-widget exclusion — this op measured them too h = mx.z - mn.z if h <= 0: raise SystemExit('flat object — no height to scale') @@ -89,8 +132,7 @@ elif OP == 'scale': roots = [o for o in objs if not o.parent] for r in roots: r.scale = [c * s for c in r.scale] - with bpy.context.temp_override(selected_editable_objects=roots + meshes(objs) + armatures(objs)): - bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + apply_scale(roots, objs) print(f'scaled ×{s:.3f} → {target}m') export(ARGS[1]) @@ -146,6 +188,35 @@ elif OP == 'fit': print(f'fitted {len(meshes(garm_objs))} garment mesh(es), mode={mode}, inflate={inflate * 1000:.0f}mm') export(ARGS[2]) +elif OP == 'unitfix': + # unitfix [target_m] + # + # Deliberately NOT scale-to-height. A 0.06m human is a UNIT error (character_kit rigs are + # 6cm, NPCFACTORY banks 1.0m, TRELLIS output varies) and silently breaks assemble — that's + # what produced a 27x oversized garment. But normalising every body to one height would + # erase the small/medium/large/obese range the library is meant to carry, so we only correct + # scales that are physically impossible for a human and leave real proportions alone. + clean() + objs = load(ARGS[0]) + target = float(ARGS[2]) if len(ARGS) > 2 else 1.72 + mn, mx = bbox_of(objs) # helper widgets excluded, or we'd measure a 2.0-unit Icosphere + h = mx.z - mn.z + if h <= 0: + raise SystemExit('flat object — no height to measure') + PLAUSIBLE = (0.5, 3.0) # any human outside this is a broken unit scale, not a body type + if PLAUSIBLE[0] <= h <= PLAUSIBLE[1]: + print(f'height {h:.3f}m is plausible — left alone (body-type variation is data, not an error)') + s = 1.0 + else: + s = target / h + roots = [o for o in objs if not o.parent] + for r in roots: + r.scale = [c * s for c in r.scale] + apply_scale(roots, objs) + print(f'UNIT FIX: {h:.4f}m is impossible for a human -> scaled x{s:.4f} to {target}m') + print(f'unitfix done (scale {s:.4f})') + export(ARGS[1]) + elif OP == 'harvest': # harvest [keep_skin_regex] # @@ -290,7 +361,7 @@ elif OP == 'assemble': # scene bbox — which drives both the HUD height and the viewer's auto-framing, so one stray # widget makes the camera frame the widget and the character render as a speck. for o in list(bpy.data.objects): - if o.type == 'MESH' and not [m for m in o.data.materials if m] and len(o.data.vertices) < 100: + if is_helper(o): print(f' dropped helper mesh {o.name} ({len(o.data.vertices)} verts, no material)') bpy.data.objects.remove(o, do_unlink=True) print(f'assembled body + {len(ARGS) - 2} garment file(s)') diff --git a/server.py b/server.py index bf4e2cb..38e8f9b 100644 --- a/server.py +++ b/server.py @@ -304,6 +304,13 @@ class H(BaseHTTPRequestHandler): q = dict(urllib.parse.parse_qsl(u.query)) if u.path == '/': return self.send(200, open(os.path.join(ROOT, 'web', 'index.html'), 'rb').read(), 'text/html') + if u.path.startswith('/vendor/'): # vendored three.js — no CDN, works offline + f = os.path.realpath(os.path.join(ROOT, 'web', u.path.lstrip('/'))) + web = os.path.realpath(os.path.join(ROOT, 'web')) + if not f.startswith(web + os.sep) or not os.path.isfile(f): + 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/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()}) diff --git a/web/index.html b/web/index.html index 6356dee..7d72677 100644 --- a/web/index.html +++ b/web/index.html @@ -203,10 +203,13 @@ +