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,421 @@
1
+ """Model element geometry: boxes compiled to textured quads."""
2
+
3
+ import math
4
+ from typing import Any, Callable, cast
5
+
6
+ import numpy as np
7
+
8
+ from litematic.render.pack.texture import _rotate
9
+ from litematic.render.quads import (
10
+ _CUBE_VTX,
11
+ CULL_NONE,
12
+ QUAD_DTYPE,
13
+ TIER_DOWN,
14
+ TIER_EAST,
15
+ TIER_NORTH,
16
+ TIER_SOUTH,
17
+ TIER_UP,
18
+ TIER_WEST,
19
+ is_parallelogram,
20
+ )
21
+
22
+ _OPAQUE_ALPHA = 250.0
23
+
24
+
25
+
26
+ _FACE_TIER = {
27
+ "down": TIER_DOWN,
28
+ "up": TIER_UP,
29
+ "north": TIER_NORTH,
30
+ "south": TIER_SOUTH,
31
+ "west": TIER_WEST,
32
+ "east": TIER_EAST,
33
+ }
34
+ # Auto-UVs follow vanilla defaultFaceUV; ring order (u1,v1),(u2,v1),(u2,v2),(u1,v2).
35
+
36
+
37
+
38
+ _VEC = {
39
+ "up": (0, 1, 0),
40
+ "down": (0, -1, 0),
41
+ "north": (0, 0, -1),
42
+ "south": (0, 0, 1),
43
+ "west": (-1, 0, 0),
44
+ "east": (1, 0, 0),
45
+ }
46
+
47
+
48
+
49
+ _ROT_AXIS = {"x": 0, "y": 1, "z": 2}
50
+
51
+
52
+
53
+ _RESCALE_FACTORS = {22.5: 1.0823922, 45.0: 1.4142135}
54
+
55
+
56
+
57
+ _FACE_GEOM: dict[int, tuple[tuple[int, int, int], ...]] = {
58
+ tier: tuple((int(c[0]), int(c[1]), int(c[2])) for c in corners)
59
+ for tier, corners in enumerate(_CUBE_VTX)
60
+ }
61
+ # Vanilla element rotation: 90-degree-free angles about one axis through an
62
+ # origin, optionally rescaled on the perpendicular axes by 1/cos(|angle|).
63
+
64
+
65
+
66
+ def _corner_tuple(point: Any) -> tuple[float, float, float]:
67
+ """Convert one vertex row to a plain three-float tuple."""
68
+ x, y, z = (float(value) for value in point)
69
+ return (x, y, z)
70
+
71
+
72
+
73
+ def _sorted_corners(quad: Any) -> list[tuple[float, float, float]]:
74
+ """Return a quad's corners as a sorted, order-independent list."""
75
+ return sorted(_corner_tuple(point) for point in quad["vtx"])
76
+
77
+
78
+ _UNIT_CORNERS: dict[int, list[tuple[float, float, float]]] = {
79
+ tier: sorted(_corner_tuple(corner) for corner in corners)
80
+ for tier, corners in _FACE_GEOM.items()
81
+ }
82
+
83
+
84
+
85
+ # Unit-cube corners per tier as sorted order-independent lists: blockstate
86
+ # rotations map a face onto its own square with reversed winding, so an
87
+ # element-wise comparison would wrongly reject rotated opaque cubes.
88
+
89
+
90
+
91
+
92
+ def _unit_cube_occl(
93
+ quads: np.ndarray, tex_arrays: dict[str, np.ndarray]
94
+ ) -> bool:
95
+ """Judge the v1 opaque-cube rule: exact unit cube, visible faces opaque."""
96
+ if len(quads) != 6:
97
+ return False
98
+ by_tier: dict[int, Any] = {}
99
+ for quad in quads:
100
+ by_tier[int(quad["shade"])] = quad
101
+ if set(by_tier) != set(_FACE_GEOM):
102
+ return False
103
+ for tier in _FACE_GEOM:
104
+ if _sorted_corners(by_tier[tier]) != _UNIT_CORNERS[tier]:
105
+ return False
106
+ alphas: list[float] = []
107
+ for tier in (TIER_UP, TIER_SOUTH, TIER_EAST):
108
+ array = tex_arrays.get(str(by_tier[tier]["texkey"]))
109
+ if array is None:
110
+ return False
111
+ alphas.append(float(array[..., 3].mean()))
112
+ return float(np.mean(alphas)) >= _OPAQUE_ALPHA
113
+
114
+
115
+
116
+ def _compile_elements(
117
+ elements: list[dict[str, Any]],
118
+ textures: dict[str, str],
119
+ ns: str,
120
+ x_rot: int,
121
+ y_rot: int,
122
+ texkey_for: Callable[[dict[str, Any], dict[str, str], str], str | None],
123
+ *,
124
+ uvlock: bool = False,
125
+ ) -> np.ndarray | None:
126
+ """Compile model elements into world-space quads for one variant."""
127
+ rows: list[tuple[Any, Any, str, int, bool, int]] = []
128
+ kx = (int(x_rot) // 90) % 4
129
+ ky = (int(y_rot) // 90) % 4
130
+ for element in elements:
131
+ if not isinstance(element, dict):
132
+ continue
133
+ bounds = _element_bounds(element)
134
+ faces = element.get("faces")
135
+ if bounds is None or not isinstance(faces, dict):
136
+ continue
137
+ try:
138
+ rotation = _element_rotation(element)
139
+ except ValueError:
140
+ continue
141
+ thin = _thin_axes(bounds)
142
+ if len(thin) > 1:
143
+ continue
144
+ rotated = rotation is not None
145
+ # Zero-thickness planes and rotated elements have non-axis-aligned
146
+ # or paired back faces: spec marks them dbl (double-sided, no AO).
147
+ double: bool = bool(thin) or rotated
148
+ shaded = bool(element.get("shade", True))
149
+ for face_name, tier in _FACE_TIER.items():
150
+ face = faces.get(face_name)
151
+ if not isinstance(face, dict):
152
+ continue
153
+ if thin and _VEC[face_name][thin[0]] == 0:
154
+ continue
155
+ texkey = texkey_for(face, textures, ns)
156
+ if texkey is None:
157
+ continue
158
+ corners = _box_corners(bounds, tier)
159
+ uvs = _face_uvs(face, tier, corners)
160
+ if uvs is None:
161
+ continue
162
+ if double or not shaded:
163
+ shade: int = TIER_UP
164
+ else:
165
+ shade = _FACE_TIER[_rotate(face_name, x_rot, y_rot)]
166
+ if rotation is not None:
167
+ axis, angle, origin, factor = rotation
168
+ corners = [
169
+ _rotate_element_point(corner, axis, angle, origin, factor)
170
+ for corner in corners
171
+ ]
172
+ cullface = face.get("cullface")
173
+ # A non-string cullface drops the face's cull, not the block.
174
+ cull = (
175
+ _FACE_TIER[_rotate(cullface, x_rot, y_rot)]
176
+ if isinstance(cullface, str) and cullface in _VEC
177
+ else CULL_NONE
178
+ )
179
+ # Vanilla bakes UV compensation only for uvlock variants.
180
+ turns = _UV_BAKE[(kx, ky)][tier] if uvlock else 0
181
+ uvs = [_bake_vertex_uv(uv, turns) for uv in uvs]
182
+ rows.append(
183
+ (_rotate_corners(corners, kx, ky), uvs, texkey, shade, double, cull)
184
+ )
185
+ if not rows:
186
+ return None
187
+ quads = np.empty(len(rows), dtype=QUAD_DTYPE)
188
+ for index, (corners, uvs, texkey, shade, dbl, cull) in enumerate(rows):
189
+ quads[index] = (corners, uvs, texkey, shade, dbl, cull)
190
+ keep = is_parallelogram(quads)
191
+ if not keep.any():
192
+ return None
193
+ return quads[keep]
194
+
195
+
196
+
197
+ def _thin_axes(
198
+ bounds: tuple[float, float, float, float, float, float],
199
+ ) -> list[int]:
200
+ """Indices of the axes where from equals to (zero-thickness planes)."""
201
+ x0, y0, z0, x1, y1, z1 = bounds
202
+ sizes = (x1 - x0, y1 - y0, z1 - z0)
203
+ return [axis for axis, size in enumerate(sizes) if size == 0.0]
204
+
205
+
206
+
207
+ def _element_rotation(
208
+ element: dict[str, Any],
209
+ ) -> tuple[int, float, tuple[float, float, float], float] | None:
210
+ """Parse rotation into (axis, angle, origin, rescale factor) or None."""
211
+ rot = element.get("rotation")
212
+ if rot is None:
213
+ return None
214
+ if not isinstance(rot, dict):
215
+ raise ValueError("element rotation must be a mapping")
216
+ axis = _ROT_AXIS.get(rot.get("axis", "y"))
217
+ if axis is None:
218
+ raise ValueError("element rotation has an unknown axis")
219
+ angle = rot.get("angle", 0.0)
220
+ if isinstance(angle, bool) or not isinstance(angle, (int, float)):
221
+ raise ValueError("element rotation angle must be a number")
222
+ angle = float(angle)
223
+ if not -45.0 <= angle <= 45.0 or angle % 22.5 != 0.0:
224
+ raise ValueError("element rotation angle out of the vanilla set")
225
+ origin = rot.get("origin", (8.0, 8.0, 8.0))
226
+ if not isinstance(origin, (list, tuple)) or len(origin) != 3:
227
+ raise ValueError("element rotation origin must be three numbers")
228
+ try:
229
+ point = tuple(float(value) for value in origin)
230
+ except (TypeError, ValueError, OverflowError) as exc:
231
+ raise ValueError("element rotation origin must be three numbers") from exc
232
+ if not all(math.isfinite(value) for value in point):
233
+ raise ValueError("element rotation origin must be finite")
234
+ rescale = bool(rot.get("rescale", False))
235
+ factor = _RESCALE_FACTORS.get(abs(angle), 1.0) if rescale else 1.0
236
+ return (axis, angle, cast("tuple[float, float, float]", point), factor)
237
+
238
+
239
+
240
+ def _rotate_element_point(
241
+ point: tuple[float, float, float],
242
+ axis: int,
243
+ angle: float,
244
+ origin: tuple[float, float, float],
245
+ factor: float,
246
+ ) -> tuple[float, float, float]:
247
+ """Rotate one point by angle about the axis through origin, rescaled."""
248
+ rel = [point[i] - origin[i] for i in range(3)]
249
+ rad = math.radians(angle)
250
+ cos, sin = math.cos(rad), math.sin(rad)
251
+ if axis == 0:
252
+ rel[1], rel[2] = rel[1] * cos - rel[2] * sin, rel[1] * sin + rel[2] * cos
253
+ elif axis == 1:
254
+ rel[0], rel[2] = rel[0] * cos + rel[2] * sin, -rel[0] * sin + rel[2] * cos
255
+ else:
256
+ rel[0], rel[1] = rel[0] * cos - rel[1] * sin, rel[0] * sin + rel[1] * cos
257
+ if factor != 1.0:
258
+ for i in range(3):
259
+ if i != axis:
260
+ rel[i] *= factor
261
+ return (rel[0] + origin[0], rel[1] + origin[1], rel[2] + origin[2])
262
+
263
+
264
+
265
+ def _element_bounds(
266
+ element: dict[str, Any],
267
+ ) -> tuple[float, float, float, float, float, float] | None:
268
+ """Parse from/to into six finite floats, or None when malformed."""
269
+ frm = element.get("from")
270
+ to = element.get("to")
271
+ if not isinstance(frm, (list, tuple)) or not isinstance(to, (list, tuple)):
272
+ return None
273
+ if len(frm) != 3 or len(to) != 3:
274
+ return None
275
+ try:
276
+ values = tuple(float(value) for value in (*frm, *to))
277
+ except (TypeError, ValueError, OverflowError):
278
+ return None
279
+ if not all(math.isfinite(value) for value in values):
280
+ return None
281
+ return cast("tuple[float, float, float, float, float, float]", values)
282
+
283
+
284
+
285
+ def _box_corners(
286
+ bounds: tuple[float, float, float, float, float, float], tier: int
287
+ ) -> list[tuple[float, float, float]]:
288
+ """Scale the pinned unit-cube corners of one tier onto an element box."""
289
+ x0, y0, z0, x1, y1, z1 = bounds
290
+ axis = ((x0, x1), (y0, y1), (z0, z1))
291
+ return [
292
+ (axis[0][corner[0] // 16], axis[1][corner[1] // 16], axis[2][corner[2] // 16])
293
+ for corner in _FACE_GEOM[tier]
294
+ ]
295
+
296
+
297
+
298
+ def _face_uvs(
299
+ face: dict[str, Any], tier: int, corners: list[tuple[float, float, float]]
300
+ ) -> list[tuple[float, float]] | None:
301
+ """Resolve per-corner texel coords per the vanilla vertex tables."""
302
+ rect = face.get("uv")
303
+ if rect is None:
304
+ rect = _default_face_uv(tier, corners)
305
+ else:
306
+ if not isinstance(rect, (list, tuple)) or len(rect) != 4:
307
+ return None
308
+ try:
309
+ rect = (float(rect[0]), float(rect[1]), float(rect[2]), float(rect[3]))
310
+ except (TypeError, ValueError, OverflowError):
311
+ return None
312
+ if not all(math.isfinite(value) for value in rect):
313
+ return None
314
+ if min(rect) < 0.0 or max(rect) > 16.0:
315
+ return None
316
+ rotation = face.get("rotation")
317
+ shift = 0
318
+ if rotation is not None:
319
+ if isinstance(rotation, bool) or not isinstance(rotation, (int, float)):
320
+ return None
321
+ if rotation not in (0, 90, 180, 270):
322
+ return None
323
+ shift = int(rotation) // 90
324
+ u1, v1, u2, v2 = rect
325
+ corner_uv = ((u1, v1), (u1, v2), (u2, v2), (u2, v1))
326
+ return [
327
+ corner_uv[(vertex + shift) % 4]
328
+ for vertex in _VANILLA_VERTEX[tier]
329
+ ]
330
+
331
+
332
+
333
+ _VANILLA_VERTEX = {
334
+ TIER_DOWN: (0, 3, 2, 1),
335
+ TIER_UP: (0, 3, 2, 1),
336
+ TIER_NORTH: (0, 3, 2, 1),
337
+ TIER_SOUTH: (0, 3, 2, 1),
338
+ TIER_WEST: (3, 0, 1, 2),
339
+ TIER_EAST: (3, 0, 1, 2),
340
+ }
341
+
342
+
343
+
344
+ _UV_BAKE = {
345
+ (0, 0): (0, 0, 0, 0, 0, 0),
346
+ (0, 1): (3, 1, 0, 0, 0, 0),
347
+ (0, 2): (2, 2, 0, 0, 0, 0),
348
+ (0, 3): (1, 3, 0, 0, 0, 0),
349
+ (1, 0): (0, 2, 2, 0, 3, 1),
350
+ (1, 1): (0, 2, 1, 1, 3, 1),
351
+ (1, 2): (0, 2, 0, 2, 3, 1),
352
+ (1, 3): (0, 2, 3, 3, 3, 1),
353
+ (2, 0): (0, 0, 2, 2, 2, 2),
354
+ (2, 1): (3, 1, 2, 2, 2, 2),
355
+ (2, 2): (2, 2, 2, 2, 2, 2),
356
+ (2, 3): (1, 3, 2, 2, 2, 2),
357
+ (3, 0): (2, 0, 2, 0, 1, 3),
358
+ (3, 1): (2, 0, 3, 3, 1, 3),
359
+ (3, 2): (2, 0, 0, 2, 1, 3),
360
+ (3, 3): (2, 0, 1, 1, 1, 3),
361
+ }
362
+
363
+
364
+ def _bake_vertex_uv(uv: tuple[float, float], turns: int) -> tuple[float, float]:
365
+ """Rotate one texel coord around the tile center by quarter-turns."""
366
+ if turns == 0:
367
+ return uv
368
+ u, v = uv
369
+ if turns == 1:
370
+ return (16.0 - v, u)
371
+ if turns == 2:
372
+ return (16.0 - u, 16.0 - v)
373
+ return (v, 16.0 - u)
374
+
375
+
376
+ def _default_face_uv(
377
+ tier: int, corners: list[tuple[float, float, float]]
378
+ ) -> tuple[float, float, float, float]:
379
+ """Vanilla defaultFaceUV extents for one tier from the element corners."""
380
+ xs = [corner[0] for corner in corners]
381
+ ys = [corner[1] for corner in corners]
382
+ zs = [corner[2] for corner in corners]
383
+ x0, x1 = min(xs), max(xs)
384
+ y0, y1 = min(ys), max(ys)
385
+ z0, z1 = min(zs), max(zs)
386
+ if tier == TIER_DOWN:
387
+ return (x0, 16.0 - z1, x1, 16.0 - z0)
388
+ if tier == TIER_UP:
389
+ return (x0, z0, x1, z1)
390
+ if tier == TIER_NORTH:
391
+ return (16.0 - x1, 16.0 - y1, 16.0 - x0, 16.0 - y0)
392
+ if tier == TIER_SOUTH:
393
+ return (x0, 16.0 - y1, x1, 16.0 - y0)
394
+ if tier == TIER_WEST:
395
+ return (z0, 16.0 - y1, z1, 16.0 - y0)
396
+ return (16.0 - z1, 16.0 - y1, 16.0 - z0, 16.0 - y0)
397
+
398
+
399
+
400
+ def _rot_x_point(point: tuple[float, float, float]) -> tuple[float, float, float]:
401
+ """Rotate one block-space point 90 degrees around the x axis."""
402
+ return (point[0], point[2], 16.0 - point[1])
403
+
404
+
405
+
406
+ def _rot_y_point(point: tuple[float, float, float]) -> tuple[float, float, float]:
407
+ """Rotate one block-space point 90 degrees around the y axis."""
408
+ return (16.0 - point[2], point[1], point[0])
409
+
410
+
411
+
412
+ def _rotate_corners(
413
+ corners: list[tuple[float, float, float]], kx: int, ky: int
414
+ ) -> list[tuple[float, float, float]]:
415
+ """Apply x then y blockstate rotation steps to all four corners."""
416
+ out = corners
417
+ for _ in range(kx):
418
+ out = [_rot_x_point(point) for point in out]
419
+ for _ in range(ky):
420
+ out = [_rot_y_point(point) for point in out]
421
+ return out
@@ -0,0 +1,181 @@
1
+ """Block-entity-shaped blocks: chests compiled from entity textures."""
2
+
3
+ import numpy as np
4
+
5
+ from litematic.render.pack.blockstate import _state_pairs
6
+ from litematic.render.quads import (
7
+ CULL_NONE,
8
+ QUAD_DTYPE,
9
+ TIER_DOWN,
10
+ TIER_EAST,
11
+ TIER_NORTH,
12
+ TIER_SOUTH,
13
+ TIER_UP,
14
+ TIER_WEST,
15
+ )
16
+
17
+ _CHEST_BASES = {
18
+ "chest": "normal",
19
+ "trapped_chest": "trapped",
20
+ "ender_chest": "ender",
21
+ "copper_chest": "copper",
22
+ "waxed_copper_chest": "copper",
23
+ "exposed_copper_chest": "copper_exposed",
24
+ "waxed_exposed_copper_chest": "copper_exposed",
25
+ "weathered_copper_chest": "copper_weathered",
26
+ "waxed_weathered_copper_chest": "copper_weathered",
27
+ "oxidized_copper_chest": "copper_oxidized",
28
+ "waxed_oxidized_copper_chest": "copper_oxidized",
29
+ }
30
+ # The model's front is +Z (south); the rotation turns it to the facing.
31
+ _FACING_ROTATION = {"south": 0, "east": 90, "north": 180, "west": 270}
32
+ _COS_SIN = {0: (1, 0), 90: (0, 1), 180: (-1, 0), 270: (0, -1)}
33
+
34
+ _TIER_OF_NORMAL = {
35
+ (0, 1, 0): TIER_UP,
36
+ (0, -1, 0): TIER_DOWN,
37
+ (0, 0, -1): TIER_NORTH,
38
+ (0, 0, 1): TIER_SOUTH,
39
+ (-1, 0, 0): TIER_WEST,
40
+ (1, 0, 0): TIER_EAST,
41
+ }
42
+
43
+
44
+ def chest_quads(
45
+ block_id: str,
46
+ state_key: str,
47
+ resolve_texture,
48
+ ) -> np.ndarray | None:
49
+ """Compile one closed chest from entity textures, or None if uncovered.
50
+
51
+ `resolve_texture` maps an entity texture ref (entity/chest/...) to a
52
+ registered texkey, returning None when the pack lacks the file.
53
+ """
54
+ stem = block_id.partition(":")[2]
55
+ base = _CHEST_BASES.get(stem)
56
+ if base is None:
57
+ return None
58
+ properties = _state_pairs(state_key)
59
+ kind = properties.get("type", "single")
60
+ if kind not in ("single", "left", "right"):
61
+ return None
62
+ texkey = resolve_texture(_chest_texture_ref(base, kind))
63
+ if texkey is None:
64
+ return None
65
+ degrees = _FACING_ROTATION.get(properties.get("facing", "north"), 180)
66
+
67
+ faces: list[tuple[list, list, tuple[int, int, int], str]] = []
68
+ if kind == "single":
69
+ _add_box(faces, (1, 0, 1), (15, 10, 15), (0, 19), texkey)
70
+ _add_box(faces, (1, 9, 1), (15, 14, 15), (0, 0), texkey)
71
+ _add_box(faces, (7, 7, 15), (9, 11, 16), (0, 0), texkey)
72
+ else:
73
+ x1, x2 = (0, 15) if kind == "left" else (1, 16)
74
+ _add_box(faces, (x1, 0, 1), (x2, 10, 15), (0, 19), texkey)
75
+ _add_box(faces, (x1, 9, 1), (x2, 14, 15), (0, 0), texkey)
76
+ lock_x = 0 if kind == "left" else 15
77
+ _add_box(faces, (lock_x, 7, 15), (lock_x + 1, 11, 16), (0, 0), texkey)
78
+
79
+ quads = np.empty(len(faces), dtype=QUAD_DTYPE)
80
+ for index, (vertices, uvs, normal, face_texkey) in enumerate(faces):
81
+ rotated = [_rotate_y(point, degrees) for point in vertices]
82
+ quads[index] = (
83
+ rotated,
84
+ [(u / 4.0, v / 4.0) for (u, v) in uvs],
85
+ face_texkey,
86
+ _TIER_OF_NORMAL[_rotate_normal(normal, degrees)],
87
+ False,
88
+ CULL_NONE,
89
+ )
90
+ return quads
91
+
92
+
93
+ def _chest_texture_ref(base: str, kind: str) -> str:
94
+ if kind in ("left", "right"):
95
+ return f"entity/chest/{base}_{kind}"
96
+ return f"entity/chest/{base}"
97
+
98
+
99
+ def _add_box(
100
+ faces: list,
101
+ frm: tuple[int, int, int],
102
+ to: tuple[int, int, int],
103
+ origin: tuple[int, int],
104
+ texkey: str,
105
+ ) -> None:
106
+ """Append one box's six faces in entity-unwrap order and UV layout."""
107
+ x1, y1, z1 = frm
108
+ x2, y2, z2 = to
109
+ width, height, depth = x2 - x1, y2 - y1, z2 - z1
110
+ u, v = origin
111
+ x1f, y1f, z1f, x2f, y2f, z2f = (
112
+ float(x1), float(y1), float(z1), float(x2), float(y2), float(z2)
113
+ )
114
+ uf, vf, wf, hf, df = (
115
+ float(u), float(v), float(width), float(height), float(depth)
116
+ )
117
+ faces.extend([
118
+ (
119
+ [(x1f, y1f, z2f), (x1f, y1f, z1f), (x2f, y1f, z1f), (x2f, y1f, z2f)],
120
+ [(uf + df, vf), (uf + df, vf + df),
121
+ (uf + df + wf, vf + df), (uf + df + wf, vf)],
122
+ (0, -1, 0),
123
+ texkey,
124
+ ),
125
+ (
126
+ [(x1f, y2f, z1f), (x1f, y2f, z2f), (x2f, y2f, z2f), (x2f, y2f, z1f)],
127
+ [(uf + df + wf, vf + df), (uf + df + wf, vf),
128
+ (uf + df + 2 * wf, vf), (uf + df + 2 * wf, vf + df)],
129
+ (0, 1, 0),
130
+ texkey,
131
+ ),
132
+ (
133
+ [(x1f, y1f, z1f), (x1f, y2f, z1f), (x2f, y2f, z1f), (x2f, y1f, z1f)],
134
+ [(uf + df, vf + df), (uf + df, vf + df + hf),
135
+ (uf + df + wf, vf + df + hf), (uf + df + wf, vf + df)],
136
+ (0, 0, -1),
137
+ texkey,
138
+ ),
139
+ (
140
+ [(x2f, y1f, z2f), (x2f, y2f, z2f), (x1f, y2f, z2f), (x1f, y1f, z2f)],
141
+ [(uf + df + wf + df, vf + df), (uf + df + wf + df, vf + df + hf),
142
+ (uf + df + 2 * wf + df, vf + df + hf),
143
+ (uf + df + 2 * wf + df, vf + df)],
144
+ (0, 0, 1),
145
+ texkey,
146
+ ),
147
+ (
148
+ [(x1f, y1f, z2f), (x1f, y2f, z2f), (x1f, y2f, z1f), (x1f, y1f, z1f)],
149
+ [(uf, vf + df), (uf, vf + df + hf),
150
+ (uf + df, vf + df + hf), (uf + df, vf + df)],
151
+ (-1, 0, 0),
152
+ texkey,
153
+ ),
154
+ (
155
+ [(x2f, y1f, z1f), (x2f, y2f, z1f), (x2f, y2f, z2f), (x2f, y1f, z2f)],
156
+ [(uf + df + wf, vf + df), (uf + df + wf, vf + df + hf),
157
+ (uf + df + wf + df, vf + df + hf), (uf + df + wf + df, vf + df)],
158
+ (1, 0, 0),
159
+ texkey,
160
+ ),
161
+ ])
162
+
163
+
164
+ def _rotate_y(
165
+ point: tuple[float, float, float], degrees: int
166
+ ) -> tuple[float, float, float]:
167
+ """Rotate one block-space point about the y axis through (8, *, 8)."""
168
+ cos, sin = _COS_SIN[degrees]
169
+ x, y, z = point[0] - 8.0, point[1], point[2] - 8.0
170
+ return (x * cos + z * sin + 8.0, y, -x * sin + z * cos + 8.0)
171
+
172
+
173
+ def _rotate_normal(
174
+ normal: tuple[int, int, int], degrees: int
175
+ ) -> tuple[int, int, int]:
176
+ cos, sin = _COS_SIN[degrees]
177
+ return (
178
+ round(normal[0] * cos + normal[2] * sin),
179
+ normal[1],
180
+ round(-normal[0] * sin + normal[2] * cos),
181
+ )