Source code for simvx.graphics.assets.mesh_loader

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