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.
- pyworldgen/__init__.py +73 -0
- pyworldgen/biomes/__init__.py +60 -0
- pyworldgen/biomes/cave.py +353 -0
- pyworldgen/biomes/flat.py +110 -0
- pyworldgen/biomes/forest.py +697 -0
- pyworldgen/biomes/landscape.py +542 -0
- pyworldgen/compose/__init__.py +23 -0
- pyworldgen/compose/forest_overlay.py +57 -0
- pyworldgen/compose/palette.py +70 -0
- pyworldgen/compose/regions.py +77 -0
- pyworldgen/compose/world.py +198 -0
- pyworldgen/core/__init__.py +67 -0
- pyworldgen/core/aabb.py +104 -0
- pyworldgen/core/coords.py +82 -0
- pyworldgen/core/hash.py +215 -0
- pyworldgen/core/math_types.py +83 -0
- pyworldgen/core/rng.py +37 -0
- pyworldgen/fields/__init__.py +5 -0
- pyworldgen/fields/base.py +81 -0
- pyworldgen/generation/__init__.py +27 -0
- pyworldgen/generation/biomes.py +39 -0
- pyworldgen/generation/caves.py +78 -0
- pyworldgen/generation/features.py +43 -0
- pyworldgen/generation/pass_base.py +39 -0
- pyworldgen/generation/scheduler.py +48 -0
- pyworldgen/generation/structures.py +20 -0
- pyworldgen/generation/terrain.py +46 -0
- pyworldgen/io/__init__.py +50 -0
- pyworldgen/io/obj.py +159 -0
- pyworldgen/io/pointcloud_io.py +93 -0
- pyworldgen/io/serialization.py +98 -0
- pyworldgen/io/voxel_io.py +105 -0
- pyworldgen/mesh/__init__.py +18 -0
- pyworldgen/mesh/cube_faces.py +21 -0
- pyworldgen/mesh/dense_mesher.py +150 -0
- pyworldgen/mesh/greedy_mesher.py +123 -0
- pyworldgen/mesh/mesh.py +76 -0
- pyworldgen/mesh/naive_mesher.py +54 -0
- pyworldgen/noise/__init__.py +95 -0
- pyworldgen/noise/_grad.py +54 -0
- pyworldgen/noise/cellular.py +76 -0
- pyworldgen/noise/fractal.py +87 -0
- pyworldgen/noise/gabor.py +67 -0
- pyworldgen/noise/opensimplex.py +98 -0
- pyworldgen/noise/perlin.py +64 -0
- pyworldgen/noise/phasor.py +44 -0
- pyworldgen/noise/simplex.py +104 -0
- pyworldgen/noise/warp.py +67 -0
- pyworldgen/noise/wave.py +61 -0
- pyworldgen/noise/wavelet.py +128 -0
- pyworldgen/pointcloud/__init__.py +12 -0
- pyworldgen/pointcloud/cloud.py +155 -0
- pyworldgen/registry.py +147 -0
- pyworldgen/rendering/__init__.py +122 -0
- pyworldgen/rendering/animation.py +86 -0
- pyworldgen/rendering/backends.py +124 -0
- pyworldgen/rendering/camera.py +130 -0
- pyworldgen/rendering/colormap.py +54 -0
- pyworldgen/rendering/paths.py +145 -0
- pyworldgen/rendering/scene.py +114 -0
- pyworldgen/rendering/software.py +169 -0
- pyworldgen/roads/__init__.py +38 -0
- pyworldgen/roads/cost.py +61 -0
- pyworldgen/roads/generate.py +262 -0
- pyworldgen/roads/geometry.py +82 -0
- pyworldgen/roads/network.py +105 -0
- pyworldgen/roads/solver.py +161 -0
- pyworldgen/roads/terrain.py +94 -0
- pyworldgen/runtime/__init__.py +6 -0
- pyworldgen/runtime/chunk_streamer.py +33 -0
- pyworldgen/runtime/navigation.py +61 -0
- pyworldgen/spatial/__init__.py +46 -0
- pyworldgen/spatial/bvh.py +110 -0
- pyworldgen/spatial/dda.py +91 -0
- pyworldgen/spatial/fps.py +111 -0
- pyworldgen/spatial/graph.py +90 -0
- pyworldgen/spatial/kdtree.py +101 -0
- pyworldgen/spatial/octree.py +67 -0
- pyworldgen/spatial/quadtree.py +78 -0
- pyworldgen/spatial/rtree.py +150 -0
- pyworldgen/spatial/spatial_hash.py +67 -0
- pyworldgen/voxel/__init__.py +51 -0
- pyworldgen/voxel/blocks.py +57 -0
- pyworldgen/voxel/lighting.py +25 -0
- pyworldgen/world/__init__.py +20 -0
- pyworldgen/world/block_storage.py +58 -0
- pyworldgen/world/chunk.py +73 -0
- pyworldgen/world/chunk_map.py +37 -0
- pyworldgen/world/edits.py +49 -0
- pyworldgen/world/queries.py +69 -0
- pyworldgen/world/world.py +34 -0
- pyworldgen-0.1.0.dist-info/METADATA +460 -0
- pyworldgen-0.1.0.dist-info/RECORD +95 -0
- pyworldgen-0.1.0.dist-info/WHEEL +5 -0
- 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
|
+
}
|