create-mekanism-modular/tools/preview_armour.py

234 lines
10 KiB
Python

#!/usr/bin/env python3
"""Render where a gem lands on Armory's worn armour, without starting the game.
MIAPI places a module by composing its slot's transform with the ones above it
and then with the model part it names, so the only way to know whether a gem sits
on a pauldron or in mid air is to do that composition and look. This does it: it
reads Armory's own models out of its jar, applies the same chain MIAPI does, and
draws the result from as many angles as asked for.
python3 tools/preview_armour.py <armory.jar> [out.png]
Two things it does not simplify away, because both change the answer:
*Perspective.* An orthographic view flatters a placement - a gem floating a pixel
off a plate looks welded to it head on. Views are taken from several yaws and
pitches with a real camera so the gap shows.
*MIAPI's arithmetic.* `Transform.merge` does not compose matrices; it multiplies
them and then decomposes the product back into translation, Euler angles and
scale. That is lossy the moment a rotation meets a non-uniform scale, which is
exactly what Armory's limb slots are, so the shear is dropped and the gem comes
out turned and stretched differently from what the matrix says. `--exact` renders
the composition MIAPI does not do, for comparison.
Everything is in model pixels, the units the module JSON is written in: +x is the
wearer's left, +y is down, -z is forward, and the origin is the base of the neck.
"""
import argparse, json, math, zipfile
# ---------------------------------------------------------------- linear algebra
def ident():
return [[1.0 if r == c else 0.0 for c in range(4)] for r in range(4)]
def matmul(a, b):
return [[sum(a[r][k] * b[k][c] for k in range(4)) for c in range(4)] for r in range(4)]
def apply(m, p):
v = list(p) + [1.0]
return tuple(sum(m[r][c] * v[c] for c in range(4)) for r in range(3))
def translate(t):
m = ident()
for i in range(3):
m[i][3] = t[i]
return m
def scale(s):
m = ident()
for i in range(3):
m[i][i] = s[i]
return m
def rot(axis, deg):
a = math.radians(deg)
c, s = math.cos(a), math.sin(a)
m = ident()
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."""
g = lambda d, k, dflt: float(d.get(k, dflt)) if isinstance(d, dict) else dflt
t, r, s = tr.get('translation', {}), tr.get('rotation', {}), tr.get('scale', {})
m = translate((g(t, 'x', 0), g(t, 'y', 0), g(t, 'z', 0)))
for axis in 'xyz':
m = matmul(m, rot(axis, g(r, axis, 0)))
return matmul(m, scale((g(s, 'x', 1), g(s, 'y', 1), g(s, 'z', 1))))
# ------------------------------------------------- MIAPI's lossy merge, reproduced
def decompose(m):
"""MIAPI's Transform.fromMatrix: translation, XYZ Euler, per-column scale.
Any shear in the product is silently discarded, which is the whole reason
this function is here rather than a plain matrix multiply.
"""
t = (m[0][3], m[1][3], m[2][3])
cols = [[m[r][c] for r in range(3)] for c in range(3)]
s = [math.sqrt(sum(v * v for v in col)) or 1.0 for col in cols]
r = [[cols[c][row] / s[c] for c in range(3)] for row in range(3)]
# R = Rx*Ry*Rz, so sin(y) is r[0][2] and the other two follow from it.
y = math.asin(max(-1.0, min(1.0, r[0][2])))
x = math.atan2(-r[1][2], r[2][2])
z = math.atan2(-r[0][1], r[0][0])
return {'translation': {'x': t[0], 'y': t[1], 'z': 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, exact=False):
"""MIAPI's Transform.merge: the parent applies first, then the product is
decomposed and rebuilt. `exact` keeps the product instead."""
product = matmul(child, parent)
return product if exact else transform_matrix(decompose(product))
# ------------------------------------------------------------------- the figure
# HumanoidModel part pivots, the frame each origin resolves against. Checked
# against HumanoidModel.createMesh rather than remembered.
PIVOTS = {
'head': (0, 0, 0), 'hat': (0, 0, 0), 'body': (0, 0, 0),
'left_arm': (5, 2, 0), 'right_arm': (-5, 2, 0),
'left_leg': (1.9, 12, 0), 'right_leg': (-1.9, 12, 0),
}
ARMORY = 'assets/miapi/models/item/armor/model'
def boxes_from_model(jar, path):
with zipfile.ZipFile(jar) as z:
data = json.loads(z.read(path))
return [(tuple(e['from']), tuple(e['to'])) for e in data.get('elements', [])]
def place(boxes, chain, pivot):
"""Local boxes through the chain and into the part's frame, as 8 corners."""
out = []
for lo, hi in boxes:
pts = []
for i in range(8):
corner = (hi[0] if i & 1 else lo[0], hi[1] if i & 2 else lo[1], hi[2] if i & 4 else lo[2])
x, y, z = apply(chain, corner)
pts.append((x + pivot[0], y + pivot[1], z + pivot[2]))
out.append(pts)
return out
def gem_box(half_xy, half_z=0.5):
"""A gem sprite as a slab centred on its own origin, the way MIAPI bakes it."""
return [((-half_xy, -half_xy, -half_z), (half_xy, half_xy, half_z))]
# ---------------------------------------------------------------------- drawing
# The 6 faces of a box as indices into the 8 corners produced by place().
FACES = [(0, 1, 3, 2), (4, 5, 7, 6), (0, 1, 5, 4), (2, 3, 7, 6), (0, 2, 6, 4), (1, 3, 7, 5)]
def camera(yaw, pitch, dist, target=(0, 8, 0)):
"""World -> eye, looking at the figure's middle from yaw/pitch degrees out.
Yaw 0 is the wearer's front. Minecraft faces a model down -z, so the camera
starts on that side rather than on +z, which would label the back "front" and
is exactly the sort of quiet inversion this tool exists to avoid.
"""
m = translate((0, 0, -dist))
m = matmul(m, rot('x', pitch))
m = matmul(m, rot('y', yaw + 180))
return matmul(m, translate((-target[0], -target[1], -target[2])))
def render(groups, out_path, views=None, size=300, fov=38.0):
"""One panel per view. `groups` is (fill, outline, label, [boxes])."""
from PIL import Image, ImageDraw
views = views or [('front', 0, 0), ('front-left', 35, 10), ('left', 90, 0),
('above-left', 45, 35), ('back', 180, 0), ('below-left', 40, -30)]
cols = min(3, len(views))
rows = (len(views) + cols - 1) // cols
img = Image.new('RGB', (size * cols, size * rows), (26, 26, 30))
d = ImageDraw.Draw(img, 'RGBA')
f = (size / 2) / math.tan(math.radians(fov) / 2)
for i, (label, yaw, pitch) in enumerate(views):
ox, oy = (i % cols) * size, (i // cols) * size
view = camera(yaw, pitch, 46.0)
def project(p):
x, y, z = apply(view, p)
# +y is down in model space, and the camera looks down -z.
depth = max(0.1, -z)
return (ox + size / 2 + x * f / depth, oy + size / 2 + y * f / depth), depth
polys = []
for fill, outline, _, boxes in groups:
for pts in boxes:
eye = [apply(view, p) for p in pts]
if all(e[2] > -0.1 for e in eye):
continue # entirely behind the camera
for face in FACES:
quad = [project(pts[k])[0] for k in face]
z = sum(project(pts[k])[1] for k in face) / 4
polys.append((z, quad, fill, outline))
for _, quad, fill, outline in sorted(polys, key=lambda t: -t[0]):
d.polygon(quad, fill=fill, outline=outline)
d.text((ox + 8, oy + 6), f"{label} yaw {yaw} pitch {pitch}", fill=(190, 190, 200))
img.save(out_path)
return out_path
# ------------------------------------------------------------------- the scene
def scene(jar, repo, exact=False):
"""Armory's heavy pieces plus a gem in each of this mod's four sockets."""
def socket(name):
with open(f'{repo}/src/main/resources/packs/armory/data/cmmodular/miapi'
f'/modules/armor/socket/{name}.json') as fh:
return json.load(fh)['data']['replace']['slots']['gem']['transform']
with zipfile.ZipFile(jar) as z:
chest = json.loads(z.read('data/tm_armory/miapi/modules/armor/chestplate.json'))['slots']
pants = json.loads(z.read('data/tm_armory/miapi/modules/armor/pants.json'))['slots']
plate, gems = [], []
for part, chain_json, model, sock in (
('left_arm', chest['arm_left'], 'arm_left/heavy', 'arm_left'),
('right_arm', chest['arm_right'], 'arm_right/heavy', 'arm_right'),
('left_leg', pants['leg_left'], 'leg_left/heavy', 'leg_left'),
('right_leg', pants['leg_right'], 'leg_right/heavy', 'leg_right'),
):
limb = transform_matrix(chain_json['transform'])
plate += place(boxes_from_model(jar, f'{ARMORY}/{model}/default.json'), limb, PIVOTS[part])
gems += place(gem_box(1.0), merge(transform_matrix(socket(sock)), limb, exact), PIVOTS[part])
# Armory's own socketed chest, as a control: its gem is known to sit on the
# sternum, so if this lands there the chain above is being modelled right.
body = transform_matrix(chest['chest_front']['transform'])
plate += place(boxes_from_model(jar, f'{ARMORY}/chest_front/socket/default.json'), body, PIVOTS['body'])
gems += place(gem_box(1.0), merge(transform_matrix(
{'translation': {'y': -1}, 'scale': {'x': 1.1, 'y': 1.1, 'z': 1.1}}), body, exact), PIVOTS['body'])
return plate, gems
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('jar')
ap.add_argument('out', nargs='?', default='armour-preview.png')
ap.add_argument('--exact', action='store_true',
help="compose matrices properly instead of reproducing MIAPI's lossy merge")
ap.add_argument('--repo', default='.')
args = ap.parse_args()
plate, gems = scene(args.jar, args.repo, args.exact)
print(render([((78, 62, 104, 255), (120, 104, 150, 255), 'plate', plate),
((86, 216, 122, 255), (235, 255, 240, 255), 'gem', gems)], args.out))
if __name__ == '__main__':
main()