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,317 @@
|
|
|
1
|
+
"""Litematica palette-index bit unpacking and file-tree parsing."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from types import MappingProxyType
|
|
5
|
+
from typing import Callable, Mapping, Optional
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from litematic.parse.errors import LitematicError, LitematicFormatError
|
|
10
|
+
from litematic.parse.model import (
|
|
11
|
+
BlockEntity,
|
|
12
|
+
BlockState,
|
|
13
|
+
Entity,
|
|
14
|
+
Litematic,
|
|
15
|
+
LitematicMetadata,
|
|
16
|
+
Region,
|
|
17
|
+
Vec3i,
|
|
18
|
+
)
|
|
19
|
+
from litematic.parse.nbt import read_nbt
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _bits_for(palette_size: int) -> int:
|
|
23
|
+
"""Bit width per entry: enough for palette_size values, at least 2."""
|
|
24
|
+
return max(2, (palette_size - 1).bit_length())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _unpack_indices(
|
|
28
|
+
packed: np.ndarray,
|
|
29
|
+
bits: int,
|
|
30
|
+
size: Vec3i,
|
|
31
|
+
start_entry: int = 0,
|
|
32
|
+
stop_entry: int | None = None,
|
|
33
|
+
) -> np.ndarray:
|
|
34
|
+
"""Unpack litematica's continuous bit stream into an (x, y, z) index array.
|
|
35
|
+
|
|
36
|
+
Entry i starts at bit i*bits of the long array, filling each long upward
|
|
37
|
+
from its low bit; an entry straddling a boundary continues into the low
|
|
38
|
+
bits of the next long. File order is
|
|
39
|
+
YZX: index = (y*lenZ + z)*lenX + x. Palettes wider than 16 bits need
|
|
40
|
+
uint32 output. Entries are processed in fixed-size chunks so transient
|
|
41
|
+
memory stays a small multiple of the output array regardless of size.
|
|
42
|
+
Pass [start_entry, stop_entry) to unpack a Y-rows range only; the range
|
|
43
|
+
is a contiguous entry span, so no state beyond the offsets is needed.
|
|
44
|
+
"""
|
|
45
|
+
count = size.x * size.y * size.z
|
|
46
|
+
if stop_entry is None:
|
|
47
|
+
stop_entry = count
|
|
48
|
+
span = stop_entry - start_entry
|
|
49
|
+
words = packed.view(np.uint64) # logical shifts; int64 storage is untouched
|
|
50
|
+
dtype = np.uint16 if bits <= 16 else np.uint32
|
|
51
|
+
out = np.empty(span, dtype=dtype)
|
|
52
|
+
mask = np.uint64((1 << bits) - 1)
|
|
53
|
+
chunk = 1 << 18
|
|
54
|
+
for base in range(start_entry, stop_entry, chunk):
|
|
55
|
+
stop = min(base + chunk, stop_entry)
|
|
56
|
+
positions = np.arange(base, stop, dtype=np.uint64) * np.uint64(bits)
|
|
57
|
+
long_index = positions >> np.uint64(6)
|
|
58
|
+
bit_offset = positions & np.uint64(63)
|
|
59
|
+
values = words[long_index] >> bit_offset
|
|
60
|
+
spill = bit_offset.astype(np.int64) + bits - 64
|
|
61
|
+
crossing = np.nonzero(spill > 0)[0]
|
|
62
|
+
if crossing.size:
|
|
63
|
+
values[crossing] |= words[long_index[crossing] + 1] << (
|
|
64
|
+
np.uint64(64) - bit_offset[crossing]
|
|
65
|
+
)
|
|
66
|
+
values &= mask
|
|
67
|
+
out[base - start_entry : stop - start_entry] = values
|
|
68
|
+
rows = span // (size.z * size.x)
|
|
69
|
+
return out.reshape(rows, size.z, size.x).transpose(2, 0, 1)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _vec3(compound: Mapping, field: str, source: str) -> Vec3i:
|
|
73
|
+
try:
|
|
74
|
+
c = compound[field]
|
|
75
|
+
return Vec3i(int(c["x"]), int(c["y"]), int(c["z"]))
|
|
76
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
77
|
+
raise LitematicFormatError(f"{source}: missing or malformed {field}") from exc
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _string(value: object, what: str, where: str) -> str:
|
|
81
|
+
if not isinstance(value, str):
|
|
82
|
+
raise LitematicFormatError(f"{where}: {what} is not a string")
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse_metadata(root: Mapping, source: str) -> LitematicMetadata:
|
|
87
|
+
try:
|
|
88
|
+
meta = root.get("Metadata") or {}
|
|
89
|
+
if not isinstance(meta, dict):
|
|
90
|
+
raise TypeError("Metadata is not a compound")
|
|
91
|
+
where = f"{source}: Metadata"
|
|
92
|
+
size = meta.get("EnclosingSize")
|
|
93
|
+
if size is None:
|
|
94
|
+
enclosing = Vec3i(0, 0, 0)
|
|
95
|
+
else:
|
|
96
|
+
enclosing = Vec3i(int(size["x"]), int(size["y"]), int(size["z"]))
|
|
97
|
+
return LitematicMetadata(
|
|
98
|
+
name=_string(meta.get("Name", ""), "Name", where),
|
|
99
|
+
author=_string(meta.get("Author", ""), "Author", where),
|
|
100
|
+
description=_string(meta.get("Description", ""), "Description", where),
|
|
101
|
+
enclosing_size=enclosing,
|
|
102
|
+
total_blocks=int(meta.get("TotalBlocks", 0)),
|
|
103
|
+
total_volume=int(meta.get("TotalVolume", 0)),
|
|
104
|
+
time_created=int(meta.get("TimeCreated", 0)),
|
|
105
|
+
time_modified=int(meta.get("TimeModified", 0)),
|
|
106
|
+
MinecraftDataVersion=int(root.get("MinecraftDataVersion", 0)),
|
|
107
|
+
LitematicVersion=int(root.get("Version",0))
|
|
108
|
+
)
|
|
109
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
110
|
+
raise LitematicFormatError(f"{source}: malformed Metadata: {exc}") from exc
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _parse_palette(entries: object, source: str) -> list[BlockState]:
|
|
114
|
+
if not isinstance(entries, list) or not entries:
|
|
115
|
+
raise LitematicFormatError(f"{source}: BlockStatePalette is missing or empty")
|
|
116
|
+
palette = []
|
|
117
|
+
try:
|
|
118
|
+
for entry in entries:
|
|
119
|
+
props = entry.get("Properties") or {}
|
|
120
|
+
palette.append(
|
|
121
|
+
BlockState(_string(entry["Name"], "Name", source), dict(props))
|
|
122
|
+
)
|
|
123
|
+
except (AttributeError, KeyError, TypeError, ValueError) as exc:
|
|
124
|
+
raise LitematicFormatError(f"{source}: malformed palette entry: {exc}") from exc
|
|
125
|
+
return palette
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _make_unpacker(
|
|
129
|
+
packed: np.ndarray,
|
|
130
|
+
bits: int,
|
|
131
|
+
size: Vec3i,
|
|
132
|
+
source: str,
|
|
133
|
+
palette_size: int,
|
|
134
|
+
) -> Callable[[], np.ndarray]:
|
|
135
|
+
def unpack() -> np.ndarray:
|
|
136
|
+
arr = _unpack_indices(packed, bits, size)
|
|
137
|
+
if arr.size and int(arr.max()) >= palette_size:
|
|
138
|
+
raise LitematicFormatError(
|
|
139
|
+
f"{source}: block index {int(arr.max())} out of range "
|
|
140
|
+
f"for palette of {palette_size}"
|
|
141
|
+
)
|
|
142
|
+
return arr
|
|
143
|
+
|
|
144
|
+
return unpack
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _make_slab_unpacker(
|
|
148
|
+
packed: np.ndarray,
|
|
149
|
+
bits: int,
|
|
150
|
+
size: Vec3i,
|
|
151
|
+
source: str,
|
|
152
|
+
palette_size: int,
|
|
153
|
+
) -> Callable[[int, int], np.ndarray]:
|
|
154
|
+
def unpack_slab(y0: int, y1: int) -> np.ndarray:
|
|
155
|
+
span = (y1 - y0) * size.z * size.x
|
|
156
|
+
arr = _unpack_indices(
|
|
157
|
+
packed, bits, size, y0 * size.z * size.x,
|
|
158
|
+
y0 * size.z * size.x + span,
|
|
159
|
+
)
|
|
160
|
+
if arr.size and int(arr.max()) >= palette_size:
|
|
161
|
+
raise LitematicFormatError(
|
|
162
|
+
f"{source}: block index {int(arr.max())} out of range "
|
|
163
|
+
f"for palette of {palette_size} in rows [{y0}, {y1})"
|
|
164
|
+
)
|
|
165
|
+
return arr
|
|
166
|
+
|
|
167
|
+
return unpack_slab
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _parse_pending_ticks(
|
|
171
|
+
entries: object, field: str, where: str
|
|
172
|
+
) -> Optional[tuple[Mapping[str, object], ...]]:
|
|
173
|
+
"""Scheduled-tick compounds ({x, y, z, Time, Priority, Block|Fluid, SubTick})."""
|
|
174
|
+
if entries is None:
|
|
175
|
+
return None
|
|
176
|
+
if not isinstance(entries, list):
|
|
177
|
+
raise LitematicFormatError(f"{where}: {field} is not a list")
|
|
178
|
+
ticks = []
|
|
179
|
+
for entry in entries:
|
|
180
|
+
if not isinstance(entry, dict):
|
|
181
|
+
raise LitematicFormatError(f"{where}: {field} entry is not a compound")
|
|
182
|
+
ticks.append(MappingProxyType(dict(entry)))
|
|
183
|
+
return tuple(ticks)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _parse_block_entities(entries: object, where: str) -> tuple[BlockEntity, ...]:
|
|
187
|
+
if not isinstance(entries, list):
|
|
188
|
+
raise LitematicFormatError(f"{where}: TileEntities is not a list")
|
|
189
|
+
out = []
|
|
190
|
+
for entry in entries:
|
|
191
|
+
try:
|
|
192
|
+
out.append(
|
|
193
|
+
BlockEntity(
|
|
194
|
+
pos=(int(entry["x"]), int(entry["y"]), int(entry["z"])),
|
|
195
|
+
id=_string(entry.get("id", ""), "id", where),
|
|
196
|
+
data=MappingProxyType(dict(entry)),
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
except (AttributeError, KeyError, TypeError, ValueError) as exc:
|
|
200
|
+
raise LitematicFormatError(
|
|
201
|
+
f"{where}: malformed tile entity: {exc}"
|
|
202
|
+
) from exc
|
|
203
|
+
return tuple(out)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _parse_entities(entries: object, where: str) -> tuple[Entity, ...]:
|
|
207
|
+
if not isinstance(entries, list):
|
|
208
|
+
raise LitematicFormatError(f"{where}: Entities is not a list")
|
|
209
|
+
out = []
|
|
210
|
+
for entry in entries:
|
|
211
|
+
try:
|
|
212
|
+
pos = entry["Pos"]
|
|
213
|
+
out.append(
|
|
214
|
+
Entity(
|
|
215
|
+
id=_string(entry.get("id", ""), "id", where),
|
|
216
|
+
pos=(float(pos[0]), float(pos[1]), float(pos[2])),
|
|
217
|
+
data=MappingProxyType(dict(entry)),
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
except (
|
|
221
|
+
AttributeError,
|
|
222
|
+
KeyError,
|
|
223
|
+
TypeError,
|
|
224
|
+
ValueError,
|
|
225
|
+
IndexError,
|
|
226
|
+
) as exc:
|
|
227
|
+
raise LitematicFormatError(f"{where}: malformed entity: {exc}") from exc
|
|
228
|
+
return tuple(out)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _parse_region(name: str, region: Mapping, source: str) -> Region:
|
|
232
|
+
where = f"{source}, region '{name}'"
|
|
233
|
+
try:
|
|
234
|
+
palette = _parse_palette(region["BlockStatePalette"], where)
|
|
235
|
+
packed = region["BlockStates"]
|
|
236
|
+
except KeyError as exc:
|
|
237
|
+
raise LitematicFormatError(f"{where}: missing field {exc}") from exc
|
|
238
|
+
pos_raw = _vec3(region, "Position", where)
|
|
239
|
+
size_raw = _vec3(region, "Size", where)
|
|
240
|
+
size = Vec3i(*(abs(s) for s in size_raw))
|
|
241
|
+
position = Vec3i(
|
|
242
|
+
*(p + (s if s < 0 else 0) for p, s in zip(pos_raw, size_raw))
|
|
243
|
+
)
|
|
244
|
+
if not isinstance(packed, np.ndarray) or packed.dtype != np.int64:
|
|
245
|
+
raise LitematicFormatError(f"{where}: BlockStates is not a long array")
|
|
246
|
+
bits = _bits_for(len(palette))
|
|
247
|
+
volume = size.x * size.y * size.z
|
|
248
|
+
expected_longs = (volume * bits + 63) // 64
|
|
249
|
+
if packed.size != expected_longs:
|
|
250
|
+
raise LitematicFormatError(
|
|
251
|
+
f"{where}: BlockStates has {packed.size} longs, "
|
|
252
|
+
f"expected {expected_longs} ({volume} blocks at {bits} bits)"
|
|
253
|
+
)
|
|
254
|
+
block_entities = _parse_block_entities(region.get("TileEntities", []), where)
|
|
255
|
+
entities = _parse_entities(region.get("Entities", []), where)
|
|
256
|
+
pending_block_ticks = _parse_pending_ticks(
|
|
257
|
+
region.get("PendingBlockTicks"), "PendingBlockTicks", where
|
|
258
|
+
)
|
|
259
|
+
pending_fluid_ticks = _parse_pending_ticks(
|
|
260
|
+
region.get("PendingFluidTicks"), "PendingFluidTicks", where
|
|
261
|
+
)
|
|
262
|
+
return Region(
|
|
263
|
+
name=name,
|
|
264
|
+
position=position,
|
|
265
|
+
size=size,
|
|
266
|
+
palette=palette,
|
|
267
|
+
block_entities=block_entities,
|
|
268
|
+
entities=entities,
|
|
269
|
+
pending_block_ticks=pending_block_ticks,
|
|
270
|
+
pending_fluid_ticks=pending_fluid_ticks,
|
|
271
|
+
packed_indices=packed,
|
|
272
|
+
_unpack=_make_unpacker(packed, bits, size, where, len(palette)),
|
|
273
|
+
_unpack_slab=_make_slab_unpacker(
|
|
274
|
+
packed, bits, size, where, len(palette)
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def parse_schematic(root: dict, source_name: str = "") -> Litematic:
|
|
280
|
+
"""Build a Litematic model from an already-parsed NBT root compound."""
|
|
281
|
+
regions = root.get("Regions")
|
|
282
|
+
if not isinstance(regions, dict) or not regions:
|
|
283
|
+
raise LitematicFormatError(f"{source_name}: no Regions")
|
|
284
|
+
parsed = {}
|
|
285
|
+
for name, raw in regions.items():
|
|
286
|
+
if not isinstance(raw, dict):
|
|
287
|
+
raise LitematicFormatError(
|
|
288
|
+
f"{source_name}, region '{name}': region is not a compound"
|
|
289
|
+
)
|
|
290
|
+
parsed[name] = _parse_region(name, raw, source_name)
|
|
291
|
+
return Litematic(metadata=_parse_metadata(root, source_name), regions=parsed)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def loads(data: bytes) -> Litematic:
|
|
295
|
+
"""Parse .litematic bytes into a Litematic model."""
|
|
296
|
+
if not isinstance(data, (bytes, bytearray, memoryview)):
|
|
297
|
+
raise LitematicError(
|
|
298
|
+
f"loads() expects bytes-like input, got {type(data).__name__}"
|
|
299
|
+
)
|
|
300
|
+
return parse_schematic(read_nbt(data), "<bytes>")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def load(path: str | Path) -> Litematic:
|
|
304
|
+
"""Read and parse a .litematic file into a Litematic model."""
|
|
305
|
+
if not isinstance(path, (str, Path)):
|
|
306
|
+
raise LitematicError(
|
|
307
|
+
f"load() expects a str or Path, got {type(path).__name__}"
|
|
308
|
+
)
|
|
309
|
+
try:
|
|
310
|
+
p = Path(path)
|
|
311
|
+
except ValueError as exc:
|
|
312
|
+
raise LitematicError(f"Cannot read {path!r}: {exc}") from exc
|
|
313
|
+
try:
|
|
314
|
+
data = p.read_bytes()
|
|
315
|
+
except OSError as exc:
|
|
316
|
+
raise LitematicError(f"Cannot read {p}: {exc}") from exc
|
|
317
|
+
return parse_schematic(read_nbt(data), str(p))
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Offline isometric rendering of parsed .litematic schematics."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from litematic.parse.model import Litematic
|
|
9
|
+
from litematic.render.errors import RenderError
|
|
10
|
+
from litematic.render.pack import load_pack
|
|
11
|
+
from litematic.render.png import encode_png
|
|
12
|
+
from litematic.render.scene.isometric import _AIR_NAMES, _compose
|
|
13
|
+
from litematic.render.scene.painter import _choose_scale, _downsample, _paint
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"RenderError",
|
|
17
|
+
"isometric",
|
|
18
|
+
"isometric_png",
|
|
19
|
+
"to_png",
|
|
20
|
+
"unresolved_colors",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
_MAX_SIZE_BOUNDS = (16, 8192)
|
|
24
|
+
_SS_BOUNDS = (1, 4)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def isometric(
|
|
28
|
+
schem: Litematic,
|
|
29
|
+
*,
|
|
30
|
+
textures_path: str | Path | None = None,
|
|
31
|
+
extra_packs: Sequence[str | Path] = (),
|
|
32
|
+
yaw: int = 0,
|
|
33
|
+
max_size: int = 2048,
|
|
34
|
+
supersample: int = 2,
|
|
35
|
+
background: tuple[int, int, int, int] | None = None,
|
|
36
|
+
) -> np.ndarray:
|
|
37
|
+
"""Render a schematic to an RGBA isometric image within max_size."""
|
|
38
|
+
if yaw not in (0, 1, 2, 3):
|
|
39
|
+
raise RenderError(f"render: yaw must be 0-3, got {yaw}")
|
|
40
|
+
if not _MAX_SIZE_BOUNDS[0] <= max_size <= _MAX_SIZE_BOUNDS[1]:
|
|
41
|
+
raise RenderError(
|
|
42
|
+
f"render: max_size must be "
|
|
43
|
+
f"{_MAX_SIZE_BOUNDS[0]}-{_MAX_SIZE_BOUNDS[1]}, got {max_size}"
|
|
44
|
+
)
|
|
45
|
+
if not _SS_BOUNDS[0] <= supersample <= _SS_BOUNDS[1]:
|
|
46
|
+
raise RenderError(
|
|
47
|
+
f"render: supersample must be {_SS_BOUNDS[0]}-{_SS_BOUNDS[1]}, "
|
|
48
|
+
f"got {supersample}"
|
|
49
|
+
)
|
|
50
|
+
bg = _background(background)
|
|
51
|
+
textures = load_pack(_require_pack(textures_path), extra_packs)
|
|
52
|
+
scene = _compose(schem, yaw, textures)
|
|
53
|
+
if len(scene.x) == 0:
|
|
54
|
+
raise RenderError("render: no blocks to render")
|
|
55
|
+
t, _ = _choose_scale(scene.span_w, scene.span_h, max_size, supersample)
|
|
56
|
+
img = _paint(scene, t, bg)
|
|
57
|
+
height, width = img.shape[:2]
|
|
58
|
+
if max(height, width) > max_size:
|
|
59
|
+
factor = max_size / max(height, width)
|
|
60
|
+
img = _downsample(
|
|
61
|
+
img, max(1, round(width * factor)), max(1, round(height * factor))
|
|
62
|
+
)
|
|
63
|
+
return img
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def isometric_png(
|
|
67
|
+
schem: Litematic,
|
|
68
|
+
*,
|
|
69
|
+
textures_path: str | Path | None = None,
|
|
70
|
+
extra_packs: Sequence[str | Path] = (),
|
|
71
|
+
yaw: int = 0,
|
|
72
|
+
max_size: int = 2048,
|
|
73
|
+
supersample: int = 2,
|
|
74
|
+
background: tuple[int, int, int, int] | None = None,
|
|
75
|
+
) -> bytes:
|
|
76
|
+
"""Render a schematic and encode the image as PNG bytes."""
|
|
77
|
+
return encode_png(
|
|
78
|
+
isometric(
|
|
79
|
+
schem,
|
|
80
|
+
textures_path=textures_path,
|
|
81
|
+
extra_packs=extra_packs,
|
|
82
|
+
yaw=yaw,
|
|
83
|
+
max_size=max_size,
|
|
84
|
+
supersample=supersample,
|
|
85
|
+
background=background,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def to_png(image: np.ndarray) -> bytes:
|
|
91
|
+
"""Encode an RGBA uint8 image array as PNG bytes."""
|
|
92
|
+
return encode_png(image)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def unresolved_colors(
|
|
96
|
+
schem: Litematic,
|
|
97
|
+
*,
|
|
98
|
+
textures_path: str | Path,
|
|
99
|
+
extra_packs: Sequence[str | Path] = (),
|
|
100
|
+
) -> set[str]:
|
|
101
|
+
"""Return block names the pack stack cannot cover; these render magenta."""
|
|
102
|
+
textures = load_pack(_require_pack(textures_path), extra_packs)
|
|
103
|
+
return {
|
|
104
|
+
state.name
|
|
105
|
+
for region in schem.regions.values()
|
|
106
|
+
for state in region.palette
|
|
107
|
+
if state.name not in _AIR_NAMES
|
|
108
|
+
and textures.resolve(state).miss is not None
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _background(
|
|
113
|
+
value: tuple[int, int, int, int] | None,
|
|
114
|
+
) -> tuple[int, int, int, int]:
|
|
115
|
+
if value is None:
|
|
116
|
+
return (0, 0, 0, 0)
|
|
117
|
+
if (
|
|
118
|
+
isinstance(value, tuple)
|
|
119
|
+
and len(value) == 4
|
|
120
|
+
and all(
|
|
121
|
+
isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 255
|
|
122
|
+
for v in value
|
|
123
|
+
)
|
|
124
|
+
):
|
|
125
|
+
return value
|
|
126
|
+
raise RenderError("render: background must be None or 4 ints in [0, 255]")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _require_pack(textures_path: str | Path | None) -> str | Path:
|
|
130
|
+
if textures_path is None:
|
|
131
|
+
raise RenderError(
|
|
132
|
+
"render: a resource pack zip is required (pass textures_path)"
|
|
133
|
+
)
|
|
134
|
+
return textures_path
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Resource-pack pipeline: blockstates → models → quads → cached npz."""
|
|
2
|
+
|
|
3
|
+
from litematic.render.pack.extract import ExtractSummary
|
|
4
|
+
from litematic.render.pack.loader import load_pack
|
|
5
|
+
from litematic.render.pack.resolve import Resolution, Resolver
|
|
6
|
+
|
|
7
|
+
__all__ = ["ExtractSummary", "Resolution", "Resolver", "load_pack"]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Blockstate JSON parsing: variants and multipart part records."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _normalize_state_key(key: str) -> str:
|
|
9
|
+
if not key:
|
|
10
|
+
return ""
|
|
11
|
+
pairs = [pair.strip() for pair in key.split(",") if pair.strip()]
|
|
12
|
+
pairs.sort(key=lambda pair: pair.split("=", 1)[0])
|
|
13
|
+
return ",".join(pairs)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _block_id(state_name: str) -> str:
|
|
18
|
+
parts = state_name.split("/")
|
|
19
|
+
ns = parts[1]
|
|
20
|
+
stem = parts[-1][: -len(".json")]
|
|
21
|
+
return f"{ns}:{stem}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _state_pairs(state_key: str) -> dict[str, str]:
|
|
26
|
+
"""Parse a state key into its property pairs; the empty key is {}."""
|
|
27
|
+
pairs: dict[str, str] = {}
|
|
28
|
+
for part in state_key.split(","):
|
|
29
|
+
prop, sep, value = part.partition("=")
|
|
30
|
+
if sep:
|
|
31
|
+
pairs[prop] = value
|
|
32
|
+
return pairs
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _when_matches(
|
|
37
|
+
condition: Mapping[str, Any], values: Mapping[str, str]
|
|
38
|
+
) -> bool:
|
|
39
|
+
"""Vanilla multipart condition: OR/AND nesting, | alternatives."""
|
|
40
|
+
if not isinstance(condition, Mapping):
|
|
41
|
+
return False
|
|
42
|
+
for key, expected in condition.items():
|
|
43
|
+
if key == "OR" or key == "AND":
|
|
44
|
+
if not isinstance(expected, list):
|
|
45
|
+
return False
|
|
46
|
+
sub = [_when_matches(item, values) for item in expected]
|
|
47
|
+
if key == "OR" and not any(sub):
|
|
48
|
+
return False
|
|
49
|
+
if key == "AND" and not all(sub):
|
|
50
|
+
return False
|
|
51
|
+
else:
|
|
52
|
+
actual = values.get(str(key))
|
|
53
|
+
if actual is None or actual not in str(expected).split("|"):
|
|
54
|
+
return False
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _parse_blockstate(
|
|
60
|
+
state: dict[str, Any],
|
|
61
|
+
) -> tuple[
|
|
62
|
+
list[tuple[str, list[dict[str, Any]]]],
|
|
63
|
+
list[dict[str, Any]] | None,
|
|
64
|
+
list[str],
|
|
65
|
+
]:
|
|
66
|
+
"""Split a blockstate into variant entries and multipart part records.
|
|
67
|
+
|
|
68
|
+
Variant keys are alternatives and map to one apply, keyed for the
|
|
69
|
+
state-keyed section. Conditional multiparts keep their raw condition
|
|
70
|
+
trees: render-time evaluation against the live properties (the
|
|
71
|
+
Blockscope select_variants shape) needs no key enumeration and no
|
|
72
|
+
property-domain inference. The model refs cover every referenced
|
|
73
|
+
model regardless, feeding the spec 5.3 texture fallback.
|
|
74
|
+
"""
|
|
75
|
+
out: list[tuple[str, list[dict[str, Any]]]] = []
|
|
76
|
+
variants = state.get("variants")
|
|
77
|
+
if isinstance(variants, dict):
|
|
78
|
+
for key, value in variants.items():
|
|
79
|
+
variant = value[0] if isinstance(value, list) and value else value
|
|
80
|
+
if isinstance(variant, dict) and isinstance(variant.get("model"), str):
|
|
81
|
+
out.append((_normalize_state_key(key), [variant]))
|
|
82
|
+
parts: list[dict[str, Any]] | None = None
|
|
83
|
+
raw = state.get("multipart")
|
|
84
|
+
if isinstance(raw, list) and raw:
|
|
85
|
+
parts = []
|
|
86
|
+
for part in raw:
|
|
87
|
+
if not isinstance(part, dict):
|
|
88
|
+
continue
|
|
89
|
+
when = part.get("when")
|
|
90
|
+
record_when = None
|
|
91
|
+
if isinstance(when, Mapping):
|
|
92
|
+
record_when = json.loads(json.dumps(when))
|
|
93
|
+
apply = part.get("apply")
|
|
94
|
+
entries = apply if isinstance(apply, list) else [apply]
|
|
95
|
+
for entry in entries:
|
|
96
|
+
# Multipart apply arrays are several models rendered
|
|
97
|
+
# together under one condition: flatten to one record per
|
|
98
|
+
# model, all sharing the part's condition.
|
|
99
|
+
if isinstance(entry, dict) and isinstance(entry.get("model"), str):
|
|
100
|
+
record = {
|
|
101
|
+
"model": entry["model"],
|
|
102
|
+
"when": record_when,
|
|
103
|
+
"x": int(entry.get("x", 0)),
|
|
104
|
+
"y": int(entry.get("y", 0)),
|
|
105
|
+
"uvlock": bool(entry.get("uvlock", False)),
|
|
106
|
+
}
|
|
107
|
+
parts.append(record)
|
|
108
|
+
refs = [v["model"] for _, applies in out for v in applies]
|
|
109
|
+
if parts is not None:
|
|
110
|
+
refs.extend(p["model"] for p in parts)
|
|
111
|
+
return out, parts, refs
|