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,347 @@
1
+ """Texture resolution: refs, models, PNG decode, tints."""
2
+
3
+ import io
4
+ import json
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+
9
+ from litematic.render.errors import RenderError
10
+ from litematic.render.pack.resolve import _tint_for
11
+ from litematic.render.pack.stack import PackStack
12
+ from litematic.render.quads import _redstone_tint
13
+
14
+ _VEC = {
15
+ "up": (0, 1, 0),
16
+ "down": (0, -1, 0),
17
+ "north": (0, 0, -1),
18
+ "south": (0, 0, 1),
19
+ "west": (-1, 0, 0),
20
+ "east": (1, 0, 0),
21
+ }
22
+ _NAME = {v: k for k, v in _VEC.items()}
23
+
24
+
25
+
26
+ class _Budget:
27
+ """Reject oversize or over-budget pack textures as missing."""
28
+
29
+ MAX_SIDE = 2048
30
+ MAX_BYTES = 256 * 1024 * 1024
31
+
32
+ def __init__(self) -> None:
33
+ self.bytes = 0
34
+
35
+ def take(self, shape: tuple[int, ...]) -> bool:
36
+ """Account one decoded texture's bytes; False when over a cap."""
37
+ height, width = int(shape[0]), int(shape[1])
38
+ if max(height, width) > self.MAX_SIDE:
39
+ return False
40
+ size = height * width * 4
41
+ if self.bytes + size > self.MAX_BYTES:
42
+ return False
43
+ self.bytes += size
44
+ return True
45
+
46
+
47
+
48
+ def _resolve_ref(ref: Any, textures: dict[str, str]) -> str | None:
49
+ """Resolve a face texture reference, following # chains up to depth 32."""
50
+ if not isinstance(ref, str) or not ref:
51
+ return None
52
+ current = ref
53
+ for _ in range(32):
54
+ if not current.startswith("#"):
55
+ return current
56
+ nxt = textures.get(current[1:])
57
+ if not isinstance(nxt, str):
58
+ return None
59
+ current = nxt
60
+ return None
61
+
62
+
63
+
64
+ def _texture_key(ref: str, ns: str) -> str:
65
+ ref_ns, _, path = ref.partition(":")
66
+ if not path:
67
+ ref_ns, path = ns, ref_ns
68
+ if ref_ns == "minecraft":
69
+ return path
70
+ return f"{ref_ns}:{path}"
71
+
72
+
73
+
74
+ def _asset_path(ns: str, ref: str, kind: str, suffix: str) -> str | None:
75
+ if ".." in ref:
76
+ return None
77
+ ref_ns, _, path = ref.partition(":")
78
+ if not path:
79
+ return f"assets/{ns}/{kind}/{ref_ns}{suffix}"
80
+ path = path.removeprefix(f"{kind}/")
81
+ return f"assets/{ref_ns}/{kind}/{path}{suffix}"
82
+
83
+
84
+
85
+ def _cached_model(
86
+ stack: PackStack,
87
+ ns: str,
88
+ ref: str,
89
+ cache: dict[str, dict[str, Any] | None],
90
+ ) -> dict[str, Any] | None:
91
+ key = f"{ns}|{ref}"
92
+ if key not in cache:
93
+ model = _resolve_model(stack, ns, ref)
94
+ if model is None:
95
+ cache[key] = None
96
+ elif model["elements"] is None:
97
+ stub_ref = _stub_texture(stack, ns, model["textures"])
98
+ cache[key] = (
99
+ None
100
+ if stub_ref is None
101
+ else {
102
+ "textures": model["textures"],
103
+ "elements": None,
104
+ "stub_ref": stub_ref,
105
+ "builtin_cut": bool(model.get("builtin_cut")),
106
+ }
107
+ )
108
+ else:
109
+ cache[key] = model
110
+ return cache[key]
111
+
112
+
113
+
114
+ def _stub_texture(
115
+ stack: PackStack, ns: str, textures: dict[str, Any]
116
+ ) -> str | None:
117
+ """Find the first concrete texture ref an element-less model can load."""
118
+ for value in textures.values():
119
+ if not isinstance(value, str) or value.startswith("#"):
120
+ continue
121
+ path = _asset_path(ns, value, "textures", ".png")
122
+ if path is not None and stack.exists(path):
123
+ return value
124
+ return None
125
+
126
+
127
+
128
+ def _resolve_model(
129
+ stack: PackStack, ns: str, model_ref: str
130
+ ) -> dict[str, Any] | None:
131
+ """Walk the parent chain and merge textures; nearest elements win."""
132
+ merged_textures: dict[str, str] = {}
133
+ elements: list[dict[str, Any]] | None = None
134
+ seen: set[str] = set()
135
+ ref: str | None = model_ref
136
+ while ref is not None and not ref.split(":")[-1].startswith("builtin"):
137
+ if ref in seen:
138
+ return None
139
+ seen.add(ref)
140
+ file = _asset_path(ns, ref, "models", ".json")
141
+ if file is None or not stack.exists(file):
142
+ return None
143
+ raw = stack.read(file)
144
+ if raw is None: # unreachable: the path exists per the check above
145
+ return None
146
+ model = json.loads(raw)
147
+ if not isinstance(model, dict):
148
+ return None
149
+ textures = model.get("textures")
150
+ if isinstance(textures, dict):
151
+ for key, value in textures.items():
152
+ merged_textures.setdefault(key, value)
153
+ if elements is None and isinstance(model.get("elements"), list):
154
+ elements = model["elements"]
155
+ parent = model.get("parent")
156
+ ref = parent if isinstance(parent, str) else None
157
+ if elements is None:
158
+ if ref is not None:
159
+ # Chain terminated at a builtin/* parent: no pack geometry, but
160
+ # the merged textures still feed the spec 5.3 texture fallback.
161
+ return {
162
+ "textures": merged_textures,
163
+ "elements": None,
164
+ "builtin_cut": True,
165
+ }
166
+ return {"textures": merged_textures, "elements": None}
167
+ return {"textures": merged_textures, "elements": elements}
168
+
169
+
170
+
171
+ def _rotate(face: str, x: int, y: int) -> str:
172
+ vec = _VEC[face]
173
+ for _ in range((x // 90) % 4): # y' = z, z' = -y
174
+ vec = (vec[0], vec[2], -vec[1])
175
+ for _ in range((y // 90) % 4): # x' = -z, z' = x
176
+ vec = (-vec[2], vec[1], vec[0])
177
+ return _NAME[vec]
178
+
179
+
180
+
181
+ def _load_texture(
182
+ stack: PackStack,
183
+ ns: str,
184
+ tex_ref: str,
185
+ tex_key: str,
186
+ cache: dict[str, np.ndarray | None],
187
+ budget: _Budget,
188
+ ) -> np.ndarray | None:
189
+ if tex_key in cache:
190
+ return cache[tex_key]
191
+ file = _asset_path(ns, tex_ref, "textures", ".png")
192
+ result: np.ndarray | None = None
193
+ if file is not None:
194
+ found = stack.read_ranked(file)
195
+ if found is not None:
196
+ data, pack_index = found
197
+ try:
198
+ image = _decode_png(data, _is_animated(stack, file, pack_index))
199
+ raw = np.asarray(image, dtype=np.uint8)
200
+ if budget.take(raw.shape):
201
+ result = raw
202
+ except RenderError:
203
+ raise
204
+ except Exception:
205
+ result = None
206
+ cache[tex_key] = result
207
+ return result
208
+
209
+
210
+
211
+ def _is_animated(stack: PackStack, file: str, pack_index: int) -> bool:
212
+ """True when the texture's .mcmeta above its own pack declares an animation."""
213
+ data = stack.mcmeta(file, pack_index)
214
+ if data is None:
215
+ return False
216
+ try:
217
+ parsed = json.loads(data)
218
+ except Exception:
219
+ return False
220
+ return isinstance(parsed, dict) and "animation" in parsed
221
+
222
+
223
+
224
+ def _decode_png(data: bytes, animated: bool) -> Any:
225
+ from PIL import Image
226
+
227
+ image = Image.open(io.BytesIO(data)).convert("RGBA")
228
+ if animated: # vertical strip: first frame only
229
+ width, height = image.size
230
+ side = min(width, height)
231
+ image = image.crop((0, 0, side, side))
232
+ return image
233
+
234
+
235
+
236
+ def _apply_tint(array: np.ndarray, tint: tuple[int, int, int]) -> np.ndarray:
237
+ factor = np.array(tint + (255,), dtype=np.uint16)
238
+ return ((array.astype(np.uint16) * factor) // 255).astype(np.uint8)
239
+
240
+
241
+
242
+ def _register_array(
243
+ raw: np.ndarray,
244
+ tex_key: str,
245
+ tint: tuple[int, int, int] | None,
246
+ tex_arrays: dict[str, np.ndarray],
247
+ ) -> str:
248
+ """Store a raw texture under tex_key, adding a tinted variant if asked."""
249
+ if tint is None:
250
+ tex_arrays.setdefault(tex_key, raw)
251
+ return tex_key
252
+ array_key = f"{tex_key}@{tint[0]},{tint[1]},{tint[2]}"
253
+ if array_key not in tex_arrays:
254
+ tex_arrays[array_key] = _apply_tint(raw, tint)
255
+ return array_key
256
+
257
+
258
+
259
+ def _first_model_texture(
260
+ stack: PackStack,
261
+ ns: str,
262
+ model_refs: list[str],
263
+ model_cache: dict[str, dict[str, Any] | None],
264
+ texture_cache: dict[str, np.ndarray | None],
265
+ budget: _Budget,
266
+ ) -> np.ndarray | None:
267
+ """First loadable concrete texture among the referenced models."""
268
+ for ref in model_refs:
269
+ model = _cached_model(stack, ns, ref, model_cache)
270
+ if model is None:
271
+ continue
272
+ for value in model["textures"].values():
273
+ tex_ref = _resolve_ref(value, model["textures"])
274
+ if tex_ref is None:
275
+ continue
276
+ raw = _load_texture(
277
+ stack,
278
+ ns,
279
+ tex_ref,
280
+ _texture_key(tex_ref, ns),
281
+ texture_cache,
282
+ budget,
283
+ )
284
+ if raw is not None:
285
+ return raw
286
+ return None
287
+
288
+
289
+
290
+ def _register_builtin_texture(
291
+ stack: PackStack,
292
+ ns: str,
293
+ block_id: str,
294
+ model_refs: list[str],
295
+ model_cache: dict[str, dict[str, Any] | None],
296
+ texture_cache: dict[str, np.ndarray | None],
297
+ tex_arrays: dict[str, np.ndarray],
298
+ budget: _Budget,
299
+ ) -> None:
300
+ """Register the builtin layer texture under the block's canonical key.
301
+
302
+ Spec 5.3 chain: the block's own model textures first, then
303
+ textures/block/<stem>.png; unresolved blocks stay for magenta.
304
+ """
305
+ raw = _first_model_texture(
306
+ stack, ns, model_refs, model_cache, texture_cache, budget
307
+ )
308
+ stem = block_id.partition(":")[2]
309
+ tex_key = _texture_key(f"block/{stem}", ns)
310
+ if raw is None:
311
+ raw = _load_texture(
312
+ stack, ns, f"block/{stem}", tex_key, texture_cache, budget
313
+ )
314
+ if raw is None:
315
+ return
316
+ tex_arrays.setdefault(tex_key, raw)
317
+ tint = _tint_for(block_id, "")
318
+ if tint is not None:
319
+ array_key = f"{tex_key}@{tint[0]},{tint[1]},{tint[2]}"
320
+ if array_key not in tex_arrays:
321
+ tex_arrays[array_key] = _apply_tint(raw, tint)
322
+
323
+
324
+
325
+ _WIRE_DUST_TEXTURES = ("block/redstone_dust_dot", "block/redstone_dust_line0")
326
+
327
+
328
+
329
+ def _register_wire_dust(
330
+ stack: PackStack,
331
+ ns: str,
332
+ texture_cache: dict[str, np.ndarray | None],
333
+ tex_arrays: dict[str, np.ndarray],
334
+ budget: _Budget,
335
+ ) -> None:
336
+ """Register power-tinted dust textures for the builtin wire family."""
337
+ for ref in _WIRE_DUST_TEXTURES:
338
+ tex_key = _texture_key(ref, ns)
339
+ raw = _load_texture(stack, ns, ref, tex_key, texture_cache, budget)
340
+ if raw is None:
341
+ continue
342
+ for power in range(16):
343
+ tint = _redstone_tint(power)
344
+ tex_arrays.setdefault(
345
+ f"{tex_key}@{tint[0]},{tint[1]},{tint[2]}",
346
+ _apply_tint(raw, tint),
347
+ )
@@ -0,0 +1,54 @@
1
+ """Minimal deterministic PNG encoder for RGBA uint8 images."""
2
+
3
+ import struct
4
+ import zlib
5
+
6
+ import numpy as np
7
+
8
+ from litematic.render.errors import RenderError
9
+
10
+ __all__ = ["encode_png"]
11
+
12
+ _SIGNATURE = b"\x89PNG\r\n\x1a\n"
13
+
14
+
15
+ def encode_png(image: np.ndarray) -> bytes:
16
+ """Encode (H, W, 4) uint8 RGBA as PNG; zero transparent RGB; raises RenderError."""
17
+ if (
18
+ not isinstance(image, np.ndarray)
19
+ or image.dtype != np.uint8
20
+ or image.ndim != 3
21
+ or image.shape[2] != 4
22
+ or min(image.shape[:2]) < 1
23
+ ):
24
+ raise RenderError(
25
+ "encode: expected (H, W, 4) uint8 array, got "
26
+ f"dtype={getattr(image, 'dtype', type(image).__name__)} "
27
+ f"shape={getattr(image, 'shape', None)}"
28
+ )
29
+
30
+ pixels = image.copy()
31
+ transparent = pixels[..., 3] == 0
32
+ pixels[..., :3][transparent] = 0
33
+
34
+ height, width = pixels.shape[:2]
35
+ scanlines = np.zeros((height, width * 4 + 1), dtype=np.uint8)
36
+ scanlines[:, 1:] = pixels.reshape(height, width * 4)
37
+ raw = scanlines.tobytes()
38
+
39
+ ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
40
+ return (
41
+ _SIGNATURE
42
+ + _chunk(b"IHDR", ihdr)
43
+ + _chunk(b"IDAT", zlib.compress(raw))
44
+ + _chunk(b"IEND", b"")
45
+ )
46
+
47
+
48
+ def _chunk(tag: bytes, data: bytes) -> bytes:
49
+ return (
50
+ struct.pack(">I", len(data))
51
+ + tag
52
+ + data
53
+ + struct.pack(">I", zlib.crc32(tag + data))
54
+ )
@@ -0,0 +1,106 @@
1
+ """Quad data core: dtype, face tiers, unit cubes, ordering, geometry checks."""
2
+
3
+ import numpy as np
4
+
5
+ __all__ = [
6
+ "AO_LEVELS",
7
+ "CULL_NONE",
8
+ "FACE_LIGHT",
9
+ "QUAD_DTYPE",
10
+ "TIER_DOWN",
11
+ "TIER_EAST",
12
+ "TIER_NORTH",
13
+ "TIER_SOUTH",
14
+ "TIER_UP",
15
+ "TIER_WEST",
16
+ "is_parallelogram",
17
+ "sort_quads",
18
+ "unit_cube_quads",
19
+ ]
20
+
21
+ QUAD_DTYPE = np.dtype(
22
+ [
23
+ ("vtx", "f4", (4, 3)),
24
+ ("uv", "f4", (4, 2)),
25
+ ("texkey", "U64"),
26
+ ("shade", "u1"),
27
+ ("dbl", "?"),
28
+ ("cull", "u1"),
29
+ ]
30
+ )
31
+
32
+ # No cullface direction: the quad never disappears through neighbor culling.
33
+ CULL_NONE = 255
34
+
35
+ TIER_DOWN = 0
36
+ TIER_UP = 1
37
+ TIER_NORTH = 2
38
+ TIER_SOUTH = 3
39
+ TIER_WEST = 4
40
+ TIER_EAST = 5
41
+
42
+ FACE_LIGHT = (0.5, 1.0, 0.8, 0.8, 0.6, 0.6)
43
+ AO_LEVELS = (0.4, 0.6, 0.8, 1.0)
44
+
45
+ # Per-tier corners in the pinned winding; auto-UVs follow vanilla defaultFaceUV.
46
+ _CUBE_VTX = np.array(
47
+ [
48
+ [(0, 0, 16), (16, 0, 16), (16, 0, 0), (0, 0, 0)], # down
49
+ [(0, 16, 0), (16, 16, 0), (16, 16, 16), (0, 16, 16)], # up
50
+ [(16, 16, 0), (0, 16, 0), (0, 0, 0), (16, 0, 0)], # north
51
+ [(0, 16, 16), (16, 16, 16), (16, 0, 16), (0, 0, 16)], # south
52
+ [(0, 16, 16), (0, 16, 0), (0, 0, 0), (0, 0, 16)], # west
53
+ [(16, 16, 0), (16, 16, 16), (16, 0, 16), (16, 0, 0)], # east
54
+ ],
55
+ dtype="f4",
56
+ )
57
+ _CUBE_UV = np.tile(
58
+ np.array([(0, 0), (16, 0), (16, 16), (0, 16)], dtype="f4"), (6, 1, 1)
59
+ )
60
+ # West/east corners run the opposite winding, hence the flipped uv ring.
61
+ _CUBE_UV[TIER_WEST] = [(16, 0), (0, 0), (0, 16), (16, 16)]
62
+ _CUBE_UV[TIER_EAST] = [(16, 0), (0, 0), (0, 16), (16, 16)]
63
+
64
+
65
+ def unit_cube_quads(texkey: str, shade_faces: bool = True) -> np.ndarray:
66
+ """Build the six textured faces of a full 0..16 unit cube."""
67
+ quads = np.empty(6, dtype=QUAD_DTYPE)
68
+ quads["vtx"] = _CUBE_VTX
69
+ quads["uv"] = _CUBE_UV
70
+ quads["texkey"] = texkey
71
+ if shade_faces:
72
+ quads["shade"] = np.arange(6, dtype=np.uint8)
73
+ else:
74
+ quads["shade"] = np.full(6, TIER_UP, dtype=np.uint8)
75
+ quads["dbl"] = False
76
+ quads["cull"] = CULL_NONE
77
+ return quads
78
+
79
+
80
+ def sort_quads(quads: np.ndarray) -> np.ndarray:
81
+ """Return a canonical copy ordered by the raw byte sequence of vtx."""
82
+ vtx = np.ascontiguousarray(quads["vtx"]).reshape(quads.shape[0], 12)
83
+ keys = vtx.view(np.dtype((np.void, vtx.dtype.itemsize * 12))).reshape(-1)
84
+ order = np.argsort(keys, kind="stable")
85
+ return quads[order]
86
+
87
+
88
+ def is_parallelogram(quads: np.ndarray, eps: float = 1e-3) -> np.ndarray:
89
+ """Flag quads whose diagonal midpoints coincide within eps on each axis."""
90
+ vtx = quads["vtx"]
91
+ diag = (vtx[:, 0] + vtx[:, 2]) - (vtx[:, 1] + vtx[:, 3])
92
+ return np.all(np.abs(diag) <= eps, axis=-1)
93
+
94
+ _REDSTONE_LOW = (76, 0, 0)
95
+ _REDSTONE_HIGH = (255, 26, 26)
96
+
97
+
98
+ def _redstone_tint(power: int) -> tuple[int, int, int]:
99
+ """Vanilla dust color for a redstone power level, 0-15."""
100
+ factor = max(0, min(15, power)) / 15.0
101
+ low, high = _REDSTONE_LOW, _REDSTONE_HIGH
102
+ return (
103
+ round(low[0] + (high[0] - low[0]) * factor),
104
+ round(low[1] + (high[1] - low[1]) * factor),
105
+ round(low[2] + (high[2] - low[2]) * factor),
106
+ )
@@ -0,0 +1,6 @@
1
+ """Scene pipeline: world composition and the isometric painter."""
2
+
3
+ from litematic.render.scene.isometric import _compose
4
+ from litematic.render.scene.painter import _choose_scale, _downsample, _paint
5
+
6
+ __all__ = ["_choose_scale", "_compose", "_downsample", "_paint"]