1054 lines
44 KiB
Python
1054 lines
44 KiB
Python
#!/usr/bin/env python3
|
|
"""Place Truly Modular modules on armour by looking at them and moving them.
|
|
|
|
Where a module ends up on a worn piece is the product of a model, a slot
|
|
transform, the transforms above it and a body part's pivot, and no amount of
|
|
reading those numbers tells you whether a gem is on a pauldron or three pixels
|
|
inside it. This composes the chain, draws it with a real depth buffer and a
|
|
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 # 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:
|
|
|
|
*Depth.* Faces are rasterised by VTK against a z-buffer rather than sorted and
|
|
painted, so a gem half-sunk into a plate reads as half-sunk rather than as
|
|
whichever of the two happened to sort in front.
|
|
|
|
*MIAPI's arithmetic.* Transforms are kept one per `origin` and merged only
|
|
within an entry, which is why a gem whose slot says `body` never picks up the
|
|
`left_arm` transform above it. A merge multiplies the two matrices and then
|
|
decomposes the product back into translation, Euler angles and scale - 2.3.8
|
|
does that on every merge, not only the old versions, so shear is dropped
|
|
wherever a rotation meets a non-uniform scale, which is what Armory's limb
|
|
slots are. That is the default here; `--exact-merge` keeps the product whole,
|
|
for seeing what the shear would have done.
|
|
|
|
Everything is in model pixels: +x is the wearer's left, +y is down, -z is
|
|
forward, and the origin is the base of the neck.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
|
|
import numpy as np
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import guiplatform
|
|
import mcmodel as mc
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
# A fallback colour per placement, for the things that are not a model with
|
|
# boxes to hue - the wearer, a selection, a plate borrowed for context. Anything
|
|
# with shapes of its own is coloured by `mcmodel.face_colour` instead.
|
|
PALETTE = [
|
|
(0.85, 0.42, 0.40), (0.45, 0.72, 0.90), (0.55, 0.80, 0.45), (0.92, 0.74, 0.36),
|
|
(0.72, 0.55, 0.88), (0.40, 0.82, 0.76), (0.90, 0.58, 0.78), (0.65, 0.65, 0.70),
|
|
]
|
|
|
|
|
|
# ------------------------------------------------------------------ placements
|
|
|
|
|
|
@dataclass
|
|
class Placement:
|
|
"""One drawn thing: a model, the transform that puts it somewhere, and -
|
|
if it is ours to change - the file that transform was read out of."""
|
|
name: str
|
|
quads: list # model-space, from mcmodel.model_quads
|
|
transform: dict # the editable MIAPI transform
|
|
outer: np.ndarray | None = None # slot chain applied after this transform
|
|
chain_origin: str | None = None # the origin that chain is filed under
|
|
source: tuple | None = None # (path, [json keys]) to write back to
|
|
offset: tuple = (0.0, 0.0, 0.0) # laid-out position, for scenes of several items
|
|
colour: tuple = (0.7, 0.7, 0.75)
|
|
original: dict = field(default_factory=dict)
|
|
|
|
def __post_init__(self):
|
|
self.original = json.loads(json.dumps(self.transform))
|
|
|
|
@property
|
|
def editable(self):
|
|
return self.source is not None
|
|
|
|
@property
|
|
def origin(self):
|
|
"""The body part this module is drawn under, or None for none of them.
|
|
|
|
Absent does **not** mean the torso. MIAPI renders armour by walking a
|
|
fixed list of seven part names and matching models to each, so a
|
|
transform that names nothing - or names `item` - matches no part and is
|
|
never drawn on the wearer at all. That entry is the inventory model,
|
|
which is why `scene_icons` picks it out by its missing origin.
|
|
"""
|
|
return self.transform.get('origin')
|
|
|
|
@property
|
|
def worn(self):
|
|
"""Whether MIAPI would draw this on the body rather than in the slot UI."""
|
|
return self.origin in mc.BODY_PARTS
|
|
|
|
@property
|
|
def auto_chain(self):
|
|
"""Whether the slot above this one composes with it.
|
|
|
|
MIAPI keeps one transform per origin and merges a child into the entry
|
|
its own origin names. So a transform composes with the slot above it
|
|
only when the two agree about which body part they are for: Armory's
|
|
chest gem says `body` and sits in a `body` slot, so the two multiply
|
|
and the gem lands on the sternum. A gem cut into a limb says `body` too
|
|
- the gemstone model declares no origin of its own - but the slot above
|
|
it says `left_arm`, so they are filed apart and the limb's transform
|
|
never reaches the gem. That is not a quirk to correct for; it is the
|
|
thing that puts these gems on the torso, and the reason a placement has
|
|
to be aimed in body space.
|
|
"""
|
|
return self.outer is not None and self.chain_origin == self.origin
|
|
|
|
def matrix(self, force_chain, lossy):
|
|
own = mc.transform_matrix(self.transform)
|
|
if self.outer is not None and (force_chain or self.auto_chain):
|
|
return mc.merge(own, self.outer, lossy)
|
|
return own
|
|
|
|
def pivot(self, force_chain):
|
|
part = self.chain_origin if (force_chain and self.outer is not None) else self.origin
|
|
base = mc.PIVOTS.get(part, (0.0, 0.0, 0.0))
|
|
return tuple(b + o for b, o in zip(base, self.offset))
|
|
|
|
def world_quads(self, force_chain, lossy):
|
|
m, off = self.matrix(force_chain, lossy), self.pivot(force_chain)
|
|
return [q.transformed(m, off) for q in self.quads]
|
|
|
|
def nudge(self, axis, amount):
|
|
t = self.transform.setdefault('translation', {})
|
|
t[axis] = round(float(t.get(axis, 0.0)) + amount, 4)
|
|
|
|
def turn(self, axis, degrees):
|
|
r = self.transform.setdefault('rotation', {})
|
|
r[axis] = round((float(r.get(axis, 0.0)) + degrees) % 360.0, 4)
|
|
|
|
def resize(self, factor):
|
|
s = self.transform.setdefault('scale', {})
|
|
for axis in 'xyz':
|
|
s[axis] = round(float(s.get(axis, 1.0)) * factor, 4)
|
|
|
|
def revert(self):
|
|
self.transform.clear()
|
|
self.transform.update(json.loads(json.dumps(self.original)))
|
|
|
|
def summary(self):
|
|
def trio(key, dflt):
|
|
sub = self.transform.get(key) or {}
|
|
return ' '.join(f'{a}{float(sub.get(a, dflt)):+.3g}' for a in 'xyz')
|
|
return (f"t[{trio('translation', 0.0)}] "
|
|
f"r[{trio('rotation', 0.0)}] "
|
|
f"s[{trio('scale', 1.0)}] "
|
|
f"origin={self.transform.get('origin', '-')}")
|
|
|
|
|
|
# ---------------------------------------------------------------------- scenes
|
|
|
|
|
|
def _armour_layer_texture(res, ref):
|
|
"""A texture id that exists, or None so the part draws untextured."""
|
|
try:
|
|
res.image(ref)
|
|
return ref
|
|
except KeyError:
|
|
return None
|
|
|
|
|
|
def scene_vanilla(res, args):
|
|
"""Vanilla armour on the vanilla humanoid - the render whose answer is known.
|
|
|
|
Nothing here goes through MIAPI: the boxes are `HumanoidModel.createMesh`
|
|
and the uv layout is `ModelPart.Cube`. If this comes out looking like a
|
|
suit of armour then the camera, the depth buffer, the uv convention and the
|
|
part pivots are all right, and anything wrong further on is MIAPI's chain
|
|
rather than the renderer.
|
|
"""
|
|
layer1 = _armour_layer_texture(res, args.layer1)
|
|
layer2 = _armour_layer_texture(res, args.layer2)
|
|
if layer1 is None:
|
|
raise SystemExit(f'no armour layer texture at {args.layer1!r} - pass --layer1')
|
|
|
|
out = []
|
|
for i, (piece, tex) in enumerate((('leggings', layer2 or layer1),
|
|
('boots', layer1),
|
|
('chestplate', layer1),
|
|
('helmet', layer1))):
|
|
quads = mc.vanilla_armour(piece, tex)
|
|
out.append(Placement(f'vanilla {piece}', quads, {}, colour=PALETTE[i]))
|
|
return out
|
|
|
|
|
|
def _armory(res, args):
|
|
"""Armory's own worn pieces, each under the slot transform that places it."""
|
|
chest = json.loads(res.read('data/tm_armory/miapi/modules/armor/chestplate.json'))['slots']
|
|
pants = json.loads(res.read('data/tm_armory/miapi/modules/armor/pants.json'))['slots']
|
|
return chest, pants
|
|
|
|
|
|
PLATES = (
|
|
('left_arm', 'arm_left', 'arm_left/heavy'),
|
|
('right_arm', 'arm_right', 'arm_right/heavy'),
|
|
('left_leg', 'leg_left', 'leg_left/heavy'),
|
|
('right_leg', 'leg_right', 'leg_right/heavy'),
|
|
)
|
|
|
|
SOCKETS = 'src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/socket'
|
|
SPACE = 'src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/space'
|
|
LIMB_GEMS = 'src/main/resources/packs/armory/data/cmmodular/miapi/modules/gem/limb'
|
|
|
|
|
|
def scene_sockets(res, args):
|
|
"""Armory's heavy limbs, plus a gem in each of this mod's four sockets.
|
|
|
|
The gems are the editable ones. Armory's own socketed chestplate is drawn
|
|
alongside as a control: its gem is known to sit on the sternum, so if that
|
|
one lands there the chain is being modelled right and a gem that lands
|
|
somewhere daft is a number to change rather than a bug to chase.
|
|
"""
|
|
chest, pants = _armory(res, args)
|
|
slots = {'arm_left': chest['arm_left'], 'arm_right': chest['arm_right'],
|
|
'leg_left': pants['leg_left'], 'leg_right': pants['leg_right']}
|
|
|
|
gem_model = mc.model_quads(res.model(f'miapi:models/item/armor/gems/{args.gem}/'
|
|
'[material.texture].json', args.variant), res)
|
|
out = []
|
|
for i, (part, slot, model) in enumerate(PLATES):
|
|
limb_tr = slots[slot]['transform']
|
|
limb = mc.transform_matrix(limb_tr)
|
|
plate = mc.model_quads(res.model(f'miapi:models/item/armor/model/{model}/'
|
|
'[material.texture].json', args.variant), res)
|
|
out.append(Placement(f'{slot} plate', plate, limb_tr, colour=(0.62, 0.62, 0.68)))
|
|
|
|
# Our own socket, drawn under the limb so it moves with it.
|
|
try:
|
|
bezel = mc.model_quads(res.model(
|
|
f'cmmodular:models/item/armor/model/{slot}/socket/'
|
|
'[material.texture].json', args.variant), res)
|
|
out.append(Placement(f'{slot} socket', bezel, limb_tr,
|
|
colour=(0.95, 0.72, 0.30)))
|
|
except KeyError:
|
|
pass
|
|
|
|
path = os.path.join(REPO, SOCKETS, f'{slot}.json')
|
|
with open(path) as fh:
|
|
module = json.load(fh)
|
|
gem_tr = module['data']['replace']['slots']['gem']['transform']
|
|
out.append(Placement(
|
|
f'{slot} gem', gem_model, gem_tr, outer=limb, chain_origin=part,
|
|
source=(path, ['data', 'replace', 'slots', 'gem', 'transform']),
|
|
colour=PALETTE[i]))
|
|
|
|
# Armory's socketed front chest, unedited, as the control.
|
|
body_tr = chest['chest_front']['transform']
|
|
out.append(Placement(
|
|
'armory chest (control)',
|
|
mc.model_quads(res.model('miapi:models/item/armor/model/chest_front/socket/'
|
|
'[material.texture].json', args.variant), res),
|
|
body_tr, colour=(0.55, 0.55, 0.62)))
|
|
out.append(Placement(
|
|
'armory chest gem (control)', gem_model,
|
|
{'translation': {'y': -1}, 'scale': {'x': 1.1, 'y': 1.1, 'z': 1.1},
|
|
'origin': 'body'},
|
|
outer=mc.transform_matrix(body_tr), chain_origin='body',
|
|
colour=(0.95, 0.95, 0.55)))
|
|
return out
|
|
|
|
|
|
def scene_armory(res, args):
|
|
"""The whole heavy set worn, as a check on the JSON-model path."""
|
|
chest, pants = _armory(res, args)
|
|
helmet = json.loads(res.read('data/tm_armory/miapi/modules/armor/helmet.json'))['slots']
|
|
pieces = [
|
|
('body', chest['chest_front'], 'chest_front/heavy'),
|
|
('body', chest['chest_back'], 'chest_back/heavy'),
|
|
('left_arm', chest['arm_left'], 'arm_left/heavy'),
|
|
('right_arm', chest['arm_right'], 'arm_right/heavy'),
|
|
('body', pants['belt'], 'belt/heavy'),
|
|
('left_leg', pants['leg_left'], 'leg_left/heavy'),
|
|
('right_leg', pants['leg_right'], 'leg_right/heavy'),
|
|
('head', helmet['hat'], 'helmet/heavy'),
|
|
]
|
|
out = []
|
|
for i, (part, slot, model) in enumerate(pieces):
|
|
quads = mc.model_quads(res.model(f'miapi:models/item/armor/model/{model}/'
|
|
'[material.texture].json', args.variant), res)
|
|
out.append(Placement(model, quads, slot['transform'],
|
|
colour=PALETTE[i % len(PALETTE)]))
|
|
return out
|
|
|
|
|
|
def scene_space(res, args):
|
|
"""The space set, composed the way MIAPI composes it rather than by slot alone.
|
|
|
|
A module's model carries a transform of its own, and the slot above it
|
|
carries another; `ModuleModel` looks up `SlotProperty.getTransformStack(key)`
|
|
and the model's matrix applies inside it. Drawing the slot transform alone -
|
|
which an earlier version of this scene did - shows the model at whatever size
|
|
it was drawn rather than the size it renders at, and the chest_back slot
|
|
scales by (1.25, 1.1, 1.5), so that is the difference between a backpack and
|
|
a slab wider than the torso. Both transforms are read from the module JSON
|
|
here, so the picture moves when the files do, and the module transform is the
|
|
editable one.
|
|
|
|
Entries whose origin is not a body part are skipped rather than drawn at the
|
|
torso: MIAPI matches a model to a part by name, so those never appear on a
|
|
wearer at all - they are the inventory model.
|
|
"""
|
|
chest = json.loads(res.read('data/tm_armory/miapi/modules/armor/chestplate.json'))['slots']
|
|
helmet = json.loads(res.read('data/tm_armory/miapi/modules/armor/helmet.json'))['slots']
|
|
# part, module file, and the slot transform carrying it. Nothing here is a
|
|
# root module: a helmet plate sits in the helmet item's `hat` slot, whose
|
|
# transform lifts it from the neck onto the head (`y -3.925`, the head's own
|
|
# centre) and scales it up a quarter. Leaving that out drops every helmet
|
|
# four pixels and makes it look like the model is wrong when it is not.
|
|
pieces = (
|
|
('body', 'life_support_socket', chest['chest_back']['transform']),
|
|
('head', 'helmet_socket', helmet['hat']['transform']),
|
|
)
|
|
out = []
|
|
for i, (part, name, outer_tr) in enumerate(pieces):
|
|
path = os.path.join(REPO, SPACE, f'{name}.json')
|
|
with open(path) as fh:
|
|
module = json.load(fh)
|
|
entries = module['data']['replace']['model']
|
|
for j, entry in enumerate(entries):
|
|
tr = entry['transform']
|
|
if tr.get('origin') not in mc.BODY_PARTS:
|
|
continue
|
|
try:
|
|
quads = mc.model_quads(res.model(entry['path'], args.variant), res)
|
|
except KeyError:
|
|
print(f'missing model {entry["path"]}')
|
|
continue
|
|
out.append(Placement(
|
|
f'{name} ({tr["origin"]})', quads, tr,
|
|
outer=mc.transform_matrix(outer_tr) if outer_tr else None,
|
|
chain_origin=part,
|
|
source=(path, ['data', 'replace', 'model', j, 'transform']),
|
|
colour=PALETTE[i % len(PALETTE)]))
|
|
|
|
# Armory's own back plate and helmet, unedited, as the controls: both are
|
|
# known to sit right, so a piece of ours that lands somewhere else is a
|
|
# number to change rather than a chain to doubt.
|
|
out.append(Placement(
|
|
'armory back plate (control)',
|
|
mc.model_quads(res.model('miapi:models/item/armor/model/chest_back/base/'
|
|
'[material.texture].json', args.variant), res),
|
|
chest['chest_back']['transform'], colour=(0.55, 0.55, 0.62)))
|
|
out.append(Placement(
|
|
'armory helmet (control)',
|
|
mc.model_quads(res.model('miapi:models/item/armor/model/helmet/base/'
|
|
'[material.texture].json', args.variant), res),
|
|
{'rotation': {'x': 0, 'y': 0, 'z': 0}, 'translation': {'x': 0, 'y': 0, 'z': 0},
|
|
'scale': {'x': 1, 'y': 1, 'z': 1}, 'origin': 'head'},
|
|
colour=(0.62, 0.62, 0.68)))
|
|
return out
|
|
|
|
|
|
def scene_icons(res, args):
|
|
"""The four inventory icons, side by side, each with its gem on it.
|
|
|
|
This is the other half of a socket and the half that is actually visible:
|
|
Armory's gemstone model declares no origin, and MIAPI only draws an
|
|
origin-less model in the `item` pass, so the icon is the one place a gem in
|
|
these slots is ever drawn. It is also read from a different entry of the
|
|
transform stack - the one a transform lands in when it names no origin - so
|
|
a slot transform aimed at the body reaches the worn piece and nothing else.
|
|
"""
|
|
out = []
|
|
for i, (part, slot, _model) in enumerate(PLATES):
|
|
# The icon offset lives on the gem module's origin-less model entry.
|
|
# The slot transform names a limb now, so it is filed under that limb
|
|
# and cannot reach the icon, which is drawn from the entry with no
|
|
# origin at all.
|
|
path = os.path.join(REPO, LIMB_GEMS, f'gem_{slot}.json')
|
|
with open(path) as fh:
|
|
module = json.load(fh)
|
|
idx = next(j for j, m in enumerate(module['model'])
|
|
if 'origin' not in m.get('transform', {}))
|
|
gem_tr = module['model'][idx]['transform']
|
|
spot = (i * 20.0 - 30.0, 0.0, 0.0)
|
|
|
|
icon = mc.model_quads(res.model(
|
|
f'miapi:models/item/armor/gui/heavy/{slot}/base/[material.texture].json',
|
|
args.variant), res, y_up=True)
|
|
out.append(Placement(f'{slot} icon', icon, {}, offset=spot,
|
|
colour=(0.62, 0.62, 0.68)))
|
|
|
|
gem = mc.model_quads(res.model(
|
|
f'miapi:models/item/armor/gems/{args.gem}/[material.texture].json',
|
|
args.variant), res, y_up=True)
|
|
out.append(Placement(
|
|
f'{slot} gem (icon)', gem, gem_tr, offset=spot,
|
|
source=(path, ['model', idx, 'transform']),
|
|
colour=PALETTE[i]))
|
|
return out
|
|
|
|
|
|
SCENES = {'vanilla': scene_vanilla, 'sockets': scene_sockets,
|
|
'armory': scene_armory, 'icons': scene_icons, 'space': scene_space}
|
|
|
|
# Scenes drawn in item space rather than on a body: +y is up and the camera
|
|
# has to agree, or every icon renders upside down and every offset written
|
|
# from looking at it is inverted.
|
|
ICON_SCENES = {'icons'}
|
|
|
|
|
|
# --------------------------------------------------------------------- meshing
|
|
|
|
|
|
def _texture_is_blank(image, uv):
|
|
"""True when a face's uv rectangle lands entirely on transparent pixels.
|
|
|
|
Armour layer textures are mostly empty, and a model that names a face it
|
|
never drew leaves a fully transparent quad which VTK will happily depth-test
|
|
against and punch a hole with. Dropping them is cheaper than sorting them.
|
|
"""
|
|
w, h = image.size
|
|
u0, v0 = uv.min(axis=0)
|
|
u1, v1 = uv.max(axis=0)
|
|
box = (max(0, int(math.floor(u0 * w))), max(0, int(math.floor(v0 * h))),
|
|
min(w, max(1, int(math.ceil(u1 * w)))), min(h, max(1, int(math.ceil(v1 * h)))))
|
|
if box[2] <= box[0] or box[3] <= box[1]:
|
|
return True
|
|
alpha = image.crop(box).split()[3]
|
|
return alpha.getextrema()[1] < 128
|
|
|
|
|
|
def build_meshes(res, quads, shape_count=None, by_colour=False, hue=None):
|
|
"""Quads -> [(PolyData, texture id, colour)], grouped so one draw is one look.
|
|
|
|
Textured, that means one mesh per texture. Untextured, `by_colour` splits
|
|
further so each mesh is a single flat colour - hue per shape, shade per
|
|
face. That is a few more actors than handing VTK a per-cell colour array
|
|
would be, and it is what this does because the array route draws nothing at
|
|
all in these wheels: `rgb=True` over cell data comes back empty whether or
|
|
not the mesh carries texture coordinates, while a plain `color=` works.
|
|
|
|
`shape_count` is how many boxes the model has. Pass it when you know it,
|
|
because a box whose every face was culled would otherwise shift the hues
|
|
off the ones the unwrap template was drawn with.
|
|
|
|
`hue` hands the whole model a colour of its own instead, for a scene of
|
|
several models where the question is which model a face belongs to rather
|
|
than which box.
|
|
"""
|
|
import pyvista as pv
|
|
|
|
if shape_count is None:
|
|
shape_count = max((q.shape for q in quads), default=0) + 1
|
|
|
|
groups = {}
|
|
for q in quads:
|
|
image = None
|
|
if q.texture:
|
|
try:
|
|
image = res.image(q.texture)
|
|
except KeyError:
|
|
image = None
|
|
if image is not None and _texture_is_blank(image, q.uv):
|
|
continue
|
|
tex = q.texture if image is not None else None
|
|
colour = mc.face_colour(q.shape, shape_count, q.face, hue) if by_colour else None
|
|
groups.setdefault((tex, colour), []).append(q)
|
|
|
|
out = []
|
|
for (tex, colour), group in groups.items():
|
|
pts = np.concatenate([q.pts for q in group])
|
|
uvs = np.concatenate([q.uv for q in group])
|
|
faces = np.hstack([[4, *range(4 * i, 4 * i + 4)] for i in range(len(group))])
|
|
mesh = pv.PolyData(pts, faces)
|
|
# VTK samples textures from the bottom up; model uv runs from the top.
|
|
mesh.active_texture_coordinates = np.column_stack([uvs[:, 0], 1.0 - uvs[:, 1]])
|
|
out.append((mesh, tex, colour))
|
|
return out
|
|
|
|
|
|
def make_texture(res, ref):
|
|
"""A Minecraft texture as VTK understands it: nearest sampled, cutout alpha.
|
|
|
|
The alpha is snapped to 0 or 255 first. Minecraft draws armour as a cutout
|
|
- a texel is either there or it is not - and a stray 247 left behind by
|
|
whoever drew the sheet is enough to tip VTK into blending the whole actor,
|
|
which makes a solid plate you can see the far side of.
|
|
"""
|
|
import pyvista as pv
|
|
|
|
rgba = np.asarray(res.image(ref)).copy()
|
|
rgba[..., 3] = np.where(rgba[..., 3] >= 128, 255, 0)
|
|
tex = pv.Texture(rgba)
|
|
tex.SetInterpolate(False) # Minecraft art is pixels, not a photograph
|
|
tex.SetRepeat(False)
|
|
tex.SetEdgeClamp(True)
|
|
return tex
|
|
|
|
|
|
# -------------------------------------------------------------------- viewport
|
|
|
|
|
|
VIEWS = [('front', 0, 0), ('front-left', 35, 12), ('left', 90, 0),
|
|
('above-left', 45, 35), ('back', 180, 0), ('below-left', 40, -30)]
|
|
|
|
HELP = """\
|
|
drag orbit scroll zoom
|
|
left/right move x-/x+ up/down move y-/y+
|
|
pgup/pgdn move z-/z+ shift x5 step
|
|
[ ] step size 1..6 rotate x/y/z -/+
|
|
- = scale tab \\ next/previous module
|
|
t textures f slot chain on/off
|
|
b body figure a axes
|
|
u revert s save to json
|
|
c print values p screenshot q quit"""
|
|
|
|
|
|
class Viewport:
|
|
"""A window on the scene, and the keys that move things around in it."""
|
|
|
|
def __init__(self, res, placements, args):
|
|
import pyvista as pv
|
|
|
|
self.res, self.placements, self.args = res, placements, args
|
|
self.force_chain = args.chain
|
|
self.textured = not args.no_textures
|
|
self.lossy = not args.exact_merge
|
|
self.step = args.step
|
|
self.turn_step = 7.5
|
|
self.show_body = args.body
|
|
self.dirty = set()
|
|
|
|
editable = [i for i, p in enumerate(placements) if p.editable]
|
|
self.selection = editable[0] if editable else 0
|
|
|
|
self.pl = pv.Plotter(off_screen=args.shot is not None,
|
|
window_size=tuple(args.size), lighting='none')
|
|
self.pl.set_background(args.background)
|
|
self.pl.enable_depth_peeling(number_of_peels=8, occlusion_ratio=0.0)
|
|
self._add_lights()
|
|
self.actors = {}
|
|
self.rebuild()
|
|
|
|
if args.shot is None:
|
|
self._bind_keys()
|
|
|
|
# Vanilla lights an entity in the inventory with two directional sources and
|
|
# a good deal of ambient; anything more dramatic makes a flat plate look
|
|
# curved, which is the opposite of useful here.
|
|
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.pl.add_light(light)
|
|
|
|
# ------------------------------------------------------------- geometry
|
|
|
|
def rebuild(self):
|
|
"""Re-mesh everything and re-add it. Cheap at this size, and it keeps
|
|
the drawn thing and the numbers from ever drifting apart."""
|
|
for name in list(self.actors):
|
|
self.pl.remove_actor(self.actors.pop(name))
|
|
|
|
for i, p in enumerate(self.placements):
|
|
quads = p.world_quads(self.force_chain, self.lossy)
|
|
meshes = build_meshes(self.res, quads, by_colour=not self.textured)
|
|
for j, (mesh, tex, colour) in enumerate(meshes):
|
|
kw = dict(smooth_shading=False, ambient=0.42, diffuse=0.78,
|
|
specular=0.0, show_edges=False)
|
|
if self.textured and tex is not None:
|
|
kw['texture'] = make_texture(self.res, tex)
|
|
kw['color'] = 'white'
|
|
else:
|
|
# Hue per box, shade per face, the same key the unwrap
|
|
# template is drawn with.
|
|
kw['color'] = colour or p.colour
|
|
name = f'p{i}_{j}'
|
|
self.actors[name] = self.pl.add_mesh(mesh, name=name, **kw)
|
|
|
|
if self.show_body:
|
|
body = mc.humanoid_body()
|
|
pts = np.concatenate([q.pts for q in body])
|
|
import pyvista as pv
|
|
faces = np.hstack([[4, *range(4 * k, 4 * k + 4)] for k in range(len(body))])
|
|
self.actors['body'] = self.pl.add_mesh(
|
|
pv.PolyData(pts, faces), name='body', color=(0.30, 0.32, 0.38),
|
|
opacity=0.28, smooth_shading=False, specular=0.0)
|
|
|
|
self._highlight()
|
|
self._hud()
|
|
|
|
def _highlight(self):
|
|
import pyvista as pv
|
|
|
|
self.pl.remove_actor(self.actors.pop('selection', None))
|
|
p = self.placements[self.selection]
|
|
if not p.editable:
|
|
return
|
|
quads = p.world_quads(self.force_chain, self.lossy)
|
|
if not quads:
|
|
return
|
|
pts = np.concatenate([q.pts for q in quads])
|
|
box = pv.Box(bounds=(pts[:, 0].min(), pts[:, 0].max(),
|
|
pts[:, 1].min(), pts[:, 1].max(),
|
|
pts[:, 2].min(), pts[:, 2].max()))
|
|
self.actors['selection'] = self.pl.add_mesh(
|
|
box.outline(), name='selection', color=(1.0, 0.85, 0.2),
|
|
line_width=2, lighting=False)
|
|
|
|
def _hud(self):
|
|
if self.args.shot is not None:
|
|
return
|
|
p = self.placements[self.selection]
|
|
mark = '*' if self.selection in self.dirty else ' '
|
|
lines = [
|
|
f'{mark}{p.name}{"" if p.editable else " (read only)"}'
|
|
f' [{p.origin}{"" if p.auto_chain else ", unchained"}]',
|
|
f' {p.summary()}',
|
|
f' step {self.step} chain {"forced" if self.force_chain else "auto"}'
|
|
f' textures {"on" if self.textured else "off"}'
|
|
f' merge {"as MIAPI does" if self.lossy else "exact, shear kept"}',
|
|
'',
|
|
HELP,
|
|
]
|
|
self.pl.add_text('\n'.join(lines), position='upper_left', font_size=8,
|
|
font='courier', color=(0.86, 0.87, 0.92), name='hud')
|
|
|
|
# ---------------------------------------------------------------- keys
|
|
|
|
def _bind_keys(self):
|
|
move = {'Left': ('x', -1), 'Right': ('x', 1), 'Up': ('y', -1),
|
|
'Down': ('y', 1), 'Prior': ('z', -1), 'Next': ('z', 1)}
|
|
for key, (axis, sign) in move.items():
|
|
self.pl.add_key_event(key, self._mover(axis, sign))
|
|
turns = {'1': ('x', -1), '2': ('x', 1), '3': ('y', -1),
|
|
'4': ('y', 1), '5': ('z', -1), '6': ('z', 1)}
|
|
for key, (axis, sign) in turns.items():
|
|
self.pl.add_key_event(key, self._turner(axis, sign))
|
|
|
|
self.pl.add_key_event('bracketleft', lambda: self._set_step(0.5))
|
|
self.pl.add_key_event('bracketright', lambda: self._set_step(2.0))
|
|
self.pl.add_key_event('minus', lambda: self._scale(1 / 1.05))
|
|
self.pl.add_key_event('equal', lambda: self._scale(1.05))
|
|
self.pl.add_key_event('Tab', lambda: self._select(1))
|
|
self.pl.add_key_event('backslash', lambda: self._select(-1))
|
|
self.pl.add_key_event('t', self._toggle_textures)
|
|
self.pl.add_key_event('f', self._toggle_chain)
|
|
self.pl.add_key_event('b', self._toggle_body)
|
|
self.pl.add_key_event('a', self._toggle_axes)
|
|
self.pl.add_key_event('u', self._revert)
|
|
self.pl.add_key_event('s', self._save)
|
|
self.pl.add_key_event('c', self._print)
|
|
self.pl.add_key_event('p', self._snap)
|
|
self.pl.add_key_event('h', lambda: print(HELP))
|
|
|
|
def _shift(self):
|
|
try:
|
|
return bool(self.pl.iren.interactor.GetShiftKey())
|
|
except Exception:
|
|
return False
|
|
|
|
def _mover(self, axis, sign):
|
|
def go():
|
|
p = self.placements[self.selection]
|
|
if not p.editable:
|
|
return self._deny()
|
|
p.nudge(axis, sign * self.step * (5 if self._shift() else 1))
|
|
self.dirty.add(self.selection)
|
|
self.rebuild()
|
|
return go
|
|
|
|
def _turner(self, axis, sign):
|
|
def go():
|
|
p = self.placements[self.selection]
|
|
if not p.editable:
|
|
return self._deny()
|
|
p.turn(axis, sign * self.turn_step * (5 if self._shift() else 1))
|
|
self.dirty.add(self.selection)
|
|
self.rebuild()
|
|
return go
|
|
|
|
def _scale(self, factor):
|
|
p = self.placements[self.selection]
|
|
if not p.editable:
|
|
return self._deny()
|
|
p.resize(factor)
|
|
self.dirty.add(self.selection)
|
|
self.rebuild()
|
|
|
|
def _deny(self):
|
|
print(f'{self.placements[self.selection].name} is read only')
|
|
|
|
def _set_step(self, factor):
|
|
self.step = round(min(4.0, max(0.0125, self.step * factor)), 4)
|
|
self._hud()
|
|
|
|
def _select(self, delta):
|
|
n = len(self.placements)
|
|
self.selection = (self.selection + delta) % n
|
|
self._highlight()
|
|
self._hud()
|
|
|
|
def _toggle_textures(self):
|
|
self.textured = not self.textured
|
|
self.rebuild()
|
|
|
|
def _toggle_chain(self):
|
|
self.force_chain = not self.force_chain
|
|
self.rebuild()
|
|
|
|
def _toggle_body(self):
|
|
self.show_body = not self.show_body
|
|
self.rebuild()
|
|
|
|
def _toggle_axes(self):
|
|
if getattr(self, '_axes_on', False):
|
|
self.pl.hide_axes()
|
|
else:
|
|
self.pl.show_axes()
|
|
self._axes_on = not getattr(self, '_axes_on', False)
|
|
|
|
def _revert(self):
|
|
self.placements[self.selection].revert()
|
|
self.dirty.discard(self.selection)
|
|
self.rebuild()
|
|
|
|
def _print(self):
|
|
p = self.placements[self.selection]
|
|
print(f'{p.name}: {json.dumps(p.transform, indent=2)}')
|
|
|
|
def _snap(self):
|
|
path = os.path.abspath(self.args.shot or 'armour-editor.png')
|
|
self.pl.screenshot(path)
|
|
print(f'wrote {path}')
|
|
|
|
def _save(self):
|
|
saved = 0
|
|
for i in sorted(self.dirty):
|
|
p = self.placements[i]
|
|
if not p.editable:
|
|
continue
|
|
write_transform(p)
|
|
print(f'saved {p.name} -> {os.path.relpath(p.source[0], REPO)}')
|
|
p.original = json.loads(json.dumps(p.transform))
|
|
saved += 1
|
|
self.dirty.clear()
|
|
if not saved:
|
|
print('nothing changed')
|
|
self._hud()
|
|
|
|
# --------------------------------------------------------------- camera
|
|
|
|
def aim(self, yaw=25.0, pitch=12.0, distance=52.0, target=None):
|
|
"""Orbital camera. Yaw 0 is the wearer's front, which is -z."""
|
|
target = tuple(self.args.target) if target is None else target
|
|
up = 1.0 if self.args.scene in ICON_SCENES else -1.0
|
|
a, b = math.radians(yaw), math.radians(pitch)
|
|
eye = (target[0] - distance * math.sin(a) * math.cos(b),
|
|
target[1] + up * distance * math.sin(b),
|
|
target[2] - distance * math.cos(a) * math.cos(b))
|
|
self.pl.camera.position = eye
|
|
self.pl.camera.focal_point = target
|
|
self.pl.camera.up = (0.0, up, 0.0) # +y is down on a body, up on an icon
|
|
self.pl.camera.view_angle = 34.0
|
|
# pyvista resets the camera the first time a mesh is added unless it is
|
|
# told the camera is already aimed, which would undo every aim() below.
|
|
# That also suppresses the clipping-range reset, and a near plane left
|
|
# where the last view put it slices the figure in half - so ask for
|
|
# that one explicitly. It moves the planes, never the camera.
|
|
self.pl.camera_set = True
|
|
self.pl.renderer.reset_camera_clipping_range()
|
|
|
|
def show(self):
|
|
self.aim(self.args.yaw, self.args.pitch, self.args.distance)
|
|
self.pl.show(title='armour editor')
|
|
|
|
def montage(self, path):
|
|
"""One panel per view, tiled - the headless equivalent of orbiting."""
|
|
from PIL import Image
|
|
|
|
from PIL import ImageDraw
|
|
|
|
views = [('icons', 180, 0)] if self.args.scene in ICON_SCENES else VIEWS
|
|
shots = []
|
|
for label, yaw, pitch in views:
|
|
self.aim(yaw, pitch, self.args.distance)
|
|
self.pl.render() # screenshot hands back the last buffer, not a new one
|
|
img = Image.fromarray(self.pl.screenshot(return_img=True))
|
|
ImageDraw.Draw(img).text((8, 6), f'{label} yaw {yaw} pitch {pitch}',
|
|
fill=(190, 190, 200))
|
|
shots.append((label, img))
|
|
cols = min(3, len(shots))
|
|
rows = (len(shots) + cols - 1) // cols
|
|
w, h = shots[0][1].size
|
|
sheet = Image.new('RGB', (w * cols, h * rows))
|
|
for i, (label, img) in enumerate(shots):
|
|
sheet.paste(img, ((i % cols) * w, (i // cols) * h))
|
|
sheet.save(path)
|
|
return path
|
|
|
|
|
|
# ----------------------------------------------------------------- json write
|
|
|
|
|
|
def write_transform(placement):
|
|
"""Put the edited numbers back where they came from.
|
|
|
|
Only the three vectors are touched; `origin`, and every sibling key in the
|
|
slot, are left exactly as they were, because a placement is the only thing
|
|
this tool has any business changing.
|
|
"""
|
|
path, keys = placement.source
|
|
with open(path) as fh:
|
|
doc = json.load(fh)
|
|
node = doc
|
|
for key in keys[:-1]:
|
|
node = node[key]
|
|
target = node.setdefault(keys[-1], {})
|
|
for group, dflt in (('translation', 0.0), ('rotation', 0.0), ('scale', 1.0)):
|
|
values = placement.transform.get(group)
|
|
if not values:
|
|
continue
|
|
clean = {a: round(float(values.get(a, dflt)), 4) + 0.0 for a in 'xyz'}
|
|
if all(v == dflt for v in clean.values()):
|
|
target.pop(group, None)
|
|
else:
|
|
target[group] = clean
|
|
with open(path, 'w') as fh:
|
|
fh.write(_dumps(doc))
|
|
fh.write('\n')
|
|
|
|
|
|
# These files write a vector on one line - `{"x": 0, "y": 90, "z": 0}` - and a
|
|
# tool that reformats every slot it touches makes a two-number change look like
|
|
# a rewrite. Dump normally, then fold the leaf vectors back up.
|
|
_VECTOR = re.compile(r'\{\s*\n\s*("(?:x|y|z)": [^,{}\n]+,?\s*\n\s*){1,3}\}')
|
|
|
|
|
|
def _dumps(doc):
|
|
text = json.dumps(doc, indent=2)
|
|
|
|
def fold(m):
|
|
inner = ' '.join(part.strip() for part in m.group(0)[1:-1].split('\n') if part.strip())
|
|
return '{' + inner + '}'
|
|
|
|
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', 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',
|
|
help='render a montage of angles headlessly instead of opening a window')
|
|
ap.add_argument('--size', nargs=2, type=int, default=[1280, 860], metavar=('W', 'H'))
|
|
ap.add_argument('--variant', default='default', help='material texture variant')
|
|
ap.add_argument('--gem', default='medium', choices=('small', 'medium', 'large'))
|
|
ap.add_argument('--layer1', default='miapi:item/armor/base/iron/layer_1',
|
|
help='armour layer texture for the vanilla scene')
|
|
ap.add_argument('--layer2', default='miapi:item/armor/base/iron/layer_2')
|
|
ap.add_argument('--step', type=float, default=0.1, help='starting nudge, in pixels')
|
|
ap.add_argument('--yaw', type=float, default=25.0)
|
|
ap.add_argument('--pitch', type=float, default=12.0)
|
|
ap.add_argument('--distance', type=float, default=52.0)
|
|
ap.add_argument('--target', nargs=3, type=float, default=[0.0, 8.0, 0.0],
|
|
metavar=('X', 'Y', 'Z'), help='what the camera orbits, in model pixels')
|
|
ap.add_argument('--background', default='#1a1a1e')
|
|
ap.add_argument('--no-textures', action='store_true',
|
|
help='start with every module in its own flat colour')
|
|
ap.add_argument('--body', action='store_true', help='draw the wearer as a reference')
|
|
ap.add_argument('--chain', action='store_true',
|
|
help='force the slot chain on even where MIAPI would file it elsewhere')
|
|
ap.add_argument('--exact-merge', action='store_true',
|
|
help='keep the whole matrix product instead of the Euler round trip'
|
|
' MIAPI does on every merge')
|
|
args = ap.parse_args(argv)
|
|
|
|
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 = Viewport(res, placements, args)
|
|
if args.shot:
|
|
print(view.montage(os.path.abspath(args.shot)))
|
|
else:
|
|
view.show()
|
|
|
|
|
|
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()
|