structura-render 0.1.0__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 (26) hide show
  1. structura_render-0.1.0/PKG-INFO +126 -0
  2. structura_render-0.1.0/README.md +107 -0
  3. structura_render-0.1.0/pyproject.toml +38 -0
  4. structura_render-0.1.0/setup.cfg +4 -0
  5. structura_render-0.1.0/src/structura_render/__init__.py +6 -0
  6. structura_render-0.1.0/src/structura_render/assets.py +82 -0
  7. structura_render-0.1.0/src/structura_render/block_model.py +255 -0
  8. structura_render-0.1.0/src/structura_render/build_full_cube_list.py +90 -0
  9. structura_render-0.1.0/src/structura_render/build_opaque_blocks.py +65 -0
  10. structura_render-0.1.0/src/structura_render/data/full_cube_blocks.json +1064 -0
  11. structura_render-0.1.0/src/structura_render/data/opaque_blocks.json +409 -0
  12. structura_render-0.1.0/src/structura_render/entity_shapes.py +184 -0
  13. structura_render-0.1.0/src/structura_render/full_cube.py +23 -0
  14. structura_render-0.1.0/src/structura_render/hero.py +99 -0
  15. structura_render-0.1.0/src/structura_render/legacy_input.py +31 -0
  16. structura_render-0.1.0/src/structura_render/mesh.py +561 -0
  17. structura_render-0.1.0/src/structura_render/projections.py +313 -0
  18. structura_render-0.1.0/src/structura_render/textures.py +195 -0
  19. structura_render-0.1.0/src/structura_render/usdz.py +307 -0
  20. structura_render-0.1.0/src/structura_render/usdz_variants.py +72 -0
  21. structura_render-0.1.0/src/structura_render.egg-info/PKG-INFO +126 -0
  22. structura_render-0.1.0/src/structura_render.egg-info/SOURCES.txt +24 -0
  23. structura_render-0.1.0/src/structura_render.egg-info/dependency_links.txt +1 -0
  24. structura_render-0.1.0/src/structura_render.egg-info/entry_points.txt +7 -0
  25. structura_render-0.1.0/src/structura_render.egg-info/requires.txt +14 -0
  26. structura_render-0.1.0/src/structura_render.egg-info/top_level.txt +1 -0
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: structura-render
3
+ Version: 0.1.0
4
+ Summary: Minecraft block-model resolution, image rendering and USDZ export
5
+ Project-URL: Repository, https://github.com/kirimba1024/structura-render
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: structura-core<0.2,>=0.1.0
9
+ Requires-Dist: numpy<3,>=1.26
10
+ Requires-Dist: Pillow<12,>=11
11
+ Requires-Dist: scipy<2,>=1.13
12
+ Provides-Extra: hero
13
+ Requires-Dist: pyvista<0.47,>=0.46; extra == "hero"
14
+ Provides-Extra: usdz
15
+ Requires-Dist: pyvista<0.47,>=0.46; extra == "usdz"
16
+ Requires-Dist: usd-core<27,>=26; extra == "usdz"
17
+ Provides-Extra: legacy
18
+ Requires-Dist: structura-core[legacy]<0.2,>=0.1.0; extra == "legacy"
19
+
20
+ # structura-render
21
+
22
+ **Turn a Minecraft Structure NBT into a real photo or a 3D file — using
23
+ your own game's actual textures and block shapes, not guesses.**
24
+
25
+ No fake textures, no hardcoded block shapes, no bundled game files. Point
26
+ it at your own Minecraft client and get pixel-accurate PNGs and USDZ out
27
+ the other end.
28
+
29
+ **This isn't a lookup table of ~50 common blocks with guessed shapes.**
30
+ The resolver walks the same blockstate → multipart/variant → model → element
31
+ rotation → UV pipeline the Minecraft client itself uses, so ordinary vanilla
32
+ blocks — stairs, doors, fences, plants, redstone and the rest — come from the
33
+ game's data rather than per-block guesses. Vanilla's non-model renderers
34
+ (chests, banners, heads, shulker boxes, conduit, bell, decorated pots,
35
+ portals and fluids) share a compact textured compound-model layer.
36
+
37
+ <p>
38
+ <img src="docs/screenshots/usdz-floating-island.png" width="49%" alt="USDZ export of a floating island structure, viewed in a 3D viewer">
39
+ <img src="docs/screenshots/hero-render-detail.png" width="49%" alt="Close-up hero-render detail: windows, timber framing, flower pots">
40
+ </p>
41
+
42
+ ## Why it's worth using
43
+
44
+ - **Data-driven where vanilla is.** JSON-modelled blocks use the same
45
+ blockstate/model data and PNGs as the game. Dynamic blocks use their real
46
+ entity textures on compact state-aware compound geometry.
47
+ - **One mesh, every output.** Geometry is resolved once and shared between
48
+ PNG renders and USDZ export, so what you preview is exactly what you get
49
+ in 3D — nothing drifts between formats.
50
+ - **Clean by design.** Zero Mojang assets in this repo or its releases —
51
+ can't, EULA forbids it. You always supply your own client, so there's no
52
+ legal grey area to worry about.
53
+ - **Version-flexible.** Point it at a `.jar` from Minecraft 1.13 through
54
+ the latest release and it just works — verified end to end on both
55
+ 1.21.1 and the newest 26.2. New blocks resolve automatically; no waiting
56
+ on us to add support.
57
+ - **Light by default.** Core install is just NumPy, Pillow and SciPy.
58
+ PyVista/USD only get pulled in if you actually ask for hero renders or
59
+ USDZ export.
60
+ - **Self-updating classification.** Which blocks are solid cubes (for
61
+ occlusion) is computed from real model geometry, not a hand-maintained
62
+ name list — it doesn't go stale as new blocks ship.
63
+
64
+ ## What it does
65
+
66
+ - **`block_model.py`** — generic blockstate/model resolver: variants,
67
+ multipart conditions, parent chains, element rotations, UV rotation and
68
+ UV locking. Builds real geometry for every JSON-modelled vanilla block.
69
+ - **`textures.py`** — resolves `#variable` texture references and samples
70
+ the real PNG, including per-pixel alpha for correct occlusion (glass,
71
+ leaves, iron bars).
72
+ - **`projections.py`** — six-view orthographic PNGs (top/bottom/N/S/E/W)
73
+ with envelope/aura/terrain-pod/glass-dome mask overlays, for engineering
74
+ QA rather than looks.
75
+ - **`hero.py`** (`hero` extra) — one real-textured, real-lit PyVista render
76
+ of the finished structure — the kind of shot above.
77
+ - **`usdz.py`** (`usdz` extra) — a textured 3D mesh for AR Quick Look or
78
+ any USDZ viewer, from the same mesh builder as the hero renderer.
79
+ - **`build_full_cube_list.py` / `build_opaque_blocks.py`** — data-derived
80
+ block classification, described above.
81
+ - **`docs/block-render-audit-26.2.md`** — one row for every vanilla
82
+ blockstate, including explicit dynamic-render limitations.
83
+ - **`legacy_input.py`** (`legacy` extra) — every entry point also accepts a
84
+ legacy `.schematic`, sponge `.schem`, `.litematic`, or anything else
85
+ [amulet-core](https://github.com/Amulet-Team/Amulet-Core) recognizes, and
86
+ converts it to Structure NBT on the fly. No manual conversion step.
87
+
88
+ ## Quick start
89
+
90
+ No clone needed for either package, but `structura-core` isn't on PyPI yet,
91
+ so `pip` can't resolve it as a transitive dependency — install it
92
+ explicitly first, then `structura-render`:
93
+
94
+ ```bash
95
+ export STRUCTURA_MINECRAFT_ASSETS="$HOME/Library/Application Support/minecraft/versions/1.21.1/1.21.1.jar"
96
+ pip install 'git+https://github.com/kirimba1024/structura-core.git'
97
+ pip install "structura-render[usdz] @ git+https://github.com/kirimba1024/structura-render.git"
98
+ structura-render-hero structure.nbt preview.png
99
+ ```
100
+
101
+ Have a legacy `.schematic`/`.schem`/`.litematic` instead of Structure NBT?
102
+ Add the `legacy` extra (`[usdz,legacy]`) and pass it straight in — no
103
+ separate conversion step:
104
+
105
+ ```bash
106
+ structura-render-hero house.schematic preview.png
107
+ ```
108
+
109
+ Point `STRUCTURA_MINECRAFT_ASSETS` at a client `.jar` (any version from
110
+ 1.13 up) or an already-extracted `assets/minecraft` directory. A `.jar` is
111
+ extracted once into `$XDG_CACHE_HOME/structura-render/jar-assets`
112
+ (`~/.cache/...` if unset), keyed by its path, size and mtime, so repeat
113
+ runs and multiple installed versions don't re-extract or collide. When the
114
+ variable is unset, the package searches parent folders and the current
115
+ directory for `assets/minecraft`.
116
+
117
+ This project's own datapack targets Minecraft Java 1.21.1 specifically
118
+ (`structura_core.version.JAVA_VERSION`); if you're previewing *its*
119
+ structures, match that version for a guaranteed-correct result. Anything
120
+ older than 1.13 uses a different asset layout entirely (pre-"flattening")
121
+ and isn't supported. On load, the package checks for `heavy_core` (added
122
+ in 1.21) and warns on stderr if it's missing, as a cheap signal that the
123
+ pointed-at client predates 1.21 — not exhaustive, just a sanity check.
124
+
125
+ Use the lighter `hero` extra when USDZ export isn't needed; plain
126
+ projection rendering skips PyVista and OpenUSD entirely.
@@ -0,0 +1,107 @@
1
+ # structura-render
2
+
3
+ **Turn a Minecraft Structure NBT into a real photo or a 3D file — using
4
+ your own game's actual textures and block shapes, not guesses.**
5
+
6
+ No fake textures, no hardcoded block shapes, no bundled game files. Point
7
+ it at your own Minecraft client and get pixel-accurate PNGs and USDZ out
8
+ the other end.
9
+
10
+ **This isn't a lookup table of ~50 common blocks with guessed shapes.**
11
+ The resolver walks the same blockstate → multipart/variant → model → element
12
+ rotation → UV pipeline the Minecraft client itself uses, so ordinary vanilla
13
+ blocks — stairs, doors, fences, plants, redstone and the rest — come from the
14
+ game's data rather than per-block guesses. Vanilla's non-model renderers
15
+ (chests, banners, heads, shulker boxes, conduit, bell, decorated pots,
16
+ portals and fluids) share a compact textured compound-model layer.
17
+
18
+ <p>
19
+ <img src="docs/screenshots/usdz-floating-island.png" width="49%" alt="USDZ export of a floating island structure, viewed in a 3D viewer">
20
+ <img src="docs/screenshots/hero-render-detail.png" width="49%" alt="Close-up hero-render detail: windows, timber framing, flower pots">
21
+ </p>
22
+
23
+ ## Why it's worth using
24
+
25
+ - **Data-driven where vanilla is.** JSON-modelled blocks use the same
26
+ blockstate/model data and PNGs as the game. Dynamic blocks use their real
27
+ entity textures on compact state-aware compound geometry.
28
+ - **One mesh, every output.** Geometry is resolved once and shared between
29
+ PNG renders and USDZ export, so what you preview is exactly what you get
30
+ in 3D — nothing drifts between formats.
31
+ - **Clean by design.** Zero Mojang assets in this repo or its releases —
32
+ can't, EULA forbids it. You always supply your own client, so there's no
33
+ legal grey area to worry about.
34
+ - **Version-flexible.** Point it at a `.jar` from Minecraft 1.13 through
35
+ the latest release and it just works — verified end to end on both
36
+ 1.21.1 and the newest 26.2. New blocks resolve automatically; no waiting
37
+ on us to add support.
38
+ - **Light by default.** Core install is just NumPy, Pillow and SciPy.
39
+ PyVista/USD only get pulled in if you actually ask for hero renders or
40
+ USDZ export.
41
+ - **Self-updating classification.** Which blocks are solid cubes (for
42
+ occlusion) is computed from real model geometry, not a hand-maintained
43
+ name list — it doesn't go stale as new blocks ship.
44
+
45
+ ## What it does
46
+
47
+ - **`block_model.py`** — generic blockstate/model resolver: variants,
48
+ multipart conditions, parent chains, element rotations, UV rotation and
49
+ UV locking. Builds real geometry for every JSON-modelled vanilla block.
50
+ - **`textures.py`** — resolves `#variable` texture references and samples
51
+ the real PNG, including per-pixel alpha for correct occlusion (glass,
52
+ leaves, iron bars).
53
+ - **`projections.py`** — six-view orthographic PNGs (top/bottom/N/S/E/W)
54
+ with envelope/aura/terrain-pod/glass-dome mask overlays, for engineering
55
+ QA rather than looks.
56
+ - **`hero.py`** (`hero` extra) — one real-textured, real-lit PyVista render
57
+ of the finished structure — the kind of shot above.
58
+ - **`usdz.py`** (`usdz` extra) — a textured 3D mesh for AR Quick Look or
59
+ any USDZ viewer, from the same mesh builder as the hero renderer.
60
+ - **`build_full_cube_list.py` / `build_opaque_blocks.py`** — data-derived
61
+ block classification, described above.
62
+ - **`docs/block-render-audit-26.2.md`** — one row for every vanilla
63
+ blockstate, including explicit dynamic-render limitations.
64
+ - **`legacy_input.py`** (`legacy` extra) — every entry point also accepts a
65
+ legacy `.schematic`, sponge `.schem`, `.litematic`, or anything else
66
+ [amulet-core](https://github.com/Amulet-Team/Amulet-Core) recognizes, and
67
+ converts it to Structure NBT on the fly. No manual conversion step.
68
+
69
+ ## Quick start
70
+
71
+ No clone needed for either package, but `structura-core` isn't on PyPI yet,
72
+ so `pip` can't resolve it as a transitive dependency — install it
73
+ explicitly first, then `structura-render`:
74
+
75
+ ```bash
76
+ export STRUCTURA_MINECRAFT_ASSETS="$HOME/Library/Application Support/minecraft/versions/1.21.1/1.21.1.jar"
77
+ pip install 'git+https://github.com/kirimba1024/structura-core.git'
78
+ pip install "structura-render[usdz] @ git+https://github.com/kirimba1024/structura-render.git"
79
+ structura-render-hero structure.nbt preview.png
80
+ ```
81
+
82
+ Have a legacy `.schematic`/`.schem`/`.litematic` instead of Structure NBT?
83
+ Add the `legacy` extra (`[usdz,legacy]`) and pass it straight in — no
84
+ separate conversion step:
85
+
86
+ ```bash
87
+ structura-render-hero house.schematic preview.png
88
+ ```
89
+
90
+ Point `STRUCTURA_MINECRAFT_ASSETS` at a client `.jar` (any version from
91
+ 1.13 up) or an already-extracted `assets/minecraft` directory. A `.jar` is
92
+ extracted once into `$XDG_CACHE_HOME/structura-render/jar-assets`
93
+ (`~/.cache/...` if unset), keyed by its path, size and mtime, so repeat
94
+ runs and multiple installed versions don't re-extract or collide. When the
95
+ variable is unset, the package searches parent folders and the current
96
+ directory for `assets/minecraft`.
97
+
98
+ This project's own datapack targets Minecraft Java 1.21.1 specifically
99
+ (`structura_core.version.JAVA_VERSION`); if you're previewing *its*
100
+ structures, match that version for a guaranteed-correct result. Anything
101
+ older than 1.13 uses a different asset layout entirely (pre-"flattening")
102
+ and isn't supported. On load, the package checks for `heavy_core` (added
103
+ in 1.21) and warns on stderr if it's missing, as a cheap signal that the
104
+ pointed-at client predates 1.21 — not exhaustive, just a sanity check.
105
+
106
+ Use the lighter `hero` extra when USDZ export isn't needed; plain
107
+ projection rendering skips PyVista and OpenUSD entirely.
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "structura-render"
7
+ version = "0.1.0"
8
+ description = "Minecraft block-model resolution, image rendering and USDZ export"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ dependencies = [
12
+ "structura-core>=0.1.0,<0.2",
13
+ "numpy>=1.26,<3",
14
+ "Pillow>=11,<12",
15
+ "scipy>=1.13,<2",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ hero = ["pyvista>=0.46,<0.47"]
20
+ usdz = ["pyvista>=0.46,<0.47", "usd-core>=26,<27"]
21
+ legacy = ["structura-core[legacy]>=0.1.0,<0.2"]
22
+
23
+ [project.scripts]
24
+ structura-render-projections = "structura_render.projections:main"
25
+ structura-render-hero = "structura_render.hero:main"
26
+ structura-export-usdz = "structura_render.usdz:main"
27
+ structura-package-usdz-variants = "structura_render.usdz_variants:main"
28
+ structura-build-full-cube-list = "structura_render.build_full_cube_list:main"
29
+ structura-build-opaque-blocks = "structura_render.build_opaque_blocks:main"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
33
+
34
+ [tool.setuptools.package-data]
35
+ structura_render = ["data/*.json"]
36
+
37
+ [project.urls]
38
+ Repository = "https://github.com/kirimba1024/structura-render"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """Minecraft Structure NBT renderers and reusable mesh construction."""
2
+
3
+ from .textures import TextureBank, tint_for
4
+
5
+ __all__ = ["TextureBank", "tint_for"]
6
+ __version__ = "0.1.0"
@@ -0,0 +1,82 @@
1
+ """Minecraft client-asset discovery without a repository-relative import."""
2
+
3
+ import hashlib
4
+ import os
5
+ import sys
6
+ import zipfile
7
+ from pathlib import Path
8
+
9
+ _VERSION_MARKER_BLOCK = "heavy_core"
10
+
11
+
12
+ def _cache_root() -> Path:
13
+ configured = os.environ.get("XDG_CACHE_HOME")
14
+ base = Path(configured).expanduser() if configured else Path.home() / ".cache"
15
+ return base / "structura-render" / "jar-assets"
16
+
17
+
18
+ def _extract_jar_assets(jar_path: Path) -> Path:
19
+ stat = jar_path.stat()
20
+ key = hashlib.sha1(
21
+ f"{jar_path.resolve()}:{stat.st_mtime_ns}:{stat.st_size}".encode(),
22
+ ).hexdigest()[:16]
23
+ dest = _cache_root() / key
24
+ marker = dest / ".extracted"
25
+ target = dest / "assets" / "minecraft"
26
+ if marker.is_file() and target.is_dir():
27
+ return target
28
+
29
+ with zipfile.ZipFile(jar_path) as archive:
30
+ members = [
31
+ name for name in archive.namelist()
32
+ if name.startswith("assets/minecraft/") and not name.endswith("/")
33
+ ]
34
+ if not members:
35
+ raise FileNotFoundError(
36
+ f"{jar_path} has no assets/minecraft/ entries; "
37
+ "is this a real Minecraft client jar?",
38
+ )
39
+ dest.mkdir(parents=True, exist_ok=True)
40
+ archive.extractall(dest, members=members)
41
+ marker.touch()
42
+ return target
43
+
44
+
45
+ def _warn_if_version_mismatch(assets_root: Path) -> None:
46
+ marker = assets_root / "models" / "block" / f"{_VERSION_MARKER_BLOCK}.json"
47
+ if not marker.is_file():
48
+ print(
49
+ f"structura_render: WARNING: {assets_root} has no "
50
+ f"{_VERSION_MARKER_BLOCK} model (added in Minecraft 1.21) -- "
51
+ "these assets look older than the datapack's target version; "
52
+ "renders may not match what actually spawns in-game.",
53
+ file=sys.stderr,
54
+ )
55
+
56
+
57
+ def minecraft_assets_root() -> Path:
58
+ configured = os.environ.get("STRUCTURA_MINECRAFT_ASSETS")
59
+ if configured:
60
+ path = Path(configured).expanduser().resolve()
61
+ if path.is_dir():
62
+ _warn_if_version_mismatch(path)
63
+ return path
64
+ if path.is_file() and path.suffix == ".jar":
65
+ extracted = _extract_jar_assets(path)
66
+ _warn_if_version_mismatch(extracted)
67
+ return extracted
68
+ raise FileNotFoundError(
69
+ f"STRUCTURA_MINECRAFT_ASSETS does not exist: {path} "
70
+ "(point it at an assets/minecraft directory or a client .jar)",
71
+ )
72
+
73
+ candidates = [Path.cwd(), *Path(__file__).resolve().parents]
74
+ for root in candidates:
75
+ path = root / "assets" / "minecraft"
76
+ if path.is_dir():
77
+ _warn_if_version_mismatch(path)
78
+ return path
79
+ return Path.cwd() / "assets" / "minecraft"
80
+
81
+
82
+ ASSETS = minecraft_assets_root()
@@ -0,0 +1,255 @@
1
+ """Resolve vanilla blockstate/model JSON into textured quads."""
2
+ import json
3
+ import math
4
+ from functools import wraps
5
+
6
+ from .assets import ASSETS
7
+
8
+ BLOCKSTATES = ASSETS / "blockstates"
9
+ MODELS = ASSETS / "models/block"
10
+
11
+ DIRECTIONS = ("up", "down", "north", "south", "east", "west")
12
+ AXIS_VEC = {
13
+ "up": (0, 1, 0), "down": (0, -1, 0),
14
+ "north": (0, 0, -1), "south": (0, 0, 1),
15
+ "east": (1, 0, 0), "west": (-1, 0, 0),
16
+ }
17
+ VEC_AXIS = {v: k for k, v in AXIS_VEC.items()}
18
+
19
+ FACE_CORNERS = {
20
+ "up": (4, 5, 6, 7), "down": (0, 1, 2, 3),
21
+ "north": (1, 0, 4, 5), "south": (3, 2, 6, 7),
22
+ "east": (2, 1, 5, 6), "west": (0, 3, 7, 4),
23
+ }
24
+ FACE_UV_BASIS = {
25
+ "up": ((1, 0, 0), (0, 0, 1)), "down": ((1, 0, 0), (0, 0, 1)),
26
+ "north": ((-1, 0, 0), (0, 1, 0)), "south": ((1, 0, 0), (0, 1, 0)),
27
+ "east": ((0, 0, -1), (0, 1, 0)), "west": ((0, 0, 1), (0, 1, 0)),
28
+ }
29
+ FACE_UV_PLANE = {
30
+ "up": (0, 2), "down": (0, 2),
31
+ "north": (0, 1), "south": (0, 1),
32
+ "east": (2, 1), "west": (2, 1),
33
+ }
34
+
35
+ _blockstate_cache = {}
36
+ _model_cache = {}
37
+ _RESOLUTION_ERRORS = (KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError)
38
+
39
+
40
+ def safe(fn):
41
+ @wraps(fn)
42
+ def wrapper(*args, **kwargs):
43
+ try:
44
+ return fn(*args, **kwargs)
45
+ except _RESOLUTION_ERRORS:
46
+ return None
47
+ return wrapper
48
+
49
+
50
+ def strip_ns(name):
51
+ name = name.split(":", 1)[-1]
52
+ return name[len("block/"):] if name.startswith("block/") else name
53
+
54
+
55
+ def load_blockstate(name):
56
+ name = strip_ns(name)
57
+ if name not in _blockstate_cache:
58
+ path = BLOCKSTATES / f"{name}.json"
59
+ _blockstate_cache[name] = json.loads(path.read_text()) if path.exists() else None
60
+ return _blockstate_cache[name]
61
+
62
+
63
+ def load_model(name):
64
+ name = strip_ns(name)
65
+ if name not in _model_cache:
66
+ path = MODELS / f"{name}.json"
67
+ _model_cache[name] = json.loads(path.read_text()) if path.exists() else None
68
+ return _model_cache[name]
69
+
70
+
71
+ def resolve_model(name, depth=0):
72
+ if depth > 10:
73
+ return {"elements": None, "textures": {}}
74
+ data = load_model(name)
75
+ if data is None:
76
+ return {"elements": None, "textures": {}}
77
+ parent = data.get("parent")
78
+ base = resolve_model(parent, depth + 1) if parent else {"elements": None, "textures": {}}
79
+ return {
80
+ "elements": data.get("elements", base["elements"]),
81
+ "textures": {**base["textures"], **data.get("textures", {})},
82
+ }
83
+
84
+
85
+ def resolve_texture(ref, textures, depth=0):
86
+ if ref is None or depth > 10:
87
+ return None
88
+ if isinstance(ref, dict):
89
+ return resolve_texture(ref.get("sprite"), textures, depth + 1)
90
+ key = ref[1:] if ref.startswith("#") else ref
91
+ if key in textures:
92
+ return resolve_texture(textures[key], textures, depth + 1)
93
+ return strip_ns(ref) if not ref.startswith("#") else None
94
+
95
+
96
+ def matching_variant(variants, props):
97
+ parsed = []
98
+ for key, value in variants.items():
99
+ pairs = tuple(pair.split("=", 1) for pair in key.split(",")) if key else ()
100
+ parsed.append((dict(pairs), value))
101
+ matches = [
102
+ (len(wanted), value)
103
+ for wanted, value in parsed
104
+ if all(props.get(k) == v for k, v in wanted.items())
105
+ ]
106
+ return max(matches, key=lambda item: item[0])[1] if matches else (parsed[0][1] if parsed else None)
107
+
108
+
109
+ def condition_matches(condition, props):
110
+ if "OR" in condition:
111
+ return any(condition_matches(part, props) for part in condition["OR"])
112
+ if "AND" in condition:
113
+ return all(condition_matches(part, props) for part in condition["AND"])
114
+ return all(props.get(key) in str(wanted).split("|") for key, wanted in condition.items())
115
+
116
+
117
+ def selected_models(blockstate, props):
118
+ if "variants" in blockstate:
119
+ entries = [matching_variant(blockstate["variants"], props)]
120
+ else:
121
+ entries = [
122
+ part["apply"] for part in blockstate.get("multipart", ())
123
+ if "when" not in part or condition_matches(part["when"], props)
124
+ ]
125
+ return [entry[0] if isinstance(entry, list) else entry for entry in entries if entry]
126
+
127
+
128
+ def _rotate(point, origin, axis, degrees):
129
+ angle = math.radians(degrees)
130
+ cosine, sine = math.cos(angle), math.sin(angle)
131
+ x, y, z = (point[i] - origin[i] for i in range(3))
132
+ if axis == "x":
133
+ y, z = y * cosine - z * sine, y * sine + z * cosine
134
+ elif axis == "y":
135
+ x, z = x * cosine - z * sine, x * sine + z * cosine
136
+ else:
137
+ x, y = x * cosine - y * sine, x * sine + y * cosine
138
+ return x + origin[0], y + origin[1], z + origin[2]
139
+
140
+
141
+ def rotate_vector(vector, x_deg, y_deg):
142
+ rotated = _rotate(vector, (0, 0, 0), "x", x_deg)
143
+ return tuple(round(v) for v in _rotate(rotated, (0, 0, 0), "y", y_deg))
144
+
145
+
146
+ def rotate_direction(direction, x_deg, y_deg):
147
+ return VEC_AXIS[rotate_vector(AXIS_VEC[direction], x_deg, y_deg)]
148
+
149
+
150
+ def rotate_element(point, rotation):
151
+ if not rotation:
152
+ return point
153
+ origin = rotation.get("origin", (8, 8, 8))
154
+ if "axis" not in rotation:
155
+ for axis in "xyz":
156
+ point = _rotate(point, origin, axis, rotation.get(axis, 0))
157
+ return point
158
+ axis, angle = rotation["axis"], rotation["angle"]
159
+ if rotation.get("rescale"):
160
+ scale = 1 / math.cos(math.radians(angle))
161
+ point = tuple(
162
+ origin[i] + (point[i] - origin[i]) * (1 if "xyz"[i] == axis else scale)
163
+ for i in range(3)
164
+ )
165
+ return _rotate(point, origin, axis, angle)
166
+
167
+
168
+ def rotate_blockstate(point, x_deg, y_deg):
169
+ point = _rotate(point, (8, 8, 8), "x", x_deg)
170
+ return _rotate(point, (8, 8, 8), "y", y_deg)
171
+
172
+
173
+ def box_corners(lo, hi):
174
+ return (
175
+ (lo[0], lo[1], lo[2]), (hi[0], lo[1], lo[2]),
176
+ (hi[0], lo[1], hi[2]), (lo[0], lo[1], hi[2]),
177
+ (lo[0], hi[1], lo[2]), (hi[0], hi[1], lo[2]),
178
+ (hi[0], hi[1], hi[2]), (lo[0], hi[1], hi[2]),
179
+ )
180
+
181
+
182
+ def _neg(vector):
183
+ return tuple(-v for v in vector)
184
+
185
+
186
+ def uvlock_quarters(direction, x_deg, y_deg):
187
+ world_direction = rotate_direction(direction, x_deg, y_deg)
188
+ u, v = (rotate_vector(axis, x_deg, y_deg) for axis in FACE_UV_BASIS[direction])
189
+ target_u, target_v = FACE_UV_BASIS[world_direction]
190
+ choices = ((u, v), (v, _neg(u)), (_neg(u), _neg(v)), (_neg(v), u))
191
+ return next((i for i, basis in enumerate(choices) if basis == (target_u, target_v)), 0)
192
+
193
+
194
+ @safe
195
+ def post_texture(name):
196
+ model = resolve_model(f"{strip_ns(name)}_post")
197
+ return resolve_texture(model["textures"].get("particle"), model["textures"])
198
+
199
+
200
+ @safe
201
+ def block_elements(name, props):
202
+ blockstate = load_blockstate(name)
203
+ if blockstate is None:
204
+ return None
205
+ entries = selected_models(blockstate, props)
206
+ result = []
207
+ for entry in entries:
208
+ model = resolve_model(entry["model"])
209
+ if not model["elements"]:
210
+ continue
211
+ x_deg, y_deg = entry.get("x", 0), entry.get("y", 0)
212
+ for element in model["elements"]:
213
+ lo, hi = element["from"], element["to"]
214
+ corners = [
215
+ rotate_blockstate(rotate_element(point, element.get("rotation")), x_deg, y_deg)
216
+ for point in box_corners(lo, hi)
217
+ ]
218
+ faces = {}
219
+ for direction, face in element.get("faces", {}).items():
220
+ texture = resolve_texture(face.get("texture"), model["textures"])
221
+ if texture is None:
222
+ continue
223
+ uv = face.get("uv", default_uv(direction, lo, hi))
224
+ world_direction = rotate_direction(direction, x_deg, y_deg)
225
+ cullface = face.get("cullface")
226
+ cullface = {"top": "up", "bottom": "down"}.get(cullface, cullface)
227
+ faces[world_direction] = {
228
+ "texture": texture,
229
+ "uv": tuple(value / 16 for value in uv),
230
+ "uv_rotation": (
231
+ face.get("rotation", 0) // 90
232
+ + (uvlock_quarters(direction, x_deg, y_deg) if entry.get("uvlock") else 0)
233
+ ) % 4,
234
+ "vertices": tuple(
235
+ tuple(value / 16 for value in corners[i])
236
+ for i in FACE_CORNERS[direction]
237
+ ),
238
+ "cullface": rotate_direction(cullface, x_deg, y_deg) if cullface else None,
239
+ "tinted": "tintindex" in face,
240
+ }
241
+ if faces:
242
+ result.append({
243
+ "lo": tuple(min(point[i] for point in corners) / 16 for i in range(3)),
244
+ "hi": tuple(max(point[i] for point in corners) / 16 for i in range(3)),
245
+ "faces": faces,
246
+ })
247
+ return result if result else ([] if entries else None)
248
+
249
+
250
+ def default_uv(direction, lo, hi):
251
+ a, b = FACE_UV_PLANE[direction]
252
+ lo_b, hi_b = lo[b], hi[b]
253
+ if b == 1:
254
+ lo_b, hi_b = 16 - hi[b], 16 - lo[b]
255
+ return lo[a], lo_b, hi[a], hi_b