mapwright 0.23.1__tar.gz → 0.25.0__tar.gz
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.
- {mapwright-0.23.1 → mapwright-0.25.0}/CHANGELOG.md +59 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/PKG-INFO +1 -1
- {mapwright-0.23.1 → mapwright-0.25.0}/pyproject.toml +1 -1
- {mapwright-0.23.1 → mapwright-0.25.0}/src/mapwright/__init__.py +1 -1
- {mapwright-0.23.1 → mapwright-0.25.0}/src/mapwright/config.py +21 -3
- mapwright-0.25.0/src/mapwright/py.typed +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/src/mapwright/terrain.py +208 -40
- mapwright-0.25.0/tests/test_api_contract.py +297 -0
- mapwright-0.23.1/tests/test_api_contract.py +0 -175
- {mapwright-0.23.1 → mapwright-0.25.0}/.github/workflows/ci.yml +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/.github/workflows/publish.yml +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/.gitignore +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/LICENSE +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/NOTICE +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/README.md +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/age-old.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/age-old.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/age-young.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/age-young.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/archipelago.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/archipelago.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/arctic.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/arctic.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/README.md +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/city_castle_1.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/city_large_1.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/city_town_1.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/city_village_1.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/decoration_compass_1.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/decoration_creature_1.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/decoration_ship_1.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/dune_1.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/hill_1.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/hill_2.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/manifest.json +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/mountain_mid_1.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/mountain_old_1.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/mountain_old_2.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/mountain_young_1.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/mountain_young_2.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/tree_cactus_1.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/tree_deciduous_1.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/tree_deciduous_2.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/tree_pine_1.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/atlas_pack/tree_pine_2.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/citadel.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/citadel.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/continent.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/continent.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/desert.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/desert.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/dungeon.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/dungeon.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/fortress-town.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/fortress-town.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/grid-city.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/grid-city.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/highlands.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/highlands.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/hint.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/hint.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/islands.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/islands.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/metropolis.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/metropolis.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/pangaea.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/pangaea.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/port.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/port.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/regions.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/regions.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/roads.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/roads.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/shantytown.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/shantytown.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/template-atoll.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/template-atoll.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/template-isthmus.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/template-isthmus.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/terrain-town.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/terrain-town.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/theme-blueprint.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/theme-blueprint.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/theme-citadel-neon.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/theme-citadel-neon.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/theme-dune.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/theme-dune.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/theme-dungeon-blueprint.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/theme-dungeon-blueprint.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/theme-neon.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/theme-neon.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/theme-parchment.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/theme-parchment.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/town.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/town.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/tropical.png +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/docs/gallery/tropical.svg +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/examples/benchmark.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/examples/gallery.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/src/mapwright/_geometry.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/src/mapwright/_graph.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/src/mapwright/_serde.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/src/mapwright/affordances.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/src/mapwright/atlas_renderer.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/src/mapwright/dungeon.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/src/mapwright/dungeon_renderer.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/src/mapwright/names.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/src/mapwright/regions.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/src/mapwright/rng.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/src/mapwright/roads.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/src/mapwright/settlement.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/src/mapwright/settlement_renderer.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/src/mapwright/svg_renderer.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/src/mapwright/themes.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/tests/test_affordances.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/tests/test_atlas_renderer.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/tests/test_config.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/tests/test_dungeon.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/tests/test_dungeon_renderer.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/tests/test_geometry.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/tests/test_graph.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/tests/test_names.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/tests/test_properties.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/tests/test_regions.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/tests/test_rng.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/tests/test_roads.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/tests/test_serialize.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/tests/test_settlement.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/tests/test_svg_renderer.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/tests/test_terrain.py +0 -0
- {mapwright-0.23.1 → mapwright-0.25.0}/tests/test_themes.py +0 -0
|
@@ -8,6 +8,65 @@ All notable changes to mapwright are documented here. The format follows
|
|
|
8
8
|
`tests/test_api_contract.py`). While the version is `0.x`, minor versions may
|
|
9
9
|
make breaking changes; these will always be noted here.
|
|
10
10
|
|
|
11
|
+
## [0.25.0] — 2026-06-06
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- **`world` preset.** One-line access to a full planet: `continents=8`,
|
|
15
|
+
`sea_level=0.64`, `continent_spread=0.95`, `mountain_density=0.7`,
|
|
16
|
+
`polar_cold=0.5`. Best rendered on a wide canvas (e.g. `generate(240, 130)`)
|
|
17
|
+
so the continents read as a world rather than one zoomed-in landmass.
|
|
18
|
+
- **`polar_cold` config knob** (0..1, default 0.5). Strength of the
|
|
19
|
+
equator→pole temperature gradient — i.e. how large the cold/snow ice caps
|
|
20
|
+
are. Latitude sets *where* the cold falls (top/bottom edges of the map);
|
|
21
|
+
this sets *how much*. Appended as a trailing `WorldMapConfig` field, so
|
|
22
|
+
positional construction and existing JSON keys are unaffected; `to_dict()`
|
|
23
|
+
now emits one additional key.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- **Multi-continent worlds reworked to read as a whole planet, not one central
|
|
27
|
+
blob.** Continents are now scattered across the map as clusters of overlapping
|
|
28
|
+
cratons (irregular outlines), sized by a skewed draw and capped by the
|
|
29
|
+
distance to their nearest neighbour (a few big landmasses among several small
|
|
30
|
+
ones, reliably separated by open ocean). Continental crust is a distance
|
|
31
|
+
falloff *swell* rather than hard plate membership, so far-apart landmasses
|
|
32
|
+
can't fuse; the centralizing radial sea-frame is dropped, so continents may
|
|
33
|
+
run to any map edge.
|
|
34
|
+
- **Plate motion uses a per-plate Euler pole** (rotation, `v = ω × r`) instead
|
|
35
|
+
of a single translation vector, so a boundary's character varies along its
|
|
36
|
+
length — arcuate, waxing mountain belts rather than uniform ridges. Boundaries
|
|
37
|
+
are classified convergent / divergent / transform and carve mountains, rift
|
|
38
|
+
troughs, and fault valleys respectively.
|
|
39
|
+
- **Oceans are populated** with clustered archipelagos and curved volcanic
|
|
40
|
+
island arcs (kept clear of the continents so they read as open-water islands).
|
|
41
|
+
- **Climate is latitude-driven.** The warm equator is anchored to the middle of
|
|
42
|
+
the map (lightly jittered) so north/south reliably means colder; the elevation
|
|
43
|
+
lapse is gentler, so equatorial peaks read as bare mountain rather than snow.
|
|
44
|
+
A polar ice-cap rule renders the far north/south as white snow. Presets:
|
|
45
|
+
`desert` is now scorching pole-to-pole (`polar_cold=0`), `arctic` has deep
|
|
46
|
+
caps (0.9), `tropical` stays warm toward the poles (0.2).
|
|
47
|
+
- **Internal cell cap raised 1500 → 8000** so planet-scale canvases render with
|
|
48
|
+
enough detail for islands and coastlines. Small canvases are unaffected (they
|
|
49
|
+
never approached the cap); large canvases generate more slowly in proportion.
|
|
50
|
+
|
|
51
|
+
Single-continent, template, and elevation-hint worlds are byte-unchanged.
|
|
52
|
+
|
|
53
|
+
## [0.24.0] — 2026-06-06
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
- **Type information (PEP 561).** Ships a `py.typed` marker, so downstream
|
|
57
|
+
projects' type checkers (mypy, pyright) now verify their use of mapwright's
|
|
58
|
+
public API against its annotations — signature drift is caught at *their*
|
|
59
|
+
build time, not just at runtime.
|
|
60
|
+
|
|
61
|
+
### Changed
|
|
62
|
+
- **The public contract is now pinned more tightly** (no API change). The
|
|
63
|
+
contract tests freeze, for every exported dataclass, the exact field *names
|
|
64
|
+
and order* (positional construction, attribute access, and `to_dict` keys all
|
|
65
|
+
depend on these), and freeze the `to_dict()` key schema for every serialisable
|
|
66
|
+
type. An internal refactor that renames/reorders/drops a public field or
|
|
67
|
+
changes a serialised key now fails CI loudly instead of silently breaking
|
|
68
|
+
consumers that persist JSON or read fields.
|
|
69
|
+
|
|
11
70
|
## [0.23.1] — 2026-06-06
|
|
12
71
|
|
|
13
72
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mapwright
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.25.0
|
|
4
4
|
Summary: Domain-neutral procedural fantasy map & world generation: Voronoi terrain, hydraulic erosion, biomes, rivers, Markov place-names, and shaded-relief SVG.
|
|
5
5
|
Project-URL: Homepage, https://github.com/sligara7/mapwright
|
|
6
6
|
Project-URL: Repository, https://github.com/sligara7/mapwright
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mapwright"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.25.0"
|
|
8
8
|
description = "Domain-neutral procedural fantasy map & world generation: Voronoi terrain, hydraulic erosion, biomes, rivers, Markov place-names, and shaded-relief SVG."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -47,6 +47,10 @@ _SPEC: list[tuple] = [
|
|
|
47
47
|
"How readily rivers are traced; more = more, smaller rivers."),
|
|
48
48
|
("lake_density", float, 0.0, 1.0,
|
|
49
49
|
"How readily inland basins fill into lakes; more = more, shallower lakes."),
|
|
50
|
+
("polar_cold", float, 0.0, 1.0,
|
|
51
|
+
"Strength of the equator→pole temperature gradient. 0 = no cold caps "
|
|
52
|
+
"(uniformly warm); 1 = strong polar ice caps with snow near the top/bottom "
|
|
53
|
+
"edges. Latitude (north/south) sets where the cold is; this sets how much."),
|
|
50
54
|
]
|
|
51
55
|
|
|
52
56
|
|
|
@@ -84,6 +88,12 @@ class WorldMapConfig:
|
|
|
84
88
|
lake_density: float = 0.5
|
|
85
89
|
"""0..1 — how readily inland basins fill into lakes (more ⇒ more lakes)."""
|
|
86
90
|
|
|
91
|
+
# --- Climate (appended for contract stability; see EXPECTED_FIELDS) ---
|
|
92
|
+
polar_cold: float = 0.5
|
|
93
|
+
"""0 ⇒ no polar chill (uniformly warm) .. 1 ⇒ strong cold ice caps at the
|
|
94
|
+
poles. Latitude sets *where* the cold falls (top/bottom edges); this knob
|
|
95
|
+
sets *how strong* the equator→pole gradient is."""
|
|
96
|
+
|
|
87
97
|
def __post_init__(self) -> None:
|
|
88
98
|
# Clamp everything so out-of-range inputs (e.g. from an LLM) are safe.
|
|
89
99
|
for name, typ, lo, hi, _desc in _SPEC:
|
|
@@ -153,10 +163,18 @@ PRESETS: dict[str, dict] = {
|
|
|
153
163
|
"highlands": {"continents": 1, "mountain_density": 0.95, "roughness": 0.75,
|
|
154
164
|
"river_density": 0.7},
|
|
155
165
|
"desert": {"temperature": 0.85, "moisture": -0.85, "sea_level": 0.28,
|
|
156
|
-
"mountain_density": 0.25, "river_density": 0.12, "lake_density": 0.1
|
|
157
|
-
|
|
166
|
+
"mountain_density": 0.25, "river_density": 0.12, "lake_density": 0.1,
|
|
167
|
+
"polar_cold": 0.0}, # scorching pole-to-pole, no ice caps
|
|
168
|
+
"arctic": {"temperature": -0.85, "moisture": 0.1, "mountain_density": 0.5,
|
|
169
|
+
"polar_cold": 0.9}, # deep, wide ice caps
|
|
158
170
|
"tropical": {"temperature": 0.6, "moisture": 0.85, "river_density": 0.85,
|
|
159
|
-
"mountain_density": 0.55},
|
|
171
|
+
"mountain_density": 0.55, "polar_cold": 0.2}, # warm to the poles
|
|
160
172
|
"islands": {"continents": 12, "sea_level": 0.62, "continent_spread": 0.85,
|
|
161
173
|
"mountain_density": 0.3},
|
|
174
|
+
# A whole planet: many distinct continents of varied size spread to the
|
|
175
|
+
# edges, ~⅓ land, polar ice caps, and islands/arcs in the oceans between.
|
|
176
|
+
# Best rendered on a WIDE canvas (e.g. generate(240, 130)) so the continents
|
|
177
|
+
# read as a world rather than one zoomed-in landmass.
|
|
178
|
+
"world": {"continents": 8, "sea_level": 0.64, "continent_spread": 0.95,
|
|
179
|
+
"mountain_density": 0.7, "polar_cold": 0.5},
|
|
162
180
|
}
|
|
File without changes
|
|
@@ -272,7 +272,7 @@ class RegionalTerrainGenerator:
|
|
|
272
272
|
sea_level = cfg.sea_level
|
|
273
273
|
erosion_passes = max(1, round(1 + cfg.roughness * 4))
|
|
274
274
|
|
|
275
|
-
n_cells = int(np.clip(round(width * height / cell_area), 16,
|
|
275
|
+
n_cells = int(np.clip(round(width * height / cell_area), 16, 8000))
|
|
276
276
|
seeds = _geometry.jittered_grid_seeds(self._rng, width, height, n_cells)
|
|
277
277
|
cell_of, seeds = _geometry.voronoi_grid(width, height, seeds, relax_iterations)
|
|
278
278
|
cells = self._build_cells(seeds, cell_of)
|
|
@@ -444,19 +444,97 @@ class RegionalTerrainGenerator:
|
|
|
444
444
|
ocean_seeds += [(self._rng.uniform(0, width), self._rng.uniform(0, height))
|
|
445
445
|
for _ in range(4)]
|
|
446
446
|
else:
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
447
|
+
# Several continents scattered across the whole map (not on a centred
|
|
448
|
+
# ring), so land spreads toward the planet's edges instead of clustering in
|
|
449
|
+
# a central disk. Each continent is a small CLUSTER of 2–3 overlapping
|
|
450
|
+
# cratons of differing size, so its outline is an irregular union (capes,
|
|
451
|
+
# bays, peninsulas) rather than a single circular bump. `continent_spread`
|
|
452
|
+
# widens the band the centres may occupy.
|
|
453
|
+
self._ocean_dir = self._rng.uniform(0.0, 2.0 * math.pi)
|
|
454
|
+
spread = cfg.continent_spread
|
|
455
|
+
margin_x = width * (0.24 - 0.16 * spread)
|
|
456
|
+
margin_y = height * (0.24 - 0.16 * spread)
|
|
457
|
+
core_r = 0.34 * math.sqrt(width * height / max(n, 1)) # nominal continent radius
|
|
458
|
+
|
|
459
|
+
# Best-candidate (Mitchell) sampling: each centre is the farthest of a
|
|
460
|
+
# handful of random tries from the centres placed so far. Always yields n
|
|
461
|
+
# centres AND maximises spacing, so continents don't graze each other the
|
|
462
|
+
# way plain rejection-with-random-fallback let them.
|
|
463
|
+
centres: list[tuple[float, float]] = []
|
|
464
|
+
for _ in range(n):
|
|
465
|
+
best: tuple[float, float] | None = None
|
|
466
|
+
best_d = -1.0
|
|
467
|
+
for _try in range(64):
|
|
468
|
+
px = self._rng.uniform(margin_x, width - margin_x)
|
|
469
|
+
py = self._rng.uniform(margin_y, height - margin_y)
|
|
470
|
+
if not centres:
|
|
471
|
+
best = (px, py)
|
|
472
|
+
break
|
|
473
|
+
d = min((px - sx) ** 2 + (py - sy) ** 2 for sx, sy in centres)
|
|
474
|
+
if d > best_d:
|
|
475
|
+
best_d, best = d, (px, py)
|
|
476
|
+
centres.append(best) # type: ignore[arg-type]
|
|
477
|
+
|
|
478
|
+
# Varied sizing, space-aware: a skewed draw (u**1.8) makes most continents
|
|
479
|
+
# modest with the occasional giant, but each is then CAPPED by its distance
|
|
480
|
+
# to the nearest other centre — so crowded continents shrink and stay
|
|
481
|
+
# separated while isolated ones grow large. This both prevents fusing into
|
|
482
|
+
# one mass and widens the size spread for free. Bigger continents get more
|
|
483
|
+
# cratons (so they're also more irregular).
|
|
484
|
+
cont_radii: list[float] = []
|
|
485
|
+
for idx, (px, py) in enumerate(centres):
|
|
486
|
+
nn = min((math.hypot(px - sx, py - sy)
|
|
487
|
+
for k, (sx, sy) in enumerate(centres) if k != idx),
|
|
488
|
+
default=core_r * 3.0)
|
|
489
|
+
u = self._rng.random()
|
|
490
|
+
csize = min(core_r * (0.50 + 1.05 * u ** 1.8), 0.42 * nn)
|
|
491
|
+
ncr = 2 + int(u > 0.45) + int(u > 0.8) # 2..4 cratons, big → more
|
|
492
|
+
for _ in range(ncr):
|
|
493
|
+
cont_seeds.append((px + self._rng.fuzzy(0, csize * 0.40),
|
|
494
|
+
py + self._rng.fuzzy(0, csize * 0.40)))
|
|
495
|
+
cont_radii.append(csize * self._rng.uniform(0.50, 0.70))
|
|
496
|
+
|
|
497
|
+
# Keep ocean features clear of the continents so they read as open-water
|
|
498
|
+
# islands and can't bridge two continents into one mass.
|
|
499
|
+
clear2 = (core_r * 0.95) ** 2
|
|
500
|
+
|
|
501
|
+
def _open_ocean(x: float, y: float) -> bool:
|
|
502
|
+
return all((x - sx) ** 2 + (y - sy) ** 2 >= clear2 for sx, sy in centres)
|
|
503
|
+
|
|
504
|
+
# Populate the oceans so the world isn't continents in a void — all small
|
|
505
|
+
# swells folded into the same field, so they self-gate (relief only where
|
|
506
|
+
# the island sits):
|
|
507
|
+
# • island GROUPS — little archipelagos (a few isles clustered), and
|
|
508
|
+
# • volcanic ARCS — curved, tapering hotspot tracks (Hawaii / Aleutians).
|
|
509
|
+
for _ in range(n): # archipelago groups
|
|
510
|
+
gx = self._rng.uniform(0, width)
|
|
511
|
+
gy = self._rng.uniform(0, height)
|
|
512
|
+
if not _open_ocean(gx, gy):
|
|
513
|
+
continue
|
|
514
|
+
for _ in range(self._rng.randint(2, 4)):
|
|
515
|
+
cont_seeds.append((gx + self._rng.fuzzy(0, core_r * 0.55),
|
|
516
|
+
gy + self._rng.fuzzy(0, core_r * 0.55)))
|
|
517
|
+
cont_radii.append(core_r * self._rng.uniform(0.14, 0.30))
|
|
518
|
+
for _ in range(max(2, (n + 1) // 2)): # curved island arcs
|
|
519
|
+
x0 = self._rng.uniform(0, width)
|
|
520
|
+
y0 = self._rng.uniform(0, height)
|
|
521
|
+
ang = self._rng.uniform(0, 2 * math.pi)
|
|
522
|
+
curve = self._rng.fuzzy(0, 0.7) # total bend (radians)
|
|
523
|
+
length = self._rng.uniform(0.24, 0.44) * min(width, height)
|
|
524
|
+
k = self._rng.randint(4, 7)
|
|
525
|
+
for j in range(k):
|
|
526
|
+
f = j / (k - 1)
|
|
527
|
+
a = ang + curve * f
|
|
528
|
+
ix = x0 + math.cos(a) * length * f + self._rng.fuzzy(0, core_r * 0.12)
|
|
529
|
+
iy = y0 + math.sin(a) * length * f + self._rng.fuzzy(0, core_r * 0.12)
|
|
530
|
+
if not _open_ocean(ix, iy): # skip seamounts that would hit a continent
|
|
531
|
+
continue
|
|
532
|
+
cont_seeds.append((ix, iy))
|
|
533
|
+
cont_radii.append(core_r * (0.28 - 0.15 * f)) # bigger, tapering
|
|
534
|
+
# Broad open-ocean plates across the full map frame the continents in deep
|
|
535
|
+
# water and supply oceanic crust for the drift/uplift model below.
|
|
458
536
|
ocean_seeds += [(self._rng.uniform(0, width), self._rng.uniform(0, height))
|
|
459
|
-
for _ in range(2)]
|
|
537
|
+
for _ in range(2 * n + 4)]
|
|
460
538
|
seeds = cont_seeds + ocean_seeds
|
|
461
539
|
is_continental = [True] * len(cont_seeds) + [False] * len(ocean_seeds)
|
|
462
540
|
n_plates = len(seeds)
|
|
@@ -467,35 +545,88 @@ class RegionalTerrainGenerator:
|
|
|
467
545
|
+ (centroids[:, None, 1] - seed_arr[None, :, 1]) ** 2)
|
|
468
546
|
plate = d2p.argmin(axis=1)
|
|
469
547
|
|
|
470
|
-
#
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
548
|
+
# Per-plate EULER POLE: a plate doesn't slide rigidly, it rotates about a pole,
|
|
549
|
+
# so its velocity (ω × r) varies in direction and speed across the plate. A
|
|
550
|
+
# single boundary then changes character along its length — convergent at one
|
|
551
|
+
# end, transform/divergent at the other — yielding arcuate, waxing-and-waning
|
|
552
|
+
# mountain belts instead of uniform ridges. A pole placed far from the map
|
|
553
|
+
# degenerates to near-uniform translation, so this strictly generalises the
|
|
554
|
+
# old single-drift model. Pole `ω` is scaled so the speed near map centre lands
|
|
555
|
+
# in the old 0.4–1.0 band; sign is random (clockwise / anticlockwise spin).
|
|
556
|
+
cxm, cym = width / 2, height / 2
|
|
557
|
+
pole = np.array([
|
|
558
|
+
(self._rng.uniform(-0.3 * width, 1.3 * width),
|
|
559
|
+
self._rng.uniform(-0.3 * height, 1.3 * height))
|
|
560
|
+
for _ in range(n_plates)
|
|
561
|
+
])
|
|
562
|
+
omega = np.array([
|
|
563
|
+
(1.0 if self._rng.random() < 0.5 else -1.0) * self._rng.uniform(0.4, 1.0)
|
|
564
|
+
/ max(math.hypot(pole[i, 0] - cxm, pole[i, 1] - cym),
|
|
565
|
+
0.3 * min(width, height))
|
|
566
|
+
for i in range(n_plates)
|
|
475
567
|
])
|
|
476
568
|
|
|
477
|
-
# Base elevation
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
#
|
|
481
|
-
#
|
|
482
|
-
#
|
|
483
|
-
|
|
569
|
+
# Base elevation. A single continent uses hard plate membership (its
|
|
570
|
+
# directional frame carves the coast). Multiple continents instead get a
|
|
571
|
+
# continental SWELL that falls off with distance from each continental core,
|
|
572
|
+
# so every mass is locally bounded and far-apart cores cannot fuse into one
|
|
573
|
+
# blob the way a shared high-crust plate partition does. Oceanic floor sits
|
|
574
|
+
# low everywhere else.
|
|
575
|
+
if n == 1:
|
|
576
|
+
base = np.array([0.55 if is_continental[plate[i]] else -0.65
|
|
577
|
+
for i in range(n_cells)])
|
|
578
|
+
else:
|
|
579
|
+
cs = np.array(cont_seeds)
|
|
580
|
+
cr = np.array(cont_radii)
|
|
581
|
+
d2 = (((centroids[:, None, 0] - cs[None, :, 0]) ** 2)
|
|
582
|
+
+ ((centroids[:, None, 1] - cs[None, :, 1]) ** 2))
|
|
583
|
+
# Each cell takes the strongest nearby craton swell; overlapping cratons in
|
|
584
|
+
# one cluster fuse into an irregular continent, distant clusters stay apart.
|
|
585
|
+
swell = np.exp(-d2 / (cr[None, :] ** 2)).max(axis=1)
|
|
586
|
+
base = -0.65 + 1.20 * swell # core ≈ +0.55, open ocean ≈ -0.65
|
|
587
|
+
|
|
588
|
+
# Boundary types: evaluate each plate's rotational velocity (ω × r) AT the
|
|
589
|
+
# boundary cell and split their relative motion into a NORMAL component (across
|
|
590
|
+
# the boundary) and a TANGENTIAL one (along it). The dominant component sets the
|
|
591
|
+
# boundary's character, which carves a different landform:
|
|
592
|
+
# • normal > 0, dominant → CONVERGENT: crust piles up → mountain range
|
|
593
|
+
# • normal < 0, dominant → DIVERGENT: crust pulls apart → rift valley
|
|
594
|
+
# • tangential dominant → TRANSFORM: shear → a weaker linear fault valley
|
|
595
|
+
# Because velocity is sampled per cell, the type varies ALONG one boundary —
|
|
596
|
+
# the same fault can collide at one end and rift at the other (the Euler payoff).
|
|
597
|
+
boundary = np.zeros(n_cells) # convergent uplift (≥ 0)
|
|
598
|
+
rift = np.zeros(n_cells) # extensional / transform lowering (≥ 0)
|
|
484
599
|
for c in cells:
|
|
485
600
|
pi = plate[c.id]
|
|
601
|
+
# Plate pi's velocity at this cell: ω × r, r = cell − pole.
|
|
602
|
+
vix = -omega[pi] * (c.cy - pole[pi, 1])
|
|
603
|
+
viy = omega[pi] * (c.cx - pole[pi, 0])
|
|
486
604
|
for nb in c.neighbors:
|
|
487
605
|
pj = plate[nb]
|
|
488
606
|
if pj == pi:
|
|
489
607
|
continue
|
|
490
608
|
dx, dy = cells[nb].cx - c.cx, cells[nb].cy - c.cy
|
|
491
609
|
length = math.hypot(dx, dy) or 1.0
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
610
|
+
vjx = -omega[pj] * (c.cy - pole[pj, 1])
|
|
611
|
+
vjy = omega[pj] * (c.cx - pole[pj, 0])
|
|
612
|
+
rvx, rvy = vix - vjx, viy - vjy
|
|
613
|
+
normal = (rvx * dx + rvy * dy) / length # + together, − apart
|
|
614
|
+
tang = abs(rvx * -dy + rvy * dx) / length # shear magnitude
|
|
496
615
|
ci, cj = is_continental[pi], is_continental[pj]
|
|
497
|
-
|
|
498
|
-
|
|
616
|
+
if normal > 0 and normal >= tang:
|
|
617
|
+
# Convergent → uplift, scaled by what collides (cont–cont > arcs).
|
|
618
|
+
mag = 1.0 if (ci and cj) else (0.6 if (ci or cj) else 0.35)
|
|
619
|
+
boundary[c.id] = max(boundary[c.id], normal * mag)
|
|
620
|
+
elif -normal >= tang:
|
|
621
|
+
# Divergent → rift. Pure ocean–ocean spreading is a (submarine)
|
|
622
|
+
# ridge, not a continental rift, so only carve where land is
|
|
623
|
+
# involved; a continent splitting apart drops a deep valley.
|
|
624
|
+
rmag = 1.0 if (ci and cj) else (0.7 if (ci or cj) else 0.0)
|
|
625
|
+
rift[c.id] = max(rift[c.id], -normal * rmag)
|
|
626
|
+
else:
|
|
627
|
+
# Transform → a shallower linear fault valley from the shear.
|
|
628
|
+
rmag = 0.6 if (ci or cj) else 0.0
|
|
629
|
+
rift[c.id] = max(rift[c.id], tang * 0.5 * rmag)
|
|
499
630
|
|
|
500
631
|
# Spread uplift inland so ranges have width (neighbour-max with decay).
|
|
501
632
|
uplift = boundary.copy()
|
|
@@ -509,6 +640,17 @@ class RegionalTerrainGenerator:
|
|
|
509
640
|
uplift = spread
|
|
510
641
|
uplift = uplift * (0.5 + 1.2 * cfg.mountain_density)
|
|
511
642
|
|
|
643
|
+
# Rifts/fault valleys are narrower than ranges: spread fewer passes, then scale.
|
|
644
|
+
for _ in range(2):
|
|
645
|
+
spread = rift.copy()
|
|
646
|
+
for c in cells:
|
|
647
|
+
for nb in c.neighbors:
|
|
648
|
+
decayed = rift[nb] * 0.55
|
|
649
|
+
if decayed > spread[c.id]:
|
|
650
|
+
spread[c.id] = decayed
|
|
651
|
+
rift = spread
|
|
652
|
+
rift = rift * 1.3 # deep enough that a continental rift can flood into a sea
|
|
653
|
+
|
|
512
654
|
# Fractal coastline detail breaks the straight plate edges into organic
|
|
513
655
|
# bays/capes; a radial edge term frames the map in sea (works *with* the
|
|
514
656
|
# percentile sea level below — it just pushes border cells to the low end).
|
|
@@ -518,11 +660,25 @@ class RegionalTerrainGenerator:
|
|
|
518
660
|
# keep the symmetric radial frame, which already scatters them into islands;
|
|
519
661
|
# a directional bias there would drown one whole flank of the ring.
|
|
520
662
|
if n == 1:
|
|
521
|
-
raw = base + uplift + 0.55 * coast
|
|
663
|
+
raw = base + uplift - rift + 0.55 * coast
|
|
522
664
|
frame = self._sea_frame(centroids, width, height, cfg, dir_amp=1.0)
|
|
523
665
|
else:
|
|
524
|
-
|
|
525
|
-
|
|
666
|
+
# Gate the additive relief (mountains + coastline noise) by the swell so it
|
|
667
|
+
# only acts on or near continents. Otherwise uplift at mid-ocean plate
|
|
668
|
+
# boundaries and ±coast noise pile up in the gaps and bridge neighbouring
|
|
669
|
+
# continents back into one blob; gated, the open ocean stays deep and
|
|
670
|
+
# smooth, keeping the masses distinct.
|
|
671
|
+
gate = np.clip(swell * 1.6, 0.0, 1.0)
|
|
672
|
+
# Rift is gated too, so it carves valleys INTO continents (a gash that can
|
|
673
|
+
# flood into a linear sea) rather than deepening the open ocean.
|
|
674
|
+
raw = base + (uplift - rift + 0.45 * coast) * gate
|
|
675
|
+
# No border drown-frame for multi-continent: the swell base already frames
|
|
676
|
+
# every continent in deep ocean, and a border frame would just pull all the
|
|
677
|
+
# land back toward the map centre (the very clustering we are removing). The
|
|
678
|
+
# map is a planet WINDOW — continents may run right off any edge. A light
|
|
679
|
+
# edge_falloff still nudges the outermost rim under so masses don't plaster
|
|
680
|
+
# flush against all four borders.
|
|
681
|
+
frame = self._radial_frame(centroids, width, height, cfg) * 0.18 * cfg.edge_falloff
|
|
526
682
|
self._finalize_heights(cells, raw, frame, cfg)
|
|
527
683
|
|
|
528
684
|
def _radial_frame(self, centroids, width: int, height: int,
|
|
@@ -843,14 +999,21 @@ class RegionalTerrainGenerator:
|
|
|
843
999
|
self, cells: list[TerrainCell], width: int, height: int, sea_level: float,
|
|
844
1000
|
cfg: WorldMapConfig,
|
|
845
1001
|
) -> None:
|
|
846
|
-
# Temperature: warm
|
|
847
|
-
#
|
|
848
|
-
|
|
1002
|
+
# Temperature: a warm equator running across the MIDDLE of the map cooling
|
|
1003
|
+
# toward the poles (top & bottom edges), minus a gentle elevation lapse, plus
|
|
1004
|
+
# a global bias. `polar_cold` sets how steeply it cools toward the poles — i.e.
|
|
1005
|
+
# how large the cold/snow ice caps are. The equator is only lightly jittered so
|
|
1006
|
+
# north/south reliably means colder. The lapse is gentle so equatorial peaks
|
|
1007
|
+
# read as bare MOUNTAIN, not snow; snow is driven by latitude (cold poles) and
|
|
1008
|
+
# only the very highest cold ground.
|
|
1009
|
+
equator = 0.5 + self._rng.fuzzy(0, 0.05)
|
|
1010
|
+
grad = 0.9 + 1.6 * cfg.polar_cold # equator→pole cooling rate
|
|
849
1011
|
for c in cells:
|
|
850
1012
|
lat = c.cy / max(1, height - 1)
|
|
851
|
-
|
|
852
|
-
temp
|
|
853
|
-
temp
|
|
1013
|
+
band = abs(lat - equator) * 2.0 # 0 at equator .. ~1 at a pole
|
|
1014
|
+
temp = 1.0 - grad * band
|
|
1015
|
+
temp -= 0.35 * max(0.0, c.height - sea_level) # gentle elevation lapse
|
|
1016
|
+
temp += cfg.temperature # global bias
|
|
854
1017
|
c.temperature = float(np.clip(temp + self._rng.fuzzy(0, 0.05), 0.0, 1.0))
|
|
855
1018
|
|
|
856
1019
|
# Moisture: multi-source BFS hop-distance from water (sea, lakes, rivers
|
|
@@ -931,6 +1094,11 @@ class RegionalTerrainGenerator:
|
|
|
931
1094
|
rel = (c.height - sea_level) / max(1e-6, 1 - sea_level) # 0..1 above sea
|
|
932
1095
|
t, m = c.temperature, c.moisture
|
|
933
1096
|
|
|
1097
|
+
# Polar ice cap: ground cold enough freezes over at any elevation, so the
|
|
1098
|
+
# far north/south read as white snow rather than brown tundra.
|
|
1099
|
+
if t < 0.12:
|
|
1100
|
+
return Biome.SNOW
|
|
1101
|
+
|
|
934
1102
|
# Elevation dominates at the extremes.
|
|
935
1103
|
if rel > 0.72:
|
|
936
1104
|
return Biome.SNOW if t < 0.35 else Biome.MOUNTAIN
|