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.
This commit is contained in:
1 parent
7c1a4bfd69
commit
ab9c67d266
10 files changed
+329
-3
No files matched your search
@@ -0,0 +1,201 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,22 @@
|
||||
import unittest
|
||||
|
||||
import npc_proportions
|
||||
|
||||
|
||||
class NpcProportionTests(unittest.TestCase):
|
||||
def test_every_library_character_is_measured(self):
|
||||
rows = npc_proportions.report()
|
||||
self.assertEqual(len(rows), 8)
|
||||
self.assertEqual(len({row["character"] for row in rows}), 8)
|
||||
for row in rows:
|
||||
self.assertGreater(row["measurements"]["height"], 1.6)
|
||||
|
||||
def test_current_library_stays_inside_the_stylized_guide(self):
|
||||
for row in npc_proportions.report():
|
||||
for key, check in row["checks"].items():
|
||||
self.assertEqual(check["status"], "ok",
|
||||
f"{row['character']} {key}={check['value']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user