pyworldgen 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. pyworldgen/__init__.py +73 -0
  2. pyworldgen/biomes/__init__.py +60 -0
  3. pyworldgen/biomes/cave.py +353 -0
  4. pyworldgen/biomes/flat.py +110 -0
  5. pyworldgen/biomes/forest.py +697 -0
  6. pyworldgen/biomes/landscape.py +542 -0
  7. pyworldgen/compose/__init__.py +23 -0
  8. pyworldgen/compose/forest_overlay.py +57 -0
  9. pyworldgen/compose/palette.py +70 -0
  10. pyworldgen/compose/regions.py +77 -0
  11. pyworldgen/compose/world.py +198 -0
  12. pyworldgen/core/__init__.py +67 -0
  13. pyworldgen/core/aabb.py +104 -0
  14. pyworldgen/core/coords.py +82 -0
  15. pyworldgen/core/hash.py +215 -0
  16. pyworldgen/core/math_types.py +83 -0
  17. pyworldgen/core/rng.py +37 -0
  18. pyworldgen/fields/__init__.py +5 -0
  19. pyworldgen/fields/base.py +81 -0
  20. pyworldgen/generation/__init__.py +27 -0
  21. pyworldgen/generation/biomes.py +39 -0
  22. pyworldgen/generation/caves.py +78 -0
  23. pyworldgen/generation/features.py +43 -0
  24. pyworldgen/generation/pass_base.py +39 -0
  25. pyworldgen/generation/scheduler.py +48 -0
  26. pyworldgen/generation/structures.py +20 -0
  27. pyworldgen/generation/terrain.py +46 -0
  28. pyworldgen/io/__init__.py +50 -0
  29. pyworldgen/io/obj.py +159 -0
  30. pyworldgen/io/pointcloud_io.py +93 -0
  31. pyworldgen/io/serialization.py +98 -0
  32. pyworldgen/io/voxel_io.py +105 -0
  33. pyworldgen/mesh/__init__.py +18 -0
  34. pyworldgen/mesh/cube_faces.py +21 -0
  35. pyworldgen/mesh/dense_mesher.py +150 -0
  36. pyworldgen/mesh/greedy_mesher.py +123 -0
  37. pyworldgen/mesh/mesh.py +76 -0
  38. pyworldgen/mesh/naive_mesher.py +54 -0
  39. pyworldgen/noise/__init__.py +95 -0
  40. pyworldgen/noise/_grad.py +54 -0
  41. pyworldgen/noise/cellular.py +76 -0
  42. pyworldgen/noise/fractal.py +87 -0
  43. pyworldgen/noise/gabor.py +67 -0
  44. pyworldgen/noise/opensimplex.py +98 -0
  45. pyworldgen/noise/perlin.py +64 -0
  46. pyworldgen/noise/phasor.py +44 -0
  47. pyworldgen/noise/simplex.py +104 -0
  48. pyworldgen/noise/warp.py +67 -0
  49. pyworldgen/noise/wave.py +61 -0
  50. pyworldgen/noise/wavelet.py +128 -0
  51. pyworldgen/pointcloud/__init__.py +12 -0
  52. pyworldgen/pointcloud/cloud.py +155 -0
  53. pyworldgen/registry.py +147 -0
  54. pyworldgen/rendering/__init__.py +122 -0
  55. pyworldgen/rendering/animation.py +86 -0
  56. pyworldgen/rendering/backends.py +124 -0
  57. pyworldgen/rendering/camera.py +130 -0
  58. pyworldgen/rendering/colormap.py +54 -0
  59. pyworldgen/rendering/paths.py +145 -0
  60. pyworldgen/rendering/scene.py +114 -0
  61. pyworldgen/rendering/software.py +169 -0
  62. pyworldgen/roads/__init__.py +38 -0
  63. pyworldgen/roads/cost.py +61 -0
  64. pyworldgen/roads/generate.py +262 -0
  65. pyworldgen/roads/geometry.py +82 -0
  66. pyworldgen/roads/network.py +105 -0
  67. pyworldgen/roads/solver.py +161 -0
  68. pyworldgen/roads/terrain.py +94 -0
  69. pyworldgen/runtime/__init__.py +6 -0
  70. pyworldgen/runtime/chunk_streamer.py +33 -0
  71. pyworldgen/runtime/navigation.py +61 -0
  72. pyworldgen/spatial/__init__.py +46 -0
  73. pyworldgen/spatial/bvh.py +110 -0
  74. pyworldgen/spatial/dda.py +91 -0
  75. pyworldgen/spatial/fps.py +111 -0
  76. pyworldgen/spatial/graph.py +90 -0
  77. pyworldgen/spatial/kdtree.py +101 -0
  78. pyworldgen/spatial/octree.py +67 -0
  79. pyworldgen/spatial/quadtree.py +78 -0
  80. pyworldgen/spatial/rtree.py +150 -0
  81. pyworldgen/spatial/spatial_hash.py +67 -0
  82. pyworldgen/voxel/__init__.py +51 -0
  83. pyworldgen/voxel/blocks.py +57 -0
  84. pyworldgen/voxel/lighting.py +25 -0
  85. pyworldgen/world/__init__.py +20 -0
  86. pyworldgen/world/block_storage.py +58 -0
  87. pyworldgen/world/chunk.py +73 -0
  88. pyworldgen/world/chunk_map.py +37 -0
  89. pyworldgen/world/edits.py +49 -0
  90. pyworldgen/world/queries.py +69 -0
  91. pyworldgen/world/world.py +34 -0
  92. pyworldgen-0.1.0.dist-info/METADATA +460 -0
  93. pyworldgen-0.1.0.dist-info/RECORD +95 -0
  94. pyworldgen-0.1.0.dist-info/WHEEL +5 -0
  95. pyworldgen-0.1.0.dist-info/top_level.txt +1 -0
