diff --git a/client/scripts/rendering/character_visual.gd b/client/scripts/rendering/character_visual.gd new file mode 100644 index 000000000..d21144d3a --- /dev/null +++ b/client/scripts/rendering/character_visual.gd @@ -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 0–8 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 diff --git a/client/scripts/rendering/character_visual_descriptor.gd b/client/scripts/rendering/character_visual_descriptor.gd new file mode 100644 index 000000000..de5c84da7 --- /dev/null +++ b/client/scripts/rendering/character_visual_descriptor.gd @@ -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 0–8 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 diff --git a/client/tests/test_character_visual_descriptor_sprint28.gd b/client/tests/test_character_visual_descriptor_sprint28.gd new file mode 100644 index 000000000..04351a615 --- /dev/null +++ b/client/tests/test_character_visual_descriptor_sprint28.gd @@ -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 0–8 + 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") diff --git a/client/tests/test_character_visual_sprint28.gd b/client/tests/test_character_visual_sprint28.gd new file mode 100644 index 000000000..169fcfb78 --- /dev/null +++ b/client/tests/test_character_visual_sprint28.gd @@ -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 0–8 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]) diff --git a/tooling/blender_compare_bones.py b/tooling/blender_compare_bones.py new file mode 100644 index 000000000..188958a9c --- /dev/null +++ b/tooling/blender_compare_bones.py @@ -0,0 +1,58 @@ +""" +blender_compare_bones.py +Usage: tooling/blender --background --python tooling/blender_compare_bones.py -- + +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 -- ") + 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") diff --git a/tooling/blender_extract_armature.py b/tooling/blender_extract_armature.py new file mode 100644 index 000000000..ba52345ba --- /dev/null +++ b/tooling/blender_extract_armature.py @@ -0,0 +1,93 @@ +""" +blender_extract_armature.py +Usage: tooling/blender --background --python tooling/blender_extract_armature.py -- + +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 -- ") + 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]) diff --git a/tooling/blender_list_animations.py b/tooling/blender_list_animations.py new file mode 100644 index 000000000..28b0294d0 --- /dev/null +++ b/tooling/blender_list_animations.py @@ -0,0 +1,32 @@ +""" +blender_list_animations.py +Usage: tooling/blender --background --python tooling/blender_list_animations.py -- + +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 -- ") + 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))) diff --git a/tooling/blender_list_bones.py b/tooling/blender_list_bones.py new file mode 100644 index 000000000..6fad939df --- /dev/null +++ b/tooling/blender_list_bones.py @@ -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