create-mekanism-modular/tools/generate_materials.py

389 lines
16 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
Point --jars at the pack's mods folder. 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. A mod whose jar
is not in the folder is skipped instead, keeping its committed files, so the set
of jars does not have to be complete to regenerate the rest.
Only the material folders are rewritten. The hand-written module JSON that lives
in the same packs is left alone.
"""
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"
VANILLA = "minecraft:"
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.
This mod's own namespace is one of them, served out of the source tree
rather than a jar. Almost every material is made of some other mod's item,
but the arcana core is made of an item added here, and it should be checked
and sampled exactly like the rest rather than being a special case that
escapes both.
"""
def __init__(self, folder: Path, own: Path | None = None):
self.zips = []
self.by_namespace = {}
self.own = own or Path(__file__).parent.parent / "src/main/resources/assets" / NAMESPACE
# Addons ship a handful of files under the namespace of the mod they add
# to - Applied Mekanistics into ae2, half the Create addons into create -
# so a namespace goes to whichever jar has the most of it, which is the
# mod that actually owns it rather than whichever sorted first.
counts = {}
for path in sorted(folder.glob("*.jar")):
zf = zipfile.ZipFile(path)
names = zf.namelist()
self.zips.append((path.name, zf, set(names)))
here = {}
for name in names:
m = re.match(r"assets/([^/]+)/", name)
if m:
here[m.group(1)] = here.get(m.group(1), 0) + 1
for namespace, count in here.items():
if count > counts.get(namespace, 0):
counts[namespace] = count
self.by_namespace[namespace] = (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)
if namespace == NAMESPACE:
return (self.own / "models/item" / f"{path}.json").exists()
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)
if namespace == NAMESPACE:
model = json.loads((self.own / "models/item" / f"{path}.json").read_text())
return model["textures"]["layer0"]
_, _, 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)
if ns == NAMESPACE:
img = Image.open(self.own / "textures" / f"{path}.png").convert("RGBA")
else:
_, 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 = {
# No name here. MIAPI names a material by the lang key
# miapi.material.<namespace>.<group>.<name>, which write_lang emits, and
# its own materials carry no name field at all. The `translation` field
# this used to write is only read when paired with `fake_translation`,
# so on its own it named nothing and the key was showing raw in game.
"groups": spec["groups"],
# Counts for what a module will accept, but is not a heading the
# workbench files the material under. See SCALE_CRAFTING_GROUP.
**({"hidden_groups": spec["hidden_groups"]}
if spec.get("hidden_groups") else {}),
"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):
"""Every item a material names has to exist in the jar that provides it."""
problems = []
for entry in spec["items"]:
if "item" not in entry:
continue
if 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 check_extension_items(ext, jars: Jars):
"""Same check for extensions - they name items too, and used to escape it."""
problems = []
for entry in ext["data"].get("data", {}).get("items", []):
item = entry.get("item", "")
if item.startswith(VANILLA):
continue
if item and not jars.has_item(item):
problems.append(f"{ext['path']}: no such item {item}")
return problems
def materials_path(out: Path, spec) -> Path:
"""Where a material or extension is written."""
path = spec["path"] if "path" in spec else f"{spec['group']}/{spec['name']}"
return out / spec["pack"] / "data" / NAMESPACE / "miapi" / "materials" / f"{path}.json"
def write_lang(lang_path: Path) -> int:
"""Emit the material name for every material into the mod's lang file.
MIAPI resolves a material's name from miapi.material.<namespace>.<group>.
<name> - its own id with the separators turned into dots - and adds the
space before the module's own name itself, so the value here is the bare
name. Written for every material rather than only the ones a jar was found
for: a name costs nothing when the mod is missing, and generating with a
partial set of jars should not strip the rest of the file.
Hand-written keys - the modules, the attributes, the arcana core - are left
exactly as they are. Only this mod's material keys are replaced.
"""
prefix = f"miapi.material.{NAMESPACE}."
existing = json.loads(lang_path.read_text()) if lang_path.exists() else {}
kept = {k: v for k, v in existing.items() if not k.startswith(prefix)}
names = {f"{prefix}{m['group']}.{m['name']}": m["translation"]
for m in sorted(MATERIALS, key=lambda m: (m["group"], m["name"]))}
lang_path.parent.mkdir(parents=True, exist_ok=True)
lang_path.write_text(json.dumps(kept | names, indent=2) + "\n")
return len(names)
def namespaces_of(spec):
"""Every namespace a material or extension needs a jar for.
Vanilla is not one of them - there is no Minecraft jar in the folder, and
the `minecraft` namespace a mod's resource overrides create is not it.
Neither is this mod's own, which comes out of the source tree and is
therefore always there.
"""
entries = (spec["items"] if "items" in spec
else spec["data"].get("data", {}).get("items", []))
names = {entry["item"].split(":", 1)[0] for entry in entries if "item" in entry}
if "palette_from" in spec:
names.add(spec["palette_from"].split(":", 1)[0])
return names - {VANILLA.rstrip(":"), NAMESPACE}
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")
# A mod whose jar is not in the folder is skipped rather than fatal, and its
# committed file is left alone: the pack this addon follows drops and adds
# mods, and losing every material of a mod you no longer have - or being
# unable to regenerate anything without a complete set of jars - is worse
# than regenerating what you can. Anything the jars *can* be checked against
# is still checked, so a typo in a mod you do have still fails the run.
def present(spec):
return namespaces_of(spec) <= set(jars.by_namespace)
materials = [spec for spec in MATERIALS if present(spec)]
extensions = [ext for ext in EXTENSIONS if present(ext)]
skipped = [spec for spec in MATERIALS if not present(spec)]
skipped += [ext for ext in EXTENSIONS if not present(ext)]
if skipped:
missing = sorted({ns for spec in skipped for ns in namespaces_of(spec)
if ns not in jars.by_namespace})
print(f"no jar for {', '.join(missing)} - leaving "
f"{len(skipped)} committed file(s) as they are\n")
problems = []
for spec in materials:
problems += check_items(spec, jars)
for ext in extensions:
problems += check_extension_items(ext, jars)
if problems:
print("aborting, item ids do not check out:", file=sys.stderr)
for p in problems:
print(" " + p, file=sys.stderr)
return 1
# Clear out the material files first. A material that moves between packs
# would otherwise leave a stale copy behind, and two files claiming the same
# material id is a silent mess to debug. Only the generated material folders
# go: the packs also hold hand-written module JSON, and the files of the
# skipped mods above have to survive a run that cannot rebuild them.
keep = {materials_path(args.out, spec) for spec in skipped}
for stale in sorted(args.out.glob(f"*/data/{NAMESPACE}/miapi/materials/**/*.json")):
if stale not in keep:
stale.unlink()
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 = materials_path(args.out, spec)
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']:<16} {NAMESPACE}:{spec['group']}/{spec['name']:<28} {swatch}")
for ext in extensions:
path = materials_path(args.out, ext)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(ext["data"], indent=2) + "\n")
written += 1
print(f" {ext['pack']:<16} extends {ext['data']['parent']}")
lang = jars.own / "lang" / "en_us.json"
named = write_lang(lang)
written += 1
print(f" {'lang':<16} {named} material names in {lang.name}")
print(f"\n{written} files written to {args.out}")
return 0
if __name__ == "__main__":
sys.exit(main())