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,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]])