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