"""Import glTF scenes into SimVX node tree.
Backend-agnostic: produces ``Node3D`` / ``MeshInstance3D`` trees with
``simvx.core.Material`` objects whose texture URIs (or embedded bytes) are
resolved lazily by whichever ``TextureManager`` the active backend owns
(Vulkan on desktop, WebRenderer on Pyodide). A single ``import_gltf``
call therefore works identically in both runtimes — no backend probe
needed at the import level.
"""
import logging
from pathlib import Path
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 so the active backend
(Vulkan ``Engine`` or web ``WebRenderer``) can resolve them through the
shared ``TextureManager``.
Returns an empty ``Node3D`` if the file is missing or the parser cannot
read it — callers get a usable (if bare) scene root rather than a crash,
matching the engine's "errors logged, normal operation silent" rule.
Skeleton and animations are attached to nodes that reference glTF skins.
"""
if not Path(file_path).exists():
log.warning("import_gltf: file not found: %s", file_path)
empty = Node3D()
empty.name = "GLTFRoot (missing)"
return empty
try:
scene = load_gltf(file_path)
except Exception:
log.exception("import_gltf: failed to parse %s", file_path)
empty = Node3D()
empty.name = "GLTFRoot (parse error)"
return empty
# 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