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.
- rust_map_parser-0.1.0.dist-info/METADATA +538 -0
- rust_map_parser-0.1.0.dist-info/RECORD +110 -0
- rust_map_parser-0.1.0.dist-info/WHEEL +5 -0
- rust_map_parser-0.1.0.dist-info/licenses/LICENSE +21 -0
- rust_map_parser-0.1.0.dist-info/top_level.txt +1 -0
- rustmap/__init__.py +44 -0
- rustmap/config.py +236 -0
- rustmap/data/__init__.py +1 -0
- rustmap/data/monument_metadata.json +954 -0
- rustmap/data/no_build_zones.json +9543 -0
- rustmap/data/prefab_manifest.json +242637 -0
- rustmap/data/spawn_rules.json +3308 -0
- rustmap/data/tunnel_tiles/__init__.py +1 -0
- rustmap/data/tunnel_tiles/template_000__curve-nw-0.png +0 -0
- rustmap/data/tunnel_tiles/template_001__curve-nw-1.png +0 -0
- rustmap/data/tunnel_tiles/template_002__intersection-bb1-n.png +0 -0
- rustmap/data/tunnel_tiles/template_003__intersection-bb4-n.png +0 -0
- rustmap/data/tunnel_tiles/template_004__intersection-b5-n.png +0 -0
- rustmap/data/tunnel_tiles/template_005__intersection-b3-n.png +0 -0
- rustmap/data/tunnel_tiles/template_006__intersection-b2-n.png +0 -0
- rustmap/data/tunnel_tiles/template_007__intersection-bb2-n.png +0 -0
- rustmap/data/tunnel_tiles/template_008__intersection-b6-n.png +0 -0
- rustmap/data/tunnel_tiles/template_009__intersection-bb3-n.png +0 -0
- rustmap/data/tunnel_tiles/template_010__intersection-b4-n.png +0 -0
- rustmap/data/tunnel_tiles/template_011__intersection-bb6-n.png +0 -0
- rustmap/data/tunnel_tiles/template_012__intersection-n.png +0 -0
- rustmap/data/tunnel_tiles/template_013__intersection-b1-n.png +0 -0
- rustmap/data/tunnel_tiles/template_014__intersection-bb5-n.png +0 -0
- rustmap/data/tunnel_tiles/template_015__station-sn-0.png +0 -0
- rustmap/data/tunnel_tiles/template_016__straight-sn-3.png +0 -0
- rustmap/data/tunnel_tiles/template_017__straight-sn-2.png +0 -0
- rustmap/data/tunnel_tiles/template_018__station-sn-1.png +0 -0
- rustmap/data/tunnel_tiles/template_019__straight-sn-0.png +0 -0
- rustmap/data/tunnel_tiles/template_020__straight-sn-1.png +0 -0
- rustmap/data/tunnel_tiles/template_021__station-sn-3.png +0 -0
- rustmap/data/tunnel_tiles/template_022__straight-sn-4.png +0 -0
- rustmap/data/tunnel_tiles/template_023__station-sn-2.png +0 -0
- rustmap/data/tunnel_tiles/template_024__straight-sn-5.png +0 -0
- rustmap/data/tunnel_tiles/template_025__curve-ne-1.png +0 -0
- rustmap/data/tunnel_tiles/template_026__curve-ne-0.png +0 -0
- rustmap/data/tunnel_tiles/template_027__intersection.png +0 -0
- rustmap/data/tunnel_tiles/template_028__intersection-bb2-s.png +0 -0
- rustmap/data/tunnel_tiles/template_029__intersection-b2-s.png +0 -0
- rustmap/data/tunnel_tiles/template_030__intersection-b6-s.png +0 -0
- rustmap/data/tunnel_tiles/template_031__intersection-bb5-s.png +0 -0
- rustmap/data/tunnel_tiles/template_032__intersection-bb1-s.png +0 -0
- rustmap/data/tunnel_tiles/template_033__intersection-s.png +0 -0
- rustmap/data/tunnel_tiles/template_034__intersection-b1-s.png +0 -0
- rustmap/data/tunnel_tiles/template_035__intersection-bb6-s.png +0 -0
- rustmap/data/tunnel_tiles/template_036__intersection-b5-s.png +0 -0
- rustmap/data/tunnel_tiles/template_037__intersection-bb4-s.png +0 -0
- rustmap/data/tunnel_tiles/template_038__intersection-b3-s.png +0 -0
- rustmap/data/tunnel_tiles/template_039__intersection-bb3-s.png +0 -0
- rustmap/data/tunnel_tiles/template_040__intersection-b4-s.png +0 -0
- rustmap/data/tunnel_tiles/template_041__straight-we-3.png +0 -0
- rustmap/data/tunnel_tiles/template_042__station-we-1.png +0 -0
- rustmap/data/tunnel_tiles/template_043__straight-we-5.png +0 -0
- rustmap/data/tunnel_tiles/template_044__straight-we-4.png +0 -0
- rustmap/data/tunnel_tiles/template_045__station-we-2.png +0 -0
- rustmap/data/tunnel_tiles/template_046__station-we-3.png +0 -0
- rustmap/data/tunnel_tiles/template_047__station-we-0.png +0 -0
- rustmap/data/tunnel_tiles/template_048__straight-we-2.png +0 -0
- rustmap/data/tunnel_tiles/template_049__straight-we-1.png +0 -0
- rustmap/data/tunnel_tiles/template_050__straight-we-0.png +0 -0
- rustmap/data/tunnel_tiles/template_051__curve-se-0.png +0 -0
- rustmap/data/tunnel_tiles/template_052__curve-se-1.png +0 -0
- rustmap/data/tunnel_tiles/template_053__intersection-bb3-w.png +0 -0
- rustmap/data/tunnel_tiles/template_054__intersection-w.png +0 -0
- rustmap/data/tunnel_tiles/template_055__intersection-bb5-w.png +0 -0
- rustmap/data/tunnel_tiles/template_056__intersection-b5-w.png +0 -0
- rustmap/data/tunnel_tiles/template_057__intersection-b1-w.png +0 -0
- rustmap/data/tunnel_tiles/template_058__intersection-bb1-w.png +0 -0
- rustmap/data/tunnel_tiles/template_059__intersection-bb4-w.png +0 -0
- rustmap/data/tunnel_tiles/template_060__intersection-b4-w.png +0 -0
- rustmap/data/tunnel_tiles/template_061__intersection-b6-w.png +0 -0
- rustmap/data/tunnel_tiles/template_062__intersection-bb2-w.png +0 -0
- rustmap/data/tunnel_tiles/template_063__intersection-b3-w.png +0 -0
- rustmap/data/tunnel_tiles/template_064__intersection-b2-w.png +0 -0
- rustmap/data/tunnel_tiles/template_065__intersection-bb6-w.png +0 -0
- rustmap/data/tunnel_tiles/template_066__curve-sw-0.png +0 -0
- rustmap/data/tunnel_tiles/template_067__curve-sw-1.png +0 -0
- rustmap/data/tunnel_tiles/template_068__intersection-bb3-e.png +0 -0
- rustmap/data/tunnel_tiles/template_069__intersection-bb6-e.png +0 -0
- rustmap/data/tunnel_tiles/template_070__intersection-b4-e.png +0 -0
- rustmap/data/tunnel_tiles/template_071__intersection-b6-e.png +0 -0
- rustmap/data/tunnel_tiles/template_072__intersection-b5-e.png +0 -0
- rustmap/data/tunnel_tiles/template_073__intersection-b1-e.png +0 -0
- rustmap/data/tunnel_tiles/template_074__intersection-b3-e.png +0 -0
- rustmap/data/tunnel_tiles/template_075__intersection-bb2-e.png +0 -0
- rustmap/data/tunnel_tiles/template_076__intersection-e.png +0 -0
- rustmap/data/tunnel_tiles/template_077__intersection-bb1-e.png +0 -0
- rustmap/data/tunnel_tiles/template_078__intersection-bb4-e.png +0 -0
- rustmap/data/tunnel_tiles/template_079__intersection-b2-e.png +0 -0
- rustmap/data/tunnel_tiles/template_080__intersection-bb5-e.png +0 -0
- rustmap/data/tunnel_tiles/tiles.json +997 -0
- rustmap/exporter.py +409 -0
- rustmap/layers.py +323 -0
- rustmap/monument_assets.py +145 -0
- rustmap/monuments.py +226 -0
- rustmap/no_build.py +288 -0
- rustmap/no_build_assets.py +303 -0
- rustmap/parser.py +297 -0
- rustmap/png.py +18 -0
- rustmap/populations.py +361 -0
- rustmap/prefabs.py +281 -0
- rustmap/renderer.py +460 -0
- rustmap/resources.py +26 -0
- rustmap/tunnel_assets.py +398 -0
- rustmap/tunnels.py +417 -0
- rustmap/validation.py +106 -0
rustmap/monuments.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Export placed gameplay monuments from a parsed Rust world map."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from importlib import resources
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .prefabs import PrefabManifest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
MONUMENT_PATH_PREFIX = "assets/bundled/prefabs/autospawn/monument/"
|
|
14
|
+
SIZE_CLASSES = {"tiny", "small", "medium", "large", "xlarge"}
|
|
15
|
+
GROUP_SIZE_CLASSES = {
|
|
16
|
+
"arctic_bases": "large",
|
|
17
|
+
"fishing_village": "small",
|
|
18
|
+
"harbor": "large",
|
|
19
|
+
"ice_lakes": "small",
|
|
20
|
+
"jungle_ruins": "small",
|
|
21
|
+
"lighthouse": "small",
|
|
22
|
+
"military_bases": "medium",
|
|
23
|
+
"roadside": "small",
|
|
24
|
+
"swamp": "small",
|
|
25
|
+
"underwater_lab": "large",
|
|
26
|
+
}
|
|
27
|
+
SAFE_ZONE_NAMES = {
|
|
28
|
+
"bandit_town", "compound", "fishing_village_a", "fishing_village_b",
|
|
29
|
+
"fishing_village_c", "stables_a", "stables_b",
|
|
30
|
+
}
|
|
31
|
+
DISPLAY_NAMES = {
|
|
32
|
+
"airfield_1": "Airfield", "apartments_complex_1": "Abandoned Military Base",
|
|
33
|
+
"arctic_research_base_a": "Arctic Research Base", "bandit_town": "Bandit Camp",
|
|
34
|
+
"compound": "Outpost", "excavator_1": "Giant Excavator Pit",
|
|
35
|
+
"ferry_terminal_1": "Ferry Terminal", "junkyard_1": "Junkyard",
|
|
36
|
+
"launch_site_1": "Launch Site", "military_tunnel_1": "Military Tunnels",
|
|
37
|
+
"nuclear_missile_silo": "Nuclear Missile Silo", "oilrig_1": "Small Oil Rig",
|
|
38
|
+
"oilrig_2": "Large Oil Rig", "powerplant_1": "Power Plant",
|
|
39
|
+
"radtown_1": "Sewer Branch", "radtown_small_3": "Mining Outpost",
|
|
40
|
+
"satellite_dish": "Satellite Dish Array", "sphere_tank": "The Dome",
|
|
41
|
+
"trainyard_1": "Train Yard", "underwater_lab_a": "Underwater Lab",
|
|
42
|
+
"water_treatment_plant_1": "Water Treatment Plant",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _humanize(name: str) -> str:
|
|
47
|
+
return name.replace("_", " ").strip().title()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _family(name: str) -> str:
|
|
51
|
+
for suffix in ("_easy", "_medium", "_hard", "_a", "_b", "_c", "_d", "_e", "_1", "_2", "_3", "_4"):
|
|
52
|
+
if name.endswith(suffix):
|
|
53
|
+
return name[:-len(suffix)]
|
|
54
|
+
return name
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _size_class(group: str, name: str) -> str:
|
|
58
|
+
if group in SIZE_CLASSES:
|
|
59
|
+
return group
|
|
60
|
+
if group == "cave":
|
|
61
|
+
for size in ("small", "medium", "large"):
|
|
62
|
+
if name.startswith(f"cave_{size}_"):
|
|
63
|
+
return size
|
|
64
|
+
if group == "offshore":
|
|
65
|
+
return "small" if name == "oilrig_1" else "large"
|
|
66
|
+
if group == "jungle_ruins" and "ziggurat" in name:
|
|
67
|
+
return "medium"
|
|
68
|
+
return GROUP_SIZE_CLASSES.get(group, "medium")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@lru_cache(maxsize=1)
|
|
72
|
+
def _gameplay_database() -> dict:
|
|
73
|
+
resource = resources.files("rustmap.data").joinpath("monument_metadata.json")
|
|
74
|
+
return json.loads(resource.read_text(encoding="utf-8"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def monument_metadata(path: str) -> dict:
|
|
78
|
+
"""Return deterministic path-derived metadata without inventing asset facts."""
|
|
79
|
+
relative = path.casefold()[len(MONUMENT_PATH_PREFIX):]
|
|
80
|
+
parts = relative.removesuffix(".prefab").split("/")
|
|
81
|
+
group, name = parts[0], parts[-1]
|
|
82
|
+
database = _gameplay_database()
|
|
83
|
+
extracted = database.get("prefabs", {}).get(path.casefold())
|
|
84
|
+
safe_zone = bool(extracted["safe_zone"]) if extracted is not None else name in SAFE_ZONE_NAMES
|
|
85
|
+
size_class = _size_class(group, name)
|
|
86
|
+
if group == "cave":
|
|
87
|
+
kind, environment = "cave", "underground"
|
|
88
|
+
elif group == "underwater_lab":
|
|
89
|
+
kind, environment = "underwater_monument", "underwater"
|
|
90
|
+
elif group == "offshore":
|
|
91
|
+
kind, environment = "offshore_monument", "offshore"
|
|
92
|
+
elif group in {"ice_lakes", "swamp"}:
|
|
93
|
+
kind, environment = "natural_feature", "surface"
|
|
94
|
+
elif group == "tiny" and name.startswith("water_well"):
|
|
95
|
+
kind, environment = "resource_location", "surface"
|
|
96
|
+
elif name.startswith("mining_quarry") or name == "excavator_1":
|
|
97
|
+
kind, environment = "resource_monument", "surface"
|
|
98
|
+
elif safe_zone:
|
|
99
|
+
kind, environment = "safe_zone", "surface"
|
|
100
|
+
elif group in {"harbor", "fishing_village", "lighthouse"}:
|
|
101
|
+
kind, environment = "coastal_monument", "coastal"
|
|
102
|
+
elif group == "roadside":
|
|
103
|
+
kind, environment = "roadside_monument", "surface"
|
|
104
|
+
else:
|
|
105
|
+
kind, environment = "monument", "surface"
|
|
106
|
+
|
|
107
|
+
tags = [kind, environment]
|
|
108
|
+
if safe_zone:
|
|
109
|
+
tags.append("safe_zone")
|
|
110
|
+
tags.append(f"size_{size_class}")
|
|
111
|
+
if extracted is None:
|
|
112
|
+
gameplay = {
|
|
113
|
+
"safe_zone": safe_zone,
|
|
114
|
+
"recycler_count": 0,
|
|
115
|
+
"keycard_requirements": [],
|
|
116
|
+
"puzzle_type": "none",
|
|
117
|
+
"loot_tier": 0,
|
|
118
|
+
}
|
|
119
|
+
else:
|
|
120
|
+
gameplay = {
|
|
121
|
+
"safe_zone": bool(extracted["safe_zone"]),
|
|
122
|
+
"recycler_count": int(extracted["recycler_count"]),
|
|
123
|
+
"keycard_requirements": list(extracted["keycard_requirements"]),
|
|
124
|
+
"puzzle_type": str(extracted["puzzle_type"]),
|
|
125
|
+
"loot_tier": int(extracted["loot_tier"]),
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
"display_name": DISPLAY_NAMES.get(name, _humanize(name)),
|
|
129
|
+
"family": _family(name),
|
|
130
|
+
"classification": {
|
|
131
|
+
"kind": kind,
|
|
132
|
+
"environment": environment,
|
|
133
|
+
"spawn_group": group,
|
|
134
|
+
"size_class": size_class,
|
|
135
|
+
},
|
|
136
|
+
"gameplay": gameplay,
|
|
137
|
+
"tags": sorted(set(tags)),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def build_monument_export(world, manifest: PrefabManifest) -> dict:
|
|
142
|
+
"""Return a deterministic JSON-compatible gameplay monument document."""
|
|
143
|
+
if world.size <= 0:
|
|
144
|
+
raise ValueError("World size must be positive to normalize monument positions")
|
|
145
|
+
|
|
146
|
+
candidates = []
|
|
147
|
+
for prefab in world.prefabs:
|
|
148
|
+
entry = manifest.get(prefab.prefab_id)
|
|
149
|
+
if entry is None or not entry.path.casefold().startswith(MONUMENT_PATH_PREFIX):
|
|
150
|
+
continue
|
|
151
|
+
if prefab.position is None:
|
|
152
|
+
continue
|
|
153
|
+
candidates.append((entry.path, prefab))
|
|
154
|
+
|
|
155
|
+
candidates.sort(key=lambda item: (
|
|
156
|
+
item[0].casefold(), float(item[1].position.x),
|
|
157
|
+
float(item[1].position.y), float(item[1].position.z),
|
|
158
|
+
int(item[1].prefab_id),
|
|
159
|
+
))
|
|
160
|
+
|
|
161
|
+
monuments = []
|
|
162
|
+
for path, prefab in candidates:
|
|
163
|
+
position = prefab.position
|
|
164
|
+
rotation = prefab.rotation
|
|
165
|
+
heading = None
|
|
166
|
+
if rotation is not None:
|
|
167
|
+
heading = round(float(rotation.y) % 360.0, 9)
|
|
168
|
+
map_x = round(float(position.x) + float(world.size) / 2.0, 9)
|
|
169
|
+
map_y = round(float(position.z) + float(world.size) / 2.0, 9)
|
|
170
|
+
monuments.append({
|
|
171
|
+
"name": Path(path).stem,
|
|
172
|
+
"prefab_path": path,
|
|
173
|
+
"map_category": prefab.category,
|
|
174
|
+
"position": {
|
|
175
|
+
"x": float(position.x), "y": float(position.y), "z": float(position.z),
|
|
176
|
+
},
|
|
177
|
+
"map_position": {"x": map_x, "y": map_y},
|
|
178
|
+
"heading_degrees": heading,
|
|
179
|
+
"metadata": monument_metadata(path),
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
"schema_version": 3,
|
|
184
|
+
"map": {
|
|
185
|
+
"serialization_version": int(world.serialization_version),
|
|
186
|
+
"timestamp": int(world.timestamp),
|
|
187
|
+
"world_size": int(world.size),
|
|
188
|
+
},
|
|
189
|
+
"coordinates": {
|
|
190
|
+
"world": "Unity world coordinates in metres: X east/west, Y elevation, Z north/south",
|
|
191
|
+
"map_position": "map metres from bottom-left: x = world_x + world_size/2; y = world_z + world_size/2",
|
|
192
|
+
"map_origin": "(0, 0) is the bottom-left of the playable map; positive X is right and positive Y is up",
|
|
193
|
+
"heading_degrees": "rotation Y normalized to [0, 360)",
|
|
194
|
+
},
|
|
195
|
+
"selection": {
|
|
196
|
+
"prefab_path_prefix": MONUMENT_PATH_PREFIX,
|
|
197
|
+
"excludes_tunnel_entrances": True,
|
|
198
|
+
"excludes_unique_environments": True,
|
|
199
|
+
},
|
|
200
|
+
"monument_count": len(monuments),
|
|
201
|
+
"unique_prefab_count": len({item["prefab_path"].casefold() for item in monuments}),
|
|
202
|
+
"metadata": {
|
|
203
|
+
"schema_version": 1,
|
|
204
|
+
"classification_source": "deterministic prefab-path rules",
|
|
205
|
+
"safe_zone_source": "curated prefab identities",
|
|
206
|
+
"component_fields": ["recycler_count", "keycard_requirements", "puzzle_type", "loot_tier"],
|
|
207
|
+
"component_fields_status": "populated from packaged Rust prefab-component extraction",
|
|
208
|
+
"component_database": {
|
|
209
|
+
"schema_version": _gameplay_database().get("schema_version"),
|
|
210
|
+
"source_rust_build_id": _gameplay_database().get("source", {}).get("rust_build_id"),
|
|
211
|
+
"prefab_count": _gameplay_database().get("prefab_count", 0),
|
|
212
|
+
"extraction": _gameplay_database().get("extraction", {}),
|
|
213
|
+
},
|
|
214
|
+
"enriched_instance_count": len(monuments),
|
|
215
|
+
},
|
|
216
|
+
"monuments": monuments,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def save_monuments(world, manifest_path: str | Path, output_path: str | Path) -> dict:
|
|
221
|
+
manifest = PrefabManifest.load(manifest_path)
|
|
222
|
+
payload = build_monument_export(world, manifest)
|
|
223
|
+
target = Path(output_path)
|
|
224
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
target.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8", newline="\n")
|
|
226
|
+
return payload
|
rustmap/no_build.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Render and export placed building-block exclusion zones."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import time
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from PIL import Image, ImageDraw
|
|
13
|
+
|
|
14
|
+
from .prefabs import PrefabManifest
|
|
15
|
+
from .png import save_png
|
|
16
|
+
from .tunnels import _instance_matrix
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
AUTOSPAWN_PREFIX = "assets/bundled/prefabs/autospawn/"
|
|
20
|
+
SURFACE_FAMILIES = frozenset(("monument", "power substations", "tunnel-entrance", "tunnel-upwards"))
|
|
21
|
+
ORIENTATION = "flip_vertical (reverse Z; preserve X left/right)"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _autospawn_family(path: str) -> str | None:
|
|
25
|
+
if not path.startswith(AUTOSPAWN_PREFIX):
|
|
26
|
+
return None
|
|
27
|
+
return path[len(AUTOSPAWN_PREFIX):].split("/",1)[0]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _round(value: float) -> float:
|
|
31
|
+
return round(float(value), 9)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _vector(values) -> dict:
|
|
35
|
+
return {"x": _round(values[0]), "y": _round(values[1]), "z": _round(values[2])}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _matrix_transform(matrix: np.ndarray, points: np.ndarray) -> np.ndarray:
|
|
39
|
+
homogeneous = np.column_stack((points, np.ones(len(points), dtype=np.float64)))
|
|
40
|
+
return (matrix @ homogeneous.T).T[:, :3]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _decompose(matrix: np.ndarray) -> dict:
|
|
44
|
+
scale = np.linalg.norm(matrix[:3, :3], axis=0)
|
|
45
|
+
rotation = matrix[:3, :3] / np.where(scale == 0.0, 1.0, scale)
|
|
46
|
+
sx = max(-1.0, min(1.0, -float(rotation[1, 2])))
|
|
47
|
+
x = math.asin(sx)
|
|
48
|
+
if abs(math.cos(x)) > 1e-7:
|
|
49
|
+
y = math.atan2(float(rotation[0, 2]), float(rotation[2, 2]))
|
|
50
|
+
z = math.atan2(float(rotation[1, 0]), float(rotation[1, 1]))
|
|
51
|
+
else:
|
|
52
|
+
y = math.atan2(-float(rotation[2, 0]), float(rotation[0, 0]))
|
|
53
|
+
z = 0.0
|
|
54
|
+
return {
|
|
55
|
+
"position": _vector(matrix[:3, 3]),
|
|
56
|
+
"rotation_euler": {
|
|
57
|
+
"x": _round(math.degrees(x)), "y": _round(math.degrees(y) % 360.0),
|
|
58
|
+
"z": _round(math.degrees(z)),
|
|
59
|
+
},
|
|
60
|
+
"scale": _vector(scale),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _draw_zones(zones: list[dict], resolution: int, fill, outline, width: int,
|
|
65
|
+
world_size: int | None = None) -> Image.Image:
|
|
66
|
+
supersample = 2
|
|
67
|
+
size = resolution * supersample
|
|
68
|
+
image = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
69
|
+
draw = ImageDraw.Draw(image, "RGBA")
|
|
70
|
+
for zone in zones:
|
|
71
|
+
transform, geometry = zone["transform"], zone["geometry"]
|
|
72
|
+
center = transform["image_position"]
|
|
73
|
+
cx, cy = center["x"] * size, center["y"] * size
|
|
74
|
+
pixels_per_metre = size / float(world_size or resolution)
|
|
75
|
+
if zone["shape"] == "circle":
|
|
76
|
+
radius = geometry["radius_m"] * pixels_per_metre
|
|
77
|
+
bounds = (cx-radius, cy-radius, cx+radius, cy+radius)
|
|
78
|
+
draw.ellipse(bounds, fill=tuple(fill), outline=tuple(outline) if width else None,
|
|
79
|
+
width=width * supersample)
|
|
80
|
+
continue
|
|
81
|
+
heading = math.radians(transform["heading_degrees"])
|
|
82
|
+
half_width = geometry["width_m"] * pixels_per_metre / 2.0
|
|
83
|
+
half_height = geometry["height_m"] * pixels_per_metre / 2.0
|
|
84
|
+
# Image Y reverses world Z. Unity positive yaw rotates +X toward -Z.
|
|
85
|
+
x_axis = (math.cos(heading), math.sin(heading))
|
|
86
|
+
z_axis = (math.sin(heading), -math.cos(heading))
|
|
87
|
+
points = [
|
|
88
|
+
(cx + sx*x_axis[0]*half_width + sz*z_axis[0]*half_height,
|
|
89
|
+
cy + sx*x_axis[1]*half_width + sz*z_axis[1]*half_height)
|
|
90
|
+
for sx, sz in ((-1,-1),(1,-1),(1,1),(-1,1))
|
|
91
|
+
]
|
|
92
|
+
draw.polygon(points, fill=tuple(fill))
|
|
93
|
+
if width:
|
|
94
|
+
draw.line(points + [points[0]], fill=tuple(outline),
|
|
95
|
+
width=width * supersample, joint="curve")
|
|
96
|
+
return image.resize((resolution, resolution), Image.Resampling.LANCZOS)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _load_packaged_data() -> tuple[dict, str]:
|
|
100
|
+
item = resources.files("rustmap.data").joinpath("no_build_zones.json")
|
|
101
|
+
return json.loads(item.read_text(encoding="utf-8")), "rustmap.data/no_build_zones.json"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build_no_build_export(world, manifest: PrefabManifest, data: dict,
|
|
105
|
+
resolution: int | None = None) -> tuple[dict, list[dict]]:
|
|
106
|
+
resolution = world.size if resolution is None else int(resolution)
|
|
107
|
+
definitions = {item["prefab_path"].casefold(): item for item in data.get("prefabs", [])}
|
|
108
|
+
candidates = []
|
|
109
|
+
for prefab in world.prefabs:
|
|
110
|
+
entry = manifest.get(prefab.prefab_id)
|
|
111
|
+
path = entry.path.casefold().replace("\\", "/") if entry else ""
|
|
112
|
+
family=_autospawn_family(path)
|
|
113
|
+
if family in SURFACE_FAMILIES and prefab.position is not None:
|
|
114
|
+
candidates.append((path, family, prefab))
|
|
115
|
+
candidates.sort(key=lambda item: (
|
|
116
|
+
item[0], float(item[2].position.x), float(item[2].position.y),
|
|
117
|
+
float(item[2].position.z), int(item[2].prefab_id),
|
|
118
|
+
))
|
|
119
|
+
|
|
120
|
+
zones, skipped, resolved_instances = [], [], 0
|
|
121
|
+
for owner_index, (path, family, prefab) in enumerate(candidates, start=1):
|
|
122
|
+
definition = definitions.get(path)
|
|
123
|
+
if definition is None or not definition.get("zones"):
|
|
124
|
+
skipped.append({"prefab_path": path, "family": family,
|
|
125
|
+
"reason": "missing_packaged_blocker_geometry"})
|
|
126
|
+
continue
|
|
127
|
+
resolved_instances += 1
|
|
128
|
+
instance = _instance_matrix(prefab)
|
|
129
|
+
for blocker_index, blocker in enumerate(definition["zones"], start=1):
|
|
130
|
+
local = np.asarray(blocker["local_matrix"], dtype=np.float64)
|
|
131
|
+
combined = instance @ local
|
|
132
|
+
center_local = blocker["center"]
|
|
133
|
+
center = _matrix_transform(combined, np.asarray([[
|
|
134
|
+
center_local["x"], center_local["y"], center_local["z"]
|
|
135
|
+
]], dtype=np.float64))[0]
|
|
136
|
+
decomposed = _decompose(combined)
|
|
137
|
+
decomposed["position"] = _vector(center)
|
|
138
|
+
norm_x = _round(center[0] / world.size + 0.5)
|
|
139
|
+
norm_z = _round(center[2] / world.size + 0.5)
|
|
140
|
+
decomposed["normalized_position"] = {"x": norm_x, "z": norm_z}
|
|
141
|
+
decomposed["image_position"] = {"x": norm_x, "y": _round(1.0 - norm_z)}
|
|
142
|
+
decomposed["heading_degrees"] = decomposed["rotation_euler"]["y"]
|
|
143
|
+
if blocker["shape"] == "circle":
|
|
144
|
+
radial_scale = math.hypot(float(combined[0, 0]), float(combined[2, 0]))
|
|
145
|
+
geometry = {"radius_m": _round(float(blocker["radius"]) * radial_scale)}
|
|
146
|
+
shape = "circle"
|
|
147
|
+
else:
|
|
148
|
+
width_scale = math.hypot(float(combined[0, 0]), float(combined[2, 0]))
|
|
149
|
+
height_scale = math.hypot(float(combined[0, 2]), float(combined[2, 2]))
|
|
150
|
+
geometry = {
|
|
151
|
+
"width_m": _round(float(blocker["size"]["x"]) * width_scale),
|
|
152
|
+
"height_m": _round(float(blocker["size"]["z"]) * height_scale),
|
|
153
|
+
}
|
|
154
|
+
shape = "rectangle"
|
|
155
|
+
local_y = blocker.get("local_y_bounds", [-0.5, 0.5])
|
|
156
|
+
y_points = _matrix_transform(instance, np.asarray([
|
|
157
|
+
[0.0, local_y[0], 0.0], [0.0, local_y[1], 0.0]
|
|
158
|
+
], dtype=np.float64))[:, 1]
|
|
159
|
+
zones.append({
|
|
160
|
+
"zone_id": "", "shape": shape,
|
|
161
|
+
"owner": {
|
|
162
|
+
"instance_id": f"owner-{owner_index:04d}", "family": family,
|
|
163
|
+
"prefab_id": int(prefab.prefab_id), "prefab_path": path,
|
|
164
|
+
},
|
|
165
|
+
"transform": decomposed,
|
|
166
|
+
"geometry": geometry,
|
|
167
|
+
"vertical_bounds": {
|
|
168
|
+
"minimum_y": _round(min(y_points)), "maximum_y": _round(max(y_points)),
|
|
169
|
+
},
|
|
170
|
+
"projected_area_m2": _round(blocker["projected_area_m2"]),
|
|
171
|
+
})
|
|
172
|
+
for index, zone in enumerate(zones, start=1):
|
|
173
|
+
zone["zone_id"] = f"no-build-{index:04d}"
|
|
174
|
+
|
|
175
|
+
source = data.get("source", {})
|
|
176
|
+
mismatch = bool(
|
|
177
|
+
source.get("content_bundle_size") is not None and (
|
|
178
|
+
int(source["content_bundle_size"]) != int(manifest.source_size or -1) or
|
|
179
|
+
int(source.get("content_bundle_mtime_ns", -1)) != int(manifest.source_mtime_ns or -1)
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
document = {
|
|
183
|
+
"schema_version": 4,
|
|
184
|
+
"status": "rendered",
|
|
185
|
+
"source": source,
|
|
186
|
+
"map": {
|
|
187
|
+
"serialization_version": int(world.serialization_version),
|
|
188
|
+
"timestamp": int(world.timestamp), "world_size": int(world.size),
|
|
189
|
+
},
|
|
190
|
+
"resolution": [resolution, resolution],
|
|
191
|
+
"coordinates": {
|
|
192
|
+
"world": "Unity metres: X east/west, Y elevation, Z north/south",
|
|
193
|
+
"normalized": "x = world_x / world_size + 0.5; z = world_z / world_size + 0.5",
|
|
194
|
+
"image": "x = normalized x; y = 1 - normalized z",
|
|
195
|
+
"orientation": ORIENTATION,
|
|
196
|
+
},
|
|
197
|
+
"selection": {
|
|
198
|
+
"restriction": "building_blocks", "deployable_only_excluded": True,
|
|
199
|
+
"strategy": "maximal_same_owner_containment",
|
|
200
|
+
"minimum_area_m2": None,
|
|
201
|
+
"allowed_shapes": ["circle", "rectangle"],
|
|
202
|
+
"included_surface_families": sorted(SURFACE_FAMILIES),
|
|
203
|
+
"missing_policy": "skip_with_warning",
|
|
204
|
+
},
|
|
205
|
+
"placed_owner_count": len(candidates),
|
|
206
|
+
"resolved_owner_count": resolved_instances,
|
|
207
|
+
"skipped_owner_count": len(skipped),
|
|
208
|
+
"unique_prefab_count": len({path for path, _, _ in candidates}),
|
|
209
|
+
"zone_count": len(zones),
|
|
210
|
+
"retained_projected_area_m2": _round(sum(zone["projected_area_m2"] for zone in zones)),
|
|
211
|
+
"excluded_definition_counts": data.get("excluded_definition_counts", {}),
|
|
212
|
+
"data_version_mismatch": mismatch,
|
|
213
|
+
"warnings": (["no_build_data_version_mismatch"] if mismatch else []) +
|
|
214
|
+
(["owners_skipped_without_blocker_geometry"] if skipped else []),
|
|
215
|
+
"skipped_owners": skipped,
|
|
216
|
+
"zones": zones,
|
|
217
|
+
}
|
|
218
|
+
return document, zones
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def save_no_build_zones(world, manifest_path: str | Path, output_dir: str | Path,
|
|
222
|
+
resolution: int | None = None,
|
|
223
|
+
fill_color=(255, 0, 0, 64), outline_color=(255, 0, 0, 255),
|
|
224
|
+
outline_width: int = 3,
|
|
225
|
+
terrain_image: str | Path | Image.Image | None = None,
|
|
226
|
+
export_images: bool = True,
|
|
227
|
+
export_json: bool = True) -> dict:
|
|
228
|
+
started = time.perf_counter()
|
|
229
|
+
output = Path(output_dir)
|
|
230
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
manifest = PrefabManifest.load(manifest_path)
|
|
232
|
+
data, data_path = _load_packaged_data()
|
|
233
|
+
document, zones = build_no_build_export(world, manifest, data, resolution)
|
|
234
|
+
image_path = output / "no_build_zones.png"
|
|
235
|
+
overlay_path = output / "no_build_zones_on_map.png"
|
|
236
|
+
json_path = output / "no_build_zones.json"
|
|
237
|
+
document["requested_outputs"] = {"images": export_images, "json": export_json}
|
|
238
|
+
document["packaged_data"] = data_path
|
|
239
|
+
document["style"] = ({
|
|
240
|
+
"fill_color": list(fill_color), "outline_color": list(outline_color),
|
|
241
|
+
"outline_width": outline_width,
|
|
242
|
+
} if export_images else None)
|
|
243
|
+
warnings = document["warnings"]
|
|
244
|
+
document["render_seconds"] = 0.0
|
|
245
|
+
document["image_file"] = None
|
|
246
|
+
document["overlay_file"] = None
|
|
247
|
+
if not export_images:
|
|
248
|
+
for stale in (image_path, overlay_path):
|
|
249
|
+
if stale.is_file():
|
|
250
|
+
stale.unlink()
|
|
251
|
+
else:
|
|
252
|
+
render_started = time.perf_counter()
|
|
253
|
+
image = _draw_zones(zones, document["resolution"][0], fill_color,
|
|
254
|
+
outline_color, outline_width, world.size)
|
|
255
|
+
document["render_seconds"] = time.perf_counter() - render_started
|
|
256
|
+
save_png(image, image_path)
|
|
257
|
+
document["image_file"] = image_path.name
|
|
258
|
+
if overlay_path.is_file():
|
|
259
|
+
overlay_path.unlink()
|
|
260
|
+
if isinstance(terrain_image, Image.Image):
|
|
261
|
+
base = terrain_image
|
|
262
|
+
else:
|
|
263
|
+
terrain = Path(terrain_image) if terrain_image is not None else None
|
|
264
|
+
if terrain is not None and terrain.is_file():
|
|
265
|
+
with Image.open(terrain) as source:
|
|
266
|
+
base = source.convert("RGBA")
|
|
267
|
+
else:
|
|
268
|
+
base = None
|
|
269
|
+
if base is not None:
|
|
270
|
+
if base.size == image.size:
|
|
271
|
+
save_png(Image.alpha_composite(base, image), overlay_path)
|
|
272
|
+
document["overlay_file"] = overlay_path.name
|
|
273
|
+
else:
|
|
274
|
+
warnings.append("terrain_resolution_mismatch_overlay_omitted")
|
|
275
|
+
else:
|
|
276
|
+
warnings.append("terrain_image_unavailable_overlay_omitted")
|
|
277
|
+
document["elapsed_seconds"] = time.perf_counter() - started
|
|
278
|
+
document["artifact_sizes_bytes"] = {}
|
|
279
|
+
if document["image_file"]:
|
|
280
|
+
document["artifact_sizes_bytes"][image_path.name] = image_path.stat().st_size
|
|
281
|
+
if overlay_path.is_file() and document["overlay_file"]:
|
|
282
|
+
document["artifact_sizes_bytes"][overlay_path.name] = overlay_path.stat().st_size
|
|
283
|
+
if export_json:
|
|
284
|
+
json_path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8", newline="\n")
|
|
285
|
+
document["artifact_sizes_bytes"][json_path.name] = json_path.stat().st_size
|
|
286
|
+
elif json_path.is_file():
|
|
287
|
+
json_path.unlink()
|
|
288
|
+
return document
|