Tools that draw the armour instead of guessing at it
Five of them, sharing one geometry layer. mcmodel.py reads Minecraft and MIAPI models out of jars and implements both of the game's UV conventions separately - entity cubes for vanilla armour, JSON model faces for every module - because they disagree on four of six faces, which survives an eyeball on a symmetric breastplate and then ruins a pauldron. armour_editor.py places modules and writes the numbers back into the JSON they came from. It replaces what preview_armour.py did and fixes what that got wrong: sorting whole faces by average depth is not a depth buffer, and a gem half-sunk into a plate was sorting arbitrarily - exactly the case the tool existed to judge. VTK rasterises against a real z-buffer instead. armour_gui.py draws new geometry onto a body part, with the origin settable, since that is what decides which part a model is drawn under and therefore what it moves with. It unwraps as it goes and writes a painting template beside the model. The untextured view colours a hue per shape and a shade per face, and the template uses the same key, so one is the legend for the other. material_editor.py edits materials.py by rewriting one argument's source span at a time, so ingots(...) stays a call, the comments stay put, and a two-number change is a two-number diff. The generated JSON is downstream and would be overwritten, so it is not what gets edited. guiplatform.py prefers Wayland and falls back to X11. VTK's wheels have no Wayland backend, but handing Qt the GL context works on both; where that fails the process dies on an X BadAccess that nothing in-process can catch, so the question is asked in a subprocess that renders the same features the tools use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>main
parent
532c3a0da8
commit
5e1b62fd9b
|
|
@ -5,3 +5,6 @@ runs/
|
||||||
repo/
|
repo/
|
||||||
*.class
|
*.class
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
||||||
|
# Python virtualenv for the tools in tools/
|
||||||
|
tools/.venv/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,812 @@
|
||||||
|
#!/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 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. Merging itself is an exact matrix multiply in
|
||||||
|
1.21; `--lossy-merge` reproduces the Euler round trip older versions did, which
|
||||||
|
drops shear wherever a rotation meets a non-uniform scale.
|
||||||
|
|
||||||
|
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 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. Absent means the torso."""
|
||||||
|
return self.transform.get('origin', 'body')
|
||||||
|
|
||||||
|
@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'
|
||||||
|
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_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}
|
||||||
|
|
||||||
|
# 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):
|
||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
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) 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 = args.lossy_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 {"lossy (1.20)" if self.lossy else "exact (1.21)"}',
|
||||||
|
'',
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------ cli
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument('scene', choices=sorted(SCENES), help='which figure to draw')
|
||||||
|
ap.add_argument('--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('--lossy-merge', action='store_true',
|
||||||
|
help='decompose each merge to Euler angles, as MIAPI did before 1.21')
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
sources = list(args.jar) or []
|
||||||
|
sources.append(os.path.join(REPO, 'src/main/resources'))
|
||||||
|
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__':
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,566 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Draw geometry onto a body part, unwrap it, and see it on the wearer.
|
||||||
|
|
||||||
|
The placement editor moves a module that already exists. This makes one. A gem
|
||||||
|
that has to follow an arm has to be drawn *by* the arm - MIAPI picks which body
|
||||||
|
part a model renders under by comparing the model's own `origin` against the
|
||||||
|
part it is drawing, and it renders under that part's animated pose, so a socket
|
||||||
|
cut into `left_arm` swings with the arm and one placed in `body` does not.
|
||||||
|
Armory's gemstone declares no origin at all, which is why it cannot be made to
|
||||||
|
follow a limb from the outside, and why the socket has to be ours.
|
||||||
|
|
||||||
|
So this edits a model file of our own: boxes in the limb's own coordinates,
|
||||||
|
shown against Armory's plate so they can be lined up with it, unwrapped onto a
|
||||||
|
texture that is written out beside them.
|
||||||
|
|
||||||
|
tools/armour_gui.py --jar <armory.jar>
|
||||||
|
|
||||||
|
Left is the box list and the part it belongs to, right is the box being edited,
|
||||||
|
middle is the wearer. Everything is in model pixels, the units the JSON is
|
||||||
|
written in.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
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 pyvistaqt # noqa: E402
|
||||||
|
|
||||||
|
import mcmodel as mc # noqa: E402
|
||||||
|
import armour_editor as ae # noqa: E402
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
ASSETS = os.path.join(REPO, 'src/main/resources/assets/cmmodular')
|
||||||
|
|
||||||
|
# Which Armory slot dresses each body part, and the plate to draw as context.
|
||||||
|
# The key is the `origin` MIAPI matches against, which is the whole point of
|
||||||
|
# choosing a part: it decides what the geometry is pinned to when the wearer
|
||||||
|
# moves.
|
||||||
|
PARTS = {
|
||||||
|
'left_arm': {'slot': 'arm_left', 'piece': 'chestplate', 'plate': 'arm_left/heavy'},
|
||||||
|
'right_arm': {'slot': 'arm_right', 'piece': 'chestplate', 'plate': 'arm_right/heavy'},
|
||||||
|
'left_leg': {'slot': 'leg_left', 'piece': 'pants', 'plate': 'leg_left/heavy'},
|
||||||
|
'right_leg': {'slot': 'leg_right', 'piece': 'pants', 'plate': 'leg_right/heavy'},
|
||||||
|
'body': {'slot': 'chest_front', 'piece': 'chestplate', 'plate': 'chest_front/heavy'},
|
||||||
|
'head': {'slot': 'hat', 'piece': 'helmet', 'plate': 'helmet/heavy'},
|
||||||
|
# Not a body part at all, but the same choice: `item` is the pass that draws
|
||||||
|
# the inventory icon, and geometry filed under it is what the icon shows.
|
||||||
|
# It is on this list because it is the other half of the same decision -
|
||||||
|
# a socket needs one model on the limb and one on the icon, and they are
|
||||||
|
# different files with different origins.
|
||||||
|
'item': {'slot': 'icon', 'piece': None, 'plate': None},
|
||||||
|
}
|
||||||
|
|
||||||
|
WEARER = '(wearer)'
|
||||||
|
|
||||||
|
PART_LABELS = {
|
||||||
|
'left_arm': 'left arm - pauldron', 'right_arm': 'right arm - pauldron',
|
||||||
|
'left_leg': 'left leg - knee', 'right_leg': 'right leg - knee',
|
||||||
|
'body': 'chest', 'head': 'helmet', 'item': 'inventory icon',
|
||||||
|
WEARER: 'the wearer',
|
||||||
|
}
|
||||||
|
|
||||||
|
PIECE_FILES = {
|
||||||
|
'chestplate': 'data/tm_armory/miapi/modules/armor/chestplate.json',
|
||||||
|
'pants': 'data/tm_armory/miapi/modules/armor/pants.json',
|
||||||
|
'helmet': 'data/tm_armory/miapi/modules/armor/helmet.json',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- workpiece
|
||||||
|
|
||||||
|
|
||||||
|
class Workpiece:
|
||||||
|
"""One cmmodular model file: boxes in a body part's own coordinates."""
|
||||||
|
|
||||||
|
def __init__(self, res, part):
|
||||||
|
self.res, self.part = res, part
|
||||||
|
self.slot = PARTS[part]['slot']
|
||||||
|
self.path = os.path.join(
|
||||||
|
ASSETS, f'models/item/armor/model/{self.slot}/socket/default.json')
|
||||||
|
self.texture_ref = f'cmmodular:equipment/{self.slot}_socket'
|
||||||
|
self.texture_path = os.path.join(
|
||||||
|
ASSETS, f'textures/equipment/{self.slot}_socket.png')
|
||||||
|
self.doc = self._load()
|
||||||
|
|
||||||
|
def _load(self):
|
||||||
|
if os.path.isfile(self.path):
|
||||||
|
with open(self.path) as fh:
|
||||||
|
return json.load(fh)
|
||||||
|
return {
|
||||||
|
'comment': f'Socket geometry drawn under {self.part}, so it follows '
|
||||||
|
f'the limb rather than the torso.',
|
||||||
|
'texture_size': [16, 16],
|
||||||
|
'textures': {'0': self.texture_ref, 'particle': self.texture_ref},
|
||||||
|
'elements': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def elements(self):
|
||||||
|
return self.doc.setdefault('elements', [])
|
||||||
|
|
||||||
|
def add_box(self, name='socket', lo=(-1.0, -1.0, -1.0), hi=(1.0, 1.0, 1.0)):
|
||||||
|
self.elements.append({'name': name, 'from': list(lo), 'to': list(hi),
|
||||||
|
'faces': {}})
|
||||||
|
self.unwrap()
|
||||||
|
return len(self.elements) - 1
|
||||||
|
|
||||||
|
def remove(self, index):
|
||||||
|
if 0 <= index < len(self.elements):
|
||||||
|
self.elements.pop(index)
|
||||||
|
self.unwrap()
|
||||||
|
|
||||||
|
def unwrap(self):
|
||||||
|
"""Re-cut the texture so every face has somewhere of its own to live."""
|
||||||
|
if not self.elements:
|
||||||
|
self.doc['texture_size'] = [16, 16]
|
||||||
|
return None
|
||||||
|
size, nets = mc.unwrap(self.elements, texture='#0')
|
||||||
|
self.doc['texture_size'] = [int(size[0]), int(size[1])]
|
||||||
|
return size, nets
|
||||||
|
|
||||||
|
def save(self, write_template=True):
|
||||||
|
packed = self.unwrap()
|
||||||
|
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
||||||
|
with open(self.path, 'w') as fh:
|
||||||
|
fh.write(ae._dumps(self.doc))
|
||||||
|
fh.write('\n')
|
||||||
|
written = [self.path]
|
||||||
|
# Only ever write a template over a texture that is not there yet -
|
||||||
|
# the guide is scaffolding, and overwriting art someone has painted
|
||||||
|
# because the box list changed would be the tool destroying the work
|
||||||
|
# it exists to support.
|
||||||
|
if write_template and packed and not os.path.isfile(self.texture_path):
|
||||||
|
size, nets = packed
|
||||||
|
os.makedirs(os.path.dirname(self.texture_path), exist_ok=True)
|
||||||
|
mc.unwrap_template(size, nets, self.elements).save(self.texture_path)
|
||||||
|
written.append(self.texture_path)
|
||||||
|
return written
|
||||||
|
|
||||||
|
def quads(self, res):
|
||||||
|
model = {'textures': self.doc.get('textures', {}),
|
||||||
|
'elements': self.elements,
|
||||||
|
'texture_size': self.doc.get('texture_size')}
|
||||||
|
return mc.model_quads(model, res)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- window
|
||||||
|
|
||||||
|
|
||||||
|
class ArmourGui(QtWidgets.QMainWindow):
|
||||||
|
def __init__(self, res, args):
|
||||||
|
super().__init__()
|
||||||
|
self.res, self.args = res, args
|
||||||
|
self.setWindowTitle('armour geometry')
|
||||||
|
self.work = Workpiece(res, args.part)
|
||||||
|
self.actors = {}
|
||||||
|
|
||||||
|
splitter = QtWidgets.QSplitter()
|
||||||
|
splitter.addWidget(self._left_panel())
|
||||||
|
self.view = pyvistaqt.QtInteractor(self, rw=guiplatform.render_window())
|
||||||
|
splitter.addWidget(self.view)
|
||||||
|
splitter.addWidget(self._right_panel())
|
||||||
|
splitter.setSizes([230, 900, 250])
|
||||||
|
self.setCentralWidget(splitter)
|
||||||
|
self.statusBar().showMessage(f'{self.work.path}')
|
||||||
|
|
||||||
|
self.view.set_background(args.background)
|
||||||
|
self._add_lights()
|
||||||
|
self._realised = False
|
||||||
|
|
||||||
|
def showEvent(self, event):
|
||||||
|
"""First draw waits for the window.
|
||||||
|
|
||||||
|
A QOpenGLWidget has no GL context until it is on screen, and VTK asked
|
||||||
|
to render before that goes looking for one of its own - which on a
|
||||||
|
Wayland session means a GLX context that cannot be made current.
|
||||||
|
"""
|
||||||
|
super().showEvent(event)
|
||||||
|
if not self._realised:
|
||||||
|
self._realised = True
|
||||||
|
QtCore.QTimer.singleShot(0, self._first_draw)
|
||||||
|
|
||||||
|
def _first_draw(self):
|
||||||
|
# Depth peeling probes the GL context, so it has to wait for one too.
|
||||||
|
self.view.enable_depth_peeling(number_of_peels=8, occlusion_ratio=0.0)
|
||||||
|
self.refresh(reset=True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- panels
|
||||||
|
|
||||||
|
def _left_panel(self):
|
||||||
|
box = QtWidgets.QWidget()
|
||||||
|
lay = QtWidgets.QVBoxLayout(box)
|
||||||
|
|
||||||
|
lay.addWidget(QtWidgets.QLabel('armour parts'))
|
||||||
|
# One list doing both jobs: the tick says whether a part is drawn, the
|
||||||
|
# selection says which one the boxes below belong to. They are the same
|
||||||
|
# question asked twice otherwise - you cannot line a socket up against a
|
||||||
|
# pauldron you have hidden, and the part you are editing is the one you
|
||||||
|
# always want on screen, so selecting a row ticks it.
|
||||||
|
self.parts_list = QtWidgets.QListWidget()
|
||||||
|
self.parts_list.setFixedHeight(150)
|
||||||
|
for part in list(PARTS) + [WEARER]:
|
||||||
|
item = QtWidgets.QListWidgetItem(PART_LABELS.get(part, part))
|
||||||
|
item.setData(QtCore.Qt.UserRole, part)
|
||||||
|
item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable)
|
||||||
|
visible = part == self.args.part or (part == WEARER and self.args.body)
|
||||||
|
item.setCheckState(QtCore.Qt.Checked if visible else QtCore.Qt.Unchecked)
|
||||||
|
self.parts_list.addItem(item)
|
||||||
|
self.parts_list.itemChanged.connect(lambda _: self.refresh())
|
||||||
|
self.parts_list.currentItemChanged.connect(self._parts_selected)
|
||||||
|
self.parts_list.setCurrentRow(list(PARTS).index(self.args.part))
|
||||||
|
self._show_edited_part()
|
||||||
|
lay.addWidget(self.parts_list)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
note = QtWidgets.QLabel('geometry follows the selected part when the '
|
||||||
|
'wearer moves')
|
||||||
|
note.setWordWrap(True)
|
||||||
|
note.setStyleSheet('color: #888;')
|
||||||
|
lay.addWidget(note)
|
||||||
|
|
||||||
|
lay.addWidget(QtWidgets.QLabel('boxes'))
|
||||||
|
self.list = QtWidgets.QListWidget()
|
||||||
|
self.list.currentRowChanged.connect(lambda _: self.refresh())
|
||||||
|
lay.addWidget(self.list, 1)
|
||||||
|
|
||||||
|
for label, slot in (('Add box', self.on_add),
|
||||||
|
('Duplicate', self.on_duplicate),
|
||||||
|
('Remove', self.on_remove),
|
||||||
|
('Unwrap UVs', self.on_unwrap),
|
||||||
|
('Save model + template', self.on_save)):
|
||||||
|
button = QtWidgets.QPushButton(label)
|
||||||
|
button.clicked.connect(slot)
|
||||||
|
lay.addWidget(button)
|
||||||
|
|
||||||
|
self.textured = QtWidgets.QCheckBox('textures')
|
||||||
|
self.textured.setChecked(not self.args.no_textures)
|
||||||
|
self.textured.toggled.connect(lambda _: self.refresh())
|
||||||
|
lay.addWidget(self.textured)
|
||||||
|
return box
|
||||||
|
|
||||||
|
# ------------------------------------------------------- visible parts
|
||||||
|
|
||||||
|
def _rows(self):
|
||||||
|
for i in range(self.parts_list.count()):
|
||||||
|
yield self.parts_list.item(i)
|
||||||
|
|
||||||
|
def _visible(self, part):
|
||||||
|
for item in self._rows():
|
||||||
|
if item.data(QtCore.Qt.UserRole) == part:
|
||||||
|
return item.checkState() == QtCore.Qt.Checked
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _set_all(self, state):
|
||||||
|
self.parts_list.blockSignals(True)
|
||||||
|
for item in self._rows():
|
||||||
|
item.setCheckState(state)
|
||||||
|
self.parts_list.blockSignals(False)
|
||||||
|
self._show_edited_part()
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def _show_edited_part(self):
|
||||||
|
"""The part being edited is never hidden - that would hide the work."""
|
||||||
|
self.parts_list.blockSignals(True)
|
||||||
|
for item in self._rows():
|
||||||
|
part = item.data(QtCore.Qt.UserRole)
|
||||||
|
font = item.font()
|
||||||
|
font.setBold(part == self.args.part)
|
||||||
|
item.setFont(font)
|
||||||
|
if part == self.args.part:
|
||||||
|
item.setCheckState(QtCore.Qt.Checked)
|
||||||
|
self.parts_list.blockSignals(False)
|
||||||
|
|
||||||
|
def _parts_selected(self, item, _previous=None):
|
||||||
|
if item is None:
|
||||||
|
return
|
||||||
|
part = item.data(QtCore.Qt.UserRole)
|
||||||
|
if part == WEARER or part == self.args.part:
|
||||||
|
self._show_edited_part()
|
||||||
|
return
|
||||||
|
self._switch_part(part)
|
||||||
|
|
||||||
|
def _right_panel(self):
|
||||||
|
box = QtWidgets.QWidget()
|
||||||
|
lay = QtWidgets.QFormLayout(box)
|
||||||
|
self.name_edit = QtWidgets.QLineEdit()
|
||||||
|
self.name_edit.editingFinished.connect(self.on_name)
|
||||||
|
lay.addRow('name', self.name_edit)
|
||||||
|
|
||||||
|
self.spins = {}
|
||||||
|
for key in ('from', 'to'):
|
||||||
|
for i, axis in enumerate('xyz'):
|
||||||
|
spin = QtWidgets.QDoubleSpinBox()
|
||||||
|
spin.setRange(-64.0, 64.0)
|
||||||
|
spin.setSingleStep(0.25)
|
||||||
|
spin.setDecimals(3)
|
||||||
|
spin.valueChanged.connect(self.on_spin)
|
||||||
|
self.spins[(key, i)] = spin
|
||||||
|
lay.addRow(f'{key} {axis}', spin)
|
||||||
|
self.size_label = QtWidgets.QLabel('-')
|
||||||
|
lay.addRow('size', self.size_label)
|
||||||
|
self.atlas_label = QtWidgets.QLabel('-')
|
||||||
|
lay.addRow('texture', self.atlas_label)
|
||||||
|
return box
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ actions
|
||||||
|
|
||||||
|
def _switch_part(self, part):
|
||||||
|
self.args.part = part
|
||||||
|
self.work = Workpiece(self.res, part)
|
||||||
|
self._show_edited_part()
|
||||||
|
self.statusBar().showMessage(self.work.path)
|
||||||
|
self.refresh(reset=True)
|
||||||
|
|
||||||
|
def on_add(self):
|
||||||
|
row = self.work.add_box()
|
||||||
|
self.refresh()
|
||||||
|
self.list.setCurrentRow(row)
|
||||||
|
|
||||||
|
def on_duplicate(self):
|
||||||
|
row = self.list.currentRow()
|
||||||
|
if row < 0:
|
||||||
|
return
|
||||||
|
clone = json.loads(json.dumps(self.work.elements[row]))
|
||||||
|
clone['name'] = clone.get('name', 'socket') + ' copy'
|
||||||
|
self.work.elements.append(clone)
|
||||||
|
self.work.unwrap()
|
||||||
|
self.refresh()
|
||||||
|
self.list.setCurrentRow(len(self.work.elements) - 1)
|
||||||
|
|
||||||
|
def on_remove(self):
|
||||||
|
row = self.list.currentRow()
|
||||||
|
if row >= 0:
|
||||||
|
self.work.remove(row)
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def on_unwrap(self):
|
||||||
|
packed = self.work.unwrap()
|
||||||
|
if packed:
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
f'unwrapped onto {packed[0][0]}x{packed[0][1]}')
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def on_save(self):
|
||||||
|
written = self.work.save()
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
'wrote ' + ', '.join(os.path.relpath(p, REPO) for p in written))
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def on_name(self):
|
||||||
|
row = self.list.currentRow()
|
||||||
|
if row >= 0:
|
||||||
|
self.work.elements[row]['name'] = self.name_edit.text()
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def on_spin(self):
|
||||||
|
row = self.list.currentRow()
|
||||||
|
if row < 0 or getattr(self, '_loading', False):
|
||||||
|
return
|
||||||
|
el = self.work.elements[row]
|
||||||
|
for key in ('from', 'to'):
|
||||||
|
el[key] = [self.spins[(key, i)].value() for i in range(3)]
|
||||||
|
self.work.unwrap()
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ drawing
|
||||||
|
|
||||||
|
def refresh(self, reset=False):
|
||||||
|
row = self.list.currentRow()
|
||||||
|
self._sync_list(row)
|
||||||
|
self._sync_fields(row)
|
||||||
|
self._rebuild(reset)
|
||||||
|
|
||||||
|
def _sync_list(self, row):
|
||||||
|
self._loading = True
|
||||||
|
self.list.blockSignals(True)
|
||||||
|
self.list.clear()
|
||||||
|
for i, el in enumerate(self.work.elements):
|
||||||
|
lo, hi = el['from'], el['to']
|
||||||
|
size = [round(abs(b - a), 3) for a, b in zip(lo, hi)]
|
||||||
|
self.list.addItem(f"{i}: {el.get('name', 'box')} {size}")
|
||||||
|
if 0 <= row < self.list.count():
|
||||||
|
self.list.setCurrentRow(row)
|
||||||
|
elif self.list.count():
|
||||||
|
self.list.setCurrentRow(0)
|
||||||
|
self.list.blockSignals(False)
|
||||||
|
self._loading = False
|
||||||
|
|
||||||
|
def _sync_fields(self, row):
|
||||||
|
self._loading = True
|
||||||
|
row = self.list.currentRow()
|
||||||
|
enabled = 0 <= row < len(self.work.elements)
|
||||||
|
for spin in self.spins.values():
|
||||||
|
spin.setEnabled(enabled)
|
||||||
|
self.name_edit.setEnabled(enabled)
|
||||||
|
if enabled:
|
||||||
|
el = self.work.elements[row]
|
||||||
|
self.name_edit.setText(el.get('name', 'box'))
|
||||||
|
for key in ('from', 'to'):
|
||||||
|
for i in range(3):
|
||||||
|
self.spins[(key, i)].setValue(float(el[key][i]))
|
||||||
|
size = [round(abs(b - a), 3) for a, b in zip(el['from'], el['to'])]
|
||||||
|
self.size_label.setText(' x '.join(str(s) for s in size))
|
||||||
|
else:
|
||||||
|
self.name_edit.setText('')
|
||||||
|
self.size_label.setText('-')
|
||||||
|
ts = self.work.doc.get('texture_size', [16, 16])
|
||||||
|
self.atlas_label.setText(f'{ts[0]} x {ts[1]}')
|
||||||
|
self._loading = False
|
||||||
|
|
||||||
|
def _context(self, part=None):
|
||||||
|
"""Armory's plate for a part, so new geometry has something to meet."""
|
||||||
|
spec = PARTS[part or self.args.part]
|
||||||
|
if spec['piece'] is None:
|
||||||
|
return self._icon_context()
|
||||||
|
piece = json.loads(self.res.read(PIECE_FILES[spec['piece']]))['slots']
|
||||||
|
slot = piece[spec['slot']]['transform']
|
||||||
|
plate = mc.model_quads(
|
||||||
|
self.res.model(f"miapi:models/item/armor/model/{spec['plate']}/"
|
||||||
|
'[material.texture].json', self.args.variant), self.res)
|
||||||
|
return slot, plate
|
||||||
|
|
||||||
|
def _icon_context(self):
|
||||||
|
"""The inventory sprite, for aiming icon geometry at.
|
||||||
|
|
||||||
|
The icon is a flat `item/generated` sprite, so the context here is a
|
||||||
|
picture rather than a shape - but it is the picture the gem has to land
|
||||||
|
on, and eyeballing pixel offsets against it beats counting them.
|
||||||
|
"""
|
||||||
|
icon = self.args.icon or 'miapi:models/item/armor/gui/heavy/arm_left/base/' \
|
||||||
|
'[material.texture].json'
|
||||||
|
return {}, mc.model_quads(self.res.model(icon, self.args.variant), self.res)
|
||||||
|
|
||||||
|
def _frame(self, part):
|
||||||
|
"""The matrix and pivot that put a part's model on the wearer."""
|
||||||
|
spec = PARTS[part]
|
||||||
|
if spec['piece'] is None:
|
||||||
|
return np.eye(4), (0.0, 0.0, 0.0)
|
||||||
|
piece = json.loads(self.res.read(PIECE_FILES[spec['piece']]))['slots']
|
||||||
|
return (mc.transform_matrix(piece[spec['slot']]['transform']),
|
||||||
|
mc.PIVOTS.get(part, (0.0, 0.0, 0.0)))
|
||||||
|
|
||||||
|
def _rebuild(self, reset=False):
|
||||||
|
for name in list(self.actors):
|
||||||
|
self.view.remove_actor(self.actors.pop(name))
|
||||||
|
textured = self.textured.isChecked()
|
||||||
|
|
||||||
|
groups = []
|
||||||
|
for part in PARTS:
|
||||||
|
if not self._visible(part):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
matrix, pivot = self._frame(part)
|
||||||
|
_slot, plate = self._context(part)
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
continue
|
||||||
|
groups.append((f'plate_{part}',
|
||||||
|
[q.transformed(matrix, pivot) for q in plate],
|
||||||
|
(0.60, 0.60, 0.66)))
|
||||||
|
|
||||||
|
try:
|
||||||
|
matrix, pivot = self._frame(self.args.part)
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
matrix, pivot = np.eye(4), (0.0, 0.0, 0.0)
|
||||||
|
try:
|
||||||
|
work = self.work.quads(self.res)
|
||||||
|
except Exception:
|
||||||
|
work = []
|
||||||
|
groups.append(('work', [q.transformed(matrix, pivot) for q in work],
|
||||||
|
(0.95, 0.72, 0.30)))
|
||||||
|
|
||||||
|
for tag, quads, colour in groups:
|
||||||
|
if not quads:
|
||||||
|
continue
|
||||||
|
# The workpiece is coloured against its own box count, which is what
|
||||||
|
# its unwrap template was drawn against; the context plate is not
|
||||||
|
# ours and gets a flat colour so the two never look related.
|
||||||
|
count = len(self.work.elements) if tag == 'work' else None
|
||||||
|
meshes = ae.build_meshes(self.res, quads, count,
|
||||||
|
by_colour=(tag == 'work' and not textured))
|
||||||
|
for j, (mesh, tex, face_colour) in enumerate(meshes):
|
||||||
|
kw = dict(smooth_shading=False, ambient=0.42, diffuse=0.78,
|
||||||
|
specular=0.0)
|
||||||
|
if textured and tex is not None:
|
||||||
|
kw['texture'] = ae.make_texture(self.res, tex)
|
||||||
|
kw['color'] = 'white'
|
||||||
|
else:
|
||||||
|
kw['color'] = face_colour or colour
|
||||||
|
name = f'{tag}{j}'
|
||||||
|
self.actors[name] = self.view.add_mesh(mesh, name=name, **kw)
|
||||||
|
|
||||||
|
if self._visible(WEARER):
|
||||||
|
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)
|
||||||
|
|
||||||
|
if reset:
|
||||||
|
self._aim(pivot)
|
||||||
|
if self._realised:
|
||||||
|
self.view.render()
|
||||||
|
|
||||||
|
def _aim(self, pivot):
|
||||||
|
target = (pivot[0], pivot[1] + 2.0, 0.0)
|
||||||
|
self.view.camera.position = (target[0] - 16, target[1] - 6, target[2] - 26)
|
||||||
|
self.view.camera.focal_point = target
|
||||||
|
self.view.camera.up = (0.0, -1.0, 0.0)
|
||||||
|
self.view.camera.view_angle = 34.0
|
||||||
|
self.view.camera_set = True
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument('--jar', action='append', default=[], metavar='PATH')
|
||||||
|
ap.add_argument('--part', default='left_arm', choices=sorted(PARTS))
|
||||||
|
ap.add_argument('--variant', default='default')
|
||||||
|
ap.add_argument('--icon', help='model path to show behind `item` geometry')
|
||||||
|
ap.add_argument('--background', default='#1a1a1e')
|
||||||
|
ap.add_argument('--no-textures', action='store_true')
|
||||||
|
ap.add_argument('--body', 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)
|
||||||
|
|
||||||
|
app = QtWidgets.QApplication(sys.argv[:1])
|
||||||
|
window = ArmourGui(res, args)
|
||||||
|
window.resize(1420, 820)
|
||||||
|
window.show()
|
||||||
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pick a Qt platform that VTK can actually draw into, and say which and why.
|
||||||
|
|
||||||
|
VTK's Python wheels ship no Wayland window backend - the on-screen class is
|
||||||
|
`vtkXOpenGLRenderWindow`, and EGL and OSMesa are both offscreen. Left alone,
|
||||||
|
Qt6 picks Wayland whenever `WAYLAND_DISPLAY` is set while VTK goes on creating
|
||||||
|
an X window, and the two disagree about who owns the surface: the process dies
|
||||||
|
with `BadWindow (X_ConfigureWindow)` before anything is drawn.
|
||||||
|
|
||||||
|
There is a way out, and it is worth taking rather than forcing everyone onto
|
||||||
|
XWayland. Asking VTK for a `vtkGenericOpenGLRenderWindow` and Qt's widget for
|
||||||
|
its `QOpenGLWidget` base puts *Qt* in charge of the GL context; VTK then draws
|
||||||
|
into a context it did not create and never touches X. That works natively on
|
||||||
|
Wayland, and on X11 as well, so it is the path used either way.
|
||||||
|
|
||||||
|
So the order of preference is Wayland, then X11, then offscreen - and the
|
||||||
|
choice is announced once, because a tool that silently moves you to XWayland is
|
||||||
|
a tool that will be blamed for the missing fractional scaling.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
def configure(prefer=None, quiet=False):
|
||||||
|
"""Set the Qt platform and VTK widget base. Call before importing Qt.
|
||||||
|
|
||||||
|
Returns the platform name chosen. `prefer` forces one; `QT_QPA_PLATFORM`
|
||||||
|
set in the environment wins over both, because someone who set it meant it.
|
||||||
|
"""
|
||||||
|
chosen = os.environ.get('QT_QPA_PLATFORM')
|
||||||
|
if not chosen:
|
||||||
|
chosen = prefer or _detect()
|
||||||
|
os.environ['QT_QPA_PLATFORM'] = chosen
|
||||||
|
origin = 'detected'
|
||||||
|
else:
|
||||||
|
origin = 'from QT_QPA_PLATFORM'
|
||||||
|
|
||||||
|
# Must happen before QVTKRenderWindowInteractor is imported: the module
|
||||||
|
# picks its base class at import time and caches it.
|
||||||
|
import vtkmodules.qt
|
||||||
|
vtkmodules.qt.QVTKRWIBase = 'QOpenGLWidget'
|
||||||
|
|
||||||
|
if not quiet:
|
||||||
|
note = ''
|
||||||
|
if origin == 'detected' and chosen == 'xcb' and os.environ.get('WAYLAND_DISPLAY'):
|
||||||
|
note = ' - Wayland is present but its GL context failed a test frame'
|
||||||
|
print(f'display: {chosen} ({origin}){note}', file=sys.stderr)
|
||||||
|
if chosen == 'offscreen':
|
||||||
|
print('display: no Wayland or X11 session - rendering to files only',
|
||||||
|
file=sys.stderr)
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
|
||||||
|
def _detect():
|
||||||
|
wayland = os.environ.get('WAYLAND_DISPLAY') and _socket_exists()
|
||||||
|
x11 = bool(os.environ.get('DISPLAY'))
|
||||||
|
if wayland and _probe('wayland'):
|
||||||
|
return 'wayland'
|
||||||
|
if x11:
|
||||||
|
return 'xcb'
|
||||||
|
return 'wayland' if wayland else 'offscreen'
|
||||||
|
|
||||||
|
|
||||||
|
# The failure this guards against is not an exception. A Wayland session whose
|
||||||
|
# GL stack cannot make the context current - WSLg with a software Mesa is the
|
||||||
|
# common one - takes the whole process down with an X `BadAccess` on the first
|
||||||
|
# paint, after the window is already up. Nothing in-process can catch that, so
|
||||||
|
# the question gets asked in a process we can afford to lose.
|
||||||
|
_PROBE = """
|
||||||
|
import os, sys
|
||||||
|
import vtkmodules.qt
|
||||||
|
vtkmodules.qt.QVTKRWIBase = 'QOpenGLWidget'
|
||||||
|
from vtkmodules.vtkRenderingOpenGL2 import vtkGenericOpenGLRenderWindow
|
||||||
|
from PySide6 import QtCore, QtWidgets
|
||||||
|
import pyvistaqt, pyvista as pv
|
||||||
|
app = QtWidgets.QApplication(['probe'])
|
||||||
|
win = QtWidgets.QMainWindow()
|
||||||
|
view = pyvistaqt.QtInteractor(win, rw=vtkGenericOpenGLRenderWindow())
|
||||||
|
win.setCentralWidget(view)
|
||||||
|
win.resize(64, 64)
|
||||||
|
win.show()
|
||||||
|
def go():
|
||||||
|
# Exercise what the tools actually ask for. A bare cube survives GL stacks
|
||||||
|
# that fall over on a textured, depth-peeled scene, and a probe that passes
|
||||||
|
# where the app crashes is worse than no probe at all.
|
||||||
|
import numpy as np
|
||||||
|
view.enable_depth_peeling(number_of_peels=8, occlusion_ratio=0.0)
|
||||||
|
mesh = pv.Cube()
|
||||||
|
mesh.active_texture_coordinates = np.random.rand(mesh.n_points, 2).astype(np.float32)
|
||||||
|
tex = pv.Texture(np.random.randint(0, 255, (8, 8, 4), dtype=np.uint8))
|
||||||
|
tex.SetInterpolate(False)
|
||||||
|
view.add_mesh(mesh, texture=tex)
|
||||||
|
view.add_light(pv.Light(position=(1, 1, 1), light_type='scene light'))
|
||||||
|
view.render()
|
||||||
|
view.screenshot(sys.argv[1] + '.png')
|
||||||
|
open(sys.argv[1], 'w').write('ok')
|
||||||
|
app.quit()
|
||||||
|
QtCore.QTimer.singleShot(0, go)
|
||||||
|
app.exec()
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _probe(platform, timeout=25):
|
||||||
|
"""Does a real VTK viewport survive its first frame on this platform?"""
|
||||||
|
if os.environ.get('MIAPI_TOOLS_NO_PROBE'):
|
||||||
|
return False
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
flag = os.path.join(tmp, 'flag')
|
||||||
|
env = dict(os.environ, QT_QPA_PLATFORM=platform)
|
||||||
|
env.pop('MIAPI_TOOLS_NO_PROBE', None)
|
||||||
|
try:
|
||||||
|
subprocess.run([sys.executable, '-c', _PROBE, flag], env=env,
|
||||||
|
timeout=timeout, stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL)
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
|
return False
|
||||||
|
return os.path.exists(flag)
|
||||||
|
|
||||||
|
|
||||||
|
def _socket_exists():
|
||||||
|
"""A `WAYLAND_DISPLAY` with no socket behind it is a stale export."""
|
||||||
|
name = os.environ.get('WAYLAND_DISPLAY', '')
|
||||||
|
if os.path.isabs(name):
|
||||||
|
return os.path.exists(name)
|
||||||
|
runtime = os.environ.get('XDG_RUNTIME_DIR')
|
||||||
|
return bool(runtime) and os.path.exists(os.path.join(runtime, name))
|
||||||
|
|
||||||
|
|
||||||
|
def render_window():
|
||||||
|
"""The render window that lets Qt own the context. Import Qt first."""
|
||||||
|
from vtkmodules.vtkRenderingOpenGL2 import vtkGenericOpenGLRenderWindow
|
||||||
|
|
||||||
|
return vtkGenericOpenGLRenderWindow()
|
||||||
|
|
@ -0,0 +1,480 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Edit the material definitions in `materials.py` without opening it.
|
||||||
|
|
||||||
|
The materials are Python, not data: an entry is an `M(...)` call, its
|
||||||
|
ingredients are usually `ingots(...)` rather than a list, and its effects are
|
||||||
|
`speed(-0.12)` rather than a dictionary. A generated JSON file is downstream of
|
||||||
|
all that and gets overwritten by the next `generate_materials.py` run, so this
|
||||||
|
edits the source instead.
|
||||||
|
|
||||||
|
It does that by rewriting one argument at a time, in place. Each field shows
|
||||||
|
the *source text* of the argument it stands for rather than a rendering of its
|
||||||
|
value, and saving replaces exactly that span of the file - so `ingots("mekanism",
|
||||||
|
"ingot_tin", ...)` stays a call, the comments under every material stay where
|
||||||
|
they were, and a diff shows the number that changed and nothing else.
|
||||||
|
|
||||||
|
tools/material_editor.py
|
||||||
|
|
||||||
|
Materials built by a loop rather than written out - the dragon scales, the gem
|
||||||
|
families - have no literal `M(...)` to edit and are shown read-only, because the
|
||||||
|
thing to change for those is the loop.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import guiplatform
|
||||||
|
|
||||||
|
guiplatform.configure(prefer=os.environ.get('ARMOUR_GUI_PLATFORM'), quiet=True)
|
||||||
|
|
||||||
|
from PySide6 import QtCore, QtGui, QtWidgets # noqa: E402
|
||||||
|
|
||||||
|
TOOLS = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
REPO = os.path.dirname(TOOLS)
|
||||||
|
SOURCE = os.path.join(TOOLS, 'materials.py')
|
||||||
|
|
||||||
|
# The signature of M(), in order, with the type each argument carries. The type
|
||||||
|
# is shown beside the field because these are Python literals being edited as
|
||||||
|
# text: "3" and "3.0" are not the same thing to a stat that is declared a float,
|
||||||
|
# and a tuple of one string needs its trailing comma.
|
||||||
|
POSITIONAL = ['name', 'pack', 'group', 'translation', 'palette_from', 'items',
|
||||||
|
'tier', 'hardness', 'density', 'flexibility', 'durability',
|
||||||
|
'enchantability', 'mining_speed']
|
||||||
|
|
||||||
|
FIELDS = [
|
||||||
|
('name', 'str'), ('pack', 'str'), ('group', 'str'),
|
||||||
|
('translation', 'str'), ('palette_from', 'str'), ('icon', 'str | None'),
|
||||||
|
('items', 'list[dict]'),
|
||||||
|
('tier', 'int'), ('hardness', 'float'), ('density', 'float'),
|
||||||
|
('flexibility', 'float'), ('durability', 'int'), ('enchantability', 'int'),
|
||||||
|
('mining_speed', 'int'), ('mining_level', 'str | None'),
|
||||||
|
('toughness', 'int'), ('armor_durability', 'int | None'),
|
||||||
|
('armor_toughness', 'float | None'), ('knockback_resistance', 'float | None'),
|
||||||
|
('groups', 'tuple[str] | None'), ('hidden_groups', 'tuple[str] | None'),
|
||||||
|
('textures', 'tuple[str]'), ('properties', 'dict | None'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# The ones worth more than a single line to look at.
|
||||||
|
TALL = {'items', 'properties', 'groups', 'hidden_groups', 'textures'}
|
||||||
|
|
||||||
|
DEFAULTS = {'toughness': '0', 'textures': '("metallic",)', 'properties': '{}',
|
||||||
|
'groups': 'None', 'hidden_groups': 'None', 'icon': 'None',
|
||||||
|
'mining_level': 'None', 'armor_durability': 'None',
|
||||||
|
'armor_toughness': 'None', 'knockback_resistance': 'None'}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ the source
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialSource:
|
||||||
|
"""`materials.py`, parsed so that a single argument can be replaced.
|
||||||
|
|
||||||
|
Everything is done in bytes. `ast` reports column offsets as byte offsets
|
||||||
|
into the encoded line, so working in characters would put every span one
|
||||||
|
place out the first time somebody writes a material with an accent in its
|
||||||
|
name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path=SOURCE):
|
||||||
|
self.path = path
|
||||||
|
self.reload()
|
||||||
|
|
||||||
|
def reload(self):
|
||||||
|
with open(self.path, 'rb') as fh:
|
||||||
|
self.data = fh.read()
|
||||||
|
self.lines = self.data.split(b'\n')
|
||||||
|
self.starts, at = [], 0
|
||||||
|
for line in self.lines:
|
||||||
|
self.starts.append(at)
|
||||||
|
at += len(line) + 1
|
||||||
|
tree = ast.parse(self.data.decode('utf-8'), self.path)
|
||||||
|
|
||||||
|
self.calls = {}
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
||||||
|
and node.func.id == 'M' and node.args
|
||||||
|
and isinstance(node.args[0], ast.Constant)
|
||||||
|
and isinstance(node.args[0].value, str)):
|
||||||
|
self.calls[node.args[0].value] = node
|
||||||
|
|
||||||
|
# Where a new material can be appended: just before the `]` that closes
|
||||||
|
# the MATERIALS list.
|
||||||
|
self.list_end = None
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if (isinstance(node, ast.Assign) and node.targets
|
||||||
|
and isinstance(node.targets[0], ast.Name)
|
||||||
|
and node.targets[0].id == 'MATERIALS'
|
||||||
|
and isinstance(node.value, ast.List)):
|
||||||
|
self.list_end = self._offset(node.value.end_lineno,
|
||||||
|
node.value.end_col_offset) - 1
|
||||||
|
|
||||||
|
def _offset(self, lineno, col):
|
||||||
|
return self.starts[lineno - 1] + col
|
||||||
|
|
||||||
|
def span(self, node):
|
||||||
|
return (self._offset(node.lineno, node.col_offset),
|
||||||
|
self._offset(node.end_lineno, node.end_col_offset))
|
||||||
|
|
||||||
|
def names(self):
|
||||||
|
return set(self.calls)
|
||||||
|
|
||||||
|
def argument(self, name, field):
|
||||||
|
"""The node for one argument of one material, or None if not passed."""
|
||||||
|
call = self.calls.get(name)
|
||||||
|
if call is None:
|
||||||
|
return None
|
||||||
|
for kw in call.keywords:
|
||||||
|
if kw.arg == field:
|
||||||
|
return kw.value
|
||||||
|
if field in POSITIONAL:
|
||||||
|
index = POSITIONAL.index(field)
|
||||||
|
if index < len(call.args):
|
||||||
|
return call.args[index]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def text(self, name, field):
|
||||||
|
node = self.argument(name, field)
|
||||||
|
if node is None:
|
||||||
|
return None
|
||||||
|
start, end = self.span(node)
|
||||||
|
return self.data[start:end].decode('utf-8')
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- writing
|
||||||
|
|
||||||
|
def apply(self, edits):
|
||||||
|
"""Replace argument spans. `edits` is [(material, field, source)].
|
||||||
|
|
||||||
|
Applied back to front so that an earlier edit never moves a later
|
||||||
|
one's offsets, and re-parsed afterwards so the next edit is measured
|
||||||
|
against what is now on disk.
|
||||||
|
"""
|
||||||
|
patches = []
|
||||||
|
for name, field, new in edits:
|
||||||
|
node = self.argument(name, field)
|
||||||
|
if node is not None:
|
||||||
|
start, end = self.span(node)
|
||||||
|
patches.append((start, end, new.encode('utf-8')))
|
||||||
|
else:
|
||||||
|
patches.append(self._insert_keyword(name, field, new))
|
||||||
|
|
||||||
|
data = self.data
|
||||||
|
for start, end, blob in sorted(patches, key=lambda p: -p[0]):
|
||||||
|
data = data[:start] + blob + data[end:]
|
||||||
|
with open(self.path, 'wb') as fh:
|
||||||
|
fh.write(data)
|
||||||
|
self.reload()
|
||||||
|
|
||||||
|
def _insert_keyword(self, name, field, new):
|
||||||
|
"""A keyword the call does not pass yet, added before its closing paren."""
|
||||||
|
call = self.calls[name]
|
||||||
|
last = call.keywords[-1].value if call.keywords else call.args[-1]
|
||||||
|
_, end = self.span(last)
|
||||||
|
return (end, end, f', {field}={new}'.encode('utf-8'))
|
||||||
|
|
||||||
|
def add_material(self, source):
|
||||||
|
"""Append a whole `M(...)` call to the end of MATERIALS."""
|
||||||
|
if self.list_end is None:
|
||||||
|
raise RuntimeError('could not find the end of MATERIALS')
|
||||||
|
blob = ('\n ' + source.strip().rstrip(',') + ',\n').encode('utf-8')
|
||||||
|
data = self.data[:self.list_end] + blob + self.data[self.list_end:]
|
||||||
|
with open(self.path, 'wb') as fh:
|
||||||
|
fh.write(data)
|
||||||
|
self.reload()
|
||||||
|
|
||||||
|
|
||||||
|
TEMPLATE = '''M("{name}", "{pack}", "metal", "{title}", "{pack}:ingot_{name}",
|
||||||
|
ingots("{pack}", "ingot_{name}"),
|
||||||
|
tier=3, hardness=5.0, density=4.0, flexibility=1, durability=300,
|
||||||
|
enchantability=12, mining_speed=6)'''
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- window
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialEditor(QtWidgets.QMainWindow):
|
||||||
|
def __init__(self, source, materials):
|
||||||
|
super().__init__()
|
||||||
|
self.source = source
|
||||||
|
self.materials = materials
|
||||||
|
self.pending = {}
|
||||||
|
self.current = None
|
||||||
|
self.setWindowTitle('materials')
|
||||||
|
|
||||||
|
splitter = QtWidgets.QSplitter()
|
||||||
|
splitter.addWidget(self._left())
|
||||||
|
splitter.addWidget(self._right())
|
||||||
|
splitter.setSizes([320, 780])
|
||||||
|
self.setCentralWidget(splitter)
|
||||||
|
self._fill_list()
|
||||||
|
self._status()
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- panes
|
||||||
|
|
||||||
|
def _left(self):
|
||||||
|
box = QtWidgets.QWidget()
|
||||||
|
lay = QtWidgets.QVBoxLayout(box)
|
||||||
|
self.filter = QtWidgets.QLineEdit()
|
||||||
|
self.filter.setPlaceholderText('filter by name, pack or group')
|
||||||
|
self.filter.textChanged.connect(self._fill_list)
|
||||||
|
lay.addWidget(self.filter)
|
||||||
|
|
||||||
|
self.list = QtWidgets.QListWidget()
|
||||||
|
self.list.currentItemChanged.connect(self._select)
|
||||||
|
lay.addWidget(self.list, 1)
|
||||||
|
|
||||||
|
row = QtWidgets.QHBoxLayout()
|
||||||
|
for label, slot in (('Add', self.on_add), ('Save', self.on_save),
|
||||||
|
('Revert', self.on_revert)):
|
||||||
|
button = QtWidgets.QPushButton(label)
|
||||||
|
button.clicked.connect(slot)
|
||||||
|
row.addWidget(button)
|
||||||
|
lay.addLayout(row)
|
||||||
|
|
||||||
|
self.jars = QtWidgets.QLineEdit(os.path.expanduser('~/.cache/abdelpak-jars'))
|
||||||
|
lay.addWidget(QtWidgets.QLabel('mod jars, for regenerating the JSON'))
|
||||||
|
lay.addWidget(self.jars)
|
||||||
|
regen = QtWidgets.QPushButton('Regenerate material JSON')
|
||||||
|
regen.clicked.connect(self.on_regenerate)
|
||||||
|
lay.addWidget(regen)
|
||||||
|
return box
|
||||||
|
|
||||||
|
def _right(self):
|
||||||
|
outer = QtWidgets.QWidget()
|
||||||
|
lay = QtWidgets.QVBoxLayout(outer)
|
||||||
|
self.heading = QtWidgets.QLabel('-')
|
||||||
|
font = self.heading.font()
|
||||||
|
font.setBold(True)
|
||||||
|
self.heading.setFont(font)
|
||||||
|
lay.addWidget(self.heading)
|
||||||
|
self.note = QtWidgets.QLabel('')
|
||||||
|
self.note.setWordWrap(True)
|
||||||
|
self.note.setStyleSheet('color: #b08;')
|
||||||
|
lay.addWidget(self.note)
|
||||||
|
|
||||||
|
scroll = QtWidgets.QScrollArea()
|
||||||
|
scroll.setWidgetResizable(True)
|
||||||
|
inner = QtWidgets.QWidget()
|
||||||
|
form = QtWidgets.QGridLayout(inner)
|
||||||
|
form.setColumnStretch(1, 1)
|
||||||
|
|
||||||
|
self.editors = {}
|
||||||
|
for row, (field, kind) in enumerate(FIELDS):
|
||||||
|
form.addWidget(QtWidgets.QLabel(field), row, 0)
|
||||||
|
if field in TALL:
|
||||||
|
widget = QtWidgets.QPlainTextEdit()
|
||||||
|
widget.setFixedHeight(58)
|
||||||
|
widget.textChanged.connect(lambda f=field: self._changed(f))
|
||||||
|
else:
|
||||||
|
widget = QtWidgets.QLineEdit()
|
||||||
|
widget.textChanged.connect(lambda _=None, f=field: self._changed(f))
|
||||||
|
widget.setFont(QtGui.QFont('monospace'))
|
||||||
|
form.addWidget(widget, row, 1)
|
||||||
|
# The type sits at the end of the field, because these are literals
|
||||||
|
# typed by hand and "3" against "3.0" is a real difference.
|
||||||
|
kind_label = QtWidgets.QLabel(kind)
|
||||||
|
kind_label.setStyleSheet('color: #888;')
|
||||||
|
form.addWidget(kind_label, row, 2)
|
||||||
|
self.editors[field] = widget
|
||||||
|
|
||||||
|
scroll.setWidget(inner)
|
||||||
|
lay.addWidget(scroll, 1)
|
||||||
|
return outer
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ contents
|
||||||
|
|
||||||
|
def _fill_list(self):
|
||||||
|
want = self.filter.text().strip().lower()
|
||||||
|
self.list.blockSignals(True)
|
||||||
|
self.list.clear()
|
||||||
|
for mat in self.materials:
|
||||||
|
label = f"{mat['name']} [{mat['pack']} / {mat['group']}]"
|
||||||
|
if want and want not in label.lower():
|
||||||
|
continue
|
||||||
|
item = QtWidgets.QListWidgetItem(label)
|
||||||
|
item.setData(QtCore.Qt.UserRole, mat['name'])
|
||||||
|
if mat['name'] not in self.source.names():
|
||||||
|
item.setForeground(QtGui.QColor('#888'))
|
||||||
|
self.list.addItem(item)
|
||||||
|
self.list.blockSignals(False)
|
||||||
|
if self.list.count():
|
||||||
|
self.list.setCurrentRow(0)
|
||||||
|
|
||||||
|
def _select(self, item, _previous=None):
|
||||||
|
if item is None:
|
||||||
|
return
|
||||||
|
self.current = item.data(QtCore.Qt.UserRole)
|
||||||
|
editable = self.current in self.source.names()
|
||||||
|
self.heading.setText(self.current)
|
||||||
|
self.note.setText('' if editable else
|
||||||
|
'Built by a loop rather than written out - read only '
|
||||||
|
'here; edit the loop that makes it.')
|
||||||
|
self._loading = True
|
||||||
|
for field, _kind in FIELDS:
|
||||||
|
text = self.source.text(self.current, field) if editable else None
|
||||||
|
key = (self.current, field)
|
||||||
|
if key in self.pending:
|
||||||
|
text = self.pending[key]
|
||||||
|
shown = text if text is not None else DEFAULTS.get(field, '')
|
||||||
|
widget = self.editors[field]
|
||||||
|
widget.setReadOnly(not editable)
|
||||||
|
if isinstance(widget, QtWidgets.QPlainTextEdit):
|
||||||
|
widget.setPlainText(shown)
|
||||||
|
else:
|
||||||
|
widget.setText(shown)
|
||||||
|
self._loading = False
|
||||||
|
self._status()
|
||||||
|
|
||||||
|
def _value(self, field):
|
||||||
|
widget = self.editors[field]
|
||||||
|
if isinstance(widget, QtWidgets.QPlainTextEdit):
|
||||||
|
return widget.toPlainText().strip()
|
||||||
|
return widget.text().strip()
|
||||||
|
|
||||||
|
def _changed(self, field):
|
||||||
|
if getattr(self, '_loading', False) or self.current is None:
|
||||||
|
return
|
||||||
|
if self.current not in self.source.names():
|
||||||
|
return
|
||||||
|
new = self._value(field)
|
||||||
|
old = self.source.text(self.current, field)
|
||||||
|
key = (self.current, field)
|
||||||
|
# An untouched optional argument stays untouched: writing `toughness=0`
|
||||||
|
# into every material that never mentioned it would be a diff of noise.
|
||||||
|
if new == (old if old is not None else DEFAULTS.get(field, '')):
|
||||||
|
self.pending.pop(key, None)
|
||||||
|
else:
|
||||||
|
self.pending[key] = new
|
||||||
|
self._mark(field, ok=self._parses(new))
|
||||||
|
self._status()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parses(text):
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
ast.parse(text, mode='eval')
|
||||||
|
return True
|
||||||
|
except SyntaxError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _mark(self, field, ok):
|
||||||
|
widget = self.editors[field]
|
||||||
|
widget.setStyleSheet('' if ok else 'background: #5a2230;')
|
||||||
|
|
||||||
|
def _status(self):
|
||||||
|
bad = [f'{n}.{f}' for (n, f), v in self.pending.items() if not self._parses(v)]
|
||||||
|
msg = f'{len(self.pending)} unsaved change(s)' if self.pending else 'no changes'
|
||||||
|
if bad:
|
||||||
|
msg += f' - will not parse: {", ".join(bad)}'
|
||||||
|
self.statusBar().showMessage(msg)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- actions
|
||||||
|
|
||||||
|
def on_save(self):
|
||||||
|
bad = [k for k, v in self.pending.items() if not self._parses(v)]
|
||||||
|
if bad:
|
||||||
|
QtWidgets.QMessageBox.warning(
|
||||||
|
self, 'materials',
|
||||||
|
'These are not valid Python and were not saved:\n\n'
|
||||||
|
+ '\n'.join(f'{n}.{f}' for n, f in bad))
|
||||||
|
return
|
||||||
|
if not self.pending:
|
||||||
|
return
|
||||||
|
edits = [(n, f, v) for (n, f), v in self.pending.items()]
|
||||||
|
self.source.apply(edits)
|
||||||
|
self.pending.clear()
|
||||||
|
self._select(self.list.currentItem())
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
f'wrote {len(edits)} change(s) to {_short(self.source.path)}')
|
||||||
|
|
||||||
|
def on_revert(self):
|
||||||
|
self.pending.clear()
|
||||||
|
self._select(self.list.currentItem())
|
||||||
|
|
||||||
|
def on_add(self):
|
||||||
|
name, ok = QtWidgets.QInputDialog.getText(
|
||||||
|
self, 'new material', 'id, lowercase with underscores:')
|
||||||
|
if not ok or not name.strip():
|
||||||
|
return
|
||||||
|
name = name.strip()
|
||||||
|
if name in self.source.names():
|
||||||
|
QtWidgets.QMessageBox.warning(self, 'materials',
|
||||||
|
f'{name} already exists.')
|
||||||
|
return
|
||||||
|
pack, ok = QtWidgets.QInputDialog.getText(self, 'new material',
|
||||||
|
'pack:', text='mekanism')
|
||||||
|
if not ok:
|
||||||
|
return
|
||||||
|
self.source.add_material(TEMPLATE.format(
|
||||||
|
name=name, pack=pack.strip() or 'mekanism',
|
||||||
|
title=name.replace('_', ' ').title()))
|
||||||
|
self.materials = load_materials(self.source.path)
|
||||||
|
self._fill_list()
|
||||||
|
for row in range(self.list.count()):
|
||||||
|
if self.list.item(row).data(QtCore.Qt.UserRole) == name:
|
||||||
|
self.list.setCurrentRow(row)
|
||||||
|
break
|
||||||
|
self.statusBar().showMessage(f'added {name} - it still needs real items '
|
||||||
|
f'and stats')
|
||||||
|
|
||||||
|
def on_regenerate(self):
|
||||||
|
folder = self.jars.text().strip()
|
||||||
|
if not os.path.isdir(folder):
|
||||||
|
QtWidgets.QMessageBox.warning(self, 'materials',
|
||||||
|
f'not a folder: {folder}')
|
||||||
|
return
|
||||||
|
self.statusBar().showMessage('regenerating...')
|
||||||
|
QtWidgets.QApplication.processEvents()
|
||||||
|
run = subprocess.run([sys.executable,
|
||||||
|
os.path.join(TOOLS, 'generate_materials.py'),
|
||||||
|
'--jars', folder],
|
||||||
|
capture_output=True, text=True, cwd=REPO)
|
||||||
|
tail = (run.stdout + run.stderr).strip().splitlines()
|
||||||
|
self.statusBar().showMessage(tail[-1] if tail else 'done')
|
||||||
|
if run.returncode:
|
||||||
|
QtWidgets.QMessageBox.warning(self, 'generate_materials.py',
|
||||||
|
'\n'.join(tail[-25:]))
|
||||||
|
|
||||||
|
|
||||||
|
def _short(path):
|
||||||
|
"""Repo-relative when it is in the repo, absolute when it is not."""
|
||||||
|
rel = os.path.relpath(path, REPO)
|
||||||
|
return path if rel.startswith(os.pardir) else rel
|
||||||
|
|
||||||
|
|
||||||
|
def load_materials(path=SOURCE):
|
||||||
|
"""The evaluated list, which is the only thing that knows the full set.
|
||||||
|
|
||||||
|
Loaded from the same file that is being edited, and freshly each time - a
|
||||||
|
material added during the session has to appear in the list, and a listing
|
||||||
|
taken from a different file than the edits go to would be a quiet lie.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location('_materials_under_edit', path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return list(module.MATERIALS)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument('--source', default=SOURCE, help='materials.py to edit')
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
app = QtWidgets.QApplication(sys.argv[:1])
|
||||||
|
window = MaterialEditor(MaterialSource(args.source),
|
||||||
|
load_materials(args.source))
|
||||||
|
window.resize(1180, 760)
|
||||||
|
window.show()
|
||||||
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -0,0 +1,657 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Minecraft and MIAPI geometry, read out of jars and turned into textured quads.
|
||||||
|
|
||||||
|
This is the part of the armour tooling that has to be *right* rather than
|
||||||
|
merely plausible: everything downstream is just a camera pointed at whatever
|
||||||
|
this module says the shape is. So the two UV conventions Minecraft uses are
|
||||||
|
both implemented here, separately and by name, rather than being averaged into
|
||||||
|
one that is wrong for half the models:
|
||||||
|
|
||||||
|
*Entity cubes* (`ModelPart.Cube`) are what vanilla armour is. Boxes are given a
|
||||||
|
single texture offset and the six faces are laid out around it in the familiar
|
||||||
|
cross; +y is down, because entity model space is flipped once more at draw
|
||||||
|
time. `vanilla_armour` builds these.
|
||||||
|
|
||||||
|
*JSON model faces* (`FaceBakery`) are what every MIAPI module is - a Blockbench
|
||||||
|
item model with a `uv` rectangle written out per face. The two conventions
|
||||||
|
disagree about which end of the rectangle is which on four of the six faces,
|
||||||
|
which is exactly the sort of difference that survives a careless eyeball on a
|
||||||
|
symmetrical breastplate and then ruins a pauldron.
|
||||||
|
|
||||||
|
Everything is in model pixels: +x is the wearer's left, +y is down, -z is
|
||||||
|
forward, and the origin of each part is its `HumanoidModel` pivot. Nothing
|
||||||
|
converts between "item space" and "entity space", because MIAPI does not
|
||||||
|
either - Armory's slot transforms carry an explicit `"rotation": {"z": 180}`
|
||||||
|
to flip a model onto a body part, and that flip is the whole conversion.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import colorsys
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- resources
|
||||||
|
|
||||||
|
|
||||||
|
class Resources:
|
||||||
|
"""Jars and loose directories, searched in the order they were given.
|
||||||
|
|
||||||
|
A resource pack and a mod jar are the same thing to this: somewhere an
|
||||||
|
`assets/<namespace>/...` path can be read from. Directories are listed
|
||||||
|
first when the same file is in both, so the repo's own assets win over the
|
||||||
|
copy inside a built jar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, sources):
|
||||||
|
self.dirs, self.zips, self.names = [], [], []
|
||||||
|
for src in sources:
|
||||||
|
src = os.path.expanduser(str(src))
|
||||||
|
if os.path.isdir(src):
|
||||||
|
self.dirs.append(src)
|
||||||
|
elif os.path.isfile(src):
|
||||||
|
self.zips.append(zipfile.ZipFile(src))
|
||||||
|
else:
|
||||||
|
raise FileNotFoundError(src)
|
||||||
|
self.names.append(os.path.basename(src.rstrip('/')))
|
||||||
|
|
||||||
|
def read(self, path):
|
||||||
|
"""Bytes at an exact `assets/...` or `data/...` path."""
|
||||||
|
for d in self.dirs:
|
||||||
|
full = os.path.join(d, path)
|
||||||
|
if os.path.isfile(full):
|
||||||
|
return open(full, 'rb').read()
|
||||||
|
for z in self.zips:
|
||||||
|
try:
|
||||||
|
return z.read(path)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
raise KeyError(path)
|
||||||
|
|
||||||
|
def json(self, path):
|
||||||
|
return json.loads(self.read(path))
|
||||||
|
|
||||||
|
# MIAPI writes model references two ways. A module's `model` entry gives a
|
||||||
|
# whole path - "miapi:models/item/foo/[material.texture].json" - while a
|
||||||
|
# model's own `parent` gives the short form the game uses, "item/generated".
|
||||||
|
def model_path(self, ref, variant='default'):
|
||||||
|
ns, _, rest = ref.partition(':')
|
||||||
|
if not _:
|
||||||
|
ns, rest = 'minecraft', ref
|
||||||
|
rest = rest.replace('[material.texture]', variant)
|
||||||
|
if not rest.startswith('models/'):
|
||||||
|
rest = 'models/' + rest
|
||||||
|
if not rest.endswith('.json'):
|
||||||
|
rest += '.json'
|
||||||
|
return f'assets/{ns}/{rest}'
|
||||||
|
|
||||||
|
def model(self, ref, variant='default'):
|
||||||
|
"""A model with its parent chain already folded in."""
|
||||||
|
model = self.json(self.model_path(ref, variant))
|
||||||
|
chain = [model]
|
||||||
|
seen = set()
|
||||||
|
while 'parent' in chain[-1]:
|
||||||
|
parent = chain[-1]['parent']
|
||||||
|
if parent in seen:
|
||||||
|
break
|
||||||
|
seen.add(parent)
|
||||||
|
try:
|
||||||
|
chain.append(self.json(self.model_path(parent, variant)))
|
||||||
|
except KeyError:
|
||||||
|
# item/generated and item/handheld are builtins with no file.
|
||||||
|
break
|
||||||
|
out = {'textures': {}, 'parents': [c.get('parent') for c in chain]}
|
||||||
|
for part in reversed(chain):
|
||||||
|
out['textures'].update(part.get('textures', {}))
|
||||||
|
for key in ('elements', 'texture_size', 'display'):
|
||||||
|
if key in part:
|
||||||
|
out[key] = part[key]
|
||||||
|
return out
|
||||||
|
|
||||||
|
def image(self, ref):
|
||||||
|
"""A texture as RGBA. `ref` is a namespaced texture id, no extension."""
|
||||||
|
ns, _, rest = ref.partition(':')
|
||||||
|
if not _:
|
||||||
|
ns, rest = 'minecraft', ref
|
||||||
|
if rest.startswith('textures/'):
|
||||||
|
rest = rest[len('textures/'):]
|
||||||
|
data = self.read(f'assets/{ns}/textures/{rest}.png')
|
||||||
|
return Image.open(io.BytesIO(data)).convert('RGBA')
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- transforms
|
||||||
|
#
|
||||||
|
# MIAPI's Transform, reproduced including the part of it that loses information.
|
||||||
|
|
||||||
|
def ident():
|
||||||
|
return np.eye(4)
|
||||||
|
|
||||||
|
|
||||||
|
def translate(t):
|
||||||
|
m = np.eye(4)
|
||||||
|
m[:3, 3] = t
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def scale(s):
|
||||||
|
m = np.eye(4)
|
||||||
|
m[0, 0], m[1, 1], m[2, 2] = s
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def rot(axis, deg):
|
||||||
|
a = math.radians(deg)
|
||||||
|
c, s = math.cos(a), math.sin(a)
|
||||||
|
m = np.eye(4)
|
||||||
|
if axis == 'x':
|
||||||
|
m[1, 1], m[1, 2], m[2, 1], m[2, 2] = c, -s, s, c
|
||||||
|
elif axis == 'y':
|
||||||
|
m[0, 0], m[0, 2], m[2, 0], m[2, 2] = c, s, -s, c
|
||||||
|
else:
|
||||||
|
m[0, 0], m[0, 1], m[1, 0], m[1, 1] = c, -s, s, c
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def transform_matrix(tr):
|
||||||
|
"""MIAPI's `Transform.toMatrix`: T * Rx * Ry * Rz * S, translation in pixels."""
|
||||||
|
tr = tr or {}
|
||||||
|
|
||||||
|
def g(key, axis, dflt):
|
||||||
|
sub = tr.get(key)
|
||||||
|
return float(sub.get(axis, dflt)) if isinstance(sub, dict) else dflt
|
||||||
|
|
||||||
|
m = translate([g('translation', a, 0.0) for a in 'xyz'])
|
||||||
|
for axis in 'xyz':
|
||||||
|
m = m @ rot(axis, g('rotation', axis, 0.0))
|
||||||
|
return m @ scale([g('scale', a, 1.0) for a in 'xyz'])
|
||||||
|
|
||||||
|
|
||||||
|
def decompose(m):
|
||||||
|
"""MIAPI's `Transform.fromMatrix`: translation, XYZ Euler, per-column scale.
|
||||||
|
|
||||||
|
Shear in the product is dropped on the floor. That is not a bug here - it
|
||||||
|
is what the mod does, and reproducing it is the only way the preview agrees
|
||||||
|
with the game once a rotation meets a non-uniform scale.
|
||||||
|
"""
|
||||||
|
t = m[:3, 3]
|
||||||
|
cols = [m[:3, c] for c in range(3)]
|
||||||
|
s = [float(np.linalg.norm(c)) or 1.0 for c in cols]
|
||||||
|
r = np.column_stack([cols[c] / s[c] for c in range(3)])
|
||||||
|
y = math.asin(max(-1.0, min(1.0, float(r[0, 2]))))
|
||||||
|
x = math.atan2(-float(r[1, 2]), float(r[2, 2]))
|
||||||
|
z = math.atan2(-float(r[0, 1]), float(r[0, 0]))
|
||||||
|
return {'translation': {'x': float(t[0]), 'y': float(t[1]), 'z': float(t[2])},
|
||||||
|
'rotation': {'x': math.degrees(x), 'y': math.degrees(y), 'z': math.degrees(z)},
|
||||||
|
'scale': {'x': s[0], 'y': s[1], 'z': s[2]}}
|
||||||
|
|
||||||
|
|
||||||
|
def merge(parent, child, lossy=False):
|
||||||
|
"""MIAPI's `Transform.merge`.
|
||||||
|
|
||||||
|
In 1.21 this is a plain matrix multiply - `Transform` holds a `Matrix4f`
|
||||||
|
and `merge` returns `new Transform(parent.matrix.mul(child.matrix))`, with
|
||||||
|
no round trip through Euler angles. The parent is the transform already
|
||||||
|
accumulated and the child the one being added, so the child applies first.
|
||||||
|
|
||||||
|
`lossy` reproduces the older behaviour, where the product was decomposed
|
||||||
|
back into translation, Euler angles and scale before being stored - which
|
||||||
|
silently drops the shear that appears the moment a rotation meets a
|
||||||
|
non-uniform scale. Armory's limb slots are exactly that, so the two answers
|
||||||
|
differ there and it is worth being able to see both.
|
||||||
|
"""
|
||||||
|
product = child @ parent
|
||||||
|
return transform_matrix(decompose(product)) if lossy else product
|
||||||
|
|
||||||
|
|
||||||
|
# `HumanoidModel.createMesh` pivots - the frame each `origin` resolves against.
|
||||||
|
PIVOTS = {
|
||||||
|
'head': (0.0, 0.0, 0.0),
|
||||||
|
'hat': (0.0, 0.0, 0.0),
|
||||||
|
'body': (0.0, 0.0, 0.0),
|
||||||
|
'item': (0.0, 0.0, 0.0),
|
||||||
|
'left_arm': (5.0, 2.0, 0.0),
|
||||||
|
'right_arm': (-5.0, 2.0, 0.0),
|
||||||
|
'left_leg': (1.9, 12.0, 0.0),
|
||||||
|
'right_leg': (-1.9, 12.0, 0.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- quads
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Quad:
|
||||||
|
"""Four corners, four texture coordinates, and the texture they index.
|
||||||
|
|
||||||
|
`shape` is which box of its model the face belongs to and `face` is which
|
||||||
|
of the six it is. Neither matters for drawing a textured model, but both
|
||||||
|
are what lets the untextured view and the unwrap template agree on a
|
||||||
|
colour, which is the only thing making one a legend for the other.
|
||||||
|
"""
|
||||||
|
pts: np.ndarray # (4, 3) model pixels
|
||||||
|
uv: np.ndarray # (4, 2) normalised, v measured down from the top
|
||||||
|
texture: str | None # namespaced texture id
|
||||||
|
shape: int = 0
|
||||||
|
face: str = ''
|
||||||
|
|
||||||
|
def transformed(self, m, offset=(0.0, 0.0, 0.0)):
|
||||||
|
pts = np.column_stack([self.pts, np.ones(4)]) @ m.T
|
||||||
|
return Quad(pts[:, :3] + np.asarray(offset, float), self.uv, self.texture,
|
||||||
|
self.shape, self.face)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- colouring
|
||||||
|
#
|
||||||
|
# One hue per shape, one shade per face. The hue says which box you are looking
|
||||||
|
# at and the shade says which side of it, so a patch of texture can be found on
|
||||||
|
# the model - and a face drawn on the wrong patch shows up as the wrong shade of
|
||||||
|
# the right colour rather than as something that looks fine.
|
||||||
|
|
||||||
|
# Saturation and value per face. Front and back are the vivid pair because they
|
||||||
|
# are what you look at most; up and down are pushed to the ends of the value
|
||||||
|
# range so a box read from above or below is never ambiguous. Entity cubes name
|
||||||
|
# their vertical faces differently, and both names are here rather than
|
||||||
|
# translated, so neither convention has to know about the other.
|
||||||
|
FACE_SHADES = {
|
||||||
|
'north': (0.90, 0.98), 'south': (0.90, 0.60),
|
||||||
|
'east': (0.55, 0.90), 'west': (0.55, 0.68),
|
||||||
|
'up': (0.26, 1.00), 'down': (1.00, 0.42),
|
||||||
|
'top': (0.26, 1.00), 'bottom': (1.00, 0.42),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def shape_hues(count):
|
||||||
|
"""The colour wheel split evenly, one slice per shape."""
|
||||||
|
count = max(1, int(count))
|
||||||
|
return [i / count for i in range(count)]
|
||||||
|
|
||||||
|
|
||||||
|
def face_colour(shape, count, face):
|
||||||
|
"""The colour of one face of one shape, as floats in 0..1."""
|
||||||
|
hues = shape_hues(count)
|
||||||
|
hue = hues[int(shape) % len(hues)]
|
||||||
|
sat, val = FACE_SHADES.get(face, (0.70, 0.80))
|
||||||
|
return colorsys.hsv_to_rgb(hue, sat, val)
|
||||||
|
|
||||||
|
|
||||||
|
# uv index 0..3 is (u1,v1), (u1,v2), (u2,v2), (u2,v1) - `BlockFaceUV.getU/getV`.
|
||||||
|
# Each entry picks x, y and z from (from, to) per corner: 0 is `from`, 1 is `to`.
|
||||||
|
_JSON_FACE = {
|
||||||
|
'north': ((1, 1, 0), (1, 0, 0), (0, 0, 0), (0, 1, 0)),
|
||||||
|
'south': ((0, 1, 1), (0, 0, 1), (1, 0, 1), (1, 1, 1)),
|
||||||
|
'west': ((0, 1, 0), (0, 0, 0), (0, 0, 1), (0, 1, 1)),
|
||||||
|
'east': ((1, 1, 1), (1, 0, 1), (1, 0, 0), (1, 1, 0)),
|
||||||
|
'up': ((0, 1, 0), (0, 1, 1), (1, 1, 1), (1, 1, 0)),
|
||||||
|
'down': ((0, 0, 1), (0, 0, 0), (1, 0, 0), (1, 0, 1)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# The two axes a face's uv rectangle runs along, for the auto-uv Minecraft
|
||||||
|
# generates when a face omits `uv`: (u axis, v axis) as (index, flipped).
|
||||||
|
_AUTO_UV = {
|
||||||
|
'north': ((0, True), (1, True)), 'south': ((0, False), (1, True)),
|
||||||
|
'west': ((2, False), (1, True)), 'east': ((2, True), (1, True)),
|
||||||
|
'up': ((0, False), (2, False)), 'down': ((0, False), (2, True)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_texture(textures, key):
|
||||||
|
"""Follow `#2` -> `#layer0` -> `miapi:item/...` to a real texture id."""
|
||||||
|
seen = 0
|
||||||
|
while isinstance(key, str) and key.startswith('#') and seen < 8:
|
||||||
|
key = textures.get(key[1:])
|
||||||
|
seen += 1
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def _element_matrix(el):
|
||||||
|
"""A JSON element's own `rotation`, about its own origin."""
|
||||||
|
r = el.get('rotation')
|
||||||
|
if not r:
|
||||||
|
return np.eye(4)
|
||||||
|
axis, angle = r.get('axis', 'y'), float(r.get('angle', 0.0))
|
||||||
|
origin = np.asarray(r.get('origin', [0, 0, 0]), float)
|
||||||
|
m = translate(origin) @ rot(axis, angle) @ translate(-origin)
|
||||||
|
if r.get('rescale') and angle:
|
||||||
|
f = 1.0 / math.cos(math.radians(abs(angle)))
|
||||||
|
s = [f, f, f]
|
||||||
|
s['xyz'.index(axis)] = 1.0
|
||||||
|
m = m @ (translate(origin) @ scale(s) @ translate(-origin))
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def model_quads(model, res=None, y_up=False):
|
||||||
|
"""Every drawable face of a parsed JSON model, in the model's own pixels.
|
||||||
|
|
||||||
|
A model with no `elements` whose ancestry runs through `item/generated` is
|
||||||
|
a flat sprite, and is turned into a two-sided slab centred on the origin -
|
||||||
|
which is what a gemstone is, and why a gem's position is a point rather
|
||||||
|
than a plate.
|
||||||
|
"""
|
||||||
|
textures = model.get('textures', {})
|
||||||
|
elements = model.get('elements')
|
||||||
|
if not elements:
|
||||||
|
return _sprite_quads(textures, res, y_up)
|
||||||
|
|
||||||
|
tw, th = (model.get('texture_size') or [16, 16])[:2]
|
||||||
|
quads = []
|
||||||
|
for index, el in enumerate(elements):
|
||||||
|
lo = np.asarray(el['from'], float)
|
||||||
|
hi = np.asarray(el['to'], float)
|
||||||
|
bounds = np.column_stack([lo, hi]) # (3, 2): axis -> (from, to)
|
||||||
|
m = _element_matrix(el)
|
||||||
|
for name, face in (el.get('faces') or {}).items():
|
||||||
|
picks = _JSON_FACE.get(name)
|
||||||
|
if picks is None:
|
||||||
|
continue
|
||||||
|
uv = face.get('uv')
|
||||||
|
if uv is None:
|
||||||
|
uv = _auto_uv(name, lo, hi)
|
||||||
|
u1, v1, u2, v2 = (float(x) for x in uv)
|
||||||
|
corners = [(u1, v1), (u1, v2), (u2, v2), (u2, v1)]
|
||||||
|
turns = int(face.get('rotation', 0) // 90) % 4
|
||||||
|
if turns:
|
||||||
|
corners = corners[turns:] + corners[:turns]
|
||||||
|
pts = np.array([[bounds[a][p[a]] for a in range(3)] for p in picks], float)
|
||||||
|
pts = (np.column_stack([pts, np.ones(4)]) @ m.T)[:, :3]
|
||||||
|
quads.append(Quad(pts,
|
||||||
|
np.array([[u / tw, v / th] for u, v in corners]),
|
||||||
|
_resolve_texture(textures, face.get('texture', '#0')),
|
||||||
|
index, name))
|
||||||
|
return quads
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_uv(name, lo, hi):
|
||||||
|
(ui, uflip), (vi, vflip) = _AUTO_UV[name]
|
||||||
|
a, b = (16 - hi[ui], 16 - lo[ui]) if uflip else (lo[ui], hi[ui])
|
||||||
|
c, d = (16 - hi[vi], 16 - lo[vi]) if vflip else (lo[vi], hi[vi])
|
||||||
|
return [a, c, b, d]
|
||||||
|
|
||||||
|
|
||||||
|
def _sprite_quads(textures, res=None, y_up=False):
|
||||||
|
"""An `item/generated` sprite: a two-sided slab, front and back.
|
||||||
|
|
||||||
|
Trimmed to the sprite's opaque pixels rather than left at the full 16x16,
|
||||||
|
because that is what the module actually is - a medium gemstone is two
|
||||||
|
pixels of gem in the middle of fourteen of nothing, and a placement judged
|
||||||
|
against the empty square around it is judged against the wrong thing.
|
||||||
|
|
||||||
|
Centred on the origin, because MIAPI places one by its middle: a gemstone
|
||||||
|
sits where its slot's translation points, not 8 pixels down and left of it.
|
||||||
|
|
||||||
|
`y_up` builds it in item space instead. Worn armour is drawn with +y down -
|
||||||
|
that is what the `"rotation": {"z": 180}` on every armour slot is for - but
|
||||||
|
an inventory icon is a plain item model, where +y is up and the sprite's top
|
||||||
|
row is at the top. Armory's belt gem settles which is which: its slot names
|
||||||
|
no origin, so it reaches the icon, and `y: 4` puts it on the buckle, which
|
||||||
|
the art draws four pixels above the middle.
|
||||||
|
"""
|
||||||
|
tex = _resolve_texture(textures, '#layer0') or _resolve_texture(textures, '#0')
|
||||||
|
x0, y0, x1, y1 = -8.0, -8.0, 8.0, 8.0
|
||||||
|
u0, v0, u1, v1 = 0.0, 0.0, 1.0, 1.0
|
||||||
|
if res is not None and tex:
|
||||||
|
try:
|
||||||
|
image = res.image(tex)
|
||||||
|
except KeyError:
|
||||||
|
image = None
|
||||||
|
box = image.split()[3].getbbox() if image is not None else None
|
||||||
|
if box:
|
||||||
|
w, h = image.size
|
||||||
|
u0, u1 = box[0] / w, box[2] / w
|
||||||
|
v0, v1 = box[1] / h, box[3] / h
|
||||||
|
# The sprite spans 16 model pixels whatever its resolution.
|
||||||
|
x0, x1 = u0 * 16.0 - 8.0, u1 * 16.0 - 8.0
|
||||||
|
y0, y1 = v0 * 16.0 - 8.0, v1 * 16.0 - 8.0
|
||||||
|
|
||||||
|
if y_up:
|
||||||
|
y0, y1 = -y1, -y0
|
||||||
|
uv_top, uv_bottom = v1, v0 # texture top is now the larger y
|
||||||
|
else:
|
||||||
|
uv_top, uv_bottom = v0, v1
|
||||||
|
v0, v1 = uv_top, uv_bottom
|
||||||
|
|
||||||
|
front = np.array([[x0, y0, -0.5], [x0, y1, -0.5], [x1, y1, -0.5], [x1, y0, -0.5]])
|
||||||
|
back = np.array([[x1, y0, 0.5], [x1, y1, 0.5], [x0, y1, 0.5], [x0, y0, 0.5]])
|
||||||
|
uv_f = np.array([[u0, v0], [u0, v1], [u1, v1], [u1, v0]])
|
||||||
|
uv_b = np.array([[u1, v0], [u1, v1], [u0, v1], [u0, v0]])
|
||||||
|
return [Quad(front, uv_f, tex, 0, 'north'), Quad(back, uv_b, tex, 0, 'south')]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- vanilla armour
|
||||||
|
#
|
||||||
|
# `ModelPart.Cube`'s layout, which is the other convention and the reference
|
||||||
|
# this whole tool is checked against.
|
||||||
|
|
||||||
|
# Per face: the corner picks, and which slice of the texture cross it takes.
|
||||||
|
# Regions are named by the offsets vanilla computes - f9..f14 across, f15..f17
|
||||||
|
# down - so the table can be read against the source it came from.
|
||||||
|
_CUBE_FACE = {
|
||||||
|
'top': (((1, 0, 1), (0, 0, 1), (0, 0, 0), (1, 0, 0)), ('f10', 'f15', 'f11', 'f16')),
|
||||||
|
'bottom': (((1, 1, 0), (0, 1, 0), (0, 1, 1), (1, 1, 1)), ('f11', 'f16', 'f12', 'f15')),
|
||||||
|
'west': (((0, 0, 0), (0, 0, 1), (0, 1, 1), (0, 1, 0)), ('f9', 'f16', 'f10', 'f17')),
|
||||||
|
'north': (((1, 0, 0), (0, 0, 0), (0, 1, 0), (1, 1, 0)), ('f10', 'f16', 'f11', 'f17')),
|
||||||
|
'east': (((1, 0, 1), (1, 0, 0), (1, 1, 0), (1, 1, 1)), ('f11', 'f16', 'f13', 'f17')),
|
||||||
|
'south': (((0, 0, 1), (1, 0, 1), (1, 1, 1), (0, 1, 1)), ('f13', 'f16', 'f14', 'f17')),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cube_quads(origin, size, tex_offs, grow=0.0, mirror=False,
|
||||||
|
tex_size=(64, 32), texture=None, shape=0):
|
||||||
|
"""One entity cube, the way `ModelPart.Cube` builds it.
|
||||||
|
|
||||||
|
`origin` and `size` are the arguments of `CubeListBuilder.addBox`, `grow`
|
||||||
|
the `CubeDeformation`. The uv rectangle is measured from the undeformed
|
||||||
|
size, which is why an inflated armour layer still lines up with the skin
|
||||||
|
it is drawn over.
|
||||||
|
"""
|
||||||
|
ox, oy, oz = (float(v) for v in origin)
|
||||||
|
dx, dy, dz = (float(v) for v in size)
|
||||||
|
x1, y1, z1 = ox - grow, oy - grow, oz - grow
|
||||||
|
x2, y2, z2 = ox + dx + grow, oy + dy + grow, oz + dz + grow
|
||||||
|
if mirror:
|
||||||
|
x1, x2 = x2, x1
|
||||||
|
bounds = ((x1, x2), (y1, y2), (z1, z2))
|
||||||
|
|
||||||
|
u, v = float(tex_offs[0]), float(tex_offs[1])
|
||||||
|
reg = {'f9': u, 'f10': u + dz, 'f11': u + dz + dx, 'f12': u + dz + dx + dx,
|
||||||
|
'f13': u + dz + dx + dz, 'f14': u + dz + dx + dz + dx,
|
||||||
|
'f15': v, 'f16': v + dz, 'f17': v + dz + dy}
|
||||||
|
tw, th = tex_size
|
||||||
|
|
||||||
|
quads = []
|
||||||
|
for name, (picks, (ua, va, ub, vb)) in _CUBE_FACE.items():
|
||||||
|
u0, v0, u1_, v1_ = reg[ua], reg[va], reg[ub], reg[vb]
|
||||||
|
# vertex order is [0]->(u1,v0) [1]->(u0,v0) [2]->(u0,v1) [3]->(u1,v1)
|
||||||
|
corners = [(u1_, v0), (u0, v0), (u0, v1_), (u1_, v1_)]
|
||||||
|
pts = np.array([[bounds[a][p[a]] for a in range(3)] for p in picks], float)
|
||||||
|
quads.append(Quad(pts, np.array([[cu / tw, cv / th] for cu, cv in corners]),
|
||||||
|
texture, shape, name))
|
||||||
|
return quads
|
||||||
|
|
||||||
|
|
||||||
|
# `HumanoidModel.createMesh`: box origin, box size, texture offset, mirrored.
|
||||||
|
HUMANOID = {
|
||||||
|
'head': ((-4, -8, -4), (8, 8, 8), (0, 0), False),
|
||||||
|
'hat': ((-4, -8, -4), (8, 8, 8), (32, 0), False),
|
||||||
|
'body': ((-4, 0, -2), (8, 12, 4), (16, 16), False),
|
||||||
|
'right_arm': ((-3, -2, -2), (4, 12, 4), (40, 16), False),
|
||||||
|
'left_arm': ((-1, -2, -2), (4, 12, 4), (40, 16), True),
|
||||||
|
'right_leg': ((-2, 0, -2), (4, 12, 4), (0, 16), False),
|
||||||
|
'left_leg': ((-2, 0, -2), (4, 12, 4), (0, 16), True),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Which parts each vanilla armour slot draws, and how far each layer is
|
||||||
|
# inflated - `HumanoidArmorModel`'s inner and outer `CubeDeformation`.
|
||||||
|
ARMOUR_PIECES = {
|
||||||
|
'helmet': (('head', 'hat'), 1.0, 1),
|
||||||
|
'chestplate': (('body', 'left_arm', 'right_arm'), 1.0, 1),
|
||||||
|
'leggings': (('body', 'left_leg', 'right_leg'), 0.5, 2),
|
||||||
|
'boots': (('left_leg', 'right_leg'), 1.0, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def vanilla_armour(piece, texture, tex_size=(64, 32)):
|
||||||
|
"""A vanilla armour piece as world-space quads, ready to draw.
|
||||||
|
|
||||||
|
The point of this is that its answer is already known. Iron leggings look
|
||||||
|
like iron leggings or the renderer is wrong, and there is no MIAPI, no
|
||||||
|
transform stack and no module JSON in the way of finding out which.
|
||||||
|
"""
|
||||||
|
parts, grow, _ = ARMOUR_PIECES[piece]
|
||||||
|
quads = []
|
||||||
|
for shape, part in enumerate(parts):
|
||||||
|
origin, size, offs, mirror = HUMANOID[part]
|
||||||
|
# The hat is a second, slightly larger skin on the head; a helmet is
|
||||||
|
# already the inflated head, so it takes vanilla's extra 0.5 as well.
|
||||||
|
g = grow + 0.5 if part == 'hat' else grow
|
||||||
|
pivot = PIVOTS[part]
|
||||||
|
quads += [q.transformed(np.eye(4), pivot)
|
||||||
|
for q in cube_quads(origin, size, offs, g, mirror, tex_size,
|
||||||
|
texture, shape)]
|
||||||
|
return quads
|
||||||
|
|
||||||
|
|
||||||
|
def humanoid_body(texture=None, tex_size=(64, 64)):
|
||||||
|
"""The wearer, as a reference figure to judge a placement against."""
|
||||||
|
quads = []
|
||||||
|
for shape, (part, (origin, size, offs, mirror)) in enumerate(HUMANOID.items()):
|
||||||
|
if part == 'hat':
|
||||||
|
continue
|
||||||
|
quads += [q.transformed(np.eye(4), PIVOTS[part])
|
||||||
|
for q in cube_quads(origin, size, offs, 0.0, mirror, tex_size,
|
||||||
|
texture, shape)]
|
||||||
|
return quads
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- unwrapping
|
||||||
|
#
|
||||||
|
# Box UV, the layout Blockbench calls "Box UV" and Minecraft's entity cubes use:
|
||||||
|
#
|
||||||
|
# [up ][dn ]
|
||||||
|
# [west][north][east][south]
|
||||||
|
#
|
||||||
|
# A box needs (2*dz + 2*dx) across and (dz + dy) down. Faces are written back as
|
||||||
|
# explicit per-face `uv` rectangles, because that is the only thing the JSON
|
||||||
|
# model format can say - `texture_size` scales them, nothing else.
|
||||||
|
|
||||||
|
def texel_size(lo, hi):
|
||||||
|
"""A box's dimensions in whole texels, which is the only size a patch has.
|
||||||
|
|
||||||
|
Rounded up and never zero: a bezel half a pixel thick still needs a row of
|
||||||
|
texture to be painted on, and a face allotted 0.5 of a texel is a face that
|
||||||
|
cannot be drawn and, on the way there, a rectangle PIL refuses to fill.
|
||||||
|
"""
|
||||||
|
return tuple(max(1, int(math.ceil(round(abs(float(b) - float(a)), 4))))
|
||||||
|
for a, b in zip(lo, hi))
|
||||||
|
|
||||||
|
|
||||||
|
def net_size(lo, hi):
|
||||||
|
"""The width and height one box's net needs, in texture pixels."""
|
||||||
|
dx, dy, dz = texel_size(lo, hi)
|
||||||
|
return (2 * dz + 2 * dx, dz + dy)
|
||||||
|
|
||||||
|
|
||||||
|
def _net_faces(u, v, dx, dy, dz):
|
||||||
|
"""Where each face lands in a net whose top-left corner is (u, v)."""
|
||||||
|
return {
|
||||||
|
'up': (u + dz, v, u + dz + dx, v + dz),
|
||||||
|
'down': (u + dz + dx, v, u + dz + dx + dx, v + dz),
|
||||||
|
'west': (u, v + dz, u + dz, v + dz + dy),
|
||||||
|
'north': (u + dz, v + dz, u + dz + dx, v + dz + dy),
|
||||||
|
'east': (u + dz + dx, v + dz, u + dz + dx + dz, v + dz + dy),
|
||||||
|
'south': (u + dz + dx + dz, v + dz, u + dz + dx + dz + dx, v + dz + dy),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def unwrap(elements, texture=None, padding=0, atlas_width=None):
|
||||||
|
"""Give every face of every element its own patch of texture.
|
||||||
|
|
||||||
|
Shelf-packs one net per element, tallest first, and writes the resulting
|
||||||
|
rectangles back into each face's `uv`. Returns the atlas size the caller
|
||||||
|
should put in `texture_size`, and the placement of each element so a
|
||||||
|
template can be drawn to match.
|
||||||
|
|
||||||
|
Nothing here is clever about sharing texture between identical faces. That
|
||||||
|
is deliberate: a shared patch is a patch you cannot edit on one face
|
||||||
|
without editing the other, and this exists so that faces can be painted.
|
||||||
|
"""
|
||||||
|
nets = []
|
||||||
|
for i, el in enumerate(elements):
|
||||||
|
lo, hi = el['from'], el['to']
|
||||||
|
dx, dy, dz = texel_size(lo, hi)
|
||||||
|
w, h = net_size(lo, hi)
|
||||||
|
nets.append({'index': i, 'dx': dx, 'dy': dy, 'dz': dz,
|
||||||
|
'w': w + 2 * padding, 'h': h + 2 * padding})
|
||||||
|
|
||||||
|
widest = max((n['w'] for n in nets), default=1.0)
|
||||||
|
total = sum(n['w'] * n['h'] for n in nets) or 1.0
|
||||||
|
if atlas_width is None:
|
||||||
|
# Wide enough for the widest net, and roughly square overall.
|
||||||
|
atlas_width = max(widest, math.sqrt(total) * 1.3)
|
||||||
|
atlas_width = 1 << max(0, math.ceil(math.log2(max(1.0, atlas_width))))
|
||||||
|
|
||||||
|
# Shelves: tallest first so a short net never strands a tall one.
|
||||||
|
shelf_x, shelf_y, shelf_h = 0.0, 0.0, 0.0
|
||||||
|
for net in sorted(nets, key=lambda n: -n['h']):
|
||||||
|
if shelf_x + net['w'] > atlas_width and shelf_x > 0:
|
||||||
|
shelf_y += shelf_h
|
||||||
|
shelf_x, shelf_h = 0.0, 0.0
|
||||||
|
net['u'], net['v'] = shelf_x + padding, shelf_y + padding
|
||||||
|
shelf_x += net['w']
|
||||||
|
shelf_h = max(shelf_h, net['h'])
|
||||||
|
height = shelf_y + shelf_h
|
||||||
|
atlas_height = 1 << max(0, math.ceil(math.log2(max(1.0, height))))
|
||||||
|
|
||||||
|
for net in nets:
|
||||||
|
el = elements[net['index']]
|
||||||
|
rects = _net_faces(net['u'], net['v'], net['dx'], net['dy'], net['dz'])
|
||||||
|
faces = el.setdefault('faces', {})
|
||||||
|
for name, rect in rects.items():
|
||||||
|
face = faces.get(name)
|
||||||
|
if face is None:
|
||||||
|
face = {'texture': texture or '#0'}
|
||||||
|
faces[name] = face
|
||||||
|
face['uv'] = [round(c, 4) for c in rect]
|
||||||
|
face.pop('rotation', None)
|
||||||
|
if texture:
|
||||||
|
face['texture'] = texture
|
||||||
|
return (atlas_width, atlas_height), nets
|
||||||
|
|
||||||
|
|
||||||
|
def unwrap_template(size, nets, elements, labelled=True):
|
||||||
|
"""A painting guide for an unwrap: one coloured, labelled patch per face.
|
||||||
|
|
||||||
|
The colours are the ones the untextured viewport uses - hue per shape,
|
||||||
|
shade per face - so the model on screen is the legend for this sheet.
|
||||||
|
Find the colour on the model, find the same colour here, and that is the
|
||||||
|
patch to paint.
|
||||||
|
|
||||||
|
Doubles as the check on the unwrap itself. Render a model with this as its
|
||||||
|
texture and every face should show its own colour, its own letter, and the
|
||||||
|
letter the right way up; anything else is a uv routed to the wrong place.
|
||||||
|
"""
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
img = Image.new('RGBA', (int(size[0]), int(size[1])), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
count = len(elements)
|
||||||
|
for net in nets:
|
||||||
|
rects = _net_faces(net['u'], net['v'], net['dx'], net['dy'], net['dz'])
|
||||||
|
for name, (u0, v0, u1, v1) in rects.items():
|
||||||
|
if u1 <= u0 or v1 <= v0:
|
||||||
|
continue
|
||||||
|
tint = tuple(int(round(c * 255))
|
||||||
|
for c in face_colour(net['index'], count, name))
|
||||||
|
d.rectangle([u0, v0, u1 - 1, v1 - 1], fill=tint + (255,))
|
||||||
|
# A darker top edge and left edge, so the patch has an up and a left.
|
||||||
|
shade = tuple(int(c * 0.62) for c in tint)
|
||||||
|
d.line([(u0, v0), (u1 - 1, v0)], fill=shade + (255,))
|
||||||
|
d.line([(u0, v0), (u0, v1 - 1)], fill=shade + (255,))
|
||||||
|
if labelled and (u1 - u0) >= 3 and (v1 - v0) >= 5:
|
||||||
|
d.text((u0 + 1, v0 + 1), name[0].upper(), fill=(20, 20, 24, 255))
|
||||||
|
return img
|
||||||
Loading…
Reference in New Issue