"""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