structura-render 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ """Determine, for every vanilla block, whether it's a real full opaque
3
+ cube (occupies [0,0,0]-[16,16,16] with all 6 faces defined) by resolving
4
+ its actual blockstate -> model -> parent chain from the game's own
5
+ files -- not a name/substring guess. Writes structura_render/data/full_cube_blocks.json,
6
+ loaded by full_cube.is_full_cube_shape()."""
7
+ import json
8
+ from pathlib import Path
9
+
10
+ from .assets import ASSETS
11
+ from .block_model import DIRECTIONS
12
+
13
+ BLOCKSTATES = ASSETS / "blockstates"
14
+ MODELS = ASSETS / "models/block"
15
+
16
+ MULTIPART_SOLID_FULL_CUBES = {
17
+ "minecraft:brown_mushroom_block",
18
+ "minecraft:red_mushroom_block",
19
+ "minecraft:mushroom_stem",
20
+ "minecraft:chiseled_bookshelf",
21
+ }
22
+
23
+ _model_cache = {}
24
+
25
+
26
+ def load_model(name):
27
+ name = name.split(":", 1)[-1]
28
+ if name.startswith("block/"):
29
+ name = name[len("block/"):]
30
+ if name in _model_cache:
31
+ return _model_cache[name]
32
+ path = MODELS / f"{name}.json"
33
+ data = json.loads(path.read_text()) if path.exists() else None
34
+ _model_cache[name] = data
35
+ return data
36
+
37
+
38
+ def resolve_elements(model_name, depth=0):
39
+ if depth > 10:
40
+ return None
41
+ data = load_model(model_name)
42
+ if data is None:
43
+ return None
44
+ if "elements" in data:
45
+ return data["elements"]
46
+ parent = data.get("parent")
47
+ return resolve_elements(parent, depth + 1) if parent else None
48
+
49
+
50
+ def is_full_cube_element(element):
51
+ if element.get("from") != [0, 0, 0] or element.get("to") != [16, 16, 16]:
52
+ return False
53
+ faces = element.get("faces", {})
54
+ return all(d in faces for d in DIRECTIONS)
55
+
56
+
57
+ def is_full_cube_elements(elements):
58
+ return bool(elements) and any(is_full_cube_element(el) for el in elements)
59
+
60
+
61
+ def variant_model_name(blockstate):
62
+ first = next(iter(blockstate["variants"].values()))
63
+ if isinstance(first, list):
64
+ first = first[0]
65
+ return first.get("model")
66
+
67
+
68
+ def classify_full_cube(name, blockstate):
69
+ if "multipart" in blockstate:
70
+ return name in MULTIPART_SOLID_FULL_CUBES
71
+ model_name = variant_model_name(blockstate)
72
+ return is_full_cube_elements(resolve_elements(model_name))
73
+
74
+
75
+ def main():
76
+ results = {}
77
+ for path in sorted(BLOCKSTATES.glob("*.json")):
78
+ name = f"minecraft:{path.stem}"
79
+ blockstate = json.loads(path.read_text())
80
+ results[name] = classify_full_cube(name, blockstate)
81
+
82
+ out_dir = Path(__file__).resolve().parent / "data"
83
+ out_dir.mkdir(exist_ok=True)
84
+ out_path = out_dir / "full_cube_blocks.json"
85
+ out_path.write_text(json.dumps(results, indent=1, sort_keys=True) + "\n")
86
+ print(f"{out_path} full_cubes={sum(results.values())}/{len(results)}")
87
+
88
+
89
+ if __name__ == "__main__":
90
+ main()
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env python3
2
+ """For every block classified as a full cube (structura_render/data/full_cube_blocks.json),
3
+ determine whether it's actually fully opaque by reading the real alpha channel
4
+ of its real texture(s) -- not by name or by shape. A full-cube block whose
5
+ texture has any non-255 alpha (leaves, ice, honey, slime...) must not occlude
6
+ neighbor faces even though its shape is a full cube. Writes
7
+ structura_render/data/opaque_blocks.json, loaded by full_cube.is_opaque_shape()."""
8
+ import json
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ from PIL import Image
13
+
14
+ from .assets import ASSETS
15
+ from .block_model import DIRECTIONS, block_elements
16
+ from .build_full_cube_list import MULTIPART_SOLID_FULL_CUBES
17
+
18
+ TEXTURES = ASSETS / "textures/block"
19
+ FULL_CUBE_DATA = Path(__file__).resolve().parent / "data/full_cube_blocks.json"
20
+
21
+ _texture_opacity_cache = {}
22
+
23
+
24
+ def texture_is_opaque(texture_name):
25
+ if texture_name not in _texture_opacity_cache:
26
+ path = TEXTURES / f"{texture_name}.png"
27
+ if not path.exists():
28
+ _texture_opacity_cache[texture_name] = True
29
+ else:
30
+ image = Image.open(path).convert("RGBA")
31
+ array = np.asarray(image)
32
+ if array.shape[0] > array.shape[1]:
33
+ array = array[: array.shape[1]]
34
+ _texture_opacity_cache[texture_name] = bool((array[..., 3] == 255).all())
35
+ return _texture_opacity_cache[texture_name]
36
+
37
+
38
+ def element_is_opaque_cube(element):
39
+ if element["lo"] != (0.0, 0.0, 0.0) or element["hi"] != (1.0, 1.0, 1.0):
40
+ return False
41
+ faces = element["faces"]
42
+ return all(d in faces and texture_is_opaque(faces[d]["texture"]) for d in DIRECTIONS)
43
+
44
+
45
+ def block_is_opaque(name):
46
+ elements = block_elements(name, {})
47
+ if elements is None:
48
+ return name in MULTIPART_SOLID_FULL_CUBES
49
+ return any(element_is_opaque_cube(element) for element in elements)
50
+
51
+
52
+ def main():
53
+ full_cubes = json.loads(FULL_CUBE_DATA.read_text())
54
+ results = {
55
+ name: block_is_opaque(name)
56
+ for name, is_full_cube in full_cubes.items()
57
+ if is_full_cube
58
+ }
59
+ out_path = Path(__file__).resolve().parent / "data/opaque_blocks.json"
60
+ out_path.write_text(json.dumps(results, indent=1, sort_keys=True) + "\n")
61
+ print(f"{out_path} opaque={sum(results.values())}/{len(results)}")
62
+
63
+
64
+ if __name__ == "__main__":
65
+ main()