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