pyworldgen/__init__.py ADDED
@@ -0,0 +1,73 @@
1
+ """pyworldgen -- a unified procedural biome-generation library.
2
+
3
+ It brings three previously separate code bases under one roof:
4
+
5
+ * a chunked voxel *world* with a generation-pass framework (was ``procgen3d``);
6
+ * pure-function *biome* generators -- caves, forests, landscapes (was
7
+ ``biomegen_lab``);
8
+ * the streamed implicit *cave* with its cross-language hash.
9
+
10
+ The unification rests on three shared ideas:
11
+
12
+ 1. **One deterministic, cross-language hash** (:mod:`pyworldgen.core.hash`)
13
+ drives every generator, so a world is reproducible from a seed on any
14
+ backend. This replaces three duplicated hash implementations (one of which
15
+ had a latent ``int64`` overflow).
16
+ 2. **A field abstraction** (:mod:`pyworldgen.fields`): a biome can be a pure
17
+ ``is_solid(x, y, z)`` function, and the same function can *fill chunks* in
18
+ the array-backed world -- so a streamed cave is meshed, saved and streamed
19
+ like hand-authored terrain.
20
+ 3. **Compact source, heavy views**: seeds/params/graphs are the source of
21
+ truth; meshes, point clouds and saved chunks are regenerable views.
22
+
23
+ Quick start::
24
+
25
+ import pyworldgen as bf
26
+ bf.list_generators()
27
+ forest = bf.generate("forest", seed="grenoble", target_tree_count=60)
28
+ cave = bf.generate("implicit_cave", seed="luxembourg", length=90.0)
29
+ """
30
+
31
+ from . import (
32
+ biomes,
33
+ compose,
34
+ core,
35
+ fields,
36
+ generation,
37
+ io,
38
+ mesh,
39
+ noise,
40
+ pointcloud,
41
+ rendering,
42
+ roads,
43
+ runtime,
44
+ spatial,
45
+ voxel,
46
+ world,
47
+ )
48
+ from .registry import generate, get_info, list_generators, register
49
+
50
+ __version__ = "0.1.0"
51
+
52
+ __all__ = [
53
+ "core",
54
+ "spatial",
55
+ "fields",
56
+ "voxel",
57
+ "world",
58
+ "mesh",
59
+ "noise",
60
+ "generation",
61
+ "runtime",
62
+ "biomes",
63
+ "pointcloud",
64
+ "rendering",
65
+ "roads",
66
+ "compose",
67
+ "io",
68
+ "generate",
69
+ "list_generators",
70
+ "get_info",
71
+ "register",
72
+ "__version__",
73
+ ]
@@ -0,0 +1,60 @@
1
+ """Biome generators, all built on the shared core hash / field abstraction."""
2
+
3
+ from .cave import (
4
+ CaveParams,
5
+ DenseVoxelVolume,
6
+ FusedCaveField,
7
+ ImplicitCave,
8
+ corridor_world_point,
9
+ sample_cave,
10
+ )
11
+ from .flat import FlatTerrain, FlatTerrainParams, flat_terrain_stats, generate_flat
12
+ from .forest import (
13
+ ForestParams,
14
+ ForestWorld,
15
+ TreeGraph,
16
+ TreeSite,
17
+ forest_stats,
18
+ generate_forest,
19
+ validate_forest,
20
+ )
21
+ from .landscape import (
22
+ LandscapeParams,
23
+ LandscapeWorld,
24
+ Pond,
25
+ RiverSegment,
26
+ generate_landscape,
27
+ generate_river_network,
28
+ landscape_stats,
29
+ )
30
+
31
+ __all__ = [
32
+ # cave
33
+ "CaveParams",
34
+ "FusedCaveField",
35
+ "ImplicitCave",
36
+ "DenseVoxelVolume",
37
+ "sample_cave",
38
+ "corridor_world_point",
39
+ # flat terrain
40
+ "FlatTerrain",
41
+ "FlatTerrainParams",
42
+ "generate_flat",
43
+ "flat_terrain_stats",
44
+ # forest
45
+ "ForestParams",
46
+ "ForestWorld",
47
+ "TreeSite",
48
+ "TreeGraph",
49
+ "generate_forest",
50
+ "forest_stats",
51
+ "validate_forest",
52
+ # landscape
53
+ "LandscapeParams",
54
+ "LandscapeWorld",
55
+ "RiverSegment",
56
+ "Pond",
57
+ "generate_landscape",
58
+ "generate_river_network",
59
+ "landscape_stats",
60
+ ]
@@ -0,0 +1,353 @@
1
+ """Implicit streamed cave -- a pure function of position, no stored grid.
2
+
3
+ Ported from the original ``cavegen`` field but re-based on the unified
4
+ :mod:`pyworldgen.core.hash`, so it is bit-reproducible across Python / JS / GLSL
5
+ and composes with the chunked world through :class:`~pyworldgen.fields`.
6
+
7
+ Openness is the union (``max``) of three constructive fields, then a
8
+ noise-perturbed wall and optional speleothems:
9
+
10
+ 1. **Corridor** -- a guaranteed, always-open wandering tube whose centreline
11
+ ``(corrX(z), corrY(z))`` depends only on the tunnel coordinate ``z``. Its
12
+ openness is strictly positive along the axis, so the whole (unbounded)
13
+ length stays navigable no matter how the other fields are tuned.
14
+ 2. **Worms** -- thin branching tunnels where two independent ridged fBm fields
15
+ are simultaneously near zero.
16
+ 3. **Caverns** -- rare low-frequency "cheese" rooms.
17
+ 4. **Wall** -- rock where openness ``<= ROUGH * signedNoise``, undulating the
18
+ surface instead of leaving a clean isosurface.
19
+ 5. **Speleothems** -- Worley-style stalactite/stalagmite cones inside the corridor.
20
+
21
+ Coordinate bridge (spec vs world):
22
+
23
+ * spec ``x`` = lateral (centred on 0), ``y`` = up, ``z`` = tunnel distance.
24
+ * world (Z-up chunk world): ``spec_x = wx - XB``, ``spec_y = wz``, ``spec_z = wy``.
25
+
26
+ :class:`ImplicitCave` applies that bridge so ``is_solid(wx, wy, wz)`` is a valid
27
+ world-space :class:`SolidField3D` and can drive a chunk pass directly.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from dataclasses import asdict, dataclass, field as dataclass_field
33
+ from typing import Any
34
+
35
+ import numpy as np
36
+
37
+ from ..core.hash import fbm3, hashf, ridged_fbm3, seed_to_int, signed_fbm3
38
+
39
+
40
+ @dataclass
41
+ class CaveParams:
42
+ seed: int = 0
43
+
44
+ # Physical bounds in spec coordinates.
45
+ FLOOR: float = 0.0
46
+ CEIL: float = 54.0
47
+ XB: float = 58.0
48
+ CY0: float = 27.0
49
+
50
+ # Guaranteed main corridor.
51
+ CORR_R: float = 7.5
52
+ CORR_ROUGH: float = 3.5
53
+ CORR_X_FREQ: float = 0.016
54
+ CORR_X_AMP: float = 18.0
55
+ CORR_X_NOISE_FREQ: float = 0.010
56
+ CORR_X_NOISE_AMP: float = 12.0
57
+ CORR_Y_FREQ: float = 0.024
58
+ CORR_Y_AMP: float = 5.0
59
+ CORR_Y_NOISE_FREQ: float = 0.013
60
+ CORR_Y_NOISE_AMP: float = 4.0
61
+
62
+ # Ridged dual-fBm worm tunnels.
63
+ FW: float = 0.05
64
+ WORM_T: float = 0.085
65
+ WSCALE: float = 26.0
66
+
67
+ # Low-frequency caverns.
68
+ FC: float = 0.022
69
+ CAV_T: float = 0.60
70
+ CSCALE: float = 34.0
71
+
72
+ # Wall roughening.
73
+ ROUGH: float = 2.2
74
+ RFREQ: float = 0.13
75
+
76
+ # Speleothems.
77
+ SPELEO: bool = True
78
+ SPELEO_CELL: float = 6.0
79
+ SPELEO_SPAWN: float = 0.20
80
+
81
+ @classmethod
82
+ def from_seed(cls, seed: int | str, **overrides: Any) -> "CaveParams":
83
+ p = cls(seed=seed_to_int(seed))
84
+ for key, value in (overrides or {}).items():
85
+ if not hasattr(p, key):
86
+ raise KeyError(f"Unknown CaveParams field: {key}")
87
+ setattr(p, key, value)
88
+ return p
89
+
90
+ def to_dict(self) -> dict[str, Any]:
91
+ return asdict(self)
92
+
93
+
94
+ class FusedCaveField:
95
+ """Pure cave field in spec coordinates. ``density > 0`` means air."""
96
+
97
+ def __init__(self, params: CaveParams):
98
+ self.p = params
99
+
100
+ def corr_x(self, z):
101
+ p = self.p
102
+ return (
103
+ np.sin(z * p.CORR_X_FREQ) * p.CORR_X_AMP
104
+ + signed_fbm3(z * p.CORR_X_NOISE_FREQ, 5.2, 1.7, 2, p.seed + 11)
105
+ * p.CORR_X_NOISE_AMP
106
+ )
107
+
108
+ def corr_y(self, z):
109
+ p = self.p
110
+ y = (
111
+ p.CY0
112
+ + np.sin(z * p.CORR_Y_FREQ + 1.3) * p.CORR_Y_AMP
113
+ + signed_fbm3(z * p.CORR_Y_NOISE_FREQ, 9.1, 4.4, 2, p.seed + 17)
114
+ * p.CORR_Y_NOISE_AMP
115
+ )
116
+ return np.clip(y, p.FLOOR + p.CORR_R + 3.0, p.CEIL - p.CORR_R - 3.0)
117
+
118
+ def components(self, x, y, z) -> dict[str, np.ndarray]:
119
+ p = self.p
120
+ x = np.asarray(x, dtype=np.float64)
121
+ y = np.asarray(y, dtype=np.float64)
122
+ z = np.asarray(z, dtype=np.float64)
123
+
124
+ cx = self.corr_x(z)
125
+ cy = self.corr_y(z)
126
+ dist = np.hypot(x - cx, y - cy)
127
+
128
+ rad = p.CORR_R + p.CORR_ROUGH * signed_fbm3(
129
+ x * 0.05, y * 0.05, z * 0.05, 2, p.seed + 23
130
+ )
131
+ corridor = rad - dist
132
+
133
+ n1 = ridged_fbm3(x * p.FW, y * p.FW, z * p.FW, 3, p.seed + 31)
134
+ n2 = ridged_fbm3(
135
+ (x + 131.0) * p.FW, (y + 57.0) * p.FW, (z + 89.0) * p.FW, 3, p.seed + 37
136
+ )
137
+ worm = (p.WORM_T - np.maximum(n1, n2)) * p.WSCALE
138
+
139
+ cavern = (
140
+ fbm3(x * p.FC + 90.0, y * p.FC + 40.0, z * p.FC + 10.0, 2, p.seed + 41)
141
+ - p.CAV_T
142
+ ) * p.CSCALE
143
+ rough = p.ROUGH * signed_fbm3(
144
+ x * p.RFREQ, y * p.RFREQ, z * p.RFREQ, 2, p.seed + 43
145
+ )
146
+
147
+ return {
148
+ "center_x": cx,
149
+ "center_y": cy,
150
+ "radius": rad,
151
+ "dist_to_corridor": dist,
152
+ "corridor": corridor,
153
+ "worm": worm,
154
+ "cavern": cavern,
155
+ "rough": rough,
156
+ }
157
+
158
+ def density(self, x, y, z, *, include_speleothems: bool = True):
159
+ p = self.p
160
+ comp = self.components(x, y, z)
161
+ openness = np.maximum.reduce([comp["corridor"], comp["worm"], comp["cavern"]])
162
+ density = openness - comp["rough"]
163
+
164
+ y = np.asarray(y, dtype=np.float64)
165
+ x = np.asarray(x, dtype=np.float64)
166
+ hard_shell = (y <= p.FLOOR) | (y >= p.CEIL) | (x <= -p.XB) | (x >= p.XB)
167
+ density = np.where(hard_shell, -1.0, density)
168
+
169
+ if include_speleothems and p.SPELEO:
170
+ spikes = self.speleothem_mask(x, y, z, comp)
171
+ density = np.where(spikes & (density > 0), -1.0, density)
172
+ return density
173
+
174
+ def speleothem_mask(self, x, y, z, comp):
175
+ p = self.p
176
+ S = float(p.SPELEO_CELL)
177
+ x = np.asarray(x, dtype=np.float64)
178
+ y = np.asarray(y, dtype=np.float64)
179
+ z = np.asarray(z, dtype=np.float64)
180
+
181
+ gi = np.floor(x / S).astype(np.int64)
182
+ gk = np.floor(z / S).astype(np.int64)
183
+ hit = np.zeros(np.broadcast(x, y, z).shape, dtype=bool)
184
+
185
+ for dgi in (-1, 0):
186
+ for dgk in (-1, 0):
187
+ ci = gi + dgi
188
+ ck = gk + dgk
189
+ spawn = hashf(ci, ck, 3, seed=p.seed + 51) < p.SPELEO_SPAWN
190
+ sx = ci * S + hashf(ci, ck, 7, seed=p.seed + 53) * S
191
+ sz = ck * S + hashf(ci, ck, 1, seed=p.seed + 59) * S
192
+ da = np.hypot(x - sx, z - sz)
193
+
194
+ rad = comp["radius"]
195
+ cy = comp["center_y"]
196
+ h = 3.0 + hashf(ci, ck, 5, seed=p.seed + 61) * (rad * 0.85)
197
+ r_base = 0.6 + hashf(ci, ck, 9, seed=p.seed + 67) * 1.3
198
+ floor_y = cy - rad
199
+ ceil_y = cy + rad
200
+ is_stalactite = hashf(ci, ck, 4, seed=p.seed + 71) > 0.5
201
+
202
+ tip = ceil_y - h
203
+ cone_top = (
204
+ (y <= ceil_y)
205
+ & (y >= tip)
206
+ & (da < r_base * (1.0 - (ceil_y - y) / np.maximum(h, 1e-6)))
207
+ )
208
+ top = floor_y + h
209
+ cone_bottom = (
210
+ (y >= floor_y)
211
+ & (y <= top)
212
+ & (da < r_base * (1.0 - (y - floor_y) / np.maximum(h, 1e-6)))
213
+ )
214
+ hit |= spawn & np.where(is_stalactite, cone_top, cone_bottom)
215
+
216
+ near_corridor = comp["dist_to_corridor"] < comp["radius"] * 1.4
217
+ return hit & near_corridor
218
+
219
+
220
+ class ImplicitCave:
221
+ """World-space :class:`SolidField3D` wrapping :class:`FusedCaveField`.
222
+
223
+ Applies the spec<->world coordinate bridge so the cave can fill chunks:
224
+ world axis 0 = lateral, axis 1 = tunnel, axis 2 = up.
225
+ """
226
+
227
+ def __init__(self, params: CaveParams):
228
+ self.params = params
229
+ self.field = FusedCaveField(params)
230
+
231
+ @classmethod
232
+ def from_seed(cls, seed: int | str, **overrides: Any) -> "ImplicitCave":
233
+ return cls(CaveParams.from_seed(seed, **overrides))
234
+
235
+ def density_world(self, wx, wy, wz):
236
+ p = self.params
237
+ spec_x = np.asarray(wx, dtype=np.float64) - p.XB
238
+ spec_y = np.asarray(wz, dtype=np.float64)
239
+ spec_z = np.asarray(wy, dtype=np.float64)
240
+ return self.field.density(spec_x, spec_y, spec_z)
241
+
242
+ def is_solid(self, wx, wy, wz):
243
+ return self.density_world(wx, wy, wz) <= 0.0
244
+
245
+
246
+ @dataclass
247
+ class DenseVoxelVolume:
248
+ """A finite sampled cave volume. ``occ=True`` is solid, ``occ=False`` is air.
249
+
250
+ Axis convention: axis 0 = lateral, axis 1 = tunnel, axis 2 = up (matches the
251
+ chunked world). This is a lightweight replacement for the original heavy
252
+ ``VoxelWorld``; it keeps just what the tests and exporters need.
253
+ """
254
+
255
+ occ: np.ndarray
256
+ resolution: float
257
+ name: str = "cave"
258
+ metadata: dict[str, Any] = dataclass_field(default_factory=dict)
259
+
260
+ @property
261
+ def shape(self):
262
+ return self.occ.shape
263
+
264
+ @property
265
+ def extent(self):
266
+ return np.asarray(self.occ.shape, dtype=float) * float(self.resolution)
267
+
268
+ def solid_fraction(self) -> float:
269
+ return float(self.occ.mean())
270
+
271
+ def surface_points(
272
+ self, max_points: int | None = 80_000, seed: int = 0
273
+ ) -> np.ndarray:
274
+ occ = self.occ
275
+ free = ~occ
276
+ exposed = np.zeros_like(occ)
277
+ exposed[1:, :, :] |= occ[1:, :, :] & free[:-1, :, :]
278
+ exposed[:-1, :, :] |= occ[:-1, :, :] & free[1:, :, :]
279
+ exposed[:, 1:, :] |= occ[:, 1:, :] & free[:, :-1, :]
280
+ exposed[:, :-1, :] |= occ[:, :-1, :] & free[:, 1:, :]
281
+ exposed[:, :, 1:] |= occ[:, :, 1:] & free[:, :, :-1]
282
+ exposed[:, :, :-1] |= occ[:, :, :-1] & free[:, :, 1:]
283
+ idx = np.argwhere(exposed)
284
+ pts = ((idx + 0.5) * self.resolution).astype(np.float32)
285
+ if max_points is not None and len(pts) > max_points:
286
+ rng = np.random.default_rng(seed)
287
+ pts = pts[rng.choice(len(pts), size=max_points, replace=False)]
288
+ return pts
289
+
290
+ def is_collision(self, world_point, radius: float = 0.5) -> bool:
291
+ p = np.asarray(world_point, dtype=float)
292
+ if np.any(p < 0.0) or np.any(p >= self.extent):
293
+ return True
294
+ cell = np.clip(
295
+ np.floor(p / self.resolution).astype(int), 0, np.asarray(self.shape) - 1
296
+ )
297
+ return bool(self.occ[cell[0], cell[1], cell[2]])
298
+
299
+
300
+ def sample_cave(
301
+ *,
302
+ seed: int | str = "cave",
303
+ resolution: float = 1.5,
304
+ length: float = 110.0,
305
+ params: dict | None = None,
306
+ ) -> DenseVoxelVolume:
307
+ """Sample the implicit cave into a finite :class:`DenseVoxelVolume`.
308
+
309
+ The volume spans the full lateral/vertical bounds and *length* along the
310
+ tunnel. The guaranteed corridor ensures both air and rock always exist.
311
+ """
312
+ p = CaveParams.from_seed(seed, **(params or {}))
313
+ field = FusedCaveField(p)
314
+ res = float(resolution)
315
+ nx = int(round(2.0 * p.XB / res))
316
+ ny = int(round(float(length) / res))
317
+ nz = int(round(p.CEIL / res))
318
+
319
+ sx = (np.arange(nx) + 0.5) * res - p.XB
320
+ su = (np.arange(nz) + 0.5) * res
321
+ SX, SU = np.meshgrid(sx, su, indexing="ij")
322
+
323
+ occ = np.zeros((nx, ny, nz), dtype=bool)
324
+ for iy in range(ny):
325
+ tunnel_m = (iy + 0.5) * res
326
+ density = field.density(SX, SU, np.full_like(SX, tunnel_m))
327
+ occ[:, iy, :] = density <= 0.0
328
+
329
+ # Enclosure.
330
+ occ[0, :, :] = occ[-1, :, :] = True
331
+ occ[:, :, 0] = occ[:, :, -1] = True
332
+
333
+ return DenseVoxelVolume(
334
+ occ=occ,
335
+ resolution=res,
336
+ name="implicit_cave",
337
+ metadata={
338
+ "type": "implicit_cave",
339
+ "seed": p.seed,
340
+ "length": float(length),
341
+ "params": p.to_dict(),
342
+ },
343
+ )
344
+
345
+
346
+ def corridor_world_point(params: CaveParams, tunnel_m: float) -> np.ndarray:
347
+ """Point on the guaranteed corridor centreline, in world coordinates."""
348
+ field = FusedCaveField(params)
349
+ z = np.array([float(tunnel_m)])
350
+ cx = float(field.corr_x(z)[0])
351
+ cy = float(field.corr_y(z)[0])
352
+ # world: lateral = spec_x + XB, tunnel = spec_z, up = spec_y
353
+ return np.array([cx + params.XB, float(tunnel_m), cy], dtype=float)
@@ -0,0 +1,110 @@
1
+ """Flat (default) terrain.
2
+
3
+ The simplest terrain: a heightfield that is perfectly flat by default, with
4
+ optional gentle tilt and undulation. It exposes the same ``height`` / ``x`` /
5
+ ``y`` / ``resolution`` interface as :class:`~pyworldgen.biomes.landscape.LandscapeWorld`,
6
+ so anything that consumes a terrain -- the road generator, the terrain mesher,
7
+ and (next) biome composition -- works on it unchanged.
8
+
9
+ Defaults produce a genuinely flat plane (``undulation_amp = 0``, no tilt); it is
10
+ meant as a neutral base to lay roads on and to blend biomes over.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import asdict, dataclass, field
16
+ from typing import Any
17
+
18
+ import numpy as np
19
+
20
+ from ..core.hash import seed_to_int, signed_fbm2
21
+
22
+
23
+ @dataclass
24
+ class FlatTerrainParams:
25
+ seed: int = 0
26
+ domain_size: tuple[float, float] = (120.0, 120.0)
27
+ resolution: float = 2.0
28
+ base_height: float = 8.0
29
+ slope_x: float = 0.0 # linear tilt per metre in x
30
+ slope_y: float = 0.0 # linear tilt per metre in y
31
+ undulation_amp: float = 0.0 # 0 -> perfectly flat
32
+ undulation_scale: float = 0.03
33
+
34
+ @classmethod
35
+ def from_seed(cls, seed: int | str, **overrides: Any) -> "FlatTerrainParams":
36
+ p = cls(seed=seed_to_int(seed))
37
+ for k, v in (overrides or {}).items():
38
+ if not hasattr(p, k):
39
+ raise KeyError(f"Unknown FlatTerrainParams field: {k}")
40
+ setattr(p, k, v)
41
+ return p
42
+
43
+ def to_dict(self) -> dict[str, Any]:
44
+ d = asdict(self)
45
+ d["domain_size"] = list(self.domain_size)
46
+ return d
47
+
48
+
49
+ @dataclass
50
+ class FlatTerrain:
51
+ height: np.ndarray
52
+ water_depth: np.ndarray
53
+ x: np.ndarray
54
+ y: np.ndarray
55
+ resolution: float
56
+ domain_size: tuple[float, float]
57
+ name: str = "flat"
58
+ metadata: dict[str, Any] = field(default_factory=dict)
59
+
60
+ @property
61
+ def shape(self):
62
+ return self.height.shape
63
+
64
+ @property
65
+ def water_surface(self):
66
+ return self.height + self.water_depth
67
+
68
+
69
+ def generate_flat(
70
+ *, seed: int | str = "flat", params: dict | None = None
71
+ ) -> FlatTerrain:
72
+ p = FlatTerrainParams.from_seed(seed, **(params or {}))
73
+ W, H = p.domain_size
74
+ nx = int(round(W / p.resolution))
75
+ ny = int(round(H / p.resolution))
76
+ x = (np.arange(nx) + 0.5) * p.resolution
77
+ y = (np.arange(ny) + 0.5) * p.resolution
78
+ X, Y = np.meshgrid(x, y, indexing="xy")
79
+
80
+ height = np.full((ny, nx), float(p.base_height), dtype=np.float64)
81
+ if p.slope_x or p.slope_y:
82
+ height += p.slope_x * X + p.slope_y * Y
83
+ if p.undulation_amp:
84
+ height += p.undulation_amp * signed_fbm2(
85
+ X * p.undulation_scale, Y * p.undulation_scale, 4, p.seed + 7
86
+ )
87
+
88
+ return FlatTerrain(
89
+ height=height.astype(np.float32),
90
+ water_depth=np.zeros((ny, nx), dtype=np.float32),
91
+ x=x.astype(np.float32),
92
+ y=y.astype(np.float32),
93
+ resolution=float(p.resolution),
94
+ domain_size=(float(W), float(H)),
95
+ metadata={"type": "flat_terrain", "seed": p.seed, "params": p.to_dict()},
96
+ )
97
+
98
+
99
+ def flat_terrain_stats(terrain: FlatTerrain) -> dict[str, Any]:
100
+ h = np.asarray(terrain.height)
101
+ return {
102
+ "type": terrain.metadata.get("type", "flat_terrain"),
103
+ "shape": list(terrain.shape),
104
+ "resolution": float(terrain.resolution),
105
+ "domain_size": [float(v) for v in terrain.domain_size],
106
+ "height_min": float(h.min()),
107
+ "height_max": float(h.max()),
108
+ "height_mean": float(h.mean()),
109
+ "is_flat": bool(np.ptp(h) < 1e-6),
110
+ }