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/tunnels.py ADDED
@@ -0,0 +1,417 @@
1
+ """Render Rust's exact final-LOD underground train-tunnel silhouettes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import time
8
+ from contextlib import nullcontext
9
+ from importlib import resources
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ from PIL import Image, ImageDraw
14
+
15
+ from .prefabs import PrefabManifest
16
+ from .png import save_png
17
+ from .tunnel_assets import (
18
+ TUNNEL_PREFIX,
19
+ find_rust_install,
20
+ load_or_refresh_tunnel_geometry,
21
+ )
22
+
23
+
24
+ def _euler_matrix(euler) -> np.ndarray:
25
+ """Unity Quaternion.Euler-compatible Z-X-Y rotation matrix."""
26
+ if euler is None:
27
+ return np.eye(3, dtype=np.float64)
28
+ x, y, z = (math.radians(float(euler.x)), math.radians(float(euler.y)),
29
+ math.radians(float(euler.z)))
30
+ sx, cx, sy, cy, sz, cz = math.sin(x), math.cos(x), math.sin(y), math.cos(y), math.sin(z), math.cos(z)
31
+ rx = np.array(((1,0,0),(0,cx,-sx),(0,sx,cx)), dtype=np.float64)
32
+ ry = np.array(((cy,0,sy),(0,1,0),(-sy,0,cy)), dtype=np.float64)
33
+ rz = np.array(((cz,-sz,0),(sz,cz,0),(0,0,1)), dtype=np.float64)
34
+ return ry @ rx @ rz
35
+
36
+
37
+ def _instance_matrix(prefab) -> np.ndarray:
38
+ matrix = np.eye(4, dtype=np.float64)
39
+ scale = prefab.scale
40
+ scales = np.array((scale.x, scale.y, scale.z), dtype=np.float64) if scale else np.ones(3)
41
+ matrix[:3, :3] = _euler_matrix(prefab.rotation) @ np.diag(scales)
42
+ matrix[:3, 3] = (prefab.position.x, prefab.position.y, prefab.position.z)
43
+ return matrix
44
+
45
+
46
+ def _write_metadata(output: Path, metadata: dict) -> dict:
47
+ target = output / "tunnels_metadata.json"
48
+ metadata["metadata_file"] = target.name
49
+ target.write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8", newline="\n")
50
+ return metadata
51
+
52
+
53
+ def _skip(output: Path, reason: str, started: float, details: dict | None = None) -> dict:
54
+ for name in ("tunnels.png", "tunnels_on_map.png"):
55
+ path = output / name
56
+ if path.is_file():
57
+ path.unlink()
58
+ metadata = {
59
+ "schema_version": 2, "layer": "TrainTunnels", "status": "skipped",
60
+ "reason": reason, "render_seconds": time.perf_counter() - started,
61
+ "orientation": "flip_vertical (reverse Z; preserve X left/right)",
62
+ }
63
+ if details:
64
+ metadata.update(details)
65
+ return _write_metadata(output, metadata)
66
+
67
+
68
+ def render_tunnel_map(world, manifest: PrefabManifest, geometry_cache: str | Path | None = None,
69
+ resolution: int | None = None, opacity: float = 1.0,
70
+ force_triangles: bool = False):
71
+ """Render exact tunnel meshes, normally by placing pre-rasterized templates."""
72
+ resolution = world.size if resolution is None else resolution
73
+ if resolution <= 0:
74
+ raise ValueError("Tunnel render resolution must be positive")
75
+ if not 0.0 <= opacity <= 1.0:
76
+ raise ValueError("Tunnel overlay opacity must be between 0 and 1")
77
+ cache = Path(geometry_cache) if geometry_cache is not None else None
78
+ if cache is None:
79
+ tile_root = resources.files("rustmap.data.tunnel_tiles")
80
+ cache_metadata = json.loads(tile_root.joinpath("tiles.json").read_text(encoding="utf-8"))
81
+ tile_source = "packaged"
82
+ geometry_path = None
83
+ tile_directory = "rustmap.data.tunnel_tiles"
84
+ def tile_exists(filename): return tile_root.joinpath(filename).is_file()
85
+ def load_tile(filename):
86
+ with tile_root.joinpath(filename).open("rb") as stream, Image.open(stream) as source:
87
+ return np.asarray(source.convert("L"), dtype=np.uint8)
88
+ else:
89
+ cache_metadata = json.loads((cache / "metadata.json").read_text(encoding="utf-8"))
90
+ tile_source = "override_cache"
91
+ geometry_path = cache / "geometry.npz"
92
+ tile_directory = str((cache / "templates").resolve())
93
+ def tile_exists(filename): return (cache / filename).is_file()
94
+ def load_tile(filename):
95
+ with Image.open(cache / filename) as source:
96
+ return np.asarray(source.convert("L"), dtype=np.uint8)
97
+ started = time.perf_counter()
98
+ related_instances, rendered_instances = 0, 0
99
+ rendered_paths, related_paths = set(), set()
100
+ loaded_tile_paths, missing_tiles = set(), set()
101
+ triangle_count = fast_instances = fallback_instances = 0
102
+ supersample = 2
103
+ extent = float(world.size)
104
+ intermediate_size = resolution * supersample
105
+ intermediate_ppm = intermediate_size / extent
106
+ mask_values = np.zeros((intermediate_size, intermediate_size), dtype=np.uint8)
107
+ template_prepare_seconds = template_png_load_seconds = paste_seconds = fallback_seconds = 0.0
108
+ cache_load_started = time.perf_counter()
109
+
110
+ arrays_context = np.load(geometry_path) if geometry_path is not None and geometry_path.is_file() else nullcontext(None)
111
+ with arrays_context as arrays:
112
+ cache_load_seconds = time.perf_counter() - cache_load_started
113
+ templates = {item["prefab_path"]: item for item in cache_metadata["templates"]}
114
+ prepared: dict[tuple, tuple[np.ndarray, float, float]] = {}
115
+ fallbacks = []
116
+ for prefab in world.prefabs:
117
+ entry = manifest.get(prefab.prefab_id)
118
+ path = entry.path.casefold().replace("\\", "/") if entry else ""
119
+ if not path.startswith(TUNNEL_PREFIX) or prefab.position is None:
120
+ continue
121
+ related_instances += 1
122
+ related_paths.add(path)
123
+ template = templates.get(path)
124
+ if template is None:
125
+ missing_tiles.add(path)
126
+ continue
127
+ rotation = prefab.rotation
128
+ scale = prefab.scale
129
+ x_angle = float(rotation.x) if rotation else 0.0
130
+ y_angle = float(rotation.y) if rotation else 0.0
131
+ z_angle = float(rotation.z) if rotation else 0.0
132
+ scales = (float(scale.x), float(scale.y), float(scale.z)) if scale else (1.0,1.0,1.0)
133
+ quarter_value = y_angle / 90.0
134
+ cardinal = (abs(x_angle) < 1e-5 and abs(z_angle) < 1e-5 and
135
+ abs(quarter_value - round(quarter_value)) < 1e-5)
136
+ uniform = (scales[0] > 0 and abs(scales[0]-scales[1]) < 1e-5 and
137
+ abs(scales[0]-scales[2]) < 1e-5)
138
+ mask_file = template.get("mask_file", "")
139
+ has_mask = bool(template.get("mask_pixels_per_meter") and tile_exists(mask_file))
140
+ if not has_mask:
141
+ missing_tiles.add(path)
142
+ if force_triangles or not (cardinal and uniform and has_mask):
143
+ fallbacks.append((prefab, template))
144
+ continue
145
+
146
+ quarters = int(round(quarter_value)) % 4
147
+ uniform_scale = scales[0]
148
+ prepare_key = (path, quarters, round(uniform_scale, 8), round(intermediate_ppm, 10))
149
+ prep_started = time.perf_counter()
150
+ prepared_item = prepared.get(prepare_key)
151
+ if prepared_item is None:
152
+ png_load_started = time.perf_counter()
153
+ try:
154
+ template_mask = load_tile(mask_file)
155
+ except (OSError, ValueError, SyntaxError):
156
+ missing_tiles.add(path)
157
+ fallbacks.append((prefab, template))
158
+ template_png_load_seconds += time.perf_counter() - png_load_started
159
+ continue
160
+ loaded_tile_paths.add(path)
161
+ template_png_load_seconds += time.perf_counter() - png_load_started
162
+ if quarters:
163
+ template_mask = np.rot90(template_mask, k=-quarters)
164
+ source_ppm = float(template["mask_pixels_per_meter"])
165
+ left = float(template["mask_left_x"]) * uniform_scale
166
+ top = float(template["mask_top_z"]) * uniform_scale
167
+ source_height, source_width = template["mask_shape"]
168
+ right = left + (source_width-1) / source_ppm * uniform_scale
169
+ bottom = top - (source_height-1) / source_ppm * uniform_scale
170
+ corners = np.array(((left,top),(right,top),(right,bottom),(left,bottom)))
171
+ angle = math.radians(quarters * 90)
172
+ c, s = math.cos(angle), math.sin(angle)
173
+ rotated_x = c*corners[:,0] + s*corners[:,1]
174
+ rotated_z = -s*corners[:,0] + c*corners[:,1]
175
+ rotated_left, rotated_right = float(rotated_x.min()), float(rotated_x.max())
176
+ rotated_bottom, rotated_top = float(rotated_z.min()), float(rotated_z.max())
177
+ target_left = math.floor(rotated_left*intermediate_ppm) / intermediate_ppm
178
+ target_right = math.ceil(rotated_right*intermediate_ppm) / intermediate_ppm
179
+ target_bottom = math.floor(rotated_bottom*intermediate_ppm) / intermediate_ppm
180
+ target_top = math.ceil(rotated_top*intermediate_ppm) / intermediate_ppm
181
+ target_size = (
182
+ max(1, int(round((target_right-target_left)*intermediate_ppm))+1),
183
+ max(1, int(round((target_top-target_bottom)*intermediate_ppm))+1),
184
+ )
185
+ effective_source_ppm = source_ppm / uniform_scale
186
+ output_to_source = effective_source_ppm / intermediate_ppm
187
+ transform = (
188
+ output_to_source, 0.0, (target_left-rotated_left)*effective_source_ppm,
189
+ 0.0, output_to_source, (rotated_top-target_top)*effective_source_ppm,
190
+ )
191
+ if not (target_size == (template_mask.shape[1], template_mask.shape[0]) and
192
+ abs(output_to_source-1.0) < 1e-12 and
193
+ abs(transform[2]) < 1e-12 and abs(transform[5]) < 1e-12):
194
+ template_mask = np.asarray(Image.fromarray(template_mask).transform(
195
+ target_size, Image.Transform.AFFINE, transform,
196
+ resample=Image.Resampling.BICUBIC,
197
+ ), dtype=np.uint8)
198
+ prepared_item = (template_mask, target_left, target_top)
199
+ prepared[prepare_key] = prepared_item
200
+ template_prepare_seconds += time.perf_counter() - prep_started
201
+ template_mask, local_left, local_top = prepared_item
202
+ destination_x = (prefab.position.x + local_left + extent/2) * intermediate_ppm
203
+ destination_y = (extent/2 - prefab.position.z - local_top) * intermediate_ppm
204
+ if (abs(destination_x-round(destination_x)) > 1e-5 or
205
+ abs(destination_y-round(destination_y)) > 1e-5):
206
+ fallbacks.append((prefab, template))
207
+ continue
208
+ else:
209
+ paste_started = time.perf_counter()
210
+ x0, y0 = int(round(destination_x)), int(round(destination_y))
211
+ h, w = template_mask.shape
212
+ target_x0, target_y0 = max(0,x0), max(0,y0)
213
+ target_x1, target_y1 = min(intermediate_size,x0+w), min(intermediate_size,y0+h)
214
+ if target_x1 > target_x0 and target_y1 > target_y0:
215
+ source_x0, source_y0 = target_x0-x0, target_y0-y0
216
+ source_x1, source_y1 = source_x0+(target_x1-target_x0), source_y0+(target_y1-target_y0)
217
+ np.maximum(mask_values[target_y0:target_y1,target_x0:target_x1],
218
+ template_mask[source_y0:source_y1,source_x0:source_x1],
219
+ out=mask_values[target_y0:target_y1,target_x0:target_x1])
220
+ paste_seconds += time.perf_counter() - paste_started
221
+ fast_instances += 1
222
+ rendered_instances += 1
223
+ rendered_paths.add(path)
224
+
225
+ omitted_fallbacks = []
226
+ if fallbacks and arrays is not None:
227
+ fallback_started = time.perf_counter()
228
+ mask = Image.fromarray(mask_values, mode="L")
229
+ draw = ImageDraw.Draw(mask)
230
+ for prefab, template in fallbacks:
231
+ vertices = arrays[template["key"] + "_vertices"]
232
+ triangles = arrays[template["key"] + "_triangles"]
233
+ homogeneous = np.column_stack((vertices, np.ones(len(vertices))))
234
+ world_vertices = (_instance_matrix(prefab) @ homogeneous.T).T[:, :3]
235
+ projected = np.round(np.column_stack((
236
+ (world_vertices[:,0]+extent/2)*intermediate_ppm,
237
+ (extent/2-world_vertices[:,2])*intermediate_ppm,
238
+ )), 6)
239
+ for triangle in triangles:
240
+ points = projected[triangle]
241
+ first, second = points[1]-points[0], points[2]-points[0]
242
+ area = abs(first[0]*second[1] - first[1]*second[0])
243
+ if area <= 0.05:
244
+ continue
245
+ draw.polygon(tuple(map(tuple, points)), fill=255)
246
+ triangle_count += 1
247
+ fallback_instances += 1
248
+ rendered_instances += 1
249
+ rendered_paths.add(template["prefab_path"])
250
+ mask_values = np.asarray(mask, dtype=np.uint8)
251
+ fallback_seconds = time.perf_counter() - fallback_started
252
+ elif fallbacks:
253
+ omitted_fallbacks = sorted({
254
+ templates_path["prefab_path"] for _, templates_path in fallbacks
255
+ })
256
+
257
+ source_identity = cache_metadata.get("identity", {})
258
+ source_content = source_identity.get("bundles", {}).get("content", {})
259
+ tile_version_mismatch = bool(
260
+ source_content and (
261
+ int(source_content.get("size", -1)) != manifest.source_size or
262
+ int(source_content.get("mtime_ns", -1)) != manifest.source_mtime_ns
263
+ )
264
+ )
265
+
266
+ downsample_started = time.perf_counter()
267
+ mask = Image.fromarray(mask_values, mode="L").resize(
268
+ (resolution, resolution), Image.Resampling.LANCZOS
269
+ )
270
+ downsample_seconds = time.perf_counter() - downsample_started
271
+ if opacity != 1.0:
272
+ mask = mask.point(lambda value: round(value * opacity))
273
+ image = Image.new("RGBA", (resolution, resolution), (211, 214, 214, 0))
274
+ image.putalpha(mask)
275
+ metadata = {
276
+ "resolution": [resolution, resolution], "world_size": world.size,
277
+ "orientation": "flip_vertical (reverse Z; preserve X left/right)",
278
+ "related_instance_count": related_instances,
279
+ "train_layer_instance_count": rendered_instances,
280
+ "related_unique_prefab_count": len(related_paths),
281
+ "train_layer_unique_prefab_count": len(rendered_paths),
282
+ "non_train_layer_prefabs": sorted(related_paths - rendered_paths),
283
+ "rasterized_triangle_count": triangle_count,
284
+ "render_mode": "triangles" if force_triangles else "cached_templates",
285
+ "fast_path_instance_count": fast_instances,
286
+ "fallback_instance_count": fallback_instances,
287
+ "omitted_fallback_instance_count": len(fallbacks) - fallback_instances,
288
+ "omitted_fallback_prefabs": omitted_fallbacks,
289
+ "prepared_template_count": len(prepared),
290
+ "cache_schema_version": cache_metadata.get("schema_version"),
291
+ "cache_pixels_per_meter": cache_metadata.get("template_pixels_per_meter"),
292
+ "cache_template_raster_seconds": cache_metadata.get("template_raster_seconds"),
293
+ "intermediate_pixels_per_meter": intermediate_ppm,
294
+ "cache_load_seconds": cache_load_seconds,
295
+ "template_prepare_seconds": template_prepare_seconds,
296
+ "template_png_load_seconds": template_png_load_seconds,
297
+ "template_paste_seconds": paste_seconds,
298
+ "fallback_raster_seconds": fallback_seconds,
299
+ "downsample_seconds": downsample_seconds,
300
+ "alpha_nonzero_pixels": int(np.count_nonzero(np.asarray(mask))),
301
+ "render_seconds": time.perf_counter() - started,
302
+ "cache_template_count": cache_metadata["template_count"],
303
+ "tile_source": tile_source,
304
+ "tile_directory": tile_directory,
305
+ "loaded_tile_count": len(loaded_tile_paths),
306
+ "missing_tiles": sorted(missing_tiles),
307
+ "fallback_geometry_available": arrays is not None,
308
+ "tile_source_build_id": source_identity.get("rust_build_id"),
309
+ "tile_version_mismatch": tile_version_mismatch,
310
+ "tile_warnings": (["tile_version_mismatch"] if tile_version_mismatch else []) +
311
+ (["instances_omitted_without_fallback_geometry"] if omitted_fallbacks else []),
312
+ }
313
+ return image, metadata
314
+
315
+
316
+ def save_tunnel_render(world, manifest_path: str | Path, output_dir: str | Path,
317
+ rust_install_path: str | Path | None = None,
318
+ resolution: int | None = None,
319
+ cache_path: str | Path | None = None,
320
+ overlay_opacity: float = 1.0,
321
+ tint_color: tuple[int, int, int, int] = (50, 45, 105, 104),
322
+ terrain_image: str | Path | Image.Image | None = None,
323
+ export_layer: bool = True,
324
+ export_overlay: bool = True) -> dict:
325
+ output = Path(output_dir)
326
+ output.mkdir(parents=True, exist_ok=True)
327
+ started = time.perf_counter()
328
+ manifest = PrefabManifest.load(manifest_path)
329
+ install = find_rust_install(rust_install_path) if rust_install_path is not None else None
330
+ cache = None
331
+ try:
332
+ if cache_path is not None:
333
+ requested_cache = Path(cache_path)
334
+ if (requested_cache / "metadata.json").is_file():
335
+ cache = requested_cache
336
+ elif install is not None:
337
+ cache = load_or_refresh_tunnel_geometry(install, requested_cache)
338
+ else:
339
+ return _skip(output, "override_cache_unavailable", started, {
340
+ "requested_cache": str(requested_cache),
341
+ "error": "The override is not a completed versioned cache and no Rust install was supplied",
342
+ })
343
+ image, render_metadata = render_tunnel_map(
344
+ world, manifest, cache, resolution, 1.0
345
+ )
346
+ except Exception as exc:
347
+ return _skip(output, "geometry_extraction_or_render_failed", started, {
348
+ "error": f"{type(exc).__name__}: {exc}",
349
+ "rust_install": str(install) if install else None,
350
+ })
351
+
352
+ image_path = output / "tunnels.png"
353
+ if not export_layer and image_path.is_file():
354
+ image_path.unlink()
355
+ png_encode_seconds = 0.0
356
+ if export_layer:
357
+ png_started = time.perf_counter()
358
+ save_png(image, image_path)
359
+ png_encode_seconds = time.perf_counter() - png_started
360
+ overlay_output_path = output / "tunnels_on_map.png"
361
+ if overlay_output_path.is_file():
362
+ overlay_output_path.unlink()
363
+ overlay_path = None
364
+ overlay_warning = None
365
+ overlay_compose_seconds = overlay_encode_seconds = 0.0
366
+ if not export_overlay:
367
+ terrain = None
368
+ overlay_warning = "overlay export disabled"
369
+ elif isinstance(terrain_image, Image.Image):
370
+ terrain = terrain_image
371
+ elif terrain_image is not None and Path(terrain_image).is_file():
372
+ with Image.open(terrain_image) as source:
373
+ terrain = source.convert("RGBA")
374
+ else:
375
+ terrain = None
376
+ if terrain is not None:
377
+ if terrain.size == image.size:
378
+ compose_started = time.perf_counter()
379
+ tint = Image.new("RGBA", terrain.size, tint_color)
380
+ tinted_terrain = Image.alpha_composite(terrain, tint)
381
+ overlay_layer = image.copy()
382
+ if overlay_opacity != 1.0:
383
+ alpha = overlay_layer.getchannel("A").point(
384
+ lambda value: round(value * overlay_opacity)
385
+ )
386
+ overlay_layer.putalpha(alpha)
387
+ overlay = Image.alpha_composite(tinted_terrain, overlay_layer)
388
+ overlay_compose_seconds = time.perf_counter() - compose_started
389
+ overlay_path = overlay_output_path
390
+ overlay_encode_started = time.perf_counter()
391
+ save_png(overlay, overlay_path)
392
+ overlay_encode_seconds = time.perf_counter() - overlay_encode_started
393
+ else:
394
+ overlay_warning = f"terrain image dimensions {terrain.size} do not match {image.size}"
395
+ elif export_overlay:
396
+ overlay_warning = "full-size terrain image was not available"
397
+
398
+ metadata = {
399
+ "schema_version": 2, "layer": "TrainTunnels", "status": "rendered",
400
+ "source": "pre-rasterized DungeonGridCell final-LOD puzzle pieces",
401
+ "rust_install": str(install) if install else None,
402
+ "geometry_cache": str(cache) if cache else None,
403
+ "requested_outputs": {"layer": export_layer, "overlay": export_overlay},
404
+ "image_file": image_path.name if export_layer else None,
405
+ "image_size_bytes": image_path.stat().st_size if export_layer else None,
406
+ "overlay_file": overlay_path.name if overlay_path else None,
407
+ "overlay_size_bytes": overlay_path.stat().st_size if overlay_path else None,
408
+ "overlay_warning": overlay_warning,
409
+ "overlay_opacity": overlay_opacity,
410
+ "terrain_tint_rgba": list(tint_color),
411
+ "png_encode_seconds": png_encode_seconds,
412
+ "overlay_compose_seconds": overlay_compose_seconds,
413
+ "overlay_encode_seconds": overlay_encode_seconds,
414
+ **render_metadata,
415
+ "total_seconds": time.perf_counter() - started,
416
+ }
417
+ return _write_metadata(output, metadata)
rustmap/validation.py ADDED
@@ -0,0 +1,106 @@
1
+ """Phase 5 comparator for Rust+ compatible map_data.json heatmaps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ from PIL import Image
11
+
12
+ from .png import save_png
13
+
14
+
15
+ TRANSFORMS = {
16
+ "identity": lambda x: x,
17
+ "flip_x": lambda x: x[:, ::-1],
18
+ "flip_z": lambda x: x[::-1, :],
19
+ "rotate_180": lambda x: x[::-1, ::-1],
20
+ "transpose": lambda x: x.T,
21
+ "transpose_flip_x": lambda x: x.T[:, ::-1],
22
+ "transpose_flip_z": lambda x: x.T[::-1, :],
23
+ "transpose_rotate_180": lambda x: x.T[::-1, ::-1],
24
+ }
25
+
26
+
27
+ def load_heatmaps(path: str | Path, resolution: int = 512) -> dict[str, np.ndarray]:
28
+ source = Path(path)
29
+ payload = json.loads(source.read_text(encoding="utf-8"))
30
+ encoded = payload.get("heatmaps", payload)
31
+ if not isinstance(encoded, dict):
32
+ raise ValueError(f"{source}: heatmaps must be a JSON object")
33
+ result = {}
34
+ expected = resolution * resolution
35
+ for name, value in encoded.items():
36
+ if not isinstance(value, str):
37
+ continue
38
+ try:
39
+ raw = base64.b64decode(value, validate=True)
40
+ except Exception as exc:
41
+ raise ValueError(f"{source}: {name!r} is not valid Base64") from exc
42
+ if len(raw) != expected:
43
+ raise ValueError(
44
+ f"{source}: {name!r} decoded to {len(raw):,} bytes; expected {expected:,}"
45
+ )
46
+ result[str(name)] = np.frombuffer(raw, dtype=np.uint8).reshape(resolution, resolution)
47
+ return result
48
+
49
+
50
+ def _metrics(candidate: np.ndarray, reference: np.ndarray) -> dict:
51
+ delta = candidate.astype(np.int16) - reference.astype(np.int16)
52
+ absolute = np.abs(delta)
53
+ return {
54
+ "exact": bool(np.array_equal(candidate, reference)),
55
+ "equal_pixels": int(np.count_nonzero(delta == 0)),
56
+ "different_pixels": int(np.count_nonzero(delta)),
57
+ "different_percent": float(np.count_nonzero(delta) * 100 / delta.size),
58
+ "mean_absolute_error": float(absolute.mean()),
59
+ "root_mean_square_error": float(np.sqrt(np.mean(delta.astype(np.float64) ** 2))),
60
+ "maximum_absolute_error": int(absolute.max()),
61
+ "candidate_nonzero": int(np.count_nonzero(candidate)),
62
+ "reference_nonzero": int(np.count_nonzero(reference)),
63
+ }
64
+
65
+
66
+ def compare_files(candidate_path: str | Path, reference_path: str | Path,
67
+ output_dir: str | Path, resolution: int = 512) -> dict:
68
+ candidate = load_heatmaps(candidate_path, resolution)
69
+ reference = load_heatmaps(reference_path, resolution)
70
+ output = Path(output_dir)
71
+ output.mkdir(parents=True, exist_ok=True)
72
+ common = sorted(candidate.keys() & reference.keys())
73
+ report = {
74
+ "candidate": str(Path(candidate_path).resolve()),
75
+ "reference": str(Path(reference_path).resolve()),
76
+ "resolution": resolution,
77
+ "candidate_categories": sorted(candidate),
78
+ "reference_categories": sorted(reference),
79
+ "missing_from_candidate": sorted(reference.keys() - candidate.keys()),
80
+ "extra_in_candidate": sorted(candidate.keys() - reference.keys()),
81
+ "categories": {},
82
+ }
83
+ exact = 0
84
+ for name in common:
85
+ variants = {key: _metrics(fn(candidate[name]), reference[name])
86
+ for key, fn in TRANSFORMS.items()}
87
+ best_name = min(variants, key=lambda key: (
88
+ variants[key]["mean_absolute_error"], variants[key]["different_pixels"]
89
+ ))
90
+ best = TRANSFORMS[best_name](candidate[name])
91
+ if variants["identity"]["exact"]:
92
+ exact += 1
93
+ diff = np.abs(best.astype(np.int16) - reference[name].astype(np.int16)).astype(np.uint8)
94
+ save_png(Image.fromarray(diff, mode="L"), output / f"{name}_diff.png")
95
+ report["categories"][name] = {
96
+ "identity": variants["identity"], "best_transform": best_name,
97
+ "best_transform_metrics": variants[best_name],
98
+ }
99
+ report["summary"] = {
100
+ "common_category_count": len(common), "exact_category_count": exact,
101
+ "all_common_exact": exact == len(common) and bool(common),
102
+ }
103
+ (output / "validation_report.json").write_text(
104
+ json.dumps(report, indent=2) + "\n", encoding="utf-8"
105
+ )
106
+ return report