litematica-render 0.1.0__tar.gz → 0.1.1__tar.gz

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.
Files changed (70) hide show
  1. {litematica_render-0.1.0 → litematica_render-0.1.1}/PKG-INFO +5 -2
  2. {litematica_render-0.1.0 → litematica_render-0.1.1}/README.md +4 -1
  3. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/__init__.py +1 -1
  4. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/render/__init__.py +46 -11
  5. litematica_render-0.1.1/src/litematic/render/entity.py +384 -0
  6. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/render/pack/extract.py +203 -3
  7. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/render/pack/loader.py +24 -3
  8. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/render/pack/resolve.py +10 -0
  9. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/render/quads.py +11 -0
  10. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/render/scene/isometric.py +215 -115
  11. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/render/scene/painter.py +217 -90
  12. litematica_render-0.1.1/src/litematic/render/scene/view.py +87 -0
  13. litematica_render-0.1.1/tests/render/entity/test_entity_frames.py +186 -0
  14. litematica_render-0.1.1/tests/render/entity/test_entity_items.py +106 -0
  15. litematica_render-0.1.1/tests/render/entity/test_entity_stands.py +89 -0
  16. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/render/pack/test_pack_extract.py +101 -0
  17. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/render/pack/test_pack_load.py +78 -2
  18. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/render/scene/test_compose.py +188 -24
  19. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/render/scene/test_paint.py +58 -36
  20. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/render/scene/test_sizing.py +4 -4
  21. litematica_render-0.1.1/tests/render/scene/test_view.py +104 -0
  22. litematica_render-0.1.1/tests/render/shapes/__init__.py +0 -0
  23. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/render/test_api.py +42 -4
  24. {litematica_render-0.1.0 → litematica_render-0.1.1}/.github/workflows/python-publish.yml +0 -0
  25. {litematica_render-0.1.0 → litematica_render-0.1.1}/.github/workflows/test.yml +0 -0
  26. {litematica_render-0.1.0 → litematica_render-0.1.1}/.gitignore +0 -0
  27. {litematica_render-0.1.0 → litematica_render-0.1.1}/LICENSE +0 -0
  28. {litematica_render-0.1.0 → litematica_render-0.1.1}/pyproject.toml +0 -0
  29. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/parse/__init__.py +0 -0
  30. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/parse/errors.py +0 -0
  31. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/parse/model.py +0 -0
  32. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/parse/nbt.py +0 -0
  33. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/parse/parser.py +0 -0
  34. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/render/errors.py +0 -0
  35. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/render/pack/__init__.py +0 -0
  36. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/render/pack/blockstate.py +0 -0
  37. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/render/pack/compile.py +0 -0
  38. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/render/pack/elements.py +0 -0
  39. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/render/pack/entities.py +0 -0
  40. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/render/pack/stack.py +0 -0
  41. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/render/pack/texture.py +0 -0
  42. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/render/png.py +0 -0
  43. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/render/scene/__init__.py +0 -0
  44. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/render/shapes/__init__.py +0 -0
  45. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/render/shapes/boxes.py +0 -0
  46. {litematica_render-0.1.0 → litematica_render-0.1.1}/src/litematic/render/shapes/families.py +0 -0
  47. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/helpers/__init__.py +0 -0
  48. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/helpers/litematica_packer.py +0 -0
  49. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/helpers/nbt_writer.py +0 -0
  50. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/parse/__init__.py +0 -0
  51. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/parse/test_api.py +0 -0
  52. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/parse/test_errors.py +0 -0
  53. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/parse/test_model.py +0 -0
  54. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/parse/test_nbt.py +0 -0
  55. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/parse/test_parser.py +0 -0
  56. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/parse/test_robustness.py +0 -0
  57. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/parse/test_slab.py +0 -0
  58. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/parse/test_unpack.py +0 -0
  59. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/render/__init__.py +0 -0
  60. {litematica_render-0.1.0/tests/render/pack → litematica_render-0.1.1/tests/render/entity}/__init__.py +0 -0
  61. {litematica_render-0.1.0/tests/render/scene → litematica_render-0.1.1/tests/render/pack}/__init__.py +0 -0
  62. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/render/pack/test_pack_entities.py +0 -0
  63. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/render/pack/test_pack_stack.py +0 -0
  64. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/render/pack/test_resolve.py +0 -0
  65. {litematica_render-0.1.0/tests/render/shapes → litematica_render-0.1.1/tests/render/scene}/__init__.py +0 -0
  66. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/render/shapes/test_shapes.py +0 -0
  67. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/render/test_init.py +0 -0
  68. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/render/test_png.py +0 -0
  69. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/render/test_quads.py +0 -0
  70. {litematica_render-0.1.0 → litematica_render-0.1.1}/tests/render/test_unresolved.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: litematica-render
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Parse Minecraft Litematica schematics and render them to isometric PNGs
5
5
  Project-URL: Homepage, https://github.com/late-maple/Litematica-Render
