diff --git a/assets/models/blender/manifest.json b/assets/models/blender/manifest.json index 6bf90fc..9c42cf7 100644 --- a/assets/models/blender/manifest.json +++ b/assets/models/blender/manifest.json @@ -98,14 +98,14 @@ "attach": "hand", "bounds_godot": { "max": [ - 0.106, - 0.376, + 0.065, + 0.06, 0.143 ], "min": [ - -0.106, + -0.065, -0.053, - -0.055 + 0.005 ] }, "category": "npc_gear", @@ -114,8 +114,8 @@ "lens" ], "path": "assets/models/blender/npc_gear/camera.glb", - "triangles": 318, - "vertices": 406 + "triangles": 312, + "vertices": 422 }, "cap": { "attach": "head", @@ -430,14 +430,14 @@ "npc_casual": { "bounds_godot": { "max": [ - 0.321, - 1.824, - 0.189 + 0.228, + 1.866, + 0.287 ], "min": [ - -0.321, - 0, - -0.178 + -0.228, + -0.004, + -0.091 ] }, "category": "npc", @@ -445,11 +445,11 @@ "materials": [ "skin", "cloth", - "pants", "accessory", - "eye_white", + "Eyebrows", + "Eye", "hair", - "rubber" + "pants" ], "mesh_node": "npc_base", "path": "assets/models/blender/npc/npc_casual.glb", @@ -466,8 +466,8 @@ "accessory", "hair" ], - "triangles": 5034, - "vertices": 4607 + "triangles": 6206, + "vertices": 12692 }, "npc_coat": { "bounds_godot": { diff --git a/assets/models/blender/npc/npc_casual.glb b/assets/models/blender/npc/npc_casual.glb index 9268b59..ac93d49 100644 Binary files a/assets/models/blender/npc/npc_casual.glb and b/assets/models/blender/npc/npc_casual.glb differ diff --git a/assets/models/blender/npc/npc_casual_ao.png b/assets/models/blender/npc/npc_casual_ao.png index 975d087..0cf2ffb 100644 Binary files a/assets/models/blender/npc/npc_casual_ao.png and b/assets/models/blender/npc/npc_casual_ao.png differ diff --git a/assets/models/blender/npc_gear/camera.glb b/assets/models/blender/npc_gear/camera.glb index 3d050e4..2e2915b 100644 Binary files a/assets/models/blender/npc_gear/camera.glb and b/assets/models/blender/npc_gear/camera.glb differ diff --git a/assets/models/blender/npc_gear/camera_ao.png b/assets/models/blender/npc_gear/camera_ao.png index 3eee89c..3ac7803 100644 Binary files a/assets/models/blender/npc_gear/camera_ao.png and b/assets/models/blender/npc_gear/camera_ao.png differ diff --git a/src/game/npc_avatar.gd b/src/game/npc_avatar.gd index beffbaa..9100b24 100644 --- a/src/game/npc_avatar.gd +++ b/src/game/npc_avatar.gd @@ -418,33 +418,36 @@ func _build_skinned_body(npc_type: int) -> bool: _head.name = "Head" _head.position = _rest_origin(["Head"]) _rig.add_child(_head) - for side in [-1.0, 1.0]: - var suffix := "L" if side < 0.0 else "R" - # The Quaternius rig labels its joints with the opposite side (its "L" sits - # at +x while the project's "L" sits at −x), so the dot-suffixed names are - # looked up with a swapped suffix. - var qsuffix := "R" if side < 0.0 else "L" + for i in 2: + var suffix := "L" if i == 0 else "R" + # The proxy pivots sit on the resolved rig bones (see RIG_BONES), so the + # proxy chirality and the exported joint naming never have to agree. + var hip_bone := bone_name("hip", i) + var knee_bone := bone_name("knee", i) + var shoulder_bone := bone_name("shoulder", i) + var elbow_bone := bone_name("elbow", i) + var hand_bone := bone_name("hand", i) var hip := Node3D.new() hip.name = "Hip" + suffix - hip.position = _rest_origin(["Hip" + suffix, "UpperLeg." + qsuffix]) + hip.position = _rest_origin([hip_bone]) _rig.add_child(hip) var knee := Node3D.new() knee.name = "Knee" - knee.position = _rest_origin(["Knee" + suffix, "LowerLeg." + qsuffix]) - hip.position + knee.position = _rest_origin([knee_bone]) - hip.position hip.add_child(knee) _legs.append(hip) _knees.append(knee) var shoulder := Node3D.new() shoulder.name = "Shoulder" + suffix - shoulder.position = _rest_origin(["Shoulder" + suffix, "UpperArm." + qsuffix]) + shoulder.position = _rest_origin([shoulder_bone]) _rig.add_child(shoulder) var elbow := Node3D.new() elbow.name = "Elbow" - elbow.position = _rest_origin(["Elbow" + suffix, "LowerArm." + qsuffix]) - shoulder.position + elbow.position = _rest_origin([elbow_bone]) - shoulder.position shoulder.add_child(elbow) var hand := Node3D.new() hand.name = "Hand" - hand.position = _rest_origin(["Hand" + suffix, "Wrist." + qsuffix]) - _rest_origin(["Elbow" + suffix, "LowerArm." + qsuffix]) + hand.position = _rest_origin([hand_bone]) - _rest_origin([elbow_bone]) elbow.add_child(hand) _arms.append(shoulder) _elbows.append(elbow) @@ -509,44 +512,74 @@ func _rest_origin(names: Array) -> Vector3: return _skeleton.get_bone_global_rest(_bone_indices[bone_name]).origin return Vector3.ZERO +## Bone-name candidates per animation joint, in proxy order: index 0 is the +## avatar's left (-x) and index 1 its right (+x). The Quaternius rig labels its +## dot-suffixed joints with the opposite side (its `L` sits at +x), so the pairs +## carry the rig-specific mapping and nothing else has to know about it. +const RIG_BONES := { + "head": [["Head"], ["Head"]], + "hip": [["UpperLeg.R", "HipL"], ["UpperLeg.L", "HipR"]], + "knee": [["LowerLeg.R", "KneeL"], ["LowerLeg.L", "KneeR"]], + "shoulder": [["UpperArm.R", "ShoulderL"], ["UpperArm.L", "ShoulderR"]], + "elbow": [["LowerArm.R", "ElbowL"], ["LowerArm.L", "ElbowR"]], + "hand": [["Wrist.R", "HandL"], ["Wrist.L", "HandR"]], +} + +## Resolved skeleton bone name for an animation joint on one side. Returns an +## empty string when the rig has neither candidate, so both the Quaternius +## 62-joint rig and the 13-core fallback resolve through the same call. +func bone_name(joint: String, side: int = 0) -> String: + if not RIG_BONES.has(joint): + return "" + var pair: Array = RIG_BONES[joint] + return _bone_of(pair[0], pair[1], side) + ## Copy the proxy pivot rotations onto armature bones. Limb bones point down, so ## an X rotation swings them forward/back; the Head bone points up, so a Y -## rotation is the look yaw. The Quaternius rig uses dot-suffixed names -## (UpperLeg.L, UpperArm.L, ...); the old 13-core rig uses HipL/ShoulderL, so -## both are tried and the first one present in the skeleton wins. +## rotation is the look yaw. func _sync_skeleton() -> void: if _skeleton == null or _bone_indices.is_empty(): return for i in 2: - _pose_bone_any(["UpperLeg.R", "HipL"], ["UpperLeg.L", "HipR"], i, _legs[i].rotation.x) - for i in 2: - _pose_bone_any(["LowerLeg.R", "KneeL"], ["LowerLeg.L", "KneeR"], i, _knees[i].rotation.x) - for i in 2: - _pose_bone_any(["UpperArm.R", "ShoulderL"], ["UpperArm.L", "ShoulderR"], i, _arms[i].rotation.x, Vector3.LEFT) - for i in 2: - _pose_bone_any(["LowerArm.R", "ElbowL"], ["LowerArm.L", "ElbowR"], i, _elbows[i].rotation.x, Vector3.LEFT) + var hip_swing := Quaternion(Vector3.RIGHT, _legs[i].rotation.x) + _swing_bone(bone_name("hip", i), hip_swing) + # `_swing_bone` writes the absolute global rotation, so a child bone has to + # receive the swing it inherits from its parent as well as its own. + _swing_bone(bone_name("knee", i), hip_swing * Quaternion(Vector3.RIGHT, _knees[i].rotation.x)) + var shoulder_swing := Quaternion(Vector3.LEFT, _arms[i].rotation.x) + _swing_bone(bone_name("shoulder", i), shoulder_swing) + _swing_bone(bone_name("elbow", i), shoulder_swing * Quaternion(Vector3.LEFT, _elbows[i].rotation.x)) if _head != null: - _pose_bone("Head", _head.rotation.y, Vector3.UP) + var head := bone_name("head") + if head != "": + _swing_bone(head, Quaternion(Vector3.UP, _head.rotation.y)) -func _pose_bone_any(left: Array, right: Array, index: int, angle: float, axis: Vector3 = Vector3.RIGHT) -> void: - var names: Array = left if index == 0 else right - for bone_name in names: +## First bone present from the left list (index 0) or the right list (index 1). +func _bone_of(left: Array, right: Array, index: int) -> String: + for bone_name in (left if index == 0 else right): if _bone_indices.has(bone_name): - _pose_bone(bone_name, angle, axis) - return + return str(bone_name) + return "" -func _pose_bone(bone_name: String, angle: float, axis: Vector3) -> void: - if not _bone_indices.has(bone_name): +## Swing a bone about a world axis through its head, keeping its rest orientation +## as the neutral. Writing the *global* pose is rig-agnostic: it does not depend +## on how the armature pack exported its rest rolls or on the pose/rest split the +## glTF importer chose, both of which differ between the 62-joint Quaternius rig +## and the 13-core fallback. Measuring `get_bone_global_pose()` after the call is +## the only reliable check (see the pose-capture gate). +## +## `rest` must be the **global** rest. `get_bone_rest()` is parent-relative and +## already carries the importer's axis conversion, so conjugating with it swung +## the limbs about the wrong axis and splayed the arms. +func _swing_bone(bone_name: String, swing: Quaternion) -> void: + if bone_name == "": return var index: int = _bone_indices[bone_name] - # The pose is rest-relative (global = parent * rest * pose), so the proxy's - # world-space swing axis must be expressed in the bone's rest-local frame. - # `rest * Quaternion(rest.inverse() * axis, angle)` swings around the world - # `axis`; for the old rig (rest = 180 deg) this reduces to the legacy - # `rest * Quaternion(axis, angle)`, so the 13-core fallback is unchanged. - var rest := _skeleton.get_bone_rest(index).basis.get_rotation_quaternion() - var local_axis := rest.inverse() * axis - _skeleton.set_bone_pose_rotation(index, rest * Quaternion(local_axis, angle)) + _skeleton.reset_bone_pose(index) + var rest := _skeleton.get_bone_global_rest(index).basis.get_rotation_quaternion() + var current := _skeleton.get_bone_global_pose(index) + var target := Basis((swing * rest).normalized()).scaled(current.basis.get_scale()) + _skeleton.set_bone_global_pose(index, Transform3D(target, current.origin)) func _build_body(npc_type: int) -> void: _rig = Node3D.new() diff --git a/src/test/tests/test_blender_assets.gd b/src/test/tests/test_blender_assets.gd index 112afed..276165d 100644 --- a/src/test/tests/test_blender_assets.gd +++ b/src/test/tests/test_blender_assets.gd @@ -9,6 +9,17 @@ const NPC_BASE := "res://assets/models/generated/npc_base.glb" const SLOTS := ["skin", "cloth", "pants", "accessory", "hair"] const BONES := ["Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR", "ElbowL", "ElbowR", "HandL", "HandR"] +## The Quaternius rig (ADR-0018) is a 62-joint armature. `NpcAvatar` maps these +## joints onto the animation proxies, so they are the drop-in requirement for a +## Quaternius-derived character instead of the 13-core names. +const QUATERNIUS_BONES := ["Head", "UpperLeg.L", "UpperLeg.R", "LowerLeg.L", + "LowerLeg.R", "UpperArm.L", "UpperArm.R", "LowerArm.L", "LowerArm.R", + "Wrist.L", "Wrist.R"] + +## Quaternius-derived characters are detected by a dot-suffixed UpperArm joint; +## the procedural library uses ShoulderL/ShoulderR instead. +func _is_quaternius(skeleton: Skeleton3D) -> bool: + return skeleton != null and skeleton.find_bone("UpperArm.L") >= 0 func _manifest() -> Dictionary: if not FileAccess.file_exists(MANIFEST): @@ -76,7 +87,8 @@ func test_characters_match_the_npc_base_contract() -> bool: problems.append("%s lacks Skeleton3D/npc_base" % name) node.free() continue - for bone in BONES: + var required: Array = QUATERNIUS_BONES if _is_quaternius(skeleton) else BONES + for bone in required: if skeleton.find_bone(bone) < 0: problems.append("%s missing bone %s" % [name, bone]) var slots := {} @@ -105,6 +117,11 @@ func test_character_joints_line_up_with_npc_base() -> bool: var skeleton := node.find_child("Skeleton3D", true, false) as Skeleton3D if node else null if skeleton == null: problems.append("%s has no skeleton" % name) + elif _is_quaternius(skeleton): + # ADR-0018: a Quaternius character keeps its own 62-joint skeleton and + # NpcAvatar re-derives the proxy pivots from it, so the joints are not + # expected to sit on npc_base's 13-core positions. + pass else: for bone in BONES: var a := _global_rest(base_skel, base_skel.find_bone(bone)).origin @@ -127,10 +144,18 @@ func test_character_bones_accept_the_avatar_pose_math() -> bool: if skeleton == null: problems.append(name) else: - for bone in BONES: + var bones: Array = QUATERNIUS_BONES if _is_quaternius(skeleton) else BONES + for bone in bones: var index := skeleton.find_bone(bone) - var rest := skeleton.get_bone_rest(index).basis.get_rotation_quaternion() - skeleton.set_bone_pose_rotation(index, rest * Quaternion(Vector3.RIGHT, 0.4)) + if index < 0: + problems.append("%s missing %s" % [name, bone]) + continue + # NpcAvatar._swing_bone(): pose = rest^-1 * swing * rest, applied + # through the global pose. The composition must stay a valid + # rotation on every limb bone of either rig. + var rest := skeleton.get_bone_global_rest(index).basis.get_rotation_quaternion() + var swing := Quaternion(Vector3.RIGHT, 0.4) + skeleton.set_bone_pose_rotation(index, rest.inverse() * swing * rest) if not skeleton.get_bone_pose_rotation(index).is_normalized(): problems.append("%s.%s" % [name, bone]) if node: diff --git a/src/test/tests/test_npc_character.gd b/src/test/tests/test_npc_character.gd index a935b89..545f4a7 100644 --- a/src/test/tests/test_npc_character.gd +++ b/src/test/tests/test_npc_character.gd @@ -141,8 +141,8 @@ func test_skinned_model_loads_and_keeps_the_head_contract() -> bool: "a skinned library character is used") var b := TestUtils.check(avatar.get_node_or_null("Rig/Head") != null, "Rig/Head survives the skinned path") - var c := TestUtils.check(avatar._skeleton != null and avatar._bone_indices.has("Head") - and avatar._bone_indices.has("HipL") and avatar._bone_indices.has("HandR"), + var c := TestUtils.check(avatar._skeleton != null and avatar.bone_name("head") != "" + and avatar.bone_name("hip", 0) != "" and avatar.bone_name("hand", 1) != "", "armature bones are bound") avatar.free() return a and b and c @@ -152,10 +152,10 @@ func test_skinned_model_loads_and_keeps_the_head_contract() -> bool: func test_pose_reaches_the_skeleton_bones() -> bool: var avatar := _avatar(Rules.NpcType.TOURIST, 55) var sk := avatar._skeleton - var hip: int = int(avatar._bone_indices["HipL"]) - var knee: int = int(avatar._bone_indices["KneeL"]) - var rest_hip := sk.get_bone_rest(hip).basis.get_rotation_quaternion() - var rest_knee := sk.get_bone_rest(knee).basis.get_rotation_quaternion() + var hip: int = int(avatar._bone_indices[avatar.bone_name("hip", 0)]) + var knee: int = int(avatar._bone_indices[avatar.bone_name("knee", 0)]) + var rest_hip := sk.get_bone_pose_rotation(hip) + var rest_knee := sk.get_bone_pose_rotation(knee) avatar.arrived = true avatar.npc.intent = "sit" for _i in 45: @@ -179,6 +179,49 @@ func test_pose_reaches_the_skeleton_bones() -> bool: avatar.free() return a and b and c and d +## Regression (ADR-0018): the shipped base must resolve every animation joint on +## the rig it actually uses, keep the baked arm-down rest, and put each proxy on +## the matching body side. The Quaternius rig labels its dot-suffixed joints with +## the opposite side (its `L` sits at +x), so `RIG_BONES` is the single mapping and +## a silent side swap would splay the arms across the torso. +## +## The swing itself is proven by the pose-capture gate; `_swing_bone()` writes the +## global pose, which Godot only computes for a skeleton inside the scene tree and +## the headless runner executes tests before the tree is ready. +func test_shipped_rig_resolves_the_animation_joints() -> bool: + var avatar := _avatar(Rules.NpcType.TOURIST, 91) + var sk := avatar._skeleton + var problems: Array[String] = [] + for joint in NpcAvatar.RIG_BONES: + for side in 2: + var bone := avatar.bone_name(joint, side) + if bone == "" or not avatar._bone_indices.has(bone): + problems.append("%s/%d unresolved" % [joint, side]) + var a := TestUtils.check(problems.is_empty(), + "the shipped rig resolves every animation joint (%s)" % str(problems)) + var wrist := avatar.bone_name("hand", 0) + var shoulder := avatar.bone_name("shoulder", 0) + var b := TestUtils.check(wrist != "" and shoulder != "", "shoulder/hand joints resolve") + if not b: + avatar.free() + return false + var shoulder_rest := sk.get_bone_global_rest(int(avatar._bone_indices[shoulder])) + var wrist_rest := sk.get_bone_global_rest(int(avatar._bone_indices[wrist])) + var c := TestUtils.check(shoulder_rest.origin.y - wrist_rest.origin.y > 0.3, + "the shipped base is baked arm-down (%.3f below the shoulder)" + % (shoulder_rest.origin.y - wrist_rest.origin.y)) + # A straight-down arm axis is what makes an X swing read as forward/back. + var arm_down := shoulder_rest.basis.y.normalized().dot(Vector3.DOWN) + var d := TestUtils.check(arm_down > 0.95, + "the arm rest axis points down (dot %.3f)" % arm_down) + # Proxy side 0 must drive the -x arm and side 1 the +x arm on either rig. + var left := sk.get_bone_global_rest(int(avatar._bone_indices[avatar.bone_name("shoulder", 0)])) + var right := sk.get_bone_global_rest(int(avatar._bone_indices[avatar.bone_name("shoulder", 1)])) + var e := TestUtils.check(left.origin.x < right.origin.x, + "proxy side 0 drives the -x arm (%.3f vs %.3f)" % [left.origin.x, right.origin.x]) + avatar.free() + return a and b and c and d and e + func test_character_selection_is_deterministic_and_complete() -> bool: # Seed 5 resolves each role to a fixed pool member; the routine crowd roles # now pick from the crowd-variety pools instead of a single npc_casual. diff --git a/tools/blender_assets.py b/tools/blender_assets.py index da35380..5ecd4dc 100644 --- a/tools/blender_assets.py +++ b/tools/blender_assets.py @@ -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") diff --git a/tools/npc_proportions.py b/tools/npc_proportions.py index 25e6de6..a337d7b 100644 --- a/tools/npc_proportions.py +++ b/tools/npc_proportions.py @@ -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, diff --git a/tools/tests/test_blender_assets.py b/tools/tests/test_blender_assets.py index 3aa937e..5d9b9d2 100644 --- a/tools/tests/test_blender_assets.py +++ b/tools/tests/test_blender_assets.py @@ -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) diff --git a/tools/tests/test_npc_proportions.py b/tools/tests/test_npc_proportions.py index 7892ef0..4f109ee 100644 --- a/tools/tests/test_npc_proportions.py +++ b/tools/tests/test_npc_proportions.py @@ -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']}")