Ship the Quaternius npc_casual pilot base: rig-agnostic pose sync, five tint slots, unwrapped UVs and wrist-strap camera
Full build / Import + headless tests (push) Successful in 41s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-06 13:42:37 +01:00
1 parent 68f49253fe
commit cd285b3a39
12 files changed
+242 -74

No files matched your search

+22 -5
View File
@@ -53,6 +53,12 @@ MANIFEST = OUT_DIR / "manifest.json"
SLOTS = ["skin", "cloth", "pants", "accessory", "hair"]
BONES = ["Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR",
"ElbowL", "ElbowR", "HandL", "HandR"]
# ADR-0018: a Quaternius-derived character keeps the upstream 62-joint armature
# instead of the 13-core skeleton, so these are the joints `NpcAvatar` maps onto
# the animation proxies and the ones the audit requires.
QUATERNIUS_BONES = ["Head", "UpperLeg.L", "UpperLeg.R", "LowerLeg.L", "LowerLeg.R",
"UpperArm.L", "UpperArm.R", "LowerArm.L", "LowerArm.R",
"Wrist.L", "Wrist.R"]
# Character cap raised to 24000/24000 on 2026-11-05 for the MPFB body, the
# accepted MakeHuman head and a seamless neck/shoulder junction. The required
# 13 core bones stay, but optional extra bones (fingers, face, toes) are
@@ -1555,9 +1561,13 @@ def build_gear(name):
rot=(math.radians(90), 0, 0)))
p.append(box("Prism", (0.05, 0.05, 0.03), (0, -0.04, 0.095), M("plastic_black"),
bevel=0.006))
p.append(tube("Strap", [(-0.065, -0.04, 0.06), (-0.10, 0.0, 0.25), (0.0, 0.05, 0.42),
(0.10, 0.0, 0.25), (0.065, -0.04, 0.06)], 0.006,
M("plastic_black"), seg=5))
# The hand attach point hangs off the wrist, so a full neck strap would
# float beside the head instead of resting on the neck. A short wrist
# loop keeps every hand item self-contained and follows the hand.
p.append(tube("WristStrap", [(-0.045, -0.02, 0.02), (-0.05, -0.035, 0.075),
(0.0, -0.045, 0.10), (0.05, -0.035, 0.075),
(0.045, -0.02, 0.02)], 0.005, M("plastic_black"),
seg=5, caps=False))
elif name == "protest_sign":
p.append(box("Pole", (0.03, 0.03, 1.1), (0, 0, 0.35), M("wood")))
p.append(box("Board", (0.62, 0.012, 0.42), (0, -0.025, 0.82), M("cardboard"),
@@ -2164,7 +2174,8 @@ def audit_asset(category, name):
nodes = gltf.get("nodes", [])
bones = [nodes[j].get("name", "") for s in gltf.get("skins", [])
for j in s.get("joints", [])]
for b in BONES:
required = QUATERNIUS_BONES if "UpperArm.L" in bones else BONES
for b in required:
if b not in bones:
res["issues"].append(f"missing bone {b}")
if not any(n.get("name") == "npc_base" and "mesh" in n for n in nodes):
@@ -2326,7 +2337,13 @@ def analyse_inspection(stats: dict) -> dict:
bones = list(stats.get("bones", []))
materials = list(stats.get("materials", []))
bones_ok = {b for b in BONES if b in bones}
needed = set(BONES) | {"Hips", "Spine", "Neck"}
# ADR-0018: a Quaternius-derived character keeps the 62-joint upstream
# armature, so the required set is the joints `NpcAvatar` maps instead of the
# 13-core skeleton.
if "UpperArm.L" in bones:
needed = set(QUATERNIUS_BONES)
else:
needed = set(BONES) | {"Hips", "Spine", "Neck"}
if not meshes:
issues.append("no mesh objects")
+42 -4
View File
@@ -78,8 +78,18 @@ def joint_positions(gltf):
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 nodes[root].get("name") == "Armature":
if root in skin_joints or holds_joints(root):
walk(root)
return out
@@ -108,13 +118,39 @@ def mesh_vertices(gltf, binary):
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))
joints = joint_positions(gltf)
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)
@@ -123,9 +159,10 @@ def measure(name):
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"]
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") and p[1] < 0.2]
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])
@@ -165,6 +202,7 @@ def measure(name):
}
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,
+3 -1
View File
@@ -106,7 +106,9 @@ class TexelDensityTests(unittest.TestCase):
def test_character_slots_report_density_and_area(self):
report = ba.texel_density("npc_casual")
self.assertEqual(report["resolution"], 256)
self.assertEqual(set(report["slots"]), set(ba.SLOTS) | {"eye_white", "rubber"})
# The five tint slots are the contract; rebuilds may add fixed-colour
# detail materials (the Quaternius base keeps `Eye` and `Eyebrows`).
self.assertLessEqual(set(ba.SLOTS), set(report["slots"]))
for slot in ba.SLOTS:
row = report["slots"][slot]
self.assertGreater(row["triangles"], 0, slot)
+10
View File
@@ -10,9 +10,19 @@ class NpcProportionTests(unittest.TestCase):
self.assertEqual(len({row["character"] for row in rows}), 12)
for row in rows:
self.assertGreater(row["measurements"]["height"], 1.6)
self.assertIn(row["rig"], ("core", "quaternius"))
# Both rigs must resolve every measurement; a missing joint would
# collapse a ratio to zero.
for key, value in row["measurements"].items():
self.assertGreater(value, 0.0, f"{row['character']} {key}")
def test_current_library_stays_inside_the_stylized_guide(self):
for row in npc_proportions.report():
# ADR-0018: the Quaternius base is accepted at its upstream
# (semi-realistic) proportions, so the stylized band does not apply.
# The measurement stays in the report for the face/silhouette gate.
if row["rig"] != "core":
continue
for key, check in row["checks"].items():
self.assertEqual(check["status"], "ok",
f"{row['character']} {key}={check['value']}")