#!/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())