535 lines
19 KiB
Python
535 lines
19 KiB
Python
#!/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
|
|
|
|
|
|
PREVIEW_SCALE_MULTIPLIER = 1.25
|
|
|
|
|
|
PREVIEW_MARGIN_RATIO = 0.22
|
|
|
|
|
|
UV_SAMPLE_SCALE = 16
|
|
|
|
|
|
PREVIEW_MODEL_ROTATION_Y = 180
|
|
|
|
|
|
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 first_animation_frame(image: Image.Image, texture_path: Path) -> Image.Image:
|
|
if getattr(image, "is_animated", False):
|
|
image.seek(0)
|
|
return image.copy()
|
|
|
|
mcmeta_path = texture_path.with_suffix(".png.mcmeta")
|
|
if not mcmeta_path.exists():
|
|
return image.copy()
|
|
|
|
try:
|
|
metadata = load_json(mcmeta_path)
|
|
except Exception:
|
|
return image.copy()
|
|
|
|
animation = metadata.get("animation") if isinstance(metadata, dict) else None
|
|
if not isinstance(animation, dict):
|
|
return image.copy()
|
|
|
|
frame_width = int(animation.get("width") or 0)
|
|
frame_height = int(animation.get("height") or 0)
|
|
if frame_width <= 0 or frame_height <= 0:
|
|
if image.height >= image.width and image.height % image.width == 0:
|
|
frame_width = frame_height = image.width
|
|
elif image.width > image.height and image.width % image.height == 0:
|
|
frame_width = frame_height = image.height
|
|
else:
|
|
frame_width, frame_height = image.size
|
|
|
|
frames = animation.get("frames")
|
|
frame_index = 0
|
|
if isinstance(frames, list) and frames:
|
|
first_frame = frames[0]
|
|
if isinstance(first_frame, int):
|
|
frame_index = first_frame
|
|
elif isinstance(first_frame, dict) and isinstance(first_frame.get("index"), int):
|
|
frame_index = int(first_frame["index"])
|
|
|
|
columns = max(1, image.width // frame_width)
|
|
frame_x = (frame_index % columns) * frame_width
|
|
frame_y = (frame_index // columns) * frame_height
|
|
frame_x = min(frame_x, max(0, image.width - frame_width))
|
|
frame_y = min(frame_y, max(0, image.height - frame_height))
|
|
return image.crop((frame_x, frame_y, frame_x + frame_width, frame_y + frame_height))
|
|
|
|
|
|
def load_texture_image(texture_path: Path) -> Image.Image:
|
|
image = Image.open(texture_path)
|
|
image = first_animation_frame(image, texture_path)
|
|
return image.convert("RGBA")
|
|
|
|
|
|
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 load_texture_image(texture_path)
|
|
|
|
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]
|
|
|
|
for key in ("display", "gui_light", "render_type", "groups", "credit", "ambientocclusion"):
|
|
if key in data:
|
|
merged[key] = data[key]
|
|
elif parent_model and key in parent_model:
|
|
merged[key] = parent_model[key]
|
|
|
|
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 apply_preview_rotation(point: tuple[float, float, float]) -> tuple[float, float, float]:
|
|
return rotate_point(point, {"angle": PREVIEW_MODEL_ROTATION_Y, "axis": "y", "origin": [8.0, 8.0, 8.0]})
|
|
|
|
|
|
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 = load_texture_image(texture_path)
|
|
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
|
|
scale_x = source_width / 16.0
|
|
scale_y = source_height / 16.0
|
|
|
|
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) * UV_SAMPLE_SCALE)))
|
|
crop_height = max(1, int(math.ceil(abs(bottom - top) * UV_SAMPLE_SCALE)))
|
|
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) -> Image.Image | None:
|
|
projected = [point[:2] for point in vertices]
|
|
p0, p1, _, p3 = projected
|
|
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(
|
|
canvas.size,
|
|
Image.Transform.AFFINE,
|
|
(a, b, c, d, e, f),
|
|
resample=Image.Resampling.NEAREST,
|
|
)
|
|
mask = Image.new("L", canvas.size, 0)
|
|
ImageDraw.Draw(mask).polygon(projected, fill=255)
|
|
texture_alpha = warped.getchannel("A")
|
|
warped.putalpha(ImageChops.multiply(texture_alpha, mask))
|
|
|
|
return warped
|
|
|
|
|
|
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]
|
|
corners = [apply_preview_rotation(point) 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
|
|
|
|
preview_margin = canvas_size * PREVIEW_MARGIN_RATIO
|
|
scale = min((canvas_size - preview_margin) / width, (canvas_size - preview_margin) / height) * PREVIEW_SCALE_MULTIPLIER
|
|
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]] = []
|
|
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
|
|
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:
|
|
warped = transform_face(texture, projected, canvas)
|
|
if warped is None:
|
|
continue
|
|
canvas.alpha_composite(warped)
|
|
|
|
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()) |