338 lines
14 KiB
Python
338 lines
14 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."""
|
|
|
|
def __init__(self, folder: Path):
|
|
self.zips = []
|
|
self.by_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)
|
|
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"],
|
|
# 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 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.
|
|
"""
|
|
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(":")}
|
|
|
|
|
|
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']}")
|
|
|
|
print(f"\n{written} files written to {args.out}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|