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
@@ -0,0 +1,398 @@
1
+ """Extract and cache Rust's final-LOD train-tunnel map geometry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import multiprocessing as mp
9
+ import os
10
+ import re
11
+ import shutil
12
+ import sys
13
+ import time
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+ from PIL import Image, ImageDraw
18
+
19
+ from .png import save_png
20
+
21
+
22
+ CACHE_SCHEMA_VERSION = 5
23
+ TEMPLATE_PIXELS_PER_METER = 8.0
24
+ ASSET_SCENE_FILE = "BuildPlayer-AssetScene-monument.25"
25
+ TUNNEL_PREFIX = "assets/bundled/prefabs/autospawn/tunnel"
26
+
27
+
28
+ def default_tunnel_cache() -> Path:
29
+ if sys.platform == "win32":
30
+ base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
31
+ elif sys.platform == "darwin":
32
+ base = Path.home() / "Library" / "Caches"
33
+ else:
34
+ base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
35
+ return base / "rustmap" / "tunnel-geometry"
36
+
37
+
38
+ def find_rust_install(explicit: str | Path | None = None) -> Path | None:
39
+ candidates: list[Path] = []
40
+ if explicit is not None:
41
+ candidate = Path(explicit)
42
+ return candidate.resolve() if (
43
+ candidate / "Bundles" / "shared" / "content.bundle"
44
+ ).is_file() else None
45
+ if os.environ.get("RUST_INSTALL_PATH"):
46
+ candidates.append(Path(os.environ["RUST_INSTALL_PATH"]))
47
+ if sys.platform == "win32":
48
+ for drive in "CDEF":
49
+ candidates.append(Path(f"{drive}:\\SteamLibrary\\steamapps\\common\\Rust"))
50
+ candidates.append(Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)")) /
51
+ "Steam" / "steamapps" / "common" / "Rust")
52
+ for candidate in candidates:
53
+ if (candidate / "Bundles" / "shared" / "content.bundle").is_file():
54
+ return candidate.resolve()
55
+ return None
56
+
57
+
58
+ def _bundle_paths(install: Path) -> dict[str, Path]:
59
+ result = {
60
+ "content": install / "Bundles" / "shared" / "content.bundle",
61
+ "asset_scenes": install / "Bundles" / "shared" / "assetscenes.bundle",
62
+ "maps": install / "Bundles" / "maps" / "maps.bundle",
63
+ }
64
+ missing = [str(path) for path in result.values() if not path.is_file()]
65
+ if missing:
66
+ raise FileNotFoundError("Missing Rust bundle(s): " + ", ".join(missing))
67
+ return result
68
+
69
+
70
+ def _build_id(install: Path) -> str | None:
71
+ manifest = install.parent.parent / "appmanifest_252490.acf"
72
+ if not manifest.is_file():
73
+ return None
74
+ match = re.search(r'"buildid"\s+"([^"]+)"', manifest.read_text(encoding="utf-8", errors="ignore"))
75
+ return match.group(1) if match else None
76
+
77
+
78
+ def bundle_identity(install: Path) -> dict:
79
+ bundles = _bundle_paths(install)
80
+ return {
81
+ "schema_version": CACHE_SCHEMA_VERSION,
82
+ "rust_build_id": _build_id(install),
83
+ "bundles": {
84
+ name: {"path": str(path), "size": path.stat().st_size,
85
+ "mtime_ns": path.stat().st_mtime_ns}
86
+ for name, path in bundles.items()
87
+ },
88
+ }
89
+
90
+
91
+ def _cache_key(identity: dict) -> str:
92
+ stable = {
93
+ "schema_version": identity["schema_version"],
94
+ "rust_build_id": identity["rust_build_id"],
95
+ "bundles": {name: {"size": value["size"], "mtime_ns": value["mtime_ns"]}
96
+ for name, value in identity["bundles"].items()},
97
+ }
98
+ return hashlib.sha256(json.dumps(stable, sort_keys=True).encode()).hexdigest()[:20]
99
+
100
+
101
+ def _vec(value, default: float = 0.0) -> np.ndarray:
102
+ return np.array([getattr(value, "x", default), getattr(value, "y", default),
103
+ getattr(value, "z", default)], dtype=np.float64)
104
+
105
+
106
+ def _local_matrix(transform) -> np.ndarray:
107
+ p = _vec(transform.m_LocalPosition)
108
+ s = _vec(transform.m_LocalScale, 1.0)
109
+ q = transform.m_LocalRotation
110
+ x, y, z, w = float(q.x), float(q.y), float(q.z), float(q.w)
111
+ rotation = np.array([
112
+ [1-2*(y*y+z*z), 2*(x*y-z*w), 2*(x*z+y*w)],
113
+ [2*(x*y+z*w), 1-2*(x*x+z*z), 2*(y*z-x*w)],
114
+ [2*(x*z-y*w), 2*(y*z+x*w), 1-2*(x*x+y*y)],
115
+ ], dtype=np.float64)
116
+ matrix = np.eye(4, dtype=np.float64)
117
+ matrix[:3, :3] = rotation @ np.diag(s)
118
+ matrix[:3, 3] = p
119
+ return matrix
120
+
121
+
122
+ def _world_matrix(transform, memo: dict[int, np.ndarray]) -> np.ndarray:
123
+ path_id = transform.object_reader.path_id
124
+ if path_id in memo:
125
+ return memo[path_id]
126
+ local = _local_matrix(transform)
127
+ parent = transform.m_Father
128
+ result = _world_matrix(parent.read(), memo) @ local if parent.path_id else local
129
+ memo[path_id] = result
130
+ return result
131
+
132
+
133
+ def _component(game_object, type_name: str):
134
+ for item in game_object.m_Component:
135
+ if item.component.type.name == type_name:
136
+ return item.component.read()
137
+ return None
138
+
139
+
140
+ def _rasterize_template(vertices: np.ndarray, triangles: np.ndarray) -> tuple[np.ndarray, dict]:
141
+ """Rasterize prefab-local X/Z triangles with stable world-grid pixel phase."""
142
+ started = time.perf_counter()
143
+ ppm = TEMPLATE_PIXELS_PER_METER
144
+ min_x, max_x = float(vertices[:, 0].min()), float(vertices[:, 0].max())
145
+ min_z, max_z = float(vertices[:, 2].min()), float(vertices[:, 2].max())
146
+ # Align bounds to the global 2 px/m lattice and retain one transparent pixel
147
+ # around the silhouette so Lanczos downsampling cannot clip edge coverage.
148
+ left_x = math.floor(min_x * ppm) / ppm - 1.0 / ppm
149
+ right_x = math.ceil(max_x * ppm) / ppm + 1.0 / ppm
150
+ bottom_z = math.floor(min_z * ppm) / ppm - 1.0 / ppm
151
+ top_z = math.ceil(max_z * ppm) / ppm + 1.0 / ppm
152
+ width = max(1, int(round((right_x - left_x) * ppm)) + 1)
153
+ height = max(1, int(round((top_z - bottom_z) * ppm)) + 1)
154
+ px = (vertices[:, 0] - left_x) * ppm
155
+ py = (top_z - vertices[:, 2]) * ppm
156
+ projected = np.round(np.column_stack((px, py)), 6)
157
+ image = Image.new("L", (width, height), 0)
158
+ draw = ImageDraw.Draw(image)
159
+ drawn = 0
160
+ for triangle in triangles:
161
+ points = projected[triangle]
162
+ first, second = points[1] - points[0], points[2] - points[0]
163
+ area = abs(first[0] * second[1] - first[1] * second[0])
164
+ if area <= 0.05:
165
+ continue
166
+ draw.polygon(tuple(map(tuple, points)), fill=255)
167
+ drawn += 1
168
+ return np.asarray(image, dtype=np.uint8), {
169
+ "mask_pixels_per_meter": ppm,
170
+ "mask_left_x": left_x,
171
+ "mask_top_z": top_z,
172
+ "mask_shape": [height, width],
173
+ "mask_triangle_count": drawn,
174
+ "mask_render_seconds": time.perf_counter() - started,
175
+ }
176
+
177
+
178
+ def _class_type_index(asset_file, class_name: str) -> int:
179
+ representatives = {}
180
+ for obj in asset_file.objects.values():
181
+ if obj.type.name == "MonoBehaviour":
182
+ representatives.setdefault(id(obj.serialized_type), obj)
183
+ for obj in representatives.values():
184
+ try:
185
+ if obj.read().m_Script.read().m_ClassName == class_name:
186
+ return obj.serialized_type.script_type_index
187
+ except Exception:
188
+ continue
189
+ raise RuntimeError(f"{class_name} MonoBehaviour type was not found")
190
+
191
+
192
+ def _extract_worker(asset_scenes: str, content: str, target: str, identity: dict, conn) -> None:
193
+ try:
194
+ import UnityPy
195
+ from UnityPy.helpers.MeshHelper import MeshHandler
196
+
197
+ started = time.perf_counter()
198
+ environment = UnityPy.load(asset_scenes, content)
199
+ scene = None
200
+ for root in environment.files.values():
201
+ files = getattr(root, "files", None) or {}
202
+ if ASSET_SCENE_FILE in files:
203
+ scene = files[ASSET_SCENE_FILE]
204
+ break
205
+ if scene is None:
206
+ raise RuntimeError(f"{ASSET_SCENE_FILE} was not found in assetscenes.bundle")
207
+
208
+ type_index = _class_type_index(scene, "DungeonGridCell")
209
+ arrays: dict[str, np.ndarray] = {}
210
+ templates = []
211
+ template_raster_seconds = 0.0
212
+ transform_memo: dict[int, np.ndarray] = {}
213
+ for component in scene.objects.values():
214
+ if (component.type.name != "MonoBehaviour" or
215
+ component.serialized_type.script_type_index != type_index):
216
+ continue
217
+ tree = component.read_typetree()
218
+ root_go = scene.objects[tree["m_GameObject"]["m_PathID"]].read()
219
+ path = root_go.m_Name.casefold().replace("\\", "/")
220
+ if not path.startswith(TUNNEL_PREFIX):
221
+ continue
222
+ root_transform = _component(root_go, "Transform")
223
+ root_inverse = np.linalg.inv(_world_matrix(root_transform, transform_memo))
224
+ vertices_parts, triangle_parts = [], []
225
+ vertex_offset = 0
226
+ mesh_count = 0
227
+ for lod_ptr in tree.get("MapRendererLods", []):
228
+ if not lod_ptr.get("m_PathID"):
229
+ continue
230
+ lod_tree = scene.objects[lod_ptr["m_PathID"]].read_typetree()
231
+ renderer = None
232
+ for state in reversed(lod_tree.get("States", [])):
233
+ pointer = state.get("renderer", {})
234
+ if pointer.get("m_PathID"):
235
+ renderer = scene.objects[pointer["m_PathID"]].read()
236
+ break
237
+ if renderer is None:
238
+ continue
239
+ renderer_go = renderer.m_GameObject.read()
240
+ mesh_filter = _component(renderer_go, "MeshFilter")
241
+ renderer_transform = _component(renderer_go, "Transform")
242
+ if mesh_filter is None or not mesh_filter.m_Mesh.path_id:
243
+ continue
244
+ mesh = mesh_filter.m_Mesh.read()
245
+ handler = MeshHandler(mesh)
246
+ handler.process()
247
+ if not handler.m_Vertices:
248
+ continue
249
+ local_to_root = root_inverse @ _world_matrix(renderer_transform, transform_memo)
250
+ vertices = np.asarray(handler.m_Vertices, dtype=np.float64)
251
+ homogeneous = np.column_stack((vertices, np.ones(len(vertices))))
252
+ vertices = (local_to_root @ homogeneous.T).T[:, :3].astype(np.float32)
253
+ triangles = [triangle for submesh in handler.get_triangles() for triangle in submesh]
254
+ if not triangles:
255
+ continue
256
+ indices = np.asarray(triangles, dtype=np.int32) + vertex_offset
257
+ vertices_parts.append(vertices)
258
+ triangle_parts.append(indices)
259
+ vertex_offset += len(vertices)
260
+ mesh_count += 1
261
+ if not vertices_parts:
262
+ continue
263
+ key = f"template_{len(templates):03d}"
264
+ combined_vertices = np.concatenate(vertices_parts)
265
+ combined_triangles = np.concatenate(triangle_parts)
266
+ arrays[key + "_vertices"] = combined_vertices
267
+ arrays[key + "_triangles"] = combined_triangles
268
+ template_mask, mask_metadata = _rasterize_template(
269
+ combined_vertices, combined_triangles
270
+ )
271
+ arrays[key + "_mask"] = template_mask
272
+ template_raster_seconds += mask_metadata["mask_render_seconds"]
273
+ templates.append({"key": key, "prefab_path": path, "mesh_count": mesh_count,
274
+ "vertex_count": len(combined_vertices),
275
+ "triangle_count": len(combined_triangles),
276
+ **mask_metadata})
277
+
278
+ destination = Path(target)
279
+ destination.mkdir(parents=True, exist_ok=True)
280
+ templates_directory = destination / "templates"
281
+ templates_directory.mkdir(parents=True, exist_ok=True)
282
+ for template in templates:
283
+ key = template["key"]
284
+ filename = f"{key}__{Path(template['prefab_path']).stem}.png"
285
+ save_png(
286
+ Image.fromarray(arrays.pop(key + "_mask"), mode="L"),
287
+ templates_directory / filename,
288
+ )
289
+ template["mask_file"] = f"templates/{filename}"
290
+ np.savez_compressed(destination / "geometry.npz", **arrays)
291
+ metadata = {"schema_version": CACHE_SCHEMA_VERSION, "identity": identity,
292
+ "template_count": len(templates), "templates": templates,
293
+ "template_pixels_per_meter": TEMPLATE_PIXELS_PER_METER,
294
+ "template_raster_seconds": template_raster_seconds,
295
+ "extraction_seconds": time.perf_counter() - started}
296
+ (destination / "metadata.json").write_text(
297
+ json.dumps(metadata, indent=2) + "\n", encoding="utf-8", newline="\n")
298
+ conn.send({"ok": True, "template_count": len(templates)})
299
+ except Exception as exc:
300
+ conn.send({"ok": False, "error": f"{type(exc).__name__}: {exc}"})
301
+ finally:
302
+ conn.close()
303
+
304
+
305
+ def refresh_tunnel_geometry(rust_install_path: str | Path,
306
+ cache_path: str | Path | None = None) -> Path:
307
+ install = find_rust_install(rust_install_path)
308
+ if install is None:
309
+ raise FileNotFoundError(f"Rust installation not found: {rust_install_path}")
310
+ identity = bundle_identity(install)
311
+ cache_root = Path(cache_path) if cache_path is not None else default_tunnel_cache()
312
+ destination = cache_root / _cache_key(identity)
313
+ bundles = _bundle_paths(install)
314
+ parent, child = mp.get_context("spawn").Pipe(False)
315
+ process = mp.get_context("spawn").Process(
316
+ target=_extract_worker,
317
+ args=(str(bundles["asset_scenes"]), str(bundles["content"]),
318
+ str(destination), identity, child),
319
+ )
320
+ process.start()
321
+ result = parent.recv()
322
+ process.join()
323
+ if not result.get("ok"):
324
+ raise RuntimeError(result.get("error", "Tunnel geometry extraction failed"))
325
+ return destination
326
+
327
+
328
+ def load_or_refresh_tunnel_geometry(rust_install_path: str | Path,
329
+ cache_path: str | Path | None = None) -> Path:
330
+ install = find_rust_install(rust_install_path)
331
+ if install is None:
332
+ raise FileNotFoundError("A matching local Rust client installation was not found")
333
+ identity = bundle_identity(install)
334
+ cache_root = Path(cache_path) if cache_path is not None else default_tunnel_cache()
335
+ destination = cache_root / _cache_key(identity)
336
+ metadata_path, geometry_path = destination / "metadata.json", destination / "geometry.npz"
337
+ if metadata_path.is_file() and geometry_path.is_file():
338
+ try:
339
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
340
+ if metadata.get("identity") == identity and metadata.get("template_count", 0) > 0:
341
+ return destination
342
+ except (OSError, ValueError):
343
+ pass
344
+ return refresh_tunnel_geometry(install, cache_root)
345
+
346
+
347
+ def refresh_tunnel_templates(rust_install_path: str | Path,
348
+ cache_path: str | Path | None = None) -> Path:
349
+ """Build named PNG tunnel pieces plus fallback geometry in a versioned cache."""
350
+ return refresh_tunnel_geometry(rust_install_path, cache_path)
351
+
352
+
353
+ def install_packaged_tunnel_templates(cache_path: str | Path,
354
+ destination: str | Path) -> Path:
355
+ """Copy PNG pieces from a local cache and write sanitized package metadata."""
356
+ cache = Path(cache_path)
357
+ target = Path(destination)
358
+ metadata = json.loads((cache / "metadata.json").read_text(encoding="utf-8"))
359
+ target.mkdir(parents=True, exist_ok=True)
360
+ expected = set()
361
+ templates = []
362
+ for item in metadata["templates"]:
363
+ filename = Path(item["mask_file"]).name
364
+ source = cache / item["mask_file"]
365
+ if not source.is_file():
366
+ raise FileNotFoundError(f"Missing generated tunnel tile: {source}")
367
+ shutil.copy2(source, target / filename)
368
+ expected.add(filename)
369
+ templates.append({
370
+ "key": item["key"], "prefab_path": item["prefab_path"],
371
+ "mask_file": filename,
372
+ "mask_pixels_per_meter": item["mask_pixels_per_meter"],
373
+ "mask_left_x": item["mask_left_x"], "mask_top_z": item["mask_top_z"],
374
+ "mask_shape": item["mask_shape"],
375
+ })
376
+ for old_tile in target.glob("*.png"):
377
+ if old_tile.name not in expected:
378
+ old_tile.unlink()
379
+ identity = metadata.get("identity", {})
380
+ sanitized_bundles = {
381
+ name: {"size": int(value["size"]), "mtime_ns": int(value["mtime_ns"])}
382
+ for name, value in identity.get("bundles", {}).items()
383
+ }
384
+ payload = {
385
+ "schema_version": int(metadata["schema_version"]),
386
+ "identity": {
387
+ "schema_version": int(identity.get("schema_version", metadata["schema_version"])),
388
+ "rust_build_id": identity.get("rust_build_id"),
389
+ "bundles": sanitized_bundles,
390
+ },
391
+ "template_count": len(templates),
392
+ "template_pixels_per_meter": metadata["template_pixels_per_meter"],
393
+ "templates": templates,
394
+ }
395
+ (target / "tiles.json").write_text(
396
+ json.dumps(payload, indent=2) + "\n", encoding="utf-8", newline="\n"
397
+ )
398
+ return target