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:
2026-03-20 07:59:23 +01:00
co-authored by Claude Opus 4.6
parent f34e6362c4
commit bcd5887b82
7 changed files with 82 additions and 33 deletions
@@ -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;
}
+31 -15
View File
@@ -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)
# =============================================================================
+4 -4
View File
@@ -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)
---
+4
View File
@@ -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)
+34 -7
View File
@@ -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)