Customize Geometry

Create custom mesh components with procedural geometry

Overview#

Custom geometry allows you to create procedural mesh components that generate vertices and faces dynamically. This is useful for parametric shapes, visualizations, and custom primitives.

MeshComponent Base#

All custom geometry extends the MeshComponent class:

from witwin.components import MeshComponent, component, float_field, int_field
import numpy as np
 
@component(name="CustomMesh")
class CustomMeshComponent(MeshComponent):
    radius = float_field(1.0, min=0.1, max=10.0)
 
    def _generate_mesh(self):
        """Generate and return (vertices, faces) as numpy arrays."""
        vertices = np.array([...], dtype=np.float32)  # (N, 3)
        faces = np.array([...], dtype=np.uint32)      # (M, 3)
        return vertices, faces

Mesh Data Format#

Vertices#

Vertices are stored as a (N, 3) numpy array of float32:

# Each row is [x, y, z]
vertices = np.array([
    [0.0, 0.0, 0.0],    # Vertex 0
    [1.0, 0.0, 0.0],    # Vertex 1
    [0.5, 1.0, 0.0],    # Vertex 2
], dtype=np.float32)

Faces#

Faces are stored as a (M, 3) numpy array of uint32, representing triangle indices:

# Each row is [v0, v1, v2] - indices into vertices array
faces = np.array([
    [0, 1, 2],  # Triangle using vertices 0, 1, 2
], dtype=np.uint32)

Implementing _generate_mesh#

The _generate_mesh method is called whenever mesh parameters change:

def _generate_mesh(self):
    # Access component fields via self
    r = float(self.radius)
    n = int(self.segments)
 
    # Generate vertex positions
    vertices = [...]
 
    # Generate face indices
    faces = [...]
 
    return (
        np.array(vertices, dtype=np.float32),
        np.array(faces, dtype=np.uint32)
    )

Triggering Regeneration#

Call self._mark_dirty() when parameters change:

def on_value_change(self, field_name, old_value, new_value):
    if field_name in ['radius', 'segments']:
        self._mark_dirty()  # Triggers _generate_mesh

Examples#

Circle/Disc Mesh#

from witwin.components import MeshComponent, component, float_field, int_field, bool_field
import numpy as np
 
@component(name="CircleMesh", display_name="Circle Mesh")
class CircleMeshComponent(MeshComponent):
    radius = float_field(1.0, min=0.01, max=100.0, description="Circle radius")
    segments = int_field(32, min=3, max=256, description="Number of segments")
    fill = bool_field(True, description="Fill the circle (disc vs ring)")
 
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.mesh_type = "Circle"
        self._mark_dirty()
 
    def _generate_mesh(self):
        r = float(self.radius)
        n = int(self.segments)
        angles = np.linspace(0, 2 * np.pi, n, endpoint=False)
 
        if self.fill:
            # Disc: center point + edge points
            vertices = [[0.0, 0.0, 0.0]]  # Center
            for angle in angles:
                x = r * np.cos(angle)
                z = r * np.sin(angle)
                vertices.append([x, 0.0, z])
            vertices = np.array(vertices, dtype=np.float32)
 
            # Fan triangulation from center
            faces = []
            for i in range(n):
                i_curr = i + 1
                i_next = (i + 1) % n + 1
                faces.append([0, i_next, i_curr])
            faces = np.array(faces, dtype=np.uint32)
        else:
            # Ring: just edge points, no faces
            vertices = []
            for angle in angles:
                x = r * np.cos(angle)
                z = r * np.sin(angle)
                vertices.append([x, 0.0, z])
            vertices = np.array(vertices, dtype=np.float32)
            faces = np.zeros((0, 3), dtype=np.uint32)
 
        return vertices, faces
 
    def on_value_change(self, field_name, old_value, new_value):
        if field_name in ['radius', 'segments', 'fill']:
            self._mark_dirty()

Grid Mesh#

@component(name="GridMesh", display_name="Grid Mesh")
class GridMeshComponent(MeshComponent):
    width = float_field(10.0, min=0.1, max=100.0)
    height = float_field(10.0, min=0.1, max=100.0)
    divisions_x = int_field(10, min=1, max=100)
    divisions_y = int_field(10, min=1, max=100)
 
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.mesh_type = "Grid"
        self._mark_dirty()
 
    def _generate_mesh(self):
        w = float(self.width)
        h = float(self.height)
        dx = int(self.divisions_x)
        dy = int(self.divisions_y)
 
        vertices = []
        faces = []
 
        # Generate vertices
        for j in range(dy + 1):
            for i in range(dx + 1):
                x = (i / dx - 0.5) * w
                z = (j / dy - 0.5) * h
                vertices.append([x, 0.0, z])
 
        # Generate faces (two triangles per cell)
        for j in range(dy):
            for i in range(dx):
                v0 = j * (dx + 1) + i
                v1 = v0 + 1
                v2 = v0 + (dx + 1)
                v3 = v2 + 1
 
                faces.append([v0, v2, v1])
                faces.append([v1, v2, v3])
 
        return (
            np.array(vertices, dtype=np.float32),
            np.array(faces, dtype=np.uint32)
        )
 
    def on_value_change(self, field_name, old_value, new_value):
        if field_name in ['width', 'height', 'divisions_x', 'divisions_y']:
            self._mark_dirty()

Parametric Surface#

