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 @@ +