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
Amir Alexander Abdelbaki 2026-08-24 20:53:07 +02:00
parent 7442ee7ac7
commit e7b211932f
6 changed files with 1071 additions and 41 deletions

View File

@ -425,6 +425,53 @@ Requires `pillow` and `numpy`.
Module JSON and textures are not generated. A run only clears the material Module JSON and textures are not generated. A run only clears the material
folders, so the hand-written packs survive it. 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 ## Notes
- Titanium's palette comes from Cosmonautics' ingot, which is violet. Air War's - Titanium's palette comes from Cosmonautics' ingot, which is violet. Air War's

View File

@ -42,6 +42,7 @@ from dataclasses import dataclass, field
import numpy as np import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import guiplatform
import mcmodel as mc import mcmodel as mc
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -956,4 +957,8 @@ def main(argv=None):
if __name__ == '__main__': 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() main()

View File

@ -33,7 +33,6 @@ guiplatform.configure(prefer=os.environ.get('ARMOUR_GUI_PLATFORM'))
import numpy as np # noqa: E402 import numpy as np # noqa: E402
from PySide6 import QtCore, QtWidgets # noqa: E402 from PySide6 import QtCore, QtWidgets # noqa: E402
import pyvistaqt # noqa: E402
import mcmodel as mc # noqa: E402 import mcmodel as mc # noqa: E402
import ARMOUR_EDITOR as ae # noqa: E402 import ARMOUR_EDITOR as ae # noqa: E402
@ -166,7 +165,7 @@ class ArmourGui(QtWidgets.QMainWindow):
splitter = QtWidgets.QSplitter() splitter = QtWidgets.QSplitter()
splitter.addWidget(self._left_panel()) 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.view)
splitter.addWidget(self._right_panel()) splitter.addWidget(self._right_panel())
splitter.setSizes([230, 900, 250]) splitter.setSizes([230, 900, 250])
@ -180,8 +179,8 @@ class ArmourGui(QtWidgets.QMainWindow):
def showEvent(self, event): def showEvent(self, event):
"""First draw waits for the window. """First draw waits for the window.
A QOpenGLWidget has no GL context until it is on screen, and VTK asked The viewport has no GL context until it is on screen, and VTK asked to
to render before that goes looking for one of its own - which on a 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. Wayland session means a GLX context that cannot be made current.
""" """
super().showEvent(event) super().showEvent(event)

752
tools/ARMOUR_QUICKSTART.py Executable file
View File

@ -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())

View File

