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,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,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)])
|