mapwright 0.24.0__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.
Files changed (131) hide show
  1. {mapwright-0.24.0 → mapwright-0.25.0}/CHANGELOG.md +42 -0
  2. {mapwright-0.24.0 → mapwright-0.25.0}/PKG-INFO +1 -1
  3. {mapwright-0.24.0 → mapwright-0.25.0}/pyproject.toml +1 -1
  4. {mapwright-0.24.0 → mapwright-0.25.0}/src/mapwright/__init__.py +1 -1
  5. {mapwright-0.24.0 → mapwright-0.25.0}/src/mapwright/config.py +21 -3
  6. {mapwright-0.24.0 → mapwright-0.25.0}/src/mapwright/terrain.py +208 -40
  7. {mapwright-0.24.0 → mapwright-0.25.0}/tests/test_api_contract.py +1 -1
  8. {mapwright-0.24.0 → mapwright-0.25.0}/.github/workflows/ci.yml +0 -0
  9. {mapwright-0.24.0 → mapwright-0.25.0}/.github/workflows/publish.yml +0 -0
  10. {mapwright-0.24.0 → mapwright-0.25.0}/.gitignore +0 -0
  11. {mapwright-0.24.0 → mapwright-0.25.0}/LICENSE +0 -0
  12. {mapwright-0.24.0 → mapwright-0.25.0}/NOTICE +0 -0
  13. {mapwright-0.24.0 → mapwright-0.25.0}/README.md +0 -0
  14. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/age-old.png +0 -0
  15. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/age-old.svg +0 -0
  16. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/age-young.png +0 -0
  17. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/age-young.svg +0 -0
  18. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/archipelago.png +0 -0
  19. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/archipelago.svg +0 -0
  20. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/arctic.png +0 -0
  21. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/arctic.svg +0 -0
  22. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas.png +0 -0
  23. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/README.md +0 -0
  24. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/city_castle_1.png +0 -0
  25. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/city_large_1.png +0 -0
  26. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/city_town_1.png +0 -0
  27. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/city_village_1.png +0 -0
  28. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/decoration_compass_1.png +0 -0
  29. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/decoration_creature_1.png +0 -0
  30. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/decoration_ship_1.png +0 -0
  31. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/dune_1.png +0 -0
  32. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/hill_1.png +0 -0
  33. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/hill_2.png +0 -0
  34. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/manifest.json +0 -0
  35. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/mountain_mid_1.png +0 -0
  36. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/mountain_old_1.png +0 -0
  37. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/mountain_old_2.png +0 -0
  38. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/mountain_young_1.png +0 -0
  39. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/mountain_young_2.png +0 -0
  40. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/tree_cactus_1.png +0 -0
  41. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/tree_deciduous_1.png +0 -0
  42. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/tree_deciduous_2.png +0 -0
  43. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/tree_pine_1.png +0 -0
  44. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/atlas_pack/tree_pine_2.png +0 -0
  45. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/citadel.png +0 -0
  46. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/citadel.svg +0 -0
  47. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/continent.png +0 -0
  48. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/continent.svg +0 -0
  49. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/desert.png +0 -0
  50. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/desert.svg +0 -0
  51. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/dungeon.png +0 -0
  52. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/dungeon.svg +0 -0
  53. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/fortress-town.png +0 -0
  54. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/fortress-town.svg +0 -0
  55. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/grid-city.png +0 -0
  56. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/grid-city.svg +0 -0
  57. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/highlands.png +0 -0
  58. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/highlands.svg +0 -0
  59. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/hint.png +0 -0
  60. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/hint.svg +0 -0
  61. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/islands.png +0 -0
  62. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/islands.svg +0 -0
  63. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/metropolis.png +0 -0
  64. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/metropolis.svg +0 -0
  65. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/pangaea.png +0 -0
  66. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/pangaea.svg +0 -0
  67. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/port.png +0 -0
  68. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/port.svg +0 -0
  69. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/regions.png +0 -0
  70. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/regions.svg +0 -0
  71. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/roads.png +0 -0
  72. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/roads.svg +0 -0
  73. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/shantytown.png +0 -0
  74. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/shantytown.svg +0 -0
  75. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/template-atoll.png +0 -0
  76. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/template-atoll.svg +0 -0
  77. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/template-isthmus.png +0 -0
  78. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/template-isthmus.svg +0 -0
  79. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/terrain-town.png +0 -0
  80. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/terrain-town.svg +0 -0
  81. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/theme-blueprint.png +0 -0
  82. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/theme-blueprint.svg +0 -0
  83. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/theme-citadel-neon.png +0 -0
  84. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/theme-citadel-neon.svg +0 -0
  85. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/theme-dune.png +0 -0
  86. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/theme-dune.svg +0 -0
  87. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/theme-dungeon-blueprint.png +0 -0
  88. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/theme-dungeon-blueprint.svg +0 -0
  89. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/theme-neon.png +0 -0
  90. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/theme-neon.svg +0 -0
  91. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/theme-parchment.png +0 -0
  92. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/theme-parchment.svg +0 -0
  93. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/town.png +0 -0
  94. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/town.svg +0 -0
  95. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/tropical.png +0 -0
  96. {mapwright-0.24.0 → mapwright-0.25.0}/docs/gallery/tropical.svg +0 -0
  97. {mapwright-0.24.0 → mapwright-0.25.0}/examples/benchmark.py +0 -0
  98. {mapwright-0.24.0 → mapwright-0.25.0}/examples/gallery.py +0 -0
  99. {mapwright-0.24.0 → mapwright-0.25.0}/src/mapwright/_geometry.py +0 -0
  100. {mapwright-0.24.0 → mapwright-0.25.0}/src/mapwright/_graph.py +0 -0
  101. {mapwright-0.24.0 → mapwright-0.25.0}/src/mapwright/_serde.py +0 -0
  102. {mapwright-0.24.0 → mapwright-0.25.0}/src/mapwright/affordances.py +0 -0
  103. {mapwright-0.24.0 → mapwright-0.25.0}/src/mapwright/atlas_renderer.py +0 -0
  104. {mapwright-0.24.0 → mapwright-0.25.0}/src/mapwright/dungeon.py +0 -0
  105. {mapwright-0.24.0 → mapwright-0.25.0}/src/mapwright/dungeon_renderer.py +0 -0
  106. {mapwright-0.24.0 → mapwright-0.25.0}/src/mapwright/names.py +0 -0
  107. {mapwright-0.24.0 → mapwright-0.25.0}/src/mapwright/py.typed +0 -0
  108. {mapwright-0.24.0 → mapwright-0.25.0}/src/mapwright/regions.py +0 -0
  109. {mapwright-0.24.0 → mapwright-0.25.0}/src/mapwright/rng.py +0 -0
  110. {mapwright-0.24.0 → mapwright-0.25.0}/src/mapwright/roads.py +0 -0
  111. {mapwright-0.24.0 → mapwright-0.25.0}/src/mapwright/settlement.py +0 -0
  112. {mapwright-0.24.0 → mapwright-0.25.0}/src/mapwright/settlement_renderer.py +0 -0
  113. {mapwright-0.24.0 → mapwright-0.25.0}/src/mapwright/svg_renderer.py +0 -0
  114. {mapwright-0.24.0 → mapwright-0.25.0}/src/mapwright/themes.py +0 -0
  115. {mapwright-0.24.0 → mapwright-0.25.0}/tests/test_affordances.py +0 -0
  116. {mapwright-0.24.0 → mapwright-0.25.0}/tests/test_atlas_renderer.py +0 -0
  117. {mapwright-0.24.0 → mapwright-0.25.0}/tests/test_config.py +0 -0
  118. {mapwright-0.24.0 → mapwright-0.25.0}/tests/test_dungeon.py +0 -0
  119. {mapwright-0.24.0 → mapwright-0.25.0}/tests/test_dungeon_renderer.py +0 -0
  120. {mapwright-0.24.0 → mapwright-0.25.0}/tests/test_geometry.py +0 -0
  121. {mapwright-0.24.0 → mapwright-0.25.0}/tests/test_graph.py +0 -0
  122. {mapwright-0.24.0 → mapwright-0.25.0}/tests/test_names.py +0 -0
  123. {mapwright-0.24.0 → mapwright-0.25.0}/tests/test_properties.py +0 -0
  124. {mapwright-0.24.0 → mapwright-0.25.0}/tests/test_regions.py +0 -0
  125. {mapwright-0.24.0 → mapwright-0.25.0}/tests/test_rng.py +0 -0
  126. {mapwright-0.24.0 → mapwright-0.25.0}/tests/test_roads.py +0 -0
  127. {mapwright-0.24.0 → mapwright-0.25.0}/tests/test_serialize.py +0 -0
  128. {mapwright-0.24.0 → mapwright-0.25.0}/tests/test_settlement.py +0 -0
  129. {mapwright-0.24.0 → mapwright-0.25.0}/tests/test_svg_renderer.py +0 -0
  130. {mapwright-0.24.0 → mapwright-0.25.0}/tests/test_terrain.py +0 -0
  131. {mapwright-0.24.0 → mapwright-0.25.0}/tests/test_themes.py +0 -0
