240 lines
8.6 KiB
Python
240 lines
8.6 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)
|
|
|
|
# The Blender library exports the armature root as "Armature"; the Quaternius
|
|
# sources call it "CharacterArmature". Resolve it from the skin instead of the
|
|
# name so a rebuilt rig cannot silently return an empty joint map.
|
|
skin_joints = set(gltf["skins"][0]["joints"]) if gltf.get("skins") else set()
|
|
|
|
def holds_joints(index):
|
|
if index in skin_joints:
|
|
return True
|
|
return any(holds_joints(child) for child in nodes[index].get("children", []))
|
|
|
|
for root in roots:
|
|
if root in skin_joints or holds_joints(root):
|
|
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
|
|
|
|
|
|
## ADR-0018: a Quaternius-derived character keeps its 62-joint armature, so the
|
|
## 13-core joint names are aliased onto the upstream ones before measuring.
|
|
QUATERNIUS_JOINTS = {
|
|
"ShoulderL": "UpperArm.L", "ShoulderR": "UpperArm.R",
|
|
"ElbowL": "LowerArm.L", "ElbowR": "LowerArm.R",
|
|
"HandL": "Wrist.L", "HandR": "Wrist.R",
|
|
"HipL": "UpperLeg.L", "HipR": "UpperLeg.R",
|
|
"KneeL": "LowerLeg.L", "KneeR": "LowerLeg.R",
|
|
"Spine": "Hips",
|
|
}
|
|
|
|
|
|
def _alias_joints(joints):
|
|
"""Return a joint map that answers both rig namings."""
|
|
out = dict(joints)
|
|
for core, upstream in QUATERNIUS_JOINTS.items():
|
|
if core not in out and upstream in joints:
|
|
out[core] = joints[upstream]
|
|
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))
|
|
raw_joints = joint_positions(gltf)
|
|
# ADR-0018: the Quaternius base ships its upstream (semi-realistic)
|
|
# proportions, so the stylized guide band does not apply to it. The row still
|
|
# reports every measurement and ratio.
|
|
rig = "quaternius" if "UpperArm.L" in raw_joints else "core"
|
|
joints = _alias_joints(raw_joints)
|
|
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", "UpperLeg.L", "UpperLeg.R")
|
|
and material == "pants"]
|
|
feet = [p for p, bone, _material in verts
|
|
if bone in ("KneeL", "KneeR", "LowerLeg.L", "LowerLeg.R") 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,
|
|
"rig": rig,
|
|
"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())
|