diff --git a/.gitignore b/.gitignore index 99b7f8c..51d4441 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ runs/ repo/ *.class __pycache__/ + +# Python virtualenv for the tools in tools/ +tools/.venv/ diff --git a/tools/armour_editor.py b/tools/armour_editor.py new file mode 100644 index 0000000..5472379 --- /dev/null +++ b/tools/armour_editor.py @@ -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 # the editable scene + tools/armour_editor.py sockets --jar --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() diff --git a/tools/armour_gui.py b/tools/armour_gui.py new file mode 100644 index 0000000..a221fbb --- /dev/null +++ b/tools/armour_gui.py @@ -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 + +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()) diff --git a/tools/guiplatform.py b/tools/guiplatform.py new file mode 100644 index 0000000..c019614 --- /dev/null +++ b/tools/guiplatform.py @@ -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() diff --git a/tools/material_editor.py b/tools/material_editor.py new file mode 100644 index 0000000..abe15cf --- /dev/null +++ b/tools/material_editor.py @@ -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()) diff --git a/tools/mcmodel.py b/tools/mcmodel.py new file mode 100644 index 0000000..903d42a --- /dev/null +++ b/tools/mcmodel.py @@ -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//...` 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