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/names.py ADDED
@@ -0,0 +1,261 @@
1
+ """Procedural name generation via character-level Markov chains.
2
+
3
+ Ported (clean-room) from the technique in Azgaar's Fantasy-Map-Generator (MIT):
4
+ train a Markov chain on a small list of example names that share a linguistic
5
+ "feel", then walk the chain to emit *new* names in the same style. This is
6
+ cheap, fully offline (no LLM call), deterministic given a :class:`SeededRNG`,
7
+ and language-agnostic — swap the training list to change the culture.
8
+
9
+ Why a Markov chain rather than just sampling syllables: an order-k chain
10
+ captures local letter-transition statistics (which clusters are plausible in a
11
+ given language) without needing a phonotactic grammar. Order 3 over the seed
12
+ lists below gives names that read as "Nordic" or "Elvish" without ever copying
13
+ a training word verbatim (we reject exact training-set hits).
14
+
15
+ The seed namebases here are intentionally compact, hand-authored word lists —
16
+ *not* copied from any GPL/unlicensed generator — so the output and this module
17
+ are clean for reuse. They're large enough for an order-3 chain; extend them
18
+ freely (more examples → richer output).
19
+
20
+ Usage::
21
+
22
+ rng = SeededRNG(seed)
23
+ namer = NameGenerator(rng)
24
+ namer.place("nordic") # -> "Skjoldur"
25
+ namer.settlement("generic") # -> "Eldmoor" (base name + culture suffix)
26
+ namer.person("elvish") # -> "Aelirien"
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from collections import defaultdict
32
+ from typing import Optional
33
+
34
+ from .rng import SeededRNG
35
+
36
+ # Sentinel marking a word boundary in the Markov context/emission alphabet.
37
+ _BOUNDARY = "\x00"
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Seed namebases — hand-authored, license-clean training lists, one per culture.
41
+ # Order-3 chains need only a couple dozen examples to produce varied output.
42
+ # ---------------------------------------------------------------------------
43
+
44
+ NAMEBASES: dict[str, list[str]] = {
45
+ "generic": [
46
+ "Aldwin", "Brennan", "Caldor", "Dunmere", "Eldric", "Faron",
47
+ "Garrick", "Hadwin", "Ironholt", "Kelmoor", "Lannet", "Marden",
48
+ "Norwick", "Oakheart", "Pelmont", "Ravenwood", "Stonefel", "Thornby",
49
+ "Ulmar", "Vendry", "Westmere", "Wyndham", "Yardley", "Ashford",
50
+ "Briarcliff", "Greymoor", "Holloway", "Redwyn", "Tarnwick",
51
+ ],
52
+ "nordic": [
53
+ "Bjorn", "Sigurd", "Ragnar", "Eirik", "Halldor", "Gunnar", "Ivar",
54
+ "Knut", "Leif", "Olaf", "Sten", "Torvald", "Ulf", "Vidar", "Asgeir",
55
+ "Skuli", "Hakon", "Brandr", "Geirmund", "Snorri", "Thorgil",
56
+ "Frosta", "Helga", "Ingrid", "Sigrun", "Yngvar", "Skjold", "Dagny",
57
+ ],
58
+ "latin": [
59
+ "Marcus", "Valeria", "Cassius", "Aurelia", "Decimus", "Octavia",
60
+ "Tiberius", "Lucilla", "Quintus", "Flavia", "Septimus", "Antonia",
61
+ "Cornelius", "Drusilla", "Gaius", "Livia", "Maximus", "Vipsania",
62
+ "Aelius", "Camilla", "Tarquin", "Sabina", "Verus", "Junia",
63
+ ],
64
+ "elvish": [
65
+ "Aelar", "Caelynn", "Eluvian", "Faelar", "Galadriel", "Ithilien",
66
+ "Laeroth", "Mirelle", "Naerith", "Oromis", "Saelihn", "Thalion",
67
+ "Aerendyl", "Cithreth", "Elarian", "Faewyn", "Illianor", "Lithriel",
68
+ "Nimriel", "Sylvaris", "Vaelynn", "Aelirien", "Maeglin", "Tinuviel",
69
+ ],
70
+ "dwarvish": [
71
+ "Borin", "Durgan", "Thrain", "Balgrim", "Kragdor", "Norund",
72
+ "Gimrek", "Brundal", "Khazek", "Morgrum", "Thoradin", "Brokkur",
73
+ "Dvalin", "Grundvik", "Hrothgar", "Kazrik", "Orin", "Throndur",
74
+ "Belmund", "Garrundr", "Korvald", "Stonebeard", "Ironfist", "Deepdelve",
75
+ ],
76
+ "eastern": [
77
+ "Akihiro", "Renjiro", "Haruki", "Kenshin", "Daichi", "Sora",
78
+ "Takeo", "Yorimoto", "Ryusei", "Kaede", "Michiyo", "Tamaki",
79
+ "Hideaki", "Naoki", "Shinobu", "Yukihiro", "Asuka", "Reiko",
80
+ "Toshiro", "Mizuki", "Hayato", "Sayuri", "Kojiro", "Emiko",
81
+ ],
82
+ "desert": [
83
+ "Aziz", "Farran", "Jamil", "Karim", "Nasir", "Rashid", "Tariq",
84
+ "Yusuf", "Zahir", "Amina", "Layla", "Nadira", "Samira", "Zaida",
85
+ "Hakim", "Ibrahim", "Khalil", "Mansur", "Rafiq", "Saladin",
86
+ "Bahram", "Cyrus", "Darius", "Faridun",
87
+ ],
88
+ "dark": [
89
+ "Malketh", "Vornak", "Drathys", "Skorvath", "Nyxara", "Vexmoor",
90
+ "Gorthak", "Zultharn", "Morvath", "Skarn", "Velkith", "Drusk",
91
+ "Azgoth", "Korrath", "Nethyr", "Sablethorn", "Grimwald", "Vauldrek",
92
+ "Mortessa", "Shaelgar", "Thessaroth", "Ulvyn", "Xathorne", "Zerith",
93
+ ],
94
+ }
95
+
96
+ # Optional culture-flavoured settlement suffixes, appended to a base name to
97
+ # form place names ("Eld" + "moor" -> "Eldmoor"). Mirrors how Azgaar composes
98
+ # burg names from a root plus a geographic suffix.
99
+ SETTLEMENT_SUFFIXES: dict[str, list[str]] = {
100
+ "generic": ["ton", "ford", "moor", "wick", "bury", "hill", "dale", "field",
101
+ "gate", "haven", "crest", "hollow", "march", "watch"],
102
+ "nordic": ["heim", "vik", "fjord", "gard", "stad", "ness", "fell", "by"],
103
+ "latin": ["um", "ium", "polis", "ara", "ena", "ica", "anum"],
104
+ "elvish": ["wood", "lond", "thil", "mar", "vale", "shire", "loth", "wen"],
105
+ "dwarvish": ["delve", "hold", "forge", "deep", "barrow", "mount", "karak"],
106
+ "eastern": ["mura", "gawa", "yama", "shiro", "do", "ji", "saki"],
107
+ "desert": ["abad", "stan", "kar", "oasis", "dune", "mir", "sah"],
108
+ "dark": ["spire", "gloom", "barrow", "throne", "mire", "fang", "shroud"],
109
+ }
110
+
111
+ # A reasonable default when a caller asks for an unknown culture.
112
+ _FALLBACK_CULTURE = "generic"
113
+
114
+
115
+ class MarkovNameGenerator:
116
+ """An order-``k`` character-level Markov chain trained on one word list."""
117
+
118
+ def __init__(self, words: list[str], order: int = 3):
119
+ self.order = order
120
+ # Keep a clean, lowercased copy of the training words so we can reject
121
+ # exact reproductions and know plausible length bounds.
122
+ self._training = {w.lower() for w in words if w and w.isalpha()}
123
+ self._min_len, self._max_len = self._length_bounds(self._training)
124
+ self._chain: dict[str, list[str]] = self._build_chain(self._training)
125
+
126
+ @staticmethod
127
+ def _length_bounds(words: set[str]) -> tuple[int, int]:
128
+ if not words:
129
+ return 4, 10
130
+ lengths = [len(w) for w in words]
131
+ return max(3, min(lengths)), max(lengths) + 2
132
+
133
+ def _build_chain(self, words: set[str]) -> dict[str, list[str]]:
134
+ """Map each k-char context to the list of characters that follow it.
135
+
136
+ We store the raw (multiset) list rather than a Counter so that picking a
137
+ next char with ``rng.choice`` is automatically frequency-weighted.
138
+ Words are padded with ``order`` boundary sentinels on each side.
139
+
140
+ ``words`` is iterated in **sorted** order, not set order: the follower
141
+ lists are positional, so set iteration (salted by ``PYTHONHASHSEED``)
142
+ would make ``rng.choice`` pick differently across processes and break
143
+ the library's seed-reproducibility guarantee.
144
+ """
145
+ chain: dict[str, list[str]] = defaultdict(list)
146
+ pad = _BOUNDARY * self.order
147
+ for word in sorted(words):
148
+ padded = pad + word + _BOUNDARY
149
+ for i in range(len(padded) - self.order):
150
+ context = padded[i : i + self.order]
151
+ nxt = padded[i + self.order]
152
+ chain[context].append(nxt)
153
+ return dict(chain)
154
+
155
+ def generate(
156
+ self,
157
+ rng: SeededRNG,
158
+ min_len: Optional[int] = None,
159
+ max_len: Optional[int] = None,
160
+ max_attempts: int = 40,
161
+ ) -> str:
162
+ """Walk the chain to produce a single capitalised name.
163
+
164
+ Retries up to ``max_attempts`` times to satisfy the length bounds and
165
+ avoid echoing a training word; falls back to the best candidate seen.
166
+ """
167
+ if not self._chain:
168
+ return ""
169
+ lo = min_len if min_len is not None else self._min_len
170
+ hi = max_len if max_len is not None else self._max_len
171
+ pad = _BOUNDARY * self.order
172
+
173
+ best = ""
174
+ for _ in range(max_attempts):
175
+ context = pad
176
+ out: list[str] = []
177
+ while True:
178
+ followers = self._chain.get(context)
179
+ if not followers:
180
+ break
181
+ nxt = rng.choice(followers)
182
+ if nxt == _BOUNDARY:
183
+ break
184
+ out.append(nxt)
185
+ if len(out) >= hi: # hard stop on runaway chains
186
+ break
187
+ context = (context + nxt)[-self.order :]
188
+ word = "".join(out)
189
+ if len(word) > len(best):
190
+ best = word
191
+ if lo <= len(word) <= hi and word not in self._training:
192
+ return word.capitalize()
193
+ return best.capitalize() if best else ""
194
+
195
+
196
+ class NameGenerator:
197
+ """High-level, culture-aware naming facade over per-culture Markov chains.
198
+
199
+ Holds a :class:`SeededRNG` and lazily builds (and caches) one
200
+ :class:`MarkovNameGenerator` per culture. All draws go through a derived
201
+ ``"names"`` sub-stream so naming stays decoupled from terrain generation —
202
+ adding a name call never shifts the terrain RNG.
203
+ """
204
+
205
+ def __init__(self, rng: SeededRNG, order: int = 3):
206
+ self._rng = rng.derive("names")
207
+ self._order = order
208
+ self._chains: dict[str, MarkovNameGenerator] = {}
209
+
210
+ @staticmethod
211
+ def cultures() -> list[str]:
212
+ """The set of built-in culture keys available for naming."""
213
+ return sorted(NAMEBASES.keys())
214
+
215
+ def _chain_for(self, culture: str) -> MarkovNameGenerator:
216
+ key = culture if culture in NAMEBASES else _FALLBACK_CULTURE
217
+ if key not in self._chains:
218
+ self._chains[key] = MarkovNameGenerator(NAMEBASES[key], self._order)
219
+ return self._chains[key]
220
+
221
+ # -- public naming API ----------------------------------------------
222
+
223
+ def place(self, culture: str = "generic") -> str:
224
+ """A bare place/region name in the given culture's style."""
225
+ return self._chain_for(culture).generate(self._rng)
226
+
227
+ def person(self, culture: str = "generic") -> str:
228
+ """A personal name in the given culture's style."""
229
+ return self._chain_for(culture).generate(self._rng)
230
+
231
+ def settlement(self, culture: str = "generic", suffix_chance: float = 0.65) -> str:
232
+ """A settlement name: a root name, often plus a geographic suffix.
233
+
234
+ e.g. ``"Eld" + "moor" -> "Eldmoor"``. With probability
235
+ ``1 - suffix_chance`` the bare root is returned instead, for variety.
236
+ """
237
+ root = self.place(culture)
238
+ if not root:
239
+ return root
240
+ suffixes = SETTLEMENT_SUFFIXES.get(culture) or SETTLEMENT_SUFFIXES[_FALLBACK_CULTURE]
241
+ if suffixes and self._rng.chance(suffix_chance):
242
+ # Drop a trailing vowel before a vowel-initial suffix to avoid
243
+ # awkward clusters ("Elda" + "moor" -> "Eldmoor").
244
+ suffix = self._rng.choice(suffixes)
245
+ if root[-1].lower() in "aeiou" and suffix[0].lower() not in "aeiou":
246
+ root = root[:-1]
247
+ return (root + suffix).capitalize()
248
+ return root
249
+
250
+ def region(self, culture: str = "generic") -> str:
251
+ """A region/territory name, e.g. "The Tarnwick Reach"."""
252
+ forms = [
253
+ "{name}",
254
+ "The {name} Reach",
255
+ "{name} March",
256
+ "{name}land",
257
+ "Vale of {name}",
258
+ "The {name} Wastes",
259
+ ]
260
+ name = self.place(culture)
261
+ return self._rng.choice(forms).format(name=name) if name else name
mapwright/rng.py ADDED
@@ -0,0 +1,163 @@
1
+ """Unified seeded random-number generator for procedural map generation.
2
+
3
+ Ported from the discipline used by Azgaar's Fantasy-Map-Generator (MIT) and
4
+ Watabou's generators: a *single* seed drives *every* stage of generation, so
5
+ the same seed always reproduces the same world. The key idea that the previous
6
+ ``random.seed(seed)`` / ``np.random.seed(seed)`` pattern lacked:
7
+
8
+ * It mutated Python's **global** ``random`` module state, so any other code
9
+ that drew from ``random`` (or any reordering of draws) silently changed the
10
+ output. Here the stream is **instance-local** — nothing leaks in or out.
11
+ * It kept two *uncoordinated* streams (stdlib ``random`` and ``numpy``). Here
12
+ both are derived from one seed, so a single integer reproduces terrain
13
+ *and* noise.
14
+ * Adding a draw anywhere shifted everything downstream. :meth:`derive` gives
15
+ each generation stage (terrain, naming, settlements, …) its own independent
16
+ sub-stream keyed by a label, so stages can evolve without desyncing.
17
+
18
+ Example::
19
+
20
+ rng = SeededRNG(request.seed) # rng.seed is the resolved int seed
21
+ terrain_rng = rng.derive("terrain") # independent, reproducible sub-stream
22
+ name_rng = rng.derive("names") # adding/removing draws here cannot
23
+ # shift terrain_rng's output
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import random
30
+ from typing import Optional, Sequence, TypeVar
31
+
32
+ import numpy as np
33
+
34
+ T = TypeVar("T")
35
+
36
+ # Seeds are kept inside the signed 31-bit range so they round-trip cleanly
37
+ # through JSON, the ``generation_seed`` column, and numpy's seed API.
38
+ _SEED_MODULUS = 2**31
39
+
40
+
41
+ def _coerce_seed(seed: Optional[int]) -> int:
42
+ """Resolve an optional seed to a concrete, reproducible 31-bit integer.
43
+
44
+ When ``seed`` is ``None`` we draw a fresh one from the OS entropy pool and
45
+ *return it* so callers can persist it and replay the exact same map later.
46
+ """
47
+ if seed is None:
48
+ # SystemRandom is process-state-free, so picking the auto-seed never
49
+ # perturbs any SeededRNG stream.
50
+ return random.SystemRandom().randrange(_SEED_MODULUS)
51
+ return int(seed) % _SEED_MODULUS
52
+
53
+
54
+ def _derive_seed(parent_seed: int, label: str) -> int:
55
+ """Deterministically mix ``parent_seed`` with ``label`` into a child seed.
56
+
57
+ Uses BLAKE2b rather than the builtin ``hash()`` because ``hash()`` of a
58
+ string is salted per-process (``PYTHONHASHSEED``) and would make derived
59
+ streams non-reproducible across runs.
60
+ """
61
+ digest = hashlib.blake2b(
62
+ f"{parent_seed}:{label}".encode("utf-8"), digest_size=8
63
+ ).digest()
64
+ return int.from_bytes(digest, "big") % _SEED_MODULUS
65
+
66
+
67
+ class SeededRNG:
68
+ """A reproducible, instance-local random stream with derivable sub-streams.
69
+
70
+ Wraps a private :class:`random.Random` and a private
71
+ :class:`numpy.random.Generator`, both seeded from the same integer. None of
72
+ the methods touch global RNG state.
73
+ """
74
+
75
+ __slots__ = ("seed", "_rng", "_np")
76
+
77
+ def __init__(self, seed: Optional[int] = None):
78
+ self.seed: int = _coerce_seed(seed)
79
+ self._rng = random.Random(self.seed)
80
+ self._np: Optional[np.random.Generator] = None # lazily constructed
81
+
82
+ # -- sub-streams -----------------------------------------------------
83
+
84
+ def derive(self, label: str) -> "SeededRNG":
85
+ """Return an independent child stream keyed by ``label``.
86
+
87
+ Same parent seed + same label always yields the same child, but the
88
+ child's draws are statistically independent of the parent's and of
89
+ sibling labels. This is how generation stages stay decoupled.
90
+ """
91
+ return SeededRNG(_derive_seed(self.seed, label))
92
+
93
+ @property
94
+ def numpy(self) -> np.random.Generator:
95
+ """A numpy ``Generator`` seeded from the same seed (for vectorised noise)."""
96
+ if self._np is None:
97
+ self._np = np.random.default_rng(self.seed)
98
+ return self._np
99
+
100
+ # -- scalar draws ----------------------------------------------------
101
+
102
+ def random(self) -> float:
103
+ """Float in ``[0.0, 1.0)``."""
104
+ return self._rng.random()
105
+
106
+ def uniform(self, low: float, high: float) -> float:
107
+ """Float in ``[low, high)``."""
108
+ return self._rng.uniform(low, high)
109
+
110
+ def randint(self, low: int, high: int) -> int:
111
+ """Integer in ``[low, high]`` (inclusive on both ends, like stdlib)."""
112
+ return self._rng.randint(low, high)
113
+
114
+ def chance(self, probability: float) -> bool:
115
+ """``True`` with the given probability (Watabou's ``Random.bool``)."""
116
+ return self._rng.random() < probability
117
+
118
+ def gauss(self, mu: float = 0.0, sigma: float = 1.0) -> float:
119
+ """A normally-distributed float."""
120
+ return self._rng.gauss(mu, sigma)
121
+
122
+ def fuzzy(self, value: float, spread: float) -> float:
123
+ """Jitter ``value`` by ``±spread`` uniformly.
124
+
125
+ Watabou uses this constantly to break up grid regularity (e.g. nudging
126
+ polygon vertices). ``rng.fuzzy(10, 2)`` returns a float in ``[8, 12)``.
127
+ """
128
+ return value + self._rng.uniform(-spread, spread)
129
+
130
+ # -- sequence draws --------------------------------------------------
131
+
132
+ def choice(self, seq: Sequence[T]) -> T:
133
+ """Uniformly pick one element."""
134
+ return self._rng.choice(seq)
135
+
136
+ def choices(
137
+ self,
138
+ population: Sequence[T],
139
+ weights: Optional[Sequence[float]] = None,
140
+ k: int = 1,
141
+ ) -> list[T]:
142
+ """Weighted sampling with replacement (stdlib semantics)."""
143
+ return self._rng.choices(population, weights=weights, k=k)
144
+
145
+ def weighted(self, options: dict[T, float]) -> T:
146
+ """Pick one key from a ``{value: weight}`` mapping, proportional to weight.
147
+
148
+ The bread-and-butter of Azgaar's culture/state/biome selection.
149
+ """
150
+ keys = list(options.keys())
151
+ weights = list(options.values())
152
+ return self._rng.choices(keys, weights=weights, k=1)[0]
153
+
154
+ def shuffle(self, seq: list) -> None:
155
+ """In-place shuffle."""
156
+ self._rng.shuffle(seq)
157
+
158
+ def sample(self, population: Sequence[T], k: int) -> list[T]:
159
+ """Sample ``k`` distinct elements (without replacement)."""
160
+ return self._rng.sample(list(population), k)
161
+
162
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
163
+ return f"SeededRNG(seed={self.seed})"
@@ -0,0 +1,249 @@
1
+ """Vector (SVG) renderer for regional terrain, with shaded relief.
2
+
3
+ Renders a :class:`~src.mapwright.terrain.TerrainResult` as a scalable SVG map:
4
+ organic Voronoi biome polygons, slope-based **shaded relief** (the hillshade
5
+ technique from rlguy/Mewo2's FantasyMapGenerator — per-cell surface normals lit
6
+ by a fixed light direction), a coastline stroke, rivers whose width tracks
7
+ discharge, and labelled settlement markers.
8
+
9
+ Why SVG for this tier (vs the PNG tile compositor used for tactical maps):
10
+ world/regional maps want to zoom, restyle, and carry crisp labels — all of which
11
+ vector handles for free, and it sidesteps the "fog/grid baked into the PNG"
12
+ tech-debt. Everything here is pure string-building (no new dependencies).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ import xml.sax.saxutils as su
19
+ from dataclasses import dataclass
20
+ from typing import Optional, Sequence
21
+
22
+ from .terrain import Biome, TerrainCell, TerrainResult, compute_cell_polygons
23
+
24
+
25
+ @dataclass
26
+ class Marker:
27
+ """A neutral point-of-interest to label on the map (e.g. a settlement).
28
+
29
+ Domain-neutral on purpose: a host app maps its own feature objects onto this
30
+ rather than the renderer depending on the host's models. ``kind`` selects the
31
+ marker size (see ``_SETTLEMENT_RADIUS``) and a substring of it (e.g. "city")
32
+ bumps the label font.
33
+ """
34
+
35
+ name: str
36
+ x: float
37
+ y: float
38
+ kind: str = ""
39
+
40
+ # Base biome fill colours (before relief shading), tuned for a parchment-ish
41
+ # fantasy palette rather than the dungeon tile colours.
42
+ _BIOME_RGB: dict[Biome, tuple[int, int, int]] = {
43
+ Biome.OCEAN: (31, 78, 107),
44
+ Biome.COAST: (61, 126, 166),
45
+ Biome.BEACH: (217, 199, 155),
46
+ Biome.DESERT: (214, 196, 130),
47
+ Biome.PLAINS: (169, 196, 127),
48
+ Biome.FOREST: (79, 130, 74),
49
+ Biome.SWAMP: (107, 123, 74),
50
+ Biome.HILLS: (160, 154, 100),
51
+ Biome.MOUNTAIN: (140, 132, 122),
52
+ Biome.TUNDRA: (188, 196, 180),
53
+ Biome.SNOW: (240, 244, 248),
54
+ Biome.RIVER: (127, 168, 106), # riverbank green; the river line draws on top
55
+ }
56
+
57
+ _OCEAN_BG = (24, 62, 86)
58
+ _COASTLINE = (40, 54, 64)
59
+ _RIVER_COLOR = (74, 130, 175)
60
+
61
+ _SETTLEMENT_RADIUS = {
62
+ "settlement_city": 5.5,
63
+ "settlement_town": 4.0,
64
+ "settlement_village": 3.0,
65
+ "settlement_castle": 4.5,
66
+ }
67
+
68
+
69
+ def _shade(base: tuple[int, int, int], brightness: float) -> str:
70
+ """Apply a relief brightness multiplier to a colour → ``#rrggbb``."""
71
+ return "#%02x%02x%02x" % tuple(
72
+ max(0, min(255, int(round(ch * brightness)))) for ch in base
73
+ )
74
+
75
+
76
+ def _rgb(c: tuple[int, int, int]) -> str:
77
+ return "#%02x%02x%02x" % c
78
+
79
+
80
+ class RegionalSVGRenderer:
81
+ """Renders a :class:`TerrainResult` to an SVG document string."""
82
+
83
+ def __init__(self, scale: float = 16.0, relief_strength: float = 60.0):
84
+ # scale = pixels per tile-unit; relief_strength exaggerates slope so the
85
+ # hillshade reads on gentle terrain (height diffs between cells are tiny,
86
+ # so this needs to be large).
87
+ self.scale = scale
88
+ self.relief_strength = relief_strength
89
+ # Light from the upper-left (classic cartographic convention).
90
+ lx, ly, lz = -1.0, -1.0, 1.4
91
+ norm = math.sqrt(lx * lx + ly * ly + lz * lz)
92
+ self._light = (lx / norm, ly / norm, lz / norm)
93
+
94
+ def render(
95
+ self,
96
+ terrain: TerrainResult,
97
+ markers: Optional[Sequence[Marker]] = None,
98
+ *,
99
+ show_relief: bool = True,
100
+ show_labels: bool = True,
101
+ ) -> str:
102
+ s = self.scale
103
+ w_px, h_px = terrain.width * s, terrain.height * s
104
+ polys = compute_cell_polygons(terrain.cells, terrain.width, terrain.height)
105
+ brightness = self._relief(terrain.cells) if show_relief else {}
106
+
107
+ parts: list[str] = [
108
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{w_px:.0f}" '
109
+ f'height="{h_px:.0f}" viewBox="0 0 {w_px:.0f} {h_px:.0f}">',
110
+ f'<rect width="{w_px:.0f}" height="{h_px:.0f}" fill="{_rgb(_OCEAN_BG)}"/>',
111
+ ]
112
+
113
+ # 1. Biome polygons with relief shading.
114
+ parts.append('<g stroke-linejoin="round">')
115
+ for cell in terrain.cells:
116
+ poly = polys.get(cell.id)
117
+ if not poly or len(poly) < 3:
118
+ continue
119
+ fill = _shade(_BIOME_RGB[cell.biome], brightness.get(cell.id, 1.0))
120
+ pts = " ".join(f"{x * s:.1f},{y * s:.1f}" for x, y in poly)
121
+ # A hairline stroke in the fill colour hides seams between cells.
122
+ parts.append(f'<polygon points="{pts}" fill="{fill}" stroke="{fill}" '
123
+ f'stroke-width="0.5"/>')
124
+ parts.append("</g>")
125
+
126
+ # 2. Coastline — edges of land cells that border the sea.
127
+ parts.append(self._coastline_svg(terrain, polys))
128
+
129
+ # 3. Rivers — downhill polylines, width by discharge.
130
+ parts.append(self._rivers_svg(terrain))
131
+
132
+ # 4. Settlements.
133
+ if markers:
134
+ parts.append(self._settlements_svg(markers, show_labels))
135
+
136
+ parts.append("</svg>")
137
+ return "".join(parts)
138
+
139
+ # -- relief ----------------------------------------------------------
140
+
141
+ def _relief(self, cells: list[TerrainCell]) -> dict[int, float]:
142
+ """Per-cell brightness from a slope normal lit by the fixed light."""
143
+ out: dict[int, float] = {}
144
+ lx, ly, lz = self._light
145
+ for c in cells:
146
+ if c.is_water:
147
+ out[c.id] = 1.0
148
+ continue
149
+ gx = gy = 0.0
150
+ count = 0
151
+ for nid in c.neighbors:
152
+ n = cells[nid]
153
+ dx, dy = n.cx - c.cx, n.cy - c.cy
154
+ d2 = dx * dx + dy * dy
155
+ if d2 <= 0:
156
+ continue
157
+ dh = (n.height - c.height) * self.relief_strength
158
+ gx += dh * dx / d2
159
+ gy += dh * dy / d2
160
+ count += 1
161
+ if count:
162
+ gx /= count
163
+ gy /= count
164
+ # Surface normal of the local plane z = -gx*x - gy*y, lit by the
165
+ # light direction. Steeper slopes facing the light brighten; those
166
+ # facing away darken — classic hillshade.
167
+ nx, ny, nz = -gx, -gy, 1.0
168
+ nlen = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0
169
+ shade = (nx * lx + ny * ly + nz * lz) / nlen # ~0..1, ~0.7 when flat
170
+ # Re-centre around the flat-ground value (lz) so flat terrain stays
171
+ # neutral and aspect drives the contrast.
172
+ out[c.id] = max(0.66, min(1.28, 1.0 + 1.1 * (shade - lz)))
173
+ return out
174
+
175
+ # -- coastline -------------------------------------------------------
176
+
177
+ def _coastline_svg(self, terrain: TerrainResult, polys) -> str:
178
+ s = self.scale
179
+ cells = terrain.cells
180
+ segs: list[str] = []
181
+ eps = 1e-6
182
+ w, h = terrain.width, terrain.height
183
+ for c in cells:
184
+ if c.is_water:
185
+ continue
186
+ poly = polys.get(c.id)
187
+ if not poly or len(poly) < 3:
188
+ continue
189
+ n = len(poly)
190
+ for i in range(n):
191
+ a, b = poly[i], poly[(i + 1) % n]
192
+ mx, my = (a[0] + b[0]) / 2, (a[1] + b[1]) / 2
193
+ # Skip edges lying on the map border (not a real coastline).
194
+ if mx < eps or my < eps or mx > w - eps or my > h - eps:
195
+ continue
196
+ # The neighbour across this edge is the nearest cell-site to the
197
+ # edge midpoint (the edge lies on their shared bisector).
198
+ nb = min(c.neighbors,
199
+ key=lambda k: (cells[k].cx - mx) ** 2 + (cells[k].cy - my) ** 2,
200
+ default=None)
201
+ if nb is not None and cells[nb].is_water:
202
+ segs.append(f'M{a[0] * s:.1f},{a[1] * s:.1f} '
203
+ f'L{b[0] * s:.1f},{b[1] * s:.1f}')
204
+ if not segs:
205
+ return ""
206
+ return (f'<path d="{" ".join(segs)}" fill="none" stroke="{_rgb(_COASTLINE)}" '
207
+ f'stroke-width="2.0" stroke-linecap="round"/>')
208
+
209
+ # -- rivers ----------------------------------------------------------
210
+
211
+ def _rivers_svg(self, terrain: TerrainResult) -> str:
212
+ s = self.scale
213
+ cells = terrain.cells
214
+ paths: list[str] = []
215
+ for river in terrain.rivers:
216
+ if len(river.cells) < 2:
217
+ continue
218
+ pts = [(cells[i].cx * s, cells[i].cy * s) for i in river.cells]
219
+ d = "M" + " L".join(f"{x:.1f},{y:.1f}" for x, y in pts)
220
+ width = max(0.8, min(4.0, 0.4 * river.width))
221
+ paths.append(f'<path d="{d}" fill="none" stroke="{_rgb(_RIVER_COLOR)}" '
222
+ f'stroke-width="{width:.1f}" stroke-linecap="round" '
223
+ f'stroke-linejoin="round"/>')
224
+ if not paths:
225
+ return ""
226
+ return '<g opacity="0.9">' + "".join(paths) + "</g>"
227
+
228
+ # -- settlements -----------------------------------------------------
229
+
230
+ def _settlements_svg(self, markers: Sequence[Marker], labels: bool) -> str:
231
+ s = self.scale
232
+ out: list[str] = ['<g font-family="Georgia, serif">']
233
+ for m in markers:
234
+ r = _SETTLEMENT_RADIUS.get(m.kind, 3.0)
235
+ cx, cy = m.x * s, m.y * s
236
+ out.append(f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{r:.1f}" '
237
+ f'fill="#f4ead8" stroke="#2b2b2b" stroke-width="1.2"/>')
238
+ if labels and m.name:
239
+ name = su.escape(m.name)
240
+ tx, ty = cx + r + 2, cy + 3
241
+ fs = 11 if "city" in m.kind else 9
242
+ # White halo behind the label for legibility over any biome.
243
+ out.append(
244
+ f'<text x="{tx:.1f}" y="{ty:.1f}" font-size="{fs}" '
245
+ f'stroke="#f7f3ea" stroke-width="3" paint-order="stroke" '
246
+ f'fill="#23211c">{name}</text>'
247
+ )
248
+ out.append("</g>")
249
+ return "".join(out)