Files
security-game/tools/npc_proportions.py
T
zwitschi ab9c67d266
Full build / Import + headless tests (push) Successful in 39s
Full build / Full desktop build + optional release (push) Skipped
Audit NPC body proportions against a shared guide
Measure all eight exported characters and record the stylized proportion bands. All variants pass; no skeleton or mesh rescale was needed.
2026-11-02 18:54:52 +01:00

202 lines
7.0 KiB
Python

#!/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())