create-mekanism-modular/tools/generate_materials.py

265 lines
10 KiB
Python

#!/usr/bin/env python3
"""Generate MIAPI material JSON from the source mods' own textures.
Every material's colour palette is sampled out of the item texture shipped by
the mod that adds it, so a material always looks like the thing it is made of.
python3 tools/generate_materials.py --jars ~/mods
The jar directory needs one jar per source mod (Mekanism, Create, Create: The
Air War, Create: Cosmonautics, Ice and Fire). Generated files are committed, so
this only needs re-running when a material definition or a source texture
changes.
Every item id in materials.py is checked against the jars; a typo fails the run
rather than shipping a material that silently refuses to load.
"""
import argparse
import json
import re
import sys
import zipfile
from pathlib import Path
import numpy as np
from PIL import Image
sys.path.insert(0, str(Path(__file__).parent))
from materials import EXTENSIONS, MATERIALS, PACKS # noqa: E402
NAMESPACE = "cmmodular"
PACK_FORMAT = 48 # 1.21.1
# Brightness levels of MIAPI's grayscale templates. Each gets a colour; values
# in between are interpolated by the game.
STOPS = [24, 68, 107, 150, 190, 216, 255]
# Where to sample the source texture for each stop, as a position in the
# texture's own brightness order. Tuned against MIAPI's hand-authored copper
# palette, which these reproduce to within ~2% per channel.
SAMPLE_POINTS = [0.02, 0.02, 0.20, 0.40, 0.66, 0.85, 0.98]
# The lowest stop sits below anything in the texture: item art rarely contains
# the deep shadow a full 3D model needs, and MIAPI's own palettes extrapolate
# it the same way (copper's darkest shade x0.52 is copper's darkest stop).
SHADOW_EXTENSION = 0.52
# Luminance below which a pixel is treated as outline rather than material.
OUTLINE_CUTOFF = 16
LUMA = np.array([0.2126, 0.7152, 0.0722])
class Jars:
"""Open mod jars, indexed by the asset namespaces they contain."""
def __init__(self, folder: Path):
self.zips = []
self.by_namespace = {}
for path in sorted(folder.glob("*.jar")):
zf = zipfile.ZipFile(path)
names = zf.namelist()
self.zips.append((path.name, zf, set(names)))
for name in names:
m = re.match(r"assets/([^/]+)/", name)
if m:
self.by_namespace.setdefault(m.group(1), (path.name, zf, set(names)))
def _for(self, namespace):
hit = self.by_namespace.get(namespace)
if hit is None:
raise LookupError(
f"no jar in --jars provides the '{namespace}' namespace; "
f"found: {', '.join(sorted(self.by_namespace)) or 'nothing'}"
)
return hit
def has_item(self, item_id: str) -> bool:
namespace, path = item_id.split(":", 1)
try:
_, _, names = self._for(namespace)
except LookupError:
return False
return f"assets/{namespace}/models/item/{path}.json" in names
def read_json(self, namespace, path):
_, zf, _ = self._for(namespace)
return json.loads(zf.read(f"assets/{namespace}/{path}"))
def texture_of(self, item_id: str) -> str:
"""Resolve an item id to the texture id its model draws."""
namespace, path = item_id.split(":", 1)
_, _, names = self._for(namespace)
seen = set()
model_ref = f"{namespace}:item/{path}"
while model_ref and model_ref not in seen:
seen.add(model_ref)
ns, mpath = model_ref.split(":", 1) if ":" in model_ref else ("minecraft", model_ref)
key = f"assets/{ns}/models/{mpath}.json"
if key not in names:
break
model = json.loads(self._for(ns)[1].read(key))
textures = model.get("textures", {})
for layer in ("layer0", "all", "texture", "particle"):
if layer in textures and not textures[layer].startswith("#"):
return textures[layer]
model_ref = model.get("parent")
guess = f"{namespace}:item/{path}"
if f"assets/{namespace}/textures/item/{path}.png" in names:
return guess
raise LookupError(f"could not resolve a texture for {item_id}")
def image(self, texture_id: str) -> Image.Image:
ns, path = texture_id.split(":", 1) if ":" in texture_id else ("minecraft", texture_id)
_, zf, _ = self._for(ns)
with zf.open(f"assets/{ns}/textures/{path}.png") as fh:
img = Image.open(fh).convert("RGBA")
img.load()
# Animated textures stack frames vertically; the first frame is enough.
if img.height > img.width:
img = img.crop((0, 0, img.width, img.width))
return img
def palette_from_image(img: Image.Image):
"""Build a MIAPI grayscale_map from a texture.
A stop's key is the brightness of MIAPI's grayscale template; its value is
simply what the material looks like at that point in its own shading. The
colours are therefore taken from the texture as they are, not re-lit to
match the stop number - which is why MIAPI's own netherite tops out at a
murky 847a84 while iron runs to white.
Samples are taken at percentiles of the texture's brightness order, so a
texture with four shades still yields a full seven-stop ramp and any hue
drift between shadow and highlight survives.
"""
px = np.asarray(img, dtype=np.float32).reshape(-1, 4)
opaque = px[px[:, 3] > 128][:, :3]
if len(opaque) == 0:
raise ValueError("texture is fully transparent")
# Item art is usually drawn on a pure black outline. That outline is not a
# shade of the material, and letting it win the dark stops turns the whole
# shadow end colourless, so it is dropped - unless the material really is
# near-black, in which case there is nothing else to sample.
lit = opaque[opaque @ LUMA >= OUTLINE_CUTOFF]
if len(lit) >= len(opaque) * 0.25:
opaque = lit
ranked = opaque[np.argsort(opaque @ LUMA)]
window = max(1, len(ranked) // 12)
samples = []
for point in SAMPLE_POINTS:
centre = int(round(point * (len(ranked) - 1)))
lo = max(0, min(centre - window // 2, len(ranked) - window))
# Median over a window rather than one pixel, so a stray outline pixel
# cannot decide a whole stop.
samples.append(np.median(ranked[lo:lo + window], axis=0))
samples[0] = samples[1] * SHADOW_EXTENSION
return {str(stop): "%02x%02x%02x" % tuple(int(round(c)) for c in sample)
for stop, sample in zip(STOPS, samples)}
def build_material(spec, jars: Jars):
texture = jars.texture_of(spec["palette_from"])
palette = palette_from_image(jars.image(texture))
out = {
"translation": spec["translation"] + " ", # trailing space: "Steel Sword"
"groups": spec["groups"],
"icon": {"type": "item", "item": spec["icon"]},
}
out.update(spec["stats"])
out["color"] = palette["216"].upper()
out["color_palette"] = {
"type": "grayscale_map",
"colors": palette,
"filler": "interpolate",
}
out["textures"] = spec["textures"]
if spec["properties"]:
out["properties"] = spec["properties"]
out["items"] = spec["items"]
return out
def check_items(spec, jars: Jars, any_of_pack: bool):
"""Item ids must exist; tags are allowed to be missing at build time."""
problems = []
for entry in spec["items"]:
if "item" not in entry:
continue
if any_of_pack:
problems.append(
f"{spec['name']}: pack '{spec['pack']}' loads when any one of its "
f"mods is present, so it must use tags, not the item id {entry['item']}"
)
elif not jars.has_item(entry["item"]):
problems.append(f"{spec['name']}: no such item {entry['item']}")
if not jars.has_item(spec["icon"]):
problems.append(f"{spec['name']}: icon item {spec['icon']} does not exist")
return problems
def main():
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--jars", type=Path, required=True,
help="folder holding the source mods' jars")
parser.add_argument("--out", type=Path,
default=Path(__file__).parent.parent / "src/main/resources/packs",
help="where the sub-packs are written")
args = parser.parse_args()
jars = Jars(args.jars)
print(f"namespaces found: {', '.join(sorted(jars.by_namespace))}\n")
problems = []
for spec in MATERIALS:
pack = PACKS[spec["pack"]]
problems += check_items(spec, jars, pack.get("any_of", False))
if problems:
print("aborting, item ids do not check out:", file=sys.stderr)
for p in problems:
print(" " + p, file=sys.stderr)
return 1
written = 0
for pack_id, pack in PACKS.items():
root = args.out / pack_id
mcmeta = {
"pack": {
"description": f"{pack['name']} for Truly Modular",
"pack_format": PACK_FORMAT,
}
}
root.mkdir(parents=True, exist_ok=True)
(root / "pack.mcmeta").write_text(json.dumps(mcmeta, indent=2) + "\n")
for spec in MATERIALS:
material = build_material(spec, jars)
path = (args.out / spec["pack"] / "data" / NAMESPACE / "miapi" / "materials"
/ spec["group"] / f"{spec['name']}.json")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(material, indent=2) + "\n")
written += 1
swatch = " ".join(material["color_palette"]["colors"][str(s)] for s in STOPS)
print(f" {spec['pack']:<13} {NAMESPACE}:{spec['group']}/{spec['name']:<28} {swatch}")
for ext in EXTENSIONS:
path = (args.out / ext["pack"] / "data" / NAMESPACE / "miapi" / "materials"
/ f"{ext['path']}.json")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(ext["data"], indent=2) + "\n")
written += 1
print(f" {ext['pack']:<13} extends {ext['data']['key']}")
print(f"\n{written} files written to {args.out}")
return 0
if __name__ == "__main__":
sys.exit(main())