Source code for simvx.core.mesh_instance_2d

"""MeshInstance2D — custom 2D mesh rendering node.

Renders arbitrary 2D geometry defined by vertices, indices, UVs, and per-vertex
colours.  Useful for deformable shapes, terrain slices, soft-body visuals, and
any geometry that doesn't fit a simple sprite or polygon.
"""


from __future__ import annotations

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

import numpy as np

from .descriptors import Property
from .math.types import Vec2
from .nodes_2d.node2d import Node2D

log = logging.getLogger(__name__)

__all__ = ["Mesh2D", "MeshInstance2D"]


[docs] @dataclass class Mesh2D: """Immutable-ish 2D mesh data: vertices, triangle indices, UVs, and per-vertex colours. Vertices and UVs are stored as ``(N, 2)`` float32 arrays. Colours are ``(N, 4)`` float32 RGBA. Indices are a flat ``uint32`` array where every three consecutive values form one triangle. Factory class-methods create common primitives with correct winding and UVs. """ vertices: np.ndarray = field(default_factory=lambda: np.empty((0, 2), dtype=np.float32)) indices: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.uint32)) uvs: np.ndarray = field(default_factory=lambda: np.empty((0, 2), dtype=np.float32)) colours: np.ndarray = field(default_factory=lambda: np.empty((0, 4), dtype=np.float32)) # -- validation ----------------------------------------------------------
[docs] def __post_init__(self): self.vertices = np.asarray(self.vertices, dtype=np.float32).reshape(-1, 2) self.indices = np.asarray(self.indices, dtype=np.uint32).ravel() self.uvs = np.asarray(self.uvs, dtype=np.float32) if self.uvs.size: self.uvs = self.uvs.reshape(-1, 2) self.colours = np.asarray(self.colours, dtype=np.float32) if self.colours.size: self.colours = self.colours.reshape(-1, 4)
# -- queries -------------------------------------------------------------- @property def vertex_count(self) -> int: return len(self.vertices) @property def triangle_count(self) -> int: return len(self.indices) // 3 @property def is_empty(self) -> bool: return self.vertex_count == 0 or len(self.indices) == 0
[docs] def get_aabb(self) -> tuple[Vec2, Vec2]: """Axis-aligned bounding box as ``(min_corner, max_corner)``.""" if self.vertex_count == 0: return Vec2(), Vec2() mn = self.vertices.min(axis=0) mx = self.vertices.max(axis=0) return Vec2(mn[0], mn[1]), Vec2(mx[0], mx[1])
# -- factory methods ------------------------------------------------------
[docs] @classmethod def from_polygon(cls, points: list | np.ndarray) -> Mesh2D: """Fan-triangulate a convex polygon. Points should be in order (CW or CCW). UVs are derived from the bounding box. All vertices share white colour. """ pts = np.asarray(points, dtype=np.float32).reshape(-1, 2) n = len(pts) if n < 3: raise ValueError("Polygon requires at least 3 points") # Fan triangulation from vertex 0 indices = np.empty((n - 2) * 3, dtype=np.uint32) for i in range(n - 2): indices[i * 3] = 0 indices[i * 3 + 1] = i + 1 indices[i * 3 + 2] = i + 2 # UVs from bounding box mn = pts.min(axis=0) mx = pts.max(axis=0) span = mx - mn span[span < 1e-9] = 1.0 uvs = (pts - mn) / span colours = np.ones((n, 4), dtype=np.float32) return cls(vertices=pts, indices=indices, uvs=uvs, colours=colours)
[docs] @classmethod def from_rect(cls, width: float, height: float, centered: bool = True) -> Mesh2D: """Axis-aligned rectangle as two triangles. When *centered* the origin is at the rectangle centre; otherwise it is at the top-left corner. """ hw, hh = width * 0.5, height * 0.5 if centered: verts = np.array([[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]], dtype=np.float32) else: verts = np.array([[0, 0], [width, 0], [width, height], [0, height]], dtype=np.float32) indices = np.array([0, 1, 2, 0, 2, 3], dtype=np.uint32) uvs = np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype=np.float32) colours = np.ones((4, 4), dtype=np.float32) return cls(vertices=verts, indices=indices, uvs=uvs, colours=colours)
[docs] @classmethod def from_circle(cls, radius: float, segments: int = 32) -> Mesh2D: """Regular polygon approximating a circle centred at the origin.""" if segments < 3: raise ValueError("Circle requires at least 3 segments") angles = np.linspace(0, 2 * math.pi, segments, endpoint=False, dtype=np.float32) verts = np.column_stack([np.cos(angles) * radius, np.sin(angles) * radius]).astype(np.float32) # Centre vertex for fan triangulation verts = np.vstack([np.array([[0.0, 0.0]], dtype=np.float32), verts]) indices = np.empty(segments * 3, dtype=np.uint32) for i in range(segments): indices[i * 3] = 0 indices[i * 3 + 1] = i + 1 indices[i * 3 + 2] = (i + 1) % segments + 1 # UVs: map from [-radius, radius] to [0, 1] uvs = (verts / radius + 1.0) * 0.5 colours = np.ones((len(verts), 4), dtype=np.float32) return cls(vertices=verts, indices=indices, uvs=uvs, colours=colours)
[docs] @classmethod def from_grid(cls, width: float, height: float, cols: int, rows: int, centered: bool = True) -> Mesh2D: """Subdivided rectangle useful for terrain slices or deformation grids. Creates a *cols* x *rows* quad grid (``(cols+1)*(rows+1)`` vertices). """ if cols < 1 or rows < 1: raise ValueError("Grid requires at least 1 column and 1 row") xs = np.linspace(0, width, cols + 1, dtype=np.float32) ys = np.linspace(0, height, rows + 1, dtype=np.float32) gx, gy = np.meshgrid(xs, ys) verts = np.column_stack([gx.ravel(), gy.ravel()]).astype(np.float32) if centered: verts[:, 0] -= width * 0.5 verts[:, 1] -= height * 0.5 # UVs uvs = np.column_stack([ gx.ravel() / width, gy.ravel() / height, ]).astype(np.float32) # Indices: two triangles per cell indices_list = [] stride = cols + 1 for r in range(rows): for c in range(cols): tl = r * stride + c tr = tl + 1 bl = tl + stride br = bl + 1 indices_list.extend([tl, tr, bl, tr, br, bl]) indices = np.array(indices_list, dtype=np.uint32) colours = np.ones((len(verts), 4), dtype=np.float32) return cls(vertices=verts, indices=indices, uvs=uvs, colours=colours)
[docs] class MeshInstance2D(Node2D): """Renders a custom 2D mesh defined by a :class:`Mesh2D`. The mesh vertices are in local space and transformed by the node's position, rotation, and scale before drawing. An optional texture and modulate colour tint the output. Example:: mesh = Mesh2D.from_rect(64, 64) node = MeshInstance2D(mesh=mesh, modulate=(1, 0, 0, 1)) root.add_child(node) """ texture = Property(None, hint="Optional texture (path or id)") modulate = Property((1.0, 1.0, 1.0, 1.0), hint="RGBA colour tint") def __init__( self, mesh: Mesh2D | None = None, texture: Any = None, modulate: tuple = (1.0, 1.0, 1.0, 1.0), position=None, rotation: float = 0.0, scale=None, **kwargs, ): super().__init__(position=position, rotation=rotation, scale=scale, **kwargs) self._mesh: Mesh2D | None = mesh if texture is not None: self.texture = texture self.modulate = modulate # GPU texture id set by the graphics backend (like Sprite2D) self._texture_id: int = -1 # -- mesh property -------------------------------------------------------- @property def mesh(self) -> Mesh2D | None: return self._mesh @mesh.setter def mesh(self, value: Mesh2D | None): self._mesh = value # -- drawing --------------------------------------------------------------
[docs] def draw(self, renderer) -> None: """Emit mesh triangles through the renderer's immediate-mode API.""" m = self._mesh if m is None or m.is_empty or not self.visible: return verts = m.vertices indices = m.indices has_colours = m.colours.size > 0 and len(m.colours) == len(verts) mod = self.modulate # Pre-compute transform pos = self.world_position rot = self.world_rotation sc = self.world_scale cos_r, sin_r = math.cos(rot), math.sin(rot) sx, sy = float(sc.x), float(sc.y) ox, oy = float(pos.x), float(pos.y) def _tf(x: float, y: float) -> tuple[float, float]: lx, ly = x * sx, y * sy return lx * cos_r - ly * sin_r + ox, lx * sin_r + ly * cos_r + oy # Resolve per-vertex colour blended with modulate def _col(i: int) -> tuple[float, float, float, float]: if has_colours: c = m.colours[i] return (c[0] * mod[0], c[1] * mod[1], c[2] * mod[2], c[3] * mod[3]) return mod tri_count = len(indices) // 3 for t in range(tri_count): i0, i1, i2 = int(indices[t * 3]), int(indices[t * 3 + 1]), int(indices[t * 3 + 2]) x0, y0 = _tf(float(verts[i0][0]), float(verts[i0][1])) x1, y1 = _tf(float(verts[i1][0]), float(verts[i1][1])) x2, y2 = _tf(float(verts[i2][0]), float(verts[i2][1])) col = _col(i0) renderer.draw_filled_triangle(x0, y0, x1, y1, x2, y2, col)
# -- serialisation --------------------------------------------------------
[docs] def to_dict(self) -> dict: """Serialise to a dictionary.""" d: dict[str, Any] = { "position": [float(self.position.x), float(self.position.y)], "rotation": self.rotation, "scale": [float(self.scale.x), float(self.scale.y)], "modulate": list(self.modulate), "visible": self.visible, } if self.texture is not None: d["texture"] = self.texture if self._mesh is not None: d["mesh"] = { "vertices": self._mesh.vertices.tolist(), "indices": self._mesh.indices.tolist(), "uvs": self._mesh.uvs.tolist() if self._mesh.uvs.size else [], "colours": self._mesh.colours.tolist() if self._mesh.colours.size else [], } return d
[docs] @classmethod def from_dict(cls, data: dict) -> MeshInstance2D: """Deserialise from a dictionary.""" mesh_data = data.get("mesh") mesh = None if mesh_data: mesh = Mesh2D( vertices=mesh_data.get("vertices", []), indices=mesh_data.get("indices", []), uvs=mesh_data.get("uvs", []), colours=mesh_data.get("colours", []), ) inst = cls( mesh=mesh, texture=data.get("texture"), modulate=tuple(data.get("modulate", (1, 1, 1, 1))), position=Vec2(*data.get("position", [0, 0])), rotation=data.get("rotation", 0.0), scale=Vec2(*data.get("scale", [1, 1])), ) inst.visible = data.get("visible", True) return inst