rust-map-parser 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. rust_map_parser-0.1.0.dist-info/METADATA +538 -0
  2. rust_map_parser-0.1.0.dist-info/RECORD +110 -0
  3. rust_map_parser-0.1.0.dist-info/WHEEL +5 -0
  4. rust_map_parser-0.1.0.dist-info/licenses/LICENSE +21 -0
  5. rust_map_parser-0.1.0.dist-info/top_level.txt +1 -0
  6. rustmap/__init__.py +44 -0
  7. rustmap/config.py +236 -0
  8. rustmap/data/__init__.py +1 -0
  9. rustmap/data/monument_metadata.json +954 -0
  10. rustmap/data/no_build_zones.json +9543 -0
  11. rustmap/data/prefab_manifest.json +242637 -0
  12. rustmap/data/spawn_rules.json +3308 -0
  13. rustmap/data/tunnel_tiles/__init__.py +1 -0
  14. rustmap/data/tunnel_tiles/template_000__curve-nw-0.png +0 -0
  15. rustmap/data/tunnel_tiles/template_001__curve-nw-1.png +0 -0
  16. rustmap/data/tunnel_tiles/template_002__intersection-bb1-n.png +0 -0
  17. rustmap/data/tunnel_tiles/template_003__intersection-bb4-n.png +0 -0
  18. rustmap/data/tunnel_tiles/template_004__intersection-b5-n.png +0 -0
  19. rustmap/data/tunnel_tiles/template_005__intersection-b3-n.png +0 -0
  20. rustmap/data/tunnel_tiles/template_006__intersection-b2-n.png +0 -0
  21. rustmap/data/tunnel_tiles/template_007__intersection-bb2-n.png +0 -0
  22. rustmap/data/tunnel_tiles/template_008__intersection-b6-n.png +0 -0
  23. rustmap/data/tunnel_tiles/template_009__intersection-bb3-n.png +0 -0
  24. rustmap/data/tunnel_tiles/template_010__intersection-b4-n.png +0 -0
  25. rustmap/data/tunnel_tiles/template_011__intersection-bb6-n.png +0 -0
  26. rustmap/data/tunnel_tiles/template_012__intersection-n.png +0 -0
  27. rustmap/data/tunnel_tiles/template_013__intersection-b1-n.png +0 -0
  28. rustmap/data/tunnel_tiles/template_014__intersection-bb5-n.png +0 -0
  29. rustmap/data/tunnel_tiles/template_015__station-sn-0.png +0 -0
  30. rustmap/data/tunnel_tiles/template_016__straight-sn-3.png +0 -0
  31. rustmap/data/tunnel_tiles/template_017__straight-sn-2.png +0 -0
  32. rustmap/data/tunnel_tiles/template_018__station-sn-1.png +0 -0
  33. rustmap/data/tunnel_tiles/template_019__straight-sn-0.png +0 -0
  34. rustmap/data/tunnel_tiles/template_020__straight-sn-1.png +0 -0
  35. rustmap/data/tunnel_tiles/template_021__station-sn-3.png +0 -0
  36. rustmap/data/tunnel_tiles/template_022__straight-sn-4.png +0 -0
  37. rustmap/data/tunnel_tiles/template_023__station-sn-2.png +0 -0
  38. rustmap/data/tunnel_tiles/template_024__straight-sn-5.png +0 -0
  39. rustmap/data/tunnel_tiles/template_025__curve-ne-1.png +0 -0
  40. rustmap/data/tunnel_tiles/template_026__curve-ne-0.png +0 -0
  41. rustmap/data/tunnel_tiles/template_027__intersection.png +0 -0
  42. rustmap/data/tunnel_tiles/template_028__intersection-bb2-s.png +0 -0
  43. rustmap/data/tunnel_tiles/template_029__intersection-b2-s.png +0 -0
  44. rustmap/data/tunnel_tiles/template_030__intersection-b6-s.png +0 -0
  45. rustmap/data/tunnel_tiles/template_031__intersection-bb5-s.png +0 -0
  46. rustmap/data/tunnel_tiles/template_032__intersection-bb1-s.png +0 -0
  47. rustmap/data/tunnel_tiles/template_033__intersection-s.png +0 -0
  48. rustmap/data/tunnel_tiles/template_034__intersection-b1-s.png +0 -0
  49. rustmap/data/tunnel_tiles/template_035__intersection-bb6-s.png +0 -0
  50. rustmap/data/tunnel_tiles/template_036__intersection-b5-s.png +0 -0
  51. rustmap/data/tunnel_tiles/template_037__intersection-bb4-s.png +0 -0
  52. rustmap/data/tunnel_tiles/template_038__intersection-b3-s.png +0 -0
  53. rustmap/data/tunnel_tiles/template_039__intersection-bb3-s.png +0 -0
  54. rustmap/data/tunnel_tiles/template_040__intersection-b4-s.png +0 -0
  55. rustmap/data/tunnel_tiles/template_041__straight-we-3.png +0 -0
  56. rustmap/data/tunnel_tiles/template_042__station-we-1.png +0 -0
  57. rustmap/data/tunnel_tiles/template_043__straight-we-5.png +0 -0
  58. rustmap/data/tunnel_tiles/template_044__straight-we-4.png +0 -0
  59. rustmap/data/tunnel_tiles/template_045__station-we-2.png +0 -0
  60. rustmap/data/tunnel_tiles/template_046__station-we-3.png +0 -0
  61. rustmap/data/tunnel_tiles/template_047__station-we-0.png +0 -0
  62. rustmap/data/tunnel_tiles/template_048__straight-we-2.png +0 -0
  63. rustmap/data/tunnel_tiles/template_049__straight-we-1.png +0 -0
  64. rustmap/data/tunnel_tiles/template_050__straight-we-0.png +0 -0
  65. rustmap/data/tunnel_tiles/template_051__curve-se-0.png +0 -0
  66. rustmap/data/tunnel_tiles/template_052__curve-se-1.png +0 -0
  67. rustmap/data/tunnel_tiles/template_053__intersection-bb3-w.png +0 -0
  68. rustmap/data/tunnel_tiles/template_054__intersection-w.png +0 -0
  69. rustmap/data/tunnel_tiles/template_055__intersection-bb5-w.png +0 -0
  70. rustmap/data/tunnel_tiles/template_056__intersection-b5-w.png +0 -0
  71. rustmap/data/tunnel_tiles/template_057__intersection-b1-w.png +0 -0
  72. rustmap/data/tunnel_tiles/template_058__intersection-bb1-w.png +0 -0
  73. rustmap/data/tunnel_tiles/template_059__intersection-bb4-w.png +0 -0
  74. rustmap/data/tunnel_tiles/template_060__intersection-b4-w.png +0 -0
  75. rustmap/data/tunnel_tiles/template_061__intersection-b6-w.png +0 -0
  76. rustmap/data/tunnel_tiles/template_062__intersection-bb2-w.png +0 -0
  77. rustmap/data/tunnel_tiles/template_063__intersection-b3-w.png +0 -0
  78. rustmap/data/tunnel_tiles/template_064__intersection-b2-w.png +0 -0
  79. rustmap/data/tunnel_tiles/template_065__intersection-bb6-w.png +0 -0
  80. rustmap/data/tunnel_tiles/template_066__curve-sw-0.png +0 -0
  81. rustmap/data/tunnel_tiles/template_067__curve-sw-1.png +0 -0
  82. rustmap/data/tunnel_tiles/template_068__intersection-bb3-e.png +0 -0
  83. rustmap/data/tunnel_tiles/template_069__intersection-bb6-e.png +0 -0
  84. rustmap/data/tunnel_tiles/template_070__intersection-b4-e.png +0 -0
  85. rustmap/data/tunnel_tiles/template_071__intersection-b6-e.png +0 -0
  86. rustmap/data/tunnel_tiles/template_072__intersection-b5-e.png +0 -0
  87. rustmap/data/tunnel_tiles/template_073__intersection-b1-e.png +0 -0
  88. rustmap/data/tunnel_tiles/template_074__intersection-b3-e.png +0 -0
  89. rustmap/data/tunnel_tiles/template_075__intersection-bb2-e.png +0 -0
  90. rustmap/data/tunnel_tiles/template_076__intersection-e.png +0 -0
  91. rustmap/data/tunnel_tiles/template_077__intersection-bb1-e.png +0 -0
  92. rustmap/data/tunnel_tiles/template_078__intersection-bb4-e.png +0 -0
  93. rustmap/data/tunnel_tiles/template_079__intersection-b2-e.png +0 -0
  94. rustmap/data/tunnel_tiles/template_080__intersection-bb5-e.png +0 -0
  95. rustmap/data/tunnel_tiles/tiles.json +997 -0
  96. rustmap/exporter.py +409 -0
  97. rustmap/layers.py +323 -0
  98. rustmap/monument_assets.py +145 -0
  99. rustmap/monuments.py +226 -0
  100. rustmap/no_build.py +288 -0
  101. rustmap/no_build_assets.py +303 -0
  102. rustmap/parser.py +297 -0
  103. rustmap/png.py +18 -0
  104. rustmap/populations.py +361 -0
  105. rustmap/prefabs.py +281 -0
  106. rustmap/renderer.py +460 -0
  107. rustmap/resources.py +26 -0
  108. rustmap/tunnel_assets.py +398 -0
  109. rustmap/tunnels.py +417 -0
  110. rustmap/validation.py +106 -0
