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.
- structura_render-0.1.0/PKG-INFO +126 -0
- structura_render-0.1.0/README.md +107 -0
- structura_render-0.1.0/pyproject.toml +38 -0
- structura_render-0.1.0/setup.cfg +4 -0
- structura_render-0.1.0/src/structura_render/__init__.py +6 -0
- structura_render-0.1.0/src/structura_render/assets.py +82 -0
- structura_render-0.1.0/src/structura_render/block_model.py +255 -0
- structura_render-0.1.0/src/structura_render/build_full_cube_list.py +90 -0
- structura_render-0.1.0/src/structura_render/build_opaque_blocks.py +65 -0
- structura_render-0.1.0/src/structura_render/data/full_cube_blocks.json +1064 -0
- structura_render-0.1.0/src/structura_render/data/opaque_blocks.json +409 -0
- structura_render-0.1.0/src/structura_render/entity_shapes.py +184 -0
- structura_render-0.1.0/src/structura_render/full_cube.py +23 -0
- structura_render-0.1.0/src/structura_render/hero.py +99 -0
- structura_render-0.1.0/src/structura_render/legacy_input.py +31 -0
- structura_render-0.1.0/src/structura_render/mesh.py +561 -0
- structura_render-0.1.0/src/structura_render/projections.py +313 -0
- structura_render-0.1.0/src/structura_render/textures.py +195 -0
- structura_render-0.1.0/src/structura_render/usdz.py +307 -0
- structura_render-0.1.0/src/structura_render/usdz_variants.py +72 -0
- structura_render-0.1.0/src/structura_render.egg-info/PKG-INFO +126 -0
- structura_render-0.1.0/src/structura_render.egg-info/SOURCES.txt +24 -0
- structura_render-0.1.0/src/structura_render.egg-info/dependency_links.txt +1 -0
- structura_render-0.1.0/src/structura_render.egg-info/entry_points.txt +7 -0
- structura_render-0.1.0/src/structura_render.egg-info/requires.txt +14 -0
- 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,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
|