diff --git a/README.md b/README.md index d667e87..e014952 100644 --- a/README.md +++ b/README.md @@ -425,6 +425,53 @@ Requires `pillow` and `numpy`. Module JSON and textures are not generated. A run only clears the material folders, so the hand-written packs survive it. +## Looking at the armour + +```sh +python3 tools/ARMOUR_QUICKSTART.py # every armour model, one at a time +python3 tools/ARMOUR_GUI.py --jar # draw and unwrap socket geometry +python3 tools/ARMOUR_EDITOR.py --jar # pose a scene and nudge placements +``` + +The quickstart is the way in and needs no arguments: it reads every model in +`src/main/resources` - worn armour, icons, sword parts, the loose item models, +the files in the tree rather than a built jar - and lists them with the first +one showing. A jar passed with `--jar` adds its models to the list too, after +yours and marked with their namespace, which is the only way to see a socket +against the plate it is cut into: the geometry in this repo is the socket, and +`arm_left/heavy` is Armory's. Click a row to show or hide it and to point the camera at it; +`Reload from disk` picks up a model saved in another window. + +The left panel is the selected model's MIAPI transform. Three arrows on the +model's origin move it along an axis and the rotate toggle swaps them for three +rings; the position and rotation boxes read what the drag did, in the units a +module writes, and take typed numbers back. `Copy as JSON` puts the `transform` +block on the clipboard - the tool writes nothing itself, so the number is what +you leave with. + +Where a model goes comes from the mod's own module data: MIAPI draws a model +under the body part its `origin` names, and those are declared in +`packs/*/data/*/miapi/modules/`. What is *not* in this tree is the offset +Armory's slot transforms carry, so without `--jar` a worn model is flipped onto +its part but not moved along it - right limb, roughly right place. Pass the +Armory jar for exact placement, and for its own plates to line new geometry up +against. + +These need pyvista and pyvistaqt, which hardly any distribution packages, so +the tools keep a virtualenv at `tools/.venv` and re-run themselves inside it. +Build it once with + +```sh +python3 -m venv --system-site-packages tools/.venv +tools/.venv/bin/pip install pyvista pyvistaqt +``` + +`--system-site-packages` is what keeps the distribution's Qt in charge; a +PySide6 from pip alongside the system Qt is a partial upgrade waiting to +happen. On a Wayland session the viewport runs through XWayland, because VTK's +Python wheels have no Wayland window backend - the tools say which display they +picked, and why, when they start. + ## Notes - Titanium's palette comes from Cosmonautics' ingot, which is violet. Air War's diff --git a/tools/ARMOUR_EDITOR.py b/tools/ARMOUR_EDITOR.py index 73f8db1..94cae49 100644 --- a/tools/ARMOUR_EDITOR.py +++ b/tools/ARMOUR_EDITOR.py @@ -42,6 +42,7 @@ 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__))) @@ -956,4 +957,8 @@ def main(argv=None): 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() diff --git a/tools/ARMOUR_GUI.py b/tools/ARMOUR_GUI.py index 65f4e6f..23bc42f 100644 --- a/tools/ARMOUR_GUI.py +++ b/tools/ARMOUR_GUI.py @@ -33,7 +33,6 @@ guiplatform.configure(prefer=os.environ.get('ARMOUR_GUI_PLATFORM')) import numpy as np # noqa: E402 from PySide6 import QtCore, QtWidgets # noqa: E402 -import pyvistaqt # noqa: E402 import mcmodel as mc # noqa: E402 import ARMOUR_EDITOR as ae # noqa: E402 @@ -166,7 +165,7 @@ class ArmourGui(QtWidgets.QMainWindow): splitter = QtWidgets.QSplitter() splitter.addWidget(self._left_panel()) - self.view = pyvistaqt.QtInteractor(self, rw=guiplatform.render_window()) + self.view = guiplatform.viewport(self) splitter.addWidget(self.view) splitter.addWidget(self._right_panel()) splitter.setSizes([230, 900, 250]) @@ -180,8 +179,8 @@ class ArmourGui(QtWidgets.QMainWindow): def showEvent(self, event): """First draw waits for the window. - A QOpenGLWidget has no GL context until it is on screen, and VTK asked - to render before that goes looking for one of its own - which on a + The viewport has no GL context until it is on screen, and VTK asked to + render before that goes looking for a context of its own - which on a Wayland session means a GLX context that cannot be made current. """ super().showEvent(event) diff --git a/tools/ARMOUR_QUICKSTART.py b/tools/ARMOUR_QUICKSTART.py new file mode 100755 index 0000000..6591946 --- /dev/null +++ b/tools/ARMOUR_QUICKSTART.py @@ -0,0 +1,752 @@ +#!/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 ] + +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. + +Nothing here writes: what you leave with is the number. When a model turns out +to be the one you want to change, ARMOUR_GUI edits its geometry and +ARMOUR_EDITOR poses it 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//models/.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[^/]+)/models/(?P.+)\.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 = placement or (None, np.eye(4), False) + # 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 [] + for entry in entries if isinstance(entries, list) else []: + ref = entry.get('path') + if not ref: + continue + transform = entry.get('transform') or {} + out[_key(ref)] = (transform.get('origin'), + mc.transform_matrix(transform), True) + return out + + +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 (('Reset to declared', self.reset_transform), + ('Copy as JSON', self.copy_transform)): + button = QtWidgets.QPushButton(label) + button.clicked.connect(slot) + lay.addWidget(button) + + note = QtWidgets.QLabel('drag a handle in the viewport, or type; nothing ' + 'here is written to disk') + 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 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()) diff --git a/tools/guiplatform.py b/tools/guiplatform.py index c019614..3974649 100644 --- a/tools/guiplatform.py +++ b/tools/guiplatform.py @@ -7,22 +7,55 @@ Qt6 picks Wayland whenever `WAYLAND_DISPLAY` is set while VTK goes on creating an X window, and the two disagree about who owns the surface: the process dies with `BadWindow (X_ConfigureWindow)` before anything is drawn. -There is a way out, and it is worth taking rather than forcing everyone onto -XWayland. Asking VTK for a `vtkGenericOpenGLRenderWindow` and Qt's widget for -its `QOpenGLWidget` base puts *Qt* in charge of the GL context; VTK then draws -into a context it did not create and never touches X. That works natively on -Wayland, and on X11 as well, so it is the path used either way. +There is a way out of that in principle - hand VTK a +`vtkGenericOpenGLRenderWindow` and let *Qt* own the GL context, which is what +VTK's C++ `QVTKOpenGLNativeWidget` does - but not from here. pyvistaqt's +interactor is the Python `QVTKRenderWindowInteractor`, and it paints from +`paintEvent`: it never implements `paintGL`, never binds Qt's framebuffer and +never makes a context current. A generic render window given to it draws into +no context at all - a black viewport, and a VTK that will not even read its +own buffer back ("render window is not current"). So VTK keeps a window of its +own, which is an X window, and a Wayland session is served through XWayland. -So the order of preference is Wayland, then X11, then offscreen - and the -choice is announced once, because a tool that silently moves you to XWayland is -a tool that will be blamed for the missing fractional scaling. +The order of preference is therefore Wayland, then X11, then offscreen, with +Wayland having to prove itself first - and the choice is announced once, +because a tool that silently moves you to XWayland is a tool that will be +blamed for the missing fractional scaling. + +The other half of saying why: a Qt that will not import fails the Wayland test +frame the same way a bad GL stack does, and blaming the compositor for that is +how an afternoon disappears. So the binding is checked first, and a broken one +is named as broken rather than dressed up as a display problem. + +Not all of that stack is packaged everywhere - pyvista and pyvistaqt usually +are not - so the tools keep their own virtualenv at `tools/.venv`, built with +`--system-site-packages` so the distribution's Qt is still the Qt in use. If +the interpreter a tool was started with cannot find the stack and that +virtualenv can, the tool is re-run there rather than failed with a recipe to +type out. """ from __future__ import annotations +import ctypes +import ctypes.util +import importlib +import importlib.util import os +import re import subprocess import sys import tempfile +import textwrap + +# The tools' own virtualenv, the modules it exists to provide, and the flag +# that keeps a hop to it from happening twice. +_VENV = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.venv') +_NO_VENV = 'MIAPI_TOOLS_NO_VENV' +_STACK = ('PySide6', 'vtkmodules', 'pyvista', 'pyvistaqt') + +# The platform this process settled on, so a tool that imports another tool +# does not re-decide and re-announce it. +_chosen = None def configure(prefer=None, quiet=False): @@ -31,38 +64,165 @@ def configure(prefer=None, quiet=False): Returns the platform name chosen. `prefer` forces one; `QT_QPA_PLATFORM` set in the environment wins over both, because someone who set it meant it. """ + # Before asking which display server to draw on, ask whether there is + # anything to draw with. A stack that will not import fails every probe + # too, and a probe that fails is read as "this platform does not work" - + # which is how a broken install ends up wearing a Wayland costume. + ensure_stack(quiet) + + global _chosen + if _chosen: + # A second caller - one tool importing another - is asking a question + # that has already been answered, and answering it twice on stderr + # reads as indecision. + return _chosen + chosen = os.environ.get('QT_QPA_PLATFORM') + note = '' if not chosen: - chosen = prefer or _detect() + chosen, note = prefer, '' + if not chosen: + chosen, note = _detect() os.environ['QT_QPA_PLATFORM'] = chosen origin = 'detected' else: origin = 'from QT_QPA_PLATFORM' - # Must happen before QVTKRenderWindowInteractor is imported: the module - # picks its base class at import time and caches it. - import vtkmodules.qt - vtkmodules.qt.QVTKRWIBase = 'QOpenGLWidget' - if not quiet: - note = '' - if origin == 'detected' and chosen == 'xcb' and os.environ.get('WAYLAND_DISPLAY'): - note = ' - Wayland is present but its GL context failed a test frame' print(f'display: {chosen} ({origin}){note}', file=sys.stderr) if chosen == 'offscreen': print('display: no Wayland or X11 session - rendering to files only', file=sys.stderr) + _chosen = chosen return chosen +def ensure_stack(quiet=False): + """Import the drawing stack, or re-run in the interpreter that has it. + + `configure` calls this; so may a tool that draws without asking for a + platform. Exits with something worth reading if neither interpreter can + import the stack. + """ + # qtpy - which pyvistaqt imports - reads QT_API to choose a binding, and + # left to its own order it finds PyQt6 first on a machine that has both. + # Two bindings in one process is a crash rather than a mismatch, so ours + # is named here, before anything goes looking. + os.environ.setdefault('QT_API', 'pyside6') + + missing = [m for m in _STACK if importlib.util.find_spec(m) is None] + if missing: + _hop_to_venv(missing, quiet) # replaces this process, if it can + raise SystemExit(_stack_message(missing=missing)) + + broken = _import_stack() + if broken: + raise SystemExit(_stack_message(broken)) + + +def _hop_to_venv(missing, quiet): + """Re-run this tool in `tools/.venv`, if that is where the stack lives. + + Only what is *absent* sends us there. A PySide6 that is installed but will + not load is a system problem, and the venv is deliberately built on the + system's Qt, so hopping would be asking the same question twice. + """ + python = os.path.join(_VENV, 'bin', 'python') + here = os.path.realpath(sys.prefix) == os.path.realpath(_VENV) + if here or os.environ.get(_NO_VENV) or not os.path.exists(python): + return + if not quiet: + print(f'python: no {", ".join(missing)} here - using {_VENV}', + file=sys.stderr) + os.environ[_NO_VENV] = '1' # one hop: let the venv fail in plain sight + try: + os.execv(python, [python, *sys.argv]) + except OSError as exc: + del os.environ[_NO_VENV] + print(f'python: {python} would not start ({exc})', file=sys.stderr) + + +def _import_stack(): + """Import what has to be imported early, in the order that matters. + + PySide6 comes first because `vtkmodules.qt` picks its binding from + whatever is already imported, and it makes that choice once. The widget + base class is left alone: `QVTKRWIBase = 'QOpenGLWidget'` is only sound + with a render window Qt drives, and nothing here drives one. Returns the + first failure, or None. + """ + for module in ('PySide6.QtCore', 'PySide6.QtWidgets', 'vtkmodules.qt'): + try: + importlib.import_module(module) + except ImportError as exc: + return f'{module}: {exc}' + return None + + +def _stack_message(err=None, missing=()): + """Say what is actually broken, and what fixes it.""" + if missing: + return '\n'.join([ + 'display: the drawing stack is incomplete, so nothing can be drawn.', + f' no module named {", ".join(missing)}', + '', *textwrap.wrap( + 'pyvista and pyvistaqt are packaged by hardly any distribution, so ' + "the tools keep a virtualenv of their own for them - built on the " + "system's Qt and VTK rather than over the top of them. The tools " + 'use it by themselves once it is there:', 78), + f' python -m venv --system-site-packages {_VENV}', + f' {os.path.join(_VENV, "bin", "pip")} install pyvista pyvistaqt', + ]) + + lines = ['display: the Qt stack will not import, so no window can be opened.', + f' {err}'] + + # The signature of a half-finished upgrade. PySide6 links Qt's *private* + # ABI, which is versioned build-for-build on purpose, so a PySide6 and a + # libQt6Core from two different releases will not load together even + # though their public API is identical. The symbol names the release + # PySide6 was compiled against; libQt6Core will tell us its own. + want = re.search(r'QtPrivate_(\d+)_(\d+)_(\d+)', err) + if 'undefined symbol' in err and (want or 'Qt_6_PRIVATE_API' in err): + built = '.'.join(want.groups()) if want else 'another release' + have = _qt_runtime_version() + found = f', but the Qt libraries here are {have}' if have else '' + lines += ['', *textwrap.wrap( + f'PySide6 was built against Qt {built}{found}. The two share a ' + 'private ABI and have to be upgraded together, so this is a partial ' + 'upgrade rather than a display problem.', 78), + ' Arch: sudo pacman -Syu', + ' (upgrading qt6-base alone leaves the rest of the system behind)', + ] + return '\n'.join(lines) + + +def _qt_runtime_version(): + """Ask libQt6Core its version, without going through the broken binding.""" + try: + path = ctypes.util.find_library('Qt6Core') or 'libQt6Core.so.6' + lib = ctypes.CDLL(path) + lib.qVersion.restype = ctypes.c_char_p + return lib.qVersion().decode() + except (OSError, AttributeError, UnicodeDecodeError): + return None + + def _detect(): + """Choose a platform, and carry back the reason for anything given up.""" wayland = os.environ.get('WAYLAND_DISPLAY') and _socket_exists() x11 = bool(os.environ.get('DISPLAY')) - if wayland and _probe('wayland'): - return 'wayland' + why = '' + if wayland: + ok, why = _probe('wayland') + if ok: + return 'wayland', '' if x11: - return 'xcb' - return 'wayland' if wayland else 'offscreen' + return 'xcb', f' - Wayland {why}' if why else '' + if wayland: + return 'wayland', (f' - Wayland {why}, and there is no X11 to fall' + ' back to') if why else '' + return 'offscreen', '' # The failure this guards against is not an exception. A Wayland session whose @@ -72,14 +232,16 @@ def _detect(): # the question gets asked in a process we can afford to lose. _PROBE = """ import os, sys -import vtkmodules.qt -vtkmodules.qt.QVTKRWIBase = 'QOpenGLWidget' -from vtkmodules.vtkRenderingOpenGL2 import vtkGenericOpenGLRenderWindow from PySide6 import QtCore, QtWidgets import pyvistaqt, pyvista as pv +# Everything above is toolchain, not display. Past this line - a window, a +# context, a frame - a failure really is the display's, and the caller may say +# so. The marker goes here rather than after the window is up because opening +# the window is itself one of the things that fails. +open(sys.argv[1] + '.started', 'w').write('ok') app = QtWidgets.QApplication(['probe']) win = QtWidgets.QMainWindow() -view = pyvistaqt.QtInteractor(win, rw=vtkGenericOpenGLRenderWindow()) +view = pyvistaqt.QtInteractor(win) win.setCentralWidget(view) win.resize(64, 64) win.show() @@ -96,6 +258,10 @@ def go(): view.add_mesh(mesh, texture=tex) view.add_light(pv.Light(position=(1, 1, 1), light_type='scene light')) view.render() + # Read the frame back out of VTK's own window. It is proof of two things + # at once: that the paint survived, and that something was drawn into a + # buffer VTK can find - the failure that leaves the viewport black does + # not raise, it just renders nowhere, and this is where that shows up. view.screenshot(sys.argv[1] + '.png') open(sys.argv[1], 'w').write('ok') app.quit() @@ -105,20 +271,55 @@ app.exec() def _probe(platform, timeout=25): - """Does a real VTK viewport survive its first frame on this platform?""" + """Does a real VTK viewport survive its first frame on this platform? + + Returns `(ok, why)`, where `why` finishes the sentence "Wayland ...". The + reason matters: a probe that died before it ever opened a window says + nothing about the display, and reporting it as a failed frame sends the + next hour after the wrong bug. + """ if os.environ.get('MIAPI_TOOLS_NO_PROBE'): - return False + return False, 'was not tried (MIAPI_TOOLS_NO_PROBE)' with tempfile.TemporaryDirectory() as tmp: flag = os.path.join(tmp, 'flag') env = dict(os.environ, QT_QPA_PLATFORM=platform) env.pop('MIAPI_TOOLS_NO_PROBE', None) try: - subprocess.run([sys.executable, '-c', _PROBE, flag], env=env, - timeout=timeout, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL) - except (subprocess.TimeoutExpired, OSError): - return False - return os.path.exists(flag) + done = subprocess.run([sys.executable, '-c', _PROBE, flag], env=env, + timeout=timeout, stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE) + except subprocess.TimeoutExpired: + return False, f'did not finish a test frame within {timeout}s' + except OSError as exc: + return False, f'could not be tested ({exc})' + if os.path.exists(flag): + return True, '' + if os.path.exists(flag + '.started'): + return False, ('is present but VTK cannot draw there - ' + + _last_error(done.stderr)) + return False, f'could not be tested - {_last_error(done.stderr)}' + + +def _last_error(stderr): + """The one line worth repeating out of whatever the probe printed.""" + lines = [ln.strip() for ln in stderr.decode('utf-8', 'replace').splitlines() + if ln.strip()] + + # An X protocol error is not raised, it is printed - five lines of it, of + # which the first and the opcode are the ones that say anything. Xlib then + # takes the process down, so this is also the last word on what happened. + for i, line in enumerate(lines): + if line.startswith('X Error of failed request:'): + what = line.split(':', 1)[1].strip() + opcode = next((ln.split(':', 1)[1].strip() for ln in lines[i:] + if ln.startswith('Major opcode')), '') + where = f" on {opcode.split(None, 1)[-1].strip('()')}" if opcode else '' + return f'X {what}{where}' + + for line in reversed(lines): + if not line.startswith(('File "', 'Traceback', '^', '~', '|')): + return line + return 'the probe failed with no output' def _socket_exists(): @@ -130,8 +331,12 @@ def _socket_exists(): return bool(runtime) and os.path.exists(os.path.join(runtime, name)) -def render_window(): - """The render window that lets Qt own the context. Import Qt first.""" - from vtkmodules.vtkRenderingOpenGL2 import vtkGenericOpenGLRenderWindow +def viewport(parent): + """The 3D view the tools draw into. Import Qt first. - return vtkGenericOpenGLRenderWindow() + Deliberately plain: pyvistaqt is left to make its own render window, since + the only one it knows how to drive is the one it makes. + """ + import pyvistaqt + + return pyvistaqt.QtInteractor(parent) diff --git a/tools/mcmodel.py b/tools/mcmodel.py index 903d42a..2af9f31 100644 --- a/tools/mcmodel.py +++ b/tools/mcmodel.py @@ -114,6 +114,28 @@ class Resources: out[key] = part[key] return out + def paths(self, prefix='', suffix='', loose=False): + """Every path in the sources between a prefix and a suffix, sorted. + + For finding what is there rather than reading what you already knew + about. Duplicates collapse the way `read` resolves them - one path, + whichever source answers for it first. `loose` limits the answer to + files on disk, for a caller that means the working tree rather than + whatever a build happened to zip up. + """ + found = set() + for d in self.dirs: + root = os.path.join(d, prefix) + start = root if os.path.isdir(root) else os.path.dirname(root) + for base, _dirs, files in os.walk(start): + rel = os.path.relpath(base, d) + found.update(f'{rel}/{f}' for f in files + if f'{rel}/{f}'.startswith(prefix) and f.endswith(suffix)) + for z in ([] if loose else self.zips): + found.update(n for n in z.namelist() + if n.startswith(prefix) and n.endswith(suffix)) + return sorted(found) + def image(self, ref): """A texture as RGBA. `ref` is a namespaced texture id, no extension.""" ns, _, rest = ref.partition(':')