create-mekanism-modular/tools/mcmodel.py

734 lines
30 KiB
Python

#!/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/<namespace>/...` 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