6
6
  Author: late-maple
@@ -27,7 +27,8 @@ Offline isometric rendering of Minecraft Litematica `.litematic` schematics.
27
27
  ## Features
28
28
 
29
29
  - Parse `.litematic` files: streaming NBT, lazy vectorized region unpacking
30
- - Render isometric PNGs from a resource pack's real blockstates, models, and textures
30
+ - Render PNGs from a resource pack's real blockstates, models, and textures
31
+ - Free camera: any rotation/slant angle with per-pixel depth buffering
31
32
  - Pack stack: layer add-on packs (e.g. Faithful 32x) over a vanilla base, MC-style first-hit lookup
32
33
 
33
34
  ## Example
@@ -42,6 +43,8 @@ png = isometric_png(
42
43
  schem,
43
44
  textures_path="vanilla-assets.zip", # base pack, required
44
45
  extra_packs=["Faithful 32x.zip"], # add-ons, earlier = higher priority
46
+ rotation=160.0, # camera azimuth degrees (default 45)
47
+ slant=25.0, # camera elevation in [-90, 90]
45
48
  max_size=2048,
46
49
  )
47
50
  with open("render.png", "wb") as f:
@@ -5,7 +5,8 @@ Offline isometric rendering of Minecraft Litematica `.litematic` schematics.
5
5
  ## Features
6
6
 
7
7
  - Parse `.litematic` files: streaming NBT, lazy vectorized region unpacking
8
- - Render isometric PNGs from a resource pack's real blockstates, models, and textures
8
+ - Render PNGs from a resource pack's real blockstates, models, and textures
9
+ - Free camera: any rotation/slant angle with per-pixel depth buffering
9
10
  - Pack stack: layer add-on packs (e.g. Faithful 32x) over a vanilla base, MC-style first-hit lookup
10
11
 
11
12
  ## Example
@@ -20,6 +21,8 @@ png = isometric_png(
20
21
  schem,
21
22
  textures_path="vanilla-assets.zip", # base pack, required
22
23
  extra_packs=["Faithful 32x.zip"], # add-ons, earlier = higher priority
24
+ rotation=160.0, # camera azimuth degrees (default 45)
25
+ slant=25.0, # camera elevation in [-90, 90]
23
26
  max_size=2048,
24
27
  )
25
28
  with open("render.png", "wb") as f:
@@ -16,7 +16,7 @@ from litematic.parse.model import (
16
16
  )
17
17
  from litematic.parse.parser import load, loads
18
18
 
19
- __version__ = "0.1.0"
19
+ __version__ = "0.1.1"
20
20
 
