#!/usr/bin/env python3 """Minecraft and MIAPI geometry, read out of jars and turned into textured quads. This is the part of the armour tooling that has to be *right* rather than merely plausible: everything downstream is just a camera pointed at whatever this module says the shape is. So the two UV conventions Minecraft uses are both implemented here, separately and by name, rather than being averaged into one that is wrong for half the models: *Entity cubes* (`ModelPart.Cube`) are what vanilla armour is. Boxes are given a single texture offset and the six faces are laid out around it in the familiar cross; +y is down, because entity model space is flipped once more at draw time. `vanilla_armour` builds these. *JSON model faces* (`FaceBakery`) are what every MIAPI module is - a Blockbench item model with a `uv` rectangle written out per face. The two conventions disagree about which end of the rectangle is which on four of the six faces, which is exactly the sort of difference that survives a careless eyeball on a symmetrical breastplate and then ruins a pauldron. Everything is in model pixels: +x is the wearer's left, +y is down, -z is forward, and the origin of each part is its `HumanoidModel` pivot. Nothing converts between "item space" and "entity space", because MIAPI does not either - Armory's slot transforms carry an explicit `"rotation": {"z": 180}` to flip a model onto a body part, and that flip is the whole conversion. """ from __future__ import annotations import colorsys import io import json import math import os import zipfile from dataclasses import dataclass import numpy as np from PIL import Image # --------------------------------------------------------------------- resources class Resources: """Jars and loose directories, searched in the order they were given. A resource pack and a mod jar are the same thing to this: somewhere an `assets//...` path can be read from. Directories are listed first when the same file is in both, so the repo's own assets win over the copy inside a built jar. """ def __init__(self, sources): self.dirs, self.zips, self.names = [], [], [] for src in sources: src = os.path.expanduser(str(src)) if os.path.isdir(src): self.dirs.append(src) elif os.path.isfile(src): self.zips.append(zipfile.ZipFile(src)) else: raise FileNotFoundError(src) self.names.append(os.path.basename(src.rstrip('/'))) def read(self, path): """Bytes at an exact `assets/...` or `data/...` path.""" for d in self.dirs: full = os.path.join(d, path) if os.path.isfile(full): return open(full, 'rb').read() for z in self.zips: try: return z.read(path) except KeyError: pass raise KeyError(path) def json(self, path): return json.loads(self.read(path)) # MIAPI writes model references two ways. A module's `model` entry gives a # whole path - "miapi:models/item/foo/[material.texture].json" - while a # model's own `parent` gives the short form the game uses, "item/generated". def model_path(self, ref, variant='default'): ns, _, rest = ref.partition(':') if not _: ns, rest = 'minecraft', ref rest = rest.replace('[material.texture]', variant) if not rest.startswith('models/'): rest = 'models/' + rest if not rest.endswith('.json'): rest += '.json' return f'assets/{ns}/{rest}' def model(self, ref, variant='default'): """A model with its parent chain already folded in.""" model = self.json(self.model_path(ref, variant)) chain = [model] seen = set() while 'parent' in chain[-1]: parent = chain[-1]['parent'] if parent in seen: break seen.add(parent) try: chain.append(self.json(self.model_path(parent, variant))) except KeyError: # item/generated and item/handheld are builtins with no file. break out = {'textures': {}, 'parents': [c.get('parent') for c in chain]} for part in reversed(chain): out['textures'].update(part.get('textures', {})) for key in ('elements', 'texture_size', 'display'): if key in part: out[key] = part[key] return out def paths(self, prefix='', suffix='', loose=False): """Every path in the sources between a prefix and a suffix, sorted. For finding what is there rather than reading what you already knew about. Duplicates collapse the way `read` resolves them - one path, whichever source answers for it first. `loose` limits the answer to files on disk, for a caller that means the working tree rather than whatever a build happened to zip up. """ found = set() for d in self.dirs: root = os.path.join(d, prefix) start = root if os.path.isdir(root) else os.path.dirname(root) for base, _dirs, files in os.walk(start): rel = os.path.relpath(base, d) found.update(f'{rel}/{f}' for f in files if f'{rel}/{f}'.startswith(prefix) and f.endswith(suffix)) for z in ([] if loose else self.zips): found.update(n for n in z.namelist() if n.startswith(prefix) and n.endswith(suffix)) return sorted(found) def image(self, ref): """A texture as RGBA. `ref` is a namespaced texture id, no extension.""" ns, _, rest = ref.partition(':') if not _: ns, rest = 'minecraft', ref if rest.startswith('textures/'): rest = rest[len('textures/'):] data = self.read(f'assets/{ns}/textures/{rest}.png') return Image.open(io.BytesIO(data)).convert('RGBA') # ------------------------------------------------------------------- transforms # # MIAPI's Transform, reproduced including the part of it that loses information. def ident(): return np.eye(4) def translate(t): m = np.eye(4) m[:3, 3] = t return m def scale(s): m = np.eye(4) m[0, 0], m[1, 1], m[2, 2] = s return m def rot(axis, deg): a = math.radians(deg) c, s = math.cos(a), math.sin(a) m = np.eye(4) if axis == 'x': m[1, 1], m[1, 2], m[2, 1], m[2, 2] = c, -s, s, c elif axis == 'y': m[0, 0], m[0, 2], m[2, 0], m[2, 2] = c, s, -s, c else: m[0, 0], m[0, 1], m[1, 0], m[1, 1] = c, -s, s, c return m def transform_matrix(tr): """MIAPI's `Transform.toMatrix`: T * Rx * Ry * Rz * S, translation in pixels.""" tr = tr or {} def g(key, axis, dflt): sub = tr.get(key) return float(sub.get(axis, dflt)) if isinstance(sub, dict) else dflt m = translate([g('translation', a, 0.0) for a in 'xyz']) for axis in 'xyz': m = m @ rot(axis, g('rotation', axis, 0.0)) return m @ scale([g('scale', a, 1.0) for a in 'xyz']) def decompose(m): """MIAPI's `Transform.fromMatrix`: translation, XYZ Euler, per-column scale. Shear in the product is dropped on the floor. That is not a bug here - it is what the mod does, and reproducing it is the only way the preview agrees with the game once a rotation meets a non-uniform scale. """ t = m[:3, 3] cols = [m[:3, c] for c in range(3)] s = [float(np.linalg.norm(c)) or 1.0 for c in cols] r = np.column_stack([cols[c] / s[c] for c in range(3)]) y = math.asin(max(-1.0, min(1.0, float(r[0, 2])))) x = math.atan2(-float(r[1, 2]), float(r[2, 2])) z = math.atan2(-float(r[0, 1]), float(r[0, 0])) return {'translation': {'x': float(t[0]), 'y': float(t[1]), 'z': float(t[2])}, 'rotation': {'x': math.degrees(x), 'y': math.degrees(y), 'z': math.degrees(z)}, 'scale': {'x': s[0], 'y': s[1], 'z': s[2]}} def merge(parent, child, lossy=False): """MIAPI's `Transform.merge`. In 1.21 this is a plain matrix multiply - `Transform` holds a `Matrix4f` and `merge` returns `new Transform(parent.matrix.mul(child.matrix))`, with no round trip through Euler angles. The parent is the transform already accumulated and the child the one being added, so the child applies first. `lossy` reproduces the older behaviour, where the product was decomposed back into translation, Euler angles and scale before being stored - which silently drops the shear that appears the moment a rotation meets a non-uniform scale. Armory's limb slots are exactly that, so the two answers differ there and it is worth being able to see both. """ product = child @ parent return transform_matrix(decompose(product)) if lossy else product # `HumanoidModel.createMesh` pivots - the frame each `origin` resolves against. PIVOTS = { 'head': (0.0, 0.0, 0.0), 'hat': (0.0, 0.0, 0.0), 'body': (0.0, 0.0, 0.0), 'item': (0.0, 0.0, 0.0), 'left_arm': (5.0, 2.0, 0.0), 'right_arm': (-5.0, 2.0, 0.0), 'left_leg': (1.9, 12.0, 0.0), 'right_leg': (-1.9, 12.0, 0.0), } # ----------------------------------------------------------------------- quads @dataclass class Quad: """Four corners, four texture coordinates, and the texture they index. `shape` is which box of its model the face belongs to and `face` is which of the six it is. Neither matters for drawing a textured model, but both are what lets the untextured view and the unwrap template agree on a colour, which is the only thing making one a legend for the other. """ pts: np.ndarray # (4, 3) model pixels uv: np.ndarray # (4, 2) normalised, v measured down from the top texture: str | None # namespaced texture id shape: int = 0 face: str = '' def transformed(self, m, offset=(0.0, 0.0, 0.0)): pts = np.column_stack([self.pts, np.ones(4)]) @ m.T return Quad(pts[:, :3] + np.asarray(offset, float), self.uv, self.texture, self.shape, self.face) # ------------------------------------------------------------------- colouring # # One hue per shape, one shade per face. The hue says which box you are looking # at and the shade says which side of it, so a patch of texture can be found on # the model - and a face drawn on the wrong patch shows up as the wrong shade of # the right colour rather than as something that looks fine. # Saturation and value per face. Front and back are the vivid pair because they # are what you look at most; up and down are pushed to the ends of the value # range so a box read from above or below is never ambiguous. Entity cubes name # their vertical faces differently, and both names are here rather than # translated, so neither convention has to know about the other. FACE_SHADES = { 'north': (0.90, 0.98), 'south': (0.90, 0.60), 'east': (0.55, 0.90), 'west': (0.55, 0.68), 'up': (0.26, 1.00), 'down': (1.00, 0.42), 'top': (0.26, 1.00), 'bottom': (1.00, 0.42), } def shape_hues(count): """The colour wheel split evenly, one slice per shape.""" count = max(1, int(count)) return [i / count for i in range(count)] def face_colour(shape, count, face): """The colour of one face of one shape, as floats in 0..1.""" hues = shape_hues(count) hue = hues[int(shape) % len(hues)] sat, val = FACE_SHADES.get(face, (0.70, 0.80)) return colorsys.hsv_to_rgb(hue, sat, val) # uv index 0..3 is (u1,v1), (u1,v2), (u2,v2), (u2,v1) - `BlockFaceUV.getU/getV`. # Each entry picks x, y and z from (from, to) per corner: 0 is `from`, 1 is `to`. _JSON_FACE = { 'north': ((1, 1, 0), (1, 0, 0), (0, 0, 0), (0, 1, 0)), 'south': ((0, 1, 1), (0, 0, 1), (1, 0, 1), (1, 1, 1)), 'west': ((0, 1, 0), (0, 0, 0), (0, 0, 1), (0, 1, 1)), 'east': ((1, 1, 1), (1, 0, 1), (1, 0, 0), (1, 1, 0)), 'up': ((0, 1, 0), (0, 1, 1), (1, 1, 1), (1, 1, 0)), 'down': ((0, 0, 1), (0, 0, 0), (1, 0, 0), (1, 0, 1)), } # The two axes a face's uv rectangle runs along, for the auto-uv Minecraft # generates when a face omits `uv`: (u axis, v axis) as (index, flipped). _AUTO_UV = { 'north': ((0, True), (1, True)), 'south': ((0, False), (1, True)), 'west': ((2, False), (1, True)), 'east': ((2, True), (1, True)), 'up': ((0, False), (2, False)), 'down': ((0, False), (2, True)), } def _resolve_texture(textures, key): """Follow `#2` -> `#layer0` -> `miapi:item/...` to a real texture id.""" seen = 0 while isinstance(key, str) and key.startswith('#') and seen < 8: key = textures.get(key[1:]) seen += 1 return key def _element_matrix(el): """A JSON element's own `rotation`, about its own origin.""" r = el.get('rotation') if not r: return np.eye(4) axis, angle = r.get('axis', 'y'), float(r.get('angle', 0.0)) origin = np.asarray(r.get('origin', [0, 0, 0]), float) m = translate(origin) @ rot(axis, angle) @ translate(-origin) if r.get('rescale') and angle: f = 1.0 / math.cos(math.radians(abs(angle))) s = [f, f, f] s['xyz'.index(axis)] = 1.0 m = m @ (translate(origin) @ scale(s) @ translate(-origin)) return m def model_quads(model, res=None, y_up=False): """Every drawable face of a parsed JSON model, in the model's own pixels. A model with no `elements` whose ancestry runs through `item/generated` is a flat sprite, and is turned into a two-sided slab centred on the origin - which is what a gemstone is, and why a gem's position is a point rather than a plate. """ textures = model.get('textures', {}) elements = model.get('elements') if not elements: return _sprite_quads(textures, res, y_up) tw, th = (model.get('texture_size') or [16, 16])[:2] quads = [] for index, el in enumerate(elements): lo = np.asarray(el['from'], float) hi = np.asarray(el['to'], float) bounds = np.column_stack([lo, hi]) # (3, 2): axis -> (from, to) m = _element_matrix(el) for name, face in (el.get('faces') or {}).items(): picks = _JSON_FACE.get(name) if picks is None: continue uv = face.get('uv') if uv is None: uv = _auto_uv(name, lo, hi) u1, v1, u2, v2 = (float(x) for x in uv) corners = [(u1, v1), (u1, v2), (u2, v2), (u2, v1)] turns = int(face.get('rotation', 0) // 90) % 4 if turns: corners = corners[turns:] + corners[:turns] pts = np.array([[bounds[a][p[a]] for a in range(3)] for p in picks], float) pts = (np.column_stack([pts, np.ones(4)]) @ m.T)[:, :3] quads.append(Quad(pts, np.array([[u / tw, v / th] for u, v in corners]), _resolve_texture(textures, face.get('texture', '#0')), index, name)) return quads def _auto_uv(name, lo, hi): (ui, uflip), (vi, vflip) = _AUTO_UV[name] a, b = (16 - hi[ui], 16 - lo[ui]) if uflip else (lo[ui], hi[ui]) c, d = (16 - hi[vi], 16 - lo[vi]) if vflip else (lo[vi], hi[vi]) return [a, c, b, d] def _sprite_quads(textures, res=None, y_up=False): """An `item/generated` sprite: a two-sided slab, front and back. Trimmed to the sprite's opaque pixels rather than left at the full 16x16, because that is what the module actually is - a medium gemstone is two pixels of gem in the middle of fourteen of nothing, and a placement judged against the empty square around it is judged against the wrong thing. Centred on the origin, because MIAPI places one by its middle: a gemstone sits where its slot's translation points, not 8 pixels down and left of it. `y_up` builds it in item space instead. Worn armour is drawn with +y down - that is what the `"rotation": {"z": 180}` on every armour slot is for - but an inventory icon is a plain item model, where +y is up and the sprite's top row is at the top. Armory's belt gem settles which is which: its slot names no origin, so it reaches the icon, and `y: 4` puts it on the buckle, which the art draws four pixels above the middle. """ tex = _resolve_texture(textures, '#layer0') or _resolve_texture(textures, '#0') x0, y0, x1, y1 = -8.0, -8.0, 8.0, 8.0 u0, v0, u1, v1 = 0.0, 0.0, 1.0, 1.0 if res is not None and tex: try: image = res.image(tex) except KeyError: image = None box = image.split()[3].getbbox() if image is not None else None if box: w, h = image.size u0, u1 = box[0] / w, box[2] / w v0, v1 = box[1] / h, box[3] / h # The sprite spans 16 model pixels whatever its resolution. x0, x1 = u0 * 16.0 - 8.0, u1 * 16.0 - 8.0 y0, y1 = v0 * 16.0 - 8.0, v1 * 16.0 - 8.0 if y_up: y0, y1 = -y1, -y0 uv_top, uv_bottom = v1, v0 # texture top is now the larger y else: uv_top, uv_bottom = v0, v1 v0, v1 = uv_top, uv_bottom front = np.array([[x0, y0, -0.5], [x0, y1, -0.5], [x1, y1, -0.5], [x1, y0, -0.5]]) back = np.array([[x1, y0, 0.5], [x1, y1, 0.5], [x0, y1, 0.5], [x0, y0, 0.5]]) uv_f = np.array([[u0, v0], [u0, v1], [u1, v1], [u1, v0]]) uv_b = np.array([[u1, v0], [u1, v1], [u0, v1], [u0, v0]]) return [Quad(front, uv_f, tex, 0, 'north'), Quad(back, uv_b, tex, 0, 'south')] # ------------------------------------------------------------- vanilla armour # # `ModelPart.Cube`'s layout, which is the other convention and the reference # this whole tool is checked against. # Per face: the corner picks, and which slice of the texture cross it takes. # Regions are named by the offsets vanilla computes - f9..f14 across, f15..f17 # down - so the table can be read against the source it came from. _CUBE_FACE = { 'top': (((1, 0, 1), (0, 0, 1), (0, 0, 0), (1, 0, 0)), ('f10', 'f15', 'f11', 'f16')), 'bottom': (((1, 1, 0), (0, 1, 0), (0, 1, 1), (1, 1, 1)), ('f11', 'f16', 'f12', 'f15')), 'west': (((0, 0, 0), (0, 0, 1), (0, 1, 1), (0, 1, 0)), ('f9', 'f16', 'f10', 'f17')), 'north': (((1, 0, 0), (0, 0, 0), (0, 1, 0), (1, 1, 0)), ('f10', 'f16', 'f11', 'f17')), 'east': (((1, 0, 1), (1, 0, 0), (1, 1, 0), (1, 1, 1)), ('f11', 'f16', 'f13', 'f17')), 'south': (((0, 0, 1), (1, 0, 1), (1, 1, 1), (0, 1, 1)), ('f13', 'f16', 'f14', 'f17')), } def cube_quads(origin, size, tex_offs, grow=0.0, mirror=False, tex_size=(64, 32), texture=None, shape=0): """One entity cube, the way `ModelPart.Cube` builds it. `origin` and `size` are the arguments of `CubeListBuilder.addBox`, `grow` the `CubeDeformation`. The uv rectangle is measured from the undeformed size, which is why an inflated armour layer still lines up with the skin it is drawn over. """ ox, oy, oz = (float(v) for v in origin) dx, dy, dz = (float(v) for v in size) x1, y1, z1 = ox - grow, oy - grow, oz - grow x2, y2, z2 = ox + dx + grow, oy + dy + grow, oz + dz + grow if mirror: x1, x2 = x2, x1 bounds = ((x1, x2), (y1, y2), (z1, z2)) u, v = float(tex_offs[0]), float(tex_offs[1]) reg = {'f9': u, 'f10': u + dz, 'f11': u + dz + dx, 'f12': u + dz + dx + dx, 'f13': u + dz + dx + dz, 'f14': u + dz + dx + dz + dx, 'f15': v, 'f16': v + dz, 'f17': v + dz + dy} tw, th = tex_size quads = [] for name, (picks, (ua, va, ub, vb)) in _CUBE_FACE.items(): u0, v0, u1_, v1_ = reg[ua], reg[va], reg[ub], reg[vb] # vertex order is [0]->(u1,v0) [1]->(u0,v0) [2]->(u0,v1) [3]->(u1,v1) corners = [(u1_, v0), (u0, v0), (u0, v1_), (u1_, v1_)] pts = np.array([[bounds[a][p[a]] for a in range(3)] for p in picks], float) quads.append(Quad(pts, np.array([[cu / tw, cv / th] for cu, cv in corners]), texture, shape, name)) return quads # `HumanoidModel.createMesh`: box origin, box size, texture offset, mirrored. HUMANOID = { 'head': ((-4, -8, -4), (8, 8, 8), (0, 0), False), 'hat': ((-4, -8, -4), (8, 8, 8), (32, 0), False), 'body': ((-4, 0, -2), (8, 12, 4), (16, 16), False), 'right_arm': ((-3, -2, -2), (4, 12, 4), (40, 16), False), 'left_arm': ((-1, -2, -2), (4, 12, 4), (40, 16), True), 'right_leg': ((-2, 0, -2), (4, 12, 4), (0, 16), False), 'left_leg': ((-2, 0, -2), (4, 12, 4), (0, 16), True), } # Which parts each vanilla armour slot draws, and how far each layer is # inflated - `HumanoidArmorModel`'s inner and outer `CubeDeformation`. ARMOUR_PIECES = { 'helmet': (('head', 'hat'), 1.0, 1), 'chestplate': (('body', 'left_arm', 'right_arm'), 1.0, 1), 'leggings': (('body', 'left_leg', 'right_leg'), 0.5, 2), 'boots': (('left_leg', 'right_leg'), 1.0, 1), } def vanilla_armour(piece, texture, tex_size=(64, 32)): """A vanilla armour piece as world-space quads, ready to draw. The point of this is that its answer is already known. Iron leggings look like iron leggings or the renderer is wrong, and there is no MIAPI, no transform stack and no module JSON in the way of finding out which. """ parts, grow, _ = ARMOUR_PIECES[piece] quads = [] for shape, part in enumerate(parts): origin, size, offs, mirror = HUMANOID[part] # The hat is a second, slightly larger skin on the head; a helmet is # already the inflated head, so it takes vanilla's extra 0.5 as well. g = grow + 0.5 if part == 'hat' else grow pivot = PIVOTS[part] quads += [q.transformed(np.eye(4), pivot) for q in cube_quads(origin, size, offs, g, mirror, tex_size, texture, shape)] return quads def humanoid_body(texture=None, tex_size=(64, 64)): """The wearer, as a reference figure to judge a placement against.""" quads = [] for shape, (part, (origin, size, offs, mirror)) in enumerate(HUMANOID.items()): if part == 'hat': continue quads += [q.transformed(np.eye(4), PIVOTS[part]) for q in cube_quads(origin, size, offs, 0.0, mirror, tex_size, texture, shape)] return quads # ------------------------------------------------------------------- unwrapping # # Box UV, the layout Blockbench calls "Box UV" and Minecraft's entity cubes use: # # [up ][dn ] # [west][north][east][south] # # A box needs (2*dz + 2*dx) across and (dz + dy) down. Faces are written back as # explicit per-face `uv` rectangles, because that is the only thing the JSON # model format can say - `texture_size` scales them, nothing else. def texel_size(lo, hi): """A box's dimensions in whole texels, which is the only size a patch has. Rounded up and never zero: a bezel half a pixel thick still needs a row of texture to be painted on, and a face allotted 0.5 of a texel is a face that cannot be drawn and, on the way there, a rectangle PIL refuses to fill. """ return tuple(max(1, int(math.ceil(round(abs(float(b) - float(a)), 4)))) for a, b in zip(lo, hi)) def net_size(lo, hi): """The width and height one box's net needs, in texture pixels.""" dx, dy, dz = texel_size(lo, hi) return (2 * dz + 2 * dx, dz + dy) def _net_faces(u, v, dx, dy, dz): """Where each face lands in a net whose top-left corner is (u, v).""" return { 'up': (u + dz, v, u + dz + dx, v + dz), 'down': (u + dz + dx, v, u + dz + dx + dx, v + dz), 'west': (u, v + dz, u + dz, v + dz + dy), 'north': (u + dz, v + dz, u + dz + dx, v + dz + dy), 'east': (u + dz + dx, v + dz, u + dz + dx + dz, v + dz + dy), 'south': (u + dz + dx + dz, v + dz, u + dz + dx + dz + dx, v + dz + dy), } def unwrap(elements, texture=None, padding=0, atlas_width=None): """Give every face of every element its own patch of texture. Shelf-packs one net per element, tallest first, and writes the resulting rectangles back into each face's `uv`. Returns the atlas size the caller should put in `texture_size`, and the placement of each element so a template can be drawn to match. Nothing here is clever about sharing texture between identical faces. That is deliberate: a shared patch is a patch you cannot edit on one face without editing the other, and this exists so that faces can be painted. """ nets = [] for i, el in enumerate(elements): lo, hi = el['from'], el['to'] dx, dy, dz = texel_size(lo, hi) w, h = net_size(lo, hi) nets.append({'index': i, 'dx': dx, 'dy': dy, 'dz': dz, 'w': w + 2 * padding, 'h': h + 2 * padding}) widest = max((n['w'] for n in nets), default=1.0) total = sum(n['w'] * n['h'] for n in nets) or 1.0 if atlas_width is None: # Wide enough for the widest net, and roughly square overall. atlas_width = max(widest, math.sqrt(total) * 1.3) atlas_width = 1 << max(0, math.ceil(math.log2(max(1.0, atlas_width)))) # Shelves: tallest first so a short net never strands a tall one. shelf_x, shelf_y, shelf_h = 0.0, 0.0, 0.0 for net in sorted(nets, key=lambda n: -n['h']): if shelf_x + net['w'] > atlas_width and shelf_x > 0: shelf_y += shelf_h shelf_x, shelf_h = 0.0, 0.0 net['u'], net['v'] = shelf_x + padding, shelf_y + padding shelf_x += net['w'] shelf_h = max(shelf_h, net['h']) height = shelf_y + shelf_h atlas_height = 1 << max(0, math.ceil(math.log2(max(1.0, height)))) for net in nets: el = elements[net['index']] rects = _net_faces(net['u'], net['v'], net['dx'], net['dy'], net['dz']) faces = el.setdefault('faces', {}) for name, rect in rects.items(): face = faces.get(name) if face is None: face = {'texture': texture or '#0'} faces[name] = face face['uv'] = [round(c, 4) for c in rect] face.pop('rotation', None) if texture: face['texture'] = texture return (atlas_width, atlas_height), nets def unwrap_template(size, nets, elements, labelled=True): """A painting guide for an unwrap: one coloured, labelled patch per face. The colours are the ones the untextured viewport uses - hue per shape, shade per face - so the model on screen is the legend for this sheet. Find the colour on the model, find the same colour here, and that is the patch to paint. Doubles as the check on the unwrap itself. Render a model with this as its texture and every face should show its own colour, its own letter, and the letter the right way up; anything else is a uv routed to the wrong place. """ from PIL import Image, ImageDraw img = Image.new('RGBA', (int(size[0]), int(size[1])), (0, 0, 0, 0)) d = ImageDraw.Draw(img) count = len(elements) for net in nets: rects = _net_faces(net['u'], net['v'], net['dx'], net['dy'], net['dz']) for name, (u0, v0, u1, v1) in rects.items(): if u1 <= u0 or v1 <= v0: continue tint = tuple(int(round(c * 255)) for c in face_colour(net['index'], count, name)) d.rectangle([u0, v0, u1 - 1, v1 - 1], fill=tint + (255,)) # A darker top edge and left edge, so the patch has an up and a left. shade = tuple(int(c * 0.62) for c in tint) d.line([(u0, v0), (u1 - 1, v0)], fill=shade + (255,)) d.line([(u0, v0), (u0, v1 - 1)], fill=shade + (255,)) if labelled and (u1 - u0) >= 3 and (v1 - v0) >= 5: d.text((u0 + 1, v0 + 1), name[0].upper(), fill=(20, 20, 24, 255)) return img