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/renderer.py
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""Python reconstruction of Rust dedicated server MapImageRenderer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import time
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from PIL import Image
|
|
14
|
+
|
|
15
|
+
from .layers import int16_grid, splat_grid, topology_grid, world_height_grid
|
|
16
|
+
from .png import save_png
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from numba import njit
|
|
20
|
+
except ImportError: # pragma: no cover
|
|
21
|
+
def njit(*args, **kwargs):
|
|
22
|
+
def decorate(fn): return fn
|
|
23
|
+
return decorate
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
COLORS = {
|
|
27
|
+
"start": (0.28627452, 0.27058825, 0.24705884, 1.0),
|
|
28
|
+
"water": (0.16941601, 0.31755757, 0.36200002, 1.0),
|
|
29
|
+
"gravel": (0.25, 0.24342105, 0.22039475, 1.0),
|
|
30
|
+
"dirt": (0.6, 0.47959462, 0.33, 1.0),
|
|
31
|
+
"sand": (0.7, 0.65968585, 0.5277487, 1.0),
|
|
32
|
+
"grass": (0.35486364, 0.37, 0.2035, 1.0),
|
|
33
|
+
"forest": (0.24843751, 0.3, 0.0703125, 1.0),
|
|
34
|
+
"rock": (0.4, 0.39379844, 0.37519377, 1.0),
|
|
35
|
+
"snow": (0.86274517, 0.9294118, 0.94117653, 1.0),
|
|
36
|
+
"pebble": (0.13725491, 0.2784314, 0.2761563, 1.0),
|
|
37
|
+
"offshore": (0.04090196, 0.22060032, 0.27450982, 1.0),
|
|
38
|
+
}
|
|
39
|
+
SPLAT_ORDER = ((7, "gravel"), (6, "pebble"), (3, "rock"), (0, "dirt"),
|
|
40
|
+
(4, "grass"), (5, "forest"), (2, "sand"), (1, "snow"))
|
|
41
|
+
SUN = np.asarray((0.95, 2.87, 2.37), dtype=np.float32)
|
|
42
|
+
SUN /= np.linalg.norm(SUN)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@njit(cache=True)
|
|
46
|
+
def _signed_distance(bitmap: np.ndarray) -> np.ndarray:
|
|
47
|
+
size = bitmap.shape[0]
|
|
48
|
+
padded = size + 2
|
|
49
|
+
near_x = np.full((padded, padded), -1, np.int32)
|
|
50
|
+
near_y = np.full((padded, padded), -1, np.int32)
|
|
51
|
+
dist = np.full((padded, padded), np.inf, np.float32)
|
|
52
|
+
# Literal port of DistanceField.Generate/GenerateJob boundary seeding.
|
|
53
|
+
for y in range(1, size - 2):
|
|
54
|
+
for x in range(1, size - 2):
|
|
55
|
+
land = bitmap[y, x] > 127
|
|
56
|
+
if land and ((bitmap[y, x-1] > 127) != land or
|
|
57
|
+
(bitmap[y, x+1] > 127) != land or
|
|
58
|
+
(bitmap[y-1, x] > 127) != land or
|
|
59
|
+
(bitmap[y+1, x] > 127) != land):
|
|
60
|
+
py, px = y + 1, x + 1
|
|
61
|
+
near_x[py, px] = x + 1
|
|
62
|
+
near_y[py, px] = y + 1
|
|
63
|
+
dist[py, px] = 0.0
|
|
64
|
+
for y in range(1, padded - 1):
|
|
65
|
+
for x in range(1, padded - 1):
|
|
66
|
+
best = dist[y, x]
|
|
67
|
+
for dy, dx, step in ((-1,-1,1.4142135),(-1,0,1.0),(-1,1,1.4142135),(0,-1,1.0)):
|
|
68
|
+
yy, xx = y + dy, x + dx
|
|
69
|
+
if dist[yy, xx] + step < best:
|
|
70
|
+
nx, ny = near_x[yy, xx], near_y[yy, xx]
|
|
71
|
+
near_x[y, x], near_y[y, x] = nx, ny
|
|
72
|
+
best = math.sqrt(float((x-nx)*(x-nx) + (y-ny)*(y-ny)))
|
|
73
|
+
dist[y, x] = best
|
|
74
|
+
for y in range(padded - 2, 0, -1):
|
|
75
|
+
for x in range(padded - 2, 0, -1):
|
|
76
|
+
best = dist[y, x]
|
|
77
|
+
for dy, dx, step in ((0,1,1.0),(1,-1,1.4142135),(1,0,1.0),(1,1,1.0)):
|
|
78
|
+
yy, xx = y + dy, x + dx
|
|
79
|
+
if dist[yy, xx] + step < best:
|
|
80
|
+
nx, ny = near_x[yy, xx], near_y[yy, xx]
|
|
81
|
+
near_x[y, x], near_y[y, x] = nx, ny
|
|
82
|
+
best = math.sqrt(float((x-nx)*(x-nx) + (y-ny)*(y-ny)))
|
|
83
|
+
dist[y, x] = best
|
|
84
|
+
out = np.empty((size, size), np.float32)
|
|
85
|
+
for y in range(size):
|
|
86
|
+
for x in range(size):
|
|
87
|
+
value = dist[y+1, x+1]
|
|
88
|
+
out[y, x] = -value if bitmap[y, x] > 127 else value
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@njit(cache=True)
|
|
93
|
+
def _gaussian_once(values: np.ndarray) -> np.ndarray:
|
|
94
|
+
size = values.shape[0]
|
|
95
|
+
offsets = (-6, -4, -2, 0, 2, 4, 6)
|
|
96
|
+
weights = (0.03125, 0.109375, 0.21875, 0.28125, 0.21875, 0.109375, 0.03125)
|
|
97
|
+
temp = np.empty_like(values)
|
|
98
|
+
out = np.empty_like(values)
|
|
99
|
+
for y in range(size):
|
|
100
|
+
for x in range(size):
|
|
101
|
+
total = 0.0
|
|
102
|
+
for k in range(7):
|
|
103
|
+
sx = min(max(x + offsets[k], 0), size - 1)
|
|
104
|
+
total += values[y, sx] * weights[k]
|
|
105
|
+
temp[y, x] = total
|
|
106
|
+
for y in range(size):
|
|
107
|
+
for x in range(size):
|
|
108
|
+
total = 0.0
|
|
109
|
+
for k in range(7):
|
|
110
|
+
sy = min(max(y + offsets[k], 0), size - 1)
|
|
111
|
+
total += temp[sy, x] * weights[k]
|
|
112
|
+
out[y, x] = total
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _bilinear(grid: np.ndarray, xs: np.ndarray, ys: np.ndarray) -> np.ndarray:
|
|
117
|
+
last_y, last_x = grid.shape[-2] - 1, grid.shape[-1] - 1
|
|
118
|
+
px, py = xs * last_x, ys * last_y
|
|
119
|
+
x0raw, y0raw = np.trunc(px).astype(np.int64), np.trunc(py).astype(np.int64)
|
|
120
|
+
tx, ty = np.clip(px - x0raw, 0, 1).astype(np.float32), np.clip(py - y0raw, 0, 1).astype(np.float32)
|
|
121
|
+
x0, y0 = np.clip(x0raw, 0, last_x), np.clip(y0raw, 0, last_y)
|
|
122
|
+
x1, y1 = np.where(px < last_x, x0 + 1, x0), np.where(py < last_y, y0 + 1, y0)
|
|
123
|
+
x1, y1 = np.clip(x1, 0, last_x), np.clip(y1, 0, last_y)
|
|
124
|
+
a, b = grid[..., y0[:,None], x0[None,:]], grid[..., y0[:,None], x1[None,:]]
|
|
125
|
+
c, d = grid[..., y1[:,None], x0[None,:]], grid[..., y1[:,None], x1[None,:]]
|
|
126
|
+
top = a + (b-a) * tx[None,:]
|
|
127
|
+
bottom = c + (d-c) * tx[None,:]
|
|
128
|
+
return top + (bottom-top) * ty[:,None]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _normal_vertices(normalized_height: np.ndarray, xs: np.ndarray, ys: np.ndarray, norm_y: float) -> np.ndarray:
|
|
132
|
+
last = normalized_height.shape[0] - 1
|
|
133
|
+
xm, xp = np.clip(xs-1,0,last), np.clip(xs+1,0,last)
|
|
134
|
+
ym, yp = np.clip(ys-1,0,last), np.clip(ys+1,0,last)
|
|
135
|
+
dx = (normalized_height[np.ix_(ys, xp)] - normalized_height[np.ix_(ys, xm)]) * np.float32(0.5)
|
|
136
|
+
dz = (normalized_height[np.ix_(yp, xs)] - normalized_height[np.ix_(ym, xs)]) * np.float32(0.5)
|
|
137
|
+
n = np.stack((-dx, np.full_like(dx, norm_y), -dz), axis=-1)
|
|
138
|
+
return n / np.maximum(np.linalg.norm(n,axis=-1,keepdims=True), 1e-20)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _slerp(a: np.ndarray, b: np.ndarray, t: np.ndarray) -> np.ndarray:
|
|
142
|
+
t = np.clip(t, 0, 1)[...,None].astype(np.float32)
|
|
143
|
+
dot = np.clip(np.sum(a*b,axis=-1,keepdims=True), -1, 1)
|
|
144
|
+
theta = np.arccos(dot)
|
|
145
|
+
sin_theta = np.sin(theta)
|
|
146
|
+
linear = a + (b-a)*t
|
|
147
|
+
result = np.where(np.abs(sin_theta)>1e-6,
|
|
148
|
+
np.sin((1-t)*theta)/np.maximum(sin_theta,1e-20)*a + np.sin(t*theta)/np.maximum(sin_theta,1e-20)*b,
|
|
149
|
+
linear)
|
|
150
|
+
return result / np.maximum(np.linalg.norm(result,axis=-1,keepdims=True),1e-20)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _sample_normals(normalized_height: np.ndarray, xs: np.ndarray, ys: np.ndarray,
|
|
154
|
+
world_size: int, vertex_normals: np.ndarray | None = None) -> np.ndarray:
|
|
155
|
+
last = normalized_height.shape[0]-1
|
|
156
|
+
px, py = xs*last, ys*last
|
|
157
|
+
x0 = np.clip(np.trunc(px).astype(int),0,last); y0=np.clip(np.trunc(py).astype(int),0,last)
|
|
158
|
+
x1=np.minimum(x0+1,last); y1=np.minimum(y0+1,last)
|
|
159
|
+
tx=np.clip(px-x0,0,1).astype(np.float32); ty=np.clip(py-y0,0,1).astype(np.float32)
|
|
160
|
+
if vertex_normals is None:
|
|
161
|
+
norm_y=np.float32(world_size/1000.0/normalized_height.shape[0])
|
|
162
|
+
n00=_normal_vertices(normalized_height,x0,y0,norm_y); n10=_normal_vertices(normalized_height,x1,y0,norm_y)
|
|
163
|
+
n01=_normal_vertices(normalized_height,x0,y1,norm_y); n11=_normal_vertices(normalized_height,x1,y1,norm_y)
|
|
164
|
+
top=_slerp(n00,n10,np.broadcast_to(tx,(len(ys),len(xs))))
|
|
165
|
+
bottom=_slerp(n01,n11,np.broadcast_to(tx,(len(ys),len(xs))))
|
|
166
|
+
else:
|
|
167
|
+
# Upscaled renders map multiple output rows onto the same height-grid
|
|
168
|
+
# row. Horizontally interpolate each unique source row once, then
|
|
169
|
+
# gather it for y0/y1. Each retained interpolation uses the exact same
|
|
170
|
+
# operands and operation order as the former per-output-row version.
|
|
171
|
+
unique_y, inverse = np.unique(np.concatenate((y0, y1)), return_inverse=True)
|
|
172
|
+
left=vertex_normals[unique_y[:,None],x0[None,:]]
|
|
173
|
+
right=vertex_normals[unique_y[:,None],x1[None,:]]
|
|
174
|
+
horizontal=_slerp(left,right,np.broadcast_to(tx,(len(unique_y),len(xs))))
|
|
175
|
+
top=horizontal[inverse[:len(y0)]]
|
|
176
|
+
bottom=horizontal[inverse[len(y0):]]
|
|
177
|
+
return _slerp(top,bottom,np.broadcast_to(ty[:,None],(len(ys),len(xs))))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _apply_ocean_level(water: np.ndarray, topology: np.ndarray,
|
|
181
|
+
ocean_level: float = 0.0) -> np.ndarray:
|
|
182
|
+
ocean=(topology & np.uint32(384)) != 0
|
|
183
|
+
return np.where((water < ocean_level) & ocean,
|
|
184
|
+
np.maximum(water, ocean_level), water)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _shore_distance(world, shore_size: int = 2048) -> np.ndarray:
|
|
188
|
+
coords=(np.arange(shore_size,dtype=np.float32)+0.5)/shore_size
|
|
189
|
+
terrain=_bilinear(world_height_grid(world),coords,coords)
|
|
190
|
+
water=_bilinear(world_height_grid(world,"water"),coords,coords)
|
|
191
|
+
# WaterLevel.GetWaterLevels only raises serialized negative water heights to
|
|
192
|
+
# the global ocean level where Ocean/Oceanside topology is present. Clamping
|
|
193
|
+
# every sample to zero creates fake lakes in dry below-sea-level terrain
|
|
194
|
+
# such as canyons and the Giant Excavator pit.
|
|
195
|
+
topology=topology_grid(world)
|
|
196
|
+
ix=np.clip(np.trunc(coords*topology.shape[1]).astype(np.int64),0,topology.shape[1]-1)
|
|
197
|
+
iy=np.clip(np.trunc(coords*topology.shape[0]).astype(np.int64),0,topology.shape[0]-1)
|
|
198
|
+
water=_apply_ocean_level(water,topology[np.ix_(iy,ix)])
|
|
199
|
+
bitmap=np.where(np.maximum(water-terrain,0)<=0,255,0).astype(np.uint8)
|
|
200
|
+
return _gaussian_once(_signed_distance(bitmap))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass
|
|
204
|
+
class MapRenderResult:
|
|
205
|
+
image: Image.Image
|
|
206
|
+
width: int
|
|
207
|
+
height: int
|
|
208
|
+
background: tuple[int,int,int]
|
|
209
|
+
timings: dict[str,float]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass
|
|
213
|
+
class MapRenderInputs:
|
|
214
|
+
splat: np.ndarray
|
|
215
|
+
topology: np.ndarray
|
|
216
|
+
normalized_height: np.ndarray
|
|
217
|
+
heights: np.ndarray
|
|
218
|
+
shore: np.ndarray
|
|
219
|
+
vertex_normals: np.ndarray
|
|
220
|
+
prepare_seconds: float
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def save_full_map_tiles(image: Image.Image, output_dir: str | Path,
|
|
224
|
+
tile_size: int = 512) -> dict:
|
|
225
|
+
"""Split a full map image into bottom-left-indexed, padded RGBA PNG tiles."""
|
|
226
|
+
if tile_size <= 0:
|
|
227
|
+
raise ValueError("tile_size must be positive")
|
|
228
|
+
started = time.perf_counter()
|
|
229
|
+
output = Path(output_dir)
|
|
230
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
width, height = image.size
|
|
232
|
+
columns = math.ceil(width / tile_size)
|
|
233
|
+
rows = math.ceil(height / tile_size)
|
|
234
|
+
specifications = []
|
|
235
|
+
expected_files = set()
|
|
236
|
+
for tile_y in range(rows):
|
|
237
|
+
map_bottom = tile_y * tile_size
|
|
238
|
+
content_height = min(tile_size, height - map_bottom)
|
|
239
|
+
pil_top = height - map_bottom - content_height
|
|
240
|
+
pil_bottom = height - map_bottom
|
|
241
|
+
for tile_x in range(columns):
|
|
242
|
+
map_left = tile_x * tile_size
|
|
243
|
+
content_width = min(tile_size, width - map_left)
|
|
244
|
+
filename = f"x_{tile_x}_y_{tile_y}.png"
|
|
245
|
+
expected_files.add(filename)
|
|
246
|
+
specifications.append({
|
|
247
|
+
"x": tile_x, "y": tile_y, "filename": filename,
|
|
248
|
+
"content_width": content_width, "content_height": content_height,
|
|
249
|
+
"map_bounds": {
|
|
250
|
+
"left": map_left, "bottom": map_bottom,
|
|
251
|
+
"right": map_left + content_width,
|
|
252
|
+
"top": map_bottom + content_height,
|
|
253
|
+
},
|
|
254
|
+
"image_content_offset": {"x": 0, "y": tile_size - content_height},
|
|
255
|
+
"crop_box": (map_left, pil_top, map_left + content_width, pil_bottom),
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
def write_tile(specification: dict) -> int:
|
|
259
|
+
content = image.crop(specification["crop_box"]).convert("RGBA")
|
|
260
|
+
tile = Image.new("RGBA", (tile_size, tile_size), (0, 0, 0, 0))
|
|
261
|
+
offset = specification["image_content_offset"]
|
|
262
|
+
tile.paste(content, (offset["x"], offset["y"]))
|
|
263
|
+
path = output / specification["filename"]
|
|
264
|
+
save_png(tile, path)
|
|
265
|
+
return path.stat().st_size
|
|
266
|
+
|
|
267
|
+
encode_started = time.perf_counter()
|
|
268
|
+
with ThreadPoolExecutor(max_workers=4) as pool:
|
|
269
|
+
sizes = list(pool.map(write_tile, specifications))
|
|
270
|
+
encode_seconds = time.perf_counter() - encode_started
|
|
271
|
+
for old_tile in output.glob("x_*_y_*.png"):
|
|
272
|
+
if old_tile.name not in expected_files:
|
|
273
|
+
old_tile.unlink()
|
|
274
|
+
|
|
275
|
+
tiles = []
|
|
276
|
+
for specification, size in zip(specifications, sizes):
|
|
277
|
+
item = {key: value for key, value in specification.items() if key != "crop_box"}
|
|
278
|
+
item["size_bytes"] = size
|
|
279
|
+
tiles.append(item)
|
|
280
|
+
metadata = {
|
|
281
|
+
"schema_version": 1,
|
|
282
|
+
"map_width": width,
|
|
283
|
+
"map_height": height,
|
|
284
|
+
"tile_size": tile_size,
|
|
285
|
+
"columns": columns,
|
|
286
|
+
"rows": rows,
|
|
287
|
+
"tile_count": len(tiles),
|
|
288
|
+
"coordinate_system": {
|
|
289
|
+
"origin": "bottom-left",
|
|
290
|
+
"x_axis": "right",
|
|
291
|
+
"y_axis": "up",
|
|
292
|
+
"units": "map pixels (one pixel per world metre for the full-size render)",
|
|
293
|
+
},
|
|
294
|
+
"padding": {"mode": "transparent", "rgba": [0, 0, 0, 0]},
|
|
295
|
+
"filename_pattern": "x_{x}_y_{y}.png",
|
|
296
|
+
"tiles": tiles,
|
|
297
|
+
"timings": {"png_encode_seconds": encode_seconds,
|
|
298
|
+
"total_seconds": time.perf_counter() - started},
|
|
299
|
+
}
|
|
300
|
+
metadata_path = output / "tiles.json"
|
|
301
|
+
metadata["metadata_file"] = metadata_path.name
|
|
302
|
+
metadata["directory_size_bytes"] = sum(sizes)
|
|
303
|
+
for _ in range(4):
|
|
304
|
+
rendered = json.dumps(metadata, indent=2) + "\n"
|
|
305
|
+
total_size = sum(sizes) + len(rendered.encode("utf-8"))
|
|
306
|
+
if metadata["directory_size_bytes"] == total_size:
|
|
307
|
+
break
|
|
308
|
+
metadata["directory_size_bytes"] = total_size
|
|
309
|
+
metadata_path.write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8", newline="\n")
|
|
310
|
+
return metadata
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _prepare_render_inputs(world, normal_chunk_rows: int = 256) -> MapRenderInputs:
|
|
314
|
+
started = time.perf_counter()
|
|
315
|
+
splat = splat_grid(world).astype(np.float32) / 255.0
|
|
316
|
+
topology = topology_grid(world)
|
|
317
|
+
raw_height = int16_grid(world, "height")
|
|
318
|
+
normalized_height = raw_height.astype(np.float32) / np.float32(32766.0)
|
|
319
|
+
heights = world_height_grid(world)
|
|
320
|
+
shore = _shore_distance(world)
|
|
321
|
+
|
|
322
|
+
size = normalized_height.shape[0]
|
|
323
|
+
coordinates = np.arange(size, dtype=np.int64)
|
|
324
|
+
norm_y = np.float32(world.size / 1000.0 / size)
|
|
325
|
+
vertex_normals = np.empty((size, size, 3), dtype=np.float32)
|
|
326
|
+
for row0 in range(0, size, normal_chunk_rows):
|
|
327
|
+
row1 = min(row0 + normal_chunk_rows, size)
|
|
328
|
+
ys = coordinates[row0:row1]
|
|
329
|
+
vertex_normals[row0:row1] = _normal_vertices(
|
|
330
|
+
normalized_height, coordinates, ys, norm_y
|
|
331
|
+
)
|
|
332
|
+
return MapRenderInputs(
|
|
333
|
+
splat, topology, normalized_height, heights, shore, vertex_normals,
|
|
334
|
+
time.perf_counter() - started,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def render_map_image(world, scale: float = 0.5, ocean_margin: int = 0,
|
|
339
|
+
transparent: bool = False, chunk_rows: int = 128,
|
|
340
|
+
prepared: MapRenderInputs | None = None) -> MapRenderResult:
|
|
341
|
+
started=time.perf_counter(); scale=float(np.clip(scale,0.1,4.0))
|
|
342
|
+
map_res=int(world.size*scale)
|
|
343
|
+
if map_res<=0 or ocean_margin<0: raise ValueError("Invalid map render dimensions")
|
|
344
|
+
width=height=map_res+ocean_margin*2
|
|
345
|
+
if prepared is None:
|
|
346
|
+
prepared = _prepare_render_inputs(world)
|
|
347
|
+
prepare_seconds = prepared.prepare_seconds
|
|
348
|
+
else:
|
|
349
|
+
prepare_seconds = 0.0
|
|
350
|
+
splat=prepared.splat; topo=prepared.topology
|
|
351
|
+
normalized_height=prepared.normalized_height; heights=prepared.heights
|
|
352
|
+
shore=prepared.shore
|
|
353
|
+
timings={"shore_seconds":prepare_seconds}
|
|
354
|
+
channels=4 if transparent else 3
|
|
355
|
+
pixels=np.empty((height,width,channels),np.uint8)
|
|
356
|
+
xs=(np.arange(width,dtype=np.float32)-ocean_margin)/map_res
|
|
357
|
+
for row0 in range(0,height,chunk_rows):
|
|
358
|
+
row1=min(row0+chunk_rows,height)
|
|
359
|
+
# PIL rows are top-down; Unity's SetPixels array is bottom-up.
|
|
360
|
+
ys=(height-1-np.arange(row0,row1,dtype=np.float32)-ocean_margin)/map_res
|
|
361
|
+
color=np.broadcast_to(np.asarray(COLORS["start"],np.float32),(len(ys),width,4)).copy()
|
|
362
|
+
sampled_splat=_bilinear(splat,xs,ys)
|
|
363
|
+
for channel,name in SPLAT_ORDER:
|
|
364
|
+
target=np.asarray(COLORS[name],np.float32)
|
|
365
|
+
t=sampled_splat[channel][...,None]*target[3]
|
|
366
|
+
color += (target-color)*t
|
|
367
|
+
height_values=_bilinear(heights,xs,ys)
|
|
368
|
+
normals=_sample_normals(normalized_height,xs,ys,world.size,prepared.vertex_normals)
|
|
369
|
+
sun=np.maximum(np.sum(normals*SUN,axis=-1),0)
|
|
370
|
+
ix=np.clip(np.trunc(xs*topo.shape[1]).astype(int),0,topo.shape[1]-1)
|
|
371
|
+
iy=np.clip(np.trunc(ys*topo.shape[0]).astype(int),0,topo.shape[0]-1)
|
|
372
|
+
ocean=(topo[np.ix_(iy,ix)] & np.uint32(384))!=0
|
|
373
|
+
shore_dist=_bilinear(shore,xs,ys)*(world.size/shore.shape[0])
|
|
374
|
+
depth=np.zeros_like(height_values,dtype=np.float32)
|
|
375
|
+
wet=shore_dist>0
|
|
376
|
+
depth[wet]=-height_values[wet]
|
|
377
|
+
replace=(depth<=0)|(~ocean)
|
|
378
|
+
depth[wet & replace]=np.maximum(depth[wet & replace],0.1*shore_dist[wet & replace])
|
|
379
|
+
water_mask=depth>0
|
|
380
|
+
if np.any(water_mask):
|
|
381
|
+
wc=np.asarray(COLORS["water"],np.float32); oc=np.asarray(COLORS["offshore"],np.float32)
|
|
382
|
+
if transparent: wc[3]=0.5; oc[:]=0
|
|
383
|
+
t1=np.clip(0.5+depth/5,0,1)[...,None]
|
|
384
|
+
t2=np.clip(depth/(max(abs(float(_bilinear(heights,np.asarray([0],np.float32),np.asarray([0],np.float32))[0,0])),5) if transparent else 50),0,1)[...,None]
|
|
385
|
+
water_color=color+(wc-color)*t1
|
|
386
|
+
water_color=water_color+(oc-water_color)*t2
|
|
387
|
+
color=np.where(water_mask[...,None],water_color,color)
|
|
388
|
+
land=~water_mask
|
|
389
|
+
color[land] += ((sun[land]-0.5)*0.65)[:,None]*color[land]
|
|
390
|
+
color[land]=(color[land]-0.5)*0.94+0.5
|
|
391
|
+
color*=1.05
|
|
392
|
+
color=np.clip(color,0,1)
|
|
393
|
+
converted=np.rint(color*255).astype(np.uint8)
|
|
394
|
+
pixels[row0:row1]=converted[...,:channels]
|
|
395
|
+
image=Image.fromarray(pixels,"RGBA" if transparent else "RGB")
|
|
396
|
+
timings["total_seconds"]=time.perf_counter()-started
|
|
397
|
+
return MapRenderResult(image,width,height,tuple(int(x) for x in pixels[0,0,:3]),timings)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def save_map_render(world, output_dir: str|Path, scale: float=0.5, ocean_margin: int=0,
|
|
401
|
+
formats=("png","jpg"), debug: bool=False,
|
|
402
|
+
full_size_png: bool=True, full_size_tiles: bool=False,
|
|
403
|
+
tile_size: int=512) -> dict:
|
|
404
|
+
if full_size_tiles and not full_size_png:
|
|
405
|
+
raise ValueError("full_size_tiles requires full_size_png=True")
|
|
406
|
+
if tile_size <= 0:
|
|
407
|
+
raise ValueError("tile_size must be positive")
|
|
408
|
+
output=Path(output_dir); output.mkdir(parents=True,exist_ok=True)
|
|
409
|
+
prepared = _prepare_render_inputs(world)
|
|
410
|
+
# The native render supersedes the convenience scaled render. This keeps
|
|
411
|
+
# full-size exports from doing a second expensive terrain pass merely
|
|
412
|
+
# because TerrainOptions.formats retains its friendly defaults.
|
|
413
|
+
scaled_requested = bool(formats) and not full_size_png
|
|
414
|
+
if scaled_requested:
|
|
415
|
+
result=render_map_image(world,scale,ocean_margin,prepared=prepared)
|
|
416
|
+
else:
|
|
417
|
+
result = None
|
|
418
|
+
full_only_result = (
|
|
419
|
+
render_map_image(world, scale=1.0, ocean_margin=0, prepared=prepared)
|
|
420
|
+
if full_size_png else None
|
|
421
|
+
)
|
|
422
|
+
timing_owner = result if result is not None else full_only_result
|
|
423
|
+
timing_owner.timings["prepare_seconds"] = prepared.prepare_seconds
|
|
424
|
+
artifacts={}
|
|
425
|
+
if scaled_requested and "png" in formats:
|
|
426
|
+
p=output/"map_render.png"; save_png(result.image,p); artifacts[p.name]=p.stat().st_size
|
|
427
|
+
elif (output/"map_render.png").is_file():
|
|
428
|
+
(output/"map_render.png").unlink()
|
|
429
|
+
if scaled_requested and ("jpg" in formats or "jpeg" in formats):
|
|
430
|
+
p=output/"map_render.jpg"; result.image.convert("RGB").save(p,"JPEG",quality=85); artifacts[p.name]=p.stat().st_size
|
|
431
|
+
elif (output/"map_render.jpg").is_file():
|
|
432
|
+
(output/"map_render.jpg").unlink()
|
|
433
|
+
full_size = None
|
|
434
|
+
tile_metadata = None
|
|
435
|
+
if full_size_png:
|
|
436
|
+
full_result = full_only_result
|
|
437
|
+
p=output/"map_render_full.png"
|
|
438
|
+
save_png(full_result.image,p)
|
|
439
|
+
artifacts[p.name]=p.stat().st_size
|
|
440
|
+
full_size={"width":full_result.width,"height":full_result.height,"scale":1.0,
|
|
441
|
+
"ocean_margin":0,"timings":full_result.timings,"artifact":p.name}
|
|
442
|
+
if full_size_tiles:
|
|
443
|
+
tile_metadata = save_full_map_tiles(
|
|
444
|
+
full_result.image, output / "map_render_tiles", tile_size
|
|
445
|
+
)
|
|
446
|
+
artifacts["map_render_tiles-total"] = tile_metadata["directory_size_bytes"]
|
|
447
|
+
background_owner = result if result is not None else full_result
|
|
448
|
+
metadata={"schema_version":1,"width":result.width if result else None,
|
|
449
|
+
"height":result.height if result else None,
|
|
450
|
+
"scale":float(np.clip(scale,0.1,4)) if result else None,
|
|
451
|
+
"ocean_margin":ocean_margin if result else None,
|
|
452
|
+
"transparent":False,"background_rgb":list(background_owner.background),
|
|
453
|
+
"constants":{"colors":COLORS,"splat_order":[name for _,name in SPLAT_ORDER],"sun_direction":SUN.tolist(),
|
|
454
|
+
"sun_power":0.65,"brightness":1.05,"contrast":0.94,"max_depth":50.0,
|
|
455
|
+
"ocean_water_level":0.0,"ocean_topology_mask":384},
|
|
456
|
+
"timings":result.timings if result else {},"full_size_render":full_size,
|
|
457
|
+
"full_size_tiles":tile_metadata,
|
|
458
|
+
"artifacts":artifacts,"validation":{"server_reference":"not_run"}}
|
|
459
|
+
p=output/"map_render_metadata.json"; p.write_text(json.dumps(metadata,indent=2)+"\n",encoding="utf-8",newline="\n")
|
|
460
|
+
return metadata
|
rustmap/resources.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""APIs for refreshing version-specific Rust data resources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .populations import extract_spawn_rules, save_rule_database
|
|
8
|
+
from .prefabs import extract_game_manifest, find_content_bundle
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def refresh_prefab_manifest(rust_install: str | Path, output_path: str | Path):
|
|
12
|
+
"""Extract and save the installed Rust GameManifest."""
|
|
13
|
+
manifest = extract_game_manifest(find_content_bundle(rust_install))
|
|
14
|
+
manifest.save(output_path)
|
|
15
|
+
return manifest
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def refresh_spawn_rules(rust_install: str | Path, output_path: str | Path) -> dict:
|
|
19
|
+
"""Extract and save population rules from an installed Rust build."""
|
|
20
|
+
root = Path(rust_install)
|
|
21
|
+
database = extract_spawn_rules(
|
|
22
|
+
root / "Bundles" / "shared" / "content.bundle",
|
|
23
|
+
root / "Bundles" / "maps" / "maps.bundle",
|
|
24
|
+
)
|
|
25
|
+
save_rule_database(database, output_path)
|
|
26
|
+
return database
|