fix(client): address PR #95 review — 8 items
SLOT_TO_BONE expanded to 12 slots (earring_l/r, necklace, wrist_l/r). _clear() outline teardown ordering fixed. toon_masked.gdshader: removed cull_disabled, added ALPHA output. _attach_to_bone() returns node instead of bool. skin_tone clamped in from_dict(). D-160 segment count 15→17. Blender scripts: docstrings + __main__ guards. Reload test tightened. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
shader_type spatial;
|
||||
render_mode unshaded, cull_disabled;
|
||||
render_mode unshaded;
|
||||
|
||||
// Original Trellis atlas texture
|
||||
uniform sampler2D albedo_tex : source_color;
|
||||
@@ -28,4 +28,5 @@ void fragment() {
|
||||
vec3 color = base * mix(1.0 - shadow_strength, 1.0, toon);
|
||||
|
||||
ALBEDO = color;
|
||||
ALPHA = original.a * tint_color.a;
|
||||
}
|
||||
|
||||
@@ -54,13 +54,19 @@ const HIDDEN_BY_DEFAULT: Array[String] = ["torso_upper"]
|
||||
## Accessory slot → Quaternius bone name (Section 3 of character-asset-organization.md).
|
||||
## Bone names verified against armature.glb — Quaternius rig uses "Head" (capital H),
|
||||
## lowercase for everything else. NOT Mixamo names (no "Hips", "LeftHand", "Spine2").
|
||||
## 12 slots total: head-attached (hat/goggles/mask/earring_l/earring_r/necklace),
|
||||
## spine-attached (backpack/belt), wrist-attached, hand-held.
|
||||
const SLOT_TO_BONE: Dictionary = {
|
||||
"hat": "Head",
|
||||
"goggles": "Head",
|
||||
"mask": "Head",
|
||||
"earring": "Head",
|
||||
"earring_l": "Head",
|
||||
"earring_r": "Head",
|
||||
"necklace": "Head",
|
||||
"backpack": "spine_03",
|
||||
"belt": "pelvis",
|
||||
"wrist_l": "lowerarm_l",
|
||||
"wrist_r": "lowerarm_r",
|
||||
"held_l": "hand_l",
|
||||
"held_r": "hand_r",
|
||||
}
|
||||
@@ -204,6 +210,17 @@ func get_skin_tone_texture_name(index: int) -> String:
|
||||
# =============================================================================
|
||||
|
||||
func _clear() -> void:
|
||||
# Outline nodes are duplicates of body/clothing meshes — must be freed FIRST,
|
||||
# before the source meshes are removed. _rebuild_outlines() is not called here
|
||||
# because we are tearing down, not rebuilding; the rebuild happens at the end
|
||||
# of load_descriptor() once the new character is assembled.
|
||||
for node in _outline_nodes:
|
||||
if is_instance_valid(node):
|
||||
if node.get_parent():
|
||||
node.get_parent().remove_child(node)
|
||||
node.free()
|
||||
_outline_nodes.clear()
|
||||
|
||||
for att in _bone_attachments:
|
||||
if is_instance_valid(att) and att.get_parent():
|
||||
att.get_parent().remove_child(att)
|
||||
@@ -222,8 +239,6 @@ func _clear() -> void:
|
||||
_active_coverages.clear()
|
||||
_active_torso_variant = "full"
|
||||
|
||||
_rebuild_outlines() # clears outline nodes before removing skeleton
|
||||
|
||||
if is_instance_valid(_body_root) and _body_root.get_parent():
|
||||
_body_root.get_parent().remove_child(_body_root)
|
||||
_body_root.queue_free()
|
||||
@@ -324,7 +339,7 @@ func _load_head(desc: CharacterVisualDescriptor) -> void:
|
||||
if desc.head_id.is_empty():
|
||||
return
|
||||
var path := BASE_PATH + "heads/templates/%s.glb" % desc.head_id
|
||||
if _attach_to_bone(path, "Head"):
|
||||
if _attach_to_bone(path, "Head") != null:
|
||||
_loaded_slots.append("head")
|
||||
|
||||
|
||||
@@ -332,7 +347,7 @@ func _load_hair(desc: CharacterVisualDescriptor) -> void:
|
||||
if desc.hair_id.is_empty() or desc.hair_id == "bald":
|
||||
return
|
||||
var path := BASE_PATH + "hair/%s.glb" % desc.hair_id
|
||||
if _attach_to_bone(path, "Head", desc.hair_tint):
|
||||
if _attach_to_bone(path, "Head", desc.hair_tint) != null:
|
||||
_loaded_slots.append("hair")
|
||||
|
||||
|
||||
@@ -340,7 +355,7 @@ func _load_facial_hair(desc: CharacterVisualDescriptor) -> void:
|
||||
if desc.facial_hair_id.is_empty():
|
||||
return
|
||||
var path := BASE_PATH + "facial_hair/%s.glb" % desc.facial_hair_id
|
||||
if _attach_to_bone(path, "Head", desc.facial_hair_tint):
|
||||
if _attach_to_bone(path, "Head", desc.facial_hair_tint) != null:
|
||||
_loaded_slots.append("facial_hair")
|
||||
|
||||
|
||||
@@ -350,23 +365,23 @@ func _load_eyebrows(desc: CharacterVisualDescriptor) -> void:
|
||||
# Convention: eyebrow_id uses the short visual name (e.g. "regular", "thick", "female"),
|
||||
# NOT the full filename prefix. Path: eyebrows/{eyebrow_id}.glb → e.g. eyebrows/regular.glb
|
||||
var path := BASE_PATH + "eyebrows/%s.glb" % desc.eyebrow_id
|
||||
if _attach_to_bone(path, "Head", desc.eyebrow_tint):
|
||||
if _attach_to_bone(path, "Head", desc.eyebrow_tint) != null:
|
||||
_loaded_slots.append("eyebrow")
|
||||
|
||||
|
||||
func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE) -> bool:
|
||||
func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE) -> BoneAttachment3D:
|
||||
if _skeleton == null:
|
||||
return false
|
||||
return null
|
||||
if not ResourceLoader.exists(path):
|
||||
push_warning("CharacterVisual: asset not found (expected): %s" % path)
|
||||
return false
|
||||
return null
|
||||
var scene := load(path) as PackedScene
|
||||
if scene == null:
|
||||
return false
|
||||
return null
|
||||
var bone_idx := _skeleton.find_bone(bone_name)
|
||||
if bone_idx == -1:
|
||||
push_error("CharacterVisual: bone '%s' not found in skeleton" % bone_name)
|
||||
return false
|
||||
return null
|
||||
var attachment := BoneAttachment3D.new()
|
||||
attachment.bone_name = bone_name
|
||||
attachment.bone_idx = bone_idx
|
||||
@@ -384,7 +399,7 @@ func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE)
|
||||
attachment.add_child(inst)
|
||||
for mi in _collect_meshes(inst):
|
||||
_apply_tinted_shader(mi, tint, mask_tex)
|
||||
return true
|
||||
return attachment
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -504,9 +519,10 @@ func _load_accessories(desc: CharacterVisualDescriptor) -> void:
|
||||
continue
|
||||
var path := BASE_PATH + "accessories/%s.glb" % item_id
|
||||
var tint: Color = desc.accessory_tints.get(item_id, Color.WHITE)
|
||||
if _attach_to_bone(path, bone_name, tint):
|
||||
var att := _attach_to_bone(path, bone_name, tint)
|
||||
if att != null:
|
||||
# Track accessory attachments separately for get_accessory_node_count()
|
||||
_accessory_attachments.append(_bone_attachments.back())
|
||||
_accessory_attachments.append(att)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -129,7 +129,7 @@ static func from_dict(data: Dictionary) -> CharacterVisualDescriptor:
|
||||
desc.facial_hair_tint = _decode_color(data.get("facial_hair_tint"), Color.WHITE)
|
||||
desc.eyebrow_id = data.get("eyebrow_id", "")
|
||||
desc.eyebrow_tint = _decode_color(data.get("eyebrow_tint"), Color.WHITE)
|
||||
desc.skin_tone = data.get("skin_tone", 0)
|
||||
desc.skin_tone = clampi(data.get("skin_tone", 0), 0, 8)
|
||||
desc.clothing_slots = data.get("clothing_slots", {})
|
||||
desc.clothing_tints = _decode_tint_map(data.get("clothing_tints", {}))
|
||||
desc.accessory_slots = data.get("accessory_slots", {})
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
## 5. Facing rotation (set_facing / rotation change)
|
||||
## 6. Edge cases: empty hair_id, no clothing, no accessories
|
||||
##
|
||||
## Spec: D-159 (body types), D-160 (15+2+2 segments), D-161 (head on Head bone),
|
||||
## Spec: D-159 (body types), D-160 (17 bone-group regions), D-161 (head on Head bone),
|
||||
## D-162 (clothing pre-baked per body type), D-164 (fork skeleton)
|
||||
## Ticket: #704
|
||||
class_name TestCharacterVisualSprint28
|
||||
@@ -158,10 +158,11 @@ func test_reload_descriptor_clears_previous_nodes() -> void:
|
||||
if count_after_first == 0:
|
||||
push_warning("TestCharacterVisualSprint28: node count 0 — assets may not be present yet")
|
||||
return
|
||||
# Second load must not double the children (would indicate no cleanup)
|
||||
assert_bool(count_after_second <= count_after_first * 2).override_failure_message(
|
||||
"load_descriptor must clear previous node tree before building new one — children doubled, suggesting stale nodes were kept"
|
||||
).is_true()
|
||||
# Second load must produce exactly the same child count — no more, no less.
|
||||
# _clear() frees and rebuilds; any deviation indicates stale nodes accumulating.
|
||||
assert_int(count_after_second).override_failure_message(
|
||||
"load_descriptor must produce identical child count on reload — stale nodes detected if higher, missing cleanup if lower"
|
||||
).is_equal(count_after_first)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -540,9 +540,9 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
- **Dissent:** None
|
||||
- **Cross-reference:** [D-149](#d-149-3d-characters-rendered-live-in-scene--not-pre-rendered-sprites), [D-150](#d-150-character-outline--inverted-hull-method)
|
||||
|
||||
### D-160: Body meshes must be segmented into 15 bone-group regions
|
||||
### D-160: Body meshes must be segmented into 17 bone-group regions
|
||||
- **Date:** 2026-03-19
|
||||
- **Decision:** Character body meshes are segmented into 15 bone-group regions (head, neck, torso, arm_upper_l/r, arm_lower_l/r, hand_l/r, leg_upper_l/r, leg_lower_l/r, foot_l/r) plus eyes and eyebrows. Each segment is a separate skinned GLB on the shared 65-bone skeleton. Segments have a 1-ring vertex overlap at boundaries to eliminate visible seams during animation. Segments can be individually hidden when clothing covers them.
|
||||
- **Decision:** Character body meshes are segmented into 17 bone-group regions (head, neck, torso, arm_upper_l/r, arm_lower_l/r, hand_l/r, leg_upper_l/r, leg_lower_l/r, foot_l/r, eyes, eyebrows). Each segment is a separate skinned GLB on the shared 65-bone skeleton. Segments have a 1-ring vertex overlap at boundaries to eliminate visible seams during animation. Segments can be individually hidden when clothing covers them.
|
||||
- **Rationale:** Validated in the Quaternius aesthetic spike. Monolithic body meshes block the compositor: hiding the body to show clothing also removes the head and limbs. Segmentation enables per-slot visibility, head separation for Trellis-generated faces, and limb loss mechanics. The 1-ring vertex overlap was validated as seamless at gameplay zoom.
|
||||
- **Raised by:** Jeroen + Tyre, during spike validation.
|
||||
- **Cross-reference:** [D-159](scope.md#d-159-character-body-type-enum-4-adult-types--2-genders--1-child-skeleton) (body type enum), [D-149](#d-149-3d-characters-rendered-live-in-scene--not-pre-rendered-sprites)
|
||||
@@ -552,7 +552,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
- **Decision:** Character heads are never baked into body or clothing meshes. The head is always a separate mesh attached to the Head bone via BoneAttachment3D. Trellis generates unique head shapes per character. Hair attaches to the same bone as a separate swappable mesh (enables hairdresser mechanic). The neck stays with the body mesh as a separate segment.
|
||||
- **Rationale:** Validated in the Quaternius spike. The Quaternius pack bakes the head into the body mesh — this blocks clothing display (hiding body = losing head) and prevents per-character face variation. Separating the head unlocks: Trellis-generated faces, idle look-around animation, helmet/hood equipment, hair as a swappable accessory.
|
||||
- **Raised by:** Jeroen, during spike validation.
|
||||
- **Cross-reference:** [D-160](#d-160-body-meshes-must-be-segmented-into-15-bone-group-regions), [D-163](#d-163-trellis-generates-unique-heads-per-character-via-boneattachment3d)
|
||||
- **Cross-reference:** [D-160](#d-160-body-meshes-must-be-segmented-into-17-bone-group-regions), [D-163](#d-163-trellis-generates-unique-heads-per-character-via-boneattachment3d)
|
||||
|
||||
### D-162: Clothing is pre-baked per body type via Blender Surface Deform
|
||||
- **Date:** 2026-03-19
|
||||
@@ -573,7 +573,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
- **Decision:** The Quaternius 65-bone skeleton and Universal Animation Library are adopted as the character rig foundation. All body meshes are replaced with hand-authored meshes on the same skeleton. The CC0 license permits unrestricted forking. Quaternius Source tier ($5/mo Patreon, one month) is recommended for .blend source files to support the Blender batch scripting pipeline.
|
||||
- **Rationale:** The spike validated the skeleton and animation library as clean, compatible, and well-structured. The body meshes are rejected: Superhero proportions contradict the life-sim aesthetic (D-153), only 2 body types exist in the standard tier (Superhero Male/Female), and procedural body type generation via bone scaling failed. The skeleton is the expensive part to create from scratch — keeping it and replacing the meshes is the correct split.
|
||||
- **Raised by:** Tyre + Araminta, confirmed by Jeroen.
|
||||
- **Cross-reference:** [D-159](scope.md#d-159-character-body-type-enum-4-adult-types--2-genders--1-child-skeleton), [D-160](#d-160-body-meshes-must-be-segmented-into-15-bone-group-regions)
|
||||
- **Cross-reference:** [D-159](scope.md#d-159-character-body-type-enum-4-adult-types--2-genders--1-child-skeleton), [D-160](#d-160-body-meshes-must-be-segmented-into-17-bone-group-regions)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ if __name__ == "__main__":
|
||||
sys.exit(1)
|
||||
|
||||
args = argv[argv.index("--") + 1:]
|
||||
if len(args) < 1:
|
||||
print("ERROR: Provide a GLB/GLTF path.")
|
||||
sys.exit(1)
|
||||
|
||||
input_path = args[0]
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
@@ -1,10 +1,37 @@
|
||||
"""
|
||||
blender_list_bones.py
|
||||
Usage: tooling/blender --background --python tooling/blender_list_bones.py -- <input.glb>
|
||||
|
||||
Lists all bone names in a GLB/GLTF armature, sorted alphabetically.
|
||||
Used to verify bone naming after the January 2026 Quaternius naming update.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import bpy
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=sys.argv[sys.argv.index("--") + 1])
|
||||
for obj in bpy.context.scene.objects:
|
||||
if obj.type == 'ARMATURE':
|
||||
for b in sorted(obj.data.bones, key=lambda x: x.name):
|
||||
print("BONE: " + b.name)
|
||||
break
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("Usage: tooling/blender --background --python tooling/blender_list_bones.py -- <input.glb>")
|
||||
sys.exit(1)
|
||||
|
||||
args = argv[argv.index("--") + 1:]
|
||||
if len(args) < 1:
|
||||
print("ERROR: Provide a GLB/GLTF path.")
|
||||
sys.exit(1)
|
||||
|
||||
input_path = args[0]
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=input_path)
|
||||
|
||||
for obj in bpy.context.scene.objects:
|
||||
if obj.type == 'ARMATURE':
|
||||
print(f"ARMATURE: {obj.name} — {len(obj.data.bones)} bones")
|
||||
for b in sorted(obj.data.bones, key=lambda x: x.name):
|
||||
print("BONE: " + b.name)
|
||||
break
|
||||
else:
|
||||
print("ERROR: No armature found in the imported file.")
|
||||
sys.exit(1)
|
||||
|
||||
Reference in New Issue
Block a user