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