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.
@@ -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,9 @@
1
+ """Errors raised by the render subpackage."""
2
+
3
+ from litematic.parse.errors import LitematicError
4
+
5
+ __all__ = ["RenderError"]
6
+
7
+
8
+ class RenderError(LitematicError):
9
+ """Raised when a render request cannot be fulfilled."""
@@ -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