"""Item icons rendered from the jars' item models into build/field-guide/icons/. Flat items composite their layer textures; block items get a small isometric cube from their block model's faces; cross-shaped plants use their texture. The PNGs are third-party assets (Cobblemon's assets are not MPL-licensed), so they only ever go to build/ and are never committed. """ import io from PIL import Image, ImageEnhance from .datasource import split_id S = 16 FACES = { # texture keys tried for each visible face of a cube "top": ("up", "top", "end", "all", "texture", "side", "particle"), "left": ("south", "front", "side", "all", "texture", "north", "particle"), "right": ("east", "side", "all", "texture", "west", "particle"), } FLAT_PARENTS = ("item/generated", "item/handheld", "item/handheld_rod", "builtin/generated") CROSS_PARENTS = ("block/cross", "block/tinted_cross", "block/flower_pot_cross", "block/crop") FOLIAGE = (72, 181, 24) class Icons: def __init__(self, stack, out_dir): self.stack, self.out = stack, out_dir self.models = {} self.missing = [] def model(self, mid): """Merged model: (parent chain ids, textures, elements).""" if mid in self.models: return self.models[mid] ns, path = split_id(mid) d = self.stack.json(f"assets/{ns}/models/{path}.json") or {} chain, textures, elements = [f"{ns}:{path}"], {}, d.get("elements") parent = d.get("parent") if parent and len(chain) < 20: pchain, ptex, pel = self.model(parent if ":" in parent else "minecraft:" + parent) chain += pchain textures.update(ptex) elements = elements or pel textures.update(d.get("textures", {})) self.models[mid] = (chain, textures, elements) return self.models[mid] def texture(self, ref, textures, depth=0): while isinstance(ref, str) and ref.startswith("#") and depth < 10: ref, depth = textures.get(ref[1:]), depth + 1 if not isinstance(ref, str): return None ns, path = split_id(ref) raw = self.stack.read(f"assets/{ns}/textures/{path}.png") if raw is None: return None img = Image.open(io.BytesIO(raw)).convert("RGBA") if img.height > img.width: # animated strip: first frame img = img.crop((0, 0, img.width, img.width)) return img.resize((S, S), Image.NEAREST) if img.size != (S, S) else img def render(self, iid): ns, path = split_id(iid) chain, tex, elements = self.model(f"{ns}:item/{path}") if len(chain) == 1 and not tex: chain, tex, elements = self.model(f"{ns}:block/{path}") if len(chain) == 1 and not tex: # no model at all: KubeJS items get item/generated with their own texture chain, tex = [f"{ns}:item/{path}", "minecraft:item/generated"], {"layer0": f"{ns}:item/{path}"} names = [c.split(":", 1)[1] for c in chain] if any(n in FLAT_PARENTS for n in names) or any(k.startswith("layer") for k in tex): layers = sorted((k for k in tex if k.startswith("layer")), key=lambda k: int(k[5:] or 0)) img = None for k in layers: t = self.texture(tex[k], tex) if t is not None: img = t if img is None else Image.alpha_composite(img, t) return img.resize((32, 32), Image.NEAREST) if img else None if any(n in CROSS_PARENTS for n in names): t = self.texture(tex.get("cross") or tex.get("crop") or tex.get("plant") or tex.get("particle"), tex) return t.resize((32, 32), Image.NEAREST) if t else None faces = {} for face, keys in FACES.items(): for k in keys: if k in tex: faces[face] = self.texture(tex[k], tex) if faces[face] is not None: break if not any(v is not None for v in faces.values()): return None tinted = any(n in ("block/leaves",) for n in names) or path.endswith("leaves") return iso_cube(faces, tinted) def write_all(self, ids): out = {} for iid in sorted(ids): try: img = self.render(iid) except (OSError, ValueError) as e: self.missing.append(f"{iid}: {e}") img = None if img is None: self.missing.append(iid) continue ns, path = split_id(iid) rel = f"icons/{ns}/{path}.png" dst = self.out / rel dst.parent.mkdir(parents=True, exist_ok=True) img.save(dst) out[iid] = rel return out def _tint(img, rgb): r, g, b, a = img.split() grey = img.convert("L") return Image.merge("RGBA", [grey.point(lambda v, c=c: v * c // 255) for c in rgb] + [a]) def iso_cube(faces, tinted=False): """32x32 isometric block icon from top/left/right 16x16 face textures.""" canvas = Image.new("RGBA", (32, 32), (0, 0, 0, 0)) geometry = { # origin, u axis, v axis (output pixels per texture pixel) "top": ((16, 0), (1, 0.5), (-1, 0.5)), "left": ((0, 8), (1, 0.5), (0, 1)), "right": ((16, 16), (1, -0.5), (0, 1)), } shade = {"top": 1.0, "left": 0.8, "right": 0.62} for face in ("top", "left", "right"): tex = faces.get(face) or faces.get("top") or next(v for v in faces.values() if v is not None) if tinted: tex = _tint(tex, FOLIAGE) (ox, oy), (ux, uy), (vx, vy) = geometry[face] det = ux * vy - uy * vx # inverse affine: texture coords from output coords a, b = vy / det, -vx / det d, e = -uy / det, ux / det c, f = -(a * ox + b * oy), -(d * ox + e * oy) layer = tex.transform((32, 32), Image.AFFINE, (a, b, c, d, e, f), resample=Image.NEAREST, fillcolor=(0, 0, 0, 0)) if shade[face] != 1.0: rgb = ImageEnhance.Brightness(layer.convert("RGB")).enhance(shade[face]) layer = Image.merge("RGBA", (*rgb.split(), layer.split()[3])) canvas = Image.alpha_composite(canvas, layer) return canvas