Source code for polyxios._scene

"""Scene-graph container for multi-mesh formats (glTF, FBX, USD, COLLADA).

`SceneData` holds the full scene hierarchy, materials, and texture images
returned by scene-aware codecs.  `PolyData` remains the flat single-mesh
type; `SceneData` sits alongside it, never replaces it.
"""

import dataclasses
from dataclasses import dataclass, field
from typing import Any

import numpy as np

from polyxios import transforms
from polyxios._types import PolyData


def _same_value(a: Any, b: Any) -> bool:
    """Return whether two attribute values are equal, arrays nested included.

    ``==`` on a dict or list holding arrays raises, since an array compares
    element-wise; this walks dicts, lists and tuples and compares arrays
    with :func:`numpy.array_equal`, NaN equal to NaN as a value read twice
    from one file is, in an array or as a float or complex scalar. A value
    is equal to itself, as it is inside a dict under ``==``. A 0-d array
    compares as the scalar it holds, as it does under ``==``, and an object
    array element by element.

    Parameters
    ----------
    a, b
        The values to compare.

    Returns
    -------
    bool
        True when they hold the same structure and values.
    """
    if a is b:
        return True
    if isinstance(a, np.ndarray) and a.ndim == 0:
        a = a[()]
    if isinstance(b, np.ndarray) and b.ndim == 0:
        b = b[()]
    if isinstance(a, np.ndarray) or isinstance(b, np.ndarray):
        return (
            isinstance(a, np.ndarray)
            and isinstance(b, np.ndarray)
            and a.shape == b.shape
            and _same_array(a, b)
        )
    if isinstance(a, dict) or isinstance(b, dict):
        return (
            isinstance(a, dict)
            and isinstance(b, dict)
            and a.keys() == b.keys()
            and all(_same_value(a[k], b[k]) for k in a)
        )
    if isinstance(a, (list, tuple)) or isinstance(b, (list, tuple)):
        return (
            type(a) is type(b)
            and len(a) == len(b)
            and all(_same_value(x, y) for x, y in zip(a, b, strict=True))
        )
    if (
        isinstance(a, (float, complex, np.inexact))
        and isinstance(b, (float, complex, np.inexact))
        and np.isnan(a)
        and np.isnan(b)
    ):
        return True
    return bool(a == b)


def _same_array(a: np.ndarray, b: np.ndarray) -> bool:
    """Return whether two arrays of one shape hold the same values.

    Parameters
    ----------
    a, b
        The arrays to compare.

    Returns
    -------
    bool
        True when every element is equal, NaN equal to NaN; the elements of
        an object array are compared with :func:`_same_value`, since they
        may be arrays themselves.
    """
    if a.dtype.kind == "O" or b.dtype.kind == "O":
        return all(_same_value(x, y) for x, y in zip(a.flat, b.flat, strict=True))
    both_float = a.dtype.kind in "fc" and b.dtype.kind in "fc"
    return bool(np.array_equal(a, b, equal_nan=both_float))


def _same_mesh(a: PolyData, b: PolyData) -> bool:
    """Return whether two meshes hold the same arrays and attributes.

    ``==`` on two distinct :class:`~polyxios.PolyData` raises, their fields
    being arrays; this compares them field by field with :func:`_same_value`.

    Parameters
    ----------
    a, b
        The meshes to compare.

    Returns
    -------
    bool
        True when every field of the two is equal by value.
    """
    return type(a) is type(b) and all(
        _same_value(getattr(a, f.name), getattr(b, f.name))
        for f in dataclasses.fields(a)
    )


