Files
zwitschi cd285b3a39
Full build / Import + headless tests (push) Successful in 41s
Full build / Full desktop build + optional release (push) Skipped
Ship the Quaternius npc_casual pilot base: rig-agnostic pose sync, five tint slots, unwrapped UVs and wrist-strap camera
2026-11-06 13:42:37 +01:00

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())