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,243 @@
1
+ """Pack extraction orchestration: blockstates → quads → npz."""
2
+
3
+ import io
4
+ import zipfile
5
+ from pathlib import Path
6
+ from typing import Any, NamedTuple, Sequence
7
+
8
+ import numpy as np
9
+
10
+ from litematic.render.errors import RenderError
11
+ from litematic.render.pack.blockstate import _block_id
12
+ from litematic.render.pack.compile import _extract_block
13
+ from litematic.render.pack.elements import _unit_cube_occl
14
+ from litematic.render.pack.resolve import _tint_for
15
+ from litematic.render.pack.stack import PackStack, open_stack
16
+ from litematic.render.pack.texture import (
17
+ _Budget,
18
+ _load_texture,
19
+ _register_array,
20
+ _register_wire_dust,
21
+ _texture_key,
22
+ )
23
+ from litematic.render.quads import sort_quads, unit_cube_quads
24
+ from litematic.render.shapes import builtin_quads
25
+
26
+
27
+ class ExtractSummary(NamedTuple):
28
+ """Outcome of one pack extraction."""
29
+
30
+ path: Path
31
+ blocks: int
32
+ skipped: int
33
+
34
+
35
+
36
+ class _CacheWriteError(RenderError):
37
+ """Raised when the npz destination cannot be written."""
38
+
39
+
40
+
41
+ _FIXED_DATE = (1980, 1, 1, 0, 0, 0)
42
+
43
+
44
+
45
+ _NPZ_VERSION = "pack-quads.1"
46
+
47
+
48
+
49
+ def _require_pillow() -> None:
50
+ try:
51
+ from PIL import Image # noqa: F401
52
+ except ImportError as exc:
53
+ raise RenderError(
54
+ "extract: Pillow is required — pip install litematic-render[pack]"
55
+ ) from exc
56
+
57
+
58
+
59
+ def _extract_pack(
60
+ sources: str | Path | Sequence[str | Path],
61
+ dest: str | Path,
62
+ pack_mtime: float | tuple[float, ...] | None = None,
63
+ ) -> ExtractSummary:
64
+ if isinstance(sources, (str, Path)):
65
+ sources = [sources]
66
+ src = Path(sources[0])
67
+ if not src.is_file():
68
+ raise RenderError(f"extract: not a resource pack: {src}: no such file")
69
+ if pack_mtime is None:
70
+ try:
71
+ pack_mtime = src.stat().st_mtime
72
+ except OSError as exc:
73
+ raise RenderError(f"extract: not a resource pack: {src}: {exc}") from exc
74
+ with open_stack(sources) as stack:
75
+ names = stack.names()
76
+ state_files = sorted(
77
+ n
78
+ for n in names
79
+ if n.startswith("assets/") and "/blockstates/" in n and n.endswith(".json")
80
+ )
81
+ if not state_files and not _fallback_blocks(names):
82
+ raise RenderError(
83
+ "extract: not a resource pack: "
84
+ f"{src}: no blockstates or block textures under assets/"
85
+ )
86
+ _require_pillow()
87
+ texture_cache: dict[str, np.ndarray | None] = {}
88
+ budget = _Budget()
89
+ model_cache: dict[str, dict[str, Any] | None] = {}
90
+ mquad_cache: dict[str, str | None] = {}
91
+ tex_arrays: dict[str, np.ndarray] = {}
92
+ quad_map: dict[str, np.ndarray] = {}
93
+ occl_map: dict[str, bool] = {}
94
+ parts_map: dict[str, str] = {}
95
+ mquad_map: dict[str, np.ndarray] = {}
96
+ blocks = 0
97
+ skipped = 0
98
+ covered: set[str] = set()
99
+ for state_name in state_files:
100
+ block_id = _block_id(state_name)
101
+ try:
102
+ states, parts_json = _extract_block(
103
+ stack,
104
+ state_name,
105
+ block_id,
106
+ model_cache,
107
+ texture_cache,
108
+ tex_arrays,
109
+ mquad_cache,
110
+ mquad_map,
111
+ budget,
112
+ )
113
+ except RenderError:
114
+ raise
115
+ except Exception:
116
+ skipped += 1
117
+ continue
118
+ if not states and parts_json is None:
119
+ skipped += 1
120
+ continue
121
+ covered.add(block_id)
122
+ for state_key, (quads, occl) in states.items():
123
+ key = f"{block_id}[{state_key}]"
124
+ quad_map[key] = quads
125
+ occl_map[key] = occl
126
+ if parts_json is not None:
127
+ parts_map[f"parts/{block_id}"] = parts_json
128
+ blocks += 1
129
+ for ns, stem in _fallback_blocks(names):
130
+ block_id = f"{ns}:{stem}"
131
+ if block_id in covered:
132
+ # Blocks with pack quads never fall through to the layers
133
+ # below; their unmatched states stay for magenta.
134
+ continue
135
+ array_key = _fallback_array_key(
136
+ stack, ns, stem, texture_cache, tex_arrays, budget
137
+ )
138
+ if array_key is None:
139
+ continue
140
+ if builtin_quads(block_id, {}) is not None:
141
+ # Builtin families never get extraction-side geometry: it
142
+ # would shadow compose's state-aware builtin step. Their
143
+ # texture is registered under the canonical block key (raw
144
+ # plus tint variant above) for compose to backfill.
145
+ continue
146
+ # Spec 5.5 texture-direct cube: the last layer before magenta
147
+ # for blocks the builtin table does not know.
148
+ cube = unit_cube_quads(array_key)
149
+ quad_map[f"{block_id}[]"] = cube
150
+ occl_map[f"{block_id}[]"] = _unit_cube_occl(cube, tex_arrays)
151
+ blocks += 1
152
+ asset_ns = {
153
+ name.split("/")[1]
154
+ for name in names
155
+ if name.startswith("assets/") and len(name.split("/")) > 2
156
+ }
157
+ for ns in sorted(asset_ns):
158
+ _register_wire_dust(stack, ns, texture_cache, tex_arrays, budget)
159
+ _write_npz(
160
+ Path(dest), tex_arrays, quad_map, occl_map, parts_map, mquad_map,
161
+ pack_mtime,
162
+ )
163
+ return ExtractSummary(Path(dest), blocks, skipped)
164
+
165
+
166
+
167
+ def _fallback_blocks(names: set[str]) -> list[tuple[str, str]]:
168
+ """List (namespace, stem) for textures directly under textures/block/."""
169
+ out: list[tuple[str, str]] = []
170
+ for name in sorted(names):
171
+ parts = name.split("/")
172
+ if (
173
+ len(parts) != 5
174
+ or parts[0] != "assets"
175
+ or parts[2] != "textures"
176
+ or parts[3] != "block"
177
+ or not parts[4].endswith(".png")
178
+ ):
179
+ continue
180
+ stem = parts[4][: -len(".png")]
181
+ if not stem or not all(
182
+ c.islower() or c.isdigit() or c == "_" for c in stem
183
+ ):
184
+ continue
185
+ out.append((parts[1], stem))
186
+ return out
187
+
188
+
189
+
190
+ def _fallback_array_key(
191
+ stack: PackStack,
192
+ ns: str,
193
+ stem: str,
194
+ texture_cache: dict[str, np.ndarray | None],
195
+ tex_arrays: dict[str, np.ndarray],
196
+ budget: _Budget,
197
+ ) -> str | None:
198
+ """Resolve a textures/block/<stem>.png fallback into an array key."""
199
+ tex_ref = f"block/{stem}"
200
+ tex_key = _texture_key(tex_ref, ns)
201
+ raw = _load_texture(stack, ns, tex_ref, tex_key, texture_cache, budget)
202
+ if raw is None:
203
+ return None
204
+ tint = _tint_for(f"{ns}:{stem}", "")
205
+ return _register_array(raw, tex_key, tint, tex_arrays)
206
+
207
+
208
+
209
+ def _write_npz(
210
+ dest: Path,
211
+ tex_arrays: dict[str, np.ndarray],
212
+ quad_map: dict[str, np.ndarray],
213
+ occl_map: dict[str, bool],
214
+ parts_map: dict[str, str],
215
+ mquad_map: dict[str, np.ndarray],
216
+ pack_mtime: float | tuple[float, ...],
217
+ ) -> None:
218
+ entries: dict[str, np.ndarray] = {
219
+ "meta/version": np.array(_NPZ_VERSION),
220
+ "meta/pack_mtime": np.array(pack_mtime),
221
+ }
222
+ for array_key, array in tex_arrays.items():
223
+ entries[f"tex/{array_key}"] = array
224
+ for key, quads in quad_map.items():
225
+ entries[f"quads/{key}"] = sort_quads(quads)
226
+ for key, occl in occl_map.items():
227
+ entries[f"occl/{key}"] = np.array(occl, dtype=np.uint8)
228
+ for key, record in parts_map.items():
229
+ entries[key] = np.array(record)
230
+ for key, quads in mquad_map.items():
231
+ entries[f"mquads/{key}"] = sort_quads(quads)
232
+ try:
233
+ with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as out:
234
+ for key in sorted(entries):
235
+ buffer = io.BytesIO()
236
+ np.lib.format.write_array(buffer, entries[key], allow_pickle=False)
237
+ info = zipfile.ZipInfo(key, date_time=_FIXED_DATE)
238
+ info.compress_type = zipfile.ZIP_DEFLATED
239
+ out.writestr(info, buffer.getvalue())
240
+ except OSError as exc:
241
+ raise _CacheWriteError(
242
+ f"extract: cannot write npz: {dest}: {exc}"
243
+ ) from exc
@@ -0,0 +1,186 @@
1
+ """Pack stack loading with an mtime-keyed extraction cache."""
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Sequence
9
+
10
+ import numpy as np
11
+
12
+ from litematic.render.errors import RenderError
13
+ from litematic.render.pack.extract import (
14
+ _NPZ_VERSION,
15
+ _CacheWriteError,
16
+ _extract_pack,
17
+ )
18
+ from litematic.render.pack.resolve import Resolver, _state_index
19
+ from litematic.render.quads import QUAD_DTYPE
20
+
21
+ __all__ = ["load_pack"]
22
+
23
+ _CACHE_SUFFIX = ".litematic-tex.npz"
24
+ _BAD_CHARS = r'[<>:"/\\|?*]'
25
+ _CACHE_STEM_MAX = 80 # the digest carries uniqueness, stems stay short
26
+
27
+ # In-process cache keyed (paths, mtimes); skips re-reading the npz per render.
28
+ _PACK_CACHE: dict[tuple[tuple[str, ...], tuple[float, ...]], Resolver] = {}
29
+
30
+
31
+ def load_pack(
32
+ pack_path: str | Path, extra_packs: Sequence[str | Path] = ()
33
+ ) -> Resolver:
34
+ """Load the pack stack, extracting to an mtime-keyed cache on miss."""
35
+ packs = [Path(pack_path), *_pack_paths(extra_packs)]
36
+ mtimes: list[float] = []
37
+ resolved: list[str] = []
38
+ for path in packs:
39
+ if not path.is_file():
40
+ raise RenderError(f"load: not a resource pack: {path}: no such file")
41
+ try:
42
+ mtimes.append(path.stat().st_mtime)
43
+ resolved.append(str(path.resolve()))
44
+ except OSError as exc:
45
+ raise RenderError(
46
+ f"load: not a resource pack: {path}: {exc}"
47
+ ) from exc
48
+ stacked = tuple(mtimes)
49
+ key = (tuple(resolved), stacked)
50
+ hit = _PACK_CACHE.get(key)
51
+ if hit is not None:
52
+ return hit
53
+ if len(packs) == 1:
54
+ cache = packs[0].with_name(packs[0].stem + _CACHE_SUFFIX)
55
+ else:
56
+ cache = packs[0].with_name(_stack_cache_name(packs, resolved))
57
+ cached = _read_cache(cache, stacked)
58
+ if cached is not None:
59
+ _PACK_CACHE[key] = cached
60
+ return cached
61
+ try:
62
+ _extract_pack(packs, cache, pack_mtime=stacked)
63
+ except _CacheWriteError:
64
+ cache = Path(tempfile.gettempdir()) / cache.name
65
+ _extract_pack(packs, cache, pack_mtime=stacked)
66
+ loaded = _parse_textures(cache)
67
+ if loaded is None or loaded.pack_mtime != stacked:
68
+ raise RenderError(f"load: cache unreadable after extraction: {cache}")
69
+ _PACK_CACHE[key] = loaded
70
+ return loaded
71
+
72
+
73
+ def _pack_paths(extra_packs: Sequence[str | Path]) -> list[Path]:
74
+ """Normalize the add-on list; a bare path becomes a one-entry stack."""
75
+ if isinstance(extra_packs, (str, Path)):
76
+ return [Path(extra_packs)]
77
+ try:
78
+ items = list(extra_packs)
79
+ except TypeError as exc:
80
+ raise RenderError(
81
+ "load: extra_packs must be a sequence of paths"
82
+ ) from exc
83
+ for item in items:
84
+ if not isinstance(item, (str, Path)):
85
+ raise RenderError("load: extra_packs must be a sequence of paths")
86
+ return [Path(item) for item in items]
87
+
88
+
89
+ def _stack_cache_name(packs: list[Path], resolved: list[str]) -> str:
90
+ """Cache file name spanning the whole stack, disambiguated by a path hash."""
91
+ digest = hashlib.sha1("\n".join(resolved).encode("utf-8")).hexdigest()[:8]
92
+ stem = re.sub(_BAD_CHARS, "_", "+".join(p.stem for p in packs))
93
+ return f"{stem[:_CACHE_STEM_MAX]}.{digest}{_CACHE_SUFFIX}"
94
+
95
+
96
+ def _read_cache(cache: Path, mtimes: tuple[float, ...]) -> Resolver | None:
97
+ if not cache.is_file():
98
+ return None
99
+ parsed = _parse_textures(cache)
100
+ if (
101
+ parsed is None
102
+ or parsed.version != _NPZ_VERSION
103
+ or parsed.pack_mtime != mtimes
104
+ ):
105
+ return None
106
+ return parsed
107
+
108
+
109
+ def _parse_textures(path: Path) -> Resolver | None:
110
+ try:
111
+ with np.load(path, allow_pickle=False) as data:
112
+ version = str(data["meta/version"].item())
113
+ pack_mtime = tuple(
114
+ float(value) for value in np.atleast_1d(data["meta/pack_mtime"])
115
+ )
116
+ arrays: dict[str, np.ndarray] = {}
117
+ quads_map: dict[str, np.ndarray] = {}
118
+ occl_map: dict[str, bool] = {}
119
+ parts_map: dict[str, str] = {}
120
+ mquad_map: dict[str, np.ndarray] = {}
121
+ for key in data.files:
122
+ if key.startswith("tex/"):
123
+ array = data[key]
124
+ if (
125
+ array.ndim == 3
126
+ and array.shape[2] == 4
127
+ and min(array.shape[:2]) >= 1
128
+ and array.dtype == np.uint8
129
+ ):
130
+ arrays[key[len("tex/") :]] = array
131
+ elif key.startswith("quads/"):
132
+ array = data[key]
133
+ if array.ndim == 1 and array.dtype == QUAD_DTYPE:
134
+ quads_map[key[len("quads/") :]] = array
135
+ elif key.startswith("occl/"):
136
+ array = data[key]
137
+ if array.shape == () and array.dtype == np.uint8:
138
+ occl_map[key[len("occl/") :]] = bool(array.item())
139
+ elif key.startswith("parts/"):
140
+ array = data[key]
141
+ if array.shape == ():
142
+ parts_map[key[len("parts/") :]] = str(array.item())
143
+ elif key.startswith("mquads/"):
144
+ array = data[key]
145
+ if array.ndim == 1 and array.dtype == QUAD_DTYPE:
146
+ mquad_map[key[len("mquads/") :]] = array
147
+ # Quads referencing unknown arrays are dropped; render degrades instead.
148
+ quads_map = {
149
+ key: value
150
+ for key, value in quads_map.items()
151
+ if {str(quad["texkey"]) for quad in value} <= arrays.keys()
152
+ }
153
+ mquad_map = {
154
+ key: value
155
+ for key, value in mquad_map.items()
156
+ if {str(quad["texkey"]) for quad in value} <= arrays.keys()
157
+ }
158
+ parts_map = {
159
+ key: record
160
+ for key, record in parts_map.items()
161
+ if _parts_usable(record, mquad_map)
162
+ }
163
+ return Resolver(
164
+ version=version,
165
+ arrays=arrays,
166
+ quads_map=quads_map,
167
+ occl_map=occl_map,
168
+ pack_mtime=pack_mtime,
169
+ states=_state_index(quads_map, occl_map),
170
+ parts_map=parts_map,
171
+ mquad_map=mquad_map,
172
+ )
173
+ except Exception:
174
+ return None
175
+
176
+
177
+ def _parts_usable(record: str, mquad_map: dict[str, np.ndarray]) -> bool:
178
+ """A parts record is usable when every referenced quad array exists."""
179
+ try:
180
+ parts = json.loads(record)["parts"]
181
+ return all(
182
+ isinstance(part.get("quads"), str) and part["quads"] in mquad_map
183
+ for part in parts
184
+ )
185
+ except (json.JSONDecodeError, KeyError, TypeError):
186
+ return False