create-mekanism-modular/tools/ARMOUR_QUICKSTART.py

753 lines
32 KiB
Python
Executable File

#!/usr/bin/env python3
"""Open every armour model there is, and look at them one at a time.
The editors ask what you meant before they will draw anything - which part,
which scene, which jar. This asks nothing. It finds every armour model in the
sources, places each one on the body part its slot names, and lists them down
the left.
Only the first is ticked. Eight plates and four sockets drawn at once are a
solid lump with the interesting bits inside it, so the blanket load is about
not having to *find* a model, not about seeing them all at the same time - tick
along the list and each one appears where it will sit on the wearer.
tools/ARMOUR_QUICKSTART.py [--jar <armory.jar>]
Everything means everything: worn armour, inventory icons, sword parts, the
loose item models. The ones that name a body part are drawn on it; the rest
are drawn in item space, where they are modelled.
Your models come off disk, out of `src/main/resources` - the files you are
editing, not the copies inside a built jar - and they are listed first. A jar
on the command line brings three things with it: the slot transforms that place
a worn piece exactly, the textures a model of ours points at, and its own
models, listed after yours and marked with their namespace. That last one
matters for a socket: the geometry here is the socket alone, and the plate it
is cut into - `arm_left/heavy [tm_armory]` - is Armory's, so seeing the two
together needs the jar. `Reload` re-reads the tree, so a model saved in another
window shows up here without restarting.
The left panel is the selected model's own transform - the `translation` and
`rotation` a MIAPI module writes, in model pixels and in degrees about the
model's origin. Three arrows on that origin move it along an axis; the rotate
toggle swaps them for three rings. Dragging one and typing a number are the
same edit seen from two sides, so the panel always reads what the model is
doing, and `Copy as JSON` hands over the block to paste into the module.
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())