#!/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 # pick a scene in the dialog 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 guiplatform 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) # --------------------------------------------------------------------- opening def guess_sources(): """Jars worth offering before anybody has typed a path. The repo's own resources come first, because the models being worked on are usually the ones in it and a list that omits them reads as though they are not available. Then Armory: every scene except `vanilla` reads its models and the tool is useless without them, so an empty list is the one starting state guaranteed to be wrong. Both beat opening on a file browser. """ found = [os.path.join(REPO, 'src/main/resources')] for folder in (os.path.expanduser('~/.cache/abdelpak-jars'), os.path.join(REPO, 'run', 'mods'), os.path.expanduser('~/.minecraft/mods')): if not os.path.isdir(folder): continue for name in sorted(os.listdir(folder)): if name.endswith('.jar') and 'armory' in name.lower(): found.append(os.path.join(folder, name)) return found def scene_blurb(name): """The first line of a scene's own docstring, so the two cannot drift.""" doc = (SCENES[name].__doc__ or '').strip() return doc.splitlines()[0] if doc else '' def opening_dialog(args): """Ask what to open. False if the window was closed without choosing. Only Qt, no VTK: the viewport this leads to is a plotter of its own, so there is no shared GL context to get wrong and no reason to make anyone sit through the Wayland probe before they have picked a scene. """ from PySide6 import QtCore, QtWidgets app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv[:1]) dialog = QtWidgets.QDialog() dialog.setObjectName('opening') dialog.setWindowTitle('armour editor') lay = QtWidgets.QVBoxLayout(dialog) lay.addWidget(QtWidgets.QLabel('What to draw')) scenes = QtWidgets.QListWidget() for name in sorted(SCENES): item = QtWidgets.QListWidgetItem(f'{name}\n {scene_blurb(name)}') item.setData(QtCore.Qt.UserRole, name) scenes.addItem(item) scenes.setCurrentRow(sorted(SCENES).index(args.scene or 'sockets')) scenes.setObjectName('scenes') scenes.setMinimumHeight(150) lay.addWidget(scenes) lay.addWidget(QtWidgets.QLabel('Where to read models from')) sources = QtWidgets.QListWidget() sources.setObjectName('sources') sources.setMaximumHeight(90) for path in (args.jar or guess_sources()): sources.addItem(path) lay.addWidget(sources) hint = QtWidgets.QLabel("The repo's own resources are searched whether or not " "they are listed; a jar is needed for Armory's plates.") hint.setStyleSheet('color: #888;') hint.setWordWrap(True) lay.addWidget(hint) row = QtWidgets.QHBoxLayout() def add_jar(): path, _ = QtWidgets.QFileDialog.getOpenFileName( dialog, 'mod jar', os.path.expanduser('~'), 'Jars (*.jar)') if path: sources.addItem(path) def add_folder(): path = QtWidgets.QFileDialog.getExistingDirectory( dialog, 'resource folder', os.path.expanduser('~')) if path: sources.addItem(path) def drop(): for item in sources.selectedItems(): sources.takeItem(sources.row(item)) for label, slot in (('Add jar...', add_jar), ('Add folder...', add_folder), ('Remove', drop)): button = QtWidgets.QPushButton(label) button.clicked.connect(slot) row.addWidget(button) lay.addLayout(row) form = QtWidgets.QFormLayout() textures = QtWidgets.QCheckBox() textures.setObjectName('textures') textures.setChecked(not args.no_textures) form.addRow('textures', textures) wearer = QtWidgets.QCheckBox() wearer.setObjectName('wearer') wearer.setChecked(args.body) form.addRow('draw the wearer', wearer) variant = QtWidgets.QLineEdit(args.variant) variant.setObjectName('variant') form.addRow('material variant', variant) gem = QtWidgets.QComboBox() gem.setObjectName('gem') gem.addItems(['small', 'medium', 'large']) gem.setCurrentText(args.gem) form.addRow('gem size', gem) lay.addLayout(form) buttons = QtWidgets.QDialogButtonBox( QtWidgets.QDialogButtonBox.Open | QtWidgets.QDialogButtonBox.Cancel) buttons.accepted.connect(dialog.accept) buttons.rejected.connect(dialog.reject) scenes.itemDoubleClicked.connect(lambda _: dialog.accept()) lay.addWidget(buttons) dialog.resize(560, 560) if dialog.exec() != QtWidgets.QDialog.Accepted: return False args.scene = scenes.currentItem().data(QtCore.Qt.UserRole) args.jar = [sources.item(i).text() for i in range(sources.count())] args.no_textures = not textures.isChecked() args.body = wearer.isChecked() args.variant = variant.text().strip() or 'default' args.gem = gem.currentText() # The viewport opens its own window through a plain plotter. Letting this # QApplication linger would leave a second event loop owning the process. app.quit() return True # ------------------------------------------------------------------------ cli def main(argv=None): ap = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument('scene', nargs='?', choices=sorted(SCENES), help='which figure to draw; omit it to be asked') 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) if args.scene is None: if args.shot: ap.error('--shot renders without a window, so it needs a scene named') if not opening_dialog(args): return sources = list(args.jar) own = os.path.join(REPO, 'src/main/resources') if own not in sources: sources.append(own) 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__': # Run where pyvista is, which on most machines is the tools' own virtualenv # rather than the interpreter this was started with. Imported rather than # run - by ARMOUR_GUI, say - it is already somewhere that has the stack. guiplatform.ensure_stack() main()