[docs] @dataclass(frozen=True, slots=True) class SceneImage: """A texture image: external URI or embedded bytes. Parameters ---------- uri File-system path or ``data:`` URI. Mutually exclusive with *data*. data Raw image bytes decoded from a glTF buffer view or a ``data:`` URI. media_type MIME type string, e.g. ``"image/png"`` or ``"image/jpeg"``. name Optional human-readable label from the source file. """ uri: str | None = None data: bytes | None = None media_type: str | None = None name: str = ""
[docs] @dataclass(frozen=True, slots=True) class SceneTexture: """A texture: image index plus OpenGL sampler settings. Parameters ---------- image Index into :attr:`SceneData.images`. mag_filter GL magnification filter enum (e.g. 9729 = ``GL_LINEAR``). min_filter GL minification filter enum (e.g. 9987 = ``GL_LINEAR_MIPMAP_LINEAR``). wrap_s GL wrap mode for the S axis (default 10497 = ``GL_REPEAT``). wrap_t GL wrap mode for the T axis (default 10497 = ``GL_REPEAT``). """ image: int mag_filter: int | None = None min_filter: int | None = None wrap_s: int = 10497 wrap_t: int = 10497
[docs] @dataclass(frozen=True, slots=True, eq=False) class SceneMaterial: """PBR metallic-roughness material. Parameters ---------- name Material name from the source file. base_color RGBA base-color factor in linear space, each component in [0, 1]. metallic Metallic factor in [0, 1]. roughness Roughness factor in [0, 1]. emissive RGB emissive factor in linear space. alpha_mode One of ``"OPAQUE"``, ``"MASK"``, or ``"BLEND"``. alpha_cutoff Alpha cutoff threshold used when *alpha_mode* is ``"MASK"``. double_sided Whether the material is rendered on both sides. base_color_texture Index into :attr:`SceneData.textures`, or ``None``. normal_texture Index into :attr:`SceneData.textures`, or ``None``. metallic_roughness_texture Index into :attr:`SceneData.textures`, or ``None``. occlusion_texture Index into :attr:`SceneData.textures`, or ``None``. emissive_texture Index into :attr:`SceneData.textures`, or ``None``. extras Format-specific extra data preserved for round-tripping. """ name: str = "" base_color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0) metallic: float = 1.0 roughness: float = 1.0 emissive: tuple[float, float, float] = (0.0, 0.0, 0.0) alpha_mode: str = "OPAQUE" alpha_cutoff: float = 0.5 double_sided: bool = False base_color_texture: int | None = None normal_texture: int | None = None metallic_roughness_texture: int | None = None occlusion_texture: int | None = None emissive_texture: int | None = None extras: dict[str, Any] = field(default_factory=dict) def __eq__(self, other: object) -> bool: if not isinstance(other, type(self)): return NotImplemented return all( _same_value(getattr(self, f.name), getattr(other, f.name)) for f in dataclasses.fields(self) ) __hash__ = None
[docs] @dataclass(frozen=True, slots=True, eq=False) class SceneNode: """One node in the scene graph. Parameters ---------- name Node name from the source file. mesh Index into :attr:`SceneData.meshes`, or ``None`` if this node carries no geometry (e.g. a grouping or camera node). children Indices of child nodes into :attr:`SceneData.nodes`. matrix Local 4×4 transform matrix (float64, row-major / C-order). Defaults to the identity transform. extras Format-specific extra data (cameras, lights, skins) preserved for round-tripping. """ name: str = "" mesh: int | None = None children: tuple[int, ...] = () matrix: np.ndarray = field(default_factory=lambda: np.eye(4, dtype=np.float64)) extras: dict[str, Any] = field(default_factory=dict) def __eq__(self, other: object) -> bool: if not isinstance(other, type(self)): return NotImplemented return ( self.name == other.name and self.mesh == other.mesh and self.children == other.children and np.array_equal(self.matrix, other.matrix) and _same_value(self.extras, other.extras) ) __hash__ = None
[docs] @dataclass(frozen=True, slots=True, eq=False) class SceneData: """Multi-mesh scene with hierarchy, materials, and texture images. This is the type returned by scene-aware codecs (glTF, FBX, USD, COLLADA). Each entry in *meshes* is a flat :class:`~polyxios.PolyData`; ``element_attrs["material"]`` holds ``int32`` indices into *materials* (``-1`` meaning no material). Node transforms are stored as local matrices; world transforms are computed on demand by :meth:`world_transform`. Parameters ---------- meshes One :class:`~polyxios.PolyData` per glTF mesh (primitives merged). nodes Scene-graph nodes with local transforms and parent–child wiring. materials PBR materials referenced by element_attrs["material"] indices. textures Textures referencing entries in *images*. images Raw image data or URI references. scenes Each entry is a tuple of root-node indices defining one scene. active_scene Index of the default scene in *scenes*. name Optional top-level name from the source file. global_attrs Format-level metadata: ``asset``, raw ``animations``, ``skins``, etc. """ meshes: tuple[PolyData, ...] nodes: tuple[SceneNode, ...] materials: tuple[SceneMaterial, ...] = () textures: tuple[SceneTexture, ...] = () images: tuple[SceneImage, ...] = () scenes: tuple[tuple[int, ...], ...] = () active_scene: int = 0 name: str = "" global_attrs: dict[str, Any] = field(default_factory=dict) def __eq__(self, other: object) -> bool: if not isinstance(other, type(self)): return NotImplemented return ( len(self.meshes) == len(other.meshes) and all( _same_mesh(a, b) for a, b in zip(self.meshes, other.meshes, strict=True) ) and self.nodes == other.nodes and self.materials == other.materials and self.textures == other.textures and self.images == other.images and self.scenes == other.scenes and self.active_scene == other.active_scene and self.name == other.name and _same_value(self.global_attrs, other.global_attrs) ) __hash__ = None def _parent_map(self) -> dict[int, int]: """Return {child_index: parent_index} for all nodes.""" parent: dict[int, int] = {} for i, node in enumerate(self.nodes): for child in node.children: parent[child] = i return parent def world_transform(self, node_index: int) -> np.ndarray: """Return the world-space 4×4 transform for the given node. Parameters ---------- node_index Index into :attr:`nodes`. Returns ------- numpy.ndarray Shape (4, 4), dtype float64. Product of all ancestor local matrices from the root down to this node. Notes ----- Rebuilds the parent map on every call (O(N) in node count). Avoid calling per-node in a tight loop over the full scene; :meth:`to_polydata` already walks all nodes without this overhead. """ parent = self._parent_map() chain: list[int] = [] visited: set[int] = set() idx: int | None = node_index while idx is not None: if idx in visited: raise ValueError(f"cycle detected in scene graph at node {idx}") visited.add(idx) chain.append(idx) idx = parent.get(idx) chain.reverse() result = np.eye(4, dtype=np.float64) for i in chain: result = result @ self.nodes[i].matrix return result def to_polydata( self, *, scene: int | None = None, apply_transforms: bool = True, ) -> PolyData: """Flatten the scene graph into a single merged PolyData. Parameters ---------- scene Which entry in :attr:`scenes` to flatten. Defaults to :attr:`active_scene`. Ignored when :attr:`nodes` is empty. apply_transforms When ``True``, each mesh's vertices are multiplied by the node's world-space transform before merging. Returns ------- PolyData All scene geometry merged into one mesh. """ if len(self.nodes) == 0: if not self.meshes: return PolyData( vertices=np.zeros((0, 3), dtype=np.float64), connectivity=np.array([], dtype=np.int32), offsets=np.array([0], dtype=np.int32), element_types=np.array([], dtype=np.uint8), ) return transforms.merge(*self.meshes) scene_idx = scene if scene is not None else self.active_scene if self.scenes: if not (0 <= scene_idx < len(self.scenes)): raise ValueError( f"scene index {scene_idx!r} is out of range; " f"this SceneData has {len(self.scenes)} scene(s)." ) root_indices = list(self.scenes[scene_idx]) else: all_children: set[int] = set() for node in self.nodes: all_children.update(node.children) root_indices = [i for i in range(len(self.nodes)) if i not in all_children] collected: list[PolyData] = [] visited_nodes: set[int] = set() stack: list[tuple[int, np.ndarray]] = [ (r, np.eye(4, dtype=np.float64)) for r in reversed(root_indices) ] while stack: node_idx, parent_world = stack.pop() if node_idx in visited_nodes: raise ValueError(f"cycle detected in scene graph at node {node_idx}") visited_nodes.add(node_idx) node = self.nodes[node_idx] world = parent_world @ node.matrix if node.mesh is not None: mesh = self.meshes[node.mesh] if apply_transforms and not np.allclose(world, np.eye(4)): n = mesh.vertices.shape[0] ones = np.ones((n, 1), dtype=np.float64) homo = np.hstack([mesh.vertices, ones]) new_vertices = (homo @ world.T)[:, :3] R = world[:3, :3] vertex_attrs = dict(mesh.vertex_attrs) if "normals" in vertex_attrs: normals = vertex_attrs["normals"] @ np.linalg.pinv(R) norms = np.linalg.norm(normals, axis=1, keepdims=True) norms = np.where(norms == 0, 1.0, norms) vertex_attrs["normals"] = normals / norms if "tangents" in vertex_attrs: tgts = vertex_attrs["tangents"].copy() tgts[:, :3] = tgts[:, :3] @ R.T t_norms = np.linalg.norm(tgts[:, :3], axis=1, keepdims=True) t_norms = np.where(t_norms == 0, 1.0, t_norms) tgts[:, :3] /= t_norms vertex_attrs["tangents"] = tgts mesh = dataclasses.replace( mesh, vertices=new_vertices, vertex_attrs=vertex_attrs ) collected.append(mesh) for child in reversed(node.children): if child in visited_nodes: raise ValueError(f"cycle detected in scene graph at node {child}") stack.append((child, world)) if not collected: return PolyData( vertices=np.zeros((0, 3), dtype=np.float64), connectivity=np.array([], dtype=np.int32), offsets=np.array([0], dtype=np.int32), element_types=np.array([], dtype=np.uint8), ) return transforms.merge(*collected)