rustmap/exporter.py ADDED
@@ -0,0 +1,409 @@
1
+ """Composable Rust map export pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from contextlib import ExitStack
9
+ from importlib import resources
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ from PIL import Image
14
+
15
+ from .config import ExportConfig, ExportResult
16
+ from .layers import generate_diagnostics
17
+ from .monuments import save_monuments
18
+ from .no_build import save_no_build_zones
19
+ from .parser import load_map
20
+ from .png import save_png
21
+ from .populations import SpawnFilterEvaluator
22
+ from .renderer import save_map_render
23
+ from .tunnels import save_tunnel_render
24
+
25
+
26
+ ORIENTATION = "flip_vertical (reverse Z rows; preserve X left/right)"
27
+ HEATMAP_PREVIEW_DIRECTORY = "Heatmap-previews"
28
+
29
+
30
+ def export_orientation(values: np.ndarray) -> np.ndarray:
31
+ """Convert native ``[z,x]`` arrays to image rows without mirroring X."""
32
+ return np.ascontiguousarray(values[::-1, :])
33
+
34
+
35
+ def _stats(values: np.ndarray, populations: list[str]) -> dict:
36
+ return {
37
+ "populations": populations,
38
+ "nonzero_pixels": int(np.count_nonzero(values)),
39
+ "minimum": int(values.min()), "maximum": int(values.max()),
40
+ "mean": float(values.mean()),
41
+ }
42
+
43
+
44
+ def _write_metadata(path: Path, metadata: dict, artifacts: dict[str, int]) -> None:
45
+ artifacts[path.name] = 0
46
+ for _ in range(4):
47
+ rendered = json.dumps(metadata, indent=2) + "\n"
48
+ size = len(rendered.encode("utf-8"))
49
+ if artifacts[path.name] == size:
50
+ break
51
+ artifacts[path.name] = size
52
+ path.write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8", newline="\n")
53
+
54
+
55
+ def _generate(config: ExportConfig, rules_path: Path | None,
56
+ manifest_path: Path | None) -> dict:
57
+ started = time.perf_counter()
58
+ output = config.output_dir
59
+ output.mkdir(parents=True, exist_ok=True)
60
+ exports = config.exports
61
+ timings: dict[str, object] = {"heatmap_categories": {}}
62
+
63
+ stage = time.perf_counter()
64
+ world = load_map(config.map_path)
65
+ timings["map_parse_seconds"] = time.perf_counter() - stage
66
+
67
+ diagnostics_dir = output / "diagnostics"
68
+ diagnostics_pool = ThreadPoolExecutor(max_workers=1) if exports.diagnostics else None
69
+ diagnostics_started = time.perf_counter()
70
+ diagnostics_future = (
71
+ diagnostics_pool.submit(generate_diagnostics, world, diagnostics_dir)
72
+ if diagnostics_pool else None
73
+ )
74
+
75
+ heatmap_options = exports.heatmaps
76
+ heatmap_categories: dict[str, dict] = {}
77
+ npz_path: Path | None = None
78
+ preview_dir: Path | None = None
79
+ heatmap_resolution: int | None = None
80
+ rule_database_metadata = None
81
+ if heatmap_options is not None:
82
+ heatmap_resolution = heatmap_options.resolved_resolution(world.size)
83
+ if rules_path is None:
84
+ raise RuntimeError("Heatmap export requires a spawn-rule database")
85
+ stage = time.perf_counter()
86
+ database = json.loads(rules_path.read_text(encoding="utf-8"))
87
+ timings["rule_database_load_seconds"] = time.perf_counter() - stage
88
+ rules = {rule["asset_path"]: rule for rule in database["rules"]}
89
+ rule_database_metadata = {
90
+ "schema_version": database.get("schema_version"),
91
+ "sources": database.get("sources", {}),
92
+ }
93
+ stage = time.perf_counter()
94
+ evaluator = SpawnFilterEvaluator(world, heatmap_resolution)
95
+ timings["heatmap_evaluator_setup_seconds"] = time.perf_counter() - stage
96
+ arrays: dict[str, np.ndarray] = {}
97
+ preview_dir = output / HEATMAP_PREVIEW_DIRECTORY
98
+ preview_pool = ThreadPoolExecutor(max_workers=4) if heatmap_options.previews else None
99
+ preview_futures = []
100
+ if preview_pool:
101
+ preview_dir.mkdir(parents=True, exist_ok=True)
102
+ for category, asset_paths in database["heatmap_categories"].items():
103
+ category_started = time.perf_counter()
104
+ combined = np.zeros(
105
+ (heatmap_resolution, heatmap_resolution), dtype=np.uint8
106
+ )
107
+ used = []
108
+ for asset_path in asset_paths:
109
+ rule = rules.get(asset_path)
110
+ if rule is None or not rule.get("active"):
111
+ continue
112
+ np.maximum(combined, evaluator.evaluate(rule), out=combined)
113
+ used.append(rule["name"])
114
+ exported = export_orientation(combined)
115
+ arrays[category] = exported
116
+ heatmap_categories[category] = _stats(exported, used)
117
+ if preview_pool:
118
+ preview_futures.append(preview_pool.submit(
119
+ save_png,
120
+ Image.fromarray(exported, mode="L"),
121
+ preview_dir / f"{category}.png",
122
+ ))
123
+ timings["heatmap_categories"][category] = time.perf_counter() - category_started
124
+ timings["heatmaps_total_seconds"] = sum(timings["heatmap_categories"].values())
125
+ npz_path = output / f"heatmaps_{heatmap_resolution}.npz"
126
+ stage = time.perf_counter()
127
+ np.savez_compressed(npz_path, **arrays)
128
+ timings["heatmap_npz_encode_seconds"] = time.perf_counter() - stage
129
+ if preview_pool:
130
+ for future in preview_futures:
131
+ future.result()
132
+ preview_pool.shutdown()
133
+ else:
134
+ timings.update({
135
+ "rule_database_load_seconds": 0.0,
136
+ "heatmap_evaluator_setup_seconds": 0.0,
137
+ "heatmaps_total_seconds": 0.0,
138
+ "heatmap_npz_encode_seconds": 0.0,
139
+ })
140
+
141
+ if diagnostics_future:
142
+ diagnostic_stats = diagnostics_future.result()
143
+ diagnostics_pool.shutdown()
144
+ timings["diagnostics_seconds"] = time.perf_counter() - diagnostics_started
145
+ else:
146
+ diagnostic_stats = {}
147
+ timings["diagnostics_seconds"] = 0.0
148
+
149
+ monuments_path = output / "monuments.json"
150
+ stage = time.perf_counter()
151
+ if exports.monuments:
152
+ if manifest_path is None:
153
+ raise RuntimeError("Monument export requires a prefab manifest")
154
+ monument_data = save_monuments(world, manifest_path, monuments_path)
155
+ else:
156
+ monument_data = {"monument_count": 0, "unique_prefab_count": 0}
157
+ timings["monuments_seconds"] = time.perf_counter() - stage
158
+
159
+ terrain_options = exports.terrain
160
+ render_metadata = None
161
+ if terrain_options is not None:
162
+ stage = time.perf_counter()
163
+ render_metadata = save_map_render(
164
+ world, output, terrain_options.scale, terrain_options.ocean_margin,
165
+ terrain_options.formats, terrain_options.debug, terrain_options.full_size,
166
+ terrain_options.tiles is not None,
167
+ terrain_options.tiles.size if terrain_options.tiles else 512,
168
+ )
169
+ timings["map_render_seconds"] = time.perf_counter() - stage
170
+ else:
171
+ timings["map_render_seconds"] = 0.0
172
+
173
+ terrain_path = (
174
+ output / "map_render_full.png"
175
+ if terrain_options is not None and terrain_options.full_size else None
176
+ )
177
+ terrain_image = None
178
+ if terrain_path is not None and terrain_path.is_file():
179
+ with Image.open(terrain_path) as source:
180
+ terrain_image = source.convert("RGBA")
181
+
182
+ no_build_options = exports.no_build_zones
183
+ tunnel_options = exports.tunnels
184
+
185
+ def run_no_build():
186
+ if manifest_path is None:
187
+ raise RuntimeError("No-build export requires a prefab manifest")
188
+ stage_started = time.perf_counter()
189
+ value = save_no_build_zones(
190
+ world, manifest_path, output,
191
+ resolution=no_build_options.resolution,
192
+ fill_color=no_build_options.fill_color,
193
+ outline_color=no_build_options.outline_color,
194
+ outline_width=no_build_options.outline_width,
195
+ terrain_image=terrain_image,
196
+ export_images=no_build_options.export_images,
197
+ export_json=no_build_options.export_json,
198
+ )
199
+ return value, time.perf_counter() - stage_started
200
+
201
+ def run_tunnels():
202
+ if manifest_path is None:
203
+ raise RuntimeError("Tunnel export requires a prefab manifest")
204
+ stage_started = time.perf_counter()
205
+ value = save_tunnel_render(
206
+ world, manifest_path, output,
207
+ resolution=tunnel_options.resolution,
208
+ overlay_opacity=tunnel_options.overlay_opacity,
209
+ tint_color=tunnel_options.tint_color,
210
+ terrain_image=terrain_image,
211
+ export_layer=tunnel_options.export_layer,
212
+ export_overlay=tunnel_options.export_overlay,
213
+ )
214
+ return value, time.perf_counter() - stage_started
215
+
216
+ if no_build_options is not None and tunnel_options is not None:
217
+ with ThreadPoolExecutor(max_workers=2) as pool:
218
+ no_build_future = pool.submit(run_no_build)
219
+ tunnel_future = pool.submit(run_tunnels)
220
+ no_build_metadata, timings["no_build_zones_seconds"] = no_build_future.result()
221
+ tunnel_metadata, timings["tunnel_render_seconds"] = tunnel_future.result()
222
+ elif no_build_options is not None:
223
+ no_build_metadata, timings["no_build_zones_seconds"] = run_no_build()
224
+ tunnel_metadata, timings["tunnel_render_seconds"] = {"status": "disabled"}, 0.0
225
+ elif tunnel_options is not None:
226
+ tunnel_metadata, timings["tunnel_render_seconds"] = run_tunnels()
227
+ no_build_metadata, timings["no_build_zones_seconds"] = {
228
+ "status": "disabled", "zone_count": 0
229
+ }, 0.0
230
+ else:
231
+ no_build_metadata = {"status": "disabled", "zone_count": 0}
232
+ tunnel_metadata = {"status": "disabled"}
233
+ timings["no_build_zones_seconds"] = 0.0
234
+ timings["tunnel_render_seconds"] = 0.0
235
+ if terrain_image is not None:
236
+ terrain_image.close()
237
+
238
+ metadata_path = output / "export_metadata.json"
239
+ metadata = {
240
+ "schema_version": 2,
241
+ "map": {
242
+ "path": str(config.map_path),
243
+ "world_size": int(world.size),
244
+ "serialization_version": int(world.serialization_version),
245
+ "timestamp": int(world.timestamp),
246
+ },
247
+ "orientation": ORIENTATION,
248
+ "enabled_outputs": {
249
+ "heatmaps": heatmap_options is not None,
250
+ "diagnostics": exports.diagnostics,
251
+ "monuments": exports.monuments,
252
+ "terrain": terrain_options is not None,
253
+ "tunnels": tunnel_options is not None,
254
+ "no_build_zones": no_build_options is not None,
255
+ },
256
+ "heatmaps": {
257
+ "file": npz_path.name if npz_path else None,
258
+ "resolution": heatmap_resolution,
259
+ "requested_resolution": heatmap_options.resolution if heatmap_options else None,
260
+ "resolution_mode": (
261
+ "world_size" if heatmap_options and heatmap_options.resolution is None
262
+ else "explicit" if heatmap_options else None
263
+ ),
264
+ "dtype": "uint8" if heatmap_options else None,
265
+ "format": "npz_compressed" if heatmap_options else None,
266
+ "preview_directory": preview_dir.name if preview_dir else None,
267
+ "rule_database": rule_database_metadata,
268
+ "categories": heatmap_categories,
269
+ },
270
+ "diagnostics": {
271
+ "directory": diagnostics_dir.name if exports.diagnostics else None,
272
+ "native_shapes": {
273
+ name: details.get("shape")
274
+ for name, details in diagnostic_stats.get("layers", {}).items()
275
+ if isinstance(details, dict) and details.get("shape") is not None
276
+ },
277
+ "orientation_validation": diagnostic_stats.get("orientation_validation"),
278
+ },
279
+ "monuments": {
280
+ "file": monuments_path.name if exports.monuments else None,
281
+ "count": int(monument_data["monument_count"]),
282
+ "unique_prefab_count": int(monument_data["unique_prefab_count"]),
283
+ },
284
+ "terrain": render_metadata,
285
+ "tunnels": tunnel_metadata,
286
+ "no_build_zones": no_build_metadata,
287
+ "timings": timings,
288
+ }
289
+ artifacts: dict[str, int] = {}
290
+ if npz_path:
291
+ artifacts[npz_path.name] = npz_path.stat().st_size
292
+ if preview_dir:
293
+ artifacts[f"{preview_dir.name}-total"] = sum(p.stat().st_size for p in preview_dir.glob("*.png"))
294
+ if exports.diagnostics:
295
+ artifacts["diagnostics-total"] = sum(p.stat().st_size for p in diagnostics_dir.glob("*.*"))
296
+ if exports.monuments:
297
+ artifacts[monuments_path.name] = monuments_path.stat().st_size
298
+ if render_metadata:
299
+ artifacts.update({str(name): int(size) for name, size in render_metadata.get("artifacts", {}).items()})
300
+ for enabled, names in (
301
+ (tunnel_options is not None, ("tunnels.png", "tunnels_on_map.png", "tunnels_metadata.json")),
302
+ (no_build_options is not None, ("no_build_zones.png", "no_build_zones_on_map.png", "no_build_zones.json")),
303
+ ):
304
+ if enabled:
305
+ for name in names:
306
+ path = output / name
307
+ if path.is_file():
308
+ artifacts[name] = path.stat().st_size
309
+ elapsed = time.perf_counter() - started
310
+ metadata["generation"] = {"elapsed_seconds": elapsed, "artifact_sizes_bytes": artifacts}
311
+ _write_metadata(metadata_path, metadata, artifacts)
312
+ elapsed = time.perf_counter() - started
313
+ metadata["generation"]["elapsed_seconds"] = elapsed
314
+ _write_metadata(metadata_path, metadata, artifacts)
315
+
316
+ if config.timing_debug:
317
+ print("\nRust map export timing breakdown")
318
+ print("-" * 48)
319
+ labels = (
320
+ "map_parse_seconds", "rule_database_load_seconds",
321
+ "heatmap_evaluator_setup_seconds", "heatmaps_total_seconds",
322
+ "heatmap_npz_encode_seconds", "diagnostics_seconds",
323
+ "monuments_seconds", "map_render_seconds",
324
+ "no_build_zones_seconds", "tunnel_render_seconds",
325
+ )
326
+ for label in labels:
327
+ print(f"{label.removesuffix('_seconds').replace('_', ' '):32s} {float(timings[label]):8.3f}s")
328
+ if timings["heatmap_categories"]:
329
+ print(" Slowest heatmap categories:")
330
+ for name, seconds in sorted(
331
+ timings["heatmap_categories"].items(), key=lambda item: item[1], reverse=True
332
+ )[:8]:
333
+ print(f" {name:28s} {seconds:8.3f}s")
334
+ print("-" * 48)
335
+ print(f"{'total':32s} {elapsed:8.3f}s\n")
336
+ return metadata
337
+
338
+
339
+ class RustMapExporter:
340
+ """Run selected map export stages from a validated configuration."""
341
+
342
+ def __init__(self, config: ExportConfig):
343
+ self.config = config.validated()
344
+
345
+ def run(self) -> ExportResult:
346
+ config = self.config
347
+ exports = config.exports
348
+ needs_rules = exports.heatmaps is not None
349
+ needs_manifest = bool(exports.monuments or exports.tunnels or exports.no_build_zones)
350
+ with ExitStack() as stack:
351
+ rules = None
352
+ if needs_rules:
353
+ rules = config.data.spawn_rules_path
354
+ if rules is None:
355
+ rules = stack.enter_context(resources.as_file(
356
+ resources.files("rustmap.data").joinpath("spawn_rules.json")
357
+ ))
358
+ manifest = None
359
+ if needs_manifest:
360
+ manifest = config.data.prefab_manifest_path
361
+ if manifest is None:
362
+ manifest = stack.enter_context(resources.as_file(
363
+ resources.files("rustmap.data").joinpath("prefab_manifest.json")
364
+ ))
365
+ metadata = _generate(config, Path(rules) if rules else None,
366
+ Path(manifest) if manifest else None)
367
+
368
+ output = config.output_dir
369
+ heatmaps = exports.heatmaps
370
+ terrain = exports.terrain
371
+ tiles = terrain.tiles if terrain else None
372
+ tunnel_options = exports.tunnels
373
+ no_build_options = exports.no_build_zones
374
+ tunnel_metadata = metadata["tunnels"]
375
+ no_build_metadata = metadata["no_build_zones"]
376
+ return ExportResult(
377
+ output_dir=output,
378
+ world_size=int(metadata["map"]["world_size"]),
379
+ elapsed_seconds=float(metadata["generation"]["elapsed_seconds"]),
380
+ metadata_file=output / "export_metadata.json",
381
+ metadata=metadata,
382
+ heatmap_categories=tuple(sorted(metadata["heatmaps"]["categories"])),
383
+ heatmaps_file=(output / metadata["heatmaps"]["file"]) if heatmaps else None,
384
+ monuments_file=(output / "monuments.json") if exports.monuments else None,
385
+ monument_count=int(metadata["monuments"]["count"]),
386
+ map_image=(output / "map_render.png")
387
+ if terrain and not terrain.full_size and "png" in terrain.formats else None,
388
+ full_map_image=(output / "map_render_full.png")
389
+ if terrain and terrain.full_size else None,
390
+ map_tiles_dir=(output / "map_render_tiles") if tiles else None,
391
+ map_tiles_metadata_file=(output / "map_render_tiles" / "tiles.json") if tiles else None,
392
+ map_tile_count=int(((metadata["terrain"] or {}).get("full_size_tiles") or {}).get("tile_count", 0)),
393
+ diagnostics_dir=(output / "diagnostics") if exports.diagnostics else None,
394
+ tunnels_image=(output / "tunnels.png")
395
+ if (tunnel_metadata.get("status") == "rendered" and
396
+ tunnel_options and tunnel_options.export_layer) else None,
397
+ tunnels_overlay_image=(output / "tunnels_on_map.png")
398
+ if tunnel_metadata.get("overlay_file") else None,
399
+ tunnels_metadata_file=(output / "tunnels_metadata.json") if exports.tunnels else None,
400
+ tunnel_render_status=str(tunnel_metadata.get("status", "disabled")),
401
+ no_build_zones_image=(output / "no_build_zones.png")
402
+ if no_build_options and no_build_options.export_images else None,
403
+ no_build_zones_overlay_image=(output / "no_build_zones_on_map.png")
404
+ if no_build_metadata.get("overlay_file") else None,
405
+ no_build_zones_file=(output / "no_build_zones.json")
406
+ if no_build_options and no_build_options.export_json else None,
407
+ no_build_zone_count=int(no_build_metadata.get("zone_count", 0)),
408
+ no_build_zone_status=str(no_build_metadata.get("status", "disabled")),
409
+ )