create-mekanism-modular/tools/ARMOUR_EDITOR.py

969 lines
39 KiB
Python

#!/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 <armory.jar> # the editable scene
tools/ARMOUR_EDITOR.py sockets --jar <j> --shot out.png
Two things it does not simplify away, because both change the answer:
*Depth.* Faces are rasterised by VTK against a z-buffer rather than sorted and
painted, so a gem half-sunk into a plate reads as half-sunk rather than as
whichever of the two happened to sort in front.
*MIAPI's arithmetic.* Transforms are kept one per `origin` and merged only
within an entry, which is why a gem whose slot says `body` never picks up the
`left_arm` transform above it. Merging itself is an exact matrix multiply in
1.21; `--lossy-merge` reproduces the Euler round trip older versions did, which
drops shear wherever a rotation meets a non-uniform scale.
Everything is in model pixels: +x is the wearer's left, +y is down, -z is
forward, and the origin is the base of the neck.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import re
import sys
from dataclasses import dataclass, field
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import 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, hue=None):
"""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.
`hue` hands the whole model a colour of its own instead, for a scene of
several models where the question is which model a face belongs to rather
than which box.
"""
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, hue) 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('<b>What to draw</b>'))
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('<b>Where to read models from</b>'))
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()