meshgen.pyΒΆ

Part of Deep Sea Aquarium.

  1"""Procedural mesh generation: pure numpy, no external libraries."""
  2
  3import math
  4
  5import numpy as np
  6
  7from simvx.core import Mesh
  8
  9
 10def compute_normals(positions: np.ndarray, indices: np.ndarray) -> np.ndarray:
 11    """Compute smooth vertex normals from triangle faces."""
 12    normals = np.zeros_like(positions)
 13    for i in range(0, len(indices), 3):
 14        i0, i1, i2 = indices[i], indices[i + 1], indices[i + 2]
 15        v0, v1, v2 = positions[i0], positions[i1], positions[i2]
 16        n = np.cross(v1 - v0, v2 - v0)
 17        normals[i0] += n
 18        normals[i1] += n
 19        normals[i2] += n
 20    lengths = np.linalg.norm(normals, axis=1, keepdims=True)
 21    lengths = np.maximum(lengths, 1e-8)
 22    return (normals / lengths).astype(np.float32)
 23
 24
 25# ============================================================================
 26# Jellyfish Bell: parametric species profiles
 27# ============================================================================
 28
 29
 30def make_jellyfish_bell(
 31    radius: float = 0.8,
 32    height: float = 0.6,
 33    rings: int = 16,
 34    segments: int = 20,
 35    profile: str = "dome",
 36    rim_curl: float = 0.0,
 37    flatness: float = 0.0,
 38    apex_sharpness: float = 0.0,
 39) -> Mesh:
 40    """Parametric jellyfish bell with species-specific profiles.
 41
 42    Profiles:
 43        "dome"  : classic rounded dome (moon jelly)
 44        "tall"  : elongated bell (sea nettle)
 45        "flat"  : wide saucer shape (crystal jelly)
 46        "bulb"  : rounded bulb, narrow at bottom (deep-sea)
 47
 48    Args:
 49        rim_curl: 0-1, how much the bottom rim curls inward
 50        flatness: 0-1, squash ratio (0=sphere, 1=pancake)
 51        apex_sharpness: 0-1, how pointed the top is
 52    """
 53    verts, uvs, idxs = [], [], []
 54
 55    for ring in range(rings + 1):
 56        t = ring / rings  # 0 = top (apex), 1 = bottom (rim)
 57        phi = t * (math.pi / 2)
 58
 59        # Base radius at this ring
 60        if profile == "tall":
 61            # Elongated bell: narrow at top, widens gradually, slight taper at bottom
 62            r_profile = math.sin(phi) ** 0.5 * (1.0 - 0.15 * t)
 63        elif profile == "flat":
 64            # Wide saucer: expands quickly, stays wide
 65            r_profile = math.sin(phi) ** 0.35
 66        elif profile == "bulb":
 67            # Round bulb: full sphere-like, contracts at bottom
 68            r_profile = math.sin(phi * 1.1) ** 0.9 * (1.0 - 0.3 * max(0, t - 0.7) / 0.3)
 69        else:  # dome
 70            r_profile = math.sin(phi) ** 0.7
 71
 72        # Apex sharpness: make the top more pointed
 73        if apex_sharpness > 0 and t < 0.3:
 74            r_profile *= (t / 0.3) ** (apex_sharpness * 0.5)
 75
 76        # Height profile
 77        squash = 1.0 - flatness * 0.6
 78        if profile == "tall":
 79            y = height * 1.4 * math.cos(phi) * squash
 80        elif profile == "flat":
 81            y = height * 0.5 * math.cos(phi) * squash
 82        else:
 83            y = height * math.cos(phi) * squash
 84
 85        # Rim curl: bottom rings curve inward
 86        r_final = radius * r_profile
 87        if rim_curl > 0 and t > 0.75:
 88            curl_t = (t - 0.75) / 0.25  # 0-1 in the curl zone
 89            r_final *= 1.0 - rim_curl * 0.3 * curl_t
 90            y -= rim_curl * 0.15 * radius * curl_t * curl_t  # Slight upward curl
 91
 92        for seg in range(segments + 1):
 93            theta = (seg / segments) * math.tau
 94            x = r_final * math.cos(theta)
 95            z = r_final * math.sin(theta)
 96            verts.append([x, y, z])
 97            uvs.append([seg / segments, t])
 98
 99    for ring in range(rings):
100        for seg in range(segments):
101            i = ring * (segments + 1) + seg
102            j = i + segments + 1
103            idxs.extend([i, i + 1, j, i + 1, j + 1, j])
104
105    positions = np.array(verts, dtype=np.float32)
106    idx = np.array(idxs, dtype=np.uint32)
107    texcoords = np.array(uvs, dtype=np.float32)
108    normals = compute_normals(positions, idx)
109    return Mesh(positions, indices=idx, normals=normals, texcoords=texcoords)
110
111
112def make_oral_arm(length: float = 0.5, width: float = 0.06, subdivisions: int = 8) -> Mesh:
113    """Thick ruffled oral arm: flat ribbon with wavy edges."""
114    verts, uvs, idxs = [], [], []
115    for i in range(subdivisions + 1):
116        t = i / subdivisions
117        y = -t * length
118        # Wavy edge gives a frilly/ruffled look
119        ruffle = width * (1.0 + 0.4 * math.sin(t * math.pi * 4)) * (1.0 - 0.3 * t)
120        verts.append([-ruffle, y, 0])
121        verts.append([ruffle, y, 0])
122        uvs.append([0, t])
123        uvs.append([1, t])
124
125    for i in range(subdivisions):
126        base = i * 2
127        idxs.extend([base, base + 1, base + 2, base + 1, base + 3, base + 2])
128
129    positions = np.array(verts, dtype=np.float32)
130    idx = np.array(idxs, dtype=np.uint32)
131    texcoords = np.array(uvs, dtype=np.float32)
132    normals = compute_normals(positions, idx)
133    return Mesh(positions, indices=idx, normals=normals, texcoords=texcoords)
134
135
136# ============================================================================
137# Tentacle Segment
138# ============================================================================
139
140
141def make_tentacle_segment(length: float = 0.12, radius: float = 0.02, segments: int = 5) -> Mesh:
142    """Small tapered cylinder for jellyfish/anemone tentacles."""
143    verts, uvs, idxs = [], [], []
144    for iy in range(2):
145        y = iy * length
146        r = radius * (1.0 - 0.3 * iy)  # Taper toward bottom
147        for seg in range(segments + 1):
148            theta = (seg / segments) * math.tau
149            x = r * math.cos(theta)
150            z = r * math.sin(theta)
151            verts.append([x, -y, z])  # Hang downward
152            uvs.append([seg / segments, iy])
153
154    for seg in range(segments):
155        i0, i1 = seg, seg + segments + 1
156        idxs.extend([i0, i0 + 1, i1, i0 + 1, i1 + 1, i1])
157
158    positions = np.array(verts, dtype=np.float32)
159    idx = np.array(idxs, dtype=np.uint32)
160    texcoords = np.array(uvs, dtype=np.float32)
161    normals = compute_normals(positions, idx)
162    return Mesh(positions, indices=idx, normals=normals, texcoords=texcoords)
163
164
165# ============================================================================
166# Fish Body
167# ============================================================================
168
169
170def make_fish_body(
171    length: float = 0.6, max_height: float = 0.15, max_width: float = 0.1, rings: int = 8, segments: int = 8
172) -> Mesh:
173    """Streamlined fish body: pointed at both ends, widest at ~40%."""
174    verts, uvs, idxs = [], [], []
175    for ring in range(rings + 1):
176        t = ring / rings  # 0 (nose) to 1 (tail)
177        profile = math.sin(math.pi * t) ** 0.6  # Widest at ~40%
178        ry = max_height * profile
179        rz = max_width * profile
180        x = (t - 0.5) * length
181        for seg in range(segments + 1):
182            theta = (seg / segments) * math.tau
183            y = ry * math.cos(theta)
184            z = rz * math.sin(theta)
185            verts.append([x, y, z])
186            uvs.append([t, seg / segments])
187
188    for ring in range(rings):
189        for seg in range(segments):
190            i = ring * (segments + 1) + seg
191            j = i + segments + 1
192            idxs.extend([i, i + 1, j, i + 1, j + 1, j])
193
194    # Tail fin: two flat triangles
195    base = len(verts)
196    tail_x = length * 0.5
197    verts.extend(
198        [
199            [tail_x, 0, 0],
200            [tail_x + length * 0.2, max_height * 0.6, 0],
201            [tail_x + length * 0.2, -max_height * 0.6, 0],
202        ]
203    )
204    uvs.extend([[1, 0.5], [1, 0], [1, 1]])
205    idxs.extend([base, base + 1, base + 2])
206
207    positions = np.array(verts, dtype=np.float32)
208    idx = np.array(idxs, dtype=np.uint32)
209    texcoords = np.array(uvs, dtype=np.float32)
210    normals = compute_normals(positions, idx)
211    return Mesh(positions, indices=idx, normals=normals, texcoords=texcoords)
212
213
214# ============================================================================
215# Kelp Ribbon
216# ============================================================================
217
218
219def make_kelp_ribbon(height: float = 3.0, width: float = 0.12, subdivisions: int = 6) -> Mesh:
220    """Flat quad strip for kelp: thin ribbon mesh."""
221    verts, uvs, idxs = [], [], []
222    for i in range(subdivisions + 1):
223        t = i / subdivisions
224        y = t * height
225        # Slight wave in the ribbon
226        x_offset = 0.05 * math.sin(t * math.pi * 2)
227        verts.append([x_offset - width / 2, y, 0])
228        verts.append([x_offset + width / 2, y, 0])
229        uvs.append([0, t])
230        uvs.append([1, t])
231
232    for i in range(subdivisions):
233        base = i * 2
234        idxs.extend([base, base + 1, base + 2, base + 1, base + 3, base + 2])
235
236    positions = np.array(verts, dtype=np.float32)
237    idx = np.array(idxs, dtype=np.uint32)
238    texcoords = np.array(uvs, dtype=np.float32)
239    normals = compute_normals(positions, idx)
240    return Mesh(positions, indices=idx, normals=normals, texcoords=texcoords)