"""Full glTF scene loading — meshes, materials, textures, node hierarchy.
Pure-Python parsing step: produces a ``GLTFScene`` with vertex/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, TEXCOORD_0, indices,
base-colour texture). Used when pygltflib isn't installed (notably
inside Pyodide / web exports). Advanced features (animations, skinning,
glTF 2.0 extensions, embedded .glb binaries) require pygltflib.
"""
import base64
import json
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import numpy as np
from simvx.graphics._types import SKINNED_VERTEX_DTYPE, VERTEX_DTYPE
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}
[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"
[docs]
@dataclass
class GLTFNode:
"""Scene graph node with optional mesh reference."""
name: str = ""
mesh_index: int | None = None
material_indices: list[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 (extracted but not applied until Phase 6)
skin_index: int | None = None
[docs]
@dataclass
class GLTFScene:
"""Complete loaded glTF scene."""
meshes: list[tuple[np.ndarray, np.ndarray]] = field(default_factory=list) # (vertices, 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)
[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"
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:
vertices, indices = _extract_primitive(gltf, prim)
scene_mesh_idx = len(scene.meshes)
scene.meshes.append((vertices, indices))
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
# --- 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:
prims = mesh_prim_map.get(gnode.mesh, [])
if prims:
node.mesh_index = prims[0][0] # First primitive
node.material_indices = [p[1] for p in prims]
if gnode.skin is not None:
node.skin_index = gnode.skin
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 (extract for Phase 6) ---
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[np.ndarray, np.ndarray]:
"""Extract vertices and indices from a single glTF primitive.
Returns skinned vertices (SKINNED_VERTEX_DTYPE) if JOINTS_0/WEIGHTS_0
are present, otherwise standard vertices (VERTEX_DTYPE).
"""
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
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)
if has_skin:
vertices = np.zeros(count, dtype=SKINNED_VERTEX_DTYPE)
vertices["joints"] = _read_accessor(gltf, attrs.JOINTS_0).astype(np.uint16)
vertices["weights"] = _read_accessor(gltf, attrs.WEIGHTS_0).astype(np.float32)
else:
vertices = np.zeros(count, dtype=VERTEX_DTYPE)
vertices["position"] = positions
if normals is not None:
vertices["normal"] = normals
if uvs is not None:
vertices["uv"] = uvs
if prim.indices is not None:
indices = _read_accessor(gltf, prim.indices).astype(np.uint32)
else:
indices = np.arange(count, dtype=np.uint32)
return vertices, indices
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)."""
if gnode.matrix:
return np.array(gnode.matrix, dtype=np.float32).reshape(4, 4)
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
np.dtype(dtype).itemsize * components
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.
# Limited to: POSITION, NORMAL, TEXCOORD_0, indices, baseColorTexture,
# normalTexture, metallicRoughnessTexture, emissiveTexture, occlusionTexture.
# Skinning / animations / glTF 2.0 extensions / .glb binaries need pygltflib.
_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")
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
count = len(positions)
vertices = np.zeros(count, dtype=VERTEX_DTYPE)
vertices["position"] = positions
if normals is not None:
vertices["normal"] = normals
if uvs is not None:
vertices["uv"] = uvs
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((vertices, indices))
prims.append((scene_mesh_idx, prim.get("material", -1)))
mesh_prim_map[mesh_idx] = prims
# --- 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:
prims = mesh_prim_map.get(gnode["mesh"], [])
if prims:
node.mesh_index = prims[0][0]
node.material_indices = [p[1] for p in prims]
# Skinning is not supported in the stdlib backend; attribute stays None.
scene.nodes.append(node)
# --- 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:
return np.array(gnode["matrix"], dtype=np.float32).reshape(4, 4)
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