mapwright 0.2.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.
mapwright/terrain.py ADDED
@@ -0,0 +1,542 @@
1
+ """Procedural regional terrain: Voronoi cells, erosion, rivers, biomes.
2
+
3
+ A clean-room Python port of the *ideas* in Azgaar's Fantasy-Map-Generator (MIT)
4
+ and rlguy/Mewo2's FantasyMapGenerator (Zlib) — no code copied from either. It
5
+ replaces the old ``sin/cos`` noise in ``layout_generator._generate_regional_terrain``
6
+ with a physically-motivated pipeline that produces organic coastlines, drainage
7
+ basins, rivers, and climate-driven biomes.
8
+
9
+ Pipeline (all on a Voronoi cell graph, then rasterised to the tile grid):
10
+
11
+ 1. **Voronoi cells** — jittered seed points + nearest-seed assignment, refined
12
+ by a couple of Lloyd-relaxation passes (the FMG/Watabou trick for evenly
13
+ organic cells). Done in pure numpy; no scipy dependency.
14
+ 2. **Heightmap** — additive primitives (a central landmass hill, a few random
15
+ hills/ranges) minus a radial edge falloff so the map reads as a continent
16
+ ringed by sea.
17
+ 3. **Planchon–Darboux depression fill** — guarantees every land cell drains to
18
+ the sea, so flux/rivers never dead-end in a pit.
19
+ 4. **Flux + hydraulic erosion** — water flows to the lowest neighbour, flux
20
+ accumulates downstream, and height is lowered by
21
+ ``river·√flux·slope + creep·slope²`` (FantasyMapGenerator's hybrid model),
22
+ carving valleys. Iterated a few passes.
23
+ 5. **Rivers** — cells whose accumulated flux exceeds a threshold trace a
24
+ downhill polyline; width scales with √flux.
25
+ 6. **Climate** — temperature from latitude minus an elevation lapse; moisture
26
+ from graph distance to water plus river proximity.
27
+ 7. **Biomes** — a Whittaker-style temperature×moisture matrix → biome → tile.
28
+
29
+ Everything is driven by a single :class:`SeededRNG`, so a seed reproduces the
30
+ whole world. The cell model is deliberately geometry-light (centroids + adjacency)
31
+ so a future SVG/graph exporter can swap nearest-seed cells for true
32
+ ``scipy.spatial.Voronoi`` polygons without touching the hydrology/climate code.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import math
38
+ from collections import deque
39
+ from dataclasses import dataclass, field
40
+ from enum import IntEnum
41
+
42
+ import numpy as np
43
+
44
+ from .config import WorldMapConfig
45
+ from .rng import SeededRNG
46
+
47
+
48
+ class Biome(IntEnum):
49
+ """Coarse biome classes. Mapping to a host app's tile vocabulary is the
50
+ consumer's job (this library stays domain-neutral)."""
51
+
52
+ OCEAN = 0
53
+ COAST = 1
54
+ BEACH = 2
55
+ DESERT = 3
56
+ PLAINS = 4
57
+ FOREST = 5
58
+ SWAMP = 6
59
+ HILLS = 7
60
+ MOUNTAIN = 8
61
+ TUNDRA = 9
62
+ SNOW = 10
63
+ RIVER = 11
64
+
65
+
66
+ @dataclass
67
+ class TerrainCell:
68
+ """One Voronoi cell with its terrain/hydrology/climate state."""
69
+
70
+ id: int
71
+ cx: float
72
+ cy: float
73
+ neighbors: list[int] = field(default_factory=list)
74
+ height: float = 0.0 # 0..1, sea level is generation sea_level
75
+ filled: float = 0.0 # depression-filled height (for drainage)
76
+ flux: float = 1.0 # accumulated upstream flow
77
+ downhill: int = -1 # cell id water flows to, or -1 at the sea
78
+ is_water: bool = False
79
+ is_river: bool = False
80
+ temperature: float = 0.5 # 0 (frozen) .. 1 (hot)
81
+ moisture: float = 0.5 # 0 (arid) .. 1 (wet)
82
+ biome: Biome = Biome.PLAINS
83
+
84
+
85
+ @dataclass
86
+ class River:
87
+ """A traced river as a polyline of cell centroids."""
88
+
89
+ cells: list[int]
90
+ width: float
91
+
92
+
93
+ @dataclass
94
+ class TerrainResult:
95
+ """Full regional terrain output: cells, the rasterised grid, and rivers."""
96
+
97
+ width: int
98
+ height: int
99
+ cells: list[TerrainCell]
100
+ cell_of: np.ndarray # int grid [height, width] -> cell id
101
+ rivers: list[River]
102
+ sea_level: float
103
+
104
+ def elevation_at(self, cell: "TerrainCell") -> float:
105
+ """Normalised height above sea level, 0..1 — handy for rasterisers."""
106
+ return max(0.0, (cell.height - self.sea_level) / max(1e-6, 1 - self.sea_level))
107
+
108
+
109
+ class RegionalTerrainGenerator:
110
+ """Builds :class:`TerrainResult` for a width×height regional map."""
111
+
112
+ def __init__(self, rng: SeededRNG):
113
+ # All terrain draws come from a derived sub-stream so terrain stays
114
+ # decoupled from naming/placement streams sharing the same root seed.
115
+ self._rng = rng.derive("terrain")
116
+ self._np = self._rng.numpy
117
+
118
+ # -- public entry ----------------------------------------------------
119
+
120
+ def generate(
121
+ self,
122
+ width: int,
123
+ height: int,
124
+ config: WorldMapConfig | None = None,
125
+ *,
126
+ cell_area: float = 6.0,
127
+ relax_iterations: int = 2,
128
+ ) -> TerrainResult:
129
+ """Run the full pipeline and return terrain for a ``width×height`` grid.
130
+
131
+ ``config`` (a :class:`WorldMapConfig`) shapes the world — sea level,
132
+ number of continents, climate, mountains, rivers. ``cell_area`` and
133
+ ``relax_iterations`` are quality/detail dials independent of the world's
134
+ character. With no config a balanced single-continent world is produced.
135
+ """
136
+ cfg = config or WorldMapConfig()
137
+ sea_level = cfg.sea_level
138
+ erosion_passes = max(1, round(1 + cfg.roughness * 4))
139
+
140
+ n_cells = int(np.clip(round(width * height / cell_area), 16, 1500))
141
+ seeds = self._sample_seeds(width, height, n_cells)
142
+ cell_of, seeds = self._voronoi(width, height, seeds, relax_iterations)
143
+ cells = self._build_cells(seeds, cell_of)
144
+
145
+ self._init_heightmap(cells, width, height, cfg)
146
+ for cell in cells:
147
+ cell.is_water = cell.height < sea_level
148
+
149
+ for _ in range(erosion_passes):
150
+ self._fill_depressions(cells, sea_level)
151
+ self._compute_flux(cells)
152
+ self._erode(cells, sea_level)
153
+ for cell in cells:
154
+ cell.is_water = cell.height < sea_level
155
+
156
+ # Final hydrology pass for stable rivers, then climate + biomes.
157
+ self._fill_depressions(cells, sea_level)
158
+ self._compute_flux(cells)
159
+ rivers = self._trace_rivers(cells, cfg.river_density)
160
+ self._compute_climate(cells, width, height, sea_level, cfg)
161
+ self._assign_biomes(cells, sea_level)
162
+
163
+ return TerrainResult(
164
+ width=width,
165
+ height=height,
166
+ cells=cells,
167
+ cell_of=cell_of,
168
+ rivers=rivers,
169
+ sea_level=sea_level,
170
+ )
171
+
172
+ # -- 1. Voronoi cells (pure numpy) -----------------------------------
173
+
174
+ def _sample_seeds(self, width: int, height: int, n: int) -> np.ndarray:
175
+ """Jittered-grid seed points — even coverage without clumping."""
176
+ cols = max(1, int(round(math.sqrt(n * width / max(1, height)))))
177
+ rows = max(1, int(math.ceil(n / cols)))
178
+ cw, ch = width / cols, height / rows
179
+ pts = []
180
+ for r in range(rows):
181
+ for c in range(cols):
182
+ jx = self._rng.random()
183
+ jy = self._rng.random()
184
+ pts.append(((c + jx) * cw, (r + jy) * ch))
185
+ return np.array(pts[:n] if len(pts) >= n else pts, dtype=float)
186
+
187
+ def _voronoi(
188
+ self, width: int, height: int, seeds: np.ndarray, relax: int
189
+ ) -> tuple[np.ndarray, np.ndarray]:
190
+ """Assign every grid cell to its nearest seed, with Lloyd relaxation."""
191
+ xs, ys = np.meshgrid(np.arange(width), np.arange(height))
192
+ coords = np.stack([xs.ravel(), ys.ravel()], axis=1).astype(float)
193
+ cell_of = self._nearest(coords, seeds).reshape(height, width)
194
+
195
+ for _ in range(relax):
196
+ # Move each seed to its region's centroid, then reassign.
197
+ new_seeds = seeds.copy()
198
+ flat = cell_of.ravel()
199
+ for cid in range(len(seeds)):
200
+ mask = flat == cid
201
+ if mask.any():
202
+ new_seeds[cid] = coords[mask].mean(axis=0)
203
+ seeds = new_seeds
204
+ cell_of = self._nearest(coords, seeds).reshape(height, width)
205
+ return cell_of, seeds
206
+
207
+ @staticmethod
208
+ def _nearest(coords: np.ndarray, seeds: np.ndarray) -> np.ndarray:
209
+ """Nearest-seed index per coord, computed in blocks to bound memory."""
210
+ p, n = coords.shape[0], seeds.shape[0]
211
+ out = np.empty(p, dtype=np.int32)
212
+ block = max(1, int(4_000_000 / max(1, n))) # ~4M float cap per block
213
+ for start in range(0, p, block):
214
+ chunk = coords[start : start + block]
215
+ d2 = ((chunk[:, None, :] - seeds[None, :, :]) ** 2).sum(axis=2)
216
+ out[start : start + block] = d2.argmin(axis=1)
217
+ return out
218
+
219
+ def _build_cells(self, seeds: np.ndarray, cell_of: np.ndarray) -> list[TerrainCell]:
220
+ """Construct cells with centroids and grid-adjacency neighbours."""
221
+ cells = [TerrainCell(id=i, cx=float(s[0]), cy=float(s[1])) for i, s in enumerate(seeds)]
222
+ neigh: list[set[int]] = [set() for _ in seeds]
223
+ # Two cells are adjacent if they touch horizontally or vertically.
224
+ a = cell_of[:, :-1]
225
+ b = cell_of[:, 1:]
226
+ for u, v in np.unique(np.stack([a.ravel(), b.ravel()], axis=1), axis=0):
227
+ if u != v:
228
+ neigh[u].add(int(v))
229
+ neigh[v].add(int(u))
230
+ a = cell_of[:-1, :]
231
+ b = cell_of[1:, :]
232
+ for u, v in np.unique(np.stack([a.ravel(), b.ravel()], axis=1), axis=0):
233
+ if u != v:
234
+ neigh[u].add(int(v))
235
+ neigh[v].add(int(u))
236
+ for cell in cells:
237
+ cell.neighbors = sorted(neigh[cell.id])
238
+ return cells
239
+
240
+ # -- 2. Heightmap primitives -----------------------------------------
241
+
242
+ def _init_heightmap(
243
+ self, cells: list[TerrainCell], width: int, height: int, cfg: WorldMapConfig
244
+ ) -> None:
245
+ cx, cy = width / 2, height / 2
246
+ diag = math.hypot(width, height)
247
+ centroids = np.array([[c.cx, c.cy] for c in cells])
248
+
249
+ def gaussian(px: float, py: float, radius: float, amp: float) -> np.ndarray:
250
+ d2 = (centroids[:, 0] - px) ** 2 + (centroids[:, 1] - py) ** 2
251
+ return amp * np.exp(-d2 / (2 * radius * radius))
252
+
253
+ # Major landmasses. One ⇒ a central continent; several ⇒ blobs on a ring.
254
+ n = cfg.continents
255
+ major_radius = diag * 0.28 / (1 + 0.45 * (n - 1))
256
+ centers: list[tuple[float, float]] = []
257
+ if n == 1:
258
+ centers.append((cx + self._rng.fuzzy(0, width * 0.1),
259
+ cy + self._rng.fuzzy(0, height * 0.1)))
260
+ else:
261
+ ring = cfg.continent_spread * min(width, height) * 0.42
262
+ for i in range(n):
263
+ ang = 2 * math.pi * i / n + self._rng.fuzzy(0, 0.5)
264
+ centers.append((cx + ring * math.cos(ang) + self._rng.fuzzy(0, width * 0.06),
265
+ cy + ring * math.sin(ang) + self._rng.fuzzy(0, height * 0.06)))
266
+
267
+ # Combine landmasses with MAX (not sum) so adjacent continents don't form
268
+ # additive land-bridges between them — that's what makes islands islands.
269
+ h = np.zeros(len(cells))
270
+ for px, py in centers:
271
+ amp = 1.0 if n == 1 else self._rng.uniform(0.8, 1.0)
272
+ h = np.maximum(h, gaussian(px, py, major_radius, amp))
273
+
274
+ # Hills/ranges — count and height scale with mountain_density. Anchored
275
+ # to a landmass and *added* on top, so they decorate continents.
276
+ n_small = round(2 + cfg.mountain_density * 8)
277
+ spread = major_radius * 0.55 # keep ranges on their landmass (no bridging)
278
+ for _ in range(n_small):
279
+ bx, by = self._rng.choice(centers)
280
+ h = h + gaussian(bx + self._rng.fuzzy(0, spread),
281
+ by + self._rng.fuzzy(0, spread),
282
+ radius=diag * self._rng.uniform(0.06, 0.16),
283
+ amp=self._rng.uniform(0.2, 0.35 + cfg.mountain_density * 0.5))
284
+
285
+ # Radial edge falloff: push the map border below sea level so the map
286
+ # reads as land ringed by sea (and gives real coastlines).
287
+ d_edge = np.sqrt((centroids[:, 0] - cx) ** 2 + (centroids[:, 1] - cy) ** 2) / (diag / 2)
288
+ h = h - np.clip((d_edge - 0.45) / 0.55, 0, 1) * (1.15 * cfg.edge_falloff)
289
+
290
+ # Normalise to 0..1.
291
+ h = h - h.min()
292
+ if h.max() > 0:
293
+ h = h / h.max()
294
+ for cell, hv in zip(cells, h):
295
+ cell.height = float(hv)
296
+
297
+ # -- 3. Planchon–Darboux depression fill -----------------------------
298
+
299
+ @staticmethod
300
+ def _fill_depressions(cells: list[TerrainCell], sea_level: float, epsilon: float = 1e-4) -> None:
301
+ """Raise pits so every land cell has a downhill path to the sea."""
302
+ INF = float("inf")
303
+ for c in cells:
304
+ # Water cells (and thus the sea) are fixed outlets at their height.
305
+ c.filled = c.height if c.height < sea_level else INF
306
+ changed = True
307
+ # Iterate to a fixed point; small cell counts make this cheap.
308
+ while changed:
309
+ changed = False
310
+ for c in cells:
311
+ if c.filled == c.height:
312
+ continue
313
+ lowest = min((cells[n].filled for n in c.neighbors), default=INF)
314
+ candidate = max(c.height, lowest + epsilon)
315
+ if candidate < c.filled:
316
+ c.filled = candidate
317
+ changed = True
318
+
319
+ # -- 4. Flux + hydraulic erosion -------------------------------------
320
+
321
+ @staticmethod
322
+ def _compute_flux(cells: list[TerrainCell]) -> None:
323
+ """Route flow downhill on filled heights and accumulate flux."""
324
+ for c in cells:
325
+ c.flux = 1.0
326
+ c.downhill = -1
327
+ land = [c for c in cells if c.height >= 0 and not c.is_water]
328
+ for c in cells:
329
+ if c.is_water:
330
+ continue
331
+ # Steepest-descent neighbour on the filled surface.
332
+ best, best_h = -1, c.filled
333
+ for n in c.neighbors:
334
+ if cells[n].filled < best_h:
335
+ best_h, best = cells[n].filled, n
336
+ c.downhill = best
337
+ # Accumulate from high to low so upstream flux is ready first.
338
+ for c in sorted(land, key=lambda c: c.filled, reverse=True):
339
+ if c.downhill >= 0:
340
+ cells[c.downhill].flux += c.flux
341
+
342
+ def _erode(self, cells: list[TerrainCell], sea_level: float,
343
+ river_factor: float = 0.06, creep_factor: float = 0.02,
344
+ max_rate: float = 0.045) -> None:
345
+ """Lower each land cell by hydraulic + creep erosion (capped)."""
346
+ for c in cells:
347
+ if c.is_water or c.downhill < 0:
348
+ continue
349
+ down = cells[c.downhill]
350
+ dist = math.hypot(c.cx - down.cx, c.cy - down.cy) or 1.0
351
+ slope = max(0.0, (c.filled - down.filled) / dist)
352
+ erosion = river_factor * math.sqrt(c.flux) * slope + creep_factor * slope * slope
353
+ c.height = max(sea_level * 0.5, c.height - min(erosion, max_rate))
354
+
355
+ # -- 5. Rivers -------------------------------------------------------
356
+
357
+ @staticmethod
358
+ def _trace_rivers(cells: list[TerrainCell], river_density: float) -> list[River]:
359
+ """Trace downhill polylines from genuine high-flux river sources.
360
+
361
+ Rivers are *rare* — only trunk streams that gathered real drainage. We
362
+ pick sources above a flux quantile (with an absolute floor so small maps
363
+ don't over-river), follow each to the sea, and only then mark the cells
364
+ of kept rivers as ``is_river`` — so short, spurious paths never paint the
365
+ interior blue. ``river_density`` (0..1) lowers both the quantile and the
366
+ floor so more, smaller rivers appear.
367
+ """
368
+ land = [c for c in cells if not c.is_water]
369
+ if not land:
370
+ return []
371
+ # A source needs accumulated flux above a threshold that scales with map
372
+ # size (so big maps aren't flooded) and shrinks with river_density, so
373
+ # higher density ⇒ lower bar ⇒ more (and smaller) rivers.
374
+ cutoff = max(5.0, (0.10 - 0.075 * river_density) * len(cells))
375
+ rivers: list[River] = []
376
+ used: set[int] = set()
377
+ sources = sorted(
378
+ (c for c in cells if not c.is_water and c.flux >= cutoff),
379
+ key=lambda c: c.flux, reverse=True,
380
+ )
381
+ for src in sources:
382
+ if src.id in used:
383
+ continue
384
+ path, cur = [], src
385
+ # Follow the trunk downhill to the sea (or into an existing river).
386
+ while cur is not None and not cur.is_water:
387
+ path.append(cur.id)
388
+ if cur.id in used:
389
+ break # merged into a previously traced river
390
+ cur = cells[cur.downhill] if cur.downhill >= 0 else None
391
+ if len(path) >= 2:
392
+ for cid in path:
393
+ used.add(cid)
394
+ cells[cid].is_river = True
395
+ rivers.append(River(cells=path, width=math.sqrt(src.flux)))
396
+ return rivers
397
+
398
+ # -- 6. Climate ------------------------------------------------------
399
+
400
+ def _compute_climate(
401
+ self, cells: list[TerrainCell], width: int, height: int, sea_level: float,
402
+ cfg: WorldMapConfig,
403
+ ) -> None:
404
+ # Temperature: warm band at a randomly placed "equator" latitude, minus
405
+ # an elevation lapse rate so peaks are cold, plus a global config bias.
406
+ equator = self._rng.uniform(0.35, 0.65)
407
+ for c in cells:
408
+ lat = c.cy / max(1, height - 1)
409
+ temp = 1.0 - 2.0 * abs(lat - equator)
410
+ temp -= 0.6 * max(0.0, c.height - sea_level) # lapse with elevation
411
+ temp += cfg.temperature # global bias
412
+ c.temperature = float(np.clip(temp + self._rng.fuzzy(0, 0.05), 0.0, 1.0))
413
+
414
+ # Moisture: multi-source BFS hop-distance from water over the cell graph,
415
+ # decaying inland; rivers add local moisture.
416
+ dist = {c.id: math.inf for c in cells}
417
+ q: deque[int] = deque()
418
+ for c in cells:
419
+ if c.is_water:
420
+ dist[c.id] = 0
421
+ q.append(c.id)
422
+ while q:
423
+ cid = q.popleft()
424
+ for n in cells[cid].neighbors:
425
+ if dist[n] > dist[cid] + 1:
426
+ dist[n] = dist[cid] + 1
427
+ q.append(n)
428
+ scale = max(3.0, (width + height) / 12.0)
429
+ for c in cells:
430
+ base = math.exp(-dist[c.id] / scale)
431
+ if c.is_river:
432
+ base = min(1.0, base + 0.35)
433
+ base += cfg.moisture * 0.5 # global bias
434
+ c.moisture = float(np.clip(base + self._rng.fuzzy(0, 0.05), 0.0, 1.0))
435
+
436
+ # -- 7. Biome assignment ---------------------------------------------
437
+
438
+ def _assign_biomes(self, cells: list[TerrainCell], sea_level: float) -> None:
439
+ water_ids = {c.id for c in cells if c.is_water}
440
+ for c in cells:
441
+ if c.is_water:
442
+ # Shoreline water (touching land) reads as coast/shallows.
443
+ touches_land = any(n not in water_ids for n in c.neighbors)
444
+ c.biome = Biome.COAST if touches_land else Biome.OCEAN
445
+ continue
446
+ if c.is_river:
447
+ c.biome = Biome.RIVER
448
+ continue
449
+ c.biome = self._whittaker(c, sea_level, water_ids)
450
+
451
+ @staticmethod
452
+ def _whittaker(c: TerrainCell, sea_level: float, water_ids: set[int]) -> Biome:
453
+ """Temperature×moisture×elevation → biome (Whittaker-style matrix)."""
454
+ rel = (c.height - sea_level) / max(1e-6, 1 - sea_level) # 0..1 above sea
455
+ t, m = c.temperature, c.moisture
456
+
457
+ # Elevation dominates at the extremes.
458
+ if rel > 0.72:
459
+ return Biome.SNOW if t < 0.35 else Biome.MOUNTAIN
460
+ if rel > 0.48:
461
+ return Biome.HILLS
462
+
463
+ touches_sea = any(n in water_ids for n in c.neighbors)
464
+
465
+ # Land just above the waterline that touches the sea → beach.
466
+ if rel < 0.06 and touches_sea:
467
+ return Biome.BEACH
468
+
469
+ # Inland low-lying very wet ground → swamp. Gated off the coast so we
470
+ # don't paint a swamp ring around every shoreline.
471
+ if m > 0.82 and rel < 0.15 and not touches_sea:
472
+ return Biome.SWAMP
473
+
474
+ if t < 0.25:
475
+ return Biome.TUNDRA
476
+ if t > 0.7:
477
+ return Biome.DESERT if m < 0.3 else Biome.FOREST
478
+ # Temperate band.
479
+ if m < 0.33:
480
+ return Biome.PLAINS
481
+ return Biome.FOREST if m > 0.5 else Biome.PLAINS
482
+
483
+
484
+ # ---------------------------------------------------------------------------
485
+ # Voronoi polygon reconstruction (for vector/SVG rendering).
486
+ #
487
+ # The cell graph stores only centroids + adjacency, so for vector output we
488
+ # rebuild each cell's convex polygon by clipping the map rectangle with the
489
+ # perpendicular bisector between the cell and each of its neighbours
490
+ # (Sutherland–Hodgman half-plane clipping). Pure Python, no scipy — exact for
491
+ # the relaxed seed sites we generate.
492
+ # ---------------------------------------------------------------------------
493
+
494
+ Point = tuple[float, float]
495
+
496
+
497
+ def _clip_halfplane(poly: list[Point], mx: float, my: float, ax: float, ay: float) -> list[Point]:
498
+ """Keep the part of ``poly`` on the ``c`` side of a bisector.
499
+
500
+ The half-plane is ``{p : (p - m)·a <= 0}`` where ``m`` is the bisector
501
+ midpoint and ``a`` points from the cell toward its neighbour.
502
+ """
503
+ def inside(p: Point) -> bool:
504
+ return (p[0] - mx) * ax + (p[1] - my) * ay <= 1e-9
505
+
506
+ def intersect(a: Point, b: Point) -> Point:
507
+ dx, dy = b[0] - a[0], b[1] - a[1]
508
+ denom = dx * ax + dy * ay
509
+ if abs(denom) < 1e-12:
510
+ return a
511
+ t = ((mx - a[0]) * ax + (my - a[1]) * ay) / denom
512
+ return (a[0] + t * dx, a[1] + t * dy)
513
+
514
+ out: list[Point] = []
515
+ n = len(poly)
516
+ for i in range(n):
517
+ a, b = poly[i], poly[(i + 1) % n]
518
+ a_in, b_in = inside(a), inside(b)
519
+ if a_in:
520
+ out.append(a)
521
+ if a_in != b_in:
522
+ out.append(intersect(a, b))
523
+ return out
524
+
525
+
526
+ def compute_cell_polygons(
527
+ cells: list[TerrainCell], width: int, height: int
528
+ ) -> dict[int, list[Point]]:
529
+ """Return a convex polygon (list of points) for each cell, clipped to the map."""
530
+ rect: list[Point] = [(0.0, 0.0), (float(width), 0.0),
531
+ (float(width), float(height)), (0.0, float(height))]
532
+ polys: dict[int, list[Point]] = {}
533
+ for c in cells:
534
+ poly = rect
535
+ for nid in c.neighbors:
536
+ n = cells[nid]
537
+ mx, my = (c.cx + n.cx) / 2, (c.cy + n.cy) / 2
538
+ poly = _clip_halfplane(poly, mx, my, n.cx - c.cx, n.cy - c.cy)
539
+ if len(poly) < 3:
540
+ break
541
+ polys[c.id] = poly
542
+ return polys
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: mapwright
3
+ Version: 0.2.0
4
+ Summary: Domain-neutral procedural fantasy map & world generation: Voronoi terrain, hydraulic erosion, biomes, rivers, Markov place-names, and shaded-relief SVG.
5
+ Project-URL: Homepage, https://github.com/sligara7/mapwright
6
+ Project-URL: Repository, https://github.com/sligara7/mapwright
7
+ Author: Anthony Sligar
8
+ License: MIT
9
+ License-File: LICENSE
10
+ License-File: NOTICE
11
+ Keywords: biomes,erosion,fantasy-map,procedural-generation,svg,terrain,ttrpg,voronoi,worldgen
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Games/Entertainment :: Role-Playing
16
+ Classifier: Topic :: Multimedia :: Graphics
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: numpy>=1.26
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=7.4; extra == 'dev'
21
+ Requires-Dist: ruff>=0.1; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # mapwright
25
+
26
+ > ⚠️ **Early development (v0.1, alpha).** The API is still moving and may change without
27
+ > notice between versions. Extracted from a working application; usable today, but pin a
28
+ > commit if you depend on it.
29
+
30
+ **Domain-neutral procedural fantasy map & world generation** — Voronoi terrain with
31
+ hydraulic erosion, climate-driven biomes, rivers, Markov place-names, and shaded-relief
32
+ SVG rendering. Pure Python, `numpy`-only, fully seed-deterministic.
33
+
34
+ mapwright produces *neutral data* (cells, biomes, rivers, polygons) and a self-contained
35
+ SVG renderer. It has no opinion about your application's models — map its output onto your
36
+ own tiles/entities however you like.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install git+https://github.com/sligara7/mapwright.git
42
+ # or, for local development:
43
+ pip install -e ".[dev]"
44
+ ```
45
+
46
+ ## Quickstart
47
+
48
+ ```python
49
+ from mapwright import SeededRNG, RegionalTerrainGenerator, RegionalSVGRenderer, Marker
50
+
51
+ # Same seed -> same world, every time.
52
+ terrain = RegionalTerrainGenerator(SeededRNG(7)).generate(width=60, height=40)
53
+
54
+ markers = [Marker(name="Eldmoor", x=30, y=18, kind="settlement_city")]
55
+ svg = RegionalSVGRenderer().render(terrain, markers)
56
+ open("world.svg", "w").write(svg)
57
+ ```
58
+
59
+ Shape the world with `WorldMapConfig` — or describe it and let an LLM fill the config:
60
+
61
+ ```python
62
+ from mapwright import WorldMapConfig, RegionalTerrainGenerator, SeededRNG
63
+
64
+ desert = WorldMapConfig.preset("desert") # ready-made worlds...
65
+ custom = WorldMapConfig(continents=7, sea_level=0.55, temperature=-0.8) # ...or tune
66
+ world = RegionalTerrainGenerator(SeededRNG(1)).generate(60, 40, config=desert)
67
+
68
+ # Every field is a bounded scalar with a clear meaning, so it doubles as a schema
69
+ # a host app (or an LLM) can populate. from_dict clamps junk to valid ranges:
70
+ WorldMapConfig.from_dict({"temperature": 5, "continents": -3}) # -> safe, clamped
71
+ ```
72
+
73
+ Presets: `continent`, `pangaea`, `archipelago`, `islands`, `highlands`, `desert`,
74
+ `arctic`, `tropical`.
75
+
76
+ Procedural place-names in several culture styles:
77
+
78
+ ```python
79
+ from mapwright import SeededRNG, NameGenerator
80
+
81
+ namer = NameGenerator(SeededRNG(7))
82
+ namer.settlement("nordic") # -> 'Eirmundheim'
83
+ namer.settlement("elvish") # -> 'Faelynnwood'
84
+ namer.region("dwarvish") # -> 'The Korvald Reach'
85
+ ```
86
+
87
+ ## What's inside
88
+
89
+ | Component | What it does |
90
+ |-----------|--------------|
91
+ | `SeededRNG` | One seed drives everything; `.derive(label)` yields independent, reproducible sub-streams (unifies stdlib + numpy). |
92
+ | `NameGenerator` | Order-k character Markov names over hand-authored culture namebases; reproducible across processes. |
93
+ | `RegionalTerrainGenerator` | Voronoi cells (Lloyd-relaxed) → heightmap → Planchon–Darboux depression fill → flux + hydraulic/creep erosion → rivers → latitude/elevation climate → Whittaker biomes. |
94
+ | `compute_cell_polygons` | Reconstructs convex Voronoi polygons (half-plane clipping) for vector rendering. |
95
+ | `RegionalSVGRenderer` | Shaded-relief (hillshade) SVG: biome polygons, coastline, rivers, labelled markers. |
96
+ | `DungeonGenerator` | BSP-partitioned rooms + minimum-spanning-tree corridors → rooms, corridor cells, and a walkable grid (with `Dungeon.ascii()`). |
97
+
98
+ Everything is neutral: `RegionalTerrainGenerator` returns a `TerrainResult` of `TerrainCell`s
99
+ (each with a `Biome`), and you decide how a `Biome` maps to your world.
100
+
101
+ ## Determinism
102
+
103
+ Every generator draws from a `SeededRNG`. The same seed (and parameters) reproduces an
104
+ identical world — terrain, names, rivers, and SVG — across runs *and across processes*
105
+ (the Markov chains are built in sorted order, so output never depends on `PYTHONHASHSEED`).
106
+
107
+ ## API stability & contract
108
+
109
+ The **public API is exactly the names exported in `mapwright.__all__`** — that's
110
+ the contract. It's pinned by `tests/test_api_contract.py` (public surface, key
111
+ signatures), so an accidental breaking change fails CI.
112
+
113
+ For the world parameters specifically, `WorldMapConfig.json_schema()` returns a
114
+ JSON Schema (draft 2020-12) — the machine-readable contract a host app or LLM can
115
+ validate/generate against, then feed through `WorldMapConfig.from_dict()` (which
116
+ clamps to valid ranges). Schema and runtime clamping are generated from the same
117
+ field spec, so they can't drift.
118
+
119
+ Versioning follows [SemVer](https://semver.org/). While at `0.x` the API may still
120
+ change between minor versions; every change is recorded in `CHANGELOG.md`. Pin a
121
+ tag or commit if you depend on it.
122
+
123
+ ## Development
124
+
125
+ ```bash
126
+ python -m venv .venv && . .venv/bin/activate
127
+ pip install -e ".[dev]"
128
+ pytest
129
+ ```
130
+
131
+ ## Credits & license
132
+
133
+ MIT licensed (see `LICENSE`). Algorithms were implemented clean-room from the publicly
134
+ described techniques of **Azgaar's Fantasy-Map-Generator** (MIT) and **Martin O'Leary /
135
+ Ryan L. Guy's FantasyMapGenerator** (Zlib); see `NOTICE` for details. The bundled name
136
+ lists are original.
@@ -0,0 +1,12 @@
1
+ mapwright/__init__.py,sha256=bFO6dMNMsxfq3f-7IXI9tSMkq_jqHTS0Krqxg4ERr1Y,2039
2
+ mapwright/config.py,sha256=dy0qfR7AV7_lFTYc2XnBtXwdW1k5Nf2RSjTlXz-Mz3k,6540
3
+ mapwright/dungeon.py,sha256=chU4rAYstUsokLb2XSfJVrOzsoNi7Zlayu-U7TQV-tE,8292
4
+ mapwright/names.py,sha256=Jt3Sc4_bOUTzvzkVkkNjRG-7xXaWlfkPIFY-GepswYw,11600
5
+ mapwright/rng.py,sha256=rfK-K8ed0u2f3Ummqx_1SMCCFB5j5EuvkexS_kn1LQY,6419
6
+ mapwright/svg_renderer.py,sha256=xC9OlwgZfb5nYHSPhT38Pa9iLIcX6YtWe7kwtAoeXfg,10008
7
+ mapwright/terrain.py,sha256=CuhHWBLtkpdjDMKMJy5PbedYN2-hULsweIsW3x-3fkM,22716
8
+ mapwright-0.2.0.dist-info/METADATA,sha256=57ej047tIMnDi-seQuuW0yfv3XWtBsF3vIFj1AxyjNU,5839
9
+ mapwright-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
10
+ mapwright-0.2.0.dist-info/licenses/LICENSE,sha256=SH_gQdl3Ihoor4g_7cE-qaRM8o1tGRh1Alo3oPsLJGI,1071
11
+ mapwright-0.2.0.dist-info/licenses/NOTICE,sha256=eyG7FgFirXwK_YzzcdZHKjOSh2bRfeOyXEvXRK5NzCo,1384
12
+ mapwright-0.2.0.dist-info/RECORD,,