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

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
from .properties import Colour

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 --------------------------------------------------------------
[docs] @property def vertex_count(self) -> int: return len(self.vertices)
[docs] @property def triangle_count(self) -> int: return len(self.indices) // 3
[docs] @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="Texture source: file path, PNG bytes, or RGBA uint8 ndarray") modulate = Colour((1.0, 1.0, 1.0, 1.0)) 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
[docs] @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_polygon([(x0, y0), (x1, y1), (x2, y2)], colour=col)