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,395 @@
1
+ """Quad painter: inverse-basis rasterization with vanilla face lighting."""
2
+
3
+ import numpy as np
4
+
5
+ from litematic.render.errors import RenderError
6
+ from litematic.render.quads import FACE_LIGHT
7
+ from litematic.render.scene.isometric import _Scene
8
+
9
+ _CUTOUT_ALPHA = 16
10
+ _COVER_ALPHA = 250
11
+ _FUSE_PIXELS = 1 << 25
12
+ # Per-rasterize-call canvas-clipped bbox budget; oversized depth layers
13
+ # split into contiguous quad sub-batches under it.
14
+ _BATCH_PIXELS = 1 << 20
15
+ # A projected quad rasterizes only when its edge vectors are not parallel;
16
+ # the threshold is relative (a fraction of |e1|*|e2|) so it holds at every
17
+ # tile scale and only true slivers / edge-on planes are dropped.
18
+ _PARALLEL_EPS = 1e-4
19
+ # Scene quads must stay parallelograms, matching quads.is_parallelogram's
20
+ # extract-side eps; pack vertices may legally reach beyond 0..16.
21
+ _PARALLELOGRAM_EPS = 1e-3
22
+ _LIGHT = np.asarray(FACE_LIGHT, np.float64)
23
+
24
+
25
+ def _paint(
26
+ scene: _Scene, t: int, background: tuple[int, int, int, int]
27
+ ) -> np.ndarray:
28
+ """Rasterize the scene's quads onto an RGBA uint8 canvas at scale t."""
29
+ try:
30
+ return _paint_inner(scene, t, background)
31
+ except RenderError:
32
+ raise
33
+ except Exception as exc:
34
+ raise RenderError(f"paint: quad rasterization failed: {exc}") from exc
35
+
36
+
37
+ def _paint_inner(
38
+ scene: _Scene, t: int, background: tuple[int, int, int, int]
39
+ ) -> np.ndarray:
40
+ """Fill the canvas background, then paint depth layers far to near."""
41
+ canvas = np.empty((t * scene.span_h, 2 * t * scene.span_w, 4), np.uint8)
42
+ canvas[:] = background
43
+ if len(scene.x) == 0:
44
+ return canvas
45
+ _assert_parallelograms(scene)
46
+ stacks, group_ids, slot_ids = _texture_stack(scene)
47
+ quad_group = group_ids[scene.texkey_ids]
48
+ quad_slot = slot_ids[scene.texkey_ids]
49
+ sx, sy = _project(scene, t)
50
+ depth = _depth(scene)
51
+ order = np.argsort(depth, kind="stable")
52
+ edges = np.flatnonzero(np.diff(depth[order])) + 1
53
+ starts = np.concatenate(([0], edges))
54
+ stops = np.concatenate((edges, [len(order)]))
55
+ for lo, hi in zip(starts.tolist(), stops.tolist()):
56
+ _paint_layer(canvas, scene, order[lo:hi], sx, sy, stacks,
57
+ quad_group, quad_slot)
58
+ del sx, sy, depth, order
59
+ return canvas
60
+
61
+
62
+ def _assert_parallelograms(scene: _Scene) -> None:
63
+ """Fail fast on non-parallelogram quads, mirroring the extract check."""
64
+ vtx = scene.quad_vtx.astype(np.float64)
65
+ diag = (vtx[:, 0] + vtx[:, 2]) - (vtx[:, 1] + vtx[:, 3])
66
+ if np.any(np.abs(diag) > _PARALLELOGRAM_EPS):
67
+ raise RenderError(
68
+ "paint: non-parallelogram quad reached the painter; "
69
+ "quads must be dropped at extraction time"
70
+ )
71
+
72
+
73
+ def _texture_stack(
74
+ scene: _Scene,
75
+ ) -> tuple[list[np.ndarray], np.ndarray, np.ndarray]:
76
+ """Group scene textures by (h, w) into sorted stacks plus lookup ids."""
77
+ arrays = list(scene.texkeys.values())
78
+ if scene.texkey_ids.size and int(scene.texkey_ids.max()) >= len(arrays):
79
+ raise RenderError("paint: texkey id outside the scene texture table")
80
+ if not arrays:
81
+ return [], np.zeros(0, np.int8), np.zeros(0, np.int64)
82
+ shapes = sorted({arr.shape[:2] for arr in arrays})
83
+ stacks = [
84
+ np.stack([a for a in arrays if a.shape[:2] == shape])
85
+ for shape in shapes
86
+ ]
87
+ group_ids = np.zeros(len(arrays), np.int8)
88
+ slot_ids = np.zeros(len(arrays), np.int64)
89
+ for gid, shape in enumerate(shapes):
90
+ for slot, index in enumerate(
91
+ i for i, a in enumerate(arrays) if a.shape[:2] == shape
92
+ ):
93
+ group_ids[index] = gid
94
+ slot_ids[index] = slot
95
+ return stacks, group_ids, slot_ids
96
+
97
+
98
+ def _project(scene: _Scene, t: int) -> tuple[np.ndarray, np.ndarray]:
99
+ """Project quad vertices to anchored screen columns and rows."""
100
+ fx = scene.quad_vtx[..., 0].astype(np.float64) / 16.0
101
+ fy = scene.quad_vtx[..., 1].astype(np.float64) / 16.0
102
+ fz = scene.quad_vtx[..., 2].astype(np.float64) / 16.0
103
+ bx = scene.x[:, None].astype(np.float64)
104
+ by = scene.y[:, None].astype(np.float64)
105
+ bz = scene.z[:, None].astype(np.float64)
106
+ col = 2.0 * t * (bx + fx - bz - fz) + 2.0 * t * (int(scene.z.max()) + 1)
107
+ row = t * (bx + bz + fx + fz) - 2.0 * t * (by + fy)
108
+ row += 2.0 * t * (int(scene.y.max()) + 1)
109
+ return col, row
110
+
111
+
112
+ def _depth(scene: _Scene) -> np.ndarray:
113
+ """Painter depth: the block sum plus the centroid in block fractions."""
114
+ centroid = scene.quad_vtx.astype(np.float64).mean(axis=1) / 16.0
115
+ return (
116
+ scene.x.astype(np.float64)
117
+ + scene.y.astype(np.float64)
118
+ + scene.z.astype(np.float64)
119
+ + centroid.sum(axis=1)
120
+ )
121
+
122
+
123
+ def _paint_layer(
124
+ canvas: np.ndarray,
125
+ scene: _Scene,
126
+ layer: np.ndarray,
127
+ sx: np.ndarray,
128
+ sy: np.ndarray,
129
+ stacks: list[np.ndarray],
130
+ quad_group: np.ndarray,
131
+ quad_slot: np.ndarray,
132
+ ) -> None:
133
+ """Rasterize one depth layer group by group: covers first, then blends."""
134
+ layer_groups = quad_group[layer]
135
+ for gid, stack in enumerate(stacks):
136
+ sub = layer[layer_groups == gid]
137
+ if len(sub) == 0:
138
+ continue
139
+ _paint_group(
140
+ canvas, scene, sub, sx, sy, stack, quad_slot, stack.shape[1:3]
141
+ )
142
+
143
+
144
+ def _paint_group(
145
+ canvas: np.ndarray,
146
+ scene: _Scene,
147
+ layer: np.ndarray,
148
+ sx: np.ndarray,
149
+ sy: np.ndarray,
150
+ textures: np.ndarray,
151
+ quad_slot: np.ndarray,
152
+ tex_hw: tuple[int, int],
153
+ ) -> None:
154
+ """Rasterize one depth layer restricted to a single texture shape."""
155
+ quad_sx = sx[layer]
156
+ quad_sy = sy[layer]
157
+ uv = scene.quad_uv[layer].astype(np.float64)
158
+ ao = scene.quad_ao[layer].astype(np.float64)
159
+ shade = scene.quad_shade[layer]
160
+ tex = textures[quad_slot[layer]]
161
+ dbl = np.nonzero(scene.quad_dbl[layer])[0]
162
+ if len(dbl):
163
+ # Double-sided quads paint both windings in the same layer.
164
+ quad_sx = np.concatenate([quad_sx, quad_sx[dbl, ::-1]])
165
+ quad_sy = np.concatenate([quad_sy, quad_sy[dbl, ::-1]])
166
+ uv = np.concatenate([uv, uv[dbl, ::-1]])
167
+ ao = np.concatenate([ao, ao[dbl, ::-1]])
168
+ shade = np.concatenate([shade, shade[dbl]])
169
+ tex = np.concatenate([tex, tex[dbl]])
170
+ height, width = canvas.shape[:2]
171
+ pixels = _bbox_pixels(quad_sx, quad_sy, height, width)[0]
172
+ chunks = _layer_chunks(pixels, _BATCH_PIXELS)
173
+ deferred = []
174
+ for lo, hi in chunks:
175
+ rows, cols, values = _rasterize(
176
+ quad_sx[lo:hi],
177
+ quad_sy[lo:hi],
178
+ uv[lo:hi],
179
+ ao[lo:hi],
180
+ shade[lo:hi],
181
+ tex[lo:hi],
182
+ tex_hw,
183
+ height,
184
+ width,
185
+ )
186
+ alpha = values[:, 3]
187
+ cover = alpha >= _COVER_ALPHA
188
+ canvas[rows[cover], cols[cover]] = values[cover]
189
+ blend = (alpha >= _CUTOUT_ALPHA) & ~cover
190
+ if blend.any():
191
+ deferred.append((rows[blend], cols[blend], values[blend]))
192
+ for rows, cols, values in deferred:
193
+ _blend_pixels(canvas, rows, cols, values)
194
+
195
+
196
+ def _bbox_pixels(
197
+ sx: np.ndarray, sy: np.ndarray, height: int, width: int
198
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
199
+ """Per-quad canvas-clipped bbox: (pixels, width, height, lo_col, lo_row)."""
200
+ lo_c = np.maximum(np.floor(sx.min(axis=1)), 0.0)
201
+ hi_c = np.minimum(np.ceil(sx.max(axis=1)), float(width))
202
+ lo_r = np.maximum(np.floor(sy.min(axis=1)), 0.0)
203
+ hi_r = np.minimum(np.ceil(sy.max(axis=1)), float(height))
204
+ width_q = np.maximum((hi_c - lo_c).astype(np.int64), 0)
205
+ height_q = np.maximum((hi_r - lo_r).astype(np.int64), 0)
206
+ return width_q * height_q, width_q, height_q, lo_c, lo_r
207
+
208
+
209
+ def _layer_chunks(pixels: np.ndarray, budget: int) -> list[tuple[int, int]]:
210
+ """Contiguous [start, stop) quad ranges whose bbox sum fits budget."""
211
+ total = int(pixels.sum())
212
+ if total <= budget:
213
+ return [(0, len(pixels))]
214
+ cum = np.cumsum(pixels)
215
+ chunks: list[tuple[int, int]] = []
216
+ start = 0
217
+ while start < len(pixels):
218
+ consumed = int(cum[start - 1]) if start else 0
219
+ stop = int(np.searchsorted(cum, consumed + budget, side="right"))
220
+ if stop <= start:
221
+ stop = start + 1 # a single quad over budget paints alone
222
+ chunks.append((start, stop))
223
+ start = stop
224
+ return chunks
225
+
226
+
227
+ def _rasterize(
228
+ sx: np.ndarray,
229
+ sy: np.ndarray,
230
+ uv: np.ndarray,
231
+ ao: np.ndarray,
232
+ shade: np.ndarray,
233
+ tex: np.ndarray,
234
+ tex_hw: tuple[int, int],
235
+ height: int,
236
+ width: int,
237
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
238
+ """Sample each quad's canvas-clipped bbox through the inverse basis."""
239
+ counts, width_q, height_q, lo_c, lo_r = _bbox_pixels(
240
+ sx, sy, height, width
241
+ )
242
+ total = int(counts.sum())
243
+ if total == 0:
244
+ empty = np.zeros(0, np.int64)
245
+ return empty, empty, np.zeros((0, 4), np.uint8)
246
+ quad_of = np.repeat(np.arange(len(sx), dtype=np.int64), counts)
247
+ starts = np.cumsum(counts) - counts
248
+ within = np.arange(total, dtype=np.int64) - np.repeat(starts, counts)
249
+ span = np.maximum(width_q[quad_of], 1)
250
+ cols = lo_c[quad_of].astype(np.int64) + within % span
251
+ rows = lo_r[quad_of].astype(np.int64) + within // span
252
+ u, v, inside = _inverse_basis(
253
+ sx, sy, quad_of, cols + 0.5, rows + 0.5
254
+ )
255
+ if not inside.any():
256
+ empty = np.zeros(0, np.int64)
257
+ return empty, empty, np.zeros((0, 4), np.uint8)
258
+ return _shade_pixels(
259
+ quad_of[inside],
260
+ u[inside],
261
+ v[inside],
262
+ rows[inside],
263
+ cols[inside],
264
+ uv,
265
+ ao,
266
+ shade,
267
+ tex,
268
+ tex_hw,
269
+ )
270
+
271
+
272
+ def _inverse_basis(
273
+ sx: np.ndarray,
274
+ sy: np.ndarray,
275
+ quad_of: np.ndarray,
276
+ px: np.ndarray,
277
+ py: np.ndarray,
278
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
279
+ """Solve p = p0 + u*(p1-p0) + v*(p3-p0); flag non-degenerate hits."""
280
+ p0x = sx[quad_of, 0]
281
+ p0y = sy[quad_of, 0]
282
+ e1x = sx[quad_of, 1] - p0x
283
+ e1y = sy[quad_of, 1] - p0y
284
+ e2x = sx[quad_of, 3] - p0x
285
+ e2y = sy[quad_of, 3] - p0y
286
+ det = e1x * e2y - e2x * e1y
287
+ dx = px - p0x
288
+ dy = py - p0y
289
+ with np.errstate(divide="ignore", invalid="ignore", over="ignore"):
290
+ u = (dx * e2y - dy * e2x) / det
291
+ v = (e1x * dy - e1y * dx) / det
292
+ norm = np.hypot(e1x, e1y) * np.hypot(e2x, e2y)
293
+ inside = (
294
+ (np.abs(det) > _PARALLEL_EPS * norm)
295
+ & (u >= 0.0)
296
+ & (u < 1.0)
297
+ & (v >= 0.0)
298
+ & (v < 1.0)
299
+ )
300
+ return u, v, inside
301
+
302
+
303
+ def _shade_pixels(
304
+ quad: np.ndarray,
305
+ u: np.ndarray,
306
+ v: np.ndarray,
307
+ rows: np.ndarray,
308
+ cols: np.ndarray,
309
+ uv: np.ndarray,
310
+ ao: np.ndarray,
311
+ shade: np.ndarray,
312
+ tex: np.ndarray,
313
+ tex_hw: tuple[int, int],
314
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
315
+ """Gather texels, apply face light times bilinear AO, and return RGBA."""
316
+ du = u * (uv[quad, 1, 0] - uv[quad, 0, 0]) + v * (
317
+ uv[quad, 3, 0] - uv[quad, 0, 0]
318
+ )
319
+ dv = u * (uv[quad, 1, 1] - uv[quad, 0, 1]) + v * (
320
+ uv[quad, 3, 1] - uv[quad, 0, 1]
321
+ )
322
+ # Scale 0..16 uv space to the texture's native size; clip, no wrap.
323
+ tex_h, tex_w = tex_hw
324
+ tx = np.clip(
325
+ np.floor((uv[quad, 0, 0] + du) * (tex_w / 16.0)), 0, tex_w - 1
326
+ ).astype(np.int64)
327
+ ty = np.clip(
328
+ np.floor((uv[quad, 0, 1] + dv) * (tex_h / 16.0)), 0, tex_h - 1
329
+ ).astype(np.int64)
330
+ brightness = _LIGHT[shade[quad]] * (
331
+ ao[quad, 0]
332
+ + u * (ao[quad, 1] - ao[quad, 0])
333
+ + v * (ao[quad, 3] - ao[quad, 0])
334
+ )
335
+ pixels = tex[quad, ty, tx].astype(np.float64)
336
+ lit = np.rint(pixels[:, :3] * brightness[:, None])
337
+ pixels[:, :3] = np.clip(lit, 0.0, 255.0)
338
+ return rows, cols, pixels.astype(np.uint8)
339
+
340
+
341
+ def _blend_pixels(
342
+ canvas: np.ndarray, rows: np.ndarray, cols: np.ndarray, src: np.ndarray
343
+ ) -> None:
344
+ """Over-composite source pixels onto the canvas at the given cells."""
345
+ dst = canvas[rows, cols].astype(np.uint16)
346
+ src_u = src.astype(np.uint16)
347
+ alpha = src_u[:, 3:4]
348
+ out_rgb = np.rint((src_u[:, :3] * alpha + dst[:, :3] * (255 - alpha)) / 255)
349
+ out_alpha = np.rint(src_u[:, 3:4] + dst[:, 3:4] * (255 - alpha) / 255)
350
+ blended = np.concatenate(
351
+ [np.clip(out_rgb, 0, 255), np.clip(out_alpha, 0, 255)], axis=1
352
+ ).astype(np.uint8)
353
+ canvas[rows, cols] = blended
354
+
355
+
356
+ def _choose_scale(
357
+ span_w: int, span_h: int, max_size: int, supersample: int
358
+ ) -> tuple[int, int]:
359
+ """Pick tile scale and supersample factor within the canvas pixel fuse."""
360
+ for ss in range(supersample, 0, -1):
361
+ t = max(1, min(max_size * ss // (2 * span_w), max_size * ss // span_h))
362
+ width = 2 * t * span_w
363
+ height = t * span_h
364
+ fits = width <= max_size * ss and height <= max_size * ss
365
+ if (fits or ss == 1) and width * height <= _FUSE_PIXELS:
366
+ return t, ss
367
+ raise RenderError(
368
+ f"scale: spans {span_w}x{span_h} exceed canvas budget at "
369
+ f"max_size={max_size}"
370
+ )
371
+
372
+
373
+ def _downsample(img: np.ndarray, out_w: int, out_h: int) -> np.ndarray:
374
+ """Box-filter downsample RGBA uint8 with alpha-weighted color."""
375
+ in_h, in_w = img.shape[:2]
376
+ if out_w == in_w and out_h == in_h:
377
+ return img.copy()
378
+ rows = np.minimum(np.arange(in_h) * out_h // in_h, out_h - 1)
379
+ cols = np.minimum(np.arange(in_w) * out_w // in_w, out_w - 1)
380
+ flat = (rows[:, None] * out_w + cols[None, :]).ravel()
381
+ size = out_h * out_w
382
+ alpha = img[..., 3].ravel().astype(np.float64) / 255.0
383
+ weight = np.bincount(flat, weights=alpha, minlength=size)
384
+ count = np.bincount(flat, minlength=size).astype(np.float64)
385
+ out_a = np.rint(weight / count * 255.0)
386
+ covered = (weight > 0) & (out_a > 0)
387
+ out = np.zeros((size, 4), np.uint8)
388
+ out[:, 3] = np.clip(out_a, 0, 255).astype(np.uint8)
389
+ for channel in range(3):
390
+ values = img[..., channel].ravel().astype(np.float64)
391
+ summed = np.bincount(flat, weights=values * alpha, minlength=size)
392
+ mean = np.zeros(size)
393
+ mean[covered] = summed[covered] / weight[covered]
394
+ out[:, channel] = np.clip(np.rint(mean), 0, 255).astype(np.uint8)
395
+ return out.reshape(out_h, out_w, 4)
@@ -0,0 +1,6 @@
1
+ """Builtin shape table: geometry fallbacks for blocks without pack models."""
2
+
3
+ from litematic.render.shapes.boxes import box_quads
4
+ from litematic.render.shapes.families import builtin_quads
5
+
6
+ __all__ = ["box_quads", "builtin_quads"]
@@ -0,0 +1,290 @@
1
+ """Box primitive and single-box builtin families for the shape table."""
2
+
3
+ from collections.abc import Mapping
4
+
5
+ import numpy as np
6
+
7
+ from litematic.render.quads import (
8
+ _CUBE_VTX,
9
+ CULL_NONE,
10
+ QUAD_DTYPE,
11
+ TIER_DOWN,
12
+ TIER_NORTH,
13
+ TIER_SOUTH,
14
+ TIER_UP,
15
+ TIER_WEST,
16
+ )
17
+
18
+ __all__ = ["box_quads"]
19
+
20
+ Box = tuple[float, float, float, float, float, float]
21
+ _FACINGS = ("north", "south", "east", "west")
22
+
23
+ # Per-tier corner templates derived from quads._CUBE_VTX.
24
+ _FACE_CORNERS: tuple[tuple[tuple[int, int, int], ...], ...] = tuple(
25
+ tuple((int(c[0]), int(c[1]), int(c[2])) for c in corners)
26
+ for corners in _CUBE_VTX
27
+ )
28
+
29
+ # Panels keyed by facing: open trapdoors rest on the facing edge, closed
30
+ # doors stand in the facing plane, open doors swing to the side, ladders
31
+ # and wall torches hug the facing wall (Blockscope shape.rs taxonomy).
32
+ _OPEN_TRAPDOOR: dict[str, Box] = {
33
+ "north": (0, 0, 0, 16, 16, 3),
34
+ "south": (0, 0, 13, 16, 16, 16),
35
+ "west": (0, 0, 0, 3, 16, 16),
36
+ "east": (13, 0, 0, 16, 16, 16),
37
+ }
38
+ _DOOR_CLOSED: dict[str, Box] = {
39
+ "north": (0, 0, 0, 16, 16, 3),
40
+ "south": (0, 0, 13, 16, 16, 16),
41
+ "west": (0, 0, 0, 3, 16, 16),
42
+ "east": (13, 0, 0, 16, 16, 16),
43
+ }
44
+ _DOOR_OPEN: dict[str, Box] = {
45
+ "north": (0, 0, 0, 3, 16, 16),
46
+ "south": (13, 0, 0, 16, 16, 16),
47
+ "west": (0, 0, 13, 16, 16, 16),
48
+ "east": (0, 0, 0, 16, 16, 3),
49
+ }
50
+ _LADDER: dict[str, Box] = {
51
+ "north": (0, 0, 0, 16, 16, 2),
52
+ "south": (0, 0, 14, 16, 16, 16),
53
+ "west": (0, 0, 0, 2, 16, 16),
54
+ "east": (14, 0, 0, 16, 16, 16),
55
+ }
56
+ _BUTTON_WALL: dict[str, Box] = {
57
+ "north": (5, 5, 0, 11, 11, 2),
58
+ "south": (5, 5, 14, 11, 11, 16),
59
+ "west": (0, 5, 5, 2, 11, 11),
60
+ "east": (14, 5, 5, 16, 11, 11),
61
+ }
62
+ _TORCH_WALL: dict[str, Box] = {
63
+ "north": (7, 3, 0, 9, 13, 2),
64
+ "south": (7, 3, 14, 9, 13, 16),
65
+ "west": (0, 3, 7, 2, 13, 9),
66
+ "east": (14, 3, 7, 16, 13, 9),
67
+ }
68
+ # Stair step footprint (x0, x1, z0, z1) per facing; north is the default.
69
+ _STAIR_STEP: dict[str, tuple[int, int, int, int]] = {
70
+ "north": (0, 16, 0, 8),
71
+ "south": (0, 16, 8, 16),
72
+ "west": (0, 8, 0, 16),
73
+ "east": (8, 16, 0, 16),
74
+ }
75
+
76
+
77
+ def box_quads(
78
+ x0: float, y0: float, z0: float, x1: float, y1: float, z1: float
79
+ ) -> np.ndarray:
80
+ """Build the six textured faces of one axis-aligned box with auto-UV."""
81
+ lo = (float(x0), float(y0), float(z0))
82
+ hi = (float(x1), float(y1), float(z1))
83
+ quads = np.empty(6, dtype=QUAD_DTYPE)
84
+ for tier in range(6):
85
+ points = [
86
+ tuple(lo[axis] if corner[axis] == 0 else hi[axis] for axis in range(3))
87
+ for corner in _FACE_CORNERS[tier]
88
+ ]
89
+ quads["vtx"][tier] = points
90
+ quads["uv"][tier] = [_auto_uv(tier, *point) for point in points]
91
+ quads["shade"][tier] = tier
92
+ quads["texkey"] = ""
93
+ quads["dbl"] = False
94
+ quads["cull"] = CULL_NONE
95
+ return quads
96
+
97
+
98
+ def _auto_uv(tier: int, x: float, y: float, z: float) -> tuple[float, float]:
99
+ """Map one box corner to texel coords per the pinned auto-UV axes."""
100
+ if tier == TIER_DOWN:
101
+ return (x, 16.0 - z)
102
+ if tier == TIER_UP:
103
+ return (x, z)
104
+ if tier == TIER_NORTH:
105
+ return (16.0 - x, 16.0 - y)
106
+ if tier == TIER_SOUTH:
107
+ return (x, 16.0 - y)
108
+ if tier == TIER_WEST:
109
+ return (z, 16.0 - y)
110
+ return (16.0 - z, 16.0 - y)
111
+
112
+
113
+ def _combine(boxes: list[Box]) -> np.ndarray:
114
+ """Concatenate the six-face quads of several boxes into one array."""
115
+ return np.concatenate([box_quads(*box) for box in boxes])
116
+
117
+
118
+ def _text(properties: Mapping[str, str], key: str, default: str) -> str:
119
+ """Read one property as a string, falling back when missing or odd."""
120
+ value = properties.get(key, default)
121
+ return value if isinstance(value, str) else default
122
+
123
+
124
+ def _flag(properties: Mapping[str, str], key: str) -> bool:
125
+ """Read one property as a boolean flag (only "true" counts)."""
126
+ return _text(properties, key, "false") == "true"
127
+
128
+
129
+ def _facing(properties: Mapping[str, str]) -> str:
130
+ """Read the facing property, limited to the four horizontal names."""
131
+ value = _text(properties, "facing", "north")
132
+ return value if value in _FACINGS else "north"
133
+
134
+
135
+ def _count(
136
+ properties: Mapping[str, str], key: str, default: int, lo: int, hi: int
137
+ ) -> int:
138
+ """Read one property as an integer clamped into [lo, hi]."""
139
+ raw = _text(properties, key, "")
140
+ try:
141
+ value = int(raw)
142
+ except ValueError:
143
+ return default
144
+ return max(lo, min(hi, value))
145
+
146
+
147
+ def _fluid(properties: Mapping[str, str]) -> np.ndarray:
148
+ """Water, lava and bubble columns: level drops the surface."""
149
+ level = _count(properties, "level", 0, 0, 15)
150
+ return box_quads(0, 0, 0, 16, max(1, 16 - level), 16)
151
+
152
+
153
+ def _snow(properties: Mapping[str, str]) -> np.ndarray:
154
+ """Snow layers: each layer adds two texels of height."""
155
+ layers = _count(properties, "layers", 1, 1, 8)
156
+ return box_quads(0, 0, 0, 16, layers * 2, 16)
157
+
158
+
159
+ def _slab(properties: Mapping[str, str]) -> np.ndarray:
160
+ """Slab: bottom/top half box; double is a full cube."""
161
+ kind = _text(properties, "type", "bottom")
162
+ if kind == "double":
163
+ return box_quads(0, 0, 0, 16, 16, 16)
164
+ if kind == "top":
165
+ return box_quads(0, 8, 0, 16, 16, 16)
166
+ return box_quads(0, 0, 0, 16, 8, 16)
167
+
168
+
169
+ def _stairs(properties: Mapping[str, str]) -> np.ndarray:
170
+ """Stairs: full-width base half slab plus a facing step box."""
171
+ top = _text(properties, "half", "bottom") == "top"
172
+ base: Box = (0, 8, 0, 16, 16, 16) if top else (0, 0, 0, 16, 8, 16)
173
+ step_y = (0.0, 8.0) if top else (8.0, 16.0)
174
+ x0, x1, z0, z1 = _STAIR_STEP[_facing(properties)]
175
+ return _combine([base, (x0, step_y[0], z0, x1, step_y[1], z1)])
176
+
177
+
178
+ def _carpet(properties: Mapping[str, str]) -> np.ndarray:
179
+ """Carpet: one-texel-thick full-footprint sheet."""
180
+ return box_quads(0, 0, 0, 16, 1, 16)
181
+
182
+
183
+ def _rail(properties: Mapping[str, str]) -> np.ndarray:
184
+ """Rail: one-texel-thick full-footprint sheet."""
185
+ return box_quads(0, 0, 0, 16, 1, 16)
186
+
187
+
188
+ def _pressure_plate(properties: Mapping[str, str]) -> np.ndarray:
189
+ """Pressure plate: one-texel-thick inset sheet."""
190
+ return box_quads(1, 0, 1, 15, 1, 15)
191
+
192
+
193
+ def _repeater(properties: Mapping[str, str]) -> np.ndarray:
194
+ """Repeater and comparator: low inset panel."""
195
+ return box_quads(2, 0, 2, 14, 2, 14)
196
+
197
+
198
+ def _trapdoor(properties: Mapping[str, str]) -> np.ndarray:
199
+ """Trapdoor: thin slab at the half plane, edge panel when open."""
200
+ if _flag(properties, "open"):
201
+ return box_quads(*_OPEN_TRAPDOOR[_facing(properties)])
202
+ if _text(properties, "half", "bottom") == "top":
203
+ return box_quads(0, 13, 0, 16, 16, 16)
204
+ return box_quads(0, 0, 0, 16, 3, 16)
205
+
206
+
207
+ def _door(properties: Mapping[str, str]) -> np.ndarray:
208
+ """Door: three-texel facing panel, swung sideways when open."""
209
+ table = _DOOR_OPEN if _flag(properties, "open") else _DOOR_CLOSED
210
+ return box_quads(*table[_facing(properties)])
211
+
212
+
213
+ def _ladder(properties: Mapping[str, str]) -> np.ndarray:
214
+ """Ladder: two-texel panel flush against the facing wall."""
215
+ return box_quads(*_LADDER[_facing(properties)])
216
+
217
+
218
+ def _button(properties: Mapping[str, str]) -> np.ndarray:
219
+ """Button: small pad on the floor, ceiling, or facing wall."""
220
+ face = _text(properties, "face", "floor")
221
+ if face == "ceiling":
222
+ return box_quads(5, 14, 5, 11, 16, 11)
223
+ if face == "wall":
224
+ return box_quads(*_BUTTON_WALL[_facing(properties)])
225
+ return box_quads(5, 0, 5, 11, 2, 11)
226
+
227
+
228
+ def _sign(properties: Mapping[str, str]) -> np.ndarray:
229
+ """Sign (standing or wall): two-texel post slab."""
230
+ return box_quads(7, 0, 0, 9, 16, 16)
231
+
232
+
233
+ def _banner(properties: Mapping[str, str]) -> np.ndarray:
234
+ """Banner (standing or wall): two-texel post slab."""
235
+ return box_quads(7, 0, 0, 9, 16, 16)
236
+
237
+
238
+ def _chest(properties: Mapping[str, str]) -> np.ndarray:
239
+ """Chest family: 14-texel box leaving seam margins."""
240
+ return box_quads(1, 0, 1, 15, 14, 15)
241
+
242
+
243
+ def _shulker_box(properties: Mapping[str, str]) -> np.ndarray:
244
+ """Shulker box: slightly inset full-height box."""
245
+ return box_quads(1, 0, 1, 15, 16, 15)
246
+
247
+
248
+ def _barrel(properties: Mapping[str, str]) -> np.ndarray:
249
+ """Barrel: slightly inset full-height box."""
250
+ return box_quads(1, 0, 1, 15, 16, 15)
251
+
252
+
253
+ def _composter(properties: Mapping[str, str]) -> np.ndarray:
254
+ """Composter: full cube per the vanilla model outline."""
255
+ return box_quads(0, 0, 0, 16, 16, 16)
256
+
257
+
258
+ def _head(properties: Mapping[str, str]) -> np.ndarray:
259
+ """Head and skull: centered 8x8x8 box."""
260
+ return box_quads(4, 0, 4, 12, 8, 12)
261
+
262
+
263
+ def _cactus(properties: Mapping[str, str]) -> np.ndarray:
264
+ """Cactus: inset full-height column."""
265
+ return box_quads(1, 0, 1, 15, 16, 15)
266
+
267
+
268
+ def _lily_pad(properties: Mapping[str, str]) -> np.ndarray:
269
+ """Lily pad: one-texel-thick full-footprint sheet."""
270
+ return box_quads(0, 0, 0, 16, 1, 16)
271
+
272
+
273
+ def _conduit(properties: Mapping[str, str]) -> np.ndarray:
274
+ """Conduit: centered 8x8x8 core."""
275
+ return box_quads(4, 4, 4, 12, 12, 12)
276
+
277
+
278
+ def _lectern(properties: Mapping[str, str]) -> np.ndarray:
279
+ """Lectern: full-footprint base plus a centered desk box."""
280
+ return _combine([(0, 0, 0, 16, 4, 16), (4, 4, 4, 12, 12, 12)])
281
+
282
+
283
+ def _torch_standing(properties: Mapping[str, str]) -> np.ndarray:
284
+ """Standing torch: small centered post."""
285
+ return box_quads(7, 0, 7, 9, 10, 9)
286
+
287
+
288
+ def _torch_wall(properties: Mapping[str, str]) -> np.ndarray:
289
+ """Wall torch: small post lying against the facing wall."""
290
+ return box_quads(*_TORCH_WALL[_facing(properties)])