WIP checkpoint: fix static registry, uint32 type, GLTF API calls, ARRAY_BONES, missing slot handler

- avatar_slot_registry.gd: _slot_data/_slot_definitions made static (every
  caller uses AvatarSlotRegistry.method() on the class, never an instance,
  so the old instance _init() populating them was never invoked); moved
  registry build to static func _static_init()
- avatar_catalog.gd: compute_loadout_hash() return type uint32 -> int
  (GDScript has no fixed-width uint type)
- avatar_loader.gd: GLTFDocument.parse() (doesn't exist) -> the real
  append_from_buffer()+generate_scene() API; Error.parse_error() (doesn't
  exist) -> error_string(); Mesh.ARRAY_JOINTS (doesn't exist) ->
  Mesh.ARRAY_BONES; removed a dead 'mesh is MultiMesh' check that can
  never be true (MeshInstance3D.mesh is always Mesh, never MultiMesh);
  fixed _merge_node_into_avatar to recurse into a Skeleton3D's children
  (skinned meshes are commonly children of the skeleton in a GLB export,
  the old code returned before ever reaching them)
- avatar_customization_ui.gd: added the missing _on_slot_button_pressed
  handler (referenced by a button connect, never defined -- just calls
  the existing _select_slot())

Verified: godot --headless --import + --quit is fully clean, and the
avatar_customization.tscn scene now instantiates successfully.

Remaining, scoped separately: avatar_visualizer.gd calls a
AvatarLoader.load_slot() static async API and SlotItem/SlotResult types
that don't exist -- AvatarLoader's real API (matching the project's own
avatar.md spec: one canonical skeleton, attachments retargeted onto it)
is instance-based load_base_body()/load_attachment(). AvatarVisualizer
needs rewriting to use that real API; it's the one out of step with spec,
not AvatarLoader.
This commit is contained in:
2026-08-18 14:47:08 +00:00
parent 803405aba4
commit dcaaf96d81
4 changed files with 46 additions and 35 deletions
+3 -2
View File
@@ -156,8 +156,9 @@ func validate_loadout(loadout: AvatarLoadout) -> Dictionary:
## Compute a hash of the loadout for change detection. ## Compute a hash of the loadout for change detection.
## Returns a uint32 hash. ## Returns a 32-bit hash value (GDScript has no fixed-width uint type — int
static func compute_loadout_hash(loadout: AvatarLoadout) -> uint32: ## is 64-bit signed, the value is just constrained to 32 bits of range).
static func compute_loadout_hash(loadout: AvatarLoadout) -> int:
var hasher = HashingContext.new() var hasher = HashingContext.new()
hasher.start(HashingContext.HASH_SHA256) hasher.start(HashingContext.HASH_SHA256)
@@ -326,6 +326,11 @@ func _populate_items() -> void:
# Slot selection # Slot selection
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
## Handle a slot button press.
func _on_slot_button_pressed(slot_id: String) -> void:
_select_slot(slot_id)
## Select a slot and refresh the item list. ## Select a slot and refresh the item list.
func _select_slot(slot_id: String) -> void: func _select_slot(slot_id: String) -> void:
_selected_slot = slot_id _selected_slot = slot_id
+30 -28
View File
@@ -305,28 +305,26 @@ func _parse_glb_as_avatar(glb_data: PackedByteArray, path: String) -> Node3D:
avatar.add_child(attachments_container) avatar.add_child(attachments_container)
# Parse the GLB and extract skeleton + meshes. # Parse the GLB and extract skeleton + meshes.
# Godot's GLTFState allows custom parsing.
var gltf_document := GLTFDocument.new() var gltf_document := GLTFDocument.new()
var gltf_state := GLTFState.new() var gltf_state := GLTFState.new()
var parse_result := gltf_document.parse(glb_data, gltf_state, "", "") var parse_result := gltf_document.append_from_buffer(glb_data, "", gltf_state)
if parse_result != OK: if parse_result != OK:
push_error("GLB parse error: %s" % Error.parse_error(parse_result)) push_error("GLB parse error: %s" % error_string(parse_result))
avatar.queue_free() avatar.queue_free()
return null return null
# Export the scene from GLTFState. # Generate the actual scene tree from the parsed GLTF state.
var root_nodes := gltf_state.get_nodes_as_resources() var scene_root := gltf_document.generate_scene(gltf_state)
if root_nodes.is_empty(): if scene_root == null:
push_error("No root nodes in GLB: %s" % path) push_error("Failed to generate scene from GLB: %s" % path)
avatar.queue_free() avatar.queue_free()
return null return null
# Merge GLB nodes into our avatar structure. # Merge GLB nodes into our avatar structure (recurses into children).
for node_resource in root_nodes: if scene_root is Node3D:
if node_resource is Node3D: _merge_node_into_avatar(scene_root, avatar, body_meshes,
_merge_node_into_avatar(node_resource, avatar, body_meshes, attach_points, attachments_container)
attach_points, attachments_container)
return avatar return avatar
@@ -338,22 +336,21 @@ func _parse_glb_as_attachment(glb_data: PackedByteArray, path: String,
var gltf_document := GLTFDocument.new() var gltf_document := GLTFDocument.new()
var gltf_state := GLTFState.new() var gltf_state := GLTFState.new()
var parse_result := gltf_document.parse(glb_data, gltf_state, "", "") var parse_result := gltf_document.append_from_buffer(glb_data, "", gltf_state)
if parse_result != OK: if parse_result != OK:
push_error("GLB parse error: %s" % Error.parse_error(parse_result)) push_error("GLB parse error: %s" % error_string(parse_result))
return null return null
var root_nodes := gltf_state.get_nodes_as_resources() var scene_root := gltf_document.generate_scene(gltf_state)
if root_nodes.is_empty(): if scene_root == null:
push_error("No root nodes in attachment GLB: %s" % path) push_error("Failed to generate scene from attachment GLB: %s" % path)
return null return null
var attachment := Node3D.new() var attachment := Node3D.new()
attachment.name = "%s_%s" % [slot_id, path.get_file().get_basename()] attachment.name = "%s_%s" % [slot_id, path.get_file().get_basename()]
for node_resource in root_nodes: if scene_root is Node3D:
if node_resource is Node3D: attachment.add_child(scene_root.duplicate())
attachment.add_child(node_resource.duplicate())
return attachment return attachment
@@ -392,6 +389,13 @@ func _merge_node_into_avatar(node: Node3D, avatar: Node3D,
var dst_parent_idx := dst_skel.find_bone(parent_name) var dst_parent_idx := dst_skel.find_bone(parent_name)
if dst_parent_idx >= 0: if dst_parent_idx >= 0:
dst_skel.set_bone_parent(new_idx, dst_parent_idx) dst_skel.set_bone_parent(new_idx, dst_parent_idx)
# Skinned meshes are commonly children of the skeleton node in a GLB
# export, not siblings — fall through to the recursion below instead
# of returning, so they still get merged in.
for child in src_skel.get_children():
if child is Node3D:
_merge_node_into_avatar(child, avatar, body_meshes,
attach_points, attachments_container)
return return
# Identify attachment point nodes (empty nodes named attach_*). # Identify attachment point nodes (empty nodes named attach_*).
@@ -468,18 +472,16 @@ func _retarget_skin(attachment_node: Node3D) -> void:
if mesh == null: if mesh == null:
continue continue
# Handle MultiMeshInstance3D. # Note: MeshInstance3D.mesh is always a Mesh, never a MultiMesh —
if mesh is MultiMesh: # MultiMesh skinning goes through MultiMeshInstance3D.multimesh,
# MultiMesh doesn't have skin weights per-vertex in the same way. # a completely separate node/property, not reachable here.
continue
if mesh is ArrayMesh: if mesh is ArrayMesh:
var arr_mesh := mesh as ArrayMesh var arr_mesh := mesh as ArrayMesh
var surface_count := arr_mesh.get_surface_count() var surface_count := arr_mesh.get_surface_count()
for surface_idx in surface_count: for surface_idx in surface_count:
var arrays := arr_mesh.surface_get_arrays(surface_idx) var arrays := arr_mesh.surface_get_arrays(surface_idx)
var joints_array := arrays[Mesh.ARRAY_JOINTS] as PackedInt32Array var joints_array := arrays[Mesh.ARRAY_BONES] as PackedInt32Array
var weights_array := arrays[Mesh.ARRAY_WEIGHTS] as PackedFloat32Array var weights_array := arrays[Mesh.ARRAY_WEIGHTS] as PackedFloat32Array
if joints_array.is_empty(): if joints_array.is_empty():
@@ -494,10 +496,10 @@ func _retarget_skin(attachment_node: Node3D) -> void:
new_joints.append(0) new_joints.append(0)
else: else:
var bone_name := skeleton.get_bone_name(old_joint) var bone_name := skeleton.get_bone_name(old_joint)
var new_idx := _bone_index_map.get(bone_name, 0) var new_idx: int = _bone_index_map.get(bone_name, 0)
new_joints.append(new_idx) new_joints.append(new_idx)
arrays[Mesh.ARRAY_JOINTS] = new_joints arrays[Mesh.ARRAY_BONES] = new_joints
arr_mesh.surface_set_arrays(surface_idx, arrays) arr_mesh.surface_set_arrays(surface_idx, arrays)
+8 -5
View File
@@ -69,17 +69,20 @@ class SlotDefinition:
is_body_part = p_is_body_part is_body_part = p_is_body_part
# Slot metadata lookup tables (indexed by SLOT_IDS position). # Slot metadata lookup tables (indexed by SLOT_IDS position). Static: every
# caller uses AvatarSlotRegistry.method(...) directly on the class, never on
# an instance, so this data has to be built once at class-load time via
# _static_init() rather than an instance _init() that's never invoked.
# Each entry: { category, mount_bones, exclusive, is_body_part } # Each entry: { category, mount_bones, exclusive, is_body_part }
var _slot_data: Dictionary = {} static var _slot_data: Dictionary = {}
var _slot_definitions: Array[SlotDefinition] = [] static var _slot_definitions: Array[SlotDefinition] = []
func _init() -> void: static func _static_init() -> void:
_build_registry() _build_registry()
func _build_registry() -> void: static func _build_registry() -> void:
_slot_data = { _slot_data = {
"head": { "head": {
"category": "body_part", "category": "body_part",