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