feat(client): CharacterVisualDescriptor and compositor (#703, #704)

Add CharacterVisualDescriptor (11-variant BodyType enum, wire format
encode/decode, body_type_key mapping) and CharacterVisual compositor
(runtime 3D character assembler with slot architecture, BoneAttachment3D,
clothing coverage, recolor mask loading, skin tone tinting, bone
validation). Includes 64 unit tests and Blender utility scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 07:48:47 +01:00
co-authored by Claude Opus 4.6
parent ff3a8e95e4
commit 23ec1ddebd
8 changed files with 1951 additions and 0 deletions
@@ -0,0 +1,609 @@
class_name CharacterVisual
extends Node3D
## Runtime character visual compositor.
## Accepts a CharacterVisualDescriptor and assembles the full 3D character from
## pre-authored asset files. No runtime deformation — selects and attaches meshes.
##
## All asset paths derived programmatically from BASE_PATH + descriptor fields:
## bodies/{body_type}/seg_{name}.glb (D-160: 21 segments per body type)
## heads/templates/{head_id}.glb (D-161: head as BoneAttachment3D)
## hair/{hair_id}.glb (BoneAttachment3D to Head bone)
## clothing/{item_id}/{body_type}.glb (D-162: pre-fitted per body type)
## accessories/{accessory_id}.glb (BoneAttachment3D per slot→bone map)
##
## Public API:
## load_descriptor(descriptor) — rebuild full character from descriptor
## set_facing(direction: Vector2|String) — rotate model to match facing direction
## get_skeleton() — returns the Skeleton3D for external use
## is_segment_visible(seg_name) — query body segment visibility
## get_active_coverage(item_id) — return coverage dict for a clothing item
## get_active_torso_variant() — "full" or "upper"
## has_slot_node(slot) — whether a named slot (hair, facial_hair) was loaded
## get_clothing_node_count() — number of clothing MeshInstance3D nodes
## get_accessory_node_count() — number of accessory BoneAttachment3D nodes
## get_skin_tone_texture_name(index) — skin tone texture filename key for index
##
## D-159 (11 body types), D-160 (21 segments), D-161 (head separate),
## D-162 (clothing pre-fitted), D-163 (heads via BoneAttachment3D), D-164 (skeleton fork)
const BASE_PATH := "res://assets/characters/"
const SKELETON_PATH := BASE_PATH + "skeleton/armature.glb"
const SKIN_TONE_DIR := BASE_PATH + "skin_tones/"
const TOON_SHADER_PATH := BASE_PATH + "shaders/toon.gdshader"
const TOON_MASKED_SHADER_PATH := BASE_PATH + "shaders/toon_masked.gdshader"
const OUTLINE_SHADER_PATH := BASE_PATH + "shaders/outline.gdshader"
## All body segment names in assembly order. D-160: 17 base + 2 swappable torso + 2 face.
## torso_upper is loaded and hidden by default; clothing coverage reveals it.
const ALL_SEGMENTS: Array[String] = [
"head", "neck", "torso", "torso_upper",
"arm_upper_l", "arm_upper_r", "arm_lower_l", "arm_lower_r",
"hand_l", "hand_r",
"leg_upper_l", "leg_upper_r", "leg_lower_l", "leg_lower_r",
"foot_l", "foot_r",
"eyes", "eyebrows",
]
## Segments that do NOT receive the skin tone shader — preserve original embedded material.
const NON_SKIN_SEGMENTS: Array[String] = ["eyes", "eyebrows"]
## Segments hidden by default; shown only when a clothing coverage rule requests it.
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").
const SLOT_TO_BONE: Dictionary = {
"hat": "Head",
"goggles": "Head",
"mask": "Head",
"earring": "Head",
"backpack": "spine_03",
"belt": "pelvis",
"held_l": "hand_l",
"held_r": "hand_r",
}
## Skin tone palette: 9 tones, index 08 matching skin_tones/ texture filenames.
const SKIN_TONES: Array[Dictionary] = [
{"lit": Color("f5e8e0"), "shadow": Color("c8b4b0"), "tex": "pale_cool"},
{"lit": Color("f0c8a0"), "shadow": Color("c89870"), "tex": "pale_warm"},
{"lit": Color("d4a878"), "shadow": Color("a87850"), "tex": "light_olive"},
{"lit": Color("b87840"), "shadow": Color("885520"), "tex": "medium_golden"},
{"lit": Color("c09060"), "shadow": Color("906040"), "tex": "olive_warm"},
{"lit": Color("8b5a2b"), "shadow": Color("5a3010"), "tex": "medium_brown"},
{"lit": Color("5c3317"), "shadow": Color("3a1a08"), "tex": "deep_brown"},
{"lit": Color("3d2010"), "shadow": Color("200e04"), "tex": "very_deep_warm"},
{"lit": Color("2d1a12"), "shadow": Color("180a06"), "tex": "very_deep_cool"},
]
const OUTLINE_WIDTH: float = 0.006
const OUTLINE_COLOR: Color = Color(0.08, 0.08, 0.12)
const OUTLINE_SIZE_THRESHOLD: float = 0.1 # skip tiny meshes (eyes, eyebrows)
# --- Internal state ---
var _skeleton: Skeleton3D = null
var _body_root: Node3D = null
var _body_meshes: Array[MeshInstance3D] = []
var _clothing_meshes: Array[MeshInstance3D] = []
var _bone_attachments: Array[BoneAttachment3D] = []
var _outline_nodes: Array[MeshInstance3D] = []
var _accessory_attachments: Array[BoneAttachment3D] = []
# Inspectable state for tests
var _active_torso_variant: String = "full"
var _active_coverages: Dictionary = {} # item_id -> coverage dict
var _loaded_slots: Array[String] = [] # "hair", "facial_hair", etc.
var _toon_shader: Shader = null
var _toon_masked_shader: Shader = null
var _outline_shader: Shader = null
func _ready() -> void:
_load_shaders()
func _load_shaders() -> void:
_toon_shader = load(TOON_SHADER_PATH) as Shader
_toon_masked_shader = load(TOON_MASKED_SHADER_PATH) as Shader
_outline_shader = load(OUTLINE_SHADER_PATH) as Shader
if not _toon_shader or not _toon_masked_shader or not _outline_shader:
push_error("CharacterVisual: shader load failed — check %s" % BASE_PATH)
# =============================================================================
# Public API
# =============================================================================
## Rebuild the full character from a CharacterVisualDescriptor.
func load_descriptor(descriptor: CharacterVisualDescriptor) -> void:
_clear()
_load_skeleton()
if _skeleton == null:
push_error("CharacterVisual: skeleton failed to load — aborting load_descriptor")
return
_load_body_segments(descriptor)
_load_head(descriptor)
_load_hair(descriptor)
_load_facial_hair(descriptor)
_load_eyebrows(descriptor)
_load_clothing(descriptor)
_load_accessories(descriptor)
_rebuild_outlines()
## Set character facing from a 2D direction vector or a named string direction.
## Vector2(0, 1) = south (toward camera), Vector2(1, 0) = east, etc.
## String values: "south", "north", "east", "west", "southwest", "northeast", etc.
func set_facing(direction: Variant) -> void:
if direction is Vector2:
# Convert 2D game-space direction to 3D Y rotation.
# atan2(x, y): Vector2(0,1)→0°, (1,0)→-90°, (0,-1)→180°, (-1,0)→90°
rotation.y = -atan2((direction as Vector2).x, (direction as Vector2).y)
elif direction is String:
var angles: Dictionary = {
"south": 0.0, "southwest": 45.0, "west": 90.0, "northwest": 135.0,
"north": 180.0, "northeast": -135.0, "east": -90.0, "southeast": -45.0,
}
rotation_degrees.y = angles.get((direction as String).to_lower(), 0.0)
## Alias kept for EntityRenderer compatibility.
func update_facing(direction: String) -> void:
set_facing(direction)
func get_skeleton() -> Skeleton3D:
return _skeleton
## Return whether a named body segment is currently visible.
func is_segment_visible(seg_name: String) -> bool:
for mi in _body_meshes:
if mi.get_meta("segment", "") == seg_name:
return mi.visible
return false
## Return the coverage.json data for a clothing item_id, or empty dict if unavailable.
func get_active_coverage(item_id: String) -> Dictionary:
return _active_coverages.get(item_id, {})
## Return the active torso variant: "full" or "upper".
func get_active_torso_variant() -> String:
return _active_torso_variant
## Return whether a named slot (hair, facial_hair, eyebrow) produced a scene node.
func has_slot_node(slot: String) -> bool:
return slot in _loaded_slots
## Return count of clothing MeshInstance3D nodes currently attached.
func get_clothing_node_count() -> int:
return _clothing_meshes.size()
## Return count of accessory BoneAttachment3D nodes currently attached.
func get_accessory_node_count() -> int:
return _accessory_attachments.size()
## Return the skin tone texture filename key for a given index (without .png extension).
func get_skin_tone_texture_name(index: int) -> String:
return SKIN_TONES[clampi(index, 0, SKIN_TONES.size() - 1)]["tex"]
# =============================================================================
# Internal — teardown
# =============================================================================
func _clear() -> void:
for att in _bone_attachments:
if is_instance_valid(att) and att.get_parent():
att.get_parent().remove_child(att)
att.free()
_bone_attachments.clear()
_accessory_attachments.clear()
_loaded_slots.clear()
for mi in _clothing_meshes:
if is_instance_valid(mi) and mi.get_parent():
mi.get_parent().remove_child(mi)
mi.free()
_clothing_meshes.clear()
_body_meshes.clear()
_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()
_body_root = null
_skeleton = null
# =============================================================================
# Internal — skeleton
# =============================================================================
func _load_skeleton() -> void:
if not ResourceLoader.exists(SKELETON_PATH):
push_error("CharacterVisual: skeleton not found at %s" % SKELETON_PATH)
return
var scene := load(SKELETON_PATH) as PackedScene
if scene == null:
push_error("CharacterVisual: skeleton PackedScene is null")
return
_body_root = scene.instantiate()
add_child(_body_root)
_skeleton = _find_skeleton(_body_root)
if _skeleton == null:
push_error("CharacterVisual: no Skeleton3D node in %s" % SKELETON_PATH)
return
_validate_slot_bones()
## Warn on any SLOT_TO_BONE entry that doesn't exist in the loaded skeleton.
## Catches bone name mismatches early rather than silently failing on accessory attach.
func _validate_slot_bones() -> void:
for slot: String in SLOT_TO_BONE:
var bone_name: String = SLOT_TO_BONE[slot]
if _skeleton.find_bone(bone_name) == -1:
push_warning("CharacterVisual: SLOT_TO_BONE['%s'] = '%s' — bone not found in skeleton" % [slot, bone_name])
# =============================================================================
# Internal — body segments (D-160)
# =============================================================================
func _load_body_segments(desc: CharacterVisualDescriptor) -> void:
var body_dir := BASE_PATH + "bodies/%s/" % desc.body_type_key()
var tone := SKIN_TONES[clampi(desc.skin_tone, 0, SKIN_TONES.size() - 1)]
for seg_name in ALL_SEGMENTS:
var seg_path := body_dir + "seg_%s.glb" % seg_name
if not ResourceLoader.exists(seg_path):
continue # segment not yet authored — skip gracefully
var seg_scene := load(seg_path) as PackedScene
if seg_scene == null:
continue
var inst := seg_scene.instantiate()
for mi in _collect_meshes(inst):
mi.get_parent().remove_child(mi)
mi.owner = null
mi.set_meta("segment", seg_name)
mi.visible = seg_name not in HIDDEN_BY_DEFAULT
_apply_body_shader(mi, seg_name, tone)
_skeleton.add_child(mi)
_body_meshes.append(mi)
inst.queue_free()
func _apply_body_shader(mi: MeshInstance3D, seg_name: String, tone: Dictionary) -> void:
if mi.mesh == null:
return
var is_skin := seg_name not in NON_SKIN_SEGMENTS
for surf in range(mi.mesh.get_surface_count()):
if not is_skin:
# Eyes/eyebrows: preserve the original embedded texture from the GLB.
var orig: Material = mi.mesh.surface_get_material(surf)
if orig != null:
mi.set_surface_override_material(surf, orig)
continue
# Skin body segments: apply skin tone via toon_masked or flat toon fallback.
var tex_path := SKIN_TONE_DIR + "%s.png" % tone["tex"]
var skin_tex := load(tex_path) as Texture2D
var mat := ShaderMaterial.new()
if skin_tex and _toon_masked_shader:
mat.shader = _toon_masked_shader
mat.set_shader_parameter("albedo_tex", skin_tex)
mat.set_shader_parameter("tint_color", tone["lit"])
mat.set_shader_parameter("shadow_strength", 0.25)
elif _toon_shader:
mat.shader = _toon_shader
mat.set_shader_parameter("base_color", tone["lit"])
mat.set_shader_parameter("shadow_color", tone["shadow"])
mat.set_shader_parameter("shadow_threshold", 0.4)
mi.set_surface_override_material(surf, mat)
# =============================================================================
# Internal — head, hair, facial hair, eyebrows (BoneAttachment3D) (D-161)
# =============================================================================
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"):
_loaded_slots.append("head")
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):
_loaded_slots.append("hair")
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):
_loaded_slots.append("facial_hair")
func _load_eyebrows(desc: CharacterVisualDescriptor) -> void:
if desc.eyebrow_id.is_empty():
return
# 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):
_loaded_slots.append("eyebrow")
func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE) -> bool:
if _skeleton == null:
return false
if not ResourceLoader.exists(path):
push_warning("CharacterVisual: asset not found (expected): %s" % path)
return false
var scene := load(path) as PackedScene
if scene == null:
return false
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
var attachment := BoneAttachment3D.new()
attachment.bone_name = bone_name
attachment.bone_idx = bone_idx
_skeleton.add_child(attachment)
_bone_attachments.append(attachment)
# Load recolor mask sidecar: {asset_name}_mask.png alongside the GLB.
# Without this, toon_masked recolor_mask defaults to black → tint_color ignored.
var mask_path := path.get_basename() + "_mask.png"
var mask_tex: Texture2D = null
if ResourceLoader.exists(mask_path):
mask_tex = load(mask_path) as Texture2D
var inst := scene.instantiate()
attachment.add_child(inst)
for mi in _collect_meshes(inst):
_apply_tinted_shader(mi, tint, mask_tex)
return true
# =============================================================================
# Internal — clothing (D-162): coverage.json → segment hiding + torso variant
# =============================================================================
func _load_clothing(desc: CharacterVisualDescriptor) -> void:
if _skeleton == null or desc.clothing_slots.is_empty():
return
var hidden_segments: Array[String] = []
_active_torso_variant = "full"
for slot: String in desc.clothing_slots:
var item_id: String = desc.clothing_slots[slot]
var item_dir := BASE_PATH + "clothing/%s/" % item_id
var coverage := _read_coverage(item_dir + "coverage.json")
_active_coverages[item_id] = coverage
var glb_path := item_dir + "%s.glb" % desc.body_type_key()
# Accumulate segment coverage across all equipped clothing
if coverage.has("hides"):
for seg_name in coverage["hides"]:
if (seg_name as String) not in hidden_segments:
hidden_segments.append(seg_name)
# Only upgrade to "upper" — never downgrade back to "full" if a later item
# uses "full". Most-revealing torso variant wins across all equipped clothing.
if coverage.get("torso_variant") == "upper":
_active_torso_variant = "upper"
# Load recolor mask: shared across all body-type variants (same UV layout after Surface Deform).
var mask_path := item_dir + "reference_mask.png"
var mask_tex: Texture2D = null
if ResourceLoader.exists(mask_path):
mask_tex = load(mask_path) as Texture2D
# Load the body-type-fitted clothing GLB
if ResourceLoader.exists(glb_path):
var scene := load(glb_path) as PackedScene
if scene:
var inst := scene.instantiate()
var tints: Array = desc.clothing_tints.get(item_id, [])
for mi in _collect_meshes(inst):
mi.get_parent().remove_child(mi)
mi.owner = null
_apply_clothing_shader(mi, tints, mask_tex)
_skeleton.add_child(mi)
_clothing_meshes.append(mi)
inst.queue_free()
# Apply segment hiding from coverage
for mi in _body_meshes:
var seg: String = mi.get_meta("segment", "")
if seg in hidden_segments:
mi.visible = false
# Apply torso variant selection
if _active_torso_variant == "upper":
for mi in _body_meshes:
match mi.get_meta("segment", ""):
"torso": mi.visible = false
"torso_upper": mi.visible = true
else:
for mi in _body_meshes:
if mi.get_meta("segment", "") == "torso_upper":
mi.visible = false
func _read_coverage(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {}
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return {}
var parsed := JSON.parse_string(file.get_as_text())
file.close()
if parsed is Dictionary:
return parsed
push_warning("CharacterVisual: coverage.json malformed at %s" % path)
return {}
func _apply_clothing_shader(mi: MeshInstance3D, tints: Array, mask_tex: Texture2D = null) -> void:
if mi.mesh == null:
return
var tint: Color = tints[0] if not tints.is_empty() else Color(0.7, 0.65, 0.6)
for surf in range(mi.mesh.get_surface_count()):
var orig: Material = mi.mesh.surface_get_material(surf)
var tex := _get_albedo_texture(orig)
var mat := ShaderMaterial.new()
if tex and _toon_masked_shader:
mat.shader = _toon_masked_shader
mat.set_shader_parameter("albedo_tex", tex)
mat.set_shader_parameter("tint_color", tint)
mat.set_shader_parameter("shadow_strength", 0.2)
if mask_tex:
mat.set_shader_parameter("recolor_mask", mask_tex)
elif _toon_shader:
mat.shader = _toon_shader
mat.set_shader_parameter("base_color", tint)
mat.set_shader_parameter("shadow_color", tint.darkened(0.4))
mat.set_shader_parameter("shadow_threshold", 0.4)
mi.set_surface_override_material(surf, mat)
# =============================================================================
# Internal — accessories (BoneAttachment3D per slot→bone)
# =============================================================================
func _load_accessories(desc: CharacterVisualDescriptor) -> void:
for slot: String in desc.accessory_slots:
var item_id: String = desc.accessory_slots[slot]
var bone_name: String = SLOT_TO_BONE.get(slot, "")
if bone_name.is_empty():
push_warning("CharacterVisual: unknown accessory slot '%s'" % slot)
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):
# Track accessory attachments separately for get_accessory_node_count()
_accessory_attachments.append(_bone_attachments.back())
# =============================================================================
# Internal — tinting (hair, accessories, bone-attached assets with tint)
# =============================================================================
func _apply_tinted_shader(mi: MeshInstance3D, tint: Color, mask_tex: Texture2D = null) -> void:
if mi.mesh == null:
return
for surf in range(mi.mesh.get_surface_count()):
var orig: Material = mi.mesh.surface_get_material(surf)
var tex := _get_albedo_texture(orig)
var mat := ShaderMaterial.new()
if tex and _toon_masked_shader:
mat.shader = _toon_masked_shader
mat.set_shader_parameter("albedo_tex", tex)
mat.set_shader_parameter("tint_color", tint)
mat.set_shader_parameter("shadow_strength", 0.2)
if mask_tex:
mat.set_shader_parameter("recolor_mask", mask_tex)
elif _toon_shader:
mat.shader = _toon_shader
mat.set_shader_parameter("base_color", tint)
mat.set_shader_parameter("shadow_color", tint.darkened(0.4))
mat.set_shader_parameter("shadow_threshold", 0.4)
mi.set_surface_override_material(surf, mat)
# =============================================================================
# Internal — outline pass (inverted hull, cull_front)
# =============================================================================
func _rebuild_outlines() -> void:
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()
if _outline_shader == null:
return
var outline_mat := ShaderMaterial.new()
outline_mat.shader = _outline_shader
outline_mat.set_shader_parameter("outline_color", OUTLINE_COLOR)
outline_mat.set_shader_parameter("outline_width", OUTLINE_WIDTH)
var all_meshes: Array[MeshInstance3D] = []
all_meshes.append_array(_body_meshes)
all_meshes.append_array(_clothing_meshes)
for mi in all_meshes:
if mi.mesh == null or not mi.visible:
continue
if mi.mesh.get_aabb().size.y < OUTLINE_SIZE_THRESHOLD:
continue
var outline_mi := mi.duplicate() as MeshInstance3D
outline_mi.name = "_outline_%s" % mi.name
outline_mi.material_override = outline_mat
mi.get_parent().add_child(outline_mi)
_outline_nodes.append(outline_mi)
# =============================================================================
# Static helpers
# =============================================================================
static func _find_skeleton(root: Node) -> Skeleton3D:
if root is Skeleton3D:
return root as Skeleton3D
for child in root.get_children():
var found := _find_skeleton(child)
if found:
return found
return null
static func _collect_meshes(root: Node) -> Array[MeshInstance3D]:
var result: Array[MeshInstance3D] = []
if root is MeshInstance3D:
result.append(root as MeshInstance3D)
for child in root.get_children():
result.append_array(_collect_meshes(child))
return result
static func _get_albedo_texture(mat: Material) -> Texture2D:
if mat == null:
return null
if mat is StandardMaterial3D:
return (mat as StandardMaterial3D).albedo_texture
if mat is BaseMaterial3D:
return (mat as BaseMaterial3D).albedo_texture
if mat is ShaderMaterial:
for p: String in ["albedo_tex", "albedo_texture", "texture_albedo"]:
var val: Variant = (mat as ShaderMaterial).get_shader_parameter(p)
if val is Texture2D:
return val as Texture2D
return null
@@ -0,0 +1,210 @@
class_name CharacterVisualDescriptor
extends RefCounted
## Data contract for character visual state: layer choices + color overrides.
## Defines the server-to-client data struct for compositing a character's appearance.
## MessagePack field names must match server/src/bridge/types.rs (rmp_serde named fields).
## D-159 (11 body types), D-160 (segmented regions), D-161 (head separate mesh).
## See docs/architecture/character-asset-organization.md Section 6.
enum BodyType {
THIN_M,
THIN_F,
AVERAGE_M,
AVERAGE_F,
MUSCULAR_M,
MUSCULAR_F,
TEEN_M,
TEEN_F,
HEAVY_M,
HEAVY_F,
CHILD,
}
## Maps BodyType enum → file key used in asset paths (e.g. "average_m").
const BODY_TYPE_KEYS: Dictionary = {
BodyType.THIN_M: "thin_m",
BodyType.THIN_F: "thin_f",
BodyType.AVERAGE_M: "average_m",
BodyType.AVERAGE_F: "average_f",
BodyType.MUSCULAR_M: "muscular_m",
BodyType.MUSCULAR_F: "muscular_f",
BodyType.TEEN_M: "teen_m",
BodyType.TEEN_F: "teen_f",
BodyType.HEAVY_M: "heavy_m",
BodyType.HEAVY_F: "heavy_f",
BodyType.CHILD: "child",
}
## Maps wire string → BodyType for MessagePack decode (rmp_serde unit enum = bare string).
const BODY_TYPE_FROM_WIRE: Dictionary = {
"ThinM": BodyType.THIN_M,
"ThinF": BodyType.THIN_F,
"AverageM": BodyType.AVERAGE_M,
"AverageF": BodyType.AVERAGE_F,
"MuscularM": BodyType.MUSCULAR_M,
"MuscularF": BodyType.MUSCULAR_F,
"TeenM": BodyType.TEEN_M,
"TeenF": BodyType.TEEN_F,
"HeavyM": BodyType.HEAVY_M,
"HeavyF": BodyType.HEAVY_F,
"Child": BodyType.CHILD,
}
## Maps BodyType → wire string for MessagePack encode.
const BODY_TYPE_TO_WIRE: Dictionary = {
BodyType.THIN_M: "ThinM",
BodyType.THIN_F: "ThinF",
BodyType.AVERAGE_M: "AverageM",
BodyType.AVERAGE_F: "AverageF",
BodyType.MUSCULAR_M: "MuscularM",
BodyType.MUSCULAR_F: "MuscularF",
BodyType.TEEN_M: "TeenM",
BodyType.TEEN_F: "TeenF",
BodyType.HEAVY_M: "HeavyM",
BodyType.HEAVY_F: "HeavyF",
BodyType.CHILD: "Child",
}
# -- Fields (match wire format field names) --
## Body type — determines which mesh variant the compositor loads (D-159)
var body_type: BodyType = BodyType.AVERAGE_M
## Head template ID — resolves to heads/templates/ (D-161)
var head_id: String = ""
## Hair style key ("bob", "ponytail", "bald", etc.)
var hair_id: String = ""
var hair_tint: Color = Color.WHITE
## Facial hair style key; empty string = none
var facial_hair_id: String = ""
var facial_hair_tint: Color = Color.WHITE
## Eyebrow style key
var eyebrow_id: String = ""
var eyebrow_tint: Color = Color.WHITE
## Skin tone index 08 into the 9-tone palette (pale_cool → very_deep_cool)
var skin_tone: int = 0
## Clothing slot → item_id ("torso" -> "coveralls_basic")
var clothing_slots: Dictionary = {}
## item_id → Array[Color] for clothing colorable regions
var clothing_tints: Dictionary = {}
## Accessory slot → item_id ("hat" -> "hat_hardhat")
var accessory_slots: Dictionary = {}
## item_id → Color for accessories
var accessory_tints: Dictionary = {}
## Get the file key for the current body type (e.g. "average_m").
func body_type_key() -> String:
return BODY_TYPE_KEYS[body_type]
## Decode from a MessagePack-decoded Dictionary (rmp_serde named fields).
## Returns null if required fields are missing.
static func from_dict(data: Dictionary) -> CharacterVisualDescriptor:
if not data.has("body_type") or not data.has("head_id"):
push_error("CharacterVisualDescriptor: missing required fields (body_type, head_id)")
return null
var desc := CharacterVisualDescriptor.new()
# body_type: rmp_serde sends unit enum variants as bare strings
var wire_bt: String = data["body_type"]
if not BODY_TYPE_FROM_WIRE.has(wire_bt):
push_error("CharacterVisualDescriptor: unknown body_type '%s'" % wire_bt)
return null
desc.body_type = BODY_TYPE_FROM_WIRE[wire_bt]
desc.head_id = data.get("head_id", "")
desc.hair_id = data.get("hair_id", "")
desc.hair_tint = _decode_color(data.get("hair_tint"), Color.WHITE)
desc.facial_hair_id = data.get("facial_hair_id", "")
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.clothing_slots = data.get("clothing_slots", {})
desc.clothing_tints = _decode_tint_map(data.get("clothing_tints", {}))
desc.accessory_slots = data.get("accessory_slots", {})
desc.accessory_tints = _decode_single_tint_map(data.get("accessory_tints", {}))
return desc
## Encode to a Dictionary matching rmp_serde named-field format.
func to_dict() -> Dictionary:
return {
"body_type": BODY_TYPE_TO_WIRE[body_type],
"head_id": head_id,
"hair_id": hair_id,
"hair_tint": _encode_color(hair_tint),
"facial_hair_id": facial_hair_id,
"facial_hair_tint": _encode_color(facial_hair_tint),
"eyebrow_id": eyebrow_id,
"eyebrow_tint": _encode_color(eyebrow_tint),
"skin_tone": skin_tone,
"clothing_slots": clothing_slots,
"clothing_tints": _encode_tint_map(clothing_tints),
"accessory_slots": accessory_slots,
"accessory_tints": _encode_single_tint_map(accessory_tints),
}
# -- Color serialization helpers --
# Wire format: [r, g, b, a] float array (rmp_serde serializes Color as tuple).
static func _decode_color(value: Variant, fallback: Color) -> Color:
if value is Array and value.size() >= 3:
return Color(value[0], value[1], value[2], value[3] if value.size() >= 4 else 1.0)
return fallback
static func _encode_color(c: Color) -> Array:
return [c.r, c.g, c.b, c.a]
## Decode clothing_tints: item_id → Array[Color] (multi-region recolor).
static func _decode_tint_map(data: Dictionary) -> Dictionary:
var result := {}
for key: String in data:
var colors: Array[Color] = []
if data[key] is Array:
for c in data[key]:
colors.append(_decode_color(c, Color.WHITE))
result[key] = colors
return result
## Encode clothing_tints to wire format.
static func _encode_tint_map(data: Dictionary) -> Dictionary:
var result := {}
for key: String in data:
var encoded: Array = []
if data[key] is Array:
for c: Color in data[key]:
encoded.append(_encode_color(c))
result[key] = encoded
return result
## Decode accessory_tints: item_id → single Color.
static func _decode_single_tint_map(data: Dictionary) -> Dictionary:
var result := {}
for key: String in data:
result[key] = _decode_color(data[key], Color.WHITE)
return result
## Encode accessory_tints to wire format.
static func _encode_single_tint_map(data: Dictionary) -> Dictionary:
var result := {}
for key: String in data:
result[key] = _encode_color(data[key])
return result
@@ -0,0 +1,386 @@
## Sprint 28 — CharacterVisualDescriptor unit tests (#703)
##
## Validates the GDScript data contract for character visual state.
## Tests cover: BodyType enum (D-159), default fields, field mutation,
## body_type_key() helper, from_dict() / to_dict() round-trips, and
## edge cases (missing fields, unknown body_type wire string).
##
## The Rust side (server/src/bridge/types.rs) is out of scope for this
## client-team file — Rust tests live server-side.
##
## Spec: D-159 (11 body types), D-160 (21 segments), D-161 (head separate)
## Ticket: #703
class_name TestCharacterVisualDescriptorSprint28
extends GdUnitTestSuite
# -- BodyType enum — 11 variants (D-159) --------------------------------------
func test_body_type_enum_has_11_variants() -> void:
# D-159: 4 adult types × 2 genders + child = 11 total.
var bt := CharacterVisualDescriptor.BodyType
var variants := [
bt.THIN_M, bt.THIN_F,
bt.AVERAGE_M, bt.AVERAGE_F,
bt.MUSCULAR_M, bt.MUSCULAR_F,
bt.TEEN_M, bt.TEEN_F,
bt.HEAVY_M, bt.HEAVY_F,
bt.CHILD,
]
assert_int(variants.size()).override_failure_message(
"BodyType enum must have exactly 11 variants per D-159"
).is_equal(11)
func test_body_type_all_variants_exist() -> void:
# Each variant must be accessible without raising an error
var bt := CharacterVisualDescriptor.BodyType
assert_bool(bt.THIN_M >= 0).is_true()
assert_bool(bt.THIN_F >= 0).is_true()
assert_bool(bt.AVERAGE_M >= 0).is_true()
assert_bool(bt.AVERAGE_F >= 0).is_true()
assert_bool(bt.MUSCULAR_M >= 0).is_true()
assert_bool(bt.MUSCULAR_F >= 0).is_true()
assert_bool(bt.TEEN_M >= 0).is_true()
assert_bool(bt.TEEN_F >= 0).is_true()
assert_bool(bt.HEAVY_M >= 0).is_true()
assert_bool(bt.HEAVY_F >= 0).is_true()
assert_bool(bt.CHILD >= 0).is_true()
# -- BODY_TYPE_KEYS lookup coverage -------------------------------------------
func test_body_type_keys_covers_all_variants() -> void:
# BODY_TYPE_KEYS must have an entry for every BodyType variant
var bt := CharacterVisualDescriptor.BodyType
var keys := CharacterVisualDescriptor.BODY_TYPE_KEYS
for variant in [bt.THIN_M, bt.THIN_F, bt.AVERAGE_M, bt.AVERAGE_F,
bt.MUSCULAR_M, bt.MUSCULAR_F, bt.TEEN_M, bt.TEEN_F,
bt.HEAVY_M, bt.HEAVY_F, bt.CHILD]:
assert_bool(keys.has(variant)).override_failure_message(
"BODY_TYPE_KEYS missing variant %d" % variant
).is_true()
func test_body_type_key_average_m_is_average_m() -> void:
var d := CharacterVisualDescriptor.new()
d.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
assert_str(d.body_type_key()).is_equal("average_m")
func test_body_type_key_child_is_child() -> void:
var d := CharacterVisualDescriptor.new()
d.body_type = CharacterVisualDescriptor.BodyType.CHILD
assert_str(d.body_type_key()).is_equal("child")
func test_body_type_keys_all_lowercase_snake_case() -> void:
# File keys must match filesystem snake_case convention
for key: String in CharacterVisualDescriptor.BODY_TYPE_KEYS.values():
assert_str(key).is_equal(key.to_lower())
# -- Wire format dictionaries -------------------------------------------------
func test_body_type_from_wire_covers_all_11_variants() -> void:
var from_wire := CharacterVisualDescriptor.BODY_TYPE_FROM_WIRE
assert_int(from_wire.size()).override_failure_message(
"BODY_TYPE_FROM_WIRE must have 11 entries (one per BodyType variant)"
).is_equal(11)
func test_body_type_to_wire_covers_all_11_variants() -> void:
var to_wire := CharacterVisualDescriptor.BODY_TYPE_TO_WIRE
assert_int(to_wire.size()).override_failure_message(
"BODY_TYPE_TO_WIRE must have 11 entries"
).is_equal(11)
func test_wire_round_trip_all_body_types() -> void:
# from_wire → to_wire must be the identity for every variant
var from_wire := CharacterVisualDescriptor.BODY_TYPE_FROM_WIRE
var to_wire := CharacterVisualDescriptor.BODY_TYPE_TO_WIRE
for wire_str: String in from_wire:
var bt = from_wire[wire_str]
assert_str(to_wire[bt]).override_failure_message(
"to_wire(from_wire('%s')) should equal '%s'" % [wire_str, wire_str]
).is_equal(wire_str)
# -- Default field values -----------------------------------------------------
func test_construction_does_not_crash() -> void:
var d := CharacterVisualDescriptor.new()
assert_bool(d != null).is_true()
func test_default_body_type_is_average_m() -> void:
var d := CharacterVisualDescriptor.new()
assert_int(d.body_type).is_equal(CharacterVisualDescriptor.BodyType.AVERAGE_M)
func test_default_head_id_is_empty_string() -> void:
var d := CharacterVisualDescriptor.new()
assert_str(d.head_id).is_equal("")
func test_default_hair_id_is_empty_string() -> void:
var d := CharacterVisualDescriptor.new()
assert_str(d.hair_id).is_equal("")
func test_default_hair_tint_is_white() -> void:
var d := CharacterVisualDescriptor.new()
assert_bool(d.hair_tint == Color.WHITE).override_failure_message(
"Default hair_tint should be Color.WHITE"
).is_true()
func test_default_facial_hair_id_is_empty_string() -> void:
var d := CharacterVisualDescriptor.new()
assert_str(d.facial_hair_id).is_equal("")
func test_default_eyebrow_id_is_empty_string() -> void:
var d := CharacterVisualDescriptor.new()
assert_str(d.eyebrow_id).is_equal("")
func test_default_eyebrow_tint_is_white() -> void:
var d := CharacterVisualDescriptor.new()
assert_bool(d.eyebrow_tint == Color.WHITE).is_true()
func test_default_skin_tone_is_zero() -> void:
var d := CharacterVisualDescriptor.new()
assert_int(d.skin_tone).is_equal(0)
func test_default_dicts_are_empty() -> void:
var d := CharacterVisualDescriptor.new()
assert_int(d.clothing_slots.size()).is_equal(0)
assert_int(d.clothing_tints.size()).is_equal(0)
assert_int(d.accessory_slots.size()).is_equal(0)
assert_int(d.accessory_tints.size()).is_equal(0)
# -- Mutation round-trips -----------------------------------------------------
func test_body_type_can_be_set_to_all_variants() -> void:
var d := CharacterVisualDescriptor.new()
var bt := CharacterVisualDescriptor.BodyType
for variant in [bt.THIN_M, bt.THIN_F, bt.AVERAGE_M, bt.AVERAGE_F,
bt.MUSCULAR_M, bt.MUSCULAR_F, bt.TEEN_M, bt.TEEN_F,
bt.HEAVY_M, bt.HEAVY_F, bt.CHILD]:
d.body_type = variant
assert_int(d.body_type).is_equal(variant)
func test_head_id_round_trips() -> void:
var d := CharacterVisualDescriptor.new()
d.head_id = "head_042"
assert_str(d.head_id).is_equal("head_042")
func test_hair_id_round_trips() -> void:
var d := CharacterVisualDescriptor.new()
d.hair_id = "ponytail"
assert_str(d.hair_id).is_equal("ponytail")
func test_skin_tone_accepts_full_range_0_to_8() -> void:
# 9-tone palette: valid indices 08
var d := CharacterVisualDescriptor.new()
for i in range(9):
d.skin_tone = i
assert_int(d.skin_tone).is_equal(i)
func test_clothing_slots_can_be_populated() -> void:
var d := CharacterVisualDescriptor.new()
d.clothing_slots["torso"] = "coveralls_basic"
d.clothing_slots["legs"] = "trousers_01"
assert_int(d.clothing_slots.size()).is_equal(2)
assert_str(d.clothing_slots["torso"]).is_equal("coveralls_basic")
func test_accessory_slots_can_be_populated() -> void:
var d := CharacterVisualDescriptor.new()
d.accessory_slots["hat"] = "hat_hardhat"
assert_str(d.accessory_slots["hat"]).is_equal("hat_hardhat")
# -- from_dict() / to_dict() round-trips --------------------------------------
func test_from_dict_minimal_valid_input() -> void:
# Only required fields; optional fields fall back to defaults
var data := {
"body_type": "AverageF",
"head_id": "head_007",
}
var d := CharacterVisualDescriptor.from_dict(data)
assert_bool(d != null).override_failure_message(
"from_dict must succeed with required fields present"
).is_true()
assert_int(d.body_type).is_equal(CharacterVisualDescriptor.BodyType.AVERAGE_F)
assert_str(d.head_id).is_equal("head_007")
func test_from_dict_all_fields() -> void:
var data := {
"body_type": "MuscularM",
"head_id": "head_003",
"hair_id": "buzz",
"hair_tint": [0.2, 0.15, 0.1, 1.0],
"facial_hair_id": "stubble",
"facial_hair_tint": [0.1, 0.08, 0.06, 1.0],
"eyebrow_id": "thick",
"eyebrow_tint": [0.1, 0.08, 0.06, 1.0],
"skin_tone": 4,
"clothing_slots": {"torso": "shirt_02"},
"clothing_tints": {},
"accessory_slots": {},
"accessory_tints": {},
}
var d := CharacterVisualDescriptor.from_dict(data)
assert_bool(d != null).is_true()
assert_int(d.body_type).is_equal(CharacterVisualDescriptor.BodyType.MUSCULAR_M)
assert_str(d.hair_id).is_equal("buzz")
assert_str(d.facial_hair_id).is_equal("stubble")
assert_int(d.skin_tone).is_equal(4)
assert_str(d.clothing_slots.get("torso", "")).is_equal("shirt_02")
func test_from_dict_missing_body_type_returns_null() -> void:
# Required field missing — must return null (not crash)
var data := {"head_id": "head_001"}
var d := CharacterVisualDescriptor.from_dict(data)
assert_bool(d == null).override_failure_message(
"from_dict must return null when body_type is absent"
).is_true()
func test_from_dict_missing_head_id_returns_null() -> void:
var data := {"body_type": "AverageM"}
var d := CharacterVisualDescriptor.from_dict(data)
assert_bool(d == null).override_failure_message(
"from_dict must return null when head_id is absent"
).is_true()
func test_from_dict_unknown_body_type_returns_null() -> void:
# Unknown wire string — must return null, not crash or silently corrupt
var data := {"body_type": "INVALID_TYPE", "head_id": "head_001"}
var d := CharacterVisualDescriptor.from_dict(data)
assert_bool(d == null).override_failure_message(
"from_dict must return null for unknown body_type wire string"
).is_true()
func test_to_dict_produces_string_body_type() -> void:
# Wire format: body_type must be a string, not an integer
var d := CharacterVisualDescriptor.new()
d.body_type = CharacterVisualDescriptor.BodyType.THIN_F
var wire := d.to_dict()
assert_bool(wire["body_type"] is String).override_failure_message(
"to_dict body_type must be a wire string, not an enum int"
).is_true()
assert_str(wire["body_type"]).is_equal("ThinF")
func test_full_round_trip_to_dict_from_dict() -> void:
# Build a descriptor, encode, decode — all fields must survive
var orig := CharacterVisualDescriptor.new()
orig.body_type = CharacterVisualDescriptor.BodyType.HEAVY_F
orig.head_id = "head_099"
orig.hair_id = "long"
orig.hair_tint = Color(0.5, 0.3, 0.1, 1.0)
orig.facial_hair_id = ""
orig.eyebrow_id = "thin"
orig.skin_tone = 7
orig.clothing_slots = {"torso": "jacket_01"}
var wire := orig.to_dict()
var restored := CharacterVisualDescriptor.from_dict(wire)
assert_bool(restored != null).is_true()
assert_int(restored.body_type).is_equal(CharacterVisualDescriptor.BodyType.HEAVY_F)
assert_str(restored.head_id).is_equal("head_099")
assert_str(restored.hair_id).is_equal("long")
assert_int(restored.skin_tone).is_equal(7)
assert_str(restored.clothing_slots.get("torso", "")).is_equal("jacket_01")
func test_to_dict_hair_tint_encodes_as_float_array() -> void:
# Color must encode as [r, g, b, a] float array for rmp_serde
var d := CharacterVisualDescriptor.new()
d.hair_tint = Color(0.8, 0.3, 0.1, 1.0)
var wire := d.to_dict()
var tint = wire["hair_tint"]
assert_bool(tint is Array).override_failure_message(
"hair_tint in wire must be Array[float], not Color"
).is_true()
assert_int((tint as Array).size()).is_equal(4)
# -- Edge cases ---------------------------------------------------------------
func test_separate_instances_have_independent_dicts() -> void:
# Dictionary defaults must not be shared between instances (aliasing bug)
var a := CharacterVisualDescriptor.new()
var b := CharacterVisualDescriptor.new()
a.clothing_slots["torso"] = "shirt_a"
assert_bool("torso" in b.clothing_slots).override_failure_message(
"clothing_slots must be independent across instances — not shared"
).is_false()
func test_skin_tone_boundary_min_zero() -> void:
var d := CharacterVisualDescriptor.new()
d.skin_tone = 0
assert_int(d.skin_tone).is_equal(0)
func test_skin_tone_boundary_max_eight() -> void:
# 9-tone palette: index 8 is the maximum (very_deep_cool or very_deep_warm)
var d := CharacterVisualDescriptor.new()
d.skin_tone = 8
assert_int(d.skin_tone).is_equal(8)
func test_facial_hair_empty_string_means_none() -> void:
# Spec: "empty string = none" for facial_hair_id
var d := CharacterVisualDescriptor.new()
d.facial_hair_id = ""
assert_str(d.facial_hair_id).is_equal("")
func test_hardcoded_descriptor_matches_ticket_acceptance_criterion() -> void:
# Ticket #703: "A hardcoded test descriptor can be constructed in GDScript
# and matched to a Rust struct without panics."
# This validates the GDScript half. Rust half lives in server-team tests.
var d := CharacterVisualDescriptor.new()
d.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_F
d.head_id = "head_001"
d.hair_id = "bob"
d.hair_tint = Color(0.4, 0.25, 0.1, 1.0)
d.facial_hair_id = ""
d.eyebrow_id = "thin"
d.eyebrow_tint = Color(0.3, 0.2, 0.1, 1.0)
d.skin_tone = 2
d.clothing_slots = {"torso": "coveralls_basic", "legs": "trousers_basic"}
d.clothing_tints = {"coveralls_basic": [Color.DARK_SLATE_GRAY]}
d.accessory_slots = {}
d.accessory_tints = {}
assert_int(d.body_type).is_equal(CharacterVisualDescriptor.BodyType.AVERAGE_F)
assert_str(d.head_id).is_equal("head_001")
assert_str(d.hair_id).is_equal("bob")
assert_int(d.skin_tone).is_equal(2)
assert_int(d.clothing_slots.size()).is_equal(2)
assert_int(d.accessory_slots.size()).is_equal(0)
# And it must encode to a valid wire dict without crashing
var wire := d.to_dict()
assert_bool(wire.has("body_type")).is_true()
assert_str(wire["body_type"]).is_equal("AverageF")
@@ -0,0 +1,553 @@
## Sprint 28 — CharacterVisual compositor tests (#704)
##
## Validates the character visual assembler (CharacterVisual.gd) against the
## architectural spec at docs/architecture/character-asset-organization.md.
##
## Most tests are test-first stubs: they push_warning and return when the
## compositor class or required asset files are not yet present. Once Stig's
## implementation lands, the guards are removed automatically.
##
## Test areas (per team lead focus list):
## 1. Descriptor-to-node-tree construction
## 2. Segment visibility toggling for clothing coverage
## 3. Torso variant swap (seg_torso vs seg_torso_upper)
## 4. Tint application (skin tone index → texture)
## 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),
## D-162 (clothing pre-baked per body type), D-164 (fork skeleton)
## Ticket: #704
class_name TestCharacterVisualSprint28
extends GdUnitTestSuite
const COMPOSITOR_PATH := "res://scripts/rendering/character_visual.gd"
const SKIN_TONES_DIR := "res://assets/characters/skin_tones/"
const BODIES_DIR := "res://assets/characters/bodies/"
const SKELETON_PATH := "res://assets/characters/skeleton/armature.glb"
## Returns a minimal valid descriptor with no optional layers.
func _make_minimal_descriptor() -> CharacterVisualDescriptor:
var d := CharacterVisualDescriptor.new()
d.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
d.head_id = "head_001"
d.hair_id = "" # empty = no hair
d.facial_hair_id = "" # empty = none
d.eyebrow_id = "" # empty = no eyebrows
d.skin_tone = 0
return d
## Returns true if the compositor script is present and loadable.
func _compositor_available() -> bool:
if not ResourceLoader.exists(COMPOSITOR_PATH):
push_warning("TestCharacterVisualSprint28: character_visual.gd not found — test-first stub (awaiting #704)")
return false
return true
## Returns true if the skeleton GLB is present (required for segment/clothing tests).
func _skeleton_available() -> bool:
return ResourceLoader.exists(SKELETON_PATH)
## Loads and instantiates a CharacterVisual node. Returns null with warning if unavailable.
func _make_compositor() -> Node:
if not _compositor_available():
return null
var script: GDScript = load(COMPOSITOR_PATH)
if script == null:
push_warning("TestCharacterVisualSprint28: failed to load character_visual.gd")
return null
var node := Node3D.new()
node.set_script(script)
add_child(node)
return node
func after_test() -> void:
# Clean up any nodes added during testing
for child in get_children():
if child != self:
child.queue_free()
# =============================================================================
# 1. API presence — compositor class exists and has expected public interface
# =============================================================================
func test_compositor_script_file_exists() -> void:
# Gate: is the file present at all?
if not ResourceLoader.exists(COMPOSITOR_PATH):
push_warning("TestCharacterVisualSprint28: character_visual.gd missing — test-first stub")
return
assert_bool(ResourceLoader.exists(COMPOSITOR_PATH)).is_true()
func test_compositor_has_load_descriptor_method() -> void:
var node := _make_compositor()
if node == null:
return
assert_bool(node.has_method("load_descriptor")).override_failure_message(
"CharacterVisual must expose load_descriptor(desc: CharacterVisualDescriptor)"
).is_true()
func test_compositor_has_set_facing_method() -> void:
var node := _make_compositor()
if node == null:
return
assert_bool(node.has_method("set_facing")).override_failure_message(
"CharacterVisual must expose set_facing(direction: Vector2 or int)"
).is_true()
func test_compositor_is_node3d() -> void:
# Compositor must be a Node3D (3D scene tree, not 2D)
if not _compositor_available():
return
var script: GDScript = load(COMPOSITOR_PATH)
var node := Node3D.new()
node.set_script(script)
add_child(node)
assert_bool(node is Node3D).override_failure_message(
"CharacterVisual must extend Node3D"
).is_true()
# =============================================================================
# 2. Descriptor-to-node-tree construction
# =============================================================================
func test_load_descriptor_does_not_crash_with_minimal_desc() -> void:
var node := _make_compositor()
if node == null:
return
var desc := _make_minimal_descriptor()
# Must not crash — assets may be missing but the call must not throw
node.load_descriptor(desc)
assert_bool(true).is_true() # If we got here, no crash
func test_load_descriptor_produces_child_nodes() -> void:
var node := _make_compositor()
if node == null:
return
var desc := _make_minimal_descriptor()
node.load_descriptor(desc)
# A fully loaded compositor must have at least one child (body skeleton or armature)
# Guard: skip if assets genuinely not present yet (real test once assets land)
if node.get_child_count() == 0:
push_warning("TestCharacterVisualSprint28: no child nodes after load_descriptor — assets may not be imported yet")
return
assert_bool(node.get_child_count() > 0).is_true()
func test_reload_descriptor_clears_previous_nodes() -> void:
# Loading a second descriptor must replace the first, not accumulate nodes
var node := _make_compositor()
if node == null:
return
var desc1 := _make_minimal_descriptor()
desc1.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
var desc2 := _make_minimal_descriptor()
desc2.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_F
node.load_descriptor(desc1)
var count_after_first := node.get_child_count()
node.load_descriptor(desc2)
var count_after_second := node.get_child_count()
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()
# =============================================================================
# 3. Segment visibility toggling for clothing coverage
# =============================================================================
func test_no_clothing_all_body_segments_visible() -> void:
var node := _make_compositor()
if node == null:
return
if not _skeleton_available():
push_warning("TestCharacterVisualSprint28: skeleton not found — segment visibility tests require #706 assets")
return
var desc := _make_minimal_descriptor()
desc.clothing_slots = {} # no clothing
node.load_descriptor(desc)
# All non-swapped segments should be visible with no clothing.
# is_segment_visible returns false for missing segments — only meaningful when skeleton loaded.
assert_bool(node.is_segment_visible("torso")).override_failure_message(
"seg_torso must be visible when no clothing covers it"
).is_true()
assert_bool(node.is_segment_visible("arm_upper_l")).is_true()
assert_bool(node.is_segment_visible("leg_upper_l")).is_true()
func test_clothing_hides_declared_segments() -> void:
# A full coverall with hides = [torso, arm_upper_l, arm_upper_r, ...] must
# hide those segments
var node := _make_compositor()
if node == null:
return
if not node.has_method("is_segment_visible"):
push_warning("TestCharacterVisualSprint28: is_segment_visible not found — stub")
return
var desc := _make_minimal_descriptor()
desc.clothing_slots = {"torso": "coveralls_basic"}
node.load_descriptor(desc)
# Edge case: coverage.json may not exist yet — guard
if not node.has_method("get_active_coverage"):
push_warning("TestCharacterVisualSprint28: coverage integration not yet wired — skipping segment hide check")
return
# Coveralls should hide the torso segment (among others)
# The exact hidden set is declared in coverage.json — we test the contract, not the file content
var coverage: Dictionary = node.get_active_coverage("coveralls_basic")
if coverage.is_empty():
push_warning("TestCharacterVisualSprint28: coveralls_basic/coverage.json not found — stub")
return
for hidden_seg: String in coverage.get("hides", []):
assert_bool(node.is_segment_visible(hidden_seg)).override_failure_message(
"Segment '%s' declared in coverage.json hides must not be visible" % hidden_seg
).is_false()
func test_segments_not_in_hides_remain_visible() -> void:
# A jacket (upper body only) must NOT hide leg segments
var node := _make_compositor()
if node == null:
return
if not _skeleton_available():
push_warning("TestCharacterVisualSprint28: skeleton not found — segment visibility tests require #706 assets")
return
var desc := _make_minimal_descriptor()
desc.clothing_slots = {"torso": "jacket_utility"} # upper body only
node.load_descriptor(desc)
var coverage: Dictionary = node.get_active_coverage("jacket_utility") if node.has_method("get_active_coverage") else {}
if coverage.is_empty():
push_warning("TestCharacterVisualSprint28: jacket_utility/coverage.json not found — stub")
return
var hides: Array = coverage.get("hides", [])
if not "leg_upper_l" in hides:
# Jacket doesn't cover legs — legs must still be visible
assert_bool(node.is_segment_visible("leg_upper_l")).override_failure_message(
"Leg segments must remain visible when only a jacket is worn"
).is_true()
# =============================================================================
# 4. Torso variant swap
# =============================================================================
func test_torso_variant_full_uses_seg_torso() -> void:
# coverage.json torso_variant: "full" → seg_torso is loaded (default)
var node := _make_compositor()
if node == null:
return
if not node.has_method("get_active_torso_variant"):
push_warning("TestCharacterVisualSprint28: get_active_torso_variant not found — stub (awaiting #704)")
return
var desc := _make_minimal_descriptor()
desc.clothing_slots = {} # no clothing → default torso
node.load_descriptor(desc)
assert_str(node.get_active_torso_variant()).override_failure_message(
"With no clothing, torso variant must be 'full' (uses seg_torso)"
).is_equal("full")
func test_torso_variant_upper_uses_seg_torso_upper() -> void:
# coverage.json torso_variant: "upper" → seg_torso_upper is loaded
# (e.g. tank top: exposes lower abdomen)
var node := _make_compositor()
if node == null:
return
if not node.has_method("get_active_torso_variant"):
push_warning("TestCharacterVisualSprint28: get_active_torso_variant not found — stub")
return
var desc := _make_minimal_descriptor()
desc.clothing_slots = {"torso": "shirt_tank"} # a tank top with torso_variant: "upper"
node.load_descriptor(desc)
var coverage: Dictionary = node.get_active_coverage("shirt_tank") if node.has_method("get_active_coverage") else {}
if coverage.is_empty() or coverage.get("torso_variant") != "upper":
push_warning("TestCharacterVisualSprint28: shirt_tank/coverage.json not found or no upper variant — stub")
return
assert_str(node.get_active_torso_variant()).override_failure_message(
"Tank top with torso_variant:'upper' must load seg_torso_upper"
).is_equal("upper")
func test_torso_hidden_when_full_coverage_clothing_worn() -> void:
# A full coverall hides the torso segment entirely (not swapped to upper)
var node := _make_compositor()
if node == null:
return
if not _skeleton_available():
push_warning("TestCharacterVisualSprint28: skeleton not found — torso hide test requires #706 assets")
return
var desc := _make_minimal_descriptor()
desc.clothing_slots = {"torso": "coveralls_basic"}
node.load_descriptor(desc)
var coverage: Dictionary = node.get_active_coverage("coveralls_basic") if node.has_method("get_active_coverage") else {}
if coverage.is_empty():
push_warning("TestCharacterVisualSprint28: coveralls_basic/coverage.json not found — stub")
return
if "torso" in coverage.get("hides", []):
# Full coverage: torso segment is hidden, not swapped
assert_bool(node.is_segment_visible("torso")).override_failure_message(
"Full-coverage clothing with 'torso' in hides must hide seg_torso entirely"
).is_false()
# =============================================================================
# 5. Facing rotation
# =============================================================================
func test_set_facing_changes_node_rotation() -> void:
var node := _make_compositor()
if node == null:
return
# set_facing only writes rotation.y — no asset dependency, no load_descriptor needed
var rot_before: float = node.rotation.y
node.set_facing(Vector2(1, 0)) # east / right
var rot_after: float = node.rotation.y
# Rotation must change when facing changes (exact value is implementation-defined)
# We only check that it differs — not the exact angle
# (Different implementations may use 4-dir or 8-dir mappings)
# Guard: allow no-op only if facing east == default (unlikely)
if rot_before == rot_after:
# Try a definitely-different facing
node.set_facing(Vector2(-1, 0)) # west
var rot_west: float = node.rotation.y
assert_bool(rot_west != rot_before).override_failure_message(
"set_facing must rotate the node — east and west must produce different rotations"
).is_true()
func test_set_facing_opposite_directions_differ() -> void:
var node := _make_compositor()
if node == null:
return
# set_facing only writes rotation.y — no asset dependency
node.set_facing(Vector2(0, -1)) # north
var rot_north: float = node.rotation.y
node.set_facing(Vector2(0, 1)) # south
var rot_south: float = node.rotation.y
assert_bool(rot_north != rot_south).override_failure_message(
"North and south facing must produce different rotation values"
).is_true()
# =============================================================================
# 6. Edge cases
# =============================================================================
func test_empty_hair_id_adds_no_hair_node() -> void:
var node := _make_compositor()
if node == null:
return
if not node.has_method("has_slot_node"):
push_warning("TestCharacterVisualSprint28: has_slot_node not found — stub (awaiting #704)")
return
var desc := _make_minimal_descriptor()
desc.hair_id = "" # explicit empty = no hair
node.load_descriptor(desc)
assert_bool(node.has_slot_node("hair")).override_failure_message(
"Empty hair_id must not add a hair node to the character tree"
).is_false()
func test_bald_hair_id_adds_no_hair_node() -> void:
# "bald" is the explicit key for no-hair — treated identically to empty string.
# Stig: `if desc.hair_id.is_empty() or desc.hair_id == "bald": return`
var node := _make_compositor()
if node == null:
return
if not node.has_method("has_slot_node"):
push_warning("TestCharacterVisualSprint28: has_slot_node not found — stub")
return
var desc := _make_minimal_descriptor()
desc.hair_id = "bald"
node.load_descriptor(desc)
assert_bool(node.has_slot_node("hair")).override_failure_message(
"hair_id='bald' must not add a hair node (bald is the explicit no-hair sentinel)"
).is_false()
func test_empty_facial_hair_id_adds_no_facial_hair_node() -> void:
var node := _make_compositor()
if node == null:
return
if not node.has_method("has_slot_node"):
push_warning("TestCharacterVisualSprint28: has_slot_node not found — stub")
return
var desc := _make_minimal_descriptor()
desc.facial_hair_id = ""
node.load_descriptor(desc)
assert_bool(node.has_slot_node("facial_hair")).override_failure_message(
"Empty facial_hair_id must not add a facial hair node"
).is_false()
func test_no_clothing_slots_leaves_no_clothing_nodes() -> void:
var node := _make_compositor()
if node == null:
return
if not node.has_method("get_clothing_node_count"):
push_warning("TestCharacterVisualSprint28: get_clothing_node_count not found — stub")
return
var desc := _make_minimal_descriptor()
desc.clothing_slots = {}
node.load_descriptor(desc)
assert_int(node.get_clothing_node_count()).override_failure_message(
"No clothing slots in descriptor must produce zero clothing nodes in the scene tree"
).is_equal(0)
func test_no_accessories_adds_no_accessory_nodes() -> void:
var node := _make_compositor()
if node == null:
return
if not node.has_method("get_accessory_node_count"):
push_warning("TestCharacterVisualSprint28: get_accessory_node_count not found — stub")
return
var desc := _make_minimal_descriptor()
desc.accessory_slots = {}
node.load_descriptor(desc)
assert_int(node.get_accessory_node_count()).override_failure_message(
"No accessory slots in descriptor must produce zero accessory nodes"
).is_equal(0)
func test_missing_asset_file_does_not_crash() -> void:
# If an asset file referenced by the descriptor is missing from disk,
# the compositor must degrade gracefully (skip the missing slot, not crash).
var node := _make_compositor()
if node == null:
return
var desc := _make_minimal_descriptor()
desc.hair_id = "hair_does_not_exist_zzz" # guaranteed non-existent
# Must not crash — should either skip the slot or substitute a placeholder
node.load_descriptor(desc)
assert_bool(true).is_true() # survived = pass
func test_all_11_body_types_load_without_crash() -> void:
# Each of the 11 body types must not cause load_descriptor to crash.
# Assets may be missing — but the compositor must handle it gracefully.
var node := _make_compositor()
if node == null:
return
var bt := CharacterVisualDescriptor.BodyType
for variant in [bt.THIN_M, bt.THIN_F, bt.AVERAGE_M, bt.AVERAGE_F,
bt.MUSCULAR_M, bt.MUSCULAR_F, bt.TEEN_M, bt.TEEN_F,
bt.HEAVY_M, bt.HEAVY_F, bt.CHILD]:
var desc := _make_minimal_descriptor()
desc.body_type = variant
node.load_descriptor(desc)
# If we get here without a crash, all 11 body types were handled
assert_bool(true).is_true()
# =============================================================================
# 7. Skin tone application
# =============================================================================
func test_skin_tone_index_resolves_to_correct_texture_name() -> void:
# The 9 skin tone textures are indexed 08 in this order (per architecture doc):
# 0:pale_cool, 1:pale_warm, 2:light_olive, 3:medium_golden,
# 4:olive_warm, 5:medium_brown, 6:deep_brown, 7:very_deep_warm, 8:very_deep_cool
var node := _make_compositor()
if node == null:
return
if not node.has_method("get_skin_tone_texture_name"):
push_warning("TestCharacterVisualSprint28: get_skin_tone_texture_name not found — stub (awaiting #704)")
return
# Spot-check a few indices
assert_str(node.get_skin_tone_texture_name(0)).override_failure_message(
"Skin tone index 0 must resolve to pale_cool.png"
).is_equal("pale_cool")
assert_str(node.get_skin_tone_texture_name(8)).override_failure_message(
"Skin tone index 8 must resolve to very_deep_cool.png"
).is_equal("very_deep_cool")
func test_skin_tone_textures_exist_on_disk() -> void:
# Verify all 9 expected skin tone PNGs are present (copied from spike in #702)
var expected := [
"pale_cool.png", "pale_warm.png", "light_olive.png", "medium_golden.png",
"olive_warm.png", "medium_brown.png", "deep_brown.png",
"very_deep_warm.png", "very_deep_cool.png",
]
for filename: String in expected:
var path := SKIN_TONES_DIR + filename
assert_bool(ResourceLoader.exists(path)).override_failure_message(
"Skin tone texture missing: %s (should have been copied in #702)" % path
).is_true()
# =============================================================================
# 8. Path construction invariants (pure logic, no assets required)
# =============================================================================
func test_body_type_key_produces_expected_path_fragment() -> void:
# Confirm that CharacterVisualDescriptor.body_type_key() returns the right
# string for use in path construction (this is the bridge between descriptor and compositor)
var d := CharacterVisualDescriptor.new()
d.body_type = CharacterVisualDescriptor.BodyType.MUSCULAR_F
assert_str(d.body_type_key()).is_equal("muscular_f")
func test_all_body_type_keys_match_expected_directory_names() -> void:
# The compositor derives paths as bodies/{body_type_key}/seg_{segment}.glb
# Every key must be lowercase and match the expected directory name convention
var expected := {
CharacterVisualDescriptor.BodyType.THIN_M: "thin_m",
CharacterVisualDescriptor.BodyType.THIN_F: "thin_f",
CharacterVisualDescriptor.BodyType.AVERAGE_M: "average_m",
CharacterVisualDescriptor.BodyType.AVERAGE_F: "average_f",
CharacterVisualDescriptor.BodyType.MUSCULAR_M: "muscular_m",
CharacterVisualDescriptor.BodyType.MUSCULAR_F: "muscular_f",
CharacterVisualDescriptor.BodyType.TEEN_M: "teen_m",
CharacterVisualDescriptor.BodyType.TEEN_F: "teen_f",
CharacterVisualDescriptor.BodyType.HEAVY_M: "heavy_m",
CharacterVisualDescriptor.BodyType.HEAVY_F: "heavy_f",
CharacterVisualDescriptor.BodyType.CHILD: "child",
}
var d := CharacterVisualDescriptor.new()
for body_type: int in expected:
d.body_type = body_type
assert_str(d.body_type_key()).override_failure_message(
"BodyType %d must produce key '%s'" % [body_type, expected[body_type]]
).is_equal(expected[body_type])
+58
View File
@@ -0,0 +1,58 @@
"""
blender_compare_bones.py
Usage: tooling/blender --background --python tooling/blender_compare_bones.py -- <file_a.glb> <file_b.glb>
Compares bone names between two GLB/GLTF files. Reports missing or extra bones.
"""
import sys
import bpy
def get_bones(path: str) -> set:
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=path)
for obj in bpy.context.scene.objects:
if obj.type == 'ARMATURE':
return {b.name for b in obj.data.bones}
return set()
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: tooling/blender --background --python tooling/blender_compare_bones.py -- <a.glb> <b.glb>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 2:
print("ERROR: Need two GLB/GLTF paths.")
sys.exit(1)
path_a, path_b = args[0], args[1]
print(f"Loading A: {path_a}")
bones_a = get_bones(path_a)
print(f"A has {len(bones_a)} bones")
print(f"Loading B: {path_b}")
bones_b = get_bones(path_b)
print(f"B has {len(bones_b)} bones")
only_in_a = bones_a - bones_b
only_in_b = bones_b - bones_a
shared = bones_a & bones_b
print(f"Shared: {len(shared)}")
if only_in_a:
print(f"Only in A ({len(only_in_a)}):")
for name in sorted(only_in_a):
print(f" - {name}")
if only_in_b:
print(f"Only in B ({len(only_in_b)}):")
for name in sorted(only_in_b):
print(f" + {name}")
if not only_in_a and not only_in_b:
print("BONE_MATCH=OK")
else:
print("BONE_MATCH=MISMATCH")
+93
View File
@@ -0,0 +1,93 @@
"""
blender_extract_armature.py
Usage: tooling/blender --background --python tooling/blender_extract_armature.py -- <input.gltf> <output.glb>
Loads a Quaternius GLTF file, strips all mesh objects and animation data,
and exports only the armature as a GLB skeleton file.
Requirements:
- 65-bone hierarchy preserved
- No mesh geometry
- No animation data
- Y-up, -Z forward (glTF default)
- 1 unit = 1 meter (Quaternius convention)
"""
import sys
import bpy
def extract_armature(input_path: str, output_path: str) -> None:
# Clear the default scene
bpy.ops.wm.read_factory_settings(use_empty=True)
# Import the GLTF/GLB
print(f"Loading: {input_path}")
bpy.ops.import_scene.gltf(filepath=input_path)
# Report what was imported
all_objects = list(bpy.context.scene.objects)
print(f"Imported {len(all_objects)} objects:")
for obj in all_objects:
print(f" {obj.name} ({obj.type})")
# Find armature objects
armatures = [obj for obj in all_objects if obj.type == 'ARMATURE']
if not armatures:
print("ERROR: No armature found in the imported file.")
sys.exit(1)
armature = armatures[0]
print(f"Armature: {armature.name}{len(armature.data.bones)} bones")
if len(armature.data.bones) < 60:
print(f"WARNING: Expected ~65 bones, found {len(armature.data.bones)}. Check compatibility.")
# Remove all non-armature objects (meshes, lights, cameras, empties)
bpy.ops.object.select_all(action='DESELECT')
for obj in all_objects:
if obj.type != 'ARMATURE':
obj.select_set(True)
bpy.ops.object.delete()
# Remove all animation data (skeleton only, no poses)
if armature.animation_data:
armature.animation_data_clear()
for action in list(bpy.data.actions):
bpy.data.actions.remove(action)
# Select only the armature for export
bpy.ops.object.select_all(action='DESELECT')
armature.select_set(True)
bpy.context.view_layer.objects.active = armature
# Export as GLB — skeleton only, no animations, no meshes
print(f"Exporting to: {output_path}")
bpy.ops.export_scene.gltf(
filepath=output_path,
use_selection=True,
export_format='GLB',
export_animations=False,
export_skins=True,
export_yup=True,
)
# Verify: report bone count and names
bones = sorted(armature.data.bones, key=lambda b: b.name)
print(f"Export complete. {len(bones)} bones:")
for bone in bones:
print(f" {bone.name}")
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: tooling/blender --background --python tooling/blender_extract_armature.py -- <input.gltf> <output.glb>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 2:
print("ERROR: Provide input GLTF/GLB path and output GLB path.")
sys.exit(1)
extract_armature(args[0], args[1])
+32
View File
@@ -0,0 +1,32 @@
"""
blender_list_animations.py
Usage: tooling/blender --background --python tooling/blender_list_animations.py -- <input.glb>
Lists all animation actions in a GLB file.
"""
import sys
import bpy
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: tooling/blender --background --python tooling/blender_list_animations.py -- <input.glb>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
input_path = args[0]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=input_path)
actions = list(bpy.data.actions)
print("ACTIONS_COUNT=" + str(len(actions)))
for action in sorted(actions, key=lambda a: a.name):
print("ACTION: " + action.name)
# Also report armature bone count if present
for obj in bpy.context.scene.objects:
if obj.type == 'ARMATURE':
print("ARMATURE_BONES=" + str(len(obj.data.bones)))
+10
View File
@@ -0,0 +1,10 @@
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