@component(name="ParametricSurface", display_name="Parametric Surface")
class ParametricSurfaceComponent(MeshComponent):
    resolution = int_field(32, min=4, max=128)
    amplitude = float_field(1.0, min=0.0, max=5.0)
    frequency = float_field(2.0, min=0.1, max=10.0)
 
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.mesh_type = "ParametricSurface"
        self._mark_dirty()
 
    def _generate_mesh(self):
        n = int(self.resolution)
        amp = float(self.amplitude)
        freq = float(self.frequency)
 
        vertices = []
        faces = []
 
        # Generate vertices on a parametric surface
        for j in range(n):
            for i in range(n):
                u = i / (n - 1)
                v = j / (n - 1)
 
                x = u * 10 - 5
                z = v * 10 - 5
 
                # Parametric height function
                y = amp * np.sin(freq * x) * np.cos(freq * z)
 
                vertices.append([x, y, z])
 
        # Generate faces
        for j in range(n - 1):
            for i in range(n - 1):
                v0 = j * n + i
                v1 = v0 + 1
                v2 = v0 + n
                v3 = v2 + 1
 
                faces.append([v0, v2, v1])
                faces.append([v1, v2, v3])
 
        return (
            np.array(vertices, dtype=np.float32),
            np.array(faces, dtype=np.uint32)
        )
 
    def on_value_change(self, field_name, old_value, new_value):
        if field_name in ['resolution', 'amplitude', 'frequency']:
            self._mark_dirty()

Manual Mesh Data#

You can also set mesh data directly without using _generate_mesh:

@component(name="DynamicMesh")
class DynamicMeshComponent(MeshComponent):
 
    @button(display_name="Load From File")
    def load_from_file(self):
        vertices = load_vertices_from_file()
        faces = load_faces_from_file()
        self.set_mesh_data(vertices, faces)
        return "Mesh loaded"

set_mesh_data Method#

def set_mesh_data(
    self,
    vertices: np.ndarray,  # (N, 3) float32
    faces: np.ndarray,     # (M, 3) uint32
    notify: bool = True    # Sync to frontend
)

Accessing Mesh Properties#

class MeshComponent:
    @property
    def vertex_count(self) -> int:
        """Number of vertices."""
        pass
 
    @property
    def face_count(self) -> int:
        """Number of triangular faces."""
        pass
 
    @property
    def is_empty(self) -> bool:
        """Check if mesh has no geometry."""
        pass
 
    @property
    def drjit_vertices(self):
        """Get vertices as drjit Array3f (3, N) for ray tracing."""
        pass
 
    @property
    def drjit_faces(self):
        """Get faces as drjit UInt32 (M*3,) flattened."""
        pass

Complete Example: Antenna Pattern#

from witwin.components import MeshComponent, component, float_field, int_field, string_field
from witwin import Console
import numpy as np
 
@component(name="AntennaPattern", display_name="Antenna Pattern Mesh")
class AntennaPatternComponent(MeshComponent):
    """Visualize antenna radiation pattern as a 3D mesh."""
 
    pattern_type = string_field(
        "dipole",
        options=["dipole", "patch", "horn", "isotropic"],
        description="Antenna pattern type"
    )
    scale = float_field(1.0, min=0.1, max=10.0, description="Pattern scale")
    theta_segments = int_field(32, min=8, max=64, description="Elevation segments")
    phi_segments = int_field(64, min=16, max=128, description="Azimuth segments")
 
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.mesh_type = "AntennaPattern"
        self._mark_dirty()
 
    def _generate_mesh(self):
        n_theta = int(self.theta_segments)
        n_phi = int(self.phi_segments)
        scale = float(self.scale)
 
        vertices = []
        faces = []
 
        # Generate vertices based on pattern
        for j in range(n_theta + 1):
            theta = np.pi * j / n_theta  # 0 to pi
 
            for i in range(n_phi):
                phi = 2 * np.pi * i / n_phi  # 0 to 2*pi
 
                # Calculate pattern gain
                gain = self._calculate_pattern(theta, phi)
                r = scale * gain
 
                # Spherical to Cartesian
                x = r * np.sin(theta) * np.cos(phi)
                y = r * np.cos(theta)
                z = r * np.sin(theta) * np.sin(phi)
 
                vertices.append([x, y, z])
 
        # Generate faces
        for j in range(n_theta):
            for i in range(n_phi):
                v0 = j * n_phi + i
                v1 = j * n_phi + (i + 1) % n_phi
                v2 = (j + 1) * n_phi + i
                v3 = (j + 1) * n_phi + (i + 1) % n_phi
 
                faces.append([v0, v2, v1])
                faces.append([v1, v2, v3])
 
        Console.log(f"Generated {self.pattern_type} pattern: {len(vertices)} vertices")
 
        return (
            np.array(vertices, dtype=np.float32),
            np.array(faces, dtype=np.uint32)
        )
 
    def _calculate_pattern(self, theta: float, phi: float) -> float:
        """Calculate antenna gain at given angles."""
        if self.pattern_type == "isotropic":
            return 1.0
 
        elif self.pattern_type == "dipole":
            # Dipole pattern: sin(theta)
            return abs(np.sin(theta)) + 0.01
 
        elif self.pattern_type == "patch":
            # Simple patch antenna approximation
            return max(0.01, np.cos(theta) ** 2)
 
        elif self.pattern_type == "horn":
            # Horn antenna approximation
            return max(0.01, np.cos(theta) ** 4)
 
        return 1.0
 
    def on_value_change(self, field_name, old_value, new_value):
        if field_name in ['pattern_type', 'scale', 'theta_segments', 'phi_segments']:
            self._mark_dirty()