"""Full glTF scene loading: meshes, materials, textures, node hierarchy.
Pure-Python parsing step: produces a ``GLTFScene`` with per-stream vertex
data (:class:`~simvx.graphics.types.VertexStreams`), index arrays, and
texture URIs (str) or embedded bytes. Backends (Vulkan desktop,
WebRenderer) consume the same data through ``TextureManager.resolve``.
Two parser backends:
* ``pygltflib``: full glTF 2.0 support (default when available).
* Pure-stdlib fallback: handles the common ``.gltf`` JSON + external
``.bin`` + image files subset (POSITION, NORMAL, TANGENT, TEXCOORD_0,
TEXCOORD_1, COLOR_0, indices, base-colour texture, KHR_lights_punctual,
KHR_texture_transform, KHR_materials_emissive_strength, cameras). Used
when pygltflib isn't installed (notably inside Pyodide / web exports).
Advanced features (other glTF 2.0 extensions, embedded .glb binaries)
require pygltflib.
"""
import base64
import json
import logging
import math
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import numpy as np
from simvx.graphics.types import VertexStreams
try: # pragma: no cover - availability depends on runtime (Pyodide lacks it)
from pygltflib import GLTF2 as _GLTF2
_HAS_PYGLTFLIB = True
except ImportError: # pragma: no cover
_GLTF2 = None
_HAS_PYGLTFLIB = False
__all__ = ["load_gltf", "GLTFScene", "GLTFMaterial", "GLTFNode"]
log = logging.getLogger(__name__)
# glTF component type → numpy dtype
_COMPONENT_DTYPES = {
5120: np.int8,
5121: np.uint8,
5122: np.int16,
5123: np.uint16,
5125: np.uint32,
5126: np.float32,
}
# glTF accessor type → component count
_TYPE_SIZES = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}
def _unorm_to_float(arr: np.ndarray) -> np.ndarray:
"""Convert a normalised-integer glTF accessor array to float32.
COLOR_0 / TEXCOORD_n accessors come as float32 or as normalised
uint8/uint16; the integer forms map to [0, 1] by dividing by the
type maximum (glTF 2.0 spec, section 3.6.2.2).
"""
if arr.dtype == np.uint8:
return arr.astype(np.float32) / 255.0
if arr.dtype == np.uint16:
return arr.astype(np.float32) / 65535.0
return arr.astype(np.float32)
def _expand_colours(colours: np.ndarray) -> np.ndarray:
"""Expand a VEC3 COLOR_0 array to VEC4 with opaque alpha."""
if colours.shape[1] == 3:
alpha = np.ones((len(colours), 1), dtype=np.float32)
colours = np.concatenate([colours, alpha], axis=1)
return colours
def _parse_khr_lights(ext: dict | None) -> list[dict[str, Any]]:
"""Normalise a root ``KHR_lights_punctual`` extension block into light dicts.
Spec defaults applied: color [1,1,1], intensity 1.0, range absent
(=unbounded, kept as None), spot innerConeAngle 0.0 /
outerConeAngle pi/4. Cone angles stay in RADIANS here; the
degree conversion happens at node build time (scene_import).
"""
lights: list[dict[str, Any]] = []
for gl in (ext or {}).get("lights", []):
c = gl.get("color", (1.0, 1.0, 1.0))
spot = gl.get("spot", {})
lights.append(
{
"name": gl.get("name", ""),
"type": gl.get("type", "point"),
"colour": (float(c[0]), float(c[1]), float(c[2])),
"intensity": float(gl.get("intensity", 1.0)),
"range": float(gl["range"]) if "range" in gl else None,
"inner_cone_angle": float(spot.get("innerConeAngle", 0.0)),
"outer_cone_angle": float(spot.get("outerConeAngle", math.pi / 4.0)),
}
)
return lights
def _first_texture_transform(texture_infos: list[Any]) -> dict[str, Any] | None:
"""Return the first ``KHR_texture_transform`` block among textureInfos.
``texture_infos`` holds pygltflib TextureInfo objects or plain glTF JSON
dicts, in priority order (base colour first: it is the authoritative UV
set for the engine's single per-material transform). ``None`` entries and
textureInfos without the extension are skipped.
"""
for info in texture_infos:
if info is None:
continue
ext = info.get("extensions") if isinstance(info, dict) else getattr(info, "extensions", None)
tt = (ext or {}).get("KHR_texture_transform")
if isinstance(tt, dict):
return tt
return None
def _apply_texture_transform(mat: GLTFMaterial, tt: dict[str, Any] | None) -> None:
"""Copy a ``KHR_texture_transform`` block onto a GLTFMaterial (spec defaults)."""
if tt is None:
return
o = tt.get("offset", (0.0, 0.0))
s = tt.get("scale", (1.0, 1.0))
mat.uv_offset = (float(o[0]), float(o[1]))
mat.uv_scale = (float(s[0]), float(s[1]))
mat.uv_rotation = float(tt.get("rotation", 0.0))
def _camera_dict(
name: str | None,
ctype: str | None,
yfov: float | None,
znear: float | None,
zfar: float | None,
) -> dict[str, Any]:
"""Normalise one glTF camera into the ``GLTFScene.cameras`` dict shape."""
return {
"name": name or "",
"type": ctype or "perspective",
"yfov": None if yfov is None else float(yfov),
"znear": None if znear is None else float(znear),
"zfar": None if zfar is None else float(zfar),
}
[docs]
@dataclass
class GLTFMaterial:
"""Extracted PBR metallic-roughness material."""
name: str = ""
albedo: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0)
metallic: float = 1.0
roughness: float = 1.0
albedo_texture: str | bytes | None = None
normal_texture: str | bytes | None = None
metallic_roughness_texture: str | bytes | None = None
emissive_texture: str | bytes | None = None
ao_texture: str | bytes | None = None
double_sided: bool = False
alpha_mode: str = "OPAQUE"
alpha_cutoff: float = 0.5
emissive: tuple[float, float, float] = (0.0, 0.0, 0.0)
# KHR_materials_emissive_strength: scalar multiplier on the emissive RGB.
emissive_strength: float = 1.0
# KHR_texture_transform (single per-material transform, applied to every
# map; taken from the base-colour textureInfo first): identity defaults.
uv_offset: tuple[float, float] = (0.0, 0.0)
uv_scale: tuple[float, float] = (1.0, 1.0)
uv_rotation: float = 0.0
[docs]
@dataclass
class GLTFNode:
"""Scene graph node with optional mesh reference.
``primitives`` carries every glTF mesh primitive attached to this node as
``(scene_mesh_index, material_index)`` pairs (indices into
``GLTFScene.meshes`` / ``GLTFScene.materials``; material index -1 means
none). Empty for meshless nodes.
"""
name: str = ""
primitives: list[tuple[int, int]] = field(default_factory=list)
transform: np.ndarray = field(default_factory=lambda: np.eye(4, dtype=np.float32))
children: list[int] = field(default_factory=list)
# Skinning: this is the parse step. Application (skeleton attach +
# Mesh._skin_stream + GPU skinned.vert) happens in
# simvx.graphics.assets.scene_import._import_scene / _build_node.
skin_index: int | None = None
# KHR_lights_punctual light / camera attachments (indices into
# ``GLTFScene.lights`` / ``GLTFScene.cameras``). Node creation happens
# in scene_import._build_node.
light_index: int | None = None
camera_index: int | None = None
[docs]
@dataclass
class GLTFScene:
"""Complete loaded glTF scene."""
meshes: list[tuple[VertexStreams, np.ndarray]] = field(default_factory=list) # (streams, indices)
materials: list[GLTFMaterial] = field(default_factory=list)
nodes: list[GLTFNode] = field(default_factory=list)
root_nodes: list[int] = field(default_factory=list)
# Skinning data
skins: list[dict[str, Any]] = field(default_factory=list)
# Animation data
animations: list[dict[str, Any]] = field(default_factory=list)
# KHR_lights_punctual lights, normalised by ``_parse_khr_lights`` (keys:
# "name", "type", "colour", "intensity", "range", "inner_cone_angle",
# "outer_cone_angle"; angles in RADIANS as in the file).
lights: list[dict[str, Any]] = field(default_factory=list)
# Cameras (keys: "name", "type", "yfov" (RADIANS), "znear", "zfar";
# absent optionals are None).
cameras: list[dict[str, Any]] = field(default_factory=list)
# Extra per-primitive vertex attributes, aligned index-for-index with
# ``meshes``. Kept as a per-field dict (not folded into the streams'
# extras stream) so per-attribute PRESENCE survives into ``Mesh``
# (``has_tangents`` etc. gate pipeline variants). Keys when present:
# "tangents" (N, 4 float32), "colours" (N, 4 float32 in [0, 1]),
# "texcoords2" (N, 2 float32). Empty dict when a primitive declares
# none of them.
mesh_extras: list[dict[str, np.ndarray]] = field(default_factory=list)
[docs]
def load_gltf(file_path: str) -> GLTFScene:
"""Load complete glTF scene with all meshes, materials, textures, and hierarchy.
Returns a GLTFScene with all data extracted and ready for import.
Dispatches to pygltflib when available, falling back to a stdlib parser
suitable for simple ``.gltf`` + ``.bin`` + image bundles (Pyodide / web).
"""
if _HAS_PYGLTFLIB:
return _load_gltf_pygltflib(file_path)
log.info("pygltflib not available: falling back to stdlib glTF parser")
return _load_gltf_stdlib(file_path)
# ---------------------------------------------------------------------------
# pygltflib backend (full support: animations, skinning, .glb binaries)
# ---------------------------------------------------------------------------
def _load_gltf_pygltflib(file_path: str) -> GLTFScene:
path = Path(file_path)
gltf = _GLTF2().load(str(path))
base_dir = path.parent
scene = GLTFScene()
# --- Materials ---
for gmat in gltf.materials or []:
mat = GLTFMaterial(name=gmat.name or "")
pbr = gmat.pbrMetallicRoughness
if pbr:
bc = pbr.baseColorFactor or [1, 1, 1, 1]
mat.albedo = tuple(bc[:4])
mat.metallic = pbr.metallicFactor if pbr.metallicFactor is not None else 1.0
mat.roughness = pbr.roughnessFactor if pbr.roughnessFactor is not None else 1.0
if pbr.baseColorTexture is not None:
mat.albedo_texture = _resolve_texture(gltf, pbr.baseColorTexture.index, base_dir)
if pbr.metallicRoughnessTexture is not None:
mat.metallic_roughness_texture = _resolve_texture(
gltf,
pbr.metallicRoughnessTexture.index,
base_dir,
)
if gmat.normalTexture is not None:
mat.normal_texture = _resolve_texture(gltf, gmat.normalTexture.index, base_dir)
if gmat.emissiveTexture is not None:
mat.emissive_texture = _resolve_texture(gltf, gmat.emissiveTexture.index, base_dir)
if gmat.occlusionTexture is not None:
mat.ao_texture = _resolve_texture(gltf, gmat.occlusionTexture.index, base_dir)
mat.double_sided = gmat.doubleSided or False
mat.alpha_mode = gmat.alphaMode or "OPAQUE"
if gmat.alphaCutoff is not None:
mat.alpha_cutoff = float(gmat.alphaCutoff)
ef = gmat.emissiveFactor
if ef and len(ef) >= 3:
mat.emissive = (float(ef[0]), float(ef[1]), float(ef[2]))
exts = gmat.extensions or {}
strength = exts.get("KHR_materials_emissive_strength", {}).get("emissiveStrength")
if strength is not None:
mat.emissive_strength = float(strength)
_apply_texture_transform(
mat,
_first_texture_transform(
[
pbr.baseColorTexture if pbr else None,
gmat.normalTexture,
pbr.metallicRoughnessTexture if pbr else None,
gmat.emissiveTexture,
gmat.occlusionTexture,
]
),
)
scene.materials.append(mat)
# --- Meshes (each primitive becomes a separate entry) ---
# mesh_prim_map[mesh_idx] = list of (scene_mesh_idx, material_idx)
mesh_prim_map: dict[int, list[tuple[int, int]]] = {}
for mesh_idx, gmesh in enumerate(gltf.meshes or []):
prims = []
for prim in gmesh.primitives:
streams, indices, extras = _extract_primitive(gltf, prim)
scene_mesh_idx = len(scene.meshes)
scene.meshes.append((streams, indices))
scene.mesh_extras.append(extras)
mat_idx = prim.material if prim.material is not None else -1
prims.append((scene_mesh_idx, mat_idx))
mesh_prim_map[mesh_idx] = prims
# --- Lights (KHR_lights_punctual) + cameras ---
scene.lights = _parse_khr_lights((gltf.extensions or {}).get("KHR_lights_punctual"))
for gcam in gltf.cameras or []:
persp = gcam.perspective
scene.cameras.append(
_camera_dict(
gcam.name,
gcam.type,
persp.yfov if persp else None,
persp.znear if persp else None,
persp.zfar if persp else None,
)
)
# --- Nodes ---
for gnode in gltf.nodes or []:
node = GLTFNode(name=gnode.name or "")
node.transform = _node_transform(gnode)
node.children = list(gnode.children) if gnode.children else []
if gnode.mesh is not None:
node.primitives = mesh_prim_map.get(gnode.mesh, [])
if gnode.skin is not None:
node.skin_index = gnode.skin
node.light_index = (gnode.extensions or {}).get("KHR_lights_punctual", {}).get("light")
node.camera_index = gnode.camera
scene.nodes.append(node)
# --- Root nodes ---
gltf_scene = gltf.scenes[gltf.scene or 0] if gltf.scenes else None
if gltf_scene and gltf_scene.nodes:
scene.root_nodes = list(gltf_scene.nodes)
elif scene.nodes:
scene.root_nodes = [0]
# --- Skins (consumed by scene_import._build_skeleton to build Skeletons) ---
for gskin in gltf.skins or []:
skin_data: dict[str, Any] = {
"name": gskin.name or "",
"joints": list(gskin.joints) if gskin.joints else [],
}
if gskin.inverseBindMatrices is not None:
skin_data["inverse_bind_matrices"] = _read_accessor(gltf, gskin.inverseBindMatrices)
scene.skins.append(skin_data)
# --- Animations ---
# Build mapping of glTF node index → bone index per skin
skin_joint_maps: list[dict[int, int]] = []
for gskin_data in scene.skins:
jmap = {}
for bone_idx, node_idx in enumerate(gskin_data.get("joints", [])):
jmap[node_idx] = bone_idx
skin_joint_maps.append(jmap)
for ganim in gltf.animations or []:
anim_data: dict[str, Any] = {
"name": ganim.name or "",
"duration": 0.0,
"tracks": [],
}
for channel in ganim.channels or []:
sampler = ganim.samplers[channel.sampler]
target_node = channel.target.node
target_path = channel.target.path # translation/rotation/scale
# Find which bone this node maps to
bone_index = -1
for jmap in skin_joint_maps:
if target_node in jmap:
bone_index = jmap[target_node]
break
if bone_index < 0:
continue
# Read keyframe times and values
times = _read_accessor(gltf, sampler.input)
values = _read_accessor(gltf, sampler.output)
if len(times) > 0:
anim_data["duration"] = max(anim_data["duration"], float(times[-1]))
# Find or create track for this bone
track = None
for t in anim_data["tracks"]:
if t["bone_index"] == bone_index:
track = t
break
if track is None:
track = {"bone_index": bone_index, "position_keys": [], "rotation_keys": [], "scale_keys": []}
anim_data["tracks"].append(track)
# Convert to keyframe lists
if target_path == "translation":
track["position_keys"] = [(float(t), v.astype(np.float32)) for t, v in zip(times, values, strict=True)]
elif target_path == "rotation":
track["rotation_keys"] = [(float(t), v.astype(np.float32)) for t, v in zip(times, values, strict=True)]
elif target_path == "scale":
track["scale_keys"] = [(float(t), v.astype(np.float32)) for t, v in zip(times, values, strict=True)]
if anim_data["tracks"]:
scene.animations.append(anim_data)
log.debug(
"Loaded glTF: %d meshes, %d materials, %d nodes, %d animations from %s",
len(scene.meshes),
len(scene.materials),
len(scene.nodes),
len(scene.animations),
path.name,
)
return scene
def _extract_primitive(gltf: Any, prim: Any) -> tuple[VertexStreams, np.ndarray, dict[str, np.ndarray]]:
"""Extract vertex streams, indices, and extra attributes from a glTF primitive.
The streams carry positions + shading (normal/uv) and, when JOINTS_0 /
WEIGHTS_0 are present, the skin stream. The third element carries
TANGENT / COLOR_0 / TEXCOORD_1 data when present (see
``GLTFScene.mesh_extras``); it is empty otherwise.
"""
attrs = prim.attributes
positions = _read_accessor(gltf, attrs.POSITION)
normals = _read_accessor(gltf, attrs.NORMAL) if attrs.NORMAL is not None else None
uvs = _read_accessor(gltf, attrs.TEXCOORD_0) if attrs.TEXCOORD_0 is not None else None
extras: dict[str, np.ndarray] = {}
tangent_acc = getattr(attrs, "TANGENT", None)
if tangent_acc is not None:
extras["tangents"] = _read_accessor(gltf, tangent_acc).astype(np.float32)
colour_acc = getattr(attrs, "COLOR_0", None)
if colour_acc is not None:
extras["colours"] = _expand_colours(_unorm_to_float(_read_accessor(gltf, colour_acc)))
uv2_acc = getattr(attrs, "TEXCOORD_1", None)
if uv2_acc is not None:
extras["texcoords2"] = _unorm_to_float(_read_accessor(gltf, uv2_acc))
has_skin = (
hasattr(attrs, "JOINTS_0")
and attrs.JOINTS_0 is not None
and hasattr(attrs, "WEIGHTS_0")
and attrs.WEIGHTS_0 is not None
)
count = len(positions)
streams = VertexStreams.build(
positions,
normals,
uvs,
joints=_read_accessor(gltf, attrs.JOINTS_0).astype(np.uint16) if has_skin else None,
weights=_read_accessor(gltf, attrs.WEIGHTS_0).astype(np.float32) if has_skin else None,
)
if prim.indices is not None:
indices = _read_accessor(gltf, prim.indices).astype(np.uint32)
else:
indices = np.arange(count, dtype=np.uint32)
return streams, indices, extras
def _resolve_texture(gltf: Any, tex_index: int, base_dir: Path) -> str | bytes | None:
"""Resolve a glTF texture index to a file path or embedded image bytes."""
if tex_index is None or tex_index >= len(gltf.textures or []):
return None
tex = gltf.textures[tex_index]
if tex.source is None or tex.source >= len(gltf.images or []):
return None
image = gltf.images[tex.source]
if image.uri:
return str(base_dir / image.uri)
# Embedded texture via bufferView (common in .glb files)
if image.bufferView is not None:
bv = gltf.bufferViews[image.bufferView]
buffer = gltf.buffers[bv.buffer]
data = gltf.get_data_from_buffer_uri(buffer.uri)
offset = bv.byteOffset or 0
return bytes(data[offset : offset + bv.byteLength])
return None
def _node_transform(gnode: Any) -> np.ndarray:
"""Extract 4x4 transform from glTF node (TRS or matrix).
glTF stores ``matrix`` flat in column-major order; transposing the
row-major reshape recovers the translation-in-last-column convention
the TRS branch composes (and ``scene_import`` decomposes).
"""
if gnode.matrix:
return np.array(gnode.matrix, dtype=np.float32).reshape(4, 4).T
mat = np.eye(4, dtype=np.float32)
if gnode.scale:
s = gnode.scale
mat[0, 0], mat[1, 1], mat[2, 2] = s[0], s[1], s[2]
if gnode.rotation:
q = gnode.rotation # [x, y, z, w]
rot = _quat_to_mat3(q[0], q[1], q[2], q[3])
scale_diag = np.diag(mat[:3, :3]).copy()
mat[:3, :3] = rot * scale_diag[np.newaxis, :]
if gnode.translation:
t = gnode.translation
mat[0, 3], mat[1, 3], mat[2, 3] = t[0], t[1], t[2]
return mat
def _quat_to_mat3(x: float, y: float, z: float, w: float) -> np.ndarray:
"""Convert quaternion to 3x3 rotation matrix."""
x2, y2, z2 = x + x, y + y, z + z
xx, xy, xz = x * x2, x * y2, x * z2
yy, yz, zz = y * y2, y * z2, z * z2
wx, wy, wz = w * x2, w * y2, w * z2
return np.array(
[
[1 - (yy + zz), xy - wz, xz + wy],
[xy + wz, 1 - (xx + zz), yz - wx],
[xz - wy, yz + wx, 1 - (xx + yy)],
],
dtype=np.float32,
)
def _read_accessor(gltf: Any, accessor_index: int) -> np.ndarray:
"""Extract numpy array from a glTF accessor."""
accessor = gltf.accessors[accessor_index]
bv = gltf.bufferViews[accessor.bufferView]
buffer = gltf.buffers[bv.buffer]
data = gltf.get_data_from_buffer_uri(buffer.uri)
dtype = _COMPONENT_DTYPES[accessor.componentType]
components = _TYPE_SIZES[accessor.type]
offset = (bv.byteOffset or 0) + (accessor.byteOffset or 0)
stride = bv.byteStride
if stride and stride != components * np.dtype(dtype).itemsize:
# Interleaved buffer: read with stride
arr = np.zeros((accessor.count, components), dtype=dtype)
for i in range(accessor.count):
start = offset + i * stride
chunk = np.frombuffer(data, dtype=dtype, count=components, offset=start)
arr[i] = chunk
return arr
arr = np.frombuffer(data, dtype=dtype, count=accessor.count * components, offset=offset)
if components > 1:
arr = arr.reshape((accessor.count, components))
return arr
# ---------------------------------------------------------------------------
# Stdlib-only backend (for Pyodide / web exports: no pygltflib dependency)
# ---------------------------------------------------------------------------
# Handles the common ``.gltf`` JSON + external ``.bin`` + image file subset:
# POSITION, NORMAL, TANGENT, TEXCOORD_0, TEXCOORD_1, COLOR_0, JOINTS_0,
# WEIGHTS_0, indices, the PBR textures, KHR_lights_punctual lights, cameras,
# plus skins (joints + inverse-bind matrices) and skeletal animations -- so
# rigged, animated glTF plays on web at desktop parity. Still needs pygltflib
# for: interleaved (byteStride) or sparse accessors, other glTF 2.0
# extensions, and embedded ``.glb`` binaries (their buffers have no URI).
_DATA_URI_PREFIX = "data:application/octet-stream;base64,"
def _load_gltf_stdlib(file_path: str) -> GLTFScene:
path = Path(file_path)
base_dir = path.parent
gltf_doc = json.loads(path.read_text())
# Resolve buffers: only external .bin files or inline base64 data URIs.
buffers_raw: list[bytes] = []
for buf in gltf_doc.get("buffers", []):
uri = buf.get("uri")
if uri is None:
log.warning("stdlib glTF parser: .glb-embedded buffers require pygltflib")
buffers_raw.append(b"")
continue
if uri.startswith("data:"):
_, _, b64 = uri.partition(",")
buffers_raw.append(base64.b64decode(b64))
else:
bin_path = base_dir / uri
buffers_raw.append(bin_path.read_bytes() if bin_path.exists() else b"")
buffer_views = gltf_doc.get("bufferViews", [])
accessors = gltf_doc.get("accessors", [])
images_doc = gltf_doc.get("images", [])
textures_doc = gltf_doc.get("textures", [])
def _read_acc(acc_idx: int) -> np.ndarray:
acc = accessors[acc_idx]
bv = buffer_views[acc["bufferView"]]
dtype = _COMPONENT_DTYPES[acc["componentType"]]
components = _TYPE_SIZES[acc["type"]]
offset = bv.get("byteOffset", 0) + acc.get("byteOffset", 0)
count = acc["count"]
data = buffers_raw[bv["buffer"]]
arr = np.frombuffer(data, dtype=dtype, count=count * components, offset=offset)
if components > 1:
arr = arr.reshape((count, components))
return arr.copy() # copy so callers can mutate safely
def _resolve_tex(tex_idx: int | None) -> str | bytes | None:
if tex_idx is None or tex_idx >= len(textures_doc):
return None
tex = textures_doc[tex_idx]
source_idx = tex.get("source")
if source_idx is None or source_idx >= len(images_doc):
return None
image = images_doc[source_idx]
uri = image.get("uri")
if uri:
if uri.startswith("data:"):
# inline base64: return the decoded image bytes
_, _, b64 = uri.partition(",")
return base64.b64decode(b64)
return str(base_dir / uri)
# bufferView-embedded image bytes
bv_idx = image.get("bufferView")
if bv_idx is not None:
bv = buffer_views[bv_idx]
data = buffers_raw[bv["buffer"]]
offset = bv.get("byteOffset", 0)
length = bv["byteLength"]
return bytes(data[offset:offset + length])
return None
scene = GLTFScene()
# --- Materials ---
for gmat in gltf_doc.get("materials", []):
mat = GLTFMaterial(name=gmat.get("name", ""))
pbr = gmat.get("pbrMetallicRoughness", {})
bc = pbr.get("baseColorFactor", [1, 1, 1, 1])
mat.albedo = tuple(bc[:4]) if len(bc) >= 4 else (*bc, 1.0)
mat.metallic = pbr.get("metallicFactor", 1.0)
mat.roughness = pbr.get("roughnessFactor", 1.0)
bct = pbr.get("baseColorTexture")
if bct is not None:
mat.albedo_texture = _resolve_tex(bct.get("index"))
mrt = pbr.get("metallicRoughnessTexture")
if mrt is not None:
mat.metallic_roughness_texture = _resolve_tex(mrt.get("index"))
nt = gmat.get("normalTexture")
if nt is not None:
mat.normal_texture = _resolve_tex(nt.get("index"))
et = gmat.get("emissiveTexture")
if et is not None:
mat.emissive_texture = _resolve_tex(et.get("index"))
ot = gmat.get("occlusionTexture")
if ot is not None:
mat.ao_texture = _resolve_tex(ot.get("index"))
mat.double_sided = gmat.get("doubleSided", False)
mat.alpha_mode = gmat.get("alphaMode", "OPAQUE")
mat.alpha_cutoff = float(gmat.get("alphaCutoff", 0.5))
ef = gmat.get("emissiveFactor")
if ef and len(ef) >= 3:
mat.emissive = (float(ef[0]), float(ef[1]), float(ef[2]))
exts = gmat.get("extensions", {})
strength = exts.get("KHR_materials_emissive_strength", {}).get("emissiveStrength")
if strength is not None:
mat.emissive_strength = float(strength)
_apply_texture_transform(mat, _first_texture_transform([bct, nt, mrt, et, ot]))
scene.materials.append(mat)
# --- Meshes (each primitive becomes a separate entry) ---
mesh_prim_map: dict[int, list[tuple[int, int]]] = {}
for mesh_idx, gmesh in enumerate(gltf_doc.get("meshes", [])):
prims: list[tuple[int, int]] = []
for prim in gmesh.get("primitives", []):
attrs = prim.get("attributes", {})
pos_acc = attrs.get("POSITION")
if pos_acc is None:
continue
positions = _read_acc(pos_acc)
normals = _read_acc(attrs["NORMAL"]) if "NORMAL" in attrs else None
uvs = _read_acc(attrs["TEXCOORD_0"]) if "TEXCOORD_0" in attrs else None
extras: dict[str, np.ndarray] = {}
if "TANGENT" in attrs:
extras["tangents"] = _read_acc(attrs["TANGENT"]).astype(np.float32)
if "COLOR_0" in attrs:
extras["colours"] = _expand_colours(_unorm_to_float(_read_acc(attrs["COLOR_0"])))
if "TEXCOORD_1" in attrs:
extras["texcoords2"] = _unorm_to_float(_read_acc(attrs["TEXCOORD_1"]))
count = len(positions)
has_skin = attrs.get("JOINTS_0") is not None and attrs.get("WEIGHTS_0") is not None
streams = VertexStreams.build(
positions,
normals,
uvs,
joints=_read_acc(attrs["JOINTS_0"]).astype(np.uint16) if has_skin else None,
weights=_read_acc(attrs["WEIGHTS_0"]).astype(np.float32) if has_skin else None,
)
idx_acc = prim.get("indices")
indices = (_read_acc(idx_acc).astype(np.uint32) if idx_acc is not None
else np.arange(count, dtype=np.uint32))
scene_mesh_idx = len(scene.meshes)
scene.meshes.append((streams, indices))
scene.mesh_extras.append(extras)
prims.append((scene_mesh_idx, prim.get("material", -1)))
mesh_prim_map[mesh_idx] = prims
# --- Lights (KHR_lights_punctual) + cameras ---
scene.lights = _parse_khr_lights(gltf_doc.get("extensions", {}).get("KHR_lights_punctual"))
for gcam in gltf_doc.get("cameras", []):
persp = gcam.get("perspective", {})
scene.cameras.append(
_camera_dict(
gcam.get("name"),
gcam.get("type"),
persp.get("yfov"),
persp.get("znear"),
persp.get("zfar"),
)
)
# --- Nodes ---
for gnode in gltf_doc.get("nodes", []):
node = GLTFNode(name=gnode.get("name", ""))
node.transform = _node_transform_stdlib(gnode)
node.children = list(gnode.get("children", []))
if "mesh" in gnode:
node.primitives = mesh_prim_map.get(gnode["mesh"], [])
node.skin_index = gnode.get("skin")
node.light_index = gnode.get("extensions", {}).get("KHR_lights_punctual", {}).get("light")
node.camera_index = gnode.get("camera")
scene.nodes.append(node)
# --- Skins (joints + inverse-bind matrices; consumed by _build_skeleton) ---
for gskin in gltf_doc.get("skins", []):
skin_data: dict[str, Any] = {
"name": gskin.get("name", ""),
"joints": list(gskin.get("joints", [])),
}
ibm = gskin.get("inverseBindMatrices")
if ibm is not None:
skin_data["inverse_bind_matrices"] = _read_acc(ibm)
scene.skins.append(skin_data)
# --- Animations (skeletal tracks; mirrors the pygltflib backend) ---
skin_joint_maps: list[dict[int, int]] = [
{node_idx: bone_idx for bone_idx, node_idx in enumerate(sk.get("joints", []))}
for sk in scene.skins
]
for ganim in gltf_doc.get("animations", []):
samplers = ganim.get("samplers", [])
anim_data: dict[str, Any] = {"name": ganim.get("name", ""), "duration": 0.0, "tracks": []}
for channel in ganim.get("channels", []):
sampler = samplers[channel["sampler"]]
target = channel.get("target", {})
target_node = target.get("node")
target_path = target.get("path") # translation/rotation/scale
bone_index = -1
for jmap in skin_joint_maps:
if target_node in jmap:
bone_index = jmap[target_node]
break
if bone_index < 0:
continue
times = _read_acc(sampler["input"])
values = _read_acc(sampler["output"])
if len(times) > 0:
anim_data["duration"] = max(anim_data["duration"], float(times[-1]))
track = next((t for t in anim_data["tracks"] if t["bone_index"] == bone_index), None)
if track is None:
track = {"bone_index": bone_index, "position_keys": [], "rotation_keys": [], "scale_keys": []}
anim_data["tracks"].append(track)
keys = [(float(t), v.astype(np.float32)) for t, v in zip(times, values, strict=True)]
if target_path == "translation":
track["position_keys"] = keys
elif target_path == "rotation":
track["rotation_keys"] = keys
elif target_path == "scale":
track["scale_keys"] = keys
if anim_data["tracks"]:
scene.animations.append(anim_data)
# --- Root nodes ---
scenes = gltf_doc.get("scenes", [])
if scenes:
active = gltf_doc.get("scene", 0)
scene.root_nodes = list(scenes[active].get("nodes", []))
elif scene.nodes:
scene.root_nodes = [0]
log.debug(
"Loaded glTF (stdlib): %d meshes, %d materials, %d nodes from %s",
len(scene.meshes),
len(scene.materials),
len(scene.nodes),
path.name,
)
return scene
def _node_transform_stdlib(gnode: dict) -> np.ndarray:
if "matrix" in gnode:
# Column-major in the file (see _node_transform): transpose to match
# the TRS branch's translation-in-last-column convention.
return np.array(gnode["matrix"], dtype=np.float32).reshape(4, 4).T
mat = np.eye(4, dtype=np.float32)
if "scale" in gnode:
s = gnode["scale"]
mat[0, 0], mat[1, 1], mat[2, 2] = s[0], s[1], s[2]
if "rotation" in gnode:
q = gnode["rotation"]
rot = _quat_to_mat3(q[0], q[1], q[2], q[3])
scale_diag = np.diag(mat[:3, :3]).copy()
mat[:3, :3] = rot * scale_diag[np.newaxis, :]
if "translation" in gnode:
t = gnode["translation"]
mat[0, 3], mat[1, 3], mat[2, 3] = t[0], t[1], t[2]
return mat