create-mekanism-modular/tools/ARMOUR_QUICKSTART.py

936 lines
40 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 module file. It writes
every model that has moved, not only the one in front of you - a row that has
been moved and not yet written is marked with a `*`, so what is pending is on
the list rather than in your memory - putting each model's numbers back into
the module entry they were read from, and 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 is named in the report
instead. 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._mark_moved()
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._mark_moved()
self.rebuild()
def moved(self):
"""Every model whose transform is no longer what its module declares."""
return [e for e in self.entries if not np.allclose(e.matrix, e.given)]
def write_to_source(self):
"""Put the moved models' numbers back into the modules that declare them.
Everything that has moved, not just the row in front of you: a session
is spent nudging several things into place, and a button that saved one
of them would leave the rest to be noticed later or lost. Placement
lives in the module, so what is written is the `translation` and the
`rotation` of the model entry each placement was read 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 is named in the report rather than passed over.
This is the one thing here that changes a module file.
"""
moved = self.moved()
if not moved:
self.statusBar().showMessage('nothing has moved - nothing to write')
return
written, refused = [], []
for entry in moved:
if entry.source is None or not entry.loose:
refused.append(entry.label)
continue
path, section, index = entry.source
full = self._on_disk(path)
if full is None:
refused.append(entry.label)
continue
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', {})
numbers = mc.decompose(entry.matrix)
for group in ('translation', 'rotation'):
transform[group] = {a: _round(numbers[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` comes back to and the row stops being marked.
entry.given = entry.matrix.copy()
entry.declared = True
written.append(os.path.basename(path))
self._mark_moved()
said = f'wrote {len(written)}: ' + ', '.join(written) if written else ''
if refused:
said += ('; ' if said else '') + 'no module names ' + ', '.join(refused)
self.statusBar().showMessage(said)
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._mark_moved()
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 _mark_moved(self):
"""Mark the rows whose model has been moved but not yet written.
Editing the text is what makes a row say it; doing that quietly is
what keeps it from being read back as a tick and redrawing the scene
on every drag.
"""
moved = {id(e) for e in self.moved()}
self.list.blockSignals(True)
for item in self._rows():
entry = self.entries[item.data(QtCore.Qt.UserRole)]
item.setText(entry.label + (' *' if id(entry) in moved else ''))
self.list.blockSignals(False)
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, every model gets a hue of its own and every side of
# a box its own saturation and brightness - so the colour says two
# things at once, which model this is and which way the face
# points, and opposite sides never read alike. The hue is indexed
# on the model's place in the whole list rather than on what is
# visible, or hiding one model would repaint the others.
colour = ae.PALETTE[i % len(ae.PALETTE)]
hue = mc.object_hue(self.entries.index(entry))
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, hue=hue)):
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())