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,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
|
+
)
|
litematic/render/png.py
ADDED
|
@@ -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"]
|