Tools that draw the armour instead of guessing at it

Five of them, sharing one geometry layer. mcmodel.py reads Minecraft and MIAPI
models out of jars and implements both of the game's UV conventions separately -
entity cubes for vanilla armour, JSON model faces for every module - because
they disagree on four of six faces, which survives an eyeball on a symmetric
breastplate and then ruins a pauldron.

armour_editor.py places modules and writes the numbers back into the JSON they
came from. It replaces what preview_armour.py did and fixes what that got
wrong: sorting whole faces by average depth is not a depth buffer, and a gem
half-sunk into a plate was sorting arbitrarily - exactly the case the tool
existed to judge. VTK rasterises against a real z-buffer instead.

armour_gui.py draws new geometry onto a body part, with the origin settable,
since that is what decides which part a model is drawn under and therefore what
it moves with. It unwraps as it goes and writes a painting template beside the
model. The untextured view colours a hue per shape and a shade per face, and
the template uses the same key, so one is the legend for the other.

material_editor.py edits materials.py by rewriting one argument's source span
at a time, so ingots(...) stays a call, the comments stay put, and a two-number
change is a two-number diff. The generated JSON is downstream and would be
overwritten, so it is not what gets edited.

guiplatform.py prefers Wayland and falls back to X11. VTK's wheels have no
Wayland backend, but handing Qt the GL context works on both; where that fails
the process dies on an X BadAccess that nothing in-process can catch, so the
question is asked in a subprocess that renders the same features the tools use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-08-24 15:12:31 +02:00
parent 532c3a0da8
commit 5e1b62fd9b
6 changed files with 2655 additions and 0 deletions

3
.gitignore vendored
View File

@ -5,3 +5,6 @@ runs/
repo/
*.class
__pycache__/
# Python virtualenv for the tools in tools/
tools/.venv/

812
tools/armour_editor.py Normal file
View File