@@ -8,6 +8,48 @@ 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
+
11
53
  ## [0.24.0] — 2026-06-06
12
54
 
13
55
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mapwright
3
- Version: 0.24.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.24.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"
@@ -63,7 +63,7 @@ from .terrain import (
63
63
  compute_cell_polygons,
64
64
  )
65
65
 
66
- __version__ = "0.24.0"
66
+ __version__ = "0.25.0"
67
67
 
68
68
  __all__ = [
69
69
  "SeededRNG",
@@ -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
- "arctic": {"temperature": -0.85, "moisture": 0.1, "mountain_density": 0.5},
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
  }
@@ -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, 1500))
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
- ring = cfg.continent_spread * min(width, height) * 0.42
448
-
449
- def ring_pt(angle: float) -> tuple[float, float]:
450
- return (cx + ring * math.cos(angle) + self._rng.fuzzy(0, width * 0.05),
451
- cy + ring * math.sin(angle) + self._rng.fuzzy(0, height * 0.05))
452
-
453
- for i in range(n):
454
- cont_seeds.append(ring_pt(2 * math.pi * i / n + self._rng.fuzzy(0, 0.4)))
455
- ocean_seeds.append(ring_pt(2 * math.pi * (i + 0.5) / n + self._rng.fuzzy(0, 0.4)))
456
- ocean_seeds.append((cx + self._rng.fuzzy(0, width * 0.1),
457
- cy + self._rng.fuzzy(0, height * 0.1))) # inner sea
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
- # A random drift direction (+ speed) per plate.
471
- drift = np.array([
472
- (math.cos(a) * s, math.sin(a) * s)
473
- for a, s in ((self._rng.uniform(0, 2 * math.pi), self._rng.uniform(0.4, 1.0))
474
- for _ in range(n_plates))
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 by plate type: continental crust rides high, oceanic low.
478
- base = np.array([0.55 if is_continental[plate[i]] else -0.65 for i in range(n_cells)])
479
-
480
- # Convergent-boundary uplift: at a cell bordering another plate, project the
481
- # plates' relative drift onto the boundary normal; pushing together uplift,
482
- # scaled by what's colliding (continent–continent ranges > arcs).
483
- boundary = np.zeros(n_cells)
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
- conv = ((drift[pi, 0] - drift[pj, 0]) * dx
493
- + (drift[pi, 1] - drift[pj, 1]) * dy) / length
494
- if conv <= 0:
495
- continue
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
- mag = 1.0 if (ci and cj) else (0.6 if (ci or cj) else 0.35)
498
- boundary[c.id] = max(boundary[c.id], conv * mag)
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
- raw = base + uplift + 0.45 * coast
525
- frame = self._radial_frame(centroids, width, height, cfg)
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 band at a randomly placed "equator" latitude, minus
847
- # an elevation lapse rate so peaks are cold, plus a global config bias.
848
- equator = self._rng.uniform(0.35, 0.65)
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
- temp = 1.0 - 2.0 * abs(lat - equator)
852
- temp -= 0.6 * max(0.0, c.height - sea_level) # lapse with elevation
853
- temp += cfg.temperature # global bias
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
@@ -70,7 +70,7 @@ EXPECTED_FIELDS = {
70
70
  "WorldMapConfig": ("sea_level", "continents", "continent_spread",
71
71
  "edge_falloff", "mountain_density", "roughness",
72
72
  "land_age", "temperature", "moisture", "river_density",
73
- "lake_density"),
73
+ "lake_density", "polar_cold"),
74
74
  "CellSummary": ("dominant_biome", "temperature", "moisture", "mean_height",
75
75
  "has_river", "has_lake", "water_fraction", "cell_count",
76
76
  "affordances"),
File without changes
File without changes
File without changes
File without changes
File without changes