21
21
  __all__ = [
22
22
  "BlockEntity",
@@ -1,16 +1,18 @@
1
1
  """Offline isometric rendering of parsed .litematic schematics."""
2
2
 
3
- from collections.abc import Sequence
3
+ from collections.abc import Mapping, Sequence
4
4
  from pathlib import Path
5
5
 
6
6
  import numpy as np
7
7
 
8
8
  from litematic.parse.model import Litematic
9
+ from litematic.render.entity import item_content_texkey
9
10
  from litematic.render.errors import RenderError
10
11
  from litematic.render.pack import load_pack
11
12
  from litematic.render.png import encode_png
12
13
  from litematic.render.scene.isometric import _AIR_NAMES, _compose
13
14
  from litematic.render.scene.painter import _choose_scale, _downsample, _paint
15
+ from litematic.render.scene.view import ISO_SLANT_DEG, view_axes
14
16
 
15
17
  __all__ = [
16
18
  "RenderError",
@@ -29,14 +31,14 @@ def isometric(
29
31
  *,
30
32
  textures_path: str | Path | None = None,
31
33
  extra_packs: Sequence[str | Path] = (),
32
- yaw: int = 0,
34
+ entities: bool = True,
35
+ rotation: float = 45.0,
36
+ slant: float = ISO_SLANT_DEG,
33
37
  max_size: int = 2048,
34
38
  supersample: int = 2,
35
39
  background: tuple[int, int, int, int] | None = None,
36
40
  ) -> np.ndarray:
37
41
  """Render a schematic to an RGBA isometric image within max_size."""
38
- if yaw not in (0, 1, 2, 3):
39
- raise RenderError(f"render: yaw must be 0-3, got {yaw}")
40
42
  if not _MAX_SIZE_BOUNDS[0] <= max_size <= _MAX_SIZE_BOUNDS[1]:
41
43
  raise RenderError(
42
44
  f"render: max_size must be "
@@ -44,12 +46,16 @@ def isometric(
44
46
  )
45
47
  if not _SS_BOUNDS[0] <= supersample <= _SS_BOUNDS[1]:
46
48
  raise RenderError(
47
- f"render: supersample must be {_SS_BOUNDS[0]}-{_SS_BOUNDS[1]}, "
48
- f"got {supersample}"
49
+ f"render: supersample must be "
50
+ f"{_SS_BOUNDS[0]}-{_SS_BOUNDS[1]}, got {supersample}"
49
51
  )
52
+ if not isinstance(entities, bool):
53
+ raise RenderError(f"render: entities must be a boolean, got {entities!r}")
54
+ # Validate angles before any pack work so bad views fail fast.
55
+ view_axes(rotation, slant)
50
56
  bg = _background(background)
51
57
  textures = load_pack(_require_pack(textures_path), extra_packs)
52
- scene = _compose(schem, yaw, textures)
58
+ scene = _compose(schem, rotation, slant, textures, entities)
53
59
  if len(scene.x) == 0:
54
60
  raise RenderError("render: no blocks to render")
55
61
  t, _ = _choose_scale(scene.span_w, scene.span_h, max_size, supersample)
@@ -68,7 +74,9 @@ def isometric_png(
68
74
  *,
69
75
  textures_path: str | Path | None = None,
70
76
  extra_packs: Sequence[str | Path] = (),
71
- yaw: int = 0,
77
+ entities: bool = True,
78
+ rotation: float = 45.0,
79
+ slant: float = ISO_SLANT_DEG,
72
80
  max_size: int = 2048,
73
81
  supersample: int = 2,
74
82
  background: tuple[int, int, int, int] | None = None,
@@ -79,7 +87,9 @@ def isometric_png(
79
87
  schem,
80
88
  textures_path=textures_path,
81
89
  extra_packs=extra_packs,
82
- yaw=yaw,
90
+ entities=entities,
91
+ rotation=rotation,
92
+ slant=slant,
83
93
  max_size=max_size,
84
94
  supersample=supersample,
85
95
  background=background,
@@ -97,16 +107,25 @@ def unresolved_colors(
97
107
  *,
98
108
  textures_path: str | Path,
99
109
  extra_packs: Sequence[str | Path] = (),
110
+ entities: bool = True,
100
111
  ) -> set[str]:
101
- """Return block names the pack stack cannot cover; these render magenta."""
112
+ """Return names the pack stack cannot cover. Palette blocks and frame
113
+ content render magenta; dropped items and armor stands skip silently
114
+ but are still reported. With entities=False only palette names are
115
+ checked."""
116
+ if not isinstance(entities, bool):
117
+ raise RenderError(f"render: entities must be a boolean, got {entities!r}")
102
118
  textures = load_pack(_require_pack(textures_path), extra_packs)
103
- return {
119
+ missed = {
104
120
  state.name
105
121
  for region in schem.regions.values()
106
122
  for state in region.palette
107
123
  if state.name not in _AIR_NAMES
108
124
  and textures.resolve(state).miss is not None
109
125
  }
126
+ if entities:
127
+ missed |= _unresolved_entities(schem, textures)
128
+ return missed
110
129
 
111
130
 
112
131
  def _background(
@@ -132,3 +151,19 @@ def _require_pack(textures_path: str | Path | None) -> str | Path:
132
151
  "render: a resource pack zip is required (pass textures_path)"
133
152
  )
134
153
  return textures_path
154
+
155
+
156
+ def _unresolved_entities(schem: Litematic, textures) -> set[str]:
157
+ """Item ids the entity builders cannot draw; skipped or magenta."""
158
+ missed: set[str] = set()
159
+ for region in schem.regions.values():
160
+ for ent in region.entities:
161
+ stem = ent.id.rpartition(":")[2]
162
+ if stem not in ("item", "item_frame", "glow_item_frame"):
163
+ continue
164
+ item = ent.data.get("Item")
165
+ item_id = item.get("id") if isinstance(item, Mapping) else None
166
+ if isinstance(item_id, str) and item_id:
167
+ if item_content_texkey(item_id, textures) is None:
168
+ missed.add(item_id)
169
+ return missed
@@ -0,0 +1,384 @@
1
+ """Entities compiled to quads: dropped items, item frames, armor stands."""
2
+
3
+ from collections.abc import Mapping
4
+
5
+ import numpy as np
6
+
7
+ from litematic.render.pack.resolve import _MAGENTA_TEXKEY, FRAME_PAD_KEY
8
+ from litematic.render.quads import CULL_NONE, QUAD_DTYPE, TIER_SOUTH
9
+
10
+ # Vanilla ground display, frozen at the bob mid-point: generated items
11
+ # scale 0.5 (half-size sprite), block items 0.25 (mini cube), both hover
12
+ # 0.1 + 1/16 - bbox.minY above the floor (ItemEntityRenderer static pose).
13
+ ITEM_FLAT_SIZE = 8.0
14
+ ITEM_BLOCK_SCALE = 0.25
15
+ ITEM_MINI_BASE = 2.6
16
+
17
+ # Facing yaw turns matching the compose convention. Vanilla puts the pad
18
+ # on the wall OPPOSITE the facing (createBoundingBox: center.relative(
19
+ # facing, -0.46875)), the front looking along the facing.
20
+ _FACING_TURNS = {"south": 2, "north": 0, "east": 3, "west": 1}
21
+ # Vanilla Facing NBT byte -> facing name (up/down unsupported in v1).
22
+ _NBT_FACING = {0: "down", 1: "up", 2: "north", 3: "south", 4: "west", 5: "east"}
23
+ _ARMORSTAND_TEXKEY = "entity/armorstand/armorstand"
24
+
25
+ # Face tiers after one 90-degree turn (south -> east), matching compose.
26
+ _TIER_TURNS = {
27
+ 1: np.array([0, 1, 4, 5, 3, 2], np.uint8),
28
+ 2: np.array([0, 1, 3, 2, 5, 4], np.uint8),
29
+ 3: np.array([0, 1, 5, 4, 2, 3], np.uint8),
30
+ }
31
+
32
+
33
+
34
+ def item_entity_quads(item_id: str, textures) -> np.ndarray | None:
35
+ """Cell-local quads for one dropped item, or None when unrenderable."""
36
+ entry = _item_entry(item_id, textures)
37
+ if entry is None:
38
+ return None
39
+ if "block" in entry:
40
+ return _mini_block_quads(entry["block"], textures)
41
+ return _flat_item_quads(entry["flat"], textures)
42
+
43
+
44
+
45
+ def magenta_entity_quads() -> np.ndarray:
46
+ """Fallback sprite for unresolvable frame content, in the frame geometry."""
47
+ return _one_quad(_MAGENTA_TEXKEY, _frame_corners())
48
+
49
+
50
+
51
+ def entity_props(data: object) -> dict[str, object]:
52
+ """Normalize raw entity NBT into the properties builders consume."""
53
+ src: Mapping[str, object] = data if isinstance(data, Mapping) else {}
54
+ key = src.get("Facing")
55
+ props: dict[str, object] = {
56
+ "facing": _NBT_FACING.get(key) if isinstance(key, int) else None
57
+ }
58
+ rotation = src.get("Rotation")
59
+ if isinstance(rotation, (list, tuple)) and rotation:
60
+ yaw = _as_float(rotation[0])
61
+ if yaw is not None:
62
+ props["yaw"] = yaw
63
+ for flag in ("Invisible", "Small", "NoBasePlate", "ShowArms"):
64
+ if flag in src:
65
+ props[flag] = bool(src[flag])
66
+ turns = _as_int(src.get("ItemRotation", 0))
67
+ props["item_rotation"] = 0 if turns is None else turns
68
+ return props
69
+
70
+
71
+ def _as_float(value: object) -> float | None:
72
+ if isinstance(value, bool):
73
+ return None
74
+ if isinstance(value, (int, float)):
75
+ return float(value)
76
+ if isinstance(value, str):
77
+ try:
78
+ return float(value)
79
+ except ValueError:
80
+ return None
81
+ return None
82
+
83
+
84
+ def _as_int(value: object) -> int | None:
85
+ number = _as_float(value)
86
+ return None if number is None else int(number)
87
+
88
+
89
+
90
+ def item_frame_quads(
91
+ properties: dict[str, object],
92
+ item_id: str | None,
93
+ textures,
94
+ ) -> np.ndarray | None:
95
+ """Wall-frame pad plus content quads, or None when nothing renders."""
96
+ facing = properties.get("facing")
97
+ if facing not in _FACING_TURNS:
98
+ return None
99
+ turns = _FACING_TURNS[facing]
100
+ pad = (
101
+ None
102
+ if properties.get("Invisible")
103
+ else _frame_pad_quads(facing, textures)
104
+ )
105
+ content = _frame_content_quads(properties, item_id, textures)
106
+ if content is not None:
107
+ content = _turn_cell_quads(content, turns)
108
+ if pad is None and content is None:
109
+ return None
110
+ return np.concatenate([q for q in (pad, content) if q is not None])
111
+
112
+
113
+
114
+ def armor_stand_quads(properties: dict[str, object], textures) -> np.ndarray | None:
115
+ """Box-group quads for one armor stand, or None when unrenderable."""
116
+ if properties.get("Invisible"):
117
+ return None
118
+ if _ARMORSTAND_TEXKEY not in textures.arrays:
119
+ return None
120
+ small = bool(properties.get("Small"))
121
+ groups = [
122
+ _part_quads(uv, box, size, pivot, flag, small)
123
+ for name, uv, box, size, pivot, flag in _ARMOR_STAND_PARTS
124
+ if not (flag == "arms" and not properties.get("ShowArms"))
125
+ and not (flag == "plate" and properties.get("NoBasePlate"))
126
+ ]
127
+ quads = np.concatenate(groups)
128
+ yaw = _as_float(properties.get("yaw"))
129
+ if yaw:
130
+ angle = np.deg2rad(yaw)
131
+ cos, sin = np.float32(np.cos(angle)), np.float32(np.sin(angle))
132
+ vtx = quads["vtx"]
133
+ dx, dz = vtx[..., 0] - 8.0, vtx[..., 2] - 8.0
134
+ vtx[..., 0] = 8.0 + dx * cos - dz * sin
135
+ vtx[..., 2] = 8.0 + dx * sin + dz * cos
136
+ return quads
137
+
138
+
139
+
140
+ def _item_entry(item_id: str, textures) -> dict[str, str] | None:
141
+ """Display entry for an item, or a direct sprite when the pack has one."""
142
+ entry = textures.item_display.get(item_id)
143
+ if entry is not None:
144
+ return entry
145
+ direct = f"item/{item_id.rpartition(':')[2]}"
146
+ if direct in textures.arrays:
147
+ return {"flat": direct}
148
+ return None
149
+
150
+
151
+
152
+ def _mini_block_quads(block_id: str, textures) -> np.ndarray | None:
153
+ quads = textures.pack_quads(block_id)
154
+ if quads is None or len(quads) == 0:
155
+ return None
156
+ out = quads.copy()
157
+ vtx = out["vtx"]
158
+ vtx[..., 0] = 8.0 - 8.0 * ITEM_BLOCK_SCALE + vtx[..., 0] * ITEM_BLOCK_SCALE
159
+ vtx[..., 1] = ITEM_MINI_BASE + vtx[..., 1] * ITEM_BLOCK_SCALE
160
+ vtx[..., 2] = 8.0 - 8.0 * ITEM_BLOCK_SCALE + vtx[..., 2] * ITEM_BLOCK_SCALE
161
+ out["dbl"] = True
162
+ out["cull"] = CULL_NONE
163
+ return out
164
+
165
+
166
+
167
+ def _flat_item_quads(texkey: str, textures) -> np.ndarray | None:
168
+ if texkey != _MAGENTA_TEXKEY and texkey not in textures.arrays:
169
+ return None
170
+ return _one_quad(texkey, _flat_corners())
171
+
172
+
173
+
174
+ def _flat_corners() -> np.ndarray:
175
+ half = ITEM_FLAT_SIZE / 2
176
+ top = ITEM_MINI_BASE + ITEM_FLAT_SIZE
177
+ return np.array(
178
+ [
179
+ [8.0 - half, top, 8.0],
180
+ [8.0 + half, top, 8.0],
181
+ [8.0 + half, ITEM_MINI_BASE, 8.0],
182
+ [8.0 - half, ITEM_MINI_BASE, 8.0],
183
+ ],
184
+ np.float32,
185
+ )
186
+
187
+
188
+
189
+ def _one_quad(texkey: str, corners: np.ndarray) -> np.ndarray:
190
+ quads = np.zeros(1, QUAD_DTYPE)
191
+ quads["vtx"][0] = corners
192
+ quads["uv"][0] = ((0, 0), (16, 0), (16, 16), (0, 16))
193
+ quads["texkey"][0] = texkey
194
+ quads["shade"][0] = TIER_SOUTH
195
+ quads["dbl"][0] = True
196
+ quads["cull"][0] = CULL_NONE
197
+ return quads
198
+
199
+
200
+
201
+ def _frame_pad_quads(facing: str, textures) -> np.ndarray | None:
202
+ pad = textures.mquad_map.get(FRAME_PAD_KEY)
203
+ if pad is not None and len(pad):
204
+ return _turn_cell_quads(pad, _FACING_TURNS[facing])
205
+ # Texture-only packs: the frame sprite stands in for the missing model.
206
+ if "item/item_frame" not in textures.arrays:
207
+ return None
208
+ quad = _one_quad(
209
+ "item/item_frame",
210
+ np.array(
211
+ [
212
+ [2.0, 14.0, 15.5],
213
+ [14.0, 14.0, 15.5],
214
+ [14.0, 2.0, 15.5],
215
+ [2.0, 2.0, 15.5],
216
+ ],
217
+ np.float32,
218
+ ),
219
+ )
220
+ quad["uv"][0] = ((2, 2), (14, 2), (14, 14), (2, 14))
221
+ return _turn_cell_quads(quad, _FACING_TURNS[facing])
222
+
223
+
224
+
225
+ def item_content_texkey(item_id: str, textures) -> str | None:
226
+ """Flat texkey drawn for an item, or None when nothing can render it."""
227
+ entry = _item_entry(item_id, textures)
228
+ if entry is None:
229
+ return None
230
+ if "flat" in entry:
231
+ return entry["flat"] if entry["flat"] in textures.arrays else None
232
+ plate = textures.pack_quads(entry["block"])
233
+ if plate is None or len(plate) == 0:
234
+ return None
235
+ return str(plate["texkey"][0])
236
+
237
+
238
+
239
+ def _frame_content_quads(
240
+ properties: dict[str, object],
241
+ item_id: str | None,
242
+ textures,
243
+ ) -> np.ndarray | None:
244
+ """Half-size sprite centered on the pad, flush with its front plane."""
245
+ if item_id is None:
246
+ return None
247
+ texkey = item_content_texkey(item_id, textures)
248
+ sprite = (
249
+ magenta_entity_quads()
250
+ if texkey is None
251
+ else _one_quad(texkey, _frame_corners())
252
+ )
253
+ return _turn_content(sprite, _as_int(properties.get("item_rotation", 0)) or 0)
254
+
255
+
256
+
257
+ def _frame_corners() -> np.ndarray:
258
+ """Content plane per vanilla FIXED: sprite mid at the pad front (z=15),
259
+ half-size sprite centered on the pad center."""
260
+ half = ITEM_FLAT_SIZE / 2
261
+ return np.array(
262
+ [
263
+ [8.0 - half, 8.0 + half, 15.0],
264
+ [8.0 + half, 8.0 + half, 15.0],
265
+ [8.0 + half, 8.0 - half, 15.0],
266
+ [8.0 - half, 8.0 - half, 15.0],
267
+ ],
268
+ np.float32,
269
+ )
270
+
271
+
272
+
273
+ def _turn_content(sprite: np.ndarray, turns: int) -> np.ndarray:
274
+ """Spin the pad-plane sprite in 45-degree item steps about the center."""
275
+ out = sprite.copy()
276
+ if turns:
277
+ angle = np.deg2rad(45.0 * turns)
278
+ cos, sin = np.float32(np.cos(angle)), np.float32(np.sin(angle))
279
+ vtx = out["vtx"]
280
+ dx, dy = vtx[..., 0] - 8.0, vtx[..., 1] - 8.0
281
+ vtx[..., 0] = 8.0 + dx * cos - dy * sin
282
+ vtx[..., 1] = 8.0 + dx * sin + dy * cos
283
+ out["cull"] = CULL_NONE
284
+ out["dbl"] = True
285
+ return out
286
+
287
+
288
+
289
+ def _turn_cell_quads(quads: np.ndarray, turns: int) -> np.ndarray:
290
+ """Rotate cell-local quads about the Y axis, compose turn convention."""
291
+ turns %= 4
292
+ if turns == 0:
293
+ return quads
294
+ out = quads.copy()
295
+ vtx = out["vtx"]
296
+ dx, dz = vtx[..., 0] - 8.0, vtx[..., 2] - 8.0
297
+ if turns == 1:
298
+ nx, nz = dz, -dx
299
+ elif turns == 2:
300
+ nx, nz = -dx, -dz
301
+ else:
302
+ nx, nz = -dz, dx
303
+ vtx[..., 0] = 8.0 + nx
304
+ vtx[..., 2] = 8.0 + nz
305
+ out["shade"] = _TIER_TURNS[turns][out["shade"]]
306
+ return out
307
+
308
+
309
+
310
+ # Vanilla ArmorStandModel boxes (64x64 texture space, model y-down, 24 =
311
+ # ground): (uv, box offset, size, pivot, flag). World mapping: x/z as-is,
312
+ # y = 24 - model_y; small applies the BabyModelTransform pivot/box scaling.
313
+ _ARMOR_STAND_PARTS = (
314
+ ("head", (0, 0), (-1, -7, -1), (2, 7, 2), (0, 1, 0), "head"),
315
+ ("body", (0, 26), (-6, 0, -1.5), (12, 3, 3), (0, 0, 0), ""),
316
+ ("right_body_stick", (16, 0), (-3, 3, -1), (2, 7, 2), (0, 0, 0), ""),
317
+ ("left_body_stick", (48, 16), (1, 3, -1), (2, 7, 2), (0, 0, 0), ""),
318
+ ("shoulder_stick", (0, 48), (-4, 10, -1), (8, 2, 2), (0, 0, 0), ""),
319
+ ("right_arm", (24, 0), (-2, -2, -1), (2, 12, 2), (-5, 2, 0), "arms"),
320
+ ("left_arm", (32, 16), (0, -2, -1), (2, 12, 2), (5, 2, 0), "arms"),
321
+ ("right_leg", (8, 0), (-1, 0, -1), (2, 11, 2), (-1.9, 12, 0), ""),
322
+ ("left_leg", (40, 16), (-1, 0, -1), (2, 11, 2), (1.9, 12, 0), ""),
323
+ ("base_plate", (0, 32), (-6, 11, -6), (12, 1, 12), (0, 12, 0), "plate"),
324
+ )
325
+ _PART_FACES = (
326
+ ("down", 0),
327
+ ("up", 1),
328
+ ("north", 2),
329
+ ("south", 3),
330
+ ("west", 4),
331
+ ("east", 5),
332
+ )
333
+
334
+
335
+
336
+ def _part_quads(
337
+ uv: tuple[int, int],
338
+ box: tuple[float, float, float],
339
+ size: tuple[float, float, float],
340
+ pivot: tuple[float, float, float],
341
+ flag: str,
342
+ small: bool,
343
+ ) -> np.ndarray:
344
+ scale, lift = (0.75, 16.0) if flag == "head" and small else (1.0, 0.0)
345
+ if small and flag != "head":
346
+ scale, lift = 0.5, 24.0
347
+ px, py, pz = (
348
+ pivot[0] * scale,
349
+ (pivot[1] + lift) * scale,
350
+ pivot[2] * scale,
351
+ )
352
+ lo = np.array(box, np.float32) * scale
353
+ hi = lo + np.array(size, np.float32) * scale
354
+ x0, x1 = 8.0 + px + lo[0], 8.0 + px + hi[0]
355
+ z0, z1 = 8.0 + pz + lo[2], 8.0 + pz + hi[2]
356
+ y0, y1 = 24.0 - (py + hi[1]), 24.0 - (py + lo[1])
357
+ u, v = uv
358
+ dz, dx, dy = size[2] * scale, size[0] * scale, size[1] * scale
359
+ rects = {
360
+ "up": (u + dz, v, u + dz + dx, v + dz),
361
+ "down": (u + dz + dx, v, u + dz + 2 * dx, v + dz),
362
+ "west": (u, v + dz, u + dz, v + dz + dy),
363
+ "north": (u + dz, v + dz, u + dz + dx, v + dz + dy),
364
+ "east": (u + dz + dx, v + dz, u + dz + dx + dz, v + dz + dy),
365
+ "south": (u + dz + dx + dz, v + dz, u + dz + dx + dz + dx, v + dz + dy),
366
+ }
367
+ corners = {
368
+ "down": ((x0, y0, z1), (x1, y0, z1), (x1, y0, z0), (x0, y0, z0)),
369
+ "up": ((x0, y1, z0), (x1, y1, z0), (x1, y1, z1), (x0, y1, z1)),
370
+ "north": ((x1, y1, z0), (x0, y1, z0), (x0, y0, z0), (x1, y0, z0)),
371
+ "south": ((x0, y1, z1), (x1, y1, z1), (x1, y0, z1), (x0, y0, z1)),
372
+ "west": ((x0, y1, z1), (x0, y1, z0), (x0, y0, z0), (x0, y0, z1)),
373
+ "east": ((x1, y1, z0), (x1, y1, z1), (x1, y0, z1), (x1, y0, z0)),
374
+ }
375
+ quads = np.zeros(6, QUAD_DTYPE)
376
+ for i, (face, tier) in enumerate(_PART_FACES):
377
+ fu0, fv0, fu1, fv1 = (c / 4.0 for c in rects[face])
378
+ quads["vtx"][i] = corners[face]
379
+ quads["uv"][i] = ((fu0, fv0), (fu1, fv0), (fu1, fv1), (fu0, fv1))
380
+ quads["texkey"][i] = _ARMORSTAND_TEXKEY
381
+ quads["shade"][i] = tier
382
+ quads["dbl"][i] = True
383
+ quads["cull"][i] = CULL_NONE
384
+ return quads