Source code for simvx.graphics.assets.scene_import

"""Import glTF scenes into SimVX node tree."""

from __future__ import annotations

import logging

import numpy as np

from simvx.core import Material, MeshInstance3D, Node3D
from simvx.core.animation.skeletal import BoneTrack, SkeletalAnimationClip
from simvx.core.graphics.mesh import Mesh
from simvx.core.skeleton import Bone, Skeleton

from .mesh_loader import GLTFNode, GLTFScene, load_gltf

__all__ = ["import_gltf"]

log = logging.getLogger(__name__)


[docs] def import_gltf(file_path: str) -> Node3D: """Load a glTF file and return a Node3D hierarchy ready for the scene tree. Each glTF node becomes a Node3D or MeshInstance3D. Materials are converted to simvx.core.Material with PBR texture URIs set for the Vulkan backend to load via TextureManager. Skeleton and animations are attached to nodes that reference glTF skins. """ scene = load_gltf(file_path) # Convert materials materials: list[Material | None] = [] for gmat in scene.materials: mat = Material( colour=gmat.albedo, metallic=gmat.metallic, roughness=gmat.roughness, albedo_map=gmat.albedo_texture, normal_map=gmat.normal_texture, metallic_roughness_map=gmat.metallic_roughness_texture, emissive_map=gmat.emissive_texture, ao_map=gmat.ao_texture, double_sided=gmat.double_sided, ) materials.append(mat) # Build skeletons from glTF skins skeletons: list[Skeleton] = [] for skin_data in scene.skins: skeleton = _build_skeleton(skin_data, scene) skeletons.append(skeleton) # Build node hierarchy built: dict[int, Node3D] = {} for idx, gnode in enumerate(scene.nodes): built[idx] = _build_node(gnode, scene, materials) # Attach skeletons to skinned nodes for idx, gnode in enumerate(scene.nodes): if gnode.skin_index is not None and gnode.skin_index < len(skeletons): node = built[idx] node.skeleton = skeletons[gnode.skin_index] node._is_skinned = True # Wire parent-child relationships for idx, gnode in enumerate(scene.nodes): for child_idx in gnode.children: if child_idx in built: built[idx].add_child(built[child_idx]) # Create root if len(scene.root_nodes) == 1: root = built[scene.root_nodes[0]] else: root = Node3D() root.name = "GLTFRoot" for ri in scene.root_nodes: if ri in built: root.add_child(built[ri]) # Import animations from glTF data animations = _import_animations(scene) if animations: root._skeletal_clips = animations log.debug( "Imported glTF: %d nodes, %d meshes, %d skeletons, %d animations", len(scene.nodes), len(scene.meshes), len(skeletons), len(animations), ) return root
def _build_node( gnode: GLTFNode, scene: GLTFScene, materials: list[Material | None], ) -> Node3D: """Build a single SimVX node from glTF node data.""" if gnode.mesh_index is not None: node = MeshInstance3D() verts, indices = scene.meshes[gnode.mesh_index] mesh = Mesh( positions=np.ascontiguousarray(verts["position"]), indices=np.ascontiguousarray(indices), normals=np.ascontiguousarray(verts["normal"]), texcoords=np.ascontiguousarray(verts["uv"]), ) # Store skinned vertex data for GPU upload if "joints" in verts.dtype.names: mesh._skinned_vertices = verts node.mesh = mesh # First material from primitive if gnode.material_indices: mat_idx = gnode.material_indices[0] if 0 <= mat_idx < len(materials): node.material = materials[mat_idx] else: node = Node3D() node.name = gnode.name or "Node" # Apply transform — extract TRS from matrix mat = gnode.transform # Translation node.position = (float(mat[0, 3]), float(mat[1, 3]), float(mat[2, 3])) # Scale (from column magnitudes of 3x3) sx = float(np.linalg.norm(mat[:3, 0])) sy = float(np.linalg.norm(mat[:3, 1])) sz = float(np.linalg.norm(mat[:3, 2])) if sx > 0.001 and sy > 0.001 and sz > 0.001: node.scale = (sx, sy, sz) return node def _build_skeleton(skin_data: dict, scene: GLTFScene) -> Skeleton: """Build Skeleton from glTF skin data.""" joint_indices = skin_data.get("joints", []) ibm_data = skin_data.get("inverse_bind_matrices") bones = [] # Map glTF node index → bone index joint_to_bone: dict[int, int] = {} for bone_idx, node_idx in enumerate(joint_indices): joint_to_bone[node_idx] = bone_idx for bone_idx, node_idx in enumerate(joint_indices): gnode = scene.nodes[node_idx] if node_idx < len(scene.nodes) else None bone = Bone() bone.name = gnode.name if gnode else f"bone_{bone_idx}" # Inverse bind matrix if ibm_data is not None and bone_idx < len(ibm_data): bone.inverse_bind_matrix = ibm_data[bone_idx].reshape(4, 4).astype(np.float32) # Local transform from the node if gnode: bone.local_transform = gnode.transform.copy() # Parent: find which joint node is parent of this joint node bone.parent_index = -1 if gnode: for other_idx in joint_indices: other_node = scene.nodes[other_idx] if other_idx < len(scene.nodes) else None if other_node and node_idx in other_node.children: bone.parent_index = joint_to_bone.get(other_idx, -1) break bones.append(bone) return Skeleton(bones) def _import_animations(scene: GLTFScene) -> list[SkeletalAnimationClip]: """Import glTF animations as SkeletalAnimationClips. Requires the raw glTF data to still be accessible via scene metadata. For now, returns empty list — animations are imported via the glTF loader when raw animation data is available. """ # Animation data is extracted during load_gltf if animations exist animations = getattr(scene, "animations", []) clips = [] for anim_data in animations: clip = SkeletalAnimationClip( name=anim_data.get("name", ""), duration=anim_data.get("duration", 0.0), ) for track_data in anim_data.get("tracks", []): track = BoneTrack(bone_index=track_data["bone_index"]) track.position_keys = track_data.get("position_keys", []) track.rotation_keys = track_data.get("rotation_keys", []) track.scale_keys = track_data.get("scale_keys", []) clip.add_bone_track(track) clips.append(clip) return clips