A viewport that draws, and a front door that opens with no arguments
The armour tools would not start and then would not draw, for two reasons that looked like one. pyvista and pyvistaqt are packaged by hardly any distribution, so `guiplatform` now keeps a virtualenv at `tools/.venv` and re-runs the tool inside it when the interpreter it was started with cannot find the stack - built with `--system-site-packages`, so the distribution's Qt stays the Qt in use. Only an absent module sends us there; a PySide6 that will not load is a system problem the venv shares, and it still gets its own message. The black viewport was the other half, and it was ours. Handing pyvistaqt a `vtkGenericOpenGLRenderWindow` is the right idea for VTK's C++ widget and wrong for this one: the Python `QVTKRenderWindowInteractor` paints from `paintEvent`, never implements `paintGL`, never binds Qt's framebuffer and never makes a context current, so the render window it was given drew into no context at all. Nothing raised - VTK would not even read its own buffer back. pyvistaqt makes its own render window now, which is the only one it knows how to drive, and the probe builds its viewport the same way the tools do rather than a way that always failed. That probe had been timing out on every launch for the same reason, so Wayland could never be chosen anywhere; it now says what actually went wrong, X protocol errors included. ARMOUR_QUICKSTART is the front door: no arguments, every model in the tree - worn armour, icons, sword parts, the loose item models - listed with the first one showing, and a jar's models listed after ours when one is passed, because a socket is geometry cut into a plate that lives in Armory's jar. Where each model goes comes from the mod's own module data, since the `origin` a module names is the part MIAPI draws it under; without Armory's slot transforms a worn model is flipped onto its part but not offset along it, and it says so. The left panel is that model's MIAPI transform. Three arrows on the model's origin move it, a toggle swaps them for three rotation rings, and the numbers are the ones a module writes - a drag is unwound back through the pivot and the flip before it reaches them, so what the panel reads is what goes in the JSON. Nothing here writes: the number is what you leave with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>main
parent
7442ee7ac7
commit
e7b211932f
47
README.md
47
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 <armory> # draw and unwrap socket geometry
|
||||
python3 tools/ARMOUR_EDITOR.py --jar <armory> # 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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 <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.
|
||||
|
||||
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/<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 = 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())
|
||||
|
|
@ -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,
|
||||
done = 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)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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(':')
|
||||
|
|
|
|||
Loading…
Reference in New Issue