Source code for simvx.graphics.assets.scene_import

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