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
import math
from pathlib import Path
from typing import Literal

import numpy as np

from simvx.core import (
    Camera3D,
    DirectionalLight3D,
    Light3D,
    Material,
    MeshInstance3D,
    Node3D,
    PointLight3D,
    SpotLight3D,
)
from simvx.core.animation.skeletal import BoneTrack, SkeletalAnimationClip, decompose_trs
from simvx.core.graphics.mesh import Mesh
from simvx.core.math.types import Quat
from simvx.core.skeleton import Bone, Skeleton

from .mesh_loader import GLTFMaterial, 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, MeshInstance3D, core light (KHR_lights_punctual -> DirectionalLight3D / PointLight3D / SpotLight3D) or Camera3D. 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] = [_convert_material(gmat) for gmat in scene.materials] # 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. A node's extra primitives (multi-material meshes) # become MeshInstance3D children; keep them so skinning reaches every one. built: dict[int, Node3D] = {} prim_children: dict[int, list[MeshInstance3D]] = {} for idx, gnode in enumerate(scene.nodes): built[idx], prim_children[idx] = _build_node(gnode, scene, materials) # Attach skeletons to skinned nodes (the node itself plus any extra # primitive children: each primitive carries its own skin stream). for idx, gnode in enumerate(scene.nodes): if gnode.skin_index is not None and gnode.skin_index < len(skeletons): for node in (built[idx], *prim_children[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 _convert_material(gmat: GLTFMaterial) -> Material: """Convert a parsed glTF material to a ``simvx.core.Material``. glTF alpha modes map onto ``Material.blend``: OPAQUE -> "opaque", BLEND -> "alpha", MASK -> "cutoff" (with the file's ``alphaCutoff``). Emissive follows the glTF formula ``emissiveFactor * emissiveTexture`` within what the additive shader term supports: a zero factor (the spec default) turns the emissive texture off entirely; a factor without a texture becomes a constant ``emissive_colour`` whose intensity slot carries ``KHR_materials_emissive_strength`` (multiplied in at import). A non-unit factor or strength combined with a texture cannot be expressed (the engine's ``emissive_colour`` is additive, not a multiplier), so the texture is kept unscaled. ``KHR_texture_transform`` maps onto the material-wide ``uv_offset`` / ``uv_scale`` / ``uv_rotation`` trio (one transform for every map, taken from the base-colour textureInfo first). """ blend: Literal["opaque", "alpha", "cutoff"] = "opaque" if gmat.alpha_mode == "BLEND": blend = "alpha" elif gmat.alpha_mode == "MASK": blend = "cutoff" emissive_map = gmat.emissive_texture emissive_colour = None emissive_strength = None if emissive_map is not None and not any(gmat.emissive): emissive_map = None elif emissive_map is None and any(gmat.emissive): emissive_colour = gmat.emissive emissive_strength = gmat.emissive_strength return Material( colour=gmat.albedo, metallic=gmat.metallic, roughness=gmat.roughness, blend=blend, alpha_cutoff=gmat.alpha_cutoff, albedo_map=gmat.albedo_texture, normal_map=gmat.normal_texture, metallic_roughness_map=gmat.metallic_roughness_texture, emissive_map=emissive_map, emissive_colour=emissive_colour, emissive_strength=emissive_strength, ao_map=gmat.ao_texture, double_sided=gmat.double_sided, uv_offset=gmat.uv_offset, uv_scale=gmat.uv_scale, uv_rotation=gmat.uv_rotation, ) def _make_mesh_instance( primitive: tuple[int, int], scene: GLTFScene, materials: list[Material | None], ) -> MeshInstance3D: """Build a MeshInstance3D for one ``(scene_mesh_index, material_index)`` primitive.""" mesh_idx, mat_idx = primitive node = MeshInstance3D() streams, indices = scene.meshes[mesh_idx] extras = scene.mesh_extras[mesh_idx] if mesh_idx < len(scene.mesh_extras) else {} # A glTF primitive may omit NORMAL (e.g. the Khronos Fox); the loader then # fills the shading stream with zeros. Real normals are unit vectors, so an # all-zero stream unambiguously means "absent": pass None so the mesh gets # generated normals downstream (SceneAdapter) instead of shading flat. normals = np.ascontiguousarray(streams.shading["normal"]) if not normals.any(): normals = None mesh = Mesh( positions=streams.positions, indices=np.ascontiguousarray(indices), normals=normals, texcoords=np.ascontiguousarray(streams.shading["uv"]), tangents=extras.get("tangents"), colours=extras.get("colours"), texcoords2=extras.get("texcoords2"), ) # Store the skin stream (joints + weights) for GPU upload if streams.skin is not None: mesh._skin_stream = streams.skin node.mesh = mesh if 0 <= mat_idx < len(materials): node.material = materials[mat_idx] return node def _lookup(index: int | None, items: list[dict]) -> dict | None: """Return ``items[index]``, or None for an absent or out-of-range index.""" if index is None: return None if 0 <= index < len(items): return items[index] log.warning("import_gltf: attachment index %d out of range (%d entries)", index, len(items)) return None # --- KHR_lights_punctual photometric intensity -> engine linear intensity --- # # glTF gives directional lights in lux (lm/m^2) and point/spot lights in candela # (lm/sr), so real files (especially Blender exports) carry values in the # hundreds to tens of thousands and would import blindingly bright against the # engine's non-physical intensity (a plain linear multiplier, default 1.0). # # Blender's exporter builds those photometric values from the lamp's radiometric # energy in its default ("Standard") lighting mode by multiplying by the CIE peak # luminous efficacy 683 lm/W, and point/spot lamps additionally by 1/(4*pi) sr. # We invert exactly that so a lamp round-trips to its Blender-native energy, then # anchor Blender's factory-default lamps onto the engine's nominal intensity 1.0: # * default Sun (1.0 W/m^2 -> 683 lux) -> 1.0 # * default Point (1000 W -> ~54351 cd) -> ~1.0 # * default Spot (1000 W -> ~54351 cd) -> ~1.0 # Artists therefore get predictable, Blender-native numbers back at import. _LUMINOUS_EFFICACY = 683.0 # lm/W, CIE peak at 555 nm; the constant Blender uses _POINT_REFERENCE_WATTS = 1000.0 # Blender's default point/spot lamp power _LUX_TO_INTENSITY = 1.0 / _LUMINOUS_EFFICACY _CANDELA_TO_INTENSITY = (4.0 * math.pi) / (_LUMINOUS_EFFICACY * _POINT_REFERENCE_WATTS) def _make_light(light: dict) -> Light3D: """Build a core light node from a normalised KHR_lights_punctual dict. glTF cone angles are half-angles in RADIANS; core ``SpotLight3D`` cones are half-angles in DEGREES. glTF ``range`` maps to ``Light3D.range`` when given; an absent range (spec: unbounded) keeps the engine default. Lights emit along the node's -Z, which is exactly ``Node3D.forward``, so the node transform needs no adjustment. glTF light intensity is photometric (lux for directional, candela for point/spot); it is converted to the engine's linear intensity via the ``_*_TO_INTENSITY`` factors above so Blender-default lamps import near 1.0. """ kind = light["type"] node: Light3D if kind == "directional": node = DirectionalLight3D() intensity_scale = _LUX_TO_INTENSITY elif kind == "spot": spot = SpotLight3D( inner_cone=math.degrees(light["inner_cone_angle"]), outer_cone=math.degrees(light["outer_cone_angle"]), ) if light["range"] is not None: spot.range = light["range"] node = spot intensity_scale = _CANDELA_TO_INTENSITY else: # "point" per spec; unknown types degrade to a point light if kind != "point": log.warning("import_gltf: unknown KHR_lights_punctual type %r, importing as point light", kind) point = PointLight3D() if light["range"] is not None: point.range = light["range"] node = point intensity_scale = _CANDELA_TO_INTENSITY node.colour = light["colour"] node.intensity = light["intensity"] * intensity_scale return node def _make_camera(cam: dict) -> Camera3D: """Build a ``Camera3D`` from a normalised glTF camera dict. glTF ``yfov`` is the vertical field of view in RADIANS; ``Camera3D.fov`` is vertical DEGREES (Property-clamped to its 1-179 range). ``znear`` / ``zfar`` map to ``near`` / ``far``; an absent ``zfar`` (spec: infinite) keeps the engine default. """ node = Camera3D() if cam["type"] != "perspective": log.warning( "import_gltf: %s camera %r imported with default perspective settings", cam["type"], cam["name"], ) return node if cam["yfov"] is not None: node.fov = math.degrees(cam["yfov"]) if cam["znear"] is not None: node.near = cam["znear"] if cam["zfar"] is not None: node.far = cam["zfar"] return node def _build_node( gnode: GLTFNode, scene: GLTFScene, materials: list[Material | None], ) -> tuple[Node3D, list[MeshInstance3D]]: """Build a single SimVX node from glTF node data. The first primitive becomes the node itself (a single-primitive mesh imports as one ``MeshInstance3D``, unchanged); every further primitive (multi-material meshes) becomes a ``MeshInstance3D`` child with an identity local transform. Returns ``(node, extra_primitive_children)``. A meshless node carrying a KHR_lights_punctual light becomes the matching core light node; one carrying a camera becomes a ``Camera3D``. When a node combines attachments (spec-legal, rare), the mesh wins the node itself and the light/camera become identity-transform children so nothing is dropped. """ light = _lookup(gnode.light_index, scene.lights) cam = _lookup(gnode.camera_index, scene.cameras) prim_nodes: list[MeshInstance3D] = [] if gnode.primitives: node: Node3D = _make_mesh_instance(gnode.primitives[0], scene, materials) base_name = gnode.name or "Node" for i, primitive in enumerate(gnode.primitives[1:], start=1): child = _make_mesh_instance(primitive, scene, materials) child.name = f"{base_name}_prim{i}" node.add_child(child) prim_nodes.append(child) elif light is not None: node = _make_light(light) light = None elif cam is not None: node = _make_camera(cam) cam = None else: node = Node3D() node.name = gnode.name or "Node" # Leftover attachments on a combined node ride along as children. if light is not None: light_child = _make_light(light) light_child.name = f"{node.name}_light" node.add_child(light_child) if cam is not None: cam_child = _make_camera(cam) cam_child.name = f"{node.name}_camera" node.add_child(cam_child) # Apply transform: decompose the node matrix into translation, rotation # (quaternion) and scale. ``gnode.transform`` uses the same # translation-in-last-column convention ``decompose_trs`` inverts. t, r_xyzw, s = decompose_trs(gnode.transform) node.position = (float(t[0]), float(t[1]), float(t[2])) node.rotation = Quat(float(r_xyzw[3]), float(r_xyzw[0]), float(r_xyzw[1]), float(r_xyzw[2])) if all(float(v) > 0.001 for v in s): node.scale = (float(s[0]), float(s[1]), float(s[2])) return node, prim_nodes 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. glTF stores mat4 column-major; the engine's # skeleton composes joint matrices row-major (world @ inverse_bind), # so transpose into row-major here. Without this the bind pose is # corrupted the moment compute_pose() runs (skinned animation only). if ibm_data is not None and bone_idx < len(ibm_data): bone.inverse_bind_matrix = ibm_data[bone_idx].reshape(4, 4).T.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