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,579 @@
1
+ """Scene composition: quad expansion, yaw remap, camera filtering, AO."""
2
+
3
+ from typing import NamedTuple
4
+
5
+ import numpy as np
6
+
7
+ from litematic.parse.model import Litematic
8
+ from litematic.render.errors import RenderError
9
+ from litematic.render.pack.resolve import Resolver
10
+ from litematic.render.quads import (
11
+ AO_LEVELS,
12
+ CULL_NONE,
13
+ TIER_DOWN,
14
+ TIER_EAST,
15
+ TIER_NORTH,
16
+ TIER_SOUTH,
17
+ TIER_UP,
18
+ TIER_WEST,
19
+ )
20
+
21
+ _AIR_NAMES = {"minecraft:air", "minecraft:cave_air", "minecraft:void_air"}
22
+ _WATER_FAMILY = {"minecraft:water", "minecraft:bubble_column"}
23
+ _Y_SLAB = 64
24
+ _CAMERA_TIERS = (TIER_UP, TIER_SOUTH, TIER_EAST)
25
+
26
+ # Face normal vectors per tier in block space; AO samples sit one step out
27
+ # along the normal and one step along each tangent sign.
28
+ _TIER_NORMALS = np.zeros((6, 3), np.int64)
29
+ _TIER_NORMALS[TIER_DOWN] = (0, -1, 0)
30
+ _TIER_NORMALS[TIER_UP] = (0, 1, 0)
31
+ _TIER_NORMALS[TIER_NORTH] = (0, 0, -1)
32
+ _TIER_NORMALS[TIER_SOUTH] = (0, 0, 1)
33
+ _TIER_NORMALS[TIER_WEST] = (-1, 0, 0)
34
+ _TIER_NORMALS[TIER_EAST] = (1, 0, 0)
35
+ _TIER_OF_NORMAL = {tuple(row): tier for tier, row in enumerate(_TIER_NORMALS)}
36
+
37
+ # Per tier: normal axis then the two tangent axes for AO corner sampling.
38
+ _TIER_AXES = np.zeros((6, 3), np.int64)
39
+ _TIER_AXES[TIER_DOWN] = (1, 0, 2)
40
+ _TIER_AXES[TIER_UP] = (1, 0, 2)
41
+ _TIER_AXES[TIER_NORTH] = (2, 0, 1)
42
+ _TIER_AXES[TIER_SOUTH] = (2, 0, 1)
43
+ _TIER_AXES[TIER_WEST] = (0, 1, 2)
44
+ _TIER_AXES[TIER_EAST] = (0, 1, 2)
45
+
46
+
47
+ class _Scene(NamedTuple):
48
+ """Camera-facing quads after yaw rotation and vertex AO, scene coords."""
49
+
50
+ x: np.ndarray # int32 per-quad block x
51
+ y: np.ndarray # int32 per-quad block y
52
+ z: np.ndarray # int32 per-quad block z
53
+ quad_vtx: np.ndarray # (Q, 4, 3) f32 block-local, yaw rotated
54
+ quad_uv: np.ndarray # (Q, 4, 2) f32 texel coords, never rotated
55
+ quad_ao: np.ndarray # (Q, 4) f32 per-vertex AO multipliers
56
+ quad_shade: np.ndarray # (Q,) u1 tier, limited to up/south/east
57
+ quad_dbl: np.ndarray # (Q,) bool double-sided flag
58
+ texkey_ids: np.ndarray # (Q,) i32 index into texkeys
59
+ texkeys: dict[str, np.ndarray] # unique texkey -> (16, 16, 4) array
60
+ span_w: int # canvas width in pixels = 2t * span_w at scale t
61
+ span_h: int # canvas height in pixels = t * span_h at scale t
62
+
63
+
64
+ def _yaw_rot(
65
+ x: np.ndarray, z: np.ndarray, yaw: int, w: int, d: int
66
+ ) -> tuple[np.ndarray, np.ndarray]:
67
+ if yaw == 1:
68
+ return z, (w - 1) - x
69
+ if yaw == 2:
70
+ return (w - 1) - x, (d - 1) - z
71
+ if yaw == 3:
72
+ return (d - 1) - z, x
73
+ return x, z
74
+
75
+
76
+ def _yaw_tier_map(yaw: int) -> np.ndarray:
77
+ """Remap the six face tiers through the yaw rotation formula."""
78
+ out = np.empty(6, np.uint8)
79
+ for tier in range(6):
80
+ nx, ny, nz = (int(v) for v in _TIER_NORMALS[tier])
81
+ if yaw == 1:
82
+ rx, rz = nz, -nx
83
+ elif yaw == 2:
84
+ rx, rz = -nx, -nz
85
+ elif yaw == 3:
86
+ rx, rz = -nz, nx
87
+ else:
88
+ rx, rz = nx, nz
89
+ out[tier] = _TIER_OF_NORMAL[(rx, ny, rz)]
90
+ return out
91
+
92
+
93
+ _YAW_TIER_MAPS = tuple(_yaw_tier_map(yaw) for yaw in range(4))
94
+
95
+
96
+ def _horizontal_cull_dirs(
97
+ yaw: int,
98
+ ) -> tuple[tuple[int, int], tuple[int, int]]:
99
+ """World-space neighbor steps whose faces face the camera after yaw."""
100
+ dirs: list[tuple[int, int]] = []
101
+ for tier in (TIER_EAST, TIER_SOUTH):
102
+ nx, _, nz = (int(v) for v in _TIER_NORMALS[tier])
103
+ for _ in range((4 - yaw) % 4):
104
+ nx, nz = nz, -nx
105
+ dirs.append((nx, nz))
106
+ return dirs[0], dirs[1]
107
+
108
+
109
+ class _Luts(NamedTuple):
110
+ """Per-palette-entry lookups for culling and quad expansion."""
111
+
112
+ is_air: np.ndarray # bool
113
+ occludes: np.ndarray # bool
114
+ pool: np.ndarray | None # (P,) QUAD_DTYPE, None when every entry is air
115
+ offsets: np.ndarray # (E + 1,) i8 start of each entry's rows in pool
116
+ texkey_ids: np.ndarray # (P,) i32 per-pool-row texkey id
117
+ cull_class: np.ndarray # (P,) i32 pair class, negative entries never pair
118
+
119
+
120
+ class _Arena(NamedTuple):
121
+ """Preallocated scene columns filled slab by slab."""
122
+
123
+ x: np.ndarray # (K,) i32 world block x per quad
124
+ y: np.ndarray # (K,) i32 world block y per quad
125
+ z: np.ndarray # (K,) i32 world block z per quad
126
+ vtx: np.ndarray # (K, 4, 3) f32 block-local, yaw rotated
127
+ uv: np.ndarray # (K, 4, 2) f32
128
+ ao: np.ndarray # (K, 4) f32
129
+ shade: np.ndarray # (K,) u1 camera-facing tier
130
+ dbl: np.ndarray # (K,) bool
131
+ texkey: np.ndarray # (K,) i32
132
+
133
+
134
+ def _new_arena(capacity: int) -> _Arena:
135
+ """Allocate the exact scene columns for the counted quad total."""
136
+ return _Arena(
137
+ np.zeros(capacity, np.int32),
138
+ np.zeros(capacity, np.int32),
139
+ np.zeros(capacity, np.int32),
140
+ np.zeros((capacity, 4, 3), np.float32),
141
+ np.zeros((capacity, 4, 2), np.float32),
142
+ np.zeros((capacity, 4), np.float32),
143
+ np.zeros(capacity, np.uint8),
144
+ np.zeros(capacity, bool),
145
+ np.zeros(capacity, np.int32),
146
+ )
147
+
148
+
149
+ def _compose(schem: Litematic, yaw: int, textures: Resolver) -> _Scene:
150
+ """Collect visible blocks from all regions into a quad scene."""
151
+ try:
152
+ return _compose_inner(schem, yaw, textures)
153
+ except RenderError:
154
+ raise
155
+ except Exception as exc:
156
+ raise RenderError(f"compose: scene composition failed: {exc}") from exc
157
+
158
+
159
+ def _compose_inner(schem: Litematic, yaw: int, textures: Resolver) -> _Scene:
160
+ """Expand every region's visible cells into yawed, filtered quads.
161
+
162
+ Pass A stashes per-slab visible cells and counts the exact camera-facing
163
+ quad total; pass B expands each slab straight into the preallocated
164
+ arena through a rolling occlusion window, so no intermediate list of
165
+ slab pieces is ever held.
166
+ """
167
+ key_ids: dict[str, int] = {}
168
+ key_arrays: dict[str, np.ndarray] = {}
169
+ tier_map = _YAW_TIER_MAPS[yaw]
170
+ dir_a, dir_b = _horizontal_cull_dirs(yaw)
171
+ plan = []
172
+ capacity = 0
173
+ for name in sorted(schem.regions):
174
+ region = schem.regions[name]
175
+ luts = _palette_luts(region.palette, textures, key_ids, key_arrays)
176
+ if luts.pool is None:
177
+ continue
178
+ cam_rows = _camera_row_counts(luts, tier_map)
179
+ stash = list(_visible_slabs(region, luts, dir_a, dir_b))
180
+ capacity += sum(
181
+ int(cam_rows[picked.astype(np.int64)].sum())
182
+ for (*_, picked) in stash
183
+ )
184
+ plan.append((region, luts, stash))
185
+ if capacity == 0:
186
+ return _empty_scene()
187
+ arena = _new_arena(capacity)
188
+ written = 0
189
+ for region, luts, stash in plan:
190
+ px, py, pz = region.position.x, region.position.y, region.position.z
191
+ for y0, y1, lx, ly, lz, picked in stash:
192
+ written += _expand_slab_into(
193
+ arena, written, y0, y1, lx, ly, lz, picked,
194
+ luts, region, tier_map, yaw, (px, py, pz),
195
+ )
196
+ stash.clear()
197
+ x = arena.x[:written]
198
+ y = arena.y[:written]
199
+ z = arena.z[:written]
200
+ x = x - int(x.min())
201
+ y = y - int(y.min())
202
+ z = z - int(z.min())
203
+ rx, rz = _yaw_rot(x, z, yaw, int(x.max()) + 1, int(z.max()) + 1)
204
+ span_w = int(rx.max()) + int(rz.max()) + 2
205
+ span_h = int(rx.max()) + int(rz.max()) + 2 * int(y.max()) + 4
206
+ # Copy so the capacity-sized arena is not pinned by views.
207
+ return _Scene(
208
+ rx.astype(np.int32),
209
+ y.astype(np.int32),
210
+ rz.astype(np.int32),
211
+ arena.vtx[:written].copy(),
212
+ arena.uv[:written].copy(),
213
+ arena.ao[:written].copy(),
214
+ arena.shade[:written].copy(),
215
+ arena.dbl[:written].copy(),
216
+ arena.texkey[:written].copy(),
217
+ dict(key_arrays),
218
+ span_w,
219
+ span_h,
220
+ )
221
+
222
+
223
+ def _camera_row_counts(luts: _Luts, tier_map: np.ndarray) -> np.ndarray:
224
+ """Camera-facing pool-row count per palette entry under this yaw."""
225
+ pool = luts.pool
226
+ if pool is None:
227
+ return np.zeros(len(luts.offsets) - 1, np.int64)
228
+ counts = np.zeros(len(luts.offsets) - 1, np.int64)
229
+ for i in range(len(counts)):
230
+ rows = pool[luts.offsets[i] : luts.offsets[i + 1]]
231
+ counts[i] = int(np.isin(tier_map[rows["shade"]], _CAMERA_TIERS).sum())
232
+ return counts
233
+
234
+
235
+ def _empty_scene() -> _Scene:
236
+ """Return the zero-quad scene with the v1 default canvas spans."""
237
+ return _Scene(
238
+ np.zeros(0, np.int32),
239
+ np.zeros(0, np.int32),
240
+ np.zeros(0, np.int32),
241
+ np.zeros((0, 4, 3), np.float32),
242
+ np.zeros((0, 4, 2), np.float32),
243
+ np.zeros((0, 4), np.float32),
244
+ np.zeros(0, np.uint8),
245
+ np.zeros(0, bool),
246
+ np.zeros(0, np.int32),
247
+ {},
248
+ 2,
249
+ 4,
250
+ )
251
+
252
+
253
+ def _cull_classes(palette) -> np.ndarray:
254
+ """Pair class per palette entry; negative entries never pair.
255
+
256
+ Same block name pairs (glass, ice, slime, water levels); the water
257
+ family pairs across names; leaves never pair (cutout leaves default).
258
+ """
259
+ classes = np.full(len(palette), -1, np.int32)
260
+ ids: dict[str, int] = {}
261
+ for i, state in enumerate(palette):
262
+ if state.name in _AIR_NAMES:
263
+ continue
264
+ if state.name.partition(":")[2].endswith("leaves"):
265
+ continue
266
+ if state.name in _WATER_FAMILY:
267
+ classes[i] = 0
268
+ continue
269
+ key = state.name
270
+ if key not in ids:
271
+ ids[key] = len(ids) + 1
272
+ classes[i] = ids[key]
273
+ return classes
274
+
275
+
276
+ def _palette_luts(
277
+ palette, textures: Resolver, key_ids: dict[str, int], key_arrays: dict
278
+ ) -> _Luts:
279
+ """Resolve every palette entry to quads, occl flags, and texkey ids."""
280
+ count = len(palette)
281
+ is_air = np.zeros(count, bool)
282
+ occludes = np.zeros(count, bool)
283
+ cull_class = _cull_classes(palette)
284
+ quads_pieces: list[np.ndarray] = []
285
+ id_pieces: list[np.ndarray] = []
286
+ lengths = np.zeros(count + 1, np.int64)
287
+ for i, state in enumerate(palette):
288
+ if state.name in _AIR_NAMES:
289
+ is_air[i] = True
290
+ continue
291
+ res = textures.resolve(state)
292
+ occludes[i] = res.occludes
293
+ quads_pieces.append(res.quads)
294
+ id_pieces.append(_texkey_ids(res.quads, textures, key_ids, key_arrays))
295
+ lengths[i + 1] = len(res.quads)
296
+ if not quads_pieces:
297
+ return _Luts(
298
+ is_air, occludes, None, np.cumsum(lengths), np.zeros(0, np.int32),
299
+ cull_class,
300
+ )
301
+ pool = (
302
+ np.concatenate(quads_pieces) if len(quads_pieces) > 1 else quads_pieces[0]
303
+ )
304
+ ids = np.concatenate(id_pieces) if len(id_pieces) > 1 else id_pieces[0]
305
+ return _Luts(is_air, occludes, pool, np.cumsum(lengths), ids, cull_class)
306
+
307
+
308
+ def _texkey_ids(
309
+ quads: np.ndarray,
310
+ textures: Resolver,
311
+ key_ids: dict[str, int],
312
+ key_arrays: dict,
313
+ ) -> np.ndarray:
314
+ """Map a quad array's texkeys to shared scene ids, registering arrays."""
315
+ unique, inverse = np.unique(quads["texkey"], return_inverse=True)
316
+ keys = [str(key) for key in unique]
317
+ fresh = [key for key in keys if key not in key_ids]
318
+ arrays = textures.texkey_arrays(fresh)
319
+ for key in fresh:
320
+ key_ids[key] = len(key_ids)
321
+ key_arrays[key] = arrays[key]
322
+ lut = np.array([key_ids[key] for key in keys], np.int32)
323
+ return lut[inverse.reshape(-1)]
324
+
325
+
326
+ def _occlusion_window(
327
+ widx: np.ndarray, occludes: np.ndarray, wy0: int
328
+ ) -> tuple[np.ndarray, tuple[int, int, int], int]:
329
+ """False-padded flat occlusion grid over the window's index rows.
330
+
331
+ AO and cullface lookups reach at most one row beyond the slab, so the
332
+ window (rows [wy0, wy1), unpacked on demand) never needs the rest of
333
+ the region; region-edge rows fall to the False padding exactly like
334
+ the vanilla out-of-bounds air.
335
+ """
336
+ sx, win_h, sz = widx.shape
337
+ padded = np.zeros((sx + 2, win_h + 2, sz + 2), bool)
338
+ padded[1 : sx + 1, 1 : win_h + 1, 1 : sz + 1] = occludes[widx]
339
+ strides = ((win_h + 2) * (sz + 2), sz + 2, 1)
340
+ return padded.ravel(), strides, wy0
341
+
342
+
343
+ def _visible_slabs(
344
+ region,
345
+ luts: _Luts,
346
+ dir_a: tuple[int, int],
347
+ dir_b: tuple[int, int],
348
+ ):
349
+ """Yield each slab's row span plus its visible cells for the arena."""
350
+ sx, sy, sz = region.size
351
+ is_air, occludes = luts.is_air, luts.occludes
352
+ for y0 in range(0, sy, _Y_SLAB):
353
+ y1 = min(y0 + _Y_SLAB, sy)
354
+ body = region.slab_indices(y0, y1)
355
+ present = ~is_air[body]
356
+ occ_body = occludes[body]
357
+ upper = occludes[body][:, 1:, :]
358
+ if y1 == sy:
359
+ next_row = np.zeros((sx, 1, sz), bool)
360
+ else:
361
+ # One extra row past the slab edge; a single-row unpack keeps
362
+ # the visibility pass from paying for the next slab's body.
363
+ next_row = occludes[region.slab_indices(y1, y1 + 1)]
364
+ upper = np.concatenate([upper, next_row], axis=1)
365
+ occ_a = _shifted(occ_body, *dir_a)
366
+ occ_b = _shifted(occ_body, *dir_b)
367
+ visible = present & ~(upper & occ_a & occ_b)
368
+ if not visible.any():
369
+ continue
370
+ lx, ly, lz = np.nonzero(visible)
371
+ yield y0, y1, lx, ly + y0, lz, body[visible]
372
+
373
+
374
+ def _shifted(grid: np.ndarray, dx: int, dz: int) -> np.ndarray:
375
+ """Neighbor occlusion one unit step along (dx, dz); outside is False."""
376
+ out = np.zeros_like(grid)
377
+ if dx > 0:
378
+ out[:-1, :, :] = grid[1:, :, :]
379
+ elif dx < 0:
380
+ out[1:, :, :] = grid[:-1, :, :]
381
+ elif dz > 0:
382
+ out[:, :, :-1] = grid[:, :, 1:]
383
+ elif dz < 0:
384
+ out[:, :, 1:] = grid[:, :, :-1]
385
+ return out
386
+
387
+
388
+ def _expand_slab_into(
389
+ arena: _Arena,
390
+ offset: int,
391
+ y0: int,
392
+ y1: int,
393
+ lx: np.ndarray,
394
+ ly: np.ndarray,
395
+ lz: np.ndarray,
396
+ picked: np.ndarray,
397
+ luts: _Luts,
398
+ region,
399
+ tier_map: np.ndarray,
400
+ yaw: int,
401
+ position: tuple[int, int, int],
402
+ ) -> int:
403
+ """Expand one stashed slab into the arena; return the quads written."""
404
+ picked = picked.astype(np.int64)
405
+ pool = luts.pool
406
+ if pool is None:
407
+ return 0
408
+ counts = luts.offsets[picked + 1] - luts.offsets[picked]
409
+ total = int(counts.sum())
410
+ if total == 0:
411
+ return 0
412
+ cell_of = np.repeat(
413
+ np.arange(len(picked), dtype=np.int32), counts
414
+ )
415
+ starts = np.cumsum(counts) - counts
416
+ within = np.arange(total, dtype=np.int64) - np.repeat(starts, counts)
417
+ pool_idx = np.repeat(luts.offsets[picked], counts) + within
418
+ # Structured rows carry a 256-byte texkey, so gather fields and chunk.
419
+ model_shade = pool["shade"][pool_idx]
420
+ keep = np.isin(tier_map[model_shade], _CAMERA_TIERS)
421
+ kept_at = np.flatnonzero(keep)
422
+ pool_rows = pool_idx[kept_at]
423
+ del pool_idx, keep
424
+ model_shade = model_shade[kept_at]
425
+ cell_of = cell_of[kept_at]
426
+ new_tier = tier_map[model_shade]
427
+ wy0 = max(y0 - 1, 0)
428
+ widx = region.slab_indices(wy0, min(y1 + 1, region.size.y))
429
+ occ = _occlusion_window(widx, luts.occludes, wy0)
430
+ px, py, pz = position
431
+ written = 0
432
+ chunk = 1 << 20
433
+ for start in range(0, len(pool_rows), chunk):
434
+ stop = min(start + chunk, len(pool_rows))
435
+ rows_c = pool_rows[start:stop]
436
+ cell_of_c = cell_of[start:stop]
437
+ cells = np.stack(
438
+ [lx[cell_of_c], ly[cell_of_c], lz[cell_of_c]], axis=1
439
+ )
440
+ alive = _cull_alive(
441
+ pool["cull"][rows_c],
442
+ cells,
443
+ picked[cell_of_c],
444
+ luts.cull_class,
445
+ occ,
446
+ widx,
447
+ wy0,
448
+ region.size.y,
449
+ )
450
+ cells = cells[alive]
451
+ rows = rows_c[alive]
452
+ vtx = pool["vtx"][rows]
453
+ dbl = pool["dbl"][rows]
454
+ end = offset + written + len(cells)
455
+ arena.x[offset + written : end] = cells[:, 0] + px
456
+ arena.y[offset + written : end] = cells[:, 1] + py
457
+ arena.z[offset + written : end] = cells[:, 2] + pz
458
+ arena.vtx[offset + written : end] = _rotate_vertices(vtx, yaw)
459
+ arena.uv[offset + written : end] = pool["uv"][rows]
460
+ arena.ao[offset + written : end] = _vertex_ao(
461
+ vtx, model_shade[start:stop][alive], dbl, cells, *occ
462
+ )
463
+ arena.shade[offset + written : end] = new_tier[start:stop][alive]
464
+ arena.dbl[offset + written : end] = dbl
465
+ arena.texkey[offset + written : end] = luts.texkey_ids[rows]
466
+ written += len(cells)
467
+ return written
468
+
469
+
470
+ def _cull_alive(
471
+ cull: np.ndarray,
472
+ cells: np.ndarray,
473
+ cell_palette: np.ndarray,
474
+ cull_class: np.ndarray,
475
+ occ: tuple[np.ndarray, tuple[int, int, int], int],
476
+ widx: np.ndarray,
477
+ wy0: int,
478
+ sy: int,
479
+ ) -> np.ndarray:
480
+ """Alive mask after neighbor culling; no-cullface rows always alive."""
481
+ mask = np.ones(len(cull), bool)
482
+ rows = np.nonzero(cull != CULL_NONE)[0]
483
+ if len(rows) == 0:
484
+ return mask
485
+ nb = cells[rows] + _TIER_NORMALS[cull[rows].astype(np.int64)]
486
+ sx, _, sz = widx.shape
487
+ valid = (
488
+ (nb[..., 0] >= 0) & (nb[..., 0] < sx)
489
+ & (nb[..., 1] >= 0) & (nb[..., 1] < sy)
490
+ & (nb[..., 2] >= 0) & (nb[..., 2] < sz)
491
+ )
492
+ nb_clipped = np.stack(
493
+ [
494
+ np.clip(nb[..., 0], 0, sx - 1),
495
+ np.clip(nb[..., 1], 0, sy - 1),
496
+ np.clip(nb[..., 2], 0, sz - 1),
497
+ ],
498
+ axis=1,
499
+ )
500
+ flat, (st_x, st_y, st_z), win_y0 = occ
501
+ win_h = st_x // st_y - 2
502
+ nb_pad = np.stack(
503
+ [
504
+ np.clip(nb[..., 0], -1, sx + 1),
505
+ np.clip(nb[..., 1], wy0 - 1, wy0 + win_h),
506
+ np.clip(nb[..., 2], -1, sz + 1),
507
+ ],
508
+ axis=1,
509
+ )
510
+ occluded = flat[
511
+ (nb_pad[..., 0] + 1) * st_x
512
+ + (nb_pad[..., 1] + 1 - win_y0) * st_y
513
+ + (nb_pad[..., 2] + 1) * st_z
514
+ ]
515
+ neighbor_pi = widx[
516
+ nb_clipped[..., 0],
517
+ nb_clipped[..., 1] - wy0,
518
+ nb_clipped[..., 2],
519
+ ]
520
+ own = cull_class[cell_palette[rows]]
521
+ pair = (
522
+ valid
523
+ & (own >= 0)
524
+ & (own == cull_class[neighbor_pi])
525
+ )
526
+ mask[rows] = ~(occluded | pair)
527
+ return mask
528
+
529
+
530
+ def _rotate_vertices(vtx: np.ndarray, yaw: int) -> np.ndarray:
531
+ """Rotate block-local vertices by the yaw formula, uv left untouched."""
532
+ out = vtx
533
+ for _ in range(yaw % 4):
534
+ x, y, z = out[..., 0], out[..., 1], out[..., 2]
535
+ out = np.stack([z, y, 16.0 - x], axis=-1)
536
+ return np.asarray(out, dtype=np.float32)
537
+
538
+
539
+ def _vertex_ao(
540
+ vtx: np.ndarray,
541
+ shade: np.ndarray,
542
+ dbl: np.ndarray,
543
+ cells: np.ndarray,
544
+ flat: np.ndarray,
545
+ strides: tuple[int, int, int],
546
+ y_base: int = 0,
547
+ ) -> np.ndarray:
548
+ """Per-vertex AO multipliers from the padded flat grid; vtx is pre-yaw."""
549
+ tier = shade.astype(np.int32)
550
+ axes = _TIER_AXES[tier]
551
+ normals = _TIER_NORMALS[tier]
552
+ st_x, st_y, st_z = strides
553
+ steps = np.array([st_x, st_y, st_z], np.int32)
554
+ t1 = steps[axes[:, 1]]
555
+ t2 = steps[axes[:, 2]]
556
+ pick1 = axes[:, 1][:, None, None]
557
+ pick2 = axes[:, 2][:, None, None]
558
+ # A vertex exactly on the block center plane (coord 8.0) signs to 0 and
559
+ # samples the straight-out neighbor; the shape table keeps coords off 8.
560
+ s1 = np.sign(np.take_along_axis(vtx, pick1, axis=2)[:, :, 0] - 8.0)
561
+ s2 = np.sign(np.take_along_axis(vtx, pick2, axis=2)[:, :, 0] - 8.0)
562
+ s1 = s1.astype(np.int32)
563
+ s2 = s2.astype(np.int32)
564
+ cells32 = cells.astype(np.int32)
565
+ base = (
566
+ (cells32[:, 0] + 1) * np.int32(st_x)
567
+ + (cells32[:, 1] + 1 - y_base) * np.int32(st_y)
568
+ + (cells32[:, 2] + 1) * np.int32(st_z)
569
+ + normals[:, 0] * np.int32(st_x)
570
+ + normals[:, 1] * np.int32(st_y)
571
+ + normals[:, 2] * np.int32(st_z)
572
+ )[:, None]
573
+ side1 = flat[base + s1 * t1[:, None]].astype(np.int32)
574
+ side2 = flat[base + s2 * t2[:, None]].astype(np.int32)
575
+ corner = flat[base + s1 * t1[:, None] + s2 * t2[:, None]].astype(np.int32)
576
+ level = np.where(side1 & side2, 0, 3 - (side1 + side2 + corner))
577
+ ao = np.asarray(AO_LEVELS, np.float32)[level]
578
+ ao[dbl] = 1.0
579
+ return ao