@ -0,0 +1,812 @@
#!/usr/bin/env python3
"""Place Truly Modular modules on armour by looking at them and moving them.
Where a module ends up on a worn piece is the product of a model, a slot
transform, the transforms above it and a body part's pivot, and no amount of
reading those numbers tells you whether a gem is on a pauldron or three pixels
inside it. This composes the chain, draws it with a real depth buffer and a
real camera, and lets the placement be pushed around with the arrow keys until
it looks right - then writes the numbers back into the module JSON they came
from.
tools/armour_editor.py vanilla # the reference render
tools/armour_editor.py sockets --jar <armory.jar> # the editable scene
tools/armour_editor.py sockets --jar <j> --shot out.png
Two things it does not simplify away, because both change the answer:
*Depth.* Faces are rasterised by VTK against a z-buffer rather than sorted and
painted, so a gem half-sunk into a plate reads as half-sunk rather than as
whichever of the two happened to sort in front.
*MIAPI's arithmetic.* Transforms are kept one per `origin` and merged only
within an entry, which is why a gem whose slot says `body` never picks up the
`left_arm` transform above it. Merging itself is an exact matrix multiply in
1.21; `--lossy-merge` reproduces the Euler round trip older versions did, which
drops shear wherever a rotation meets a non-uniform scale.
Everything is in model pixels: +x is the wearer's left, +y is down, -z is
forward, and the origin is the base of the neck.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import re
import sys
from dataclasses import dataclass, field
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import mcmodel as mc
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# A fallback colour per placement, for the things that are not a model with
# boxes to hue - the wearer, a selection, a plate borrowed for context. Anything
# with shapes of its own is coloured by `mcmodel.face_colour` instead.
PALETTE = [
(0.85, 0.42, 0.40), (0.45, 0.72, 0.90), (0.55, 0.80, 0.45), (0.92, 0.74, 0.36),
(0.72, 0.55, 0.88), (0.40, 0.82, 0.76), (0.90, 0.58, 0.78), (0.65, 0.65, 0.70),
]
# ------------------------------------------------------------------ placements
@dataclass
class Placement:
"""One drawn thing: a model, the transform that puts it somewhere, and -
if it is ours to change - the file that transform was read out of."""
name: str
quads: list # model-space, from mcmodel.model_quads
transform: dict # the editable MIAPI transform
outer: np.ndarray | None = None # slot chain applied after this transform
chain_origin: str | None = None # the origin that chain is filed under
source: tuple | None = None # (path, [json keys]) to write back to
offset: tuple = (0.0, 0.0, 0.0) # laid-out position, for scenes of several items
colour: tuple = (0.7, 0.7, 0.75)
original: dict = field(default_factory=dict)
def __post_init__(self):
self.original = json.loads(json.dumps(self.transform))
@property
def editable(self):
return self.source is not None
@property
def origin(self):
"""The body part this module is drawn under. Absent means the torso."""
return self.transform.get('origin', 'body')
@property
def auto_chain(self):
"""Whether the slot above this one composes with it.
MIAPI keeps one transform per origin and merges a child into the entry
its own origin names. So a transform composes with the slot above it
only when the two agree about which body part they are for: Armory's
chest gem says `body` and sits in a `body` slot, so the two multiply
and the gem lands on the sternum. A gem cut into a limb says `body` too
- the gemstone model declares no origin of its own - but the slot above
it says `left_arm`, so they are filed apart and the limb's transform
never reaches the gem. That is not a quirk to correct for; it is the
thing that puts these gems on the torso, and the reason a placement has
to be aimed in body space.
"""
return self.outer is not None and self.chain_origin == self.origin
def matrix(self, force_chain, lossy):
own = mc.transform_matrix(self.transform)
if self.outer is not None and (force_chain or self.auto_chain):
return mc.merge(own, self.outer, lossy)
return own
def pivot(self, force_chain):
part = self.chain_origin if (force_chain and self.outer is not None) else self.origin
base = mc.PIVOTS.get(part, (0.0, 0.0, 0.0))
return tuple(b + o for b, o in zip(base, self.offset))
def world_quads(self, force_chain, lossy):
m, off = self.matrix(force_chain, lossy), self.pivot(force_chain)
return [q.transformed(m, off) for q in self.quads]
def nudge(self, axis, amount):
t = self.transform.setdefault('translation', {})
t[axis] = round(float(t.get(axis, 0.0)) + amount, 4)
def turn(self, axis, degrees):
r = self.transform.setdefault('rotation', {})
r[axis] = round((float(r.get(axis, 0.0)) + degrees) % 360.0, 4)
def resize(self, factor):
s = self.transform.setdefault('scale', {})
for axis in 'xyz':
s[axis] = round(float(s.get(axis, 1.0)) * factor, 4)
def revert(self):
self.transform.clear()
self.transform.update(json.loads(json.dumps(self.original)))
def summary(self):
def trio(key, dflt):
sub = self.transform.get(key) or {}
return ' '.join(f'{a}{float(sub.get(a, dflt)):+.3g}' for a in 'xyz')
return (f"t[{trio('translation', 0.0)}] "
f"r[{trio('rotation', 0.0)}] "
f"s[{trio('scale', 1.0)}] "
f"origin={self.transform.get('origin', '-')}")
# ---------------------------------------------------------------------- scenes
def _armour_layer_texture(res, ref):
"""A texture id that exists, or None so the part draws untextured."""
try:
res.image(ref)
return ref
except KeyError:
return None
def scene_vanilla(res, args):
"""Vanilla armour on the vanilla humanoid - the render whose answer is known.
Nothing here goes through MIAPI: the boxes are `HumanoidModel.createMesh`
and the uv layout is `ModelPart.Cube`. If this comes out looking like a
suit of armour then the camera, the depth buffer, the uv convention and the
part pivots are all right, and anything wrong further on is MIAPI's chain
rather than the renderer.
"""
layer1 = _armour_layer_texture(res, args.layer1)
layer2 = _armour_layer_texture(res, args.layer2)
if layer1 is None:
raise SystemExit(f'no armour layer texture at {args.layer1!r} - pass --layer1')
out = []
for i, (piece, tex) in enumerate((('leggings', layer2 or layer1),
('boots', layer1),
('chestplate', layer1),
('helmet', layer1))):
quads = mc.vanilla_armour(piece, tex)
out.append(Placement(f'vanilla {piece}', quads, {}, colour=PALETTE[i]))
return out
def _armory(res, args):
"""Armory's own worn pieces, each under the slot transform that places it."""
chest = json.loads(res.read('data/tm_armory/miapi/modules/armor/chestplate.json'))['slots']
pants = json.loads(res.read('data/tm_armory/miapi/modules/armor/pants.json'))['slots']
return chest, pants
PLATES = (
('left_arm', 'arm_left', 'arm_left/heavy'),
('right_arm', 'arm_right', 'arm_right/heavy'),
('left_leg', 'leg_left', 'leg_left/heavy'),
('right_leg', 'leg_right', 'leg_right/heavy'),
)
SOCKETS = 'src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/socket'
LIMB_GEMS = 'src/main/resources/packs/armory/data/cmmodular/miapi/modules/gem/limb'
def scene_sockets(res, args):
"""Armory's heavy limbs, plus a gem in each of this mod's four sockets.
The gems are the editable ones. Armory's own socketed chestplate is drawn
alongside as a control: its gem is known to sit on the sternum, so if that
one lands there the chain is being modelled right and a gem that lands
somewhere daft is a number to change rather than a bug to chase.
"""
chest, pants = _armory(res, args)
slots = {'arm_left': chest['arm_left'], 'arm_right': chest['arm_right'],
'leg_left': pants['leg_left'], 'leg_right': pants['leg_right']}
gem_model = mc.model_quads(res.model(f'miapi:models/item/armor/gems/{args.gem}/'
'[material.texture].json', args.variant), res)
out = []
for i, (part, slot, model) in enumerate(PLATES):
limb_tr = slots[slot]['transform']
limb = mc.transform_matrix(limb_tr)
plate = mc.model_quads(res.model(f'miapi:models/item/armor/model/{model}/'
'[material.texture].json', args.variant), res)
out.append(Placement(f'{slot} plate', plate, limb_tr, colour=(0.62, 0.62, 0.68)))
# Our own socket, drawn under the limb so it moves with it.
try:
bezel = mc.model_quads(res.model(
f'cmmodular:models/item/armor/model/{slot}/socket/'
'[material.texture].json', args.variant), res)
out.append(Placement(f'{slot} socket', bezel, limb_tr,
colour=(0.95, 0.72, 0.30)))
except KeyError:
pass
path = os.path.join(REPO, SOCKETS, f'{slot}.json')
with open(path) as fh:
module = json.load(fh)
gem_tr = module['data']['replace']['slots']['gem']['transform']
out.append(Placement(
f'{slot} gem', gem_model, gem_tr, outer=limb, chain_origin=part,
source=(path, ['data', 'replace', 'slots', 'gem', 'transform']),
colour=PALETTE[i]))
# Armory's socketed front chest, unedited, as the control.
body_tr = chest['chest_front']['transform']
out.append(Placement(
'armory chest (control)',
mc.model_quads(res.model('miapi:models/item/armor/model/chest_front/socket/'
'[material.texture].json', args.variant), res),
body_tr, colour=(0.55, 0.55, 0.62)))
out.append(Placement(
'armory chest gem (control)', gem_model,
{'translation': {'y': -1}, 'scale': {'x': 1.1, 'y': 1.1, 'z': 1.1},
'origin': 'body'},
outer=mc.transform_matrix(body_tr), chain_origin='body',
colour=(0.95, 0.95, 0.55)))
return out
def scene_armory(res, args):
"""The whole heavy set worn, as a check on the JSON-model path."""
chest, pants = _armory(res, args)
helmet = json.loads(res.read('data/tm_armory/miapi/modules/armor/helmet.json'))['slots']
pieces = [
('body', chest['chest_front'], 'chest_front/heavy'),
('body', chest['chest_back'], 'chest_back/heavy'),
('left_arm', chest['arm_left'], 'arm_left/heavy'),
('right_arm', chest['arm_right'], 'arm_right/heavy'),
('body', pants['belt'], 'belt/heavy'),
('left_leg', pants['leg_left'], 'leg_left/heavy'),
('right_leg', pants['leg_right'], 'leg_right/heavy'),
('head', helmet['hat'], 'helmet/heavy'),
]
out = []
for i, (part, slot, model) in enumerate(pieces):
quads = mc.model_quads(res.model(f'miapi:models/item/armor/model/{model}/'
'[material.texture].json', args.variant), res)
out.append(Placement(model, quads, slot['transform'],
colour=PALETTE[i % len(PALETTE)]))
return out
def scene_icons(res, args):
"""The four inventory icons, side by side, each with its gem on it.
This is the other half of a socket and the half that is actually visible:
Armory's gemstone model declares no origin, and MIAPI only draws an
origin-less model in the `item` pass, so the icon is the one place a gem in
these slots is ever drawn. It is also read from a different entry of the
transform stack - the one a transform lands in when it names no origin - so
a slot transform aimed at the body reaches the worn piece and nothing else.
"""
out = []
for i, (part, slot, _model) in enumerate(PLATES):
# The icon offset lives on the gem module's origin-less model entry.
# The slot transform names a limb now, so it is filed under that limb
# and cannot reach the icon, which is drawn from the entry with no
# origin at all.
path = os.path.join(REPO, LIMB_GEMS, f'gem_{slot}.json')
with open(path) as fh:
module = json.load(fh)
idx = next(j for j, m in enumerate(module['model'])
if 'origin' not in m.get('transform', {}))
gem_tr = module['model'][idx]['transform']
spot = (i * 20.0 - 30.0, 0.0, 0.0)
icon = mc.model_quads(res.model(
f'miapi:models/item/armor/gui/heavy/{slot}/base/[material.texture].json',
args.variant), res, y_up=True)
out.append(Placement(f'{slot} icon', icon, {}, offset=spot,
colour=(0.62, 0.62, 0.68)))
gem = mc.model_quads(res.model(
f'miapi:models/item/armor/gems/{args.gem}/[material.texture].json',
args.variant), res, y_up=True)
out.append(Placement(
f'{slot} gem (icon)', gem, gem_tr, offset=spot,
source=(path, ['model', idx, 'transform']),
colour=PALETTE[i]))
return out
SCENES = {'vanilla': scene_vanilla, 'sockets': scene_sockets,
'armory': scene_armory, 'icons': scene_icons}
# Scenes drawn in item space rather than on a body: +y is up and the camera
# has to agree, or every icon renders upside down and every offset written
# from looking at it is inverted.
ICON_SCENES = {'icons'}
# --------------------------------------------------------------------- meshing
def _texture_is_blank(image, uv):
"""True when a face's uv rectangle lands entirely on transparent pixels.
Armour layer textures are mostly empty, and a model that names a face it
never drew leaves a fully transparent quad which VTK will happily depth-test
against and punch a hole with. Dropping them is cheaper than sorting them.
"""
w, h = image.size
u0, v0 = uv.min(axis=0)
u1, v1 = uv.max(axis=0)
box = (max(0, int(math.floor(u0 * w))), max(0, int(math.floor(v0 * h))),
min(w, max(1, int(math.ceil(u1 * w)))), min(h, max(1, int(math.ceil(v1 * h)))))
if box[2] <= box[0] or box[3] <= box[1]:
return True
alpha = image.crop(box).split()[3]
return alpha.getextrema()[1] < 128
def build_meshes(res, quads, shape_count=None, by_colour=False):
"""Quads -> [(PolyData, texture id, colour)], grouped so one draw is one look.
Textured, that means one mesh per texture. Untextured, `by_colour` splits
further so each mesh is a single flat colour - hue per shape, shade per
face. That is a few more actors than handing VTK a per-cell colour array
would be, and it is what this does because the array route draws nothing at
all in these wheels: `rgb=True` over cell data comes back empty whether or
not the mesh carries texture coordinates, while a plain `color=` works.
`shape_count` is how many boxes the model has. Pass it when you know it,
because a box whose every face was culled would otherwise shift the hues
off the ones the unwrap template was drawn with.
"""
import pyvista as pv
if shape_count is None:
shape_count = max((q.shape for q in quads), default=0) + 1
groups = {}
for q in quads:
image = None
if q.texture:
try:
image = res.image(q.texture)
except KeyError:
image = None
if image is not None and _texture_is_blank(image, q.uv):
continue
tex = q.texture if image is not None else None
colour = mc.face_colour(q.shape, shape_count, q.face) if by_colour else None
groups.setdefault((tex, colour), []).append(q)
out = []
for (tex, colour), group in groups.items():
pts = np.concatenate([q.pts for q in group])
uvs = np.concatenate([q.uv for q in group])
faces = np.hstack([[4, *range(4 * i, 4 * i + 4)] for i in range(len(group))])
mesh = pv.PolyData(pts, faces)
# VTK samples textures from the bottom up; model uv runs from the top.
mesh.active_texture_coordinates = np.column_stack([uvs[:, 0], 1.0 - uvs[:, 1]])
out.append((mesh, tex, colour))
return out
def make_texture(res, ref):
"""A Minecraft texture as VTK understands it: nearest sampled, cutout alpha.
The alpha is snapped to 0 or 255 first. Minecraft draws armour as a cutout
- a texel is either there or it is not - and a stray 247 left behind by
whoever drew the sheet is enough to tip VTK into blending the whole actor,
which makes a solid plate you can see the far side of.
"""
import pyvista as pv
rgba = np.asarray(res.image(ref)).copy()
rgba[..., 3] = np.where(rgba[..., 3] >= 128, 255, 0)
tex = pv.Texture(rgba)
tex.SetInterpolate(False) # Minecraft art is pixels, not a photograph
tex.SetRepeat(False)
tex.SetEdgeClamp(True)
return tex
# -------------------------------------------------------------------- viewport
VIEWS = [('front', 0, 0), ('front-left', 35, 12), ('left', 90, 0),
('above-left', 45, 35), ('back', 180, 0), ('below-left', 40, -30)]
HELP = """\
drag orbit scroll zoom
left/right move x-/x+ up/down move y-/y+
pgup/pgdn move z-/z+ shift x5 step
[ ] step size 1..6 rotate x/y/z -/+
- = scale tab \\ next/previous module
t textures f slot chain on/off
b body figure a axes
u revert s save to json
c print values p screenshot q quit"""
class Viewport:
"""A window on the scene, and the keys that move things around in it."""
def __init__(self, res, placements, args):
import pyvista as pv
self.res, self.placements, self.args = res, placements, args
self.force_chain = args.chain
self.textured = not args.no_textures
self.lossy = args.lossy_merge
self.step = args.step
self.turn_step = 7.5
self.show_body = args.body
self.dirty = set()
editable = [i for i, p in enumerate(placements) if p.editable]
self.selection = editable[0] if editable else 0
self.pl = pv.Plotter(off_screen=args.shot is not None,
window_size=tuple(args.size), lighting='none')
self.pl.set_background(args.background)
self.pl.enable_depth_peeling(number_of_peels=8, occlusion_ratio=0.0)
self._add_lights()
self.actors = {}
self.rebuild()
if args.shot is None:
self._bind_keys()
# Vanilla lights an entity in the inventory with two directional sources and
# a good deal of ambient; anything more dramatic makes a flat plate look
# curved, which is the opposite of useful here.
def _add_lights(self):
import pyvista as pv
for direction, intensity in (((0.2, -1.0, -0.7), 0.62), ((-0.2, -1.0, 0.7), 0.44)):
v = np.asarray(direction, float)
light = pv.Light(position=tuple(-v * 100), focal_point=(0, 0, 0),
light_type='scene light')
light.intensity = intensity
self.pl.add_light(light)
# ------------------------------------------------------------- geometry
def rebuild(self):
"""Re-mesh everything and re-add it. Cheap at this size, and it keeps
the drawn thing and the numbers from ever drifting apart."""
for name in list(self.actors):
self.pl.remove_actor(self.actors.pop(name))
for i, p in enumerate(self.placements):
quads = p.world_quads(self.force_chain, self.lossy)
meshes = build_meshes(self.res, quads, by_colour=not self.textured)
for j, (mesh, tex, colour) in enumerate(meshes):
kw = dict(smooth_shading=False, ambient=0.42, diffuse=0.78,
specular=0.0, show_edges=False)
if self.textured and tex is not None:
kw['texture'] = make_texture(self.res, tex)
kw['color'] = 'white'
else:
# Hue per box, shade per face, the same key the unwrap
# template is drawn with.
kw['color'] = colour or p.colour
name = f'p{i}_{j}'
self.actors[name] = self.pl.add_mesh(mesh, name=name, **kw)
if self.show_body:
body = mc.humanoid_body()
pts = np.concatenate([q.pts for q in body])
import pyvista as pv
faces = np.hstack([[4, *range(4 * k, 4 * k + 4)] for k in range(len(body))])
self.actors['body'] = self.pl.add_mesh(
pv.PolyData(pts, faces), name='body', color=(0.30, 0.32, 0.38),
opacity=0.28, smooth_shading=False, specular=0.0)
self._highlight()
self._hud()
def _highlight(self):
import pyvista as pv
self.pl.remove_actor(self.actors.pop('selection', None))
p = self.placements[self.selection]
if not p.editable:
return
quads = p.world_quads(self.force_chain, self.lossy)
if not quads:
return
pts = np.concatenate([q.pts for q in quads])
box = pv.Box(bounds=(pts[:, 0].min(), pts[:, 0].max(),
pts[:, 1].min(), pts[:, 1].max(),
pts[:, 2].min(), pts[:, 2].max()))
self.actors['selection'] = self.pl.add_mesh(
box.outline(), name='selection', color=(1.0, 0.85, 0.2),
line_width=2, lighting=False)
def _hud(self):
if self.args.shot is not None:
return
p = self.placements[self.selection]
mark = '*' if self.selection in self.dirty else ' '
lines = [
f'{mark}{p.name}{"" if p.editable else " (read only)"}'
f' [{p.origin}{"" if p.auto_chain else ", unchained"}]',
f' {p.summary()}',
f' step {self.step} chain {"forced" if self.force_chain else "auto"}'
f' textures {"on" if self.textured else "off"}'
f' merge {"lossy (1.20)" if self.lossy else "exact (1.21)"}',
'',
HELP,
]
self.pl.add_text('\n'.join(lines), position='upper_left', font_size=8,
font='courier', color=(0.86, 0.87, 0.92), name='hud')
# ---------------------------------------------------------------- keys
def _bind_keys(self):
move = {'Left': ('x', -1), 'Right': ('x', 1), 'Up': ('y', -1),
'Down': ('y', 1), 'Prior': ('z', -1), 'Next': ('z', 1)}
for key, (axis, sign) in move.items():
self.pl.add_key_event(key, self._mover(axis, sign))
turns = {'1': ('x', -1), '2': ('x', 1), '3': ('y', -1),
'4': ('y', 1), '5': ('z', -1), '6': ('z', 1)}
for key, (axis, sign) in turns.items():
self.pl.add_key_event(key, self._turner(axis, sign))
self.pl.add_key_event('bracketleft', lambda: self._set_step(0.5))
self.pl.add_key_event('bracketright', lambda: self._set_step(2.0))
self.pl.add_key_event('minus', lambda: self._scale(1 / 1.05))
self.pl.add_key_event('equal', lambda: self._scale(1.05))
self.pl.add_key_event('Tab', lambda: self._select(1))
self.pl.add_key_event('backslash', lambda: self._select(-1))
self.pl.add_key_event('t', self._toggle_textures)
self.pl.add_key_event('f', self._toggle_chain)
self.pl.add_key_event('b', self._toggle_body)
self.pl.add_key_event('a', self._toggle_axes)
self.pl.add_key_event('u', self._revert)
self.pl.add_key_event('s', self._save)
self.pl.add_key_event('c', self._print)
self.pl.add_key_event('p', self._snap)
self.pl.add_key_event('h', lambda: print(HELP))
def _shift(self):
try:
return bool(self.pl.iren.interactor.GetShiftKey())
except Exception:
return False
def _mover(self, axis, sign):
def go():
p = self.placements[self.selection]
if not p.editable:
return self._deny()
p.nudge(axis, sign * self.step * (5 if self._shift() else 1))
self.dirty.add(self.selection)
self.rebuild()
return go
def _turner(self, axis, sign):
def go():
p = self.placements[self.selection]
if not p.editable:
return self._deny()
p.turn(axis, sign * self.turn_step * (5 if self._shift() else 1))
self.dirty.add(self.selection)
self.rebuild()
return go
def _scale(self, factor):
p = self.placements[self.selection]
if not p.editable:
return self._deny()
p.resize(factor)
self.dirty.add(self.selection)
self.rebuild()
def _deny(self):
print(f'{self.placements[self.selection].name} is read only')
def _set_step(self, factor):
self.step = round(min(4.0, max(0.0125, self.step * factor)), 4)
self._hud()
def _select(self, delta):
n = len(self.placements)
self.selection = (self.selection + delta) % n
self._highlight()
self._hud()
def _toggle_textures(self):
self.textured = not self.textured
self.rebuild()
def _toggle_chain(self):
self.force_chain = not self.force_chain
self.rebuild()
def _toggle_body(self):
self.show_body = not self.show_body
self.rebuild()
def _toggle_axes(self):
if getattr(self, '_axes_on', False):
self.pl.hide_axes()
else:
self.pl.show_axes()
self._axes_on = not getattr(self, '_axes_on', False)
def _revert(self):
self.placements[self.selection].revert()
self.dirty.discard(self.selection)
self.rebuild()
def _print(self):
p = self.placements[self.selection]
print(f'{p.name}: {json.dumps(p.transform, indent=2)}')
def _snap(self):
path = os.path.abspath(self.args.shot or 'armour-editor.png')
self.pl.screenshot(path)
print(f'wrote {path}')
def _save(self):
saved = 0
for i in sorted(self.dirty):
p = self.placements[i]
if not p.editable:
continue
write_transform(p)
print(f'saved {p.name} -> {os.path.relpath(p.source[0], REPO)}')
p.original = json.loads(json.dumps(p.transform))
saved += 1
self.dirty.clear()
if not saved:
print('nothing changed')
self._hud()
# --------------------------------------------------------------- camera
def aim(self, yaw=25.0, pitch=12.0, distance=52.0, target=None):
"""Orbital camera. Yaw 0 is the wearer's front, which is -z."""
target = tuple(self.args.target) if target is None else target
up = 1.0 if self.args.scene in ICON_SCENES else -1.0
a, b = math.radians(yaw), math.radians(pitch)
eye = (target[0] - distance * math.sin(a) * math.cos(b),
target[1] + up * distance * math.sin(b),
target[2] - distance * math.cos(a) * math.cos(b))
self.pl.camera.position = eye
self.pl.camera.focal_point = target
self.pl.camera.up = (0.0, up, 0.0) # +y is down on a body, up on an icon
self.pl.camera.view_angle = 34.0
# pyvista resets the camera the first time a mesh is added unless it is
# told the camera is already aimed, which would undo every aim() below.
# That also suppresses the clipping-range reset, and a near plane left
# where the last view put it slices the figure in half - so ask for
# that one explicitly. It moves the planes, never the camera.
self.pl.camera_set = True
self.pl.renderer.reset_camera_clipping_range()
def show(self):
self.aim(self.args.yaw, self.args.pitch, self.args.distance)
self.pl.show(title='armour editor')
def montage(self, path):
"""One panel per view, tiled - the headless equivalent of orbiting."""
from PIL import Image
from PIL import ImageDraw
views = [('icons', 180, 0)] if self.args.scene in ICON_SCENES else VIEWS
shots = []
for label, yaw, pitch in views:
self.aim(yaw, pitch, self.args.distance)
self.pl.render() # screenshot hands back the last buffer, not a new one
img = Image.fromarray(self.pl.screenshot(return_img=True))
ImageDraw.Draw(img).text((8, 6), f'{label} yaw {yaw} pitch {pitch}',
fill=(190, 190, 200))
shots.append((label, img))
cols = min(3, len(shots))
rows = (len(shots) + cols - 1) // cols
w, h = shots[0][1].size
sheet = Image.new('RGB', (w * cols, h * rows))
for i, (label, img) in enumerate(shots):
sheet.paste(img, ((i % cols) * w, (i // cols) * h))
sheet.save(path)
return path
# ----------------------------------------------------------------- json write
def write_transform(placement):
"""Put the edited numbers back where they came from.
Only the three vectors are touched; `origin`, and every sibling key in the
slot, are left exactly as they were, because a placement is the only thing
this tool has any business changing.
"""
path, keys = placement.source
with open(path) as fh:
doc = json.load(fh)
node = doc
for key in keys[:-1]:
node = node[key]
target = node.setdefault(keys[-1], {})
for group, dflt in (('translation', 0.0), ('rotation', 0.0), ('scale', 1.0)):
values = placement.transform.get(group)
if not values:
continue
clean = {a: round(float(values.get(a, dflt)), 4) + 0.0 for a in 'xyz'}
if all(v == dflt for v in clean.values()):
target.pop(group, None)
else:
target[group] = clean
with open(path, 'w') as fh:
fh.write(_dumps(doc))
fh.write('\n')
# These files write a vector on one line - `{"x": 0, "y": 90, "z": 0}` - and a
# tool that reformats every slot it touches makes a two-number change look like
# a rewrite. Dump normally, then fold the leaf vectors back up.
_VECTOR = re.compile(r'\{\s*\n\s*("(?:x|y|z)": [^,{}\n]+,?\s*\n\s*){1,3}\}')
def _dumps(doc):
text = json.dumps(doc, indent=2)
def fold(m):
inner = ' '.join(part.strip() for part in m.group(0)[1:-1].split('\n') if part.strip())
return '{' + inner + '}'
return _VECTOR.sub(fold, text)
# ------------------------------------------------------------------------ cli
def main(argv=None):
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('scene', choices=sorted(SCENES), help='which figure to draw')
ap.add_argument('--jar', action='append', default=[], metavar='PATH',
help='mod jar or resource directory; repeatable, searched in order')
ap.add_argument('--shot', metavar='PNG',
help='render a montage of angles headlessly instead of opening a window')
ap.add_argument('--size', nargs=2, type=int, default=[1280, 860], metavar=('W', 'H'))
ap.add_argument('--variant', default='default', help='material texture variant')
ap.add_argument('--gem', default='medium', choices=('small', 'medium', 'large'))
ap.add_argument('--layer1', default='miapi:item/armor/base/iron/layer_1',
help='armour layer texture for the vanilla scene')
ap.add_argument('--layer2', default='miapi:item/armor/base/iron/layer_2')
ap.add_argument('--step', type=float, default=0.1, help='starting nudge, in pixels')
ap.add_argument('--yaw', type=float, default=25.0)
ap.add_argument('--pitch', type=float, default=12.0)
ap.add_argument('--distance', type=float, default=52.0)
ap.add_argument('--target', nargs=3, type=float, default=[0.0, 8.0, 0.0],
metavar=('X', 'Y', 'Z'), help='what the camera orbits, in model pixels')
ap.add_argument('--background', default='#1a1a1e')
ap.add_argument('--no-textures', action='store_true',
help='start with every module in its own flat colour')
ap.add_argument('--body', action='store_true', help='draw the wearer as a reference')
ap.add_argument('--chain', action='store_true',
help='force the slot chain on even where MIAPI would file it elsewhere')
ap.add_argument('--lossy-merge', action='store_true',
help='decompose each merge to Euler angles, as MIAPI did before 1.21')
args = ap.parse_args(argv)
sources = list(args.jar) or []
sources.append(os.path.join(REPO, 'src/main/resources'))
res = mc.Resources(sources)
placements = SCENES[args.scene](res, args)
view = Viewport(res, placements, args)
if args.shot:
print(view.montage(os.path.abspath(args.shot)))
else:
view.show()
if __name__ == '__main__':
main()

566
tools/armour_gui.py Normal file
View File

@ -0,0 +1,566 @@
#!/usr/bin/env python3
"""Draw geometry onto a body part, unwrap it, and see it on the wearer.
The placement editor moves a module that already exists. This makes one. A gem
that has to follow an arm has to be drawn *by* the arm - MIAPI picks which body
part a model renders under by comparing the model's own `origin` against the
part it is drawing, and it renders under that part's animated pose, so a socket
cut into `left_arm` swings with the arm and one placed in `body` does not.
Armory's gemstone declares no origin at all, which is why it cannot be made to
follow a limb from the outside, and why the socket has to be ours.
So this edits a model file of our own: boxes in the limb's own coordinates,
shown against Armory's plate so they can be lined up with it, unwrapped onto a
texture that is written out beside them.
tools/armour_gui.py --jar <armory.jar>
Left is the box list and the part it belongs to, right is the box being edited,
middle is the wearer. Everything is in model pixels, the units the JSON is
written in.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import guiplatform
guiplatform.configure(prefer=os.environ.get('ARMOUR_GUI_PLATFORM'))
import numpy as np # noqa: E402
from PySide6 import QtCore, QtWidgets # noqa: E402
import pyvistaqt # noqa: E402
import mcmodel as mc # noqa: E402
import armour_editor as ae # noqa: E402
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ASSETS = os.path.join(REPO, 'src/main/resources/assets/cmmodular')
# Which Armory slot dresses each body part, and the plate to draw as context.
# The key is the `origin` MIAPI matches against, which is the whole point of
# choosing a part: it decides what the geometry is pinned to when the wearer
# moves.
PARTS = {
'left_arm': {'slot': 'arm_left', 'piece': 'chestplate', 'plate': 'arm_left/heavy'},
'right_arm': {'slot': 'arm_right', 'piece': 'chestplate', 'plate': 'arm_right/heavy'},
'left_leg': {'slot': 'leg_left', 'piece': 'pants', 'plate': 'leg_left/heavy'},
'right_leg': {'slot': 'leg_right', 'piece': 'pants', 'plate': 'leg_right/heavy'},
'body': {'slot': 'chest_front', 'piece': 'chestplate', 'plate': 'chest_front/heavy'},
'head': {'slot': 'hat', 'piece': 'helmet', 'plate': 'helmet/heavy'},
# Not a body part at all, but the same choice: `item` is the pass that draws
# the inventory icon, and geometry filed under it is what the icon shows.
# It is on this list because it is the other half of the same decision -
# a socket needs one model on the limb and one on the icon, and they are
# different files with different origins.
'item': {'slot': 'icon', 'piece': None, 'plate': None},
}
WEARER = '(wearer)'
PART_LABELS = {
'left_arm': 'left arm - pauldron', 'right_arm': 'right arm - pauldron',
'left_leg': 'left leg - knee', 'right_leg': 'right leg - knee',
'body': 'chest', 'head': 'helmet', 'item': 'inventory icon',
WEARER: 'the wearer',
}
PIECE_FILES = {
'chestplate': 'data/tm_armory/miapi/modules/armor/chestplate.json',
'pants': 'data/tm_armory/miapi/modules/armor/pants.json',
'helmet': 'data/tm_armory/miapi/modules/armor/helmet.json',
}
# ------------------------------------------------------------------- workpiece
class Workpiece:
"""One cmmodular model file: boxes in a body part's own coordinates."""
def __init__(self, res, part):
self.res, self.part = res, part
self.slot = PARTS[part]['slot']
self.path = os.path.join(
ASSETS, f'models/item/armor/model/{self.slot}/socket/default.json')
self.texture_ref = f'cmmodular:equipment/{self.slot}_socket'
self.texture_path = os.path.join(
ASSETS, f'textures/equipment/{self.slot}_socket.png')
self.doc = self._load()
def _load(self):
if os.path.isfile(self.path):
with open(self.path) as fh:
return json.load(fh)
return {
'comment': f'Socket geometry drawn under {self.part}, so it follows '
f'the limb rather than the torso.',
'texture_size': [16, 16],
'textures': {'0': self.texture_ref, 'particle': self.texture_ref},
'elements': [],
}
@property
def elements(self):
return self.doc.setdefault('elements', [])
def add_box(self, name='socket', lo=(-1.0, -1.0, -1.0), hi=(1.0, 1.0, 1.0)):
self.elements.append({'name': name, 'from': list(lo), 'to': list(hi),
'faces': {}})
self.unwrap()
return len(self.elements) - 1
def remove(self, index):
if 0 <= index < len(self.elements):
self.elements.pop(index)
self.unwrap()
def unwrap(self):
"""Re-cut the texture so every face has somewhere of its own to live."""
if not self.elements:
self.doc['texture_size'] = [16, 16]
return None
size, nets = mc.unwrap(self.elements, texture='#0')
self.doc['texture_size'] = [int(size[0]), int(size[1])]
return size, nets
def save(self, write_template=True):
packed = self.unwrap()
os.makedirs(os.path.dirname(self.path), exist_ok=True)
with open(self.path, 'w') as fh:
fh.write(ae._dumps(self.doc))
fh.write('\n')
written = [self.path]
# Only ever write a template over a texture that is not there yet -
# the guide is scaffolding, and overwriting art someone has painted
# because the box list changed would be the tool destroying the work
# it exists to support.
if write_template and packed and not os.path.isfile(self.texture_path):
size, nets = packed
os.makedirs(os.path.dirname(self.texture_path), exist_ok=True)
mc.unwrap_template(size, nets, self.elements).save(self.texture_path)
written.append(self.texture_path)
return written
def quads(self, res):
model = {'textures': self.doc.get('textures', {}),
'elements': self.elements,
'texture_size': self.doc.get('texture_size')}
return mc.model_quads(model, res)
# ----------------------------------------------------------------------- window
class ArmourGui(QtWidgets.QMainWindow):
def __init__(self, res, args):
super().__init__()
self.res, self.args = res, args
self.setWindowTitle('armour geometry')
self.work = Workpiece(res, args.part)
self.actors = {}
splitter = QtWidgets.QSplitter()
splitter.addWidget(self._left_panel())
self.view = pyvistaqt.QtInteractor(self, rw=guiplatform.render_window())
splitter.addWidget(self.view)
splitter.addWidget(self._right_panel())
splitter.setSizes([230, 900, 250])
self.setCentralWidget(splitter)
self.statusBar().showMessage(f'{self.work.path}')
self.view.set_background(args.background)
self._add_lights()
self._realised = False
def showEvent(self, event):
"""First draw waits for the window.
A QOpenGLWidget has no GL context until it is on screen, and VTK asked
to render before that goes looking for one of its own - which on a
Wayland session means a GLX context that cannot be made current.
"""
super().showEvent(event)
if not self._realised:
self._realised = True
QtCore.QTimer.singleShot(0, self._first_draw)
def _first_draw(self):
# Depth peeling probes the GL context, so it has to wait for one too.
self.view.enable_depth_peeling(number_of_peels=8, occlusion_ratio=0.0)
self.refresh(reset=True)
# ------------------------------------------------------------- panels
def _left_panel(self):
box = QtWidgets.QWidget()
lay = QtWidgets.QVBoxLayout(box)
lay.addWidget(QtWidgets.QLabel('armour parts'))
# One list doing both jobs: the tick says whether a part is drawn, the
# selection says which one the boxes below belong to. They are the same
# question asked twice otherwise - you cannot line a socket up against a
# pauldron you have hidden, and the part you are editing is the one you
# always want on screen, so selecting a row ticks it.
self.parts_list = QtWidgets.QListWidget()
self.parts_list.setFixedHeight(150)
for part in list(PARTS) + [WEARER]:
item = QtWidgets.QListWidgetItem(PART_LABELS.get(part, part))
item.setData(QtCore.Qt.UserRole, part)
item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable)
visible = part == self.args.part or (part == WEARER and self.args.body)
item.setCheckState(QtCore.Qt.Checked if visible else QtCore.Qt.Unchecked)
self.parts_list.addItem(item)
self.parts_list.itemChanged.connect(lambda _: self.refresh())
self.parts_list.currentItemChanged.connect(self._parts_selected)
self.parts_list.setCurrentRow(list(PARTS).index(self.args.part))
self._show_edited_part()
lay.addWidget(self.parts_list)
row = QtWidgets.QHBoxLayout()
for label, state in (('All', QtCore.Qt.Checked), ('None', QtCore.Qt.Unchecked)):
button = QtWidgets.QPushButton(label)
button.clicked.connect(lambda _=None, st=state: self._set_all(st))
row.addWidget(button)
lay.addLayout(row)
note = QtWidgets.QLabel('geometry follows the selected part when the '
'wearer moves')
note.setWordWrap(True)
note.setStyleSheet('color: #888;')
lay.addWidget(note)
lay.addWidget(QtWidgets.QLabel('boxes'))
self.list = QtWidgets.QListWidget()
self.list.currentRowChanged.connect(lambda _: self.refresh())
lay.addWidget(self.list, 1)
for label, slot in (('Add box', self.on_add),
('Duplicate', self.on_duplicate),
('Remove', self.on_remove),
('Unwrap UVs', self.on_unwrap),
('Save model + template', self.on_save)):
button = QtWidgets.QPushButton(label)
button.clicked.connect(slot)
lay.addWidget(button)
self.textured = QtWidgets.QCheckBox('textures')
self.textured.setChecked(not self.args.no_textures)
self.textured.toggled.connect(lambda _: self.refresh())
lay.addWidget(self.textured)
return box
# ------------------------------------------------------- visible parts
def _rows(self):
for i in range(self.parts_list.count()):
yield self.parts_list.item(i)
def _visible(self, part):
for item in self._rows():
if item.data(QtCore.Qt.UserRole) == part:
return item.checkState() == QtCore.Qt.Checked
return False
def _set_all(self, state):
self.parts_list.blockSignals(True)
for item in self._rows():
item.setCheckState(state)
self.parts_list.blockSignals(False)
self._show_edited_part()
self.refresh()
def _show_edited_part(self):
"""The part being edited is never hidden - that would hide the work."""
self.parts_list.blockSignals(True)
for item in self._rows():
part = item.data(QtCore.Qt.UserRole)
font = item.font()
font.setBold(part == self.args.part)
item.setFont(font)
if part == self.args.part:
item.setCheckState(QtCore.Qt.Checked)
self.parts_list.blockSignals(False)
def _parts_selected(self, item, _previous=None):
if item is None:
return
part = item.data(QtCore.Qt.UserRole)
if part == WEARER or part == self.args.part:
self._show_edited_part()
return
self._switch_part(part)
def _right_panel(self):
box = QtWidgets.QWidget()
lay = QtWidgets.QFormLayout(box)
self.name_edit = QtWidgets.QLineEdit()
self.name_edit.editingFinished.connect(self.on_name)
lay.addRow('name', self.name_edit)
self.spins = {}
for key in ('from', 'to'):
for i, axis in enumerate('xyz'):
spin = QtWidgets.QDoubleSpinBox()
spin.setRange(-64.0, 64.0)
spin.setSingleStep(0.25)
spin.setDecimals(3)
spin.valueChanged.connect(self.on_spin)
self.spins[(key, i)] = spin
lay.addRow(f'{key} {axis}', spin)
self.size_label = QtWidgets.QLabel('-')
lay.addRow('size', self.size_label)
self.atlas_label = QtWidgets.QLabel('-')
lay.addRow('texture', self.atlas_label)
return box
def _add_lights(self):
import pyvista as pv
for direction, intensity in (((0.2, -1.0, -0.7), 0.62),
((-0.2, -1.0, 0.7), 0.44)):
v = np.asarray(direction, float)
light = pv.Light(position=tuple(-v * 100), focal_point=(0, 0, 0),
light_type='scene light')
light.intensity = intensity
self.view.add_light(light)
# ------------------------------------------------------------ actions
def _switch_part(self, part):
self.args.part = part
self.work = Workpiece(self.res, part)
self._show_edited_part()
self.statusBar().showMessage(self.work.path)
self.refresh(reset=True)
def on_add(self):
row = self.work.add_box()
self.refresh()
self.list.setCurrentRow(row)
def on_duplicate(self):
row = self.list.currentRow()
if row < 0:
return
clone = json.loads(json.dumps(self.work.elements[row]))
clone['name'] = clone.get('name', 'socket') + ' copy'
self.work.elements.append(clone)
self.work.unwrap()
self.refresh()
self.list.setCurrentRow(len(self.work.elements) - 1)
def on_remove(self):
row = self.list.currentRow()
if row >= 0:
self.work.remove(row)
self.refresh()
def on_unwrap(self):
packed = self.work.unwrap()
if packed:
self.statusBar().showMessage(
f'unwrapped onto {packed[0][0]}x{packed[0][1]}')
self.refresh()
def on_save(self):
written = self.work.save()
self.statusBar().showMessage(
'wrote ' + ', '.join(os.path.relpath(p, REPO) for p in written))
self.refresh()
def on_name(self):
row = self.list.currentRow()
if row >= 0:
self.work.elements[row]['name'] = self.name_edit.text()
self.refresh()
def on_spin(self):
row = self.list.currentRow()
if row < 0 or getattr(self, '_loading', False):
return
el = self.work.elements[row]
for key in ('from', 'to'):
el[key] = [self.spins[(key, i)].value() for i in range(3)]
self.work.unwrap()
self.refresh()
# ------------------------------------------------------------ drawing
def refresh(self, reset=False):
row = self.list.currentRow()
self._sync_list(row)
self._sync_fields(row)
self._rebuild(reset)
def _sync_list(self, row):
self._loading = True
self.list.blockSignals(True)
self.list.clear()
for i, el in enumerate(self.work.elements):
lo, hi = el['from'], el['to']
size = [round(abs(b - a), 3) for a, b in zip(lo, hi)]
self.list.addItem(f"{i}: {el.get('name', 'box')} {size}")
if 0 <= row < self.list.count():
self.list.setCurrentRow(row)
elif self.list.count():
self.list.setCurrentRow(0)
self.list.blockSignals(False)
self._loading = False
def _sync_fields(self, row):
self._loading = True
row = self.list.currentRow()
enabled = 0 <= row < len(self.work.elements)
for spin in self.spins.values():
spin.setEnabled(enabled)
self.name_edit.setEnabled(enabled)
if enabled:
el = self.work.elements[row]
self.name_edit.setText(el.get('name', 'box'))
for key in ('from', 'to'):
for i in range(3):
self.spins[(key, i)].setValue(float(el[key][i]))
size = [round(abs(b - a), 3) for a, b in zip(el['from'], el['to'])]
self.size_label.setText(' x '.join(str(s) for s in size))
else:
self.name_edit.setText('')
self.size_label.setText('-')
ts = self.work.doc.get('texture_size', [16, 16])
self.atlas_label.setText(f'{ts[0]} x {ts[1]}')
self._loading = False
def _context(self, part=None):
"""Armory's plate for a part, so new geometry has something to meet."""
spec = PARTS[part or self.args.part]
if spec['piece'] is None:
return self._icon_context()
piece = json.loads(self.res.read(PIECE_FILES[spec['piece']]))['slots']
slot = piece[spec['slot']]['transform']
plate = mc.model_quads(
self.res.model(f"miapi:models/item/armor/model/{spec['plate']}/"
'[material.texture].json', self.args.variant), self.res)
return slot, plate
def _icon_context(self):
"""The inventory sprite, for aiming icon geometry at.
The icon is a flat `item/generated` sprite, so the context here is a
picture rather than a shape - but it is the picture the gem has to land
on, and eyeballing pixel offsets against it beats counting them.
"""
icon = self.args.icon or 'miapi:models/item/armor/gui/heavy/arm_left/base/' \
'[material.texture].json'
return {}, mc.model_quads(self.res.model(icon, self.args.variant), self.res)
def _frame(self, part):
"""The matrix and pivot that put a part's model on the wearer."""
spec = PARTS[part]
if spec['piece'] is None:
return np.eye(4), (0.0, 0.0, 0.0)
piece = json.loads(self.res.read(PIECE_FILES[spec['piece']]))['slots']
return (mc.transform_matrix(piece[spec['slot']]['transform']),
mc.PIVOTS.get(part, (0.0, 0.0, 0.0)))
def _rebuild(self, reset=False):
for name in list(self.actors):
self.view.remove_actor(self.actors.pop(name))
textured = self.textured.isChecked()
groups = []
for part in PARTS:
if not self._visible(part):
continue
try:
matrix, pivot = self._frame(part)
_slot, plate = self._context(part)
except (KeyError, TypeError):
continue
groups.append((f'plate_{part}',
[q.transformed(matrix, pivot) for q in plate],
(0.60, 0.60, 0.66)))
try:
matrix, pivot = self._frame(self.args.part)
except (KeyError, TypeError):
matrix, pivot = np.eye(4), (0.0, 0.0, 0.0)
try:
work = self.work.quads(self.res)
except Exception:
work = []
groups.append(('work', [q.transformed(matrix, pivot) for q in work],
(0.95, 0.72, 0.30)))
for tag, quads, colour in groups:
if not quads:
continue
# The workpiece is coloured against its own box count, which is what
# its unwrap template was drawn against; the context plate is not
# ours and gets a flat colour so the two never look related.
count = len(self.work.elements) if tag == 'work' else None
meshes = ae.build_meshes(self.res, quads, count,
by_colour=(tag == 'work' and not textured))
for j, (mesh, tex, face_colour) in enumerate(meshes):
kw = dict(smooth_shading=False, ambient=0.42, diffuse=0.78,
specular=0.0)
if textured and tex is not None:
kw['texture'] = ae.make_texture(self.res, tex)
kw['color'] = 'white'
else:
kw['color'] = face_colour or colour
name = f'{tag}{j}'
self.actors[name] = self.view.add_mesh(mesh, name=name, **kw)
if self._visible(WEARER):
import pyvista as pv
body = mc.humanoid_body()
pts = np.concatenate([q.pts for q in body])
faces = np.hstack([[4, *range(4 * k, 4 * k + 4)] for k in range(len(body))])
self.actors['body'] = self.view.add_mesh(
pv.PolyData(pts, faces), name='body', color=(0.30, 0.32, 0.38),
opacity=0.25, smooth_shading=False, specular=0.0)
if reset:
self._aim(pivot)
if self._realised:
self.view.render()
def _aim(self, pivot):
target = (pivot[0], pivot[1] + 2.0, 0.0)
self.view.camera.position = (target[0] - 16, target[1] - 6, target[2] - 26)
self.view.camera.focal_point = target
self.view.camera.up = (0.0, -1.0, 0.0)
self.view.camera.view_angle = 34.0
self.view.camera_set = True
def main(argv=None):
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--jar', action='append', default=[], metavar='PATH')
ap.add_argument('--part', default='left_arm', choices=sorted(PARTS))
ap.add_argument('--variant', default='default')
ap.add_argument('--icon', help='model path to show behind `item` geometry')
ap.add_argument('--background', default='#1a1a1e')
ap.add_argument('--no-textures', action='store_true')
ap.add_argument('--body', action='store_true')
args = ap.parse_args(argv)
sources = list(args.jar)
sources.append(os.path.join(REPO, 'src/main/resources'))
res = mc.Resources(sources)
app = QtWidgets.QApplication(sys.argv[:1])
window = ArmourGui(res, args)
window.resize(1420, 820)
window.show()
return app.exec()
if __name__ == '__main__':
sys.exit(main())

137
tools/guiplatform.py Normal file
View File

@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Pick a Qt platform that VTK can actually draw into, and say which and why.
VTK's Python wheels ship no Wayland window backend - the on-screen class is
`vtkXOpenGLRenderWindow`, and EGL and OSMesa are both offscreen. Left alone,
Qt6 picks Wayland whenever `WAYLAND_DISPLAY` is set while VTK goes on creating
an X window, and the two disagree about who owns the surface: the process dies
with `BadWindow (X_ConfigureWindow)` before anything is drawn.
There is a way out, and it is worth taking rather than forcing everyone onto
XWayland. Asking VTK for a `vtkGenericOpenGLRenderWindow` and Qt's widget for
its `QOpenGLWidget` base puts *Qt* in charge of the GL context; VTK then draws
into a context it did not create and never touches X. That works natively on
Wayland, and on X11 as well, so it is the path used either way.
So the order of preference is Wayland, then X11, then offscreen - and the
choice is announced once, because a tool that silently moves you to XWayland is
a tool that will be blamed for the missing fractional scaling.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
def configure(prefer=None, quiet=False):
"""Set the Qt platform and VTK widget base. Call before importing Qt.
Returns the platform name chosen. `prefer` forces one; `QT_QPA_PLATFORM`
set in the environment wins over both, because someone who set it meant it.
"""
chosen = os.environ.get('QT_QPA_PLATFORM')
if not chosen:
chosen = prefer or _detect()
os.environ['QT_QPA_PLATFORM'] = chosen
origin = 'detected'
else:
origin = 'from QT_QPA_PLATFORM'
# Must happen before QVTKRenderWindowInteractor is imported: the module
# picks its base class at import time and caches it.
import vtkmodules.qt
vtkmodules.qt.QVTKRWIBase = 'QOpenGLWidget'
if not quiet:
note = ''
if origin == 'detected' and chosen == 'xcb' and os.environ.get('WAYLAND_DISPLAY'):
note = ' - Wayland is present but its GL context failed a test frame'
print(f'display: {chosen} ({origin}){note}', file=sys.stderr)
if chosen == 'offscreen':
print('display: no Wayland or X11 session - rendering to files only',
file=sys.stderr)
return chosen
def _detect():
wayland = os.environ.get('WAYLAND_DISPLAY') and _socket_exists()
x11 = bool(os.environ.get('DISPLAY'))
if wayland and _probe('wayland'):
return 'wayland'
if x11:
return 'xcb'
return 'wayland' if wayland else 'offscreen'
# The failure this guards against is not an exception. A Wayland session whose
# GL stack cannot make the context current - WSLg with a software Mesa is the
# common one - takes the whole process down with an X `BadAccess` on the first
# paint, after the window is already up. Nothing in-process can catch that, so
# the question gets asked in a process we can afford to lose.
_PROBE = """
import os, sys
import vtkmodules.qt
vtkmodules.qt.QVTKRWIBase = 'QOpenGLWidget'
from vtkmodules.vtkRenderingOpenGL2 import vtkGenericOpenGLRenderWindow
from PySide6 import QtCore, QtWidgets
import pyvistaqt, pyvista as pv
app = QtWidgets.QApplication(['probe'])
win = QtWidgets.QMainWindow()
view = pyvistaqt.QtInteractor(win, rw=vtkGenericOpenGLRenderWindow())
win.setCentralWidget(view)
win.resize(64, 64)
win.show()
def go():
# Exercise what the tools actually ask for. A bare cube survives GL stacks
# that fall over on a textured, depth-peeled scene, and a probe that passes
# where the app crashes is worse than no probe at all.
import numpy as np
view.enable_depth_peeling(number_of_peels=8, occlusion_ratio=0.0)
mesh = pv.Cube()
mesh.active_texture_coordinates = np.random.rand(mesh.n_points, 2).astype(np.float32)
tex = pv.Texture(np.random.randint(0, 255, (8, 8, 4), dtype=np.uint8))
tex.SetInterpolate(False)
view.add_mesh(mesh, texture=tex)
view.add_light(pv.Light(position=(1, 1, 1), light_type='scene light'))
view.render()
view.screenshot(sys.argv[1] + '.png')
open(sys.argv[1], 'w').write('ok')
app.quit()
QtCore.QTimer.singleShot(0, go)
app.exec()
"""
def _probe(platform, timeout=25):
"""Does a real VTK viewport survive its first frame on this platform?"""
if os.environ.get('MIAPI_TOOLS_NO_PROBE'):
return False
with tempfile.TemporaryDirectory() as tmp:
flag = os.path.join(tmp, 'flag')
env = dict(os.environ, QT_QPA_PLATFORM=platform)
env.pop('MIAPI_TOOLS_NO_PROBE', None)
try:
subprocess.run([sys.executable, '-c', _PROBE, flag], env=env,
timeout=timeout, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
except (subprocess.TimeoutExpired, OSError):
return False
return os.path.exists(flag)
def _socket_exists():
"""A `WAYLAND_DISPLAY` with no socket behind it is a stale export."""
name = os.environ.get('WAYLAND_DISPLAY', '')
if os.path.isabs(name):
return os.path.exists(name)
runtime = os.environ.get('XDG_RUNTIME_DIR')
return bool(runtime) and os.path.exists(os.path.join(runtime, name))
def render_window():
"""The render window that lets Qt own the context. Import Qt first."""
from vtkmodules.vtkRenderingOpenGL2 import vtkGenericOpenGLRenderWindow
return vtkGenericOpenGLRenderWindow()

480
tools/material_editor.py Normal file
View File

@ -0,0 +1,480 @@
#!/usr/bin/env python3
"""Edit the material definitions in `materials.py` without opening it.
The materials are Python, not data: an entry is an `M(...)` call, its
ingredients are usually `ingots(...)` rather than a list, and its effects are
`speed(-0.12)` rather than a dictionary. A generated JSON file is downstream of
all that and gets overwritten by the next `generate_materials.py` run, so this
edits the source instead.
It does that by rewriting one argument at a time, in place. Each field shows
the *source text* of the argument it stands for rather than a rendering of its
value, and saving replaces exactly that span of the file - so `ingots("mekanism",
"ingot_tin", ...)` stays a call, the comments under every material stay where
they were, and a diff shows the number that changed and nothing else.
tools/material_editor.py
Materials built by a loop rather than written out - the dragon scales, the gem
families - have no literal `M(...)` to edit and are shown read-only, because the
thing to change for those is the loop.
"""
from __future__ import annotations
import argparse
import ast
import os
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import guiplatform
guiplatform.configure(prefer=os.environ.get('ARMOUR_GUI_PLATFORM'), quiet=True)
from PySide6 import QtCore, QtGui, QtWidgets # noqa: E402
TOOLS = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(TOOLS)
SOURCE = os.path.join(TOOLS, 'materials.py')
# The signature of M(), in order, with the type each argument carries. The type
# is shown beside the field because these are Python literals being edited as
# text: "3" and "3.0" are not the same thing to a stat that is declared a float,
# and a tuple of one string needs its trailing comma.
POSITIONAL = ['name', 'pack', 'group', 'translation', 'palette_from', 'items',
'tier', 'hardness', 'density', 'flexibility', 'durability',
'enchantability', 'mining_speed']
FIELDS = [
('name', 'str'), ('pack', 'str'), ('group', 'str'),
('translation', 'str'), ('palette_from', 'str'), ('icon', 'str | None'),
('items', 'list[dict]'),
('tier', 'int'), ('hardness', 'float'), ('density', 'float'),
('flexibility', 'float'), ('durability', 'int'), ('enchantability', 'int'),
('mining_speed', 'int'), ('mining_level', 'str | None'),
('toughness', 'int'), ('armor_durability', 'int | None'),
('armor_toughness', 'float | None'), ('knockback_resistance', 'float | None'),
('groups', 'tuple[str] | None'), ('hidden_groups', 'tuple[str] | None'),
('textures', 'tuple[str]'), ('properties', 'dict | None'),
]
# The ones worth more than a single line to look at.
TALL = {'items', 'properties', 'groups', 'hidden_groups', 'textures'}
DEFAULTS = {'toughness': '0', 'textures': '("metallic",)', 'properties': '{}',
'groups': 'None', 'hidden_groups': 'None', 'icon': 'None',
'mining_level': 'None', 'armor_durability': 'None',
'armor_toughness': 'None', 'knockback_resistance': 'None'}
# ------------------------------------------------------------------ the source
class MaterialSource:
"""`materials.py`, parsed so that a single argument can be replaced.
Everything is done in bytes. `ast` reports column offsets as byte offsets
into the encoded line, so working in characters would put every span one
place out the first time somebody writes a material with an accent in its
name.
"""
def __init__(self, path=SOURCE):
self.path = path
self.reload()
def reload(self):
with open(self.path, 'rb') as fh:
self.data = fh.read()
self.lines = self.data.split(b'\n')
self.starts, at = [], 0
for line in self.lines:
self.starts.append(at)
at += len(line) + 1
tree = ast.parse(self.data.decode('utf-8'), self.path)
self.calls = {}
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id == 'M' and node.args
and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str)):
self.calls[node.args[0].value] = node
# Where a new material can be appended: just before the `]` that closes
# the MATERIALS list.
self.list_end = None
for node in ast.walk(tree):
if (isinstance(node, ast.Assign) and node.targets
and isinstance(node.targets[0], ast.Name)
and node.targets[0].id == 'MATERIALS'
and isinstance(node.value, ast.List)):
self.list_end = self._offset(node.value.end_lineno,
node.value.end_col_offset) - 1
def _offset(self, lineno, col):
return self.starts[lineno - 1] + col
def span(self, node):
return (self._offset(node.lineno, node.col_offset),
self._offset(node.end_lineno, node.end_col_offset))
def names(self):
return set(self.calls)
def argument(self, name, field):
"""The node for one argument of one material, or None if not passed."""
call = self.calls.get(name)
if call is None:
return None
for kw in call.keywords:
if kw.arg == field:
return kw.value
if field in POSITIONAL:
index = POSITIONAL.index(field)
if index < len(call.args):
return call.args[index]
return None
def text(self, name, field):
node = self.argument(name, field)
if node is None:
return None
start, end = self.span(node)
return self.data[start:end].decode('utf-8')
# ------------------------------------------------------------- writing
def apply(self, edits):
"""Replace argument spans. `edits` is [(material, field, source)].
Applied back to front so that an earlier edit never moves a later
one's offsets, and re-parsed afterwards so the next edit is measured
against what is now on disk.
"""
patches = []
for name, field, new in edits:
node = self.argument(name, field)
if node is not None:
start, end = self.span(node)
patches.append((start, end, new.encode('utf-8')))
else:
patches.append(self._insert_keyword(name, field, new))
data = self.data
for start, end, blob in sorted(patches, key=lambda p: -p[0]):
data = data[:start] + blob + data[end:]
with open(self.path, 'wb') as fh:
fh.write(data)
self.reload()
def _insert_keyword(self, name, field, new):
"""A keyword the call does not pass yet, added before its closing paren."""
call = self.calls[name]
last = call.keywords[-1].value if call.keywords else call.args[-1]
_, end = self.span(last)
return (end, end, f', {field}={new}'.encode('utf-8'))
def add_material(self, source):
"""Append a whole `M(...)` call to the end of MATERIALS."""
if self.list_end is None:
raise RuntimeError('could not find the end of MATERIALS')
blob = ('\n ' + source.strip().rstrip(',') + ',\n').encode('utf-8')
data = self.data[:self.list_end] + blob + self.data[self.list_end:]
with open(self.path, 'wb') as fh:
fh.write(data)
self.reload()
TEMPLATE = '''M("{name}", "{pack}", "metal", "{title}", "{pack}:ingot_{name}",
ingots("{pack}", "ingot_{name}"),
tier=3, hardness=5.0, density=4.0, flexibility=1, durability=300,
enchantability=12, mining_speed=6)'''
# ----------------------------------------------------------------------- window
class MaterialEditor(QtWidgets.QMainWindow):
def __init__(self, source, materials):
super().__init__()
self.source = source
self.materials = materials
self.pending = {}
self.current = None
self.setWindowTitle('materials')
splitter = QtWidgets.QSplitter()
splitter.addWidget(self._left())
splitter.addWidget(self._right())
splitter.setSizes([320, 780])
self.setCentralWidget(splitter)
self._fill_list()
self._status()
# -------------------------------------------------------------- panes
def _left(self):
box = QtWidgets.QWidget()
lay = QtWidgets.QVBoxLayout(box)
self.filter = QtWidgets.QLineEdit()
self.filter.setPlaceholderText('filter by name, pack or group')
self.filter.textChanged.connect(self._fill_list)
lay.addWidget(self.filter)
self.list = QtWidgets.QListWidget()
self.list.currentItemChanged.connect(self._select)
lay.addWidget(self.list, 1)
row = QtWidgets.QHBoxLayout()
for label, slot in (('Add', self.on_add), ('Save', self.on_save),
('Revert', self.on_revert)):
button = QtWidgets.QPushButton(label)
button.clicked.connect(slot)
row.addWidget(button)
lay.addLayout(row)
self.jars = QtWidgets.QLineEdit(os.path.expanduser('~/.cache/abdelpak-jars'))
lay.addWidget(QtWidgets.QLabel('mod jars, for regenerating the JSON'))
lay.addWidget(self.jars)
regen = QtWidgets.QPushButton('Regenerate material JSON')
regen.clicked.connect(self.on_regenerate)
lay.addWidget(regen)
return box
def _right(self):
outer = QtWidgets.QWidget()
lay = QtWidgets.QVBoxLayout(outer)
self.heading = QtWidgets.QLabel('-')
font = self.heading.font()
font.setBold(True)
self.heading.setFont(font)
lay.addWidget(self.heading)
self.note = QtWidgets.QLabel('')
self.note.setWordWrap(True)
self.note.setStyleSheet('color: #b08;')
lay.addWidget(self.note)
scroll = QtWidgets.QScrollArea()
scroll.setWidgetResizable(True)
inner = QtWidgets.QWidget()
form = QtWidgets.QGridLayout(inner)
form.setColumnStretch(1, 1)
self.editors = {}
for row, (field, kind) in enumerate(FIELDS):
form.addWidget(QtWidgets.QLabel(field), row, 0)
if field in TALL:
widget = QtWidgets.QPlainTextEdit()
widget.setFixedHeight(58)
widget.textChanged.connect(lambda f=field: self._changed(f))
else:
widget = QtWidgets.QLineEdit()
widget.textChanged.connect(lambda _=None, f=field: self._changed(f))
widget.setFont(QtGui.QFont('monospace'))
form.addWidget(widget, row, 1)
# The type sits at the end of the field, because these are literals
# typed by hand and "3" against "3.0" is a real difference.
kind_label = QtWidgets.QLabel(kind)
kind_label.setStyleSheet('color: #888;')
form.addWidget(kind_label, row, 2)
self.editors[field] = widget
scroll.setWidget(inner)
lay.addWidget(scroll, 1)
return outer
# ------------------------------------------------------------ contents
def _fill_list(self):
want = self.filter.text().strip().lower()
self.list.blockSignals(True)
self.list.clear()
for mat in self.materials:
label = f"{mat['name']} [{mat['pack']} / {mat['group']}]"
if want and want not in label.lower():
continue
item = QtWidgets.QListWidgetItem(label)
item.setData(QtCore.Qt.UserRole, mat['name'])
if mat['name'] not in self.source.names():
item.setForeground(QtGui.QColor('#888'))
self.list.addItem(item)
self.list.blockSignals(False)
if self.list.count():
self.list.setCurrentRow(0)
def _select(self, item, _previous=None):
if item is None:
return
self.current = item.data(QtCore.Qt.UserRole)
editable = self.current in self.source.names()
self.heading.setText(self.current)
self.note.setText('' if editable else
'Built by a loop rather than written out - read only '
'here; edit the loop that makes it.')
self._loading = True
for field, _kind in FIELDS:
text = self.source.text(self.current, field) if editable else None
key = (self.current, field)
if key in self.pending:
text = self.pending[key]
shown = text if text is not None else DEFAULTS.get(field, '')
widget = self.editors[field]
widget.setReadOnly(not editable)
if isinstance(widget, QtWidgets.QPlainTextEdit):
widget.setPlainText(shown)
else:
widget.setText(shown)
self._loading = False
self._status()
def _value(self, field):
widget = self.editors[field]
if isinstance(widget, QtWidgets.QPlainTextEdit):
return widget.toPlainText().strip()
return widget.text().strip()
def _changed(self, field):
if getattr(self, '_loading', False) or self.current is None:
return
if self.current not in self.source.names():
return
new = self._value(field)
old = self.source.text(self.current, field)
key = (self.current, field)
# An untouched optional argument stays untouched: writing `toughness=0`
# into every material that never mentioned it would be a diff of noise.
if new == (old if old is not None else DEFAULTS.get(field, '')):
self.pending.pop(key, None)
else:
self.pending[key] = new
self._mark(field, ok=self._parses(new))
self._status()
@staticmethod
def _parses(text):
if not text:
return False
try:
ast.parse(text, mode='eval')
return True
except SyntaxError:
return False
def _mark(self, field, ok):
widget = self.editors[field]
widget.setStyleSheet('' if ok else 'background: #5a2230;')
def _status(self):
bad = [f'{n}.{f}' for (n, f), v in self.pending.items() if not self._parses(v)]
msg = f'{len(self.pending)} unsaved change(s)' if self.pending else 'no changes'
if bad:
msg += f' - will not parse: {", ".join(bad)}'
self.statusBar().showMessage(msg)
# ------------------------------------------------------------- actions
def on_save(self):
bad = [k for k, v in self.pending.items() if not self._parses(v)]
if bad:
QtWidgets.QMessageBox.warning(
self, 'materials',
'These are not valid Python and were not saved:\n\n'
+ '\n'.join(f'{n}.{f}' for n, f in bad))
return
if not self.pending:
return
edits = [(n, f, v) for (n, f), v in self.pending.items()]
self.source.apply(edits)
self.pending.clear()
self._select(self.list.currentItem())
self.statusBar().showMessage(
f'wrote {len(edits)} change(s) to {_short(self.source.path)}')
def on_revert(self):
self.pending.clear()
self._select(self.list.currentItem())
def on_add(self):
name, ok = QtWidgets.QInputDialog.getText(
self, 'new material', 'id, lowercase with underscores:')
if not ok or not name.strip():
return
name = name.strip()
if name in self.source.names():
QtWidgets.QMessageBox.warning(self, 'materials',
f'{name} already exists.')
return
pack, ok = QtWidgets.QInputDialog.getText(self, 'new material',
'pack:', text='mekanism')
if not ok:
return
self.source.add_material(TEMPLATE.format(
name=name, pack=pack.strip() or 'mekanism',
title=name.replace('_', ' ').title()))
self.materials = load_materials(self.source.path)
self._fill_list()
for row in range(self.list.count()):
if self.list.item(row).data(QtCore.Qt.UserRole) == name:
self.list.setCurrentRow(row)
break
self.statusBar().showMessage(f'added {name} - it still needs real items '
f'and stats')
def on_regenerate(self):
folder = self.jars.text().strip()
if not os.path.isdir(folder):
QtWidgets.QMessageBox.warning(self, 'materials',
f'not a folder: {folder}')
return
self.statusBar().showMessage('regenerating...')
QtWidgets.QApplication.processEvents()
run = subprocess.run([sys.executable,
os.path.join(TOOLS, 'generate_materials.py'),
'--jars', folder],
capture_output=True, text=True, cwd=REPO)
tail = (run.stdout + run.stderr).strip().splitlines()
self.statusBar().showMessage(tail[-1] if tail else 'done')
if run.returncode:
QtWidgets.QMessageBox.warning(self, 'generate_materials.py',
'\n'.join(tail[-25:]))
def _short(path):
"""Repo-relative when it is in the repo, absolute when it is not."""
rel = os.path.relpath(path, REPO)
return path if rel.startswith(os.pardir) else rel
def load_materials(path=SOURCE):
"""The evaluated list, which is the only thing that knows the full set.
Loaded from the same file that is being edited, and freshly each time - a
material added during the session has to appear in the list, and a listing
taken from a different file than the edits go to would be a quiet lie.
"""
import importlib.util
spec = importlib.util.spec_from_file_location('_materials_under_edit', path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return list(module.MATERIALS)
def main(argv=None):
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--source', default=SOURCE, help='materials.py to edit')
args = ap.parse_args(argv)
app = QtWidgets.QApplication(sys.argv[:1])
window = MaterialEditor(MaterialSource(args.source),
load_materials(args.source))
window.resize(1180, 760)
window.show()
return app.exec()
if __name__ == '__main__':
sys.exit(main())

657
tools/mcmodel.py Normal file
View File

@ -0,0 +1,657 @@
#!/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 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