#!/usr/bin/env python3 """Audit the procedural NPC library against a shared proportion guide. The measurements come from the exported GLBs, not from authored node values: head bounds use Head/skin vertices, shoulder silhouette uses the upper-chest band, hip width uses pants vertices and limb lengths use the rest-pose bones. This keeps the report valid for every character variant even when clothing or hair changes the overall bounds. Run outside Blender: python3 tools/npc_proportions.py report python3 tools/npc_proportions.py report --json """ from __future__ import annotations import argparse import json import math from pathlib import Path from blender_assets import _accessor, _read_glb, asset_path ROOT = Path(__file__).resolve().parent.parent # Ratios are measured against total standing height. These are stylized-but- # credible bands, not photoreal anatomy: the character is deliberately a low- # poly adult with a somewhat larger head and a broad build option. GUIDE = { "head_height": (0.16, 0.22), "head_aspect": (0.70, 1.00), "shoulder_width": (0.28, 0.38), "hip_width": (0.17, 0.23), "torso_length": (0.28, 0.38), "leg_length": (0.40, 0.48), "arm_length": (0.28, 0.38), "foot_length": (0.12, 0.18), } def _qmul(a, b): ax, ay, az, aw = a bx, by, bz, bw = b return (aw * bx + ax * bw + ay * bz - az * by, aw * by - ax * bz + ay * bw + az * bx, aw * bz + ax * by - ay * bx + az * bw, aw * bw - ax * bx - ay * by - az * bz) def _qrot(q, v): x, y, z, w = q tx = 2.0 * (y * v[2] - z * v[1]) ty = 2.0 * (z * v[0] - x * v[2]) tz = 2.0 * (x * v[1] - y * v[0]) return (v[0] + w * tx + (y * tz - z * ty), v[1] + w * ty + (z * tx - x * tz), v[2] + w * tz + (x * ty - y * tx)) def joint_positions(gltf): """World rest-pose joint centres from the glTF node hierarchy.""" nodes = gltf["nodes"] parent = {} for index, node in enumerate(nodes): for child in node.get("children", []): parent[child] = index roots = [i for i in range(len(nodes)) if i not in parent] out = {} def walk(index, parent_t=(0.0, 0.0, 0.0), parent_q=(0.0, 0.0, 0.0, 1.0)): node = nodes[index] translation = node.get("translation", [0.0, 0.0, 0.0]) rotation = node.get("rotation", [0.0, 0.0, 0.0, 1.0]) rotated = _qrot(parent_q, translation) world = tuple(parent_t[i] + rotated[i] for i in range(3)) out[node.get("name", "")] = world child_q = _qmul(parent_q, rotation) for child in node.get("children", []): walk(child, world, child_q) for root in roots: if nodes[root].get("name") == "Armature": walk(root) return out def mesh_vertices(gltf, binary): """Return (position, strongest bone, material) for every GLB vertex.""" nodes = gltf["nodes"] joints = [nodes[j].get("name", "") for j in gltf["skins"][0]["joints"]] materials = [m.get("name", "") for m in gltf.get("materials", [])] out = [] for mesh in gltf.get("meshes", []): for prim in mesh.get("primitives", []): attrs = prim.get("attributes", {}) if "POSITION" not in attrs or "JOINTS_0" not in attrs or "WEIGHTS_0" not in attrs: continue positions = _accessor(gltf, binary, attrs["POSITION"]) joint_rows = _accessor(gltf, binary, attrs["JOINTS_0"]) weight_rows = _accessor(gltf, binary, attrs["WEIGHTS_0"]) mat_index = prim.get("material") material = materials[mat_index] if mat_index is not None and mat_index < len(materials) else "" for pos, joint_row, weight_row in zip(positions, joint_rows, weight_rows): strongest = max(range(4), key=lambda i: weight_row[i]) index = joint_row[strongest] bone = joints[index] if index < len(joints) else "" out.append((pos, bone, material)) return out def _span(values): return max(values) - min(values) if values else 0.0 def measure(name): _magic, _version, _length, _size, gltf, binary = _read_glb(asset_path("npc", name)) joints = joint_positions(gltf) verts = mesh_vertices(gltf, binary) ys = [p[1] for p, _bone, _material in verts] height = _span(ys) head = [p for p, bone, material in verts if bone == "Head" and material == "skin"] shoulder_y = joints["ShoulderL"][1] shoulder = [p for p, _bone, _material in verts if shoulder_y - 0.08 <= p[1] <= shoulder_y + 0.06] hip = [p for p, bone, material in verts if bone in ("HipL", "HipR") and material == "pants"] feet = [p for p, bone, _material in verts if bone in ("KneeL", "KneeR") and p[1] < 0.2] head_h = _span([p[1] for p in head]) head_w = _span([p[0] for p in head]) shoulder_w = _span([p[0] for p in shoulder]) hip_w = _span([p[0] for p in hip]) torso = shoulder_y - joints["Spine"][1] leg = joints["Spine"][1] - min(ys) arm = shoulder_y - joints["HandL"][1] foot = _span([p[2] for p in feet]) values = { "height": height, "head_height": head_h, "head_width": head_w, "shoulder_width": shoulder_w, "hip_width": hip_w, "torso_length": torso, "leg_length": leg, "arm_length": arm, "foot_length": foot, } ratios = { "head_height": head_h / height, "head_aspect": head_w / head_h, "shoulder_width": shoulder_w / height, "hip_width": hip_w / height, "torso_length": torso / height, "leg_length": leg / height, "arm_length": arm / height, "foot_length": foot / height, } checks = {} for key, (low, high) in GUIDE.items(): value = ratios[key] checks[key] = { "value": round(value, 3), "range": [low, high], "status": "ok" if low <= value <= high else "outside", } return { "character": name, "measurements": {key: round(value, 3) for key, value in values.items()}, "ratios": {key: round(value, 3) for key, value in ratios.items()}, "checks": checks, } def report(): from blender_assets import CHARACTERS return [measure(name) for name in CHARACTERS] def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(dest="command", required=True) show = sub.add_parser("report") show.add_argument("--json", action="store_true") args = parser.parse_args(argv) if args.command != "report": return 1 rows = report() if args.json: print(json.dumps(rows, indent=2)) return 0 for row in rows: print(f"{row['character']:16s} height={row['measurements']['height']:.3f} m") for key, check in row["checks"].items(): print(f" {key:18s} {check['value']:.3f} " f"target {check['range'][0]:.2f}-{check['range'][1]:.2f} " f"{check['status']}") return 0 if __name__ == "__main__": raise SystemExit(main())