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)