Ship the Quaternius npc_casual pilot base: rig-agnostic pose sync, five tint slots, unwrapped UVs and wrist-strap camera
This commit is contained in:
1 parent
68f49253fe
commit
cd285b3a39
12 files changed
+242
-74
No files matched your search
@@ -98,14 +98,14 @@
|
|||||||
"attach": "hand",
|
"attach": "hand",
|
||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
"max": [
|
"max": [
|
||||||
0.106,
|
0.065,
|
||||||
0.376,
|
0.06,
|
||||||
0.143
|
0.143
|
||||||
],
|
],
|
||||||
"min": [
|
"min": [
|
||||||
-0.106,
|
-0.065,
|
||||||
-0.053,
|
-0.053,
|
||||||
-0.055
|
0.005
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"category": "npc_gear",
|
"category": "npc_gear",
|
||||||
@@ -114,8 +114,8 @@
|
|||||||
"lens"
|
"lens"
|
||||||
],
|
],
|
||||||
"path": "assets/models/blender/npc_gear/camera.glb",
|
"path": "assets/models/blender/npc_gear/camera.glb",
|
||||||
"triangles": 318,
|
"triangles": 312,
|
||||||
"vertices": 406
|
"vertices": 422
|
||||||
},
|
},
|
||||||
"cap": {
|
"cap": {
|
||||||
"attach": "head",
|
"attach": "head",
|
||||||
@@ -430,14 +430,14 @@
|
|||||||
"npc_casual": {
|
"npc_casual": {
|
||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
"max": [
|
"max": [
|
||||||
0.321,
|
0.228,
|
||||||
1.824,
|
1.866,
|
||||||
0.189
|
0.287
|
||||||
],
|
],
|
||||||
"min": [
|
"min": [
|
||||||
-0.321,
|
-0.228,
|
||||||
0,
|
-0.004,
|
||||||
-0.178
|
-0.091
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"category": "npc",
|
"category": "npc",
|
||||||
@@ -445,11 +445,11 @@
|
|||||||
"materials": [
|
"materials": [
|
||||||
"skin",
|
"skin",
|
||||||
"cloth",
|
"cloth",
|
||||||
"pants",
|
|
||||||
"accessory",
|
"accessory",
|
||||||
"eye_white",
|
"Eyebrows",
|
||||||
|
"Eye",
|
||||||
"hair",
|
"hair",
|
||||||
"rubber"
|
"pants"
|
||||||
],
|
],
|
||||||
"mesh_node": "npc_base",
|
"mesh_node": "npc_base",
|
||||||
"path": "assets/models/blender/npc/npc_casual.glb",
|
"path": "assets/models/blender/npc/npc_casual.glb",
|
||||||
@@ -466,8 +466,8 @@
|
|||||||
"accessory",
|
"accessory",
|
||||||
"hair"
|
"hair"
|
||||||
],
|
],
|
||||||
"triangles": 5034,
|
"triangles": 6206,
|
||||||
"vertices": 4607
|
"vertices": 12692
|
||||||
},
|
},
|
||||||
"npc_coat": {
|
"npc_coat": {
|
||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 70 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
+70
-37
@@ -418,33 +418,36 @@ func _build_skinned_body(npc_type: int) -> bool:
|
|||||||
_head.name = "Head"
|
_head.name = "Head"
|
||||||
_head.position = _rest_origin(["Head"])
|
_head.position = _rest_origin(["Head"])
|
||||||
_rig.add_child(_head)
|
_rig.add_child(_head)
|
||||||
for side in [-1.0, 1.0]:
|
for i in 2:
|
||||||
var suffix := "L" if side < 0.0 else "R"
|
var suffix := "L" if i == 0 else "R"
|
||||||
# The Quaternius rig labels its joints with the opposite side (its "L" sits
|
# The proxy pivots sit on the resolved rig bones (see RIG_BONES), so the
|
||||||
# at +x while the project's "L" sits at −x), so the dot-suffixed names are
|
# proxy chirality and the exported joint naming never have to agree.
|
||||||
# looked up with a swapped suffix.
|
var hip_bone := bone_name("hip", i)
|
||||||
var qsuffix := "R" if side < 0.0 else "L"
|
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()
|
var hip := Node3D.new()
|
||||||
hip.name = "Hip" + suffix
|
hip.name = "Hip" + suffix
|
||||||
hip.position = _rest_origin(["Hip" + suffix, "UpperLeg." + qsuffix])
|
hip.position = _rest_origin([hip_bone])
|
||||||
_rig.add_child(hip)
|
_rig.add_child(hip)
|
||||||
var knee := Node3D.new()
|
var knee := Node3D.new()
|
||||||
knee.name = "Knee"
|
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)
|
hip.add_child(knee)
|
||||||
_legs.append(hip)
|
_legs.append(hip)
|
||||||
_knees.append(knee)
|
_knees.append(knee)
|
||||||
var shoulder := Node3D.new()
|
var shoulder := Node3D.new()
|
||||||
shoulder.name = "Shoulder" + suffix
|
shoulder.name = "Shoulder" + suffix
|
||||||
shoulder.position = _rest_origin(["Shoulder" + suffix, "UpperArm." + qsuffix])
|
shoulder.position = _rest_origin([shoulder_bone])
|
||||||
_rig.add_child(shoulder)
|
_rig.add_child(shoulder)
|
||||||
var elbow := Node3D.new()
|
var elbow := Node3D.new()
|
||||||
elbow.name = "Elbow"
|
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)
|
shoulder.add_child(elbow)
|
||||||
var hand := Node3D.new()
|
var hand := Node3D.new()
|
||||||
hand.name = "Hand"
|
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)
|
elbow.add_child(hand)
|
||||||
_arms.append(shoulder)
|
_arms.append(shoulder)
|
||||||
_elbows.append(elbow)
|
_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 _skeleton.get_bone_global_rest(_bone_indices[bone_name]).origin
|
||||||
return Vector3.ZERO
|
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
|
## 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
|
## 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
|
## rotation is the look yaw.
|
||||||
## (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.
|
|
||||||
func _sync_skeleton() -> void:
|
func _sync_skeleton() -> void:
|
||||||
if _skeleton == null or _bone_indices.is_empty():
|
if _skeleton == null or _bone_indices.is_empty():
|
||||||
return
|
return
|
||||||
for i in 2:
|
for i in 2:
|
||||||
_pose_bone_any(["UpperLeg.R", "HipL"], ["UpperLeg.L", "HipR"], i, _legs[i].rotation.x)
|
var hip_swing := Quaternion(Vector3.RIGHT, _legs[i].rotation.x)
|
||||||
for i in 2:
|
_swing_bone(bone_name("hip", i), hip_swing)
|
||||||
_pose_bone_any(["LowerLeg.R", "KneeL"], ["LowerLeg.L", "KneeR"], i, _knees[i].rotation.x)
|
# `_swing_bone` writes the absolute global rotation, so a child bone has to
|
||||||
for i in 2:
|
# receive the swing it inherits from its parent as well as its own.
|
||||||
_pose_bone_any(["UpperArm.R", "ShoulderL"], ["UpperArm.L", "ShoulderR"], i, _arms[i].rotation.x, Vector3.LEFT)
|
_swing_bone(bone_name("knee", i), hip_swing * Quaternion(Vector3.RIGHT, _knees[i].rotation.x))
|
||||||
for i in 2:
|
var shoulder_swing := Quaternion(Vector3.LEFT, _arms[i].rotation.x)
|
||||||
_pose_bone_any(["LowerArm.R", "ElbowL"], ["LowerArm.L", "ElbowR"], i, _elbows[i].rotation.x, Vector3.LEFT)
|
_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:
|
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:
|
## First bone present from the left list (index 0) or the right list (index 1).
|
||||||
var names: Array = left if index == 0 else right
|
func _bone_of(left: Array, right: Array, index: int) -> String:
|
||||||
for bone_name in names:
|
for bone_name in (left if index == 0 else right):
|
||||||
if _bone_indices.has(bone_name):
|
if _bone_indices.has(bone_name):
|
||||||
_pose_bone(bone_name, angle, axis)
|
return str(bone_name)
|
||||||
return
|
return ""
|
||||||
|
|
||||||
func _pose_bone(bone_name: String, angle: float, axis: Vector3) -> void:
|
## Swing a bone about a world axis through its head, keeping its rest orientation
|
||||||
if not _bone_indices.has(bone_name):
|
## 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
|
return
|
||||||
var index: int = _bone_indices[bone_name]
|
var index: int = _bone_indices[bone_name]
|
||||||
# The pose is rest-relative (global = parent * rest * pose), so the proxy's
|
_skeleton.reset_bone_pose(index)
|
||||||
# world-space swing axis must be expressed in the bone's rest-local frame.
|
var rest := _skeleton.get_bone_global_rest(index).basis.get_rotation_quaternion()
|
||||||
# `rest * Quaternion(rest.inverse() * axis, angle)` swings around the world
|
var current := _skeleton.get_bone_global_pose(index)
|
||||||
# `axis`; for the old rig (rest = 180 deg) this reduces to the legacy
|
var target := Basis((swing * rest).normalized()).scaled(current.basis.get_scale())
|
||||||
# `rest * Quaternion(axis, angle)`, so the 13-core fallback is unchanged.
|
_skeleton.set_bone_global_pose(index, Transform3D(target, current.origin))
|
||||||
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))
|
|
||||||
|
|
||||||
func _build_body(npc_type: int) -> void:
|
func _build_body(npc_type: int) -> void:
|
||||||
_rig = Node3D.new()
|
_rig = Node3D.new()
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ const NPC_BASE := "res://assets/models/generated/npc_base.glb"
|
|||||||
const SLOTS := ["skin", "cloth", "pants", "accessory", "hair"]
|
const SLOTS := ["skin", "cloth", "pants", "accessory", "hair"]
|
||||||
const BONES := ["Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR",
|
const BONES := ["Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR",
|
||||||
"ElbowL", "ElbowR", "HandL", "HandR"]
|
"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:
|
func _manifest() -> Dictionary:
|
||||||
if not FileAccess.file_exists(MANIFEST):
|
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)
|
problems.append("%s lacks Skeleton3D/npc_base" % name)
|
||||||
node.free()
|
node.free()
|
||||||
continue
|
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:
|
if skeleton.find_bone(bone) < 0:
|
||||||
problems.append("%s missing bone %s" % [name, bone])
|
problems.append("%s missing bone %s" % [name, bone])
|
||||||
var slots := {}
|
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
|
var skeleton := node.find_child("Skeleton3D", true, false) as Skeleton3D if node else null
|
||||||
if skeleton == null:
|
if skeleton == null:
|
||||||
problems.append("%s has no skeleton" % name)
|
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:
|
else:
|
||||||
for bone in BONES:
|
for bone in BONES:
|
||||||
var a := _global_rest(base_skel, base_skel.find_bone(bone)).origin
|
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:
|
if skeleton == null:
|
||||||
problems.append(name)
|
problems.append(name)
|
||||||
else:
|
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 index := skeleton.find_bone(bone)
|
||||||
var rest := skeleton.get_bone_rest(index).basis.get_rotation_quaternion()
|
if index < 0:
|
||||||
skeleton.set_bone_pose_rotation(index, rest * Quaternion(Vector3.RIGHT, 0.4))
|
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():
|
if not skeleton.get_bone_pose_rotation(index).is_normalized():
|
||||||
problems.append("%s.%s" % [name, bone])
|
problems.append("%s.%s" % [name, bone])
|
||||||
if node:
|
if node:
|
||||||
|
|||||||
@@ -141,8 +141,8 @@ func test_skinned_model_loads_and_keeps_the_head_contract() -> bool:
|
|||||||
"a skinned library character is used")
|
"a skinned library character is used")
|
||||||
var b := TestUtils.check(avatar.get_node_or_null("Rig/Head") != null,
|
var b := TestUtils.check(avatar.get_node_or_null("Rig/Head") != null,
|
||||||
"Rig/Head survives the skinned path")
|
"Rig/Head survives the skinned path")
|
||||||
var c := TestUtils.check(avatar._skeleton != null and avatar._bone_indices.has("Head")
|
var c := TestUtils.check(avatar._skeleton != null and avatar.bone_name("head") != ""
|
||||||
and avatar._bone_indices.has("HipL") and avatar._bone_indices.has("HandR"),
|
and avatar.bone_name("hip", 0) != "" and avatar.bone_name("hand", 1) != "",
|
||||||
"armature bones are bound")
|
"armature bones are bound")
|
||||||
avatar.free()
|
avatar.free()
|
||||||
return a and b and c
|
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:
|
func test_pose_reaches_the_skeleton_bones() -> bool:
|
||||||
var avatar := _avatar(Rules.NpcType.TOURIST, 55)
|
var avatar := _avatar(Rules.NpcType.TOURIST, 55)
|
||||||
var sk := avatar._skeleton
|
var sk := avatar._skeleton
|
||||||
var hip: int = int(avatar._bone_indices["HipL"])
|
var hip: int = int(avatar._bone_indices[avatar.bone_name("hip", 0)])
|
||||||
var knee: int = int(avatar._bone_indices["KneeL"])
|
var knee: int = int(avatar._bone_indices[avatar.bone_name("knee", 0)])
|
||||||
var rest_hip := sk.get_bone_rest(hip).basis.get_rotation_quaternion()
|
var rest_hip := sk.get_bone_pose_rotation(hip)
|
||||||
var rest_knee := sk.get_bone_rest(knee).basis.get_rotation_quaternion()
|
var rest_knee := sk.get_bone_pose_rotation(knee)
|
||||||
avatar.arrived = true
|
avatar.arrived = true
|
||||||
avatar.npc.intent = "sit"
|
avatar.npc.intent = "sit"
|
||||||
for _i in 45:
|
for _i in 45:
|
||||||
@@ -179,6 +179,49 @@ func test_pose_reaches_the_skeleton_bones() -> bool:
|
|||||||
avatar.free()
|
avatar.free()
|
||||||
return a and b and c and d
|
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:
|
func test_character_selection_is_deterministic_and_complete() -> bool:
|
||||||
# Seed 5 resolves each role to a fixed pool member; the routine crowd roles
|
# 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.
|
# now pick from the crowd-variety pools instead of a single npc_casual.
|
||||||
|
|||||||
+22
-5
@@ -53,6 +53,12 @@ MANIFEST = OUT_DIR / "manifest.json"
|
|||||||
SLOTS = ["skin", "cloth", "pants", "accessory", "hair"]
|
SLOTS = ["skin", "cloth", "pants", "accessory", "hair"]
|
||||||
BONES = ["Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR",
|
BONES = ["Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR",
|
||||||
"ElbowL", "ElbowR", "HandL", "HandR"]
|
"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
|
# 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
|
# accepted MakeHuman head and a seamless neck/shoulder junction. The required
|
||||||
# 13 core bones stay, but optional extra bones (fingers, face, toes) are
|
# 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)))
|
rot=(math.radians(90), 0, 0)))
|
||||||
p.append(box("Prism", (0.05, 0.05, 0.03), (0, -0.04, 0.095), M("plastic_black"),
|
p.append(box("Prism", (0.05, 0.05, 0.03), (0, -0.04, 0.095), M("plastic_black"),
|
||||||
bevel=0.006))
|
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),
|
# The hand attach point hangs off the wrist, so a full neck strap would
|
||||||
(0.10, 0.0, 0.25), (0.065, -0.04, 0.06)], 0.006,
|
# float beside the head instead of resting on the neck. A short wrist
|
||||||
M("plastic_black"), seg=5))
|
# 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":
|
elif name == "protest_sign":
|
||||||
p.append(box("Pole", (0.03, 0.03, 1.1), (0, 0, 0.35), M("wood")))
|
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"),
|
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", [])
|
nodes = gltf.get("nodes", [])
|
||||||
bones = [nodes[j].get("name", "") for s in gltf.get("skins", [])
|
bones = [nodes[j].get("name", "") for s in gltf.get("skins", [])
|
||||||
for j in s.get("joints", [])]
|
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:
|
if b not in bones:
|
||||||
res["issues"].append(f"missing bone {b}")
|
res["issues"].append(f"missing bone {b}")
|
||||||
if not any(n.get("name") == "npc_base" and "mesh" in n for n in nodes):
|
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", []))
|
bones = list(stats.get("bones", []))
|
||||||
materials = list(stats.get("materials", []))
|
materials = list(stats.get("materials", []))
|
||||||
bones_ok = {b for b in BONES if b in bones}
|
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:
|
if not meshes:
|
||||||
issues.append("no mesh objects")
|
issues.append("no mesh objects")
|
||||||
|
|||||||
@@ -78,8 +78,18 @@ def joint_positions(gltf):
|
|||||||
for child in node.get("children", []):
|
for child in node.get("children", []):
|
||||||
walk(child, world, child_q)
|
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:
|
for root in roots:
|
||||||
if nodes[root].get("name") == "Armature":
|
if root in skin_joints or holds_joints(root):
|
||||||
walk(root)
|
walk(root)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -108,13 +118,39 @@ def mesh_vertices(gltf, binary):
|
|||||||
return out
|
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):
|
def _span(values):
|
||||||
return max(values) - min(values) if values else 0.0
|
return max(values) - min(values) if values else 0.0
|
||||||
|
|
||||||
|
|
||||||
def measure(name):
|
def measure(name):
|
||||||
_magic, _version, _length, _size, gltf, binary = _read_glb(asset_path("npc", 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)
|
verts = mesh_vertices(gltf, binary)
|
||||||
ys = [p[1] for p, _bone, _material in verts]
|
ys = [p[1] for p, _bone, _material in verts]
|
||||||
height = _span(ys)
|
height = _span(ys)
|
||||||
@@ -123,9 +159,10 @@ def measure(name):
|
|||||||
shoulder = [p for p, _bone, _material in verts
|
shoulder = [p for p, _bone, _material in verts
|
||||||
if shoulder_y - 0.08 <= p[1] <= shoulder_y + 0.06]
|
if shoulder_y - 0.08 <= p[1] <= shoulder_y + 0.06]
|
||||||
hip = [p for p, bone, material in verts
|
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
|
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_h = _span([p[1] for p in head])
|
||||||
head_w = _span([p[0] for p in head])
|
head_w = _span([p[0] for p in head])
|
||||||
shoulder_w = _span([p[0] for p in shoulder])
|
shoulder_w = _span([p[0] for p in shoulder])
|
||||||
@@ -165,6 +202,7 @@ def measure(name):
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"character": name,
|
"character": name,
|
||||||
|
"rig": rig,
|
||||||
"measurements": {key: round(value, 3) for key, value in values.items()},
|
"measurements": {key: round(value, 3) for key, value in values.items()},
|
||||||
"ratios": {key: round(value, 3) for key, value in ratios.items()},
|
"ratios": {key: round(value, 3) for key, value in ratios.items()},
|
||||||
"checks": checks,
|
"checks": checks,
|
||||||
|
|||||||
@@ -106,7 +106,9 @@ class TexelDensityTests(unittest.TestCase):
|
|||||||
def test_character_slots_report_density_and_area(self):
|
def test_character_slots_report_density_and_area(self):
|
||||||
report = ba.texel_density("npc_casual")
|
report = ba.texel_density("npc_casual")
|
||||||
self.assertEqual(report["resolution"], 256)
|
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:
|
for slot in ba.SLOTS:
|
||||||
row = report["slots"][slot]
|
row = report["slots"][slot]
|
||||||
self.assertGreater(row["triangles"], 0, slot)
|
self.assertGreater(row["triangles"], 0, slot)
|
||||||
|
|||||||
@@ -10,9 +10,19 @@ class NpcProportionTests(unittest.TestCase):
|
|||||||
self.assertEqual(len({row["character"] for row in rows}), 12)
|
self.assertEqual(len({row["character"] for row in rows}), 12)
|
||||||
for row in rows:
|
for row in rows:
|
||||||
self.assertGreater(row["measurements"]["height"], 1.6)
|
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):
|
def test_current_library_stays_inside_the_stylized_guide(self):
|
||||||
for row in npc_proportions.report():
|
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():
|
for key, check in row["checks"].items():
|
||||||
self.assertEqual(check["status"], "ok",
|
self.assertEqual(check["status"], "ok",
|
||||||
f"{row['character']} {key}={check['value']}")
|
f"{row['character']} {key}={check['value']}")
|
||||||
|
|||||||
Reference in new issue
Block a user