Normalize generated docs and asset naming
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -196,27 +196,88 @@ def build_markdown(records: list[dict[str, Any]]) -> str:
|
||||
|
||||
|
||||
def build_readme(records: list[dict[str, Any]]) -> str:
|
||||
total_cases = sum(len(record["cases"]) for record in records)
|
||||
namespaces = sorted(
|
||||
{
|
||||
case_namespace(case)
|
||||
for record in records
|
||||
for case in record["cases"]
|
||||
if case_namespace(case)
|
||||
}
|
||||
)
|
||||
lines: list[str] = []
|
||||
|
||||
lines.append("# Renameable Item Catalog")
|
||||
lines.append("")
|
||||
lines.append("Generated from the resource pack item model JSON.")
|
||||
lines.append("")
|
||||
lines.append("## Namespace catalogs")
|
||||
lines.append("")
|
||||
for namespace in namespaces:
|
||||
lines.append(f"- [renameable-items-{namespace}.md](build/renameable-items-{namespace}.md)")
|
||||
|
||||
lines.append("")
|
||||
lines.append("Each namespace catalog is generated from the matching model namespace in the resource pack.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def case_namespace(case: dict[str, Any]) -> str | None:
|
||||
resolved = namespace_and_path(case["model"])
|
||||
if resolved is None:
|
||||
return None
|
||||
|
||||
return resolved[0]
|
||||
|
||||
|
||||
def build_namespace_catalog(records: list[dict[str, Any]], namespace: str) -> str:
|
||||
namespace_records = [
|
||||
record
|
||||
for record in records
|
||||
if any(case_namespace(case) == namespace for case in record["cases"])
|
||||
]
|
||||
total_cases = sum(1 for record in namespace_records for case in record["cases"] if case_namespace(case) == namespace)
|
||||
lines: list[str] = []
|
||||
|
||||
lines.append(f"# Renameable Item Catalog - {namespace}")
|
||||
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"- Base items: {len(namespace_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("- Render mode: local Python renderer")
|
||||
lines.append("")
|
||||
|
||||
for record in records:
|
||||
lines.append(f"- [{record['item']}](build/renameable-items.md#{record['item']})")
|
||||
for record in namespace_records:
|
||||
lines.append(f"## {record['item']}")
|
||||
lines.append("")
|
||||
lines.append(f"- Generated from: `{record['source_file']}`")
|
||||
lines.append(f"- Fallback model: `{record['fallback_model']}`")
|
||||
|
||||
lines.append("")
|
||||
lines.append("### Rename variants")
|
||||
lines.append("")
|
||||
|
||||
namespace_cases = [case for case in record["cases"] if case_namespace(case) == namespace]
|
||||
|
||||
if not namespace_cases:
|
||||
lines.append("- None found")
|
||||
lines.append("")
|
||||
continue
|
||||
|
||||
for case in namespace_cases:
|
||||
if case["render_path"]:
|
||||
lines.append(f"- `{case['when']}` -> `{case['model']}`")
|
||||
lines.append(f" - Preview: <img src=\"{case['render_path']}\" alt=\"{case['when']}\" width=\"160\" />")
|
||||
else:
|
||||
lines.append(f"- `{case['when']}` -> `{case['model']}` (not present yet)")
|
||||
|
||||
lines.append("")
|
||||
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("The linked catalog contains the generated item sections and rename variants.")
|
||||
lines.append("This file is generated. Add your renderer output under the matching `renders/` paths to populate image previews later.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -423,8 +484,18 @@ def main() -> int:
|
||||
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")
|
||||
namespace_names = sorted(
|
||||
{
|
||||
case_namespace(case)
|
||||
for record in records
|
||||
for case in record["cases"]
|
||||
if case_namespace(case)
|
||||
}
|
||||
)
|
||||
|
||||
for namespace in namespace_names:
|
||||
namespace_catalog = build_namespace_catalog(records, namespace)
|
||||
(output_dir / f"renameable-items-{namespace}.md").write_text(namespace_catalog, encoding="utf-8")
|
||||
|
||||
if args.copy_renders:
|
||||
copy_existing_renders(pack_root, output_dir)
|
||||
|
||||
@@ -13,6 +13,18 @@ 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)
|
||||
@@ -34,6 +46,57 @@ def texture_ref_to_path(pack_root: Path, texture_ref: str) -> Path:
|
||||
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()
|
||||
@@ -58,7 +121,7 @@ def load_image_or_placeholder(pack_root: Path, texture_ref: str | None, size: tu
|
||||
|
||||
texture_path = texture_ref_to_path(pack_root, texture_ref)
|
||||
if texture_path.exists():
|
||||
return Image.open(texture_path).convert("RGBA")
|
||||
return load_texture_image(texture_path)
|
||||
|
||||
return placeholder_texture(texture_ref, size)
|
||||
|
||||
@@ -183,6 +246,10 @@ def apply_display_transform(point: tuple[float, float, float], display: dict[str
|
||||
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"]
|
||||
@@ -261,23 +328,22 @@ def resolve_face_texture(pack_root: Path, model: dict[str, Any], face: dict[str,
|
||||
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")
|
||||
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
|
||||
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)
|
||||
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))))
|
||||
crop_height = max(1, int(math.ceil(abs(bottom - top))))
|
||||
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,
|
||||
@@ -296,19 +362,9 @@ def resolve_face_texture(pack_root: Path, model: dict[str, Any], face: dict[str,
|
||||
return crop
|
||||
|
||||
|
||||
def transform_face(texture: Image.Image, vertices: list[tuple[float, float, float]], canvas: Image.Image) -> tuple[Image.Image, tuple[int, int]] | None:
|
||||
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]
|
||||
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
|
||||
p0, p1, _, p3 = projected
|
||||
source_width, source_height = texture.size
|
||||
edge_a_x = p1[0] - p0[0]
|
||||
edge_a_y = p1[1] - p0[1]
|
||||
@@ -325,13 +381,18 @@ def transform_face(texture: Image.Image, vertices: list[tuple[float, float, floa
|
||||
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)
|
||||
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, (min_x, min_y)
|
||||
return warped
|
||||
|
||||
|
||||
def render_model(pack_root: Path, model_ref: str, output_path: Path, cache: dict[str, dict[str, Any]]) -> None:
|
||||
@@ -353,6 +414,7 @@ def render_model(pack_root: Path, model_ref: str, output_path: Path, cache: dict
|
||||
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))
|
||||
@@ -367,7 +429,8 @@ def render_model(pack_root: Path, model_ref: str, output_path: Path, cache: dict
|
||||
canvas.save(output_path)
|
||||
return
|
||||
|
||||
scale = min((canvas_size - 56) / width, (canvas_size - 56) / height)
|
||||
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:
|
||||
@@ -396,51 +459,21 @@ def render_model(pack_root: Path, model_ref: str, output_path: Path, cache: dict
|
||||
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:
|
||||
warped = transform_face(texture, projected, canvas)
|
||||
if warped is None:
|
||||
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(ImageChops.multiply(warped.getchannel("A"), mask))
|
||||
canvas.alpha_composite(warped, (min_x, min_y))
|
||||
canvas.alpha_composite(warped)
|
||||
|
||||
canvas.save(output_path)
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/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"]
|
||||
|
||||
latest_release_entry = next(version for version in manifest["versions"] if version["id"] == latest_release)
|
||||
with urlopen(latest_release_entry["url"], timeout=30) as response:
|
||||
version_metadata = json.load(response)
|
||||
|
||||
java_version = version_metadata.get("javaVersion", {}).get("majorVersion")
|
||||
if not isinstance(java_version, int):
|
||||
raise RuntimeError(f"Latest Minecraft release {latest_release} does not declare a javaVersion.majorVersion")
|
||||
|
||||
print(latest_release)
|
||||
print(f"snapshot={latest_snapshot}")
|
||||
print(f"java_version={java_version}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user