#!/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=True): """MIAPI's `Transform.merge`, as MIAPI 2.3.8 actually has it. Read out of the jar rather than assumed, because the assumption was wrong and cost a set of gem placements. `Transform.merge(a, b)` builds both matrices, computes `b.mul(a)` - so the accumulated transform applies first and the new one after it, in the frame the accumulation left off in - and then hands the product to `fromMatrix`. That last step is the important one: it decomposes back into translation, Euler angles and per-axis scale, so every merge drops whatever shear the product had. Armory's limb slots are a rotation with a non-uniform scale, which is exactly where shear appears, so this is not a corner case here - it is the case. `TransformMap.add` calls it as `merge(what is there, what is being added)`, which is what makes a slot transform act *after* the plate's rather than inside it. A number written as an offset in the model's own space lands somewhere else entirely; `packs/armory/.../socket/*.json` are solved in the frame this describes. `lossy=False` gives the exact product instead, for seeing what the shear would have done had MIAPI kept it. """ product = child @ parent return transform_matrix(decompose(product)) if lossy else product # `HumanoidModel.createMesh` pivots - the frame each `origin` resolves against. # The only origins MIAPI will draw on a body. Verified against # `ArmorModelManager$ModelPartProvider.modelParts` in miapi 2.3.8: the renderer # walks this fixed list and matches models to each name. A transform naming # anything else - or naming nothing at all - matches no part and is never drawn # on the wearer; it is the inventory model. `Transform.repair` leaves a missing # origin missing, it does not substitute one. BODY_PARTS = ('head', 'hat', 'body', 'left_arm', 'right_arm', 'left_leg', 'right_leg') 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, arranged so that no two faces of a box read as # the same colour and, above all, that opposite faces do not: a face is told # from its opposite by both axes at once, never by brightness alone, because # the pair you most need to tell apart is the one you can only ever see one of # at a time. Every face sits at its own rung of the value ladder as well, so a # box seen against a bright background is still read the same way. # # up pale and brightest down vivid and darkest # north vivid and bright south washed and dim # west palest and mid-bright east vivid and mid-dark # # 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 = { 'up': (0.45, 1.00), 'down': (1.00, 0.24), 'north': (1.00, 0.86), 'south': (0.35, 0.44), 'west': (0.30, 0.78), 'east': (0.90, 0.50), 'top': (0.45, 1.00), 'bottom': (1.00, 0.24), } # How far the boxes of one object spread from its hue. Enough to tell one box # from the next, far less than the gap between objects, so a box is never # mistaken for something else on screen. BOX_BAND = 0.10 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 object_hue(index): """A hue of its own for each object in a scene, however many there are. Successive golden-ratio turns around the wheel: any two are far apart, the third is not squeezed between the first two, and it never runs out. What it is not is stable against reordering - an object keeps its colour only as long as it keeps its index, so index by something that does not move. """ return (int(index) * 0.6180339887498949) % 1.0 def face_colour(shape, count, face, hue=None): """The colour of one face of one shape, as floats in 0..1. Left alone, the hue says which box of one model this is - the wheel split per shape, which is what an unwrap template is drawn against. Given a `hue`, it says which *object* instead, and the boxes of that object sit in a narrow band around it. Either way the saturation and value say which of the six faces it is. """ count = max(1, int(count)) if hue is None: hue = shape_hues(count)[int(shape) % count] else: hue = (float(hue) + BOX_BAND * (int(shape) % count) / count) % 1.0 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 when it is drawing one model - 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. A scene of several models hands each one a hue of its own instead, since there the question is which model a face belongs to; the shades are the same either way, so the sheet is still read by them. 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