Add build-local render and release workflows

This commit is contained in:
2026-08-10 21:54:05 +01:00
parent eadef6c60e
commit 4a91533b63
244 changed files with 3585 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env python3
from __future__ import annotations
from build_readme_impl import main
if __name__ == "__main__":
raise SystemExit(main())
+436
View File
@@ -0,0 +1,436 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import html
import json
import shutil
from pathlib import Path
from typing import Any
def load_json(path: Path) -> Any:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def iter_custom_name_selects(node: Any) -> list[dict[str, Any]]:
selects: list[dict[str, Any]] = []
if isinstance(node, dict):
if node.get("type") == "select" and node.get("component") == "minecraft:custom_name":
selects.append(node)
for value in node.values():
selects.extend(iter_custom_name_selects(value))
elif isinstance(node, list):
for value in node:
selects.extend(iter_custom_name_selects(value))
return selects
def resolve_model_ref(model_node: Any) -> str | None:
if not isinstance(model_node, dict):
return None
model_ref = model_node.get("model")
if isinstance(model_ref, str):
return model_ref
return None
def namespace_and_path(model_ref: str) -> tuple[str, str] | None:
if ":" not in model_ref:
return None
namespace, path = model_ref.split(":", 1)
return namespace, path
def item_name_from_fallback(fallback_model: str) -> str | None:
prefix = "minecraft:item/"
if fallback_model.startswith(prefix):
return fallback_model[len(prefix) :]
return None
def render_path_for_model(model_ref: str) -> Path | None:
resolved = namespace_and_path(model_ref)
if resolved is None:
return None
namespace, path = resolved
if namespace == "minecraft" and path.startswith("item/"):
return None
return Path("renders") / namespace / Path(path).with_suffix(".png")
def collect_renameable_items(pack_root: Path) -> list[dict[str, Any]]:
item_files = sorted(pack_root.glob("assets/*/items/*.json"))
records: list[dict[str, Any]] = []
for item_file in item_files:
try:
data = load_json(item_file)
except json.JSONDecodeError:
continue
selects = iter_custom_name_selects(data)
if not selects:
continue
for select_node in selects:
fallback_ref = resolve_model_ref(select_node.get("fallback"))
if fallback_ref is None:
continue
item_name = item_name_from_fallback(fallback_ref)
if item_name is None:
continue
cases: list[dict[str, Any]] = []
for case in select_node.get("cases", []):
if not isinstance(case, dict):
continue
case_model_ref = resolve_model_ref(case.get("model"))
if case_model_ref is None:
continue
case_render_path = render_path_for_model(case_model_ref)
cases.append(
{
"when": str(case.get("when", "")),
"model": case_model_ref,
"render_path": case_render_path.as_posix() if case_render_path else None,
}
)
cases.sort(key=lambda case: case["when"])
base_render_path = render_path_for_model(fallback_ref)
records.append(
{
"item": item_name,
"fallback_model": fallback_ref,
"source_file": item_file.relative_to(pack_root).as_posix(),
"render_path": base_render_path.as_posix() if base_render_path else None,
"cases": cases,
}
)
records.sort(key=lambda record: (record["item"], record["source_file"]))
return records
def copy_existing_renders(pack_root: Path, output_dir: Path) -> None:
source_renders = pack_root / "renders"
if not source_renders.exists():
return
for source_file in source_renders.rglob("*.png"):
relative_path = source_file.relative_to(source_renders)
destination = output_dir / "renders" / relative_path
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_file, destination)
def build_markdown(records: list[dict[str, Any]]) -> str:
total_cases = sum(len(record["cases"]) for record in records)
lines: list[str] = []
lines.append("# Renameable Item Catalog")
lines.append("")
lines.append("Generated from the resource pack item model JSON.")
lines.append("")
lines.append("## Summary")
lines.append("")
lines.append(f"- Base items: {len(records)}")
lines.append(f"- Rename variants: {total_cases}")
lines.append("- Render mode: pluggable")
lines.append("")
lines.append("Sections are sorted by the item they are generated from.")
for record in records:
lines.append(f"## {record['item']}")
lines.append("")
lines.append(f"- Generated from: `{record['source_file']}`")
lines.append(f"- Fallback model: `{record['fallback_model']}`")
if record["render_path"]:
lines.append(f"- Base render: `{record['render_path']}`")
lines.append(f" - Preview: <img src=\"{record['render_path']}\" alt=\"{record['item']}\" width=\"160\" />")
else:
lines.append("- Base render: not present yet")
lines.append("")
lines.append("### Rename variants")
lines.append("")
if not record["cases"]:
lines.append("- None found")
lines.append("")
continue
for case in record["cases"]:
if case["render_path"]:
render_line = f"`{case['render_path']}`"
lines.append(f"- `{case['when']}` -> `{case['model']}`")
lines.append(f" - Preview: <img src=\"{case['render_path']}\" alt=\"{case['when']}\" width=\"160\" />")
continue
else:
render_line = "not present yet"
lines.append(f"- `{case['when']}` -> `{case['model']}` ({render_line})")
lines.append("")
lines.append("---")
lines.append("")
lines.append("This file is generated. Add your renderer output under the matching `renders/` paths to populate image previews later.")
return "\n".join(lines)
def build_readme(records: list[dict[str, Any]]) -> str:
total_cases = sum(len(record["cases"]) for record in records)
lines: list[str] = []
lines.append("# Renameable Item Catalog")
lines.append("")
lines.append("Generated from the resource pack item model JSON.")
lines.append("")
lines.append("## Summary")
lines.append("")
lines.append(f"- Base items: {len(records)}")
lines.append(f"- Rename variants: {total_cases}")
lines.append("- Full catalog: [build/renameable-items.md](build/renameable-items.md)")
lines.append("")
lines.append("## Item Index")
lines.append("")
for record in records:
lines.append(f"- [{record['item']}](build/renameable-items.md#{record['item']})")
lines.append("")
lines.append("The linked catalog contains the generated item sections and rename variants.")
return "\n".join(lines)
def build_html(records: list[dict[str, Any]]) -> str:
total_cases = sum(len(record["cases"]) for record in records)
cards: list[str] = []
for record in records:
preview_items: list[str] = []
for case in record["cases"]:
render_path = case["render_path"]
if render_path:
preview = f'<img src="{html.escape(render_path)}" alt="{html.escape(case["when"])}" />'
else:
preview = '<div class="preview-missing">render pending</div>'
preview_items.append(
"<article class='variant'>"
f"<div class='variant-preview'>{preview}</div>"
f"<div class='variant-name'>{html.escape(case['when'])}</div>"
f"<div class='variant-model'>{html.escape(case['model'])}</div>"
"</article>"
)
render_path = record["render_path"]
if render_path:
hero = f'<img src="{html.escape(render_path)}" alt="{html.escape(record["item"])}" />'
else:
hero = '<div class="hero-missing">no base render</div>'
cards.append(
"<section class='card'>"
f"<div class='hero'>{hero}</div>"
"<div class='meta'>"
f"<h2>{html.escape(record['item'])}</h2>"
f"<p class='source'>{html.escape(record['source_file'])}</p>"
f"<p class='fallback'>Fallback model: {html.escape(record['fallback_model'])}</p>"
f"<div class='variants'>{''.join(preview_items)}</div>"
"</div>"
"</section>"
)
template = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Minecraft Renameable Items</title>
<style>
:root {{
color-scheme: dark;
--bg: #11150f;
--bg-soft: #182017;
--card: rgba(22, 28, 20, 0.88);
--line: rgba(182, 214, 159, 0.18);
--text: #edf4e8;
--muted: #a7b4a1;
--accent: #9de57b;
--accent-soft: rgba(157, 229, 123, 0.14);
--shadow: 0 24px 70px rgba(0, 0, 0, 0.38);
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(157, 229, 123, 0.16), transparent 26%),
radial-gradient(circle at top right, rgba(86, 143, 52, 0.2), transparent 30%),
linear-gradient(180deg, #0d110c 0%, var(--bg) 32%, #0c100b 100%);
min-height: 100vh;
}}
.shell {{ max-width: 1280px; margin: 0 auto; padding: 40px 20px 64px; }}
.hero-panel {{
border: 1px solid var(--line);
border-radius: 28px;
background: linear-gradient(135deg, rgba(28, 39, 24, 0.96), rgba(16, 20, 14, 0.94));
box-shadow: var(--shadow);
padding: 28px;
margin-bottom: 28px;
overflow: hidden;
position: relative;
}}
.hero-panel::after {{
content: "";
position: absolute;
inset: -40% -20% auto auto;
width: 360px;
height: 360px;
background: radial-gradient(circle, rgba(157, 229, 123, 0.2), transparent 68%);
pointer-events: none;
}}
.kicker {{ text-transform: uppercase; letter-spacing: 0.2em; color: var(--accent); font-size: 0.78rem; margin: 0 0 10px; }}
h1 {{ margin: 0; font-size: clamp(2rem, 3.8vw, 4rem); line-height: 0.96; max-width: 10ch; }}
.summary {{ color: var(--muted); max-width: 72ch; margin: 16px 0 0; font-size: 1rem; line-height: 1.6; }}
.stats {{ display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 22px; }}
.stat {{
border: 1px solid var(--line);
border-radius: 18px;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.02);
}}
.stat span {{ display: block; color: var(--muted); font-size: 0.82rem; margin-bottom: 4px; }}
.stat strong {{ font-size: 1.4rem; }}
.grid {{ display: grid; gap: 18px; }}
.card {{
display: grid;
grid-template-columns: 210px minmax(0, 1fr);
gap: 18px;
border: 1px solid var(--line);
border-radius: 24px;
background: var(--card);
box-shadow: var(--shadow);
padding: 18px;
}}
.hero {{
min-height: 210px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(160deg, rgba(157, 229, 123, 0.09), rgba(255, 255, 255, 0.02));
display: grid;
place-items: center;
overflow: hidden;
}}
.hero img, .variant-preview img {{ width: 100%; height: 100%; object-fit: contain; image-rendering: auto; }}
.hero-missing, .preview-missing {{
color: var(--muted);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.12em;
padding: 16px;
text-align: center;
}}
.meta h2 {{ margin: 2px 0 8px; font-size: 1.7rem; }}
.source, .fallback {{ color: var(--muted); margin: 0 0 8px; }}
.variants {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-top: 16px; }}
.variant {{
border: 1px solid rgba(157, 229, 123, 0.14);
border-radius: 18px;
background: rgba(255, 255, 255, 0.03);
padding: 10px;
}}
.variant-preview {{
aspect-ratio: 1 / 1;
border-radius: 14px;
background: rgba(0, 0, 0, 0.18);
margin-bottom: 10px;
overflow: hidden;
display: grid;
place-items: center;
}}
.variant-name {{ font-weight: 700; margin-bottom: 4px; }}
.variant-model {{ color: var(--muted); font-size: 0.82rem; word-break: break-word; }}
.footer {{ color: var(--muted); margin-top: 24px; font-size: 0.9rem; }}
@media (max-width: 860px) {{
.stats, .card {{ grid-template-columns: 1fr; }}
.hero {{ min-height: 160px; }}
}}
</style>
</head>
<body>
<main class="shell">
<section class="hero-panel">
<p class="kicker">Minecraft resource pack browser</p>
<h1>Renameable item catalog</h1>
<p class="summary">This readme is generated from the pack model JSON. It lists every item that changes by custom name, and it is ready to consume rendered PNGs once a renderer job writes them into the output tree.</p>
<div class="stats">
<div class="stat"><span>Base items</span><strong>{len(records)}</strong></div>
<div class="stat"><span>Rename variants</span><strong>{total_cases}</strong></div>
<div class="stat"><span>Render mode</span><strong>pluggable</strong></div>
</div>
</section>
<section class="grid">
{''.join(cards)}
</section>
<p class="footer">Generated from the contents of the resource pack.</p>
</main>
</body>
</html>"""
return template
def main() -> int:
parser = argparse.ArgumentParser(description="Build the renameable-item readme from a Minecraft resource pack.")
parser.add_argument("--pack-root", default="resourcepack", help="Path to the resource pack root")
parser.add_argument("--output-dir", default="site/dist", help="Directory for generated site files")
parser.add_argument("--readme-path", default="README.md", help="Path to the generated README file")
parser.add_argument("--copy-renders", action="store_true", help="Copy pre-rendered PNGs from <pack-root>/renders into the output")
args = parser.parse_args()
pack_root = Path(args.pack_root).resolve()
output_dir = Path(args.output_dir).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
records = collect_renameable_items(pack_root)
manifest_path = output_dir / "renameable-items.json"
manifest_path.write_text(json.dumps(records, indent=2), encoding="utf-8")
readme_path = Path(args.readme_path).resolve()
readme_path.write_text(build_readme(records), encoding="utf-8")
catalog_path = output_dir / "renameable-items.md"
catalog_path.write_text(build_markdown(records), encoding="utf-8")
if args.copy_renders:
copy_existing_renders(pack_root, output_dir)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from pathlib import Path
def zip_resourcepack(resourcepack_root: Path, archive_path: Path) -> None:
archive_path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(archive_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
for source_file in sorted(resourcepack_root.rglob("*")):
if source_file.is_dir():
continue
relative_path = source_file.relative_to(resourcepack_root).as_posix()
archive.write(source_file, relative_path)
def request_json(request: urllib.request.Request) -> dict[str, object]:
with urllib.request.urlopen(request) as response:
return json.load(response)
def create_release(api_base: str, repository: str, token: str, tag: str, name: str, body: str) -> dict[str, object]:
payload = json.dumps(
{
"tag_name": tag,
"name": name,
"body": body,
"draft": False,
"prerelease": False,
}
).encode("utf-8")
request = urllib.request.Request(
f"{api_base}/repos/{repository}/releases",
data=payload,
method="POST",
headers={
"Authorization": f"token {token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
return request_json(request)
def upload_asset(api_base: str, repository: str, token: str, release_id: int, archive_path: Path, asset_name: str) -> dict[str, object]:
with archive_path.open("rb") as handle:
payload = handle.read()
query = urllib.parse.urlencode({"name": asset_name})
request = urllib.request.Request(
f"{api_base}/repos/{repository}/releases/{release_id}/assets?{query}",
data=payload,
method="POST",
headers={
"Authorization": f"token {token}",
"Content-Type": "application/zip",
"Accept": "application/json",
},
)
return request_json(request)
def main() -> int:
parser = argparse.ArgumentParser(description="Package the resource pack and publish it as a Gitea release asset.")
parser.add_argument("--resourcepack-root", default="resourcepack", help="Path to the resource pack root")
parser.add_argument("--archive-path", default="build/resourcepack.zip", help="Path to the generated zip archive")
parser.add_argument("--tag", required=True, help="Release tag to publish")
parser.add_argument("--name", default=None, help="Release name to publish")
parser.add_argument("--body", default=None, help="Release notes/body text")
args = parser.parse_args()
token = os.environ.get("RELEASE_TOKEN") or os.environ.get("GITEA_TOKEN") or os.environ.get("GITHUB_TOKEN")
server_url = os.environ.get("GITEA_SERVER_URL") or os.environ.get("GITHUB_SERVER_URL")
repository = os.environ.get("GITEA_REPOSITORY") or os.environ.get("GITHUB_REPOSITORY")
if not token:
raise SystemExit("RELEASE_TOKEN is required")
if not server_url:
raise SystemExit("GITEA_SERVER_URL or GITHUB_SERVER_URL is required")
if not repository:
raise SystemExit("GITEA_REPOSITORY or GITHUB_REPOSITORY is required")
api_base = server_url.rstrip("/") + "/api/v1"
resourcepack_root = Path(args.resourcepack_root).resolve()
archive_path = Path(args.archive_path).resolve()
release_name = args.name or args.tag
release_body = args.body or f"Automated resource pack release for {args.tag}."
asset_name = f"resourcepack-{args.tag}.zip"
zip_resourcepack(resourcepack_root, archive_path)
release = create_release(api_base, repository, token, args.tag, release_name, release_body)
release_id = release.get("id")
if not isinstance(release_id, int):
raise SystemExit("Release creation did not return a numeric release id")
upload_asset(api_base, repository, token, release_id, archive_path, asset_name)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import subprocess
import sys
from pathlib import Path
def main() -> int:
parser = argparse.ArgumentParser(description="Render renameable item previews with the Minecraft client.")
parser.add_argument("--manifest", default="build/renameable-items.json", help="Path to the generated manifest")
parser.add_argument("--pack-root", default="resourcepack", help="Path to the resource pack root")
parser.add_argument("--output-dir", default="build", help="Directory where renders should be written")
args = parser.parse_args()
repo_root = Path(__file__).resolve().parents[2]
client_renderer = repo_root / "build" / "client-renderer"
env = os.environ.copy()
env["RENDER_MANIFEST"] = str((repo_root / args.manifest).resolve())
env["RENDER_OUTPUT_DIR"] = str((repo_root / args.output_dir).resolve())
env["RENDER_PACK_ROOT"] = str((repo_root / args.pack_root).resolve())
command = ["gradle", "-p", str(client_renderer), "runClient", "--no-daemon"]
try:
completed = subprocess.run(command, cwd=repo_root, env=env)
except FileNotFoundError:
fallback = [
sys.executable,
str(Path(__file__).resolve().with_name("render_models.py")),
"--manifest",
str((repo_root / args.manifest).resolve()),
"--pack-root",
str((repo_root / args.pack_root).resolve()),
"--output-dir",
str((repo_root / args.output_dir).resolve()),
]
completed = subprocess.run(fallback, cwd=repo_root, env=env)
return completed.returncode
if __name__ == "__main__":
raise SystemExit(main())
+494
View File
@@ -0,0 +1,494 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import math
import hashlib
import shutil
from functools import lru_cache
from pathlib import Path
from typing import Any
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageOps
def load_json(path: Path) -> Any:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def normalize_model_ref(model_ref: str) -> str:
if ":" in model_ref:
return model_ref
return f"minecraft:{model_ref}"
def model_ref_to_path(pack_root: Path, model_ref: str) -> Path:
namespace, path = normalize_model_ref(model_ref).split(":", 1)
return pack_root / "assets" / namespace / "models" / f"{path}.json"
def texture_ref_to_path(pack_root: Path, texture_ref: str) -> Path:
namespace, path = normalize_model_ref(texture_ref).split(":", 1)
return pack_root / "assets" / namespace / "textures" / f"{path}.png"
def resolve_texture_value(model: dict[str, Any], texture_ref: str, seen: set[str] | None = None) -> str | None:
if seen is None:
seen = set()
value = texture_ref
while value.startswith("#"):
slot = value[1:]
if slot in seen:
return None
seen.add(slot)
textures = model.get("textures", {})
value = textures.get(slot)
if not isinstance(value, str):
return None
return normalize_model_ref(value)
def load_image_or_placeholder(pack_root: Path, texture_ref: str | None, size: tuple[int, int]) -> Image.Image:
if texture_ref is None:
return placeholder_texture("missing", size)
texture_path = texture_ref_to_path(pack_root, texture_ref)
if texture_path.exists():
return Image.open(texture_path).convert("RGBA")
return placeholder_texture(texture_ref, size)
def placeholder_texture(key: str, size: tuple[int, int]) -> Image.Image:
width, height = size
digest = hashlib.sha1(key.encode("utf-8")).digest()
base = (digest[0], digest[1], digest[2], 255)
dark = tuple(max(0, channel - 42) for channel in base[:3]) + (255,)
image = Image.new("RGBA", size, base)
draw = ImageDraw.Draw(image)
step = max(2, min(width, height) // 4)
for y in range(0, height, step):
for x in range(0, width, step):
if (x // step + y // step) % 2 == 0:
draw.rectangle((x, y, x + step - 1, y + step - 1), fill=dark)
image = image.resize(size, Image.Resampling.NEAREST)
return image
def merge_model(pack_root: Path, model_ref: str, cache: dict[str, dict[str, Any]], stack: set[str] | None = None) -> dict[str, Any] | None:
model_ref = normalize_model_ref(model_ref)
if model_ref in cache:
return cache[model_ref]
if stack is None:
stack = set()
if model_ref in stack:
return None
stack.add(model_ref)
model_path = model_ref_to_path(pack_root, model_ref)
if not model_path.exists():
return None
data = load_json(model_path)
parent_ref = data.get("parent")
parent_model = None
if isinstance(parent_ref, str):
parent_model = merge_model(pack_root, parent_ref, cache, stack)
merged: dict[str, Any] = {}
if parent_model:
merged.update(parent_model)
textures = dict(merged.get("textures", {}))
textures.update(data.get("textures", {}))
merged["textures"] = textures
if "elements" in data:
merged["elements"] = data["elements"]
elif parent_model and "elements" in parent_model:
merged["elements"] = parent_model["elements"]
else:
merged["elements"] = []
if "texture_size" in data:
merged["texture_size"] = data["texture_size"]
elif parent_model and "texture_size" in parent_model:
merged["texture_size"] = parent_model["texture_size"]
else:
merged["texture_size"] = [16, 16]
cache[model_ref] = merged
return merged
def rotate_point(point: tuple[float, float, float], rotation: dict[str, Any] | None) -> tuple[float, float, float]:
if not rotation:
return point
angle = math.radians(float(rotation.get("angle", 0)))
axis = rotation.get("axis")
origin = rotation.get("origin", [0, 0, 0])
ox, oy, oz = (float(origin[0]), float(origin[1]), float(origin[2]))
x, y, z = point
x -= ox
y -= oy
z -= oz
sin_a = math.sin(angle)
cos_a = math.cos(angle)
if axis == "x":
y, z = y * cos_a - z * sin_a, y * sin_a + z * cos_a
elif axis == "y":
x, z = x * cos_a + z * sin_a, -x * sin_a + z * cos_a
elif axis == "z":
x, y = x * cos_a - y * sin_a, x * sin_a + y * cos_a
return x + ox, y + oy, z + oz
def apply_element_rotation(point: tuple[float, float, float], element: dict[str, Any]) -> tuple[float, float, float]:
return rotate_point(point, element.get("rotation"))
def apply_display_transform(point: tuple[float, float, float], display: dict[str, Any] | None) -> tuple[float, float, float]:
if not display:
return point
x, y, z = point
center_x, center_y, center_z = 8.0, 8.0, 8.0
x -= center_x
y -= center_y
z -= center_z
scale = display.get("scale", [1, 1, 1])
x *= float(scale[0])
y *= float(scale[1])
z *= float(scale[2])
x, y, z = rotate_point((x, y, z), {"angle": float(display.get("rotation", [0, 0, 0])[0]), "axis": "x", "origin": [0, 0, 0]})
x, y, z = rotate_point((x, y, z), {"angle": float(display.get("rotation", [0, 0, 0])[1]), "axis": "y", "origin": [0, 0, 0]})
x, y, z = rotate_point((x, y, z), {"angle": float(display.get("rotation", [0, 0, 0])[2]), "axis": "z", "origin": [0, 0, 0]})
translation = display.get("translation", [0, 0, 0])
x += float(translation[0]) / 16.0
y += float(translation[1]) / 16.0
z += float(translation[2]) / 16.0
return x + center_x, y + center_y, z + center_z
def element_corners(element: dict[str, Any]) -> list[tuple[float, float, float]]:
from_x, from_y, from_z = element["from"]
to_x, to_y, to_z = element["to"]
base = [
(from_x, from_y, from_z),
(to_x, from_y, from_z),
(to_x, to_y, from_z),
(from_x, to_y, from_z),
(from_x, from_y, to_z),
(to_x, from_y, to_z),
(to_x, to_y, to_z),
(from_x, to_y, to_z),
]
return [apply_element_rotation(point, element) for point in base]
def project_point(point: tuple[float, float, float], scale: float, offset_x: float, offset_y: float) -> tuple[float, float, float]:
x, y, z = point
yaw = math.radians(45)
pitch = math.radians(35.26438968)
x1 = x * math.cos(yaw) + z * math.sin(yaw)
z1 = -x * math.sin(yaw) + z * math.cos(yaw)
y1 = y * math.cos(pitch) - z1 * math.sin(pitch)
z2 = y * math.sin(pitch) + z1 * math.cos(pitch)
return offset_x + x1 * scale, offset_y - y1 * scale, z2
def face_vertices(corners: list[tuple[float, float, float]], face_name: str) -> list[tuple[float, float, float]]:
if face_name == "north":
return [corners[0], corners[1], corners[2], corners[3]]
if face_name == "south":
return [corners[5], corners[4], corners[7], corners[6]]
if face_name == "west":
return [corners[4], corners[0], corners[3], corners[7]]
if face_name == "east":
return [corners[1], corners[5], corners[6], corners[2]]
if face_name == "up":
return [corners[3], corners[2], corners[6], corners[7]]
if face_name == "down":
return [corners[4], corners[5], corners[1], corners[0]]
return []
def sort_face_key(vertices: list[tuple[float, float, float]]) -> float:
return sum(point[2] for point in vertices) / len(vertices)
def face_normal(vertices: list[tuple[float, float, float]]) -> tuple[float, float, float]:
p0, p1, p2 = vertices[0], vertices[1], vertices[2]
ux, uy, uz = p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]
vx, vy, vz = p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]
return (
uy * vz - uz * vy,
uz * vx - ux * vz,
ux * vy - uy * vx,
)
def normalize_vector(vector: tuple[float, float, float]) -> tuple[float, float, float]:
length = math.sqrt(vector[0] ** 2 + vector[1] ** 2 + vector[2] ** 2)
if length == 0:
return (0.0, 0.0, 0.0)
return (vector[0] / length, vector[1] / length, vector[2] / length)
def resolve_face_texture(pack_root: Path, model: dict[str, Any], face: dict[str, Any]) -> Image.Image:
texture_ref = face.get("texture")
if not isinstance(texture_ref, str):
return placeholder_texture("missing-face", (16, 16))
resolved = resolve_texture_value(model, texture_ref)
if resolved is None:
return placeholder_texture(texture_ref, (16, 16))
texture_path = texture_ref_to_path(pack_root, resolved)
texture_size = model.get("texture_size", [16, 16])
if texture_path.exists():
texture_image = Image.open(texture_path).convert("RGBA")
else:
texture_image = placeholder_texture(resolved, (int(texture_size[0]), int(texture_size[1])))
u1, v1, u2, v2 = face.get("uv", [0, 0, texture_image.width, texture_image.height])
source_width, source_height = texture_image.size
texture_size_x, texture_size_y = texture_size
scale_x = source_width / float(texture_size_x or source_width)
scale_y = source_height / float(texture_size_y or source_height)
left = float(min(u1, u2) * scale_x)
right = float(max(u1, u2) * scale_x)
top = float(min(v1, v2) * scale_y)
bottom = float(max(v1, v2) * scale_y)
crop_width = max(1, int(math.ceil(abs(right - left))))
crop_height = max(1, int(math.ceil(abs(bottom - top))))
crop = texture_image.transform(
(crop_width, crop_height),
Image.Transform.EXTENT,
(left, top, right, bottom),
resample=Image.Resampling.NEAREST,
)
if u2 < u1:
crop = crop.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
if v2 < v1:
crop = crop.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
rotation = int(face.get("rotation", 0) or 0)
if rotation:
crop = crop.rotate(rotation, expand=True, resample=Image.Resampling.NEAREST)
return crop
def transform_face(texture: Image.Image, vertices: list[tuple[float, float, float]], canvas: Image.Image) -> tuple[Image.Image, tuple[int, int]] | None:
projected = [point[:2] for point in vertices]
xs = [point[0] for point in projected]
ys = [point[1] for point in projected]
min_x = math.floor(min(xs))
min_y = math.floor(min(ys))
max_x = math.ceil(max(xs))
max_y = math.ceil(max(ys))
width = max(1, max_x - min_x)
height = max(1, max_y - min_y)
local = [(x - min_x, y - min_y) for x, y, _ in projected]
p0, p1, _, p3 = local
source_width, source_height = texture.size
edge_a_x = p1[0] - p0[0]
edge_a_y = p1[1] - p0[1]
edge_b_x = p3[0] - p0[0]
edge_b_y = p3[1] - p0[1]
det = edge_a_x * edge_b_y - edge_a_y * edge_b_x
if abs(det) < 1e-5:
return None
a = source_width * edge_b_y / det
b = -source_width * edge_b_x / det
c = source_width * (edge_b_x * p0[1] - edge_b_y * p0[0]) / det
d = -source_height * edge_a_y / det
e = source_height * edge_a_x / det
f = source_height * (edge_a_y * p0[0] - edge_a_x * p0[1]) / det
warped = texture.transform((width, height), Image.Transform.AFFINE, (a, b, c, d, e, f), resample=Image.Resampling.BICUBIC)
mask = Image.new("L", (width, height), 0)
ImageDraw.Draw(mask).polygon(local, fill=255)
return warped.putalpha(mask), (min_x, min_y)
def render_model(pack_root: Path, model_ref: str, output_path: Path, cache: dict[str, dict[str, Any]]) -> None:
model = merge_model(pack_root, model_ref, cache)
output_path.parent.mkdir(parents=True, exist_ok=True)
canvas_size = 256
canvas = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0))
if not model or not model.get("elements"):
render_placeholder_icon(canvas, model_ref)
canvas.save(output_path)
return
transformed_elements: list[tuple[list[tuple[float, float, float]], dict[str, Any], dict[str, Any]]] = []
all_projected: list[tuple[float, float, float]] = []
display = model.get("display", {}).get("gui") if isinstance(model.get("display"), dict) else None
for element in model.get("elements", []):
corners = element_corners(element)
corners = [apply_display_transform(point, display) for point in corners]
for corner in corners:
all_projected.append(corner)
transformed_elements.append((corners, element, model))
projected_points = [project_point(point, 1.0, 0.0, 0.0) for point in all_projected]
xs = [point[0] for point in projected_points]
ys = [point[1] for point in projected_points]
width = max(xs) - min(xs)
height = max(ys) - min(ys)
if width <= 0 or height <= 0:
render_placeholder_icon(canvas, model_ref)
canvas.save(output_path)
return
scale = min((canvas_size - 56) / width, (canvas_size - 56) / height)
bbox_xs: list[float] = []
bbox_ys: list[float] = []
for corner in all_projected:
x, y, _ = project_point(corner, scale, 0.0, 0.0)
bbox_xs.append(x)
bbox_ys.append(y)
min_x = min(bbox_xs)
max_x = max(bbox_xs)
min_y = min(bbox_ys)
max_y = max(bbox_ys)
offset_x = (canvas_size - (max_x - min_x)) / 2 - min_x
offset_y = (canvas_size - (max_y - min_y)) / 2 - min_y + 6
shadow_width = int((max_x - min_x) * 0.75)
shadow_height = max(14, int((max_y - min_y) * 0.18))
shadow = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0))
shadow_box = (
int((canvas_size - shadow_width) / 2),
int(canvas_size * 0.74),
int((canvas_size + shadow_width) / 2),
int(canvas_size * 0.74) + shadow_height,
)
ImageDraw.Draw(shadow).ellipse(shadow_box, fill=(0, 0, 0, 80))
shadow = shadow.filter(ImageFilter.GaussianBlur(8))
canvas.alpha_composite(shadow)
faces: list[tuple[float, list[tuple[float, float, float]], Image.Image]] = []
camera_direction = normalize_vector((-1.0, -1.0, -1.0))
for corners, element, model_data in transformed_elements:
for face_name, face in element.get("faces", {}).items():
vertices = face_vertices(corners, face_name)
if not vertices:
continue
normal = face_normal(vertices)
if normal[0] * camera_direction[0] + normal[1] * camera_direction[1] + normal[2] * camera_direction[2] >= 0:
continue
projected = [project_point(point, scale, offset_x, offset_y) for point in vertices]
texture = resolve_face_texture(pack_root, model_data, face)
faces.append((sort_face_key(projected), projected, texture))
faces.sort(key=lambda item: item[0])
for _, projected, texture in faces:
xs = [point[0] for point in projected]
ys = [point[1] for point in projected]
min_x = math.floor(min(xs))
min_y = math.floor(min(ys))
max_x = math.ceil(max(xs))
max_y = math.ceil(max(ys))
width = max(1, max_x - min_x)
height = max(1, max_y - min_y)
local = [(x - min_x, y - min_y) for x, y, _ in projected]
p0, p1, _, p3 = local
edge_a_x = p1[0] - p0[0]
edge_a_y = p1[1] - p0[1]
edge_b_x = p3[0] - p0[0]
edge_b_y = p3[1] - p0[1]
det = edge_a_x * edge_b_y - edge_a_y * edge_b_x
if abs(det) < 1e-5:
continue
source_width, source_height = texture.size
a = source_width * edge_b_y / det
b = -source_width * edge_b_x / det
c = source_width * (edge_b_x * p0[1] - edge_b_y * p0[0]) / det
d = -source_height * edge_a_y / det
e = source_height * edge_a_x / det
f = source_height * (edge_a_y * p0[0] - edge_a_x * p0[1]) / det
warped = texture.transform((width, height), Image.Transform.AFFINE, (a, b, c, d, e, f), resample=Image.Resampling.NEAREST)
mask = Image.new("L", (width, height), 0)
ImageDraw.Draw(mask).polygon(local, fill=255)
warped.putalpha(mask)
canvas.alpha_composite(warped, (min_x, min_y))
canvas.save(output_path)
def render_placeholder_icon(canvas: Image.Image, model_ref: str) -> None:
draw = ImageDraw.Draw(canvas)
base_box = (70, 76, 186, 178)
top_box = (82, 52, 174, 104)
side_box = (174, 64, 198, 174)
draw.rounded_rectangle(base_box, radius=14, fill=(120, 126, 134, 255), outline=(220, 226, 232, 70), width=2)
draw.polygon([(82, 52), (174, 52), (186, 76), (70, 76)], fill=(170, 176, 184, 255))
draw.polygon([(174, 52), (186, 76), (186, 178), (174, 174)], fill=(94, 100, 108, 255))
draw.line((92, 98, 162, 150), fill=(255, 255, 255, 90), width=6)
draw.line((92, 150, 162, 98), fill=(255, 255, 255, 90), width=6)
def unique_model_refs(records: list[dict[str, Any]]) -> list[str]:
refs: set[str] = set()
for record in records:
for case in record.get("cases", []):
refs.add(case["model"])
return sorted(refs)
def main() -> int:
parser = argparse.ArgumentParser(description="Render 3D-ish PNG previews from resource pack model JSON.")
parser.add_argument("--manifest", default="build/renameable-items.json", help="Path to the generated manifest")
parser.add_argument("--pack-root", default="resourcepack", help="Path to the resource pack root")
parser.add_argument("--output-dir", default="build", help="Directory where renders should be written")
args = parser.parse_args()
manifest_path = Path(args.manifest).resolve()
pack_root = Path(args.pack_root).resolve()
output_dir = Path(args.output_dir).resolve()
records = load_json(manifest_path)
refs = unique_model_refs(records)
cache: dict[str, dict[str, Any]] = {}
renders_root = output_dir / "renders"
if renders_root.exists():
shutil.rmtree(renders_root)
for model_ref in refs:
namespace, relative_path = normalize_model_ref(model_ref).split(":", 1)
output_path = output_dir / "renders" / namespace / Path(relative_path).with_suffix(".png")
render_model(pack_root, model_ref, output_path, cache)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
from urllib.request import urlopen
VERSION_MANIFEST_URL = "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json"
def main() -> int:
with urlopen(VERSION_MANIFEST_URL, timeout=30) as response:
manifest = json.load(response)
latest_release = manifest["latest"]["release"]
latest_snapshot = manifest["latest"]["snapshot"]
print(latest_release)
print(f"snapshot={latest_snapshot}")
return 0
if __name__ == "__main__":
raise SystemExit(main())