899 lines
38 KiB
Python
Executable File
899 lines
38 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Open every armour model there is, and look at them one at a time.
|
|
|
|
The editors ask what you meant before they will draw anything - which part,
|
|
which scene, which jar. This asks nothing. It finds every armour model in the
|
|
sources, places each one on the body part its slot names, and lists them down
|
|
the left.
|
|
|
|
Only the first is ticked. Eight plates and four sockets drawn at once are a
|
|
solid lump with the interesting bits inside it, so the blanket load is about
|
|
not having to *find* a model, not about seeing them all at the same time - tick
|
|
along the list and each one appears where it will sit on the wearer.
|
|
|
|
tools/ARMOUR_QUICKSTART.py [--jar <armory.jar>]
|
|
|
|
Everything means everything: worn armour, inventory icons, sword parts, the
|
|
loose item models. The ones that name a body part are drawn on it; the rest
|
|
are drawn in item space, where they are modelled.
|
|
|
|
Your models come off disk, out of `src/main/resources` - the files you are
|
|
editing, not the copies inside a built jar - and they are listed first. A jar
|
|
on the command line brings three things with it: the slot transforms that place
|
|
a worn piece exactly, the textures a model of ours points at, and its own
|
|
models, listed after yours and marked with their namespace. That last one
|
|
matters for a socket: the geometry here is the socket alone, and the plate it
|
|
is cut into - `arm_left/heavy [tm_armory]` - is Armory's, so seeing the two
|
|
together needs the jar. `Reload` re-reads the tree, so a model saved in another
|
|
window shows up here without restarting.
|
|
|
|
The left panel is the selected model's own transform - the `translation` and
|
|
`rotation` a MIAPI module writes, in model pixels and in degrees about the
|
|
model's origin. Three arrows on that origin move it along an axis; the rotate
|
|
toggle swaps them for three rings. Dragging one and typing a number are the
|
|
same edit seen from two sides, so the panel always reads what the model is
|
|
doing, and `Copy as JSON` hands over the block to paste into the module.
|
|
|
|
`Write to source` is the one thing here that changes a file: it puts those
|
|
numbers back into the module entry they were read from, leaving its scale and
|
|
its origin alone. Placement lives in the module, so a model no module names -
|
|
a sword part, an icon of its own - has nowhere to write to and says so. When it
|
|
is the geometry itself that wants moving, ARMOUR_GUI edits the boxes and
|
|
ARMOUR_EDITOR poses the result in a scene.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
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 mcmodel as mc # noqa: E402
|
|
import ARMOUR_EDITOR as ae # noqa: E402
|
|
import ARMOUR_GUI as ag # noqa: E402
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
# A model's directory says which slot it dresses; the slot says which body part
|
|
# MIAPI draws it under, and that is what decides where it lands and what it
|
|
# swings with. Slots Armory has that we do not are here too, because a jar on
|
|
# the command line brings them along.
|
|
# When a model's module data does not say which body part it rides on, the
|
|
# folder it sits in is the next best thing: worn armour is filed by the Armory
|
|
# slot that dresses each part, and those names map onto the parts one to one.
|
|
SLOT_PARTS = {
|
|
'arm_left': 'left_arm', 'arm_right': 'right_arm',
|
|
'leg_left': 'left_leg', 'leg_right': 'right_leg',
|
|
'chest_front': 'body', 'chest_back': 'body',
|
|
'belt': 'body', 'cape': 'body',
|
|
'helmet': 'head', 'hat': 'head',
|
|
}
|
|
|
|
# Every model in the tree, whatever it dresses: `assets/<ns>/models/<rest>.json`.
|
|
# Armour, icons, sword parts, the loose item models - all of it, because a
|
|
# blanket load that quietly skips a folder is worse than no blanket at all.
|
|
MODEL_PATH = re.compile(r'^assets/(?P<ns>[^/]+)/models/(?P<rest>.+)\.json$')
|
|
|
|
|
|
class Entry:
|
|
"""One model: where it is read from, and where it belongs."""
|
|
|
|
def __init__(self, ns, rest, placement=None, loose=True):
|
|
self.ns, self.rest, self.loose = ns, rest, loose
|
|
self.ref = f'{ns}:models/{rest}.json'
|
|
# What the module data says beats what the directory is called: the
|
|
# `origin` is the part MIAPI draws the model under, and the folder is
|
|
# a filing convention that only usually agrees. Anything that names no
|
|
# part is drawn in item space, which is where icons and held pieces
|
|
# are modelled anyway.
|
|
origin, self.matrix, self.declared, self.source = \
|
|
placement or (None, np.eye(4), False, None)
|
|
# The transform is editable - that is what the handles in the viewport
|
|
# move - so the declared one is kept to go back to.
|
|
self.given = self.matrix.copy()
|
|
self.part = origin or _part_from_path(rest) or 'item'
|
|
name = re.sub(r'^item/', '', rest)
|
|
name = re.sub(r'/(\[material\.texture\]|[^/]+)$', '', name) if '/' in name else name
|
|
self.label = name + ('' if loose and ns == 'cmmodular' else f' [{ns}]')
|
|
|
|
def quads(self, res, variant):
|
|
return mc.model_quads(res.model(self.ref, variant), res)
|
|
|
|
def shapes(self, res, variant):
|
|
"""How many boxes the model has - what its unwrap was coloured against."""
|
|
return len(res.model(self.ref, variant).get('elements', []))
|
|
|
|
|
|
def _part_from_path(rest):
|
|
"""The body part a worn armour path implies, if it is one."""
|
|
m = re.match(r'^item/armor/model/([^/]+)/', rest)
|
|
return SLOT_PARTS.get(m.group(1)) if m else None
|
|
|
|
|
|
def placements(res):
|
|
"""Where each model rides, as the mod's own module data declares it.
|
|
|
|
A model file is boxes and nothing else; the part it is drawn under and any
|
|
offset it carries live in the MIAPI module that names it. Those are in this
|
|
tree - `packs/*/data/*/miapi/modules/**` - so the placement is read from
|
|
the same working copy as the geometry rather than guessed from a folder
|
|
name. Keyed by model path with the material placeholder left in, which is
|
|
the form modules write.
|
|
"""
|
|
out = {}
|
|
for path in res.paths('', '.json', loose=True):
|
|
if '/miapi/modules/' not in path:
|
|
continue
|
|
try:
|
|
doc = res.json(path)
|
|
except (KeyError, ValueError):
|
|
continue
|
|
for section in ('merge', 'replace'):
|
|
entries = doc.get('data', {}).get(section, {}).get('model') or []
|
|
entries = entries if isinstance(entries, list) else [entries]
|
|
for i, entry in enumerate(entries):
|
|
ref = isinstance(entry, dict) and entry.get('path')
|
|
if not ref:
|
|
continue
|
|
transform = entry.get('transform') or {}
|
|
key = _key(ref)
|
|
# A module can name the same model twice - once for the body
|
|
# part and once, origin-less, for the inventory icon. The one
|
|
# that names a part is the placement being looked at, so it
|
|
# wins; the icon entry only fills in when it is all there is.
|
|
if key in out and not transform.get('origin'):
|
|
continue
|
|
out[key] = (transform.get('origin'), mc.transform_matrix(transform),
|
|
True, (path, section, i))
|
|
return out
|
|
|
|
|
|
def _texture_key(doc):
|
|
"""The `#key` a model's faces are textured through, or the only one there is."""
|
|
for element in doc.get('elements') or []:
|
|
for face in (element.get('faces') or {}).values():
|
|
if isinstance(face, dict) and face.get('texture'):
|
|
return face['texture']
|
|
for name in doc.get('textures') or {}:
|
|
if name != 'particle':
|
|
return f'#{name}'
|
|
return '#0'
|
|
|
|
|
|
def _texture_path(ref):
|
|
"""Where a namespaced texture id lives, as a path into the resource tree."""
|
|
ns, _, rest = ref.partition(':')
|
|
if not _:
|
|
ns, rest = 'minecraft', ref
|
|
if rest.startswith('textures/'):
|
|
rest = rest[len('textures/'):]
|
|
return f'assets/{ns}/textures/{rest}.png'
|
|
|
|
|
|
def _round(value):
|
|
"""A number fit to be written down.
|
|
|
|
No float dust and no negative zero, and a whole number stays whole - a
|
|
module that said `0` should not come back saying `0.0` for the sake of a
|
|
drag that never touched that axis.
|
|
"""
|
|
value = round(float(value), 4) + 0.0
|
|
return int(value) if value == int(value) else value
|
|
|
|
|
|
def _key(ref):
|
|
"""A model reference cut back to its folder.
|
|
|
|
A module names `.../socket/[material.texture].json` and the file on disk is
|
|
`.../socket/default.json`; the folder is the part they agree on.
|
|
"""
|
|
return ref.rsplit('/', 1)[0]
|
|
|
|
|
|
def discover(res, variant='default'):
|
|
"""Every model in the sources, in a stable order.
|
|
|
|
Two kinds of file are picked up. Most models are filed one folder per
|
|
model with a file per material - `socket/default.json`, or the
|
|
`[material.texture].json` a module names - and those count once, drawn in
|
|
the variant asked for. The rest are plain item models with no variants at
|
|
all, and they count once too.
|
|
"""
|
|
# Files on disk are the ones being edited, so they are listed first and
|
|
# win any tie. A loaded jar is listed too, after them: a socket module is
|
|
# geometry cut into a plate that lives in Armory's jar, and a socket with
|
|
# nothing to sit on is half a picture.
|
|
on_disk = res.paths('assets/', '.json', loose=True)
|
|
in_jars = [p for p in res.paths('assets/', '.json') if p not in set(on_disk)]
|
|
variants = {p.rsplit('/', 1)[0] for p in on_disk + in_jars
|
|
if p.rsplit('/', 1)[1] in (f'{variant}.json', '[material.texture].json')}
|
|
|
|
declared, out = placements(res), []
|
|
for path in on_disk + in_jars:
|
|
m = MODEL_PATH.match(path)
|
|
if not m:
|
|
continue
|
|
folder, file = path.rsplit('/', 1)
|
|
if file in (f'{variant}.json', '[material.texture].json'):
|
|
key = _key(f"{m['ns']}:models/{m['rest']}")
|
|
elif folder in variants:
|
|
continue # another material of a model already listed once
|
|
else:
|
|
key = f"{m['ns']}:models/{m['rest']}"
|
|
out.append(Entry(m['ns'], m['rest'], declared.get(key), loose=path in on_disk))
|
|
|
|
# Worn armour first and in body order - head down to legs - then whatever
|
|
# is drawn in item space, so the first row is something on the wearer.
|
|
parts = ['head', 'body', 'left_arm', 'right_arm', 'left_leg', 'right_leg']
|
|
return sorted(out, key=lambda e: (e.ns != 'cmmodular', e.part == 'item',
|
|
parts.index(e.part) if e.part in parts else 9,
|
|
e.label))
|
|
|
|
|
|
# The conversion from a model's own space to a body part's. Armory's slot
|
|
# transforms carry it explicitly, as `"rotation": {"z": 180}`, and it is the
|
|
# whole of the conversion - so when the jar those transforms live in is not
|
|
# loaded, the flip is still the right thing to assume. What is lost with the
|
|
# jar is the offset alongside it, not the flip.
|
|
FLIP = mc.rot('z', 180)
|
|
|
|
|
|
def place(res, entry):
|
|
"""The matrix and pivot that put one model on the wearer.
|
|
|
|
Three things: the transform the module data gives the model, the transform
|
|
of the Armory slot the piece sits in - when its jar is loaded - and the
|
|
pivot of the body part the model named as its origin.
|
|
"""
|
|
return slot_matrix(res, entry.part) @ entry.matrix, \
|
|
mc.PIVOTS.get(entry.part, (0.0, 0.0, 0.0))
|
|
|
|
|
|
def slot_matrix(res, part):
|
|
"""Armory's transform for the slot that dresses a part, or the flip alone.
|
|
|
|
The icon pass gets neither: it is drawn in item space, where there is no
|
|
body part to be converted onto.
|
|
"""
|
|
spec = ag.PARTS.get(part)
|
|
if part == 'item' or spec is None or spec['piece'] is None:
|
|
return np.eye(4)
|
|
try:
|
|
piece = json.loads(res.read(ag.PIECE_FILES[spec['piece']]))['slots']
|
|
return mc.transform_matrix(piece[spec['slot']]['transform'])
|
|
except (KeyError, TypeError, ValueError):
|
|
return FLIP
|
|
|
|
|
|
class Quickstart(QtWidgets.QMainWindow):
|
|
def __init__(self, res, entries, args):
|
|
super().__init__()
|
|
self.res, self.entries, self.args = res, entries, args
|
|
self.actors = {}
|
|
# Before the panel is built: selecting its first row asks to aim the
|
|
# camera, and there is no camera to aim until the window is up.
|
|
self._realised = False
|
|
self._toggled = None
|
|
self._loading = False
|
|
self.gizmo = None
|
|
self.setWindowTitle('armour models')
|
|
|
|
splitter = QtWidgets.QSplitter()
|
|
splitter.addWidget(self._transform_panel())
|
|
splitter.addWidget(self._panel())
|
|
self.view = guiplatform.viewport(self)
|
|
splitter.addWidget(self.view)
|
|
splitter.setSizes([220, 250, 1000])
|
|
self.setCentralWidget(splitter)
|
|
|
|
self.view.set_background(args.background)
|
|
self._add_lights()
|
|
self._say_selected()
|
|
|
|
# --------------------------------------------------------- transform
|
|
|
|
def _transform_panel(self):
|
|
"""The numbers the handles in the viewport move, and the other way round.
|
|
|
|
These are a MIAPI module transform, in the units a module writes:
|
|
translation in model pixels, rotation in degrees about the model's own
|
|
origin. So what this panel reads is what goes in the JSON - the point
|
|
of dragging a model into place is the number you are left holding.
|
|
"""
|
|
box = QtWidgets.QWidget()
|
|
lay = QtWidgets.QVBoxLayout(box)
|
|
|
|
self.transform_label = QtWidgets.QLabel('transform')
|
|
self.transform_label.setWordWrap(True)
|
|
lay.addWidget(self.transform_label)
|
|
|
|
# Move or rotate, never both: the rings and the arrows sit in the same
|
|
# place on screen, and a drag that grabs the wrong one is a model
|
|
# somewhere unexpected with no way back but the numbers.
|
|
row = QtWidgets.QHBoxLayout()
|
|
self.mode = QtWidgets.QButtonGroup(box)
|
|
for i, label in enumerate(('move', 'rotate')):
|
|
button = QtWidgets.QRadioButton(label)
|
|
button.setChecked(i == 0)
|
|
self.mode.addButton(button, i)
|
|
row.addWidget(button)
|
|
self.mode.idToggled.connect(lambda _i, on: on and self._show_handles())
|
|
lay.addLayout(row)
|
|
|
|
self.spins = {}
|
|
for group, label, limit, step in (('translation', 'position', 64.0, 0.25),
|
|
('rotation', 'rotation', 360.0, 5.0)):
|
|
lay.addWidget(QtWidgets.QLabel(label))
|
|
form = QtWidgets.QFormLayout()
|
|
for axis in 'xyz':
|
|
spin = QtWidgets.QDoubleSpinBox()
|
|
spin.setRange(-limit, limit)
|
|
spin.setSingleStep(step)
|
|
spin.setDecimals(4)
|
|
spin.valueChanged.connect(self._typed)
|
|
self.spins[(group, axis)] = spin
|
|
form.addRow(axis, spin)
|
|
lay.addLayout(form)
|
|
|
|
for label, slot in (('Write to source', self.write_to_source),
|
|
('Reset to declared', self.reset_transform),
|
|
('Copy as JSON', self.copy_transform),
|
|
('Unwrap UVs', self.unwrap_uvs)):
|
|
button = QtWidgets.QPushButton(label)
|
|
button.clicked.connect(slot)
|
|
lay.addWidget(button)
|
|
|
|
note = QtWidgets.QLabel('drag a handle in the viewport, or type. Only '
|
|
'`Write to source` touches the tree')
|
|
note.setWordWrap(True)
|
|
note.setStyleSheet('color: #888;')
|
|
lay.addWidget(note)
|
|
lay.addStretch(1)
|
|
return box
|
|
|
|
def _transform_of(self, entry):
|
|
"""The selected model's transform as MIAPI would write it down."""
|
|
return mc.decompose(entry.matrix if entry is not None else np.eye(4))
|
|
|
|
def _sync_transform(self):
|
|
"""Put the selected model's numbers in the boxes."""
|
|
entry = self._current()
|
|
self.transform_label.setText(
|
|
f'transform - {entry.label}' if entry else 'transform')
|
|
written = self._transform_of(entry)
|
|
self._loading = True
|
|
for (group, axis), spin in self.spins.items():
|
|
spin.setEnabled(entry is not None)
|
|
spin.setValue(round(float(written[group][axis]), 4))
|
|
self._loading = False
|
|
|
|
def _typed(self):
|
|
"""A number was edited: rebuild the model where it now says it goes."""
|
|
entry = self._current()
|
|
if self._loading or entry is None:
|
|
return
|
|
entry.matrix = mc.transform_matrix(
|
|
{group: {axis: self.spins[(group, axis)].value() for axis in 'xyz'}
|
|
for group in ('translation', 'rotation')})
|
|
self.rebuild()
|
|
|
|
def reset_transform(self):
|
|
"""Back to what the module data declares - the state it opened in."""
|
|
entry = self._current()
|
|
if entry is None:
|
|
return
|
|
entry.matrix = entry.given.copy()
|
|
self._sync_transform()
|
|
self.rebuild()
|
|
|
|
def write_to_source(self):
|
|
"""Put the panel's numbers back into the module that declares the model.
|
|
|
|
Placement lives in the module rather than in the geometry, so that is
|
|
what is written: the `translation` and `rotation` of the model entry
|
|
the tool read this placement from, with its scale and its origin left
|
|
as the module had them. A model no module names has nowhere to write
|
|
to - its geometry is its position - and says so rather than guessing.
|
|
|
|
This is the one thing here that changes a file.
|
|
"""
|
|
entry = self._current()
|
|
if entry is None:
|
|
return
|
|
if entry.source is None or not entry.loose:
|
|
self.statusBar().showMessage(
|
|
f'{entry.label}: no module in this tree names it, so there is no'
|
|
' transform to write - move the boxes in ARMOUR_GUI instead')
|
|
return
|
|
|
|
path, section, index = entry.source
|
|
full = self._on_disk(path)
|
|
if full is None:
|
|
self.statusBar().showMessage(f'{path}: not in a directory to write to')
|
|
return
|
|
with open(full) as fh:
|
|
doc = json.load(fh)
|
|
model = doc['data'][section]['model']
|
|
target = (model[index] if isinstance(model, list) else model)
|
|
transform = target.setdefault('transform', {})
|
|
written = mc.decompose(entry.matrix)
|
|
for group in ('translation', 'rotation'):
|
|
transform[group] = {a: _round(written[group][a]) for a in 'xyz'}
|
|
with open(full, 'w') as fh:
|
|
fh.write(ae._dumps(doc))
|
|
fh.write('\n')
|
|
|
|
# What the file says is what is declared now, so this is the state
|
|
# `Reset to declared` should come back to.
|
|
entry.given = entry.matrix.copy()
|
|
entry.declared = True
|
|
self.statusBar().showMessage(f'wrote the transform to {path}')
|
|
|
|
def unwrap_uvs(self, _checked=False):
|
|
"""Re-cut the selected model's texture so every face has its own patch.
|
|
|
|
The same unwrap ARMOUR_GUI does when it saves, on a model that is
|
|
already drawn rather than one being built: every face gets a rectangle
|
|
of its own, the atlas size is written back beside the boxes, and a
|
|
template is painted to match - but only where there is no texture yet.
|
|
A guide is scaffolding, and overwriting art someone has painted because
|
|
the boxes moved would be the tool destroying the work it exists for.
|
|
"""
|
|
entry = self._current()
|
|
if entry is None:
|
|
return
|
|
model_path = f'assets/{entry.ns}/models/{entry.rest}.json'
|
|
full = self._on_disk(model_path) if entry.loose else None
|
|
if full is None:
|
|
self.statusBar().showMessage(f'{entry.label}: not a file in this tree')
|
|
return
|
|
with open(full) as fh:
|
|
doc = json.load(fh)
|
|
if not doc.get('elements'):
|
|
self.statusBar().showMessage(
|
|
f'{entry.label}: a sprite, not boxes - there is nothing to unwrap')
|
|
return
|
|
|
|
# Unwrap onto the key the model's own faces already name, so a model
|
|
# textured through `#1` does not come back pointing at `#0`.
|
|
key = _texture_key(doc)
|
|
size, nets = mc.unwrap(doc['elements'], texture=key)
|
|
doc['texture_size'] = [int(size[0]), int(size[1])]
|
|
with open(full, 'w') as fh:
|
|
fh.write(ae._dumps(doc))
|
|
fh.write('\n')
|
|
written = [model_path]
|
|
|
|
ref = (doc.get('textures') or {}).get(key.lstrip('#'))
|
|
template = _texture_path(ref) if ref else None
|
|
if template and self._on_disk(template) is None:
|
|
beside = os.path.join(full[:-len(model_path)], template)
|
|
os.makedirs(os.path.dirname(beside), exist_ok=True)
|
|
mc.unwrap_template(size, nets, doc['elements']).save(beside)
|
|
written.append(template)
|
|
|
|
self.rebuild()
|
|
self.statusBar().showMessage(
|
|
f'unwrapped onto {int(size[0])}x{int(size[1])} - wrote '
|
|
+ ', '.join(written))
|
|
|
|
def _on_disk(self, path):
|
|
"""The writable file behind a resource path, if one of the sources has it."""
|
|
for directory in self.res.dirs:
|
|
full = os.path.join(directory, path)
|
|
if os.path.isfile(full):
|
|
return full
|
|
return None
|
|
|
|
def copy_transform(self):
|
|
"""The transform block, ready to paste into the module that names it."""
|
|
entry = self._current()
|
|
if entry is None:
|
|
return
|
|
written = {k: {a: round(float(v[a]), 4) for a in 'xyz'}
|
|
for k, v in self._transform_of(entry).items()}
|
|
written['origin'] = entry.part
|
|
text = json.dumps({'transform': written}, indent=2)
|
|
QtWidgets.QApplication.clipboard().setText(text)
|
|
self.statusBar().showMessage(f'copied the transform for {entry.label}')
|
|
|
|
# ---------------------------------------------------------- handles
|
|
|
|
def _place_gizmo(self):
|
|
"""Put the move and rotate handles on the selected model.
|
|
|
|
The widget hangs off one actor but a model can be several - one per
|
|
texture - so a drag is mirrored onto the rest of them, and folded into
|
|
the model's own transform when the mouse comes up.
|
|
"""
|
|
self._drop_gizmo()
|
|
entry = self._current()
|
|
actors = self._actors_of(entry)
|
|
if not actors or not self._realised:
|
|
return
|
|
matrix, pivot = place(self.res, entry)
|
|
# The handles sit on the model's own origin rather than the middle of
|
|
# its geometry, because that is the point MIAPI rotates a model about
|
|
# and the point the numbers are measured from.
|
|
origin = tuple(np.asarray(matrix)[:3, 3] + np.asarray(pivot, float))
|
|
self.gizmo = self.view.add_affine_transform_widget(
|
|
actors[0], origin=origin, scale=0.35,
|
|
interact_callback=self._dragging, release_callback=self._dropped)
|
|
self._show_handles()
|
|
|
|
def _drop_gizmo(self):
|
|
if self.gizmo is not None:
|
|
try:
|
|
self.gizmo.remove()
|
|
except (AttributeError, RuntimeError):
|
|
pass
|
|
self.gizmo = None
|
|
|
|
def _show_handles(self):
|
|
"""Arrows for moving, rings for rotating - one set at a time."""
|
|
if self.gizmo is None:
|
|
return
|
|
rotating = self.mode.checkedId() == 1
|
|
for actor in self.gizmo._arrows:
|
|
actor.SetVisibility(not rotating)
|
|
for actor in self.gizmo._circles:
|
|
actor.SetVisibility(rotating)
|
|
self.view.render()
|
|
|
|
def _actors_of(self, entry):
|
|
"""Every actor drawing one model, in the order they were added."""
|
|
if entry is None:
|
|
return []
|
|
try:
|
|
i = self._visible().index(entry)
|
|
except ValueError:
|
|
return []
|
|
return [a for name, a in self.actors.items() if name.startswith(f'model{i}_')]
|
|
|
|
def _dragging(self, user_matrix):
|
|
"""Mid-drag: move the model's other actors with the one being dragged."""
|
|
entry = self._current()
|
|
for actor in self._actors_of(entry)[1:]:
|
|
actor.user_matrix = user_matrix
|
|
self._show_typed(self._folded(entry, user_matrix))
|
|
|
|
def _dropped(self, user_matrix):
|
|
"""Mouse up: fold the drag into the transform and redraw from it."""
|
|
entry = self._current()
|
|
if entry is None:
|
|
return
|
|
entry.matrix = self._folded(entry, user_matrix)
|
|
for actor in self._actors_of(entry):
|
|
actor.user_matrix = np.eye(4)
|
|
self._sync_transform()
|
|
self.rebuild()
|
|
|
|
def _folded(self, entry, user_matrix):
|
|
"""A world-space drag, expressed as the model's own transform.
|
|
|
|
The widget moves the actor where it stands, and where it stands is the
|
|
model already carried onto the wearer - part pivot, slot flip and all.
|
|
Undoing those two leaves the drag in the space the module writes in.
|
|
"""
|
|
if entry is None:
|
|
return np.eye(4)
|
|
onto = slot_matrix(self.res, entry.part)
|
|
pivot = np.eye(4)
|
|
pivot[:3, 3] = mc.PIVOTS.get(entry.part, (0.0, 0.0, 0.0))
|
|
drag = np.linalg.inv(onto) @ np.linalg.inv(pivot) @ np.asarray(user_matrix) \
|
|
@ pivot @ onto
|
|
return drag @ entry.matrix
|
|
|
|
def _show_typed(self, matrix):
|
|
"""Live numbers during a drag, without redrawing the scene."""
|
|
written = mc.decompose(matrix)
|
|
self._loading = True
|
|
for (group, axis), spin in self.spins.items():
|
|
spin.setValue(round(float(written[group][axis]), 4))
|
|
self._loading = False
|
|
|
|
# ------------------------------------------------------------- panel
|
|
|
|
def _panel(self):
|
|
box = QtWidgets.QWidget()
|
|
lay = QtWidgets.QVBoxLayout(box)
|
|
self.count_label = QtWidgets.QLabel()
|
|
lay.addWidget(self.count_label)
|
|
|
|
# A click anywhere on a row toggles it, checkbox or label, because a
|
|
# list of things to show is read as a list of switches and half of a
|
|
# switch is worse than none: a label click that only ever *added* left
|
|
# no way to take anything off the screen except a four-pixel target.
|
|
# Arrow keys move the selection without toggling, for looking through
|
|
# the list without changing what is drawn.
|
|
self.list = QtWidgets.QListWidget()
|
|
for i, entry in enumerate(self.entries):
|
|
item = QtWidgets.QListWidgetItem(entry.label)
|
|
item.setData(QtCore.Qt.UserRole, i)
|
|
item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable)
|
|
first = i == 0 or self.args.all
|
|
item.setCheckState(QtCore.Qt.Checked if first else QtCore.Qt.Unchecked)
|
|
self.list.addItem(item)
|
|
self.list.itemChanged.connect(self._ticked)
|
|
self.list.itemClicked.connect(self._clicked)
|
|
self.list.currentItemChanged.connect(self._selected)
|
|
self._count()
|
|
self.list.setCurrentRow(0)
|
|
lay.addWidget(self.list, 1)
|
|
|
|
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)
|
|
|
|
self.textured = QtWidgets.QCheckBox('textures')
|
|
self.textured.setChecked(not self.args.no_textures)
|
|
self.textured.toggled.connect(lambda _: self.rebuild())
|
|
lay.addWidget(self.textured)
|
|
|
|
self.wearer = QtWidgets.QCheckBox('the wearer')
|
|
self.wearer.setChecked(self.args.body)
|
|
self.wearer.toggled.connect(lambda _: self.rebuild())
|
|
lay.addWidget(self.wearer)
|
|
|
|
for label, slot in (('Aim at selection', lambda: self._aim(self._current())),
|
|
('Reload from disk', self.reload)):
|
|
button = QtWidgets.QPushButton(label)
|
|
button.clicked.connect(slot)
|
|
lay.addWidget(button)
|
|
return box
|
|
|
|
def reload(self):
|
|
"""Read the tree again, keeping whatever is ticked that still exists."""
|
|
ticked = {e.ref for e in self._visible()}
|
|
selected = self._current()
|
|
self.entries = discover(self.res, self.args.variant)
|
|
self.list.blockSignals(True)
|
|
self.list.clear()
|
|
for i, entry in enumerate(self.entries):
|
|
item = QtWidgets.QListWidgetItem(entry.label)
|
|
item.setData(QtCore.Qt.UserRole, i)
|
|
item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable)
|
|
keep = entry.ref in ticked or (not ticked and i == 0)
|
|
item.setCheckState(QtCore.Qt.Checked if keep else QtCore.Qt.Unchecked)
|
|
self.list.addItem(item)
|
|
if selected is not None and entry.ref == selected.ref:
|
|
self.list.setCurrentRow(i)
|
|
self.list.blockSignals(False)
|
|
self._count()
|
|
self._sync_transform()
|
|
self.rebuild()
|
|
self.statusBar().showMessage(f'reloaded - {len(self.entries)} models')
|
|
|
|
def _count(self):
|
|
self.count_label.setText(f'{len(self.entries)} armour models')
|
|
|
|
def _rows(self):
|
|
for i in range(self.list.count()):
|
|
yield self.list.item(i)
|
|
|
|
def _current(self):
|
|
item = self.list.currentItem()
|
|
return None if item is None else self.entries[item.data(QtCore.Qt.UserRole)]
|
|
|
|
def _visible(self):
|
|
return [self.entries[item.data(QtCore.Qt.UserRole)] for item in self._rows()
|
|
if item.checkState() == QtCore.Qt.Checked]
|
|
|
|
def _set_all(self, state):
|
|
self.list.blockSignals(True)
|
|
for item in self._rows():
|
|
item.setCheckState(state)
|
|
self.list.blockSignals(False)
|
|
self.rebuild()
|
|
|
|
def _clicked(self, item):
|
|
"""A click on the label toggles the row, the way one on the box does.
|
|
|
|
Qt has already toggled the row when the click landed on the checkbox
|
|
itself, and `_ticked` leaves word that it did - so the two paths do not
|
|
cancel each other out and clicking a box stays one toggle, not two.
|
|
"""
|
|
by_box, self._toggled = self._toggled, None
|
|
if by_box is item:
|
|
return
|
|
item.setCheckState(QtCore.Qt.Unchecked
|
|
if item.checkState() == QtCore.Qt.Checked
|
|
else QtCore.Qt.Checked)
|
|
self._toggled = None
|
|
|
|
def _ticked(self, item):
|
|
"""A tick draws a model and looks at it. An untick only takes it away."""
|
|
self._toggled = item
|
|
self.rebuild()
|
|
if item.checkState() == QtCore.Qt.Checked:
|
|
self.list.setCurrentItem(item)
|
|
self._aim(self.entries[item.data(QtCore.Qt.UserRole)])
|
|
self._say_selected()
|
|
|
|
def _selected(self, item, _previous=None):
|
|
"""Selecting a row looks at it, and hands it to the transform panel."""
|
|
if item is None:
|
|
return
|
|
self._sync_transform()
|
|
self._place_gizmo()
|
|
self._aim(self._current())
|
|
self._say_selected()
|
|
|
|
def _say_selected(self):
|
|
entry = self._current()
|
|
if entry is None:
|
|
return
|
|
hidden = '' if entry.ref in {e.ref for e in self._visible()} else ' (hidden)'
|
|
self.statusBar().showMessage(f'{entry.ref} under {entry.part}{hidden}')
|
|
|
|
# ----------------------------------------------------------- drawing
|
|
|
|
def showEvent(self, event):
|
|
"""First draw waits for the window, as it does in the other tools."""
|
|
super().showEvent(event)
|
|
if not self._realised:
|
|
self._realised = True
|
|
QtCore.QTimer.singleShot(0, self._first_draw)
|
|
|
|
def _first_draw(self):
|
|
# Depth peeling needs a GL context, so it waits for one too.
|
|
self.view.enable_depth_peeling(number_of_peels=8, occlusion_ratio=0.0)
|
|
self._sync_transform()
|
|
self.rebuild()
|
|
self._aim(self._current())
|
|
|
|
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)
|
|
|
|
def rebuild(self):
|
|
for name in list(self.actors):
|
|
self.view.remove_actor(self.actors.pop(name))
|
|
textured = self.textured.isChecked()
|
|
|
|
for i, entry in enumerate(self._visible()):
|
|
matrix, pivot = place(self.res, entry)
|
|
try:
|
|
quads = [q.transformed(matrix, pivot)
|
|
for q in entry.quads(self.res, self.args.variant)]
|
|
except (KeyError, ValueError) as exc:
|
|
# A model that names a texture or a parent no loaded jar has.
|
|
# Worth saying rather than drawing a hole, and worth carrying
|
|
# on from: the rest of the list is still readable.
|
|
print(f'{entry.ref}: {exc}', file=sys.stderr)
|
|
continue
|
|
# Untextured, a face is coloured the way its unwrap template is:
|
|
# hue per box, saturation and brightness per side. That is what
|
|
# makes the viewport a legend for the texture sheet - the green top
|
|
# of box two in here is the green top of box two on the sheet. The
|
|
# palette is only there for a model whose faces were all culled.
|
|
colour = ae.PALETTE[i % len(ae.PALETTE)]
|
|
count = entry.shapes(self.res, self.args.variant)
|
|
for j, (mesh, tex, face) in enumerate(
|
|
ae.build_meshes(self.res, quads, count, by_colour=not textured)):
|
|
kw = dict(smooth_shading=False, ambient=0.42, diffuse=0.78,
|
|
specular=0.0)
|
|
if textured and tex is not None:
|
|
try:
|
|
kw['texture'] = ae.make_texture(self.res, tex)
|
|
kw['color'] = 'white'
|
|
except KeyError:
|
|
# A texture no source has. The shape is still worth
|
|
# seeing, so it falls back to a colour rather than
|
|
# taking the model out of the scene.
|
|
kw['color'] = face or colour
|
|
else:
|
|
kw['color'] = face or colour
|
|
name = f'model{i}_{j}'
|
|
self.actors[name] = self.view.add_mesh(mesh, name=name, **kw)
|
|
|
|
if self.wearer.isChecked():
|
|
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)
|
|
|
|
# The handles hang off actors that have just been replaced, so they
|
|
# are put back on the new ones rather than left pointing at the old.
|
|
self._place_gizmo()
|
|
|
|
if self._realised:
|
|
self.view.render()
|
|
|
|
# From the front, from the wearer's right, and a little above - which is
|
|
# -z, -x and -y, because model space has +y going down.
|
|
_EYE = np.array([-0.52, -0.19, -0.83])
|
|
|
|
def _aim(self, entry):
|
|
"""Frame one model: where it sits, and far enough back to see all of it.
|
|
|
|
Distance comes from the model rather than a constant, because these
|
|
range from a 2px socket to a whole chestplate and a camera pinned 26
|
|
pixels out puts you inside the big ones.
|
|
"""
|
|
if entry is None or not self._realised:
|
|
return
|
|
matrix, pivot = place(self.res, entry)
|
|
try:
|
|
quads = [q.transformed(matrix, pivot)
|
|
for q in entry.quads(self.res, self.args.variant)]
|
|
except (KeyError, ValueError):
|
|
quads = []
|
|
if quads:
|
|
pts = np.concatenate([q.pts for q in quads])
|
|
lo, hi = pts.min(axis=0), pts.max(axis=0)
|
|
centre, span = (lo + hi) / 2.0, float(np.linalg.norm(hi - lo))
|
|
else:
|
|
centre = np.array([pivot[0], pivot[1] + 2.0, 0.0])
|
|
span = 8.0
|
|
self.view.camera.focal_point = tuple(centre)
|
|
self.view.camera.position = tuple(centre + self._EYE * (span * 1.8 + 10.0))
|
|
self.view.camera.up = (0.0, -1.0, 0.0)
|
|
self.view.camera.view_angle = 34.0
|
|
self.view.camera_set = True
|
|
self.view.render()
|
|
|
|
|
|
def main(argv=None):
|
|
ap = argparse.ArgumentParser(
|
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument('--jar', action='append', default=[], metavar='PATH',
|
|
help='mod jar or resource directory; repeatable, searched in order')
|
|
ap.add_argument('--variant', default='default', help='material texture variant')
|
|
ap.add_argument('--all', action='store_true',
|
|
help='start with every model visible instead of just the first')
|
|
ap.add_argument('--body', action='store_true', help='draw the wearer as a reference')
|
|
ap.add_argument('--background', default='#1a1a1e')
|
|
ap.add_argument('--no-textures', 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)
|
|
|
|
entries = discover(res, args.variant)
|
|
if not entries:
|
|
raise SystemExit('no armour models found - is `--jar` pointing at a mod jar?')
|
|
mine = sum(1 for e in entries if e.loose)
|
|
borrowed = len(entries) - mine
|
|
print(f'models: {mine} read from src/main/resources'
|
|
+ (f', {borrowed} more out of the jars' if borrowed else ''),
|
|
file=sys.stderr)
|
|
if not args.jar:
|
|
print('models: no jar, so a worn model is flipped onto the part its module'
|
|
" names but not offset - Armory's slot transforms carry the offset,"
|
|
' and they are in its jar', file=sys.stderr)
|
|
|
|
app = QtWidgets.QApplication(sys.argv[:1])
|
|
window = Quickstart(res, entries, args)
|
|
window.resize(1420, 820)
|
|
window.show()
|
|
return app.exec()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|