litematica-render 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.
- litematic/__init__.py +34 -0
- litematic/parse/__init__.py +1 -0
- litematic/parse/errors.py +13 -0
- litematic/parse/model.py +225 -0
- litematic/parse/nbt.py +167 -0
- litematic/parse/parser.py +317 -0
- litematic/render/__init__.py +134 -0
- litematic/render/errors.py +9 -0
- litematic/render/pack/__init__.py +7 -0
- litematic/render/pack/blockstate.py +111 -0
- litematic/render/pack/compile.py +423 -0
- litematic/render/pack/elements.py +421 -0
- litematic/render/pack/entities.py +181 -0
- litematic/render/pack/extract.py +243 -0
- litematic/render/pack/loader.py +186 -0
- litematic/render/pack/resolve.py +344 -0
- litematic/render/pack/stack.py +83 -0
- litematic/render/pack/texture.py +347 -0
- litematic/render/png.py +54 -0
- litematic/render/quads.py +106 -0
- litematic/render/scene/__init__.py +6 -0
- litematic/render/scene/isometric.py +579 -0
- litematic/render/scene/painter.py +395 -0
- litematic/render/shapes/__init__.py +6 -0
- litematic/render/shapes/boxes.py +290 -0
- litematic/render/shapes/families.py +357 -0
- litematica_render-0.1.0.dist-info/METADATA +52 -0
- litematica_render-0.1.0.dist-info/RECORD +30 -0
- litematica_render-0.1.0.dist-info/WHEEL +4 -0
- litematica_render-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Per-state block resolution: pack quads, builtin shapes, texture backfill."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Iterable, Mapping
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from litematic.parse.model import BlockState
|
|
10
|
+
from litematic.render.errors import RenderError
|
|
11
|
+
from litematic.render.pack.blockstate import (
|
|
12
|
+
_normalize_state_key,
|
|
13
|
+
_state_pairs,
|
|
14
|
+
_when_matches,
|
|
15
|
+
)
|
|
16
|
+
from litematic.render.quads import QUAD_DTYPE, _redstone_tint, unit_cube_quads
|
|
17
|
+
from litematic.render.shapes import builtin_quads
|
|
18
|
+
|
|
19
|
+
__all__ = ["MISS_NO_GEOMETRY", "MISS_NO_TEXTURE", "Resolution", "Resolver"]
|
|
20
|
+
|
|
21
|
+
MISS_NO_GEOMETRY = "no-geometry"
|
|
22
|
+
MISS_NO_TEXTURE = "no-texture"
|
|
23
|
+
|
|
24
|
+
_MAGENTA_TEXKEY = "__magenta__"
|
|
25
|
+
_MAGENTA_ARRAY = np.full((16, 16, 4), (255, 0, 255, 255), np.uint8)
|
|
26
|
+
_MAGENTA_ARRAY.flags.writeable = False # painter must never mutate in place
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Resolution(NamedTuple):
|
|
30
|
+
"""One state's drawable outcome; quads are always paintable."""
|
|
31
|
+
|
|
32
|
+
quads: np.ndarray # (Q,) QUAD_DTYPE; magenta cube when the chain misses
|
|
33
|
+
occludes: bool
|
|
34
|
+
miss: str | None # None, MISS_NO_GEOMETRY, or MISS_NO_TEXTURE
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Resolver:
|
|
38
|
+
"""Resolve palette states to paintable quads; the constructor is a test seam."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
version: str,
|
|
43
|
+
arrays: dict[str, np.ndarray],
|
|
44
|
+
quads_map: dict[str, np.ndarray],
|
|
45
|
+
occl_map: dict[str, bool],
|
|
46
|
+
pack_mtime: float | tuple[float, ...],
|
|
47
|
+
states: dict[str, dict[str, dict[str, str]]] | None,
|
|
48
|
+
parts_map: dict[str, str],
|
|
49
|
+
mquad_map: dict[str, np.ndarray],
|
|
50
|
+
) -> None:
|
|
51
|
+
self.version = version
|
|
52
|
+
self.arrays = arrays
|
|
53
|
+
self.quads_map = quads_map
|
|
54
|
+
self.occl_map = occl_map
|
|
55
|
+
self.pack_mtime = pack_mtime
|
|
56
|
+
self.states = states
|
|
57
|
+
self.parts_map = parts_map
|
|
58
|
+
self.mquad_map = mquad_map
|
|
59
|
+
|
|
60
|
+
def resolve(self, state: BlockState) -> Resolution:
|
|
61
|
+
"""Run the chain: pack quads, builtin shapes, backfill, magenta."""
|
|
62
|
+
name = state.name
|
|
63
|
+
properties = state.properties
|
|
64
|
+
quads = self._pack_quads(name, properties)
|
|
65
|
+
if quads is not None:
|
|
66
|
+
return Resolution(quads, self._occludes(name, properties), None)
|
|
67
|
+
miss = MISS_NO_GEOMETRY
|
|
68
|
+
for props in (properties, {}):
|
|
69
|
+
built = builtin_quads(name, props)
|
|
70
|
+
if built is None:
|
|
71
|
+
continue
|
|
72
|
+
miss = MISS_NO_TEXTURE
|
|
73
|
+
if _backfill_builtin(name, built, self.arrays):
|
|
74
|
+
return Resolution(
|
|
75
|
+
built, self._occludes(name, properties), None
|
|
76
|
+
)
|
|
77
|
+
break
|
|
78
|
+
return Resolution(unit_cube_quads(_MAGENTA_TEXKEY), False, miss)
|
|
79
|
+
|
|
80
|
+
def texkey_arrays(self, keys: Iterable[str]) -> dict[str, np.ndarray]:
|
|
81
|
+
"""Array per texkey, magenta included; unknown keys are an error."""
|
|
82
|
+
out: dict[str, np.ndarray] = {}
|
|
83
|
+
for key in keys:
|
|
84
|
+
if key == _MAGENTA_TEXKEY:
|
|
85
|
+
out[key] = _MAGENTA_ARRAY
|
|
86
|
+
elif key in self.arrays:
|
|
87
|
+
out[key] = self.arrays[key]
|
|
88
|
+
else:
|
|
89
|
+
raise RenderError(
|
|
90
|
+
f"resolve: quad texkey missing from pack arrays: {key}"
|
|
91
|
+
)
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
def _pack_quads(
|
|
95
|
+
self, name: str, properties: Mapping[str, str]
|
|
96
|
+
) -> np.ndarray | None:
|
|
97
|
+
"""Return the state's QUAD_DTYPE quads, [] key as fallback."""
|
|
98
|
+
for state_key in self._matching_states(name, properties):
|
|
99
|
+
entry = self.quads_map.get(f"{name}[{state_key}]")
|
|
100
|
+
if entry is not None:
|
|
101
|
+
return entry
|
|
102
|
+
record = self.parts_map.get(name)
|
|
103
|
+
if record is not None:
|
|
104
|
+
merged = self._evaluated_parts(name, record, properties)
|
|
105
|
+
if merged is not None:
|
|
106
|
+
return merged
|
|
107
|
+
return self.quads_map.get(f"{name}[]")
|
|
108
|
+
|
|
109
|
+
def _evaluated_parts(
|
|
110
|
+
self,
|
|
111
|
+
name: str,
|
|
112
|
+
record: str,
|
|
113
|
+
properties: Mapping[str, str],
|
|
114
|
+
) -> np.ndarray | None:
|
|
115
|
+
"""Concatenate the parts whose condition the live state satisfies."""
|
|
116
|
+
selected = [
|
|
117
|
+
self.mquad_map[part["quads"]]
|
|
118
|
+
for part in json.loads(record)["parts"]
|
|
119
|
+
if part["when"] is None
|
|
120
|
+
or _when_matches(part["when"], properties)
|
|
121
|
+
]
|
|
122
|
+
if not selected:
|
|
123
|
+
return None
|
|
124
|
+
merged = (
|
|
125
|
+
np.concatenate(selected) if len(selected) > 1 else selected[0]
|
|
126
|
+
)
|
|
127
|
+
if name.partition(":")[2] == "redstone_wire":
|
|
128
|
+
merged = _power_tinted(merged, properties, self.arrays)
|
|
129
|
+
return merged
|
|
130
|
+
|
|
131
|
+
def _occludes(
|
|
132
|
+
self, name: str, properties: Mapping[str, str]
|
|
133
|
+
) -> bool:
|
|
134
|
+
"""Return the state's opaque-cube flag; missing entries are False."""
|
|
135
|
+
for state_key in self._matching_states(name, properties):
|
|
136
|
+
flag = self.occl_map.get(f"{name}[{state_key}]")
|
|
137
|
+
if flag is not None:
|
|
138
|
+
return flag
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
def _matching_states(
|
|
142
|
+
self, name: str, properties: Mapping[str, str]
|
|
143
|
+
) -> list[str]:
|
|
144
|
+
"""State keys satisfied by the properties, most specific first."""
|
|
145
|
+
if self.states is None:
|
|
146
|
+
return [_state_key_of(properties), ""]
|
|
147
|
+
found = [
|
|
148
|
+
(-len(pairs), key)
|
|
149
|
+
for key, pairs in self.states.get(name, {}).items()
|
|
150
|
+
if all(
|
|
151
|
+
properties.get(prop) == value for prop, value in pairs.items()
|
|
152
|
+
)
|
|
153
|
+
]
|
|
154
|
+
return [key for _, key in sorted(found)]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _power_tinted(
|
|
158
|
+
quads: np.ndarray,
|
|
159
|
+
properties: Mapping[str, str],
|
|
160
|
+
arrays: dict[str, np.ndarray],
|
|
161
|
+
) -> np.ndarray:
|
|
162
|
+
"""Rewrite wire part texkeys to the live power's dust tint."""
|
|
163
|
+
try:
|
|
164
|
+
power = max(0, min(15, int(properties.get("power", "0"))))
|
|
165
|
+
except (TypeError, ValueError):
|
|
166
|
+
power = 0
|
|
167
|
+
r, g, b = _redstone_tint(power)
|
|
168
|
+
out = quads.copy()
|
|
169
|
+
keys = []
|
|
170
|
+
for entry in out["texkey"]:
|
|
171
|
+
base = str(entry).partition("@")[0]
|
|
172
|
+
candidate = f"{base}@{r},{g},{b}"
|
|
173
|
+
keys.append(candidate if candidate in arrays else str(entry))
|
|
174
|
+
out["texkey"] = np.asarray(keys, dtype=QUAD_DTYPE["texkey"])
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _state_key_of(properties: Mapping[str, str]) -> str:
|
|
179
|
+
joined = ",".join(f"{key}={value}" for key, value in properties.items())
|
|
180
|
+
return _normalize_state_key(joined)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _state_index(
|
|
184
|
+
quads_map: dict[str, np.ndarray], occl_map: dict[str, bool]
|
|
185
|
+
) -> dict[str, dict[str, dict[str, str]]]:
|
|
186
|
+
"""Map block name to {state key: required property pairs}."""
|
|
187
|
+
index: dict[str, dict[str, dict[str, str]]] = {}
|
|
188
|
+
for full_key in (*quads_map, *occl_map):
|
|
189
|
+
block, _, state = str(full_key).partition("[")
|
|
190
|
+
if not state.endswith("]"):
|
|
191
|
+
continue
|
|
192
|
+
index.setdefault(block, {})[state[:-1]] = _state_pairs(state[:-1])
|
|
193
|
+
return index
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# Base-material conventions: longest suffix first, woods to planks, dyes to wool.
|
|
197
|
+
_FAMILY_SUFFIXES: tuple[str, ...] = (
|
|
198
|
+
"_pressure_plate",
|
|
199
|
+
"_fence_gate",
|
|
200
|
+
"_wall_banner",
|
|
201
|
+
"_wall_sign",
|
|
202
|
+
"_trapdoor",
|
|
203
|
+
"_button",
|
|
204
|
+
"_stairs",
|
|
205
|
+
"_fence",
|
|
206
|
+
"_carpet",
|
|
207
|
+
"_slab",
|
|
208
|
+
"_wall",
|
|
209
|
+
"_sign",
|
|
210
|
+
"_banner",
|
|
211
|
+
"_door",
|
|
212
|
+
)
|
|
213
|
+
_WOODS = frozenset(
|
|
214
|
+
{
|
|
215
|
+
"oak",
|
|
216
|
+
"spruce",
|
|
217
|
+
"birch",
|
|
218
|
+
"jungle",
|
|
219
|
+
"acacia",
|
|
220
|
+
"dark_oak",
|
|
221
|
+
"mangrove",
|
|
222
|
+
"cherry",
|
|
223
|
+
"bamboo",
|
|
224
|
+
"crimson",
|
|
225
|
+
"warped",
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
_DYES = frozenset(
|
|
229
|
+
{
|
|
230
|
+
"white",
|
|
231
|
+
"orange",
|
|
232
|
+
"magenta",
|
|
233
|
+
"light_blue",
|
|
234
|
+
"yellow",
|
|
235
|
+
"lime",
|
|
236
|
+
"pink",
|
|
237
|
+
"gray",
|
|
238
|
+
"light_gray",
|
|
239
|
+
"cyan",
|
|
240
|
+
"purple",
|
|
241
|
+
"blue",
|
|
242
|
+
"brown",
|
|
243
|
+
"green",
|
|
244
|
+
"red",
|
|
245
|
+
"black",
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
_TINTS: dict[str, tuple[int, int, int]] = {
|
|
250
|
+
"grass": (145, 189, 89),
|
|
251
|
+
"foliage": (119, 171, 47),
|
|
252
|
+
"water": (63, 118, 228),
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _tint_for(block_id: str, state_key: str) -> tuple[int, int, int] | None:
|
|
257
|
+
path = block_id.split(":", 1)[-1]
|
|
258
|
+
if path == "water" or path == "bubble_column":
|
|
259
|
+
return _TINTS["water"]
|
|
260
|
+
if path == "redstone_wire":
|
|
261
|
+
# Empty state key = multipart part: live power rewrites texkeys later.
|
|
262
|
+
if not state_key:
|
|
263
|
+
return None
|
|
264
|
+
return _redstone_state_tint(state_key)
|
|
265
|
+
if "leaves" in path or path == "vine":
|
|
266
|
+
return _TINTS["foliage"]
|
|
267
|
+
if "grass" in path or "fern" in path:
|
|
268
|
+
return _TINTS["grass"]
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _redstone_state_tint(state_key: str) -> tuple[int, int, int]:
|
|
273
|
+
power = 0
|
|
274
|
+
for pair in state_key.split(","):
|
|
275
|
+
if pair.startswith("power="):
|
|
276
|
+
try:
|
|
277
|
+
power = max(0, min(15, int(pair[len("power=") :])))
|
|
278
|
+
except ValueError:
|
|
279
|
+
power = 0
|
|
280
|
+
return _redstone_tint(power)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _backfill_builtin(
|
|
284
|
+
name: str, quads: np.ndarray, arrays: dict[str, np.ndarray]
|
|
285
|
+
) -> bool:
|
|
286
|
+
"""Fill builtin quads' texkeys from direct, then material, keys."""
|
|
287
|
+
if np.all(quads["texkey"] != ""):
|
|
288
|
+
return all(str(key) in arrays for key in quads["texkey"])
|
|
289
|
+
ns, _, stem = name.partition(":")
|
|
290
|
+
prefix = "" if ns in ("", "minecraft") else f"{ns}:"
|
|
291
|
+
base = f"{prefix}block/{stem or name}"
|
|
292
|
+
tint = _tint_for(name, "")
|
|
293
|
+
direct = [base]
|
|
294
|
+
if tint is not None:
|
|
295
|
+
direct.insert(0, f"{base}@{tint[0]},{tint[1]},{tint[2]}")
|
|
296
|
+
for key in direct:
|
|
297
|
+
if key in arrays:
|
|
298
|
+
quads["texkey"] = key
|
|
299
|
+
return True
|
|
300
|
+
door = _door_texkeys(prefix, stem)
|
|
301
|
+
if door is not None and all(key in arrays for key in door):
|
|
302
|
+
quads["texkey"] = np.where(
|
|
303
|
+
quads["vtx"][..., 1].mean(axis=1) >= 8.0, door[0], door[1]
|
|
304
|
+
)
|
|
305
|
+
return True
|
|
306
|
+
for key in _material_keys(prefix, stem):
|
|
307
|
+
if key in arrays:
|
|
308
|
+
quads["texkey"] = key
|
|
309
|
+
return True
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _door_texkeys(prefix: str, stem: str) -> tuple[str, str] | None:
|
|
314
|
+
"""(top, bottom) texture keys for a vanilla door stem, else None."""
|
|
315
|
+
if not stem.endswith("_door"):
|
|
316
|
+
return None
|
|
317
|
+
material = stem[: -len("_door")]
|
|
318
|
+
return (
|
|
319
|
+
f"{prefix}block/{material}_door_top",
|
|
320
|
+
f"{prefix}block/{material}_door_bottom",
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _material_keys(prefix: str, stem: str) -> tuple[str, ...]:
|
|
325
|
+
"""Ordered base-material texture keys per vanilla naming rules."""
|
|
326
|
+
if stem.endswith("_pane"):
|
|
327
|
+
# Vanilla panes texture from <stem>_top, then the bare material.
|
|
328
|
+
material = stem[: -len("_pane")]
|
|
329
|
+
return (
|
|
330
|
+
f"{prefix}block/{stem}_top",
|
|
331
|
+
f"{prefix}block/{material}",
|
|
332
|
+
f"{prefix}block/{material}s",
|
|
333
|
+
)
|
|
334
|
+
for suffix in _FAMILY_SUFFIXES:
|
|
335
|
+
if stem.endswith(suffix):
|
|
336
|
+
material = stem[: -len(suffix)]
|
|
337
|
+
break
|
|
338
|
+
else:
|
|
339
|
+
return ()
|
|
340
|
+
if material in _WOODS:
|
|
341
|
+
return (f"{prefix}block/{material}_planks",)
|
|
342
|
+
if suffix == "_carpet" and material in _DYES:
|
|
343
|
+
return (f"{prefix}block/{material}_wool",)
|
|
344
|
+
return (f"{prefix}block/{material}", f"{prefix}block/{material}s")
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Pack stack: ordered zip packs, lookups scan top-down with first hit winning."""
|
|
2
|
+
|
|
3
|
+
import zipfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Sequence
|
|
6
|
+
|
|
7
|
+
from litematic.render.errors import RenderError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PackStack:
|
|
11
|
+
"""Zip packs in lookup order: index 0 is checked first, the base is last."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self, packs: list[tuple[Path, zipfile.ZipFile, set[str]]]
|
|
15
|
+
) -> None:
|
|
16
|
+
self._packs = packs
|
|
17
|
+
self._names: set[str] = set()
|
|
18
|
+
for _, _, names in packs:
|
|
19
|
+
self._names |= names
|
|
20
|
+
|
|
21
|
+
def names(self) -> set[str]:
|
|
22
|
+
"""Union of entry names across every pack."""
|
|
23
|
+
return self._names
|
|
24
|
+
|
|
25
|
+
def exists(self, path: str) -> bool:
|
|
26
|
+
return path in self._names
|
|
27
|
+
|
|
28
|
+
def read(self, path: str) -> bytes | None:
|
|
29
|
+
found = self.read_ranked(path)
|
|
30
|
+
return None if found is None else found[0]
|
|
31
|
+
|
|
32
|
+
def read_ranked(self, path: str) -> tuple[bytes, int] | None:
|
|
33
|
+
"""First pack holding path; returns (bytes, pack index)."""
|
|
34
|
+
for index in range(len(self._packs)):
|
|
35
|
+
_, archive, names = self._packs[index]
|
|
36
|
+
if path in names:
|
|
37
|
+
return archive.read(path), index
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
def mcmeta(self, path: str, pack_index: int) -> bytes | None:
|
|
41
|
+
"""Texture metadata from the top down to the texture's pack, inclusive."""
|
|
42
|
+
meta = f"{path}.mcmeta"
|
|
43
|
+
for index in range(pack_index + 1):
|
|
44
|
+
_, archive, names = self._packs[index]
|
|
45
|
+
if meta in names:
|
|
46
|
+
return archive.read(meta)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def close(self) -> None:
|
|
50
|
+
for _, archive, _ in self._packs:
|
|
51
|
+
archive.close()
|
|
52
|
+
|
|
53
|
+
def __enter__(self) -> "PackStack":
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def __exit__(self, *exc: object) -> None:
|
|
57
|
+
self.close()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def open_stack(sources: Sequence[str | Path]) -> PackStack:
|
|
61
|
+
"""Open every zip; sources are [base, *add-ons] in user priority order."""
|
|
62
|
+
opened: list[tuple[Path, zipfile.ZipFile, set[str]]] = []
|
|
63
|
+
try:
|
|
64
|
+
for position, source in enumerate(sources):
|
|
65
|
+
path = Path(source)
|
|
66
|
+
try:
|
|
67
|
+
archive = zipfile.ZipFile(path)
|
|
68
|
+
except (zipfile.BadZipFile, OSError) as exc:
|
|
69
|
+
raise RenderError(
|
|
70
|
+
f"extract: not a resource pack: {path}: {exc}"
|
|
71
|
+
) from exc
|
|
72
|
+
names = set(archive.namelist())
|
|
73
|
+
opened.append((path, archive, names))
|
|
74
|
+
if position and not any(name.startswith("assets/") for name in names):
|
|
75
|
+
raise RenderError(
|
|
76
|
+
f"extract: not a resource pack: {path}: no assets/ entries"
|
|
77
|
+
)
|
|
78
|
+
except BaseException:
|
|
79
|
+
for _, archive, _ in opened:
|
|
80
|
+
archive.close()
|
|
81
|
+
raise
|
|
82
|
+
# Lookup order: earlier add-on wins, the base is always checked last.
|
|
83
|
+
return PackStack([*opened[1:], opened[0]])
|