#!/usr/bin/env python3 """Open every armour model there is, and look at them one at a time. The editors ask what you meant before they will draw anything - which part, which scene, which jar. This asks nothing. It finds every armour model in the sources, places each one on the body part its slot names, and lists them down the left. Only the first is ticked. Eight plates and four sockets drawn at once are a solid lump with the interesting bits inside it, so the blanket load is about not having to *find* a model, not about seeing them all at the same time - tick along the list and each one appears where it will sit on the wearer. tools/ARMOUR_QUICKSTART.py [--jar ] Everything means everything: worn armour, inventory icons, sword parts, the loose item models. The ones that name a body part are drawn on it; the rest are drawn in item space, where they are modelled. Your models come off disk, out of `src/main/resources` - the files you are editing, not the copies inside a built jar - and they are listed first. A jar on the command line brings three things with it: the slot transforms that place a worn piece exactly, the textures a model of ours points at, and its own models, listed after yours and marked with their namespace. That last one matters for a socket: the geometry here is the socket alone, and the plate it is cut into - `arm_left/heavy [tm_armory]` - is Armory's, so seeing the two together needs the jar. `Reload` re-reads the tree, so a model saved in another window shows up here without restarting. The left panel is the selected model's own transform - the `translation` and `rotation` a MIAPI module writes, in model pixels and in degrees about the model's origin. Three arrows on that origin move it along an axis; the rotate toggle swaps them for three rings. Dragging one and typing a number are the same edit seen from two sides, so the panel always reads what the model is doing, and `Copy as JSON` hands over the block to paste into the module. `Write to source` is the one thing here that changes a file: it puts those numbers back into the module entry they were read from, leaving its scale and its origin alone. Placement lives in the module, so a model no module names - a sword part, an icon of its own - has nowhere to write to and says so. When it is the geometry itself that wants moving, ARMOUR_GUI edits the boxes and ARMOUR_EDITOR poses the result in a scene. """ from __future__ import annotations import argparse import json import os import re import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import guiplatform guiplatform.configure(prefer=os.environ.get('ARMOUR_GUI_PLATFORM')) import numpy as np # noqa: E402 from PySide6 import QtCore, QtWidgets # noqa: E402 import mcmodel as mc # noqa: E402 import ARMOUR_EDITOR as ae # noqa: E402 import ARMOUR_GUI as ag # noqa: E402 REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # A model's directory says which slot it dresses; the slot says which body part # MIAPI draws it under, and that is what decides where it lands and what it # swings with. Slots Armory has that we do not are here too, because a jar on # the command line brings them along. # When a model's module data does not say which body part it rides on, the # folder it sits in is the next best thing: worn armour is filed by the Armory # slot that dresses each part, and those names map onto the parts one to one. SLOT_PARTS = { 'arm_left': 'left_arm', 'arm_right': 'right_arm', 'leg_left': 'left_leg', 'leg_right': 'right_leg', 'chest_front': 'body', 'chest_back': 'body', 'belt': 'body', 'cape': 'body', 'helmet': 'head', 'hat': 'head', } # Every model in the tree, whatever it dresses: `assets//models/.json`. # Armour, icons, sword parts, the loose item models - all of it, because a # blanket load that quietly skips a folder is worse than no blanket at all. MODEL_PATH = re.compile(r'^assets/(?P[^/]+)/models/(?P.+)\.json$') class Entry: """One model: where it is read from, and where it belongs.""" def __init__(self, ns, rest, placement=None, loose=True): self.ns, self.rest, self.loose = ns, rest, loose self.ref = f'{ns}:models/{rest}.json' # What the module data says beats what the directory is called: the # `origin` is the part MIAPI draws the model under, and the folder is # a filing convention that only usually agrees. Anything that names no # part is drawn in item space, which is where icons and held pieces # are modelled anyway. origin, self.matrix, self.declared, self.source = \ placement or (None, np.eye(4), False, None) # 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 [] entries = entries if isinstance(entries, list) else [entries] for i, entry in enumerate(entries): ref = isinstance(entry, dict) and entry.get('path') if not ref: continue transform = entry.get('transform') or {} key = _key(ref) # A module can name the same model twice - once for the body # part and once, origin-less, for the inventory icon. The one # that names a part is the placement being looked at, so it # wins; the icon entry only fills in when it is all there is. if key in out and not transform.get('origin'): continue out[key] = (transform.get('origin'), mc.transform_matrix(transform), True, (path, section, i)) return out def _texture_key(doc): """The `#key` a model's faces are textured through, or the only one there is.""" for element in doc.get('elements') or []: for face in (element.get('faces') or {}).values(): if isinstance(face, dict) and face.get('texture'): return face['texture'] for name in doc.get('textures') or {}: if name != 'particle': return f'#{name}' return '#0' def _texture_path(ref): """Where a namespaced texture id lives, as a path into the resource tree.""" ns, _, rest = ref.partition(':') if not _: ns, rest = 'minecraft', ref if rest.startswith('textures/'): rest = rest[len('textures/'):] return f'assets/{ns}/textures/{rest}.png' def _round(value): """A number fit to be written down. No float dust and no negative zero, and a whole number stays whole - a module that said `0` should not come back saying `0.0` for the sake of a drag that never touched that axis. """ value = round(float(value), 4) + 0.0 return int(value) if value == int(value) else value 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 (('Write to source', self.write_to_source), ('Reset to declared', self.reset_transform), ('Copy as JSON', self.copy_transform), ('Unwrap UVs', self.unwrap_uvs)): button = QtWidgets.QPushButton(label) button.clicked.connect(slot) lay.addWidget(button) note = QtWidgets.QLabel('drag a handle in the viewport, or type. Only ' '`Write to source` touches the tree') 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 write_to_source(self): """Put the panel's numbers back into the module that declares the model. Placement lives in the module rather than in the geometry, so that is what is written: the `translation` and `rotation` of the model entry the tool read this placement from, with its scale and its origin left as the module had them. A model no module names has nowhere to write to - its geometry is its position - and says so rather than guessing. This is the one thing here that changes a file. """ entry = self._current() if entry is None: return if entry.source is None or not entry.loose: self.statusBar().showMessage( f'{entry.label}: no module in this tree names it, so there is no' ' transform to write - move the boxes in ARMOUR_GUI instead') return path, section, index = entry.source full = self._on_disk(path) if full is None: self.statusBar().showMessage(f'{path}: not in a directory to write to') return with open(full) as fh: doc = json.load(fh) model = doc['data'][section]['model'] target = (model[index] if isinstance(model, list) else model) transform = target.setdefault('transform', {}) written = mc.decompose(entry.matrix) for group in ('translation', 'rotation'): transform[group] = {a: _round(written[group][a]) for a in 'xyz'} with open(full, 'w') as fh: fh.write(ae._dumps(doc)) fh.write('\n') # What the file says is what is declared now, so this is the state # `Reset to declared` should come back to. entry.given = entry.matrix.copy() entry.declared = True self.statusBar().showMessage(f'wrote the transform to {path}') def unwrap_uvs(self, _checked=False): """Re-cut the selected model's texture so every face has its own patch. The same unwrap ARMOUR_GUI does when it saves, on a model that is already drawn rather than one being built: every face gets a rectangle of its own, the atlas size is written back beside the boxes, and a template is painted to match - but only where there is no texture yet. A guide is scaffolding, and overwriting art someone has painted because the boxes moved would be the tool destroying the work it exists for. """ entry = self._current() if entry is None: return model_path = f'assets/{entry.ns}/models/{entry.rest}.json' full = self._on_disk(model_path) if entry.loose else None if full is None: self.statusBar().showMessage(f'{entry.label}: not a file in this tree') return with open(full) as fh: doc = json.load(fh) if not doc.get('elements'): self.statusBar().showMessage( f'{entry.label}: a sprite, not boxes - there is nothing to unwrap') return # Unwrap onto the key the model's own faces already name, so a model # textured through `#1` does not come back pointing at `#0`. key = _texture_key(doc) size, nets = mc.unwrap(doc['elements'], texture=key) doc['texture_size'] = [int(size[0]), int(size[1])] with open(full, 'w') as fh: fh.write(ae._dumps(doc)) fh.write('\n') written = [model_path] ref = (doc.get('textures') or {}).get(key.lstrip('#')) template = _texture_path(ref) if ref else None if template and self._on_disk(template) is None: beside = os.path.join(full[:-len(model_path)], template) os.makedirs(os.path.dirname(beside), exist_ok=True) mc.unwrap_template(size, nets, doc['elements']).save(beside) written.append(template) self.rebuild() self.statusBar().showMessage( f'unwrapped onto {int(size[0])}x{int(size[1])} - wrote ' + ', '.join(written)) def _on_disk(self, path): """The writable file behind a resource path, if one of the sources has it.""" for directory in self.res.dirs: full = os.path.join(directory, path) if os.path.isfile(full): return full return None 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())