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