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
+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", {})