A front door for the armour editor, and names that say what to run

Running it needed a scene and a jar path known in advance, which is a poor way
to meet a tool. With no scene named it now asks: the four scenes listed with
their own docstring's first line as the description, so the blurb cannot drift
from what the scene builds, and the sources pre-filled with the repo's own
resources first and Armory's jar after, found by looking where the pack's jars
actually live. Textures, wearer, material variant and gem size are there too.

The dialog is Qt and nothing else. The viewport it leads to is a plotter with a
window of its own, so there is no shared GL context to get wrong and no reason
to sit through the Wayland probe before a scene has been picked; the
QApplication is shut down before the viewport opens rather than left to run a
second event loop. `--shot` still insists on a scene, because it renders
without a window and an unattended render should not stop to ask.

Widgets carry object names. That began as a way to drive them from a test after
addressing them by index quietly drove the wrong ones, and it is right anyway -
an index moves the next time a row is added above it.

The three tools you start are ALL CAPS now and the libraries are not, so the
directory says which is which. main() also stops adding the repo's resources a
second time when the dialog has already listed them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-08-24 16:18:33 +02:00
parent 742705839d
commit 7442ee7ac7
3 changed files with 156 additions and 9 deletions

View File

@ -9,9 +9,10 @@ real camera, and lets the placement be pushed around with the arrow keys until
it looks right - then writes the numbers back into the module JSON they came
from.
tools/armour_editor.py vanilla # the reference render
tools/armour_editor.py sockets --jar <armory.jar> # the editable scene
tools/armour_editor.py sockets --jar <j> --shot out.png
tools/ARMOUR_EDITOR.py # pick a scene in the dialog
tools/ARMOUR_EDITOR.py vanilla # the reference render
tools/ARMOUR_EDITOR.py sockets --jar <armory.jar> # the editable scene
tools/ARMOUR_EDITOR.py sockets --jar <j> --shot out.png
Two things it does not simplify away, because both change the answer:
@ -763,13 +764,151 @@ def _dumps(doc):
return _VECTOR.sub(fold, text)
# --------------------------------------------------------------------- opening
def guess_sources():
"""Jars worth offering before anybody has typed a path.
The repo's own resources come first, because the models being worked on are
usually the ones in it and a list that omits them reads as though they are
not available. Then Armory: every scene except `vanilla` reads its models
and the tool is useless without them, so an empty list is the one starting
state guaranteed to be wrong. Both beat opening on a file browser.
"""
found = [os.path.join(REPO, 'src/main/resources')]
for folder in (os.path.expanduser('~/.cache/abdelpak-jars'),
os.path.join(REPO, 'run', 'mods'),
os.path.expanduser('~/.minecraft/mods')):
if not os.path.isdir(folder):
continue
for name in sorted(os.listdir(folder)):
if name.endswith('.jar') and 'armory' in name.lower():
found.append(os.path.join(folder, name))
return found
def scene_blurb(name):
"""The first line of a scene's own docstring, so the two cannot drift."""
doc = (SCENES[name].__doc__ or '').strip()
return doc.splitlines()[0] if doc else ''
def opening_dialog(args):
"""Ask what to open. False if the window was closed without choosing.
Only Qt, no VTK: the viewport this leads to is a plotter of its own, so
there is no shared GL context to get wrong and no reason to make anyone sit
through the Wayland probe before they have picked a scene.
"""
from PySide6 import QtCore, QtWidgets
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv[:1])
dialog = QtWidgets.QDialog()
dialog.setObjectName('opening')
dialog.setWindowTitle('armour editor')
lay = QtWidgets.QVBoxLayout(dialog)
lay.addWidget(QtWidgets.QLabel('<b>What to draw</b>'))
scenes = QtWidgets.QListWidget()
for name in sorted(SCENES):
item = QtWidgets.QListWidgetItem(f'{name}\n {scene_blurb(name)}')
item.setData(QtCore.Qt.UserRole, name)
scenes.addItem(item)
scenes.setCurrentRow(sorted(SCENES).index(args.scene or 'sockets'))
scenes.setObjectName('scenes')
scenes.setMinimumHeight(150)
lay.addWidget(scenes)
lay.addWidget(QtWidgets.QLabel('<b>Where to read models from</b>'))
sources = QtWidgets.QListWidget()
sources.setObjectName('sources')
sources.setMaximumHeight(90)
for path in (args.jar or guess_sources()):
sources.addItem(path)
lay.addWidget(sources)
hint = QtWidgets.QLabel("The repo's own resources are searched whether or not "
"they are listed; a jar is needed for Armory's plates.")
hint.setStyleSheet('color: #888;')
hint.setWordWrap(True)
lay.addWidget(hint)
row = QtWidgets.QHBoxLayout()
def add_jar():
path, _ = QtWidgets.QFileDialog.getOpenFileName(
dialog, 'mod jar', os.path.expanduser('~'), 'Jars (*.jar)')
if path:
sources.addItem(path)
def add_folder():
path = QtWidgets.QFileDialog.getExistingDirectory(
dialog, 'resource folder', os.path.expanduser('~'))
if path:
sources.addItem(path)
def drop():
for item in sources.selectedItems():
sources.takeItem(sources.row(item))
for label, slot in (('Add jar...', add_jar), ('Add folder...', add_folder),
('Remove', drop)):
button = QtWidgets.QPushButton(label)
button.clicked.connect(slot)
row.addWidget(button)
lay.addLayout(row)
form = QtWidgets.QFormLayout()
textures = QtWidgets.QCheckBox()
textures.setObjectName('textures')
textures.setChecked(not args.no_textures)
form.addRow('textures', textures)
wearer = QtWidgets.QCheckBox()
wearer.setObjectName('wearer')
wearer.setChecked(args.body)
form.addRow('draw the wearer', wearer)
variant = QtWidgets.QLineEdit(args.variant)
variant.setObjectName('variant')
form.addRow('material variant', variant)
gem = QtWidgets.QComboBox()
gem.setObjectName('gem')
gem.addItems(['small', 'medium', 'large'])
gem.setCurrentText(args.gem)
form.addRow('gem size', gem)
lay.addLayout(form)
buttons = QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.Open | QtWidgets.QDialogButtonBox.Cancel)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
scenes.itemDoubleClicked.connect(lambda _: dialog.accept())
lay.addWidget(buttons)
dialog.resize(560, 560)
if dialog.exec() != QtWidgets.QDialog.Accepted:
return False
args.scene = scenes.currentItem().data(QtCore.Qt.UserRole)
args.jar = [sources.item(i).text() for i in range(sources.count())]
args.no_textures = not textures.isChecked()
args.body = wearer.isChecked()
args.variant = variant.text().strip() or 'default'
args.gem = gem.currentText()
# The viewport opens its own window through a plain plotter. Letting this
# QApplication linger would leave a second event loop owning the process.
app.quit()
return True
# ------------------------------------------------------------------------ cli
def main(argv=None):
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('scene', choices=sorted(SCENES), help='which figure to draw')
ap.add_argument('scene', nargs='?', choices=sorted(SCENES),
help='which figure to draw; omit it to be asked')
ap.add_argument('--jar', action='append', default=[], metavar='PATH',
help='mod jar or resource directory; repeatable, searched in order')
ap.add_argument('--shot', metavar='PNG',
@ -796,8 +935,16 @@ def main(argv=None):
help='decompose each merge to Euler angles, as MIAPI did before 1.21')
args = ap.parse_args(argv)
sources = list(args.jar) or []
sources.append(os.path.join(REPO, 'src/main/resources'))
if args.scene is None:
if args.shot:
ap.error('--shot renders without a window, so it needs a scene named')
if not opening_dialog(args):
return
sources = list(args.jar)
own = os.path.join(REPO, 'src/main/resources')
if own not in sources:
sources.append(own)
res = mc.Resources(sources)
placements = SCENES[args.scene](res, args)

View File

@ -13,7 +13,7 @@ So this edits a model file of our own: boxes in the limb's own coordinates,
shown against Armory's plate so they can be lined up with it, unwrapped onto a
texture that is written out beside them.
tools/armour_gui.py --jar <armory.jar>
tools/ARMOUR_GUI.py --jar <armory.jar>
Left is the box list and the part it belongs to, right is the box being edited,
middle is the wearer. Everything is in model pixels, the units the JSON is
@ -36,7 +36,7 @@ from PySide6 import QtCore, QtWidgets # noqa: E40
import pyvistaqt # noqa: E402
import mcmodel as mc # noqa: E402
import armour_editor as ae # noqa: E402
import ARMOUR_EDITOR as ae # noqa: E402
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ASSETS = os.path.join(REPO, 'src/main/resources/assets/cmmodular')

View File

@ -13,7 +13,7 @@ value, and saving replaces exactly that span of the file - so `ingots("mekanism"
"ingot_tin", ...)` stays a call, the comments under every material stay where
they were, and a diff shows the number that changed and nothing else.
tools/material_editor.py
tools/MATERIAL_EDITOR.py
Materials built by a loop rather than written out - the dragon scales, the gem
families - have no literal `M(...)` to edit and are shown read-only, because the