@ -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 an X window, and the two disagree about who owns the surface: the process dies
with `BadWindow (X_ConfigureWindow)` before anything is drawn. with `BadWindow (X_ConfigureWindow)` before anything is drawn.
There is a way out, and it is worth taking rather than forcing everyone onto There is a way out of that in principle - hand VTK a
XWayland. Asking VTK for a `vtkGenericOpenGLRenderWindow` and Qt's widget for `vtkGenericOpenGLRenderWindow` and let *Qt* own the GL context, which is what
its `QOpenGLWidget` base puts *Qt* in charge of the GL context; VTK then draws VTK's C++ `QVTKOpenGLNativeWidget` does - but not from here. pyvistaqt's
into a context it did not create and never touches X. That works natively on interactor is the Python `QVTKRenderWindowInteractor`, and it paints from
Wayland, and on X11 as well, so it is the path used either way. `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 The order of preference is therefore Wayland, then X11, then offscreen, with
choice is announced once, because a tool that silently moves you to XWayland is Wayland having to prove itself first - and the choice is announced once,
a tool that will be blamed for the missing fractional scaling. 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 from __future__ import annotations
import ctypes
import ctypes.util
import importlib
import importlib.util
import os import os
import re
import subprocess import subprocess
import sys import sys
import tempfile 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): 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` 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. 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') chosen = os.environ.get('QT_QPA_PLATFORM')
note = ''
if not chosen: if not chosen:
chosen = prefer or _detect() chosen, note = prefer, ''
if not chosen:
chosen, note = _detect()
os.environ['QT_QPA_PLATFORM'] = chosen os.environ['QT_QPA_PLATFORM'] = chosen
origin = 'detected' origin = 'detected'
else: else:
origin = 'from QT_QPA_PLATFORM' 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: 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) print(f'display: {chosen} ({origin}){note}', file=sys.stderr)
if chosen == 'offscreen': if chosen == 'offscreen':
print('display: no Wayland or X11 session - rendering to files only', print('display: no Wayland or X11 session - rendering to files only',
file=sys.stderr) file=sys.stderr)
_chosen = chosen
return 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(): def _detect():
"""Choose a platform, and carry back the reason for anything given up."""
wayland = os.environ.get('WAYLAND_DISPLAY') and _socket_exists() wayland = os.environ.get('WAYLAND_DISPLAY') and _socket_exists()
x11 = bool(os.environ.get('DISPLAY')) x11 = bool(os.environ.get('DISPLAY'))
if wayland and _probe('wayland'): why = ''
return 'wayland' if wayland:
ok, why = _probe('wayland')
if ok:
return 'wayland', ''
if x11: if x11:
return 'xcb' return 'xcb', f' - Wayland {why}' if why else ''
return 'wayland' if wayland else 'offscreen' 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 # 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. # the question gets asked in a process we can afford to lose.
_PROBE = """ _PROBE = """
import os, sys import os, sys
import vtkmodules.qt
vtkmodules.qt.QVTKRWIBase = 'QOpenGLWidget'
from vtkmodules.vtkRenderingOpenGL2 import vtkGenericOpenGLRenderWindow
from PySide6 import QtCore, QtWidgets from PySide6 import QtCore, QtWidgets
import pyvistaqt, pyvista as pv 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']) app = QtWidgets.QApplication(['probe'])
win = QtWidgets.QMainWindow() win = QtWidgets.QMainWindow()
view = pyvistaqt.QtInteractor(win, rw=vtkGenericOpenGLRenderWindow()) view = pyvistaqt.QtInteractor(win)
win.setCentralWidget(view) win.setCentralWidget(view)
win.resize(64, 64) win.resize(64, 64)
win.show() win.show()
@ -96,6 +258,10 @@ def go():
view.add_mesh(mesh, texture=tex) view.add_mesh(mesh, texture=tex)
view.add_light(pv.Light(position=(1, 1, 1), light_type='scene light')) view.add_light(pv.Light(position=(1, 1, 1), light_type='scene light'))
view.render() 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') view.screenshot(sys.argv[1] + '.png')
open(sys.argv[1], 'w').write('ok') open(sys.argv[1], 'w').write('ok')
app.quit() app.quit()
@ -105,20 +271,55 @@ app.exec()
def _probe(platform, timeout=25): 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'): if os.environ.get('MIAPI_TOOLS_NO_PROBE'):
return False return False, 'was not tried (MIAPI_TOOLS_NO_PROBE)'
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
flag = os.path.join(tmp, 'flag') flag = os.path.join(tmp, 'flag')
env = dict(os.environ, QT_QPA_PLATFORM=platform) env = dict(os.environ, QT_QPA_PLATFORM=platform)
env.pop('MIAPI_TOOLS_NO_PROBE', None) env.pop('MIAPI_TOOLS_NO_PROBE', None)
try: 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, timeout=timeout, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL) stderr=subprocess.PIPE)
except (subprocess.TimeoutExpired, OSError): except subprocess.TimeoutExpired:
return False return False, f'did not finish a test frame within {timeout}s'
return os.path.exists(flag) 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(): def _socket_exists():
@ -130,8 +331,12 @@ def _socket_exists():
return bool(runtime) and os.path.exists(os.path.join(runtime, name)) return bool(runtime) and os.path.exists(os.path.join(runtime, name))
def render_window(): def viewport(parent):
"""The render window that lets Qt own the context. Import Qt first.""" """The 3D view the tools draw into. Import Qt first.
from vtkmodules.vtkRenderingOpenGL2 import vtkGenericOpenGLRenderWindow
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)

View File

@ -114,6 +114,28 @@ class Resources:
out[key] = part[key] out[key] = part[key]
return out 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): def image(self, ref):
"""A texture as RGBA. `ref` is a namespaced texture id, no extension.""" """A texture as RGBA. `ref` is a namespaced texture id, no extension."""
ns, _, rest = ref.partition(':') ns, _, rest = ref.partition(':')