Compare commits

..

No commits in common. "main" and "gem-slots-and-the-fangs-element" have entirely different histories.

43 changed files with 46 additions and 5453 deletions

3
.gitignore vendored
View File

@ -5,6 +5,3 @@ runs/
repo/ repo/
*.class *.class
__pycache__/ __pycache__/
# Python virtualenv for the tools in tools/
tools/.venv/

183
README.md
View File

@ -106,13 +106,6 @@ every attribute rather than only this mod's, and reads this mod's own attributes
back off the equipment as well, so the answer is the same whether or not the map back off the equipment as well, so the answer is the same whether or not the map
ever lost them. ever lost them.
**Counting gem cases.** `ApothicSockets.java` makes each Apothic gem case worth
an Apotheosis socket, which data cannot do at all - every route through MIAPI's
component properties is either dead or crashes the crafting screen, see below.
It is the one place this mod reaches into another's internals, and it does it
reflectively so that neither Apotheosis nor Truly Modular becomes something to
build against.
**One item.** The arcana core, which is the only thing here that is not a **One item.** The arcana core, which is the only thing here that is not a
material or a module - see below. material or a module - see below.
@ -140,7 +133,6 @@ Arsenal, Armory, Archery and MIAPI turns up no module anywhere that offers a
`gem_melee_large` slot - the large gemstone is defined and then has nowhere to `gem_melee_large` slot - the large gemstone is defined and then has nowhere to
go. So there are two here: a medium socket, and a great socket for the large go. So there are two here: a medium socket, and a great socket for the large
gem. Each is a heavier counterweight than the last, paid for in swing speed. gem. Each is a heavier counterweight than the last, paid for in swing speed.
Either socket takes an Apothic gem case as readily as a gemstone - see below.
**Socketed arms and legs** (`packs/armory`) finish a set Armory started. **Socketed arms and legs** (`packs/armory`) finish a set Armory started.
Armory sockets four pieces - helmet, chestplate, backplate and belt - and stops Armory sockets four pieces - helmet, chestplate, backplate and belt - and stops
@ -150,40 +142,15 @@ right, each with the same `gem_armor_medium` slot the rest of the set uses and
the same price, 5% of the piece's armour. the same price, 5% of the piece's armour.
They inherit Armory's heavy pieces whole and change two things: the slot, and They inherit Armory's heavy pieces whole and change two things: the slot, and
where the gem sits in it. where the gem sits in it. Armory's own gems are placed on the body - the chest's
sits on the sternum, from `origin: body` - so these do the same with the limb as
Where the gem sits is the unfinished part of these four, and the honest summary the origin. On an arm the gem is turned to face outwards and set at the top of
is that it is placed by trial rather than by arithmetic. MIAPI draws a worn piece the shoulder, in the pauldron; on a leg it faces forward, halfway down, in the
once per body part; which part a module is drawn under comes from its own model's knee. The numbers come from Armory's own: a sprite lies proud of the side of an
`origin`, and where it sits comes from the matching entry of its slot's transform arm at 2.15 and of the front of a body at 2.35, which is what its banner patches
stack. Armory's gemstone declares no origin, so a gem is drawn once, in the use, so the gems use it too. They are eyeball values on someone else's model
`body` pass, from the `body` entry - which means a gem slot is placed in body and the four transforms are the only thing to touch if a gem sits proud or
space no matter which limb it is cut into, and is pinned to the torso rather than sinks in.
to the limb it belongs to.
The numbers are body-space pixels from the neck: +x to the wearer's left, +y
down, -z forward. A pauldron gem is turned a quarter turn about y to face out of
the shoulder; a knee gem faces forward already. Right-hand pieces are the same
numbers with x and the turn negated.
They are not right yet, and two attempts to make them right are worth recording
so that they are not repeated. Naming the limb as the gem slot's `origin` - so
the offset lands in the entry the limb pass reads and the gem follows the arm -
composes correctly on paper and in game puts the gems several blocks away from
the wearer, floating. And `tools/preview_armour.py`, which applies the same chain
MIAPI does and draws the result from any angle, agrees with the paper rather than
with the game: given the configuration that in practice left gems at the neck, it
puts them on the pauldron. The tool is worth keeping for reading Armory's own
geometry - where the plate bands are, which face is which, how far the chain
scales a limb - and it should not be trusted to predict a gem's final position
until it can reproduce a symptom somebody has actually seen.
One thing does not survive any of this and is not fixable from here. A slot
transform that names an origin has nothing left for the inventory icon, which
reads a different entry of the same stack, so the gem sits at the centre of the
icon - the collar of a chestplate, which is where Armory leaves its own, and the
crotch of a pair of pants, which is where this one leaves ours. The two
placements come from one transform and cannot both be aimed.
The pieces take any medium armour gemstone, which includes the Apothic gem case The pieces take any medium armour gemstone, which includes the Apothic gem case
below - so a pauldron can hold an Apotheosis gem as readily as a Truly Modular below - so a pauldron can hold an Apotheosis gem as readily as a Truly Modular
@ -194,56 +161,11 @@ for an Apotheosis one. Apotheosis gems are not modules and never can be - what
makes one worth having is its own rarity and purity data, which a MIAPI makes one worth having is its own rarity and purity data, which a MIAPI
material cannot carry - so the two systems cannot share a slot. What they can material cannot carry - so the two systems cannot share a slot. What they can
share is the item. Apotheosis stores how many gems a thing holds in a plain share is the item. Apotheosis stores how many gems a thing holds in a plain
data component, `apotheosis:sockets`, and MIAPI can set any component a module data component, `apotheosis:sockets`, and MIAPI's `component` property sets any
likes: put a case in an armour piece's `gem_armor_medium` slot instead of a component a module likes, so the case is one line of data: put it in an armour
gemstone, and the piece reports an Apotheosis socket. The gem itself goes in at piece's `gem_armor_medium` slot instead of a gemstone, and the piece reports one
a smithing table, through Apotheosis' own socketing recipe, not at the workbench. Apotheosis socket. The gem itself goes in at a smithing table, through
Apotheosis' own socketing recipe, not at the workbench.
A weapon takes one too, and there is a case per gem size to say so. A slot
accepts what its `allowed` list names: the socketed pommels above ask for
`gem_melee_medium` and `gem_melee_large`, and the baseline sockets - Arsenal's
own socket pommel, and the dual socket guard that carries two of them - ask for
`gem_melee_small`. So the case is allowed in the medium slots, a **great case**
answers for the large, and a **small case** for the small, which is the same
trade in a sword that it is in a pauldron. The small one names the armour size
key as well, because a small slot is a small slot wherever it was cut and a key
nothing asks for costs nothing.
Nothing else changes. Sockets are counted per case rather than per item, so a
sword with a case in its pommel reports one the same way a chestplate does and
a guard holding two small cases reports two; Apotheosis sockets gems into
weapons already. All three are filed under `packs/apotheosis` rather than with
the pommels, because a case without Apotheosis is an item that grants nothing,
while a case without Arsenal is one that simply never fits anywhere.
One socket a case, and none of it is in the module JSON, because every route
through data is closed. MIAPI's `components` property sets a component rather
than adding to it, so two cases both writing `1` leave the piece with one socket
between them. The property that could resolve a count instead of a constant,
`advanced_components`, is written but never registered in MIAPI 2.3.8 -
`PropertyRegistry` wires up `components` and `material_component_property` and
never mentions it - so that key resolves to nothing. And the one that does work
turned out to be worse than useless: `ComponentProperty.preview` dereferences the
workbench without a null check, MIAPI passes it null while hovering an option in
the replace list, and `shouldExecuteOnCraft` only calls it for a module that
carries the property - so a gem case with a `components` line crashed the
crafting screen, and only a gem case did.
So `ApothicSockets.java` answers it instead, and Apotheosis makes that easy by
asking the question out loud: `SocketHelper` reads the component and then fires
`GetItemSocketsEvent` so anything may revise the answer. This counts the cases on
the stack and adds one for each. Nothing writes the component now, which is
better than the data version managed: a socket cut by a Sigil of Socketing
survives and the cases stack on top, where the component used to overwrite it.
It reaches both mods reflectively rather than compiling against them. Neither is
a build dependency here and one socket is not worth making them one, so the seven
members it needs - three off MIAPI's module tree, three off the event, and the
event class itself - are resolved once at startup and cached. Missing classes
mean the mods are not installed, which is ordinary and silent; a missing method
means something was renamed, which is logged, because it is a thing to fix rather
than a thing to expect. Either way the count falls back to what the component
says on its own.
That leaves the two systems where they each work best. Apotheosis reads the That leaves the two systems where they each work best. Apotheosis reads the
socket count through `SocketHelper`, which classifies the item with socket count through `SocketHelper`, which classifies the item with
@ -253,23 +175,16 @@ arrive as attribute modifiers on the stack, through `ItemAttributeModifierEvent`
which is where MIAPI is not looking; MIAPI writes its own numbers into the which is where MIAPI is not looking; MIAPI writes its own numbers into the
`ATTRIBUTE_MODIFIERS` component, and the two add up rather than overwrite. `ATTRIBUTE_MODIFIERS` component, and the two add up rather than overwrite.
It is drawn here rather than borrowed. It used to wear Armory's medium gemstone It wears Armory's own medium gemstone model rather than art of its own, which
model, which was the least work and the wrong picture: a case is the setting, is both the least work and the right picture: a case is a setting cut for a
not the stone, and one that looks like a gemstone is indistinguishable from the gem, in whatever material it was built from, and it sits exactly where a
gemstone it is an alternative to. So `items/gem_case.png` is a four-by-four gemstone would. Nothing is copied to do it - the module points at Armory's
bezel around an empty two-by-two socket - a gemstone's own footprint, with the model path, and Armory is required for the pack to load at all.
mount drawn around it - lit from the top left like the pommel sockets, and on
the same greys the materials' palettes map, so it comes out in whatever it was
built from. The socket stays empty-looking whatever is in it, which is honest:
an Apotheosis gem lives in a component and MIAPI has nothing to render for it.
The bezel is twice a gemstone across, which would put a case at twice a One socket a case, which is the one-for-one trade the module is. Two things
gemstone's size in a slot sized for one, so the module's own model transform worth knowing: the component is the module's while the module is in, so sockets
carries a scale of 0.5 and hands the slot back a gemstone's footprint. That is added with a Sigil of Socketing are overwritten by it and come back when the
the module's business rather than the slot's: a socket that holds either should case comes out, and a gem sitting in a case stops counting if the case is
not have to be scaled differently depending on which went in.
One thing worth knowing: a gem sitting in a case stops counting if the case is
removed - it is not destroyed, it is just in a socket that no longer exists. removed - it is not destroyed, it is just in a socket that no longer exists.
**The space set** (`packs/armory`) is Create: Cosmonautics' answer to vacuum, **The space set** (`packs/armory`) is Create: Cosmonautics' answer to vacuum,
@ -443,58 +358,6 @@ Requires `pillow` and `numpy`.
Module JSON and textures are not generated. A run only clears the material Module JSON and textures are not generated. A run only clears the material
folders, so the hand-written packs survive it. folders, so the hand-written packs survive it.
## Looking at the armour
```sh
python3 tools/ARMOUR_QUICKSTART.py # every armour model, one at a time
python3 tools/ARMOUR_GUI.py --jar <armory> # draw and unwrap socket geometry
python3 tools/ARMOUR_EDITOR.py --jar <armory> # pose a scene and nudge placements
```
The quickstart is the way in and needs no arguments: it reads every model in
`src/main/resources` - worn armour, icons, sword parts, the loose item models,
the files in the tree rather than a built jar - and lists them with the first
one showing. A jar passed with `--jar` adds its models to the list too, after
yours and marked with their namespace, which is the only way to see a socket
against the plate it is cut into: the geometry in this repo is the socket, and
`arm_left/heavy` is Armory's. Click a row to show or hide it and to point the camera at it;
`Reload from disk` picks up a model saved in another window.
The left panel is the selected model's MIAPI transform. Three arrows on the
model's origin move it along an axis and the rotate toggle swaps them for three
rings; the position and rotation boxes read what the drag did, in the units a
module writes, and take typed numbers back. `Copy as JSON` puts the `transform`
block on the clipboard, and `Write to source` puts it back in the module entry
it was read from - scale and origin untouched. It writes every model that has
moved rather than just the selected one, and marks a moved row with a `*` until
it has; a model no module names has nowhere to write to and is named in the
report instead. `Unwrap UVs` re-cuts the selected model's texture so every
face has a patch of its own, writes the atlas size beside the boxes, and paints
a template to match where there is no texture yet - never over one there is.
Where a model goes comes from the mod's own module data: MIAPI draws a model
under the body part its `origin` names, and those are declared in
`packs/*/data/*/miapi/modules/`. What is *not* in this tree is the offset
Armory's slot transforms carry, so without `--jar` a worn model is flipped onto
its part but not moved along it - right limb, roughly right place. Pass the
Armory jar for exact placement, and for its own plates to line new geometry up
against.
These need pyvista and pyvistaqt, which hardly any distribution packages, so
the tools keep a virtualenv at `tools/.venv` and re-run themselves inside it.
Build it once with
```sh
python3 -m venv --system-site-packages tools/.venv
tools/.venv/bin/pip install pyvista pyvistaqt
```
`--system-site-packages` is what keeps the distribution's Qt in charge; a
PySide6 from pip alongside the system Qt is a partial upgrade waiting to
happen. On a Wayland session the viewport runs through XWayland, because VTK's
Python wheels have no Wayland window backend - the tools say which display they
picked, and why, when they start.
## Notes ## Notes
- Titanium's palette comes from Cosmonautics' ingot, which is violet. Air War's - Titanium's palette comes from Cosmonautics' ingot, which is violet. Air War's

View File

@ -1,144 +0,0 @@
package eu.abdelbaki.cmmodular;
import java.lang.reflect.Method;
import java.util.List;
import java.util.function.Consumer;
import net.minecraft.world.item.ItemStack;
import net.neoforged.bus.api.Event;
import net.neoforged.neoforge.common.NeoForge;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Makes each Apothic gem case worth one Apotheosis socket instead of the piece
* as a whole being worth one.
*
* <p>None of this is in the module JSON, and it cannot be. MIAPI's
* {@code components} property sets a component rather than adding to it, so two
* cases both writing 1 would leave the piece with one socket between them; the
* property that could resolve a count instead, {@code advanced_components}, is
* written but never registered in MIAPI 2.3.8, so that key is dead. And the one
* that does work is worse than useless here: {@code ComponentProperty.preview}
* dereferences the workbench without a null check, and MIAPI hands it null while
* hovering an option in the replace list, so merely carrying a {@code components}
* property crashes the crafting screen on a server.
*
* <p>Apotheosis asks the question itself. {@code SocketHelper.getSockets} reads
* the component and then fires {@code GetItemSocketsEvent} so anything can
* revise the answer, which is exactly what is wanted, so this counts the cases
* and revises it.
*
* <p>It does that without compiling against either mod. This mod deliberately
* has no dependencies to build on - Truly Modular and Apotheosis are runtime
* mods it talks to through data - and one socket is not worth giving that up.
* So both sides are reached reflectively and resolved once at startup. Failing
* costs nothing: the count reverts to what the component alone says, which is
* the behaviour without this class at all. Absence is silent, since a pack
* without those mods is the ordinary case, but a rename is logged, because
* that one is a thing to fix rather than a thing to expect.
*/
public final class ApothicSockets {
/** The one place this mod logs: a silent reflective failure is unfindable. */
private static final Logger LOGGER = LoggerFactory.getLogger(CMModular.MOD_ID);
private static final String EVENT = "dev.shadowsoffire.apotheosis.event.GetItemSocketsEvent";
private static final String MIAPI_MODULE = "smartin.miapi.modules.ItemModule";
/**
* Module ids that are a gem case: one per gem size, one per limb socket.
* A case is not listed twice for being allowed in a pommel as well as in
* armour - it is one module either way, and this counts modules, so a
* guard holding two small cases is worth two sockets by the same rule
* that makes a chestplate holding one worth one.
*/
private static final List<String> CASES = List.of(
"cmmodular:gem/apothic_case",
"cmmodular:gem/case_small",
"cmmodular:gem/case_great",
"cmmodular:gem/limb/case_arm_left",
"cmmodular:gem/limb/case_arm_right",
"cmmodular:gem/limb/case_leg_left",
"cmmodular:gem/limb/case_leg_right");
/** MIAPI's module tree, reached without naming its types at compile time. */
private static Method getModules;
private static Method getFlatList;
private static Method getModule;
private static Method moduleId;
/** Apotheosis' event, likewise. */
private static Method getStack;
private static Method getSockets;
private static Method setSockets;
private ApothicSockets() {
}
static void register() {
Class<?> itemModule;
Class<?> moduleInstance;
Class<?> event;
try {
itemModule = Class.forName(MIAPI_MODULE);
moduleInstance = Class.forName("smartin.miapi.modules.ModuleInstance");
event = Class.forName(EVENT);
} catch (ClassNotFoundException absent) {
// One of the two mods is not installed, which is the ordinary case
// for a pack without them and nothing to say anything about. The
// classes answer that better than ModList does, which is not
// reliably populated this early.
return;
}
try {
getModules = itemModule.getMethod("getModules", ItemStack.class);
getFlatList = moduleInstance.getMethod("getFlatList");
getModule = moduleInstance.getMethod("getModule");
moduleId = itemModule.getMethod("id");
getStack = event.getMethod("getStack");
getSockets = event.getMethod("getSockets");
setSockets = event.getMethod("setSockets", int.class);
@SuppressWarnings("unchecked")
Class<Event> type = (Class<Event>) event;
NeoForge.EVENT_BUS.addListener(type, (Consumer<Event>) ApothicSockets::onGetSockets);
} catch (ReflectiveOperationException | ClassCastException moved) {
// Both mods are here but something has been renamed, which is worth
// saying out loud: the cases still work, they just stop counting.
LOGGER.warn("Apothic gem cases will hold one socket a piece rather than one"
+ " each - Apotheosis or MIAPI has moved something: {}", moved.toString());
}
}
/**
* Answers with a socket a case, added to whatever the stack already had.
* Nothing else here writes that component now, so a socket cut by a Sigil of
* Socketing survives and the cases stack on top of it, which is a better
* trade than the component's own answer of overwriting it.
*/
private static void onGetSockets(Event event) {
try {
int cases = countCases((ItemStack) getStack.invoke(event));
if (cases > 0) {
setSockets.invoke(event, (int) getSockets.invoke(event) + cases);
}
} catch (ReflectiveOperationException | RuntimeException e) {
// Answering wrongly is worse than not answering: leave the count be.
}
}
private static int countCases(ItemStack stack) throws ReflectiveOperationException {
Object root = getModules.invoke(null, stack);
if (root == null) {
return 0;
}
int found = 0;
for (Object module : (List<?>) getFlatList.invoke(root)) {
Object id = moduleId.invoke(getModule.invoke(module));
if (id != null && CASES.contains(id.toString())) {
found++;
}
}
return found;
}
}

View File

@ -87,7 +87,6 @@ public class CMModular {
SandPhasing.register(modBus); SandPhasing.register(modBus);
EquipmentAttributes.register(modBus); EquipmentAttributes.register(modBus);
ArcanaCore.register(modBus); ArcanaCore.register(modBus);
ApothicSockets.register();
modBus.addListener(this::addMaterialPacks); modBus.addListener(this::addMaterialPacks);
} }

View File

@ -25,16 +25,6 @@
"miapi.module.cmmodular.armor.socket.leg_right.name": "%s Heavy Socket Right Pants", "miapi.module.cmmodular.armor.socket.leg_right.name": "%s Heavy Socket Right Pants",
"miapi.module.cmmodular.armor.socket.leg_right.description": "Heavy Pants with a Gemstone set into the Knee", "miapi.module.cmmodular.armor.socket.leg_right.description": "Heavy Pants with a Gemstone set into the Knee",
"miapi.module.cmmodular.gem.apothic_case.name": "Apothic Gem Case", "miapi.module.cmmodular.gem.apothic_case.name": "Apothic Gem Case",
"miapi.module.cmmodular.gem.case_small.name": "Small Apothic Gem Case",
"miapi.module.cmmodular.gem.case_great.name": "Great Apothic Gem Case",
"miapi.module.cmmodular.gem.limb.gem_arm_left.name": "%s Left Pauldron Gemstone",
"miapi.module.cmmodular.gem.limb.case_arm_left.name": "Left Pauldron Apothic Gem Case",
"miapi.module.cmmodular.gem.limb.gem_arm_right.name": "%s Right Pauldron Gemstone",
"miapi.module.cmmodular.gem.limb.case_arm_right.name": "Right Pauldron Apothic Gem Case",
"miapi.module.cmmodular.gem.limb.gem_leg_left.name": "%s Left Knee Gemstone",
"miapi.module.cmmodular.gem.limb.case_leg_left.name": "Left Knee Apothic Gem Case",
"miapi.module.cmmodular.gem.limb.gem_leg_right.name": "%s Right Knee Gemstone",
"miapi.module.cmmodular.gem.limb.case_leg_right.name": "Right Knee Apothic Gem Case",
"miapi.material.cmmodular.bone.dragonbone": "Dragonbone", "miapi.material.cmmodular.bone.dragonbone": "Dragonbone",
"miapi.material.cmmodular.bone.hippogryph_talon": "Talon", "miapi.material.cmmodular.bone.hippogryph_talon": "Talon",
"miapi.material.cmmodular.bone.sea_serpent_fang": "Sea Serpent Fang", "miapi.material.cmmodular.bone.sea_serpent_fang": "Sea Serpent Fang",

View File

@ -1,6 +0,0 @@
{
"parent": "item/generated",
"textures": {
"layer0": "cmmodular:items/gem_case"
}
}

View File

@ -1,6 +0,0 @@
{
"parent": "item/generated",
"textures": {
"layer0": "cmmodular:items/gem_case_great"
}
}

View File

@ -1,6 +0,0 @@
{
"parent": "item/generated",
"textures": {
"layer0": "cmmodular:items/gem_case_small"
}
}

View File

@ -1,91 +0,0 @@
{
"comment": "Gem socket drawn under left_arm, so it moves with the limb. MIAPI draws a model in the pass its own origin names, and Armory's gemstone names none, so the socket has to be ours for anything here to follow the limb at all.",
"texture_size": [
16,
8
],
"textures": {
"0": "cmmodular:equipment/arm_left_socket",
"particle": "cmmodular:equipment/arm_left_socket"
},
"elements": [
{
"name": "pauldron socket",
"from": [
-2.5,
-0.25,
-1.5
],
"to": [
-2.0,
1.75,
1.5
],
"rotation": {
"angle": -22.5,
"axis": "z",
"origin": [
0,
0,
0
]
},
"faces": {
"up": {
"texture": "#0",
"uv": [
3.0,
0.0,
4.0,
3.0
]
},
"down": {
"texture": "#0",
"uv": [
4.0,
0.0,
5.0,
3.0
]
},
"west": {
"texture": "#0",
"uv": [
0.0,
3.0,
3.0,
5.0
]
},
"north": {
"texture": "#0",
"uv": [
3.0,
3.0,
4.0,
5.0
]
},
"east": {
"texture": "#0",
"uv": [
4.0,
3.0,
7.0,
5.0
]
},
"south": {
"texture": "#0",
"uv": [
7.0,
3.0,
8.0,
5.0
]
}
}
}
]
}

View File

@ -1,91 +0,0 @@
{
"comment": "Gem socket drawn under right_arm, so it moves with the limb. MIAPI draws a model in the pass its own origin names, and Armory's gemstone names none, so the socket has to be ours for anything here to follow the limb at all.",
"texture_size": [
16,
8
],
"textures": {
"0": "cmmodular:equipment/arm_right_socket",
"particle": "cmmodular:equipment/arm_right_socket"
},
"elements": [
{
"name": "pauldron socket",
"from": [
2.0,
-0.25,
-1.5
],
"to": [
2.5,
1.75,
1.5
],
"rotation": {
"angle": 22.5,
"axis": "z",
"origin": [
0,
0,
0
]
},
"faces": {
"up": {
"texture": "#0",
"uv": [
3.0,
0.0,
4.0,
3.0
]
},
"down": {
"texture": "#0",
"uv": [
4.0,
0.0,
5.0,
3.0
]
},
"west": {
"texture": "#0",
"uv": [
0.0,
3.0,
3.0,
5.0
]
},
"north": {
"texture": "#0",
"uv": [
3.0,
3.0,
4.0,
5.0
]
},
"east": {
"texture": "#0",
"uv": [
4.0,
3.0,
7.0,
5.0
]
},
"south": {
"texture": "#0",
"uv": [
7.0,
3.0,
8.0,
5.0
]
}
}
}
]
}

View File

@ -1,82 +0,0 @@
{
"comment": "Gem socket drawn under left_leg, so it moves with the limb. MIAPI draws a model in the pass its own origin names, and Armory's gemstone names none, so the socket has to be ours for anything here to follow the limb at all.",
"texture_size": [
8,
4
],
"textures": {
"0": "cmmodular:equipment/leg_left_socket",
"particle": "cmmodular:equipment/leg_left_socket"
},
"elements": [
{
"name": "knee socket",
"from": [
-1.5,
-2.95,
-2.7
],
"to": [
1.5,
-0.95,
-2.2
],
"faces": {
"up": {
"texture": "#0",
"uv": [
1.0,
0.0,
4.0,
1.0
]
},
"down": {
"texture": "#0",
"uv": [
4.0,
0.0,
7.0,
1.0
]
},
"west": {
"texture": "#0",
"uv": [
0.0,
1.0,
1.0,
3.0
]
},
"north": {
"texture": "#0",
"uv": [
1.0,
1.0,
4.0,
3.0
]
},
"east": {
"texture": "#0",
"uv": [
4.0,
1.0,
5.0,
3.0
]
},
"south": {
"texture": "#0",
"uv": [
5.0,
1.0,
8.0,
3.0
]
}
}
}
]
}

View File

@ -1,82 +0,0 @@
{
"comment": "Gem socket drawn under right_leg, so it moves with the limb. MIAPI draws a model in the pass its own origin names, and Armory's gemstone names none, so the socket has to be ours for anything here to follow the limb at all.",
"texture_size": [
8,
4
],
"textures": {
"0": "cmmodular:equipment/leg_right_socket",
"particle": "cmmodular:equipment/leg_right_socket"
},
"elements": [
{
"name": "knee socket",
"from": [
-1.5,
-2.95,
-2.7
],
"to": [
1.5,
-0.95,
-2.2
],
"faces": {
"up": {
"texture": "#0",
"uv": [
1.0,
0.0,
4.0,
1.0
]
},
"down": {
"texture": "#0",
"uv": [
4.0,
0.0,
7.0,
1.0
]
},
"west": {
"texture": "#0",
"uv": [
0.0,
1.0,
1.0,
3.0
]
},
"north": {
"texture": "#0",
"uv": [
1.0,
1.0,
4.0,
3.0
]
},
"east": {
"texture": "#0",
"uv": [
4.0,
1.0,
5.0,
3.0
]
},
"south": {
"texture": "#0",
"uv": [
5.0,
1.0,
8.0,
3.0
]
}
}
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 B

View File

@ -9,19 +9,17 @@
} }
}, },
"allowed_in_slots": [ "allowed_in_slots": [
"gem_armor_medium", "gem_armor_medium"
"gem_melee_medium"
], ],
"tag": [ "tag": [
"gem_armor", "gem_armor",
"gem_armor_medium", "gem_armor_medium",
"gem_armor_generic", "gem_armor_generic",
"gem_armor_medium_generic", "gem_armor_medium_generic"
"gem_melee",
"gem_melee_medium",
"gem_melee_generic",
"gem_melee_medium_generic"
], ],
"component": {
"apotheosis:sockets": 1
},
"material_property": [ "material_property": [
"default" "default"
], ],

View File

@ -1,37 +0,0 @@
{
"comment": "The great socket takes a large gem and nothing else, so the case that goes in it has to be a large one too - `gem_melee_large` is the key the great pommel asks for, and a medium case does not answer to it.",
"display_name": "miapi.module.cmmodular.gem.case_great.name",
"model": {
"path": "miapi:models/item/armor/gems/large/[material.texture].json",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 0, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1}
}
},
"allowed_in_slots": [
"gem_melee_large"
],
"tag": [
"gem_melee",
"gem_melee_large",
"gem_melee_generic",
"gem_melee_large_generic"
],
"material_property": [
"default"
],
"priority": 0,
"repair_priority": 0,
"rarity": "rare",
"allowed_material": {
"allowedMaterials": [
"metal",
"crystal",
"glass",
"bone",
"stone"
],
"cost": 2
}
}

View File

@ -1,42 +0,0 @@
{
"comment": "The small case, for the slots a small gemstone goes in - Arsenal's own socket pommel and the dual socket guard, which is two of them. Both size keys are named because a small slot is a small slot whether it was cut into a weapon or into armour, and a key nothing asks for costs nothing.",
"display_name": "miapi.module.cmmodular.gem.case_small.name",
"model": {
"path": "miapi:models/item/armor/gems/small/[material.texture].json",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 0, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1}
}
},
"allowed_in_slots": [
"gem_melee_small",
"gem_armor_small"
],
"tag": [
"gem_melee",
"gem_melee_small",
"gem_melee_generic",
"gem_melee_small_generic",
"gem_armor",
"gem_armor_small",
"gem_armor_generic",
"gem_armor_small_generic"
],
"material_property": [
"default"
],
"priority": 0,
"repair_priority": 0,
"rarity": "rare",
"allowed_material": {
"allowedMaterials": [
"metal",
"crystal",
"glass",
"bone",
"stone"
],
"cost": 2
}
}

View File

@ -1,47 +0,0 @@
{
"display_name": "miapi.module.cmmodular.gem.limb.case_arm_left.name",
"model": [
{
"path": "miapi:models/item/armor/gems/medium/[material.texture].json",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 0, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1},
"origin": "left_arm"
}
},
{
"path": "miapi:models/item/armor/gems/medium/[material.texture].json",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 4.5, "y": 3.5, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1}
}
}
],
"allowed_in_slots": [
"gem_armor_medium_arm_left"
],
"tag": [
"gem_armor",
"gem_armor_medium",
"gem_armor_generic",
"gem_armor_medium_generic"
],
"material_property": [
"default"
],
"priority": 0,
"repair_priority": 0,
"rarity": "rare",
"allowed_material": {
"allowedMaterials": [
"metal",
"crystal",
"glass",
"bone",
"stone"
],
"cost": 2
}
}

View File

@ -1,47 +0,0 @@
{
"display_name": "miapi.module.cmmodular.gem.limb.case_arm_right.name",
"model": [
{
"path": "miapi:models/item/armor/gems/medium/[material.texture].json",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 0, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1},
"origin": "right_arm"
}
},
{
"path": "miapi:models/item/armor/gems/medium/[material.texture].json",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": -4.5, "y": 3.5, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1}
}
}
],
"allowed_in_slots": [
"gem_armor_medium_arm_right"
],
"tag": [
"gem_armor",
"gem_armor_medium",
"gem_armor_generic",
"gem_armor_medium_generic"
],
"material_property": [
"default"
],
"priority": 0,
"repair_priority": 0,
"rarity": "rare",
"allowed_material": {
"allowedMaterials": [
"metal",
"crystal",
"glass",
"bone",
"stone"
],
"cost": 2
}
}

View File

@ -1,47 +0,0 @@
{
"display_name": "miapi.module.cmmodular.gem.limb.case_leg_left.name",
"model": [
{
"path": "miapi:models/item/armor/gems/medium/[material.texture].json",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 0, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1},
"origin": "left_leg"
}
},
{
"path": "miapi:models/item/armor/gems/medium/[material.texture].json",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 2.5, "y": -4.5, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1}
}
}
],
"allowed_in_slots": [
"gem_armor_medium_leg_left"
],
"tag": [
"gem_armor",
"gem_armor_medium",
"gem_armor_generic",
"gem_armor_medium_generic"
],
"material_property": [
"default"
],
"priority": 0,
"repair_priority": 0,
"rarity": "rare",
"allowed_material": {
"allowedMaterials": [
"metal",
"crystal",
"glass",
"bone",
"stone"
],
"cost": 2
}
}

View File

@ -1,47 +0,0 @@
{
"display_name": "miapi.module.cmmodular.gem.limb.case_leg_right.name",
"model": [
{
"path": "miapi:models/item/armor/gems/medium/[material.texture].json",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 0, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1},
"origin": "right_leg"
}
},
{
"path": "miapi:models/item/armor/gems/medium/[material.texture].json",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": -2.5, "y": -4.5, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1}
}
}
],
"allowed_in_slots": [
"gem_armor_medium_leg_right"
],
"tag": [
"gem_armor",
"gem_armor_medium",
"gem_armor_generic",
"gem_armor_medium_generic"
],
"material_property": [
"default"
],
"priority": 0,
"repair_priority": 0,
"rarity": "rare",
"allowed_material": {
"allowedMaterials": [
"metal",
"crystal",
"glass",
"bone",
"stone"
],
"cost": 2
}
}

View File

@ -12,18 +12,6 @@
"operation": "*", "operation": "*",
"slot": "chest" "slot": "chest"
} }
],
"model": [
{
"path": "cmmodular:models/item/armor/model/arm_left/socket/[material.texture].json",
"trim_mode": "ARMOR_LAYER_ONE",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 0, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1},
"origin": "left_arm"
}
}
] ]
}, },
"replace": { "replace": {
@ -44,13 +32,13 @@
}, },
"gem": { "gem": {
"allowed": [ "allowed": [
"gem_armor_medium_arm_left" "gem_armor_medium"
], ],
"translationKey": "miapi.slot.gemstone.medium", "translationKey": "miapi.slot.gemstone.medium",
"transform": { "transform": {
"rotation": {"x": 90.0, "y": 55.7523, "z": -90.0}, "rotation": {"x": 0, "y": 90, "z": 0},
"translation": {"x": -1.7667, "y": 1.5983, "z": 0.0}, "translation": {"x": -2.15, "y": -2, "z": 0},
"scale": {"x": 0.6667, "y": 0.8171, "z": 0.7123}, "scale": {"x": 1, "y": 1, "z": 1},
"origin": "left_arm" "origin": "left_arm"
} }
} }

View File

@ -12,18 +12,6 @@
"operation": "*", "operation": "*",
"slot": "chest" "slot": "chest"
} }
],
"model": [
{
"path": "cmmodular:models/item/armor/model/arm_right/socket/[material.texture].json",
"trim_mode": "ARMOR_LAYER_ONE",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 0, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1},
"origin": "right_arm"
}
}
] ]
}, },
"replace": { "replace": {
@ -44,13 +32,13 @@
}, },
"gem": { "gem": {
"allowed": [ "allowed": [
"gem_armor_medium_arm_right" "gem_armor_medium"
], ],
"translationKey": "miapi.slot.gemstone.medium", "translationKey": "miapi.slot.gemstone.medium",
"transform": { "transform": {
"rotation": {"x": 90.0, "y": -55.7523, "z": 90.0}, "rotation": {"x": 0, "y": -90, "z": 0},
"translation": {"x": 1.7667, "y": 1.5983, "z": 0.0}, "translation": {"x": 2.15, "y": -2, "z": 0},
"scale": {"x": 0.6667, "y": 0.8171, "z": 0.7123}, "scale": {"x": 1, "y": 1, "z": 1},
"origin": "right_arm" "origin": "right_arm"
} }
} }

View File

@ -12,31 +12,19 @@
"operation": "*", "operation": "*",
"slot": "legs" "slot": "legs"
} }
],
"model": [
{
"path": "cmmodular:models/item/armor/model/leg_left/socket/[material.texture].json",
"trim_mode": "ARMOR_LAYER_ONE",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 0, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1},
"origin": "left_leg"
}
}
] ]
}, },
"replace": { "replace": {
"slots": { "slots": {
"gem": { "gem": {
"allowed": [ "allowed": [
"gem_armor_medium_leg_left" "gem_armor_medium"
], ],
"translationKey": "miapi.slot.gemstone.medium", "translationKey": "miapi.slot.gemstone.medium",
"transform": { "transform": {
"rotation": {"x": 0.0, "y": 0.0, "z": -180.0}, "rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0.0, "y": -1.95, "z": -2.4917}, "translation": {"x": 0, "y": 5, "z": -2.35},
"scale": {"x": 0.8333, "y": 1.0, "z": 0.8333}, "scale": {"x": 1, "y": 1, "z": 1},
"origin": "left_leg" "origin": "left_leg"
} }
} }

View File

@ -12,31 +12,19 @@
"operation": "*", "operation": "*",
"slot": "legs" "slot": "legs"
} }
],
"model": [
{
"path": "cmmodular:models/item/armor/model/leg_right/socket/[material.texture].json",
"trim_mode": "ARMOR_LAYER_ONE",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": -2, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1},
"origin": "right_leg"
}
}
] ]
}, },
"replace": { "replace": {
"slots": { "slots": {
"gem": { "gem": {
"allowed": [ "allowed": [
"gem_armor_medium_leg_right" "gem_armor_medium"
], ],
"translationKey": "miapi.slot.gemstone.medium", "translationKey": "miapi.slot.gemstone.medium",
"transform": { "transform": {
"rotation": {"x": 0.0, "y": 0.0, "z": -180.0}, "rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0.0, "y": -1.95, "z": -2.4917}, "translation": {"x": 0, "y": 5, "z": -2.35},
"scale": {"x": 0.8333, "y": 1.0, "z": 0.8333}, "scale": {"x": 1, "y": 1, "z": 1},
"origin": "right_leg" "origin": "right_leg"
} }
} }

View File

@ -21,7 +21,7 @@
"trim_mode": "ARMOR_LAYER_ONE", "trim_mode": "ARMOR_LAYER_ONE",
"transform": { "transform": {
"rotation": {"x": 0, "y": 0, "z": 0}, "rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 4.1387, "z": 0}, "translation": {"x": 0, "y": 0, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1}, "scale": {"x": 1, "y": 1, "z": 1},
"origin": "head" "origin": "head"
} }

View File

@ -21,7 +21,7 @@
"trim_mode": "ARMOR_LAYER_ONE", "trim_mode": "ARMOR_LAYER_ONE",
"transform": { "transform": {
"rotation": {"x": 0, "y": 0, "z": 0}, "rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": -2.1901, "z": 0}, "translation": {"x": 0, "y": 0, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1}, "scale": {"x": 1, "y": 1, "z": 1},
"origin": "body" "origin": "body"
} }

View File

@ -1,48 +0,0 @@
{
"display_name": "miapi.module.cmmodular.gem.limb.gem_arm_left.name",
"model": [
{
"path": "miapi:models/item/armor/gems/medium/[material.texture].json",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 0, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1},
"origin": "left_arm"
}
},
{
"path": "miapi:models/item/armor/gems/medium/[material.texture].json",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 4.5, "y": 3.5, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1}
}
}
],
"allowed_in_slots": [
"gem_armor_medium_arm_left"
],
"material_property": [
"default",
"gem_armor_handheld",
"gem_armor_generic"
],
"module_stats": {
"gem_power": 1
},
"tag": [
"gem_armor",
"gem_armor_medium",
"gem_armor_generic",
"gem_armor_medium_generic"
],
"priority": 0,
"repair_priority": 0,
"rarity": "uncommon",
"allowed_material": {
"allowedMaterials": [
"gem_armor"
],
"cost": 2
}
}

View File

@ -1,48 +0,0 @@
{
"display_name": "miapi.module.cmmodular.gem.limb.gem_arm_right.name",
"model": [
{
"path": "miapi:models/item/armor/gems/medium/[material.texture].json",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 0, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1},
"origin": "right_arm"
}
},
{
"path": "miapi:models/item/armor/gems/medium/[material.texture].json",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": -4.5, "y": 3.5, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1}
}
}
],
"allowed_in_slots": [
"gem_armor_medium_arm_right"
],
"material_property": [
"default",
"gem_armor_handheld",
"gem_armor_generic"
],
"module_stats": {
"gem_power": 1
},
"tag": [
"gem_armor",
"gem_armor_medium",
"gem_armor_generic",
"gem_armor_medium_generic"
],
"priority": 0,
"repair_priority": 0,
"rarity": "uncommon",
"allowed_material": {
"allowedMaterials": [
"gem_armor"
],
"cost": 2
}
}

View File

@ -1,48 +0,0 @@
{
"display_name": "miapi.module.cmmodular.gem.limb.gem_leg_left.name",
"model": [
{
"path": "miapi:models/item/armor/gems/medium/[material.texture].json",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 0, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1},
"origin": "left_leg"
}
},
{
"path": "miapi:models/item/armor/gems/medium/[material.texture].json",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 2.5, "y": -4.5, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1}
}
}
],
"allowed_in_slots": [
"gem_armor_medium_leg_left"
],
"material_property": [
"default",
"gem_armor_handheld",
"gem_armor_generic"
],
"module_stats": {
"gem_power": 1
},
"tag": [
"gem_armor",
"gem_armor_medium",
"gem_armor_generic",
"gem_armor_medium_generic"
],
"priority": 0,
"repair_priority": 0,
"rarity": "uncommon",
"allowed_material": {
"allowedMaterials": [
"gem_armor"
],
"cost": 2
}
}

View File

@ -1,48 +0,0 @@
{
"display_name": "miapi.module.cmmodular.gem.limb.gem_leg_right.name",
"model": [
{
"path": "miapi:models/item/armor/gems/medium/[material.texture].json",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 0, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1},
"origin": "right_leg"
}
},
{
"path": "miapi:models/item/armor/gems/medium/[material.texture].json",
"transform": {
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": -2.5, "y": -4.5, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1}
}
}
],
"allowed_in_slots": [
"gem_armor_medium_leg_right"
],
"material_property": [
"default",
"gem_armor_handheld",
"gem_armor_generic"
],
"module_stats": {
"gem_power": 1
},
"tag": [
"gem_armor",
"gem_armor_medium",
"gem_armor_generic",
"gem_armor_medium_generic"
],
"priority": 0,
"repair_priority": 0,
"rarity": "uncommon",
"allowed_material": {
"allowedMaterials": [
"gem_armor"
],
"cost": 2
}
}

View File

@ -1,968 +0,0 @@
#!/usr/bin/env python3
"""Place Truly Modular modules on armour by looking at them and moving them.
Where a module ends up on a worn piece is the product of a model, a slot
transform, the transforms above it and a body part's pivot, and no amount of
reading those numbers tells you whether a gem is on a pauldron or three pixels
inside it. This composes the chain, draws it with a real depth buffer and a
real camera, and lets the placement be pushed around with the arrow keys until
it looks right - then writes the numbers back into the module JSON they came
from.
tools/ARMOUR_EDITOR.py # pick a scene in the dialog
tools/ARMOUR_EDITOR.py vanilla # the reference render
tools/ARMOUR_EDITOR.py sockets --jar <armory.jar> # the editable scene
tools/ARMOUR_EDITOR.py sockets --jar <j> --shot out.png
Two things it does not simplify away, because both change the answer:
*Depth.* Faces are rasterised by VTK against a z-buffer rather than sorted and
painted, so a gem half-sunk into a plate reads as half-sunk rather than as
whichever of the two happened to sort in front.
*MIAPI's arithmetic.* Transforms are kept one per `origin` and merged only
within an entry, which is why a gem whose slot says `body` never picks up the
`left_arm` transform above it. 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 guiplatform
import mcmodel as mc
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# A fallback colour per placement, for the things that are not a model with
# boxes to hue - the wearer, a selection, a plate borrowed for context. Anything
# with shapes of its own is coloured by `mcmodel.face_colour` instead.
PALETTE = [
(0.85, 0.42, 0.40), (0.45, 0.72, 0.90), (0.55, 0.80, 0.45), (0.92, 0.74, 0.36),
(0.72, 0.55, 0.88), (0.40, 0.82, 0.76), (0.90, 0.58, 0.78), (0.65, 0.65, 0.70),
]
# ------------------------------------------------------------------ placements
@dataclass
class Placement:
"""One drawn thing: a model, the transform that puts it somewhere, and -
if it is ours to change - the file that transform was read out of."""
name: str
quads: list # model-space, from mcmodel.model_quads
transform: dict # the editable MIAPI transform
outer: np.ndarray | None = None # slot chain applied after this transform
chain_origin: str | None = None # the origin that chain is filed under
source: tuple | None = None # (path, [json keys]) to write back to
offset: tuple = (0.0, 0.0, 0.0) # laid-out position, for scenes of several items
colour: tuple = (0.7, 0.7, 0.75)
original: dict = field(default_factory=dict)
def __post_init__(self):
self.original = json.loads(json.dumps(self.transform))
@property
def editable(self):
return self.source is not None
@property
def origin(self):
"""The body part this module is drawn under. 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, hue=None):
"""Quads -> [(PolyData, texture id, colour)], grouped so one draw is one look.
Textured, that means one mesh per texture. Untextured, `by_colour` splits
further so each mesh is a single flat colour - hue per shape, shade per
face. That is a few more actors than handing VTK a per-cell colour array
would be, and it is what this does because the array route draws nothing at
all in these wheels: `rgb=True` over cell data comes back empty whether or
not the mesh carries texture coordinates, while a plain `color=` works.
`shape_count` is how many boxes the model has. Pass it when you know it,
because a box whose every face was culled would otherwise shift the hues
off the ones the unwrap template was drawn with.
`hue` hands the whole model a colour of its own instead, for a scene of
several models where the question is which model a face belongs to rather
than which box.
"""
import pyvista as pv
if shape_count is None:
shape_count = max((q.shape for q in quads), default=0) + 1
groups = {}
for q in quads:
image = None
if q.texture:
try:
image = res.image(q.texture)
except KeyError:
image = None
if image is not None and _texture_is_blank(image, q.uv):
continue
tex = q.texture if image is not None else None
colour = mc.face_colour(q.shape, shape_count, q.face, hue) if by_colour else None
groups.setdefault((tex, colour), []).append(q)
out = []
for (tex, colour), group in groups.items():
pts = np.concatenate([q.pts for q in group])
uvs = np.concatenate([q.uv for q in group])
faces = np.hstack([[4, *range(4 * i, 4 * i + 4)] for i in range(len(group))])
mesh = pv.PolyData(pts, faces)
# VTK samples textures from the bottom up; model uv runs from the top.
mesh.active_texture_coordinates = np.column_stack([uvs[:, 0], 1.0 - uvs[:, 1]])
out.append((mesh, tex, colour))
return out
def make_texture(res, ref):
"""A Minecraft texture as VTK understands it: nearest sampled, cutout alpha.
The alpha is snapped to 0 or 255 first. Minecraft draws armour as a cutout
- a texel is either there or it is not - and a stray 247 left behind by
whoever drew the sheet is enough to tip VTK into blending the whole actor,
which makes a solid plate you can see the far side of.
"""
import pyvista as pv
rgba = np.asarray(res.image(ref)).copy()
rgba[..., 3] = np.where(rgba[..., 3] >= 128, 255, 0)
tex = pv.Texture(rgba)
tex.SetInterpolate(False) # Minecraft art is pixels, not a photograph
tex.SetRepeat(False)
tex.SetEdgeClamp(True)
return tex
# -------------------------------------------------------------------- viewport
VIEWS = [('front', 0, 0), ('front-left', 35, 12), ('left', 90, 0),
('above-left', 45, 35), ('back', 180, 0), ('below-left', 40, -30)]
HELP = """\
drag orbit scroll zoom
left/right move x-/x+ up/down move y-/y+
pgup/pgdn move z-/z+ shift x5 step
[ ] step size 1..6 rotate x/y/z -/+
- = scale tab \\ next/previous module
t textures f slot chain on/off
b body figure a axes
u revert s save to json
c print values p screenshot q quit"""
class Viewport:
"""A window on the scene, and the keys that move things around in it."""
def __init__(self, res, placements, args):
import pyvista as pv
self.res, self.placements, self.args = res, placements, args
self.force_chain = args.chain
self.textured = not args.no_textures
self.lossy = 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)
# --------------------------------------------------------------------- opening
def guess_sources():
"""Jars worth offering before anybody has typed a path.
The repo's own resources come first, because the models being worked on are
usually the ones in it and a list that omits them reads as though they are
not available. Then Armory: every scene except `vanilla` reads its models
and the tool is useless without them, so an empty list is the one starting
state guaranteed to be wrong. Both beat opening on a file browser.
"""
found = [os.path.join(REPO, 'src/main/resources')]
for folder in (os.path.expanduser('~/.cache/abdelpak-jars'),
os.path.join(REPO, 'run', 'mods'),
os.path.expanduser('~/.minecraft/mods')):
if not os.path.isdir(folder):
continue
for name in sorted(os.listdir(folder)):
if name.endswith('.jar') and 'armory' in name.lower():
found.append(os.path.join(folder, name))
return found
def scene_blurb(name):
"""The first line of a scene's own docstring, so the two cannot drift."""
doc = (SCENES[name].__doc__ or '').strip()
return doc.splitlines()[0] if doc else ''
def opening_dialog(args):
"""Ask what to open. False if the window was closed without choosing.
Only Qt, no VTK: the viewport this leads to is a plotter of its own, so
there is no shared GL context to get wrong and no reason to make anyone sit
through the Wayland probe before they have picked a scene.
"""
from PySide6 import QtCore, QtWidgets
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv[:1])
dialog = QtWidgets.QDialog()
dialog.setObjectName('opening')
dialog.setWindowTitle('armour editor')
lay = QtWidgets.QVBoxLayout(dialog)
lay.addWidget(QtWidgets.QLabel('<b>What to draw</b>'))
scenes = QtWidgets.QListWidget()
for name in sorted(SCENES):
item = QtWidgets.QListWidgetItem(f'{name}\n {scene_blurb(name)}')
item.setData(QtCore.Qt.UserRole, name)
scenes.addItem(item)
scenes.setCurrentRow(sorted(SCENES).index(args.scene or 'sockets'))
scenes.setObjectName('scenes')
scenes.setMinimumHeight(150)
lay.addWidget(scenes)
lay.addWidget(QtWidgets.QLabel('<b>Where to read models from</b>'))
sources = QtWidgets.QListWidget()
sources.setObjectName('sources')
sources.setMaximumHeight(90)
for path in (args.jar or guess_sources()):
sources.addItem(path)
lay.addWidget(sources)
hint = QtWidgets.QLabel("The repo's own resources are searched whether or not "
"they are listed; a jar is needed for Armory's plates.")
hint.setStyleSheet('color: #888;')
hint.setWordWrap(True)
lay.addWidget(hint)
row = QtWidgets.QHBoxLayout()
def add_jar():
path, _ = QtWidgets.QFileDialog.getOpenFileName(
dialog, 'mod jar', os.path.expanduser('~'), 'Jars (*.jar)')
if path:
sources.addItem(path)
def add_folder():
path = QtWidgets.QFileDialog.getExistingDirectory(
dialog, 'resource folder', os.path.expanduser('~'))
if path:
sources.addItem(path)
def drop():
for item in sources.selectedItems():
sources.takeItem(sources.row(item))
for label, slot in (('Add jar...', add_jar), ('Add folder...', add_folder),
('Remove', drop)):
button = QtWidgets.QPushButton(label)
button.clicked.connect(slot)
row.addWidget(button)
lay.addLayout(row)
form = QtWidgets.QFormLayout()
textures = QtWidgets.QCheckBox()
textures.setObjectName('textures')
textures.setChecked(not args.no_textures)
form.addRow('textures', textures)
wearer = QtWidgets.QCheckBox()
wearer.setObjectName('wearer')
wearer.setChecked(args.body)
form.addRow('draw the wearer', wearer)
variant = QtWidgets.QLineEdit(args.variant)
variant.setObjectName('variant')
form.addRow('material variant', variant)
gem = QtWidgets.QComboBox()
gem.setObjectName('gem')
gem.addItems(['small', 'medium', 'large'])
gem.setCurrentText(args.gem)
form.addRow('gem size', gem)
lay.addLayout(form)
buttons = QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.Open | QtWidgets.QDialogButtonBox.Cancel)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
scenes.itemDoubleClicked.connect(lambda _: dialog.accept())
lay.addWidget(buttons)
dialog.resize(560, 560)
if dialog.exec() != QtWidgets.QDialog.Accepted:
return False
args.scene = scenes.currentItem().data(QtCore.Qt.UserRole)
args.jar = [sources.item(i).text() for i in range(sources.count())]
args.no_textures = not textures.isChecked()
args.body = wearer.isChecked()
args.variant = variant.text().strip() or 'default'
args.gem = gem.currentText()
# The viewport opens its own window through a plain plotter. Letting this
# QApplication linger would leave a second event loop owning the process.
app.quit()
return True
# ------------------------------------------------------------------------ cli
def main(argv=None):
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('scene', nargs='?', choices=sorted(SCENES),
help='which figure to draw; omit it to be asked')
ap.add_argument('--jar', action='append', default=[], metavar='PATH',
help='mod jar or resource directory; repeatable, searched in order')
ap.add_argument('--shot', metavar='PNG',
help='render a montage of angles headlessly instead of opening a window')
ap.add_argument('--size', nargs=2, type=int, default=[1280, 860], metavar=('W', 'H'))
ap.add_argument('--variant', default='default', help='material texture variant')
ap.add_argument('--gem', default='medium', choices=('small', 'medium', 'large'))
ap.add_argument('--layer1', default='miapi:item/armor/base/iron/layer_1',
help='armour layer texture for the vanilla scene')
ap.add_argument('--layer2', default='miapi:item/armor/base/iron/layer_2')
ap.add_argument('--step', type=float, default=0.1, help='starting nudge, in pixels')
ap.add_argument('--yaw', type=float, default=25.0)
ap.add_argument('--pitch', type=float, default=12.0)
ap.add_argument('--distance', type=float, default=52.0)
ap.add_argument('--target', nargs=3, type=float, default=[0.0, 8.0, 0.0],
metavar=('X', 'Y', 'Z'), help='what the camera orbits, in model pixels')
ap.add_argument('--background', default='#1a1a1e')
ap.add_argument('--no-textures', action='store_true',
help='start with every module in its own flat colour')
ap.add_argument('--body', action='store_true', help='draw the wearer as a reference')
ap.add_argument('--chain', action='store_true',
help='force the slot chain on even where MIAPI would file it elsewhere')
ap.add_argument('--lossy-merge', action='store_true',
help='decompose each merge to Euler angles, as MIAPI did before 1.21')
args = ap.parse_args(argv)
if args.scene is None:
if args.shot:
ap.error('--shot renders without a window, so it needs a scene named')
if not opening_dialog(args):
return
sources = list(args.jar)
own = os.path.join(REPO, 'src/main/resources')
if own not in sources:
sources.append(own)
res = mc.Resources(sources)
placements = SCENES[args.scene](res, args)
view = Viewport(res, placements, args)
if args.shot:
print(view.montage(os.path.abspath(args.shot)))
else:
view.show()
if __name__ == '__main__':
# Run where pyvista is, which on most machines is the tools' own virtualenv
# rather than the interpreter this was started with. Imported rather than
# run - by ARMOUR_GUI, say - it is already somewhere that has the stack.
guiplatform.ensure_stack()
main()

View File

@ -1,565 +0,0 @@
#!/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 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 = guiplatform.viewport(self)
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.
The viewport has no GL context until it is on screen, and VTK asked to
render before that goes looking for a context 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())

View File

@ -1,935 +0,0 @@
#!/usr/bin/env python3
"""Open every armour model there is, and look at them one at a time.
The editors ask what you meant before they will draw anything - which part,
which scene, which jar. This asks nothing. It finds every armour model in the
sources, places each one on the body part its slot names, and lists them down
the left.
Only the first is ticked. Eight plates and four sockets drawn at once are a
solid lump with the interesting bits inside it, so the blanket load is about
not having to *find* a model, not about seeing them all at the same time - tick
along the list and each one appears where it will sit on the wearer.
tools/ARMOUR_QUICKSTART.py [--jar <armory.jar>]
Everything means everything: worn armour, inventory icons, sword parts, the
loose item models. The ones that name a body part are drawn on it; the rest
are drawn in item space, where they are modelled.
Your models come off disk, out of `src/main/resources` - the files you are
editing, not the copies inside a built jar - and they are listed first. A jar
on the command line brings three things with it: the slot transforms that place
a worn piece exactly, the textures a model of ours points at, and its own
models, listed after yours and marked with their namespace. That last one
matters for a socket: the geometry here is the socket alone, and the plate it
is cut into - `arm_left/heavy [tm_armory]` - is Armory's, so seeing the two
together needs the jar. `Reload` re-reads the tree, so a model saved in another
window shows up here without restarting.
The left panel is the selected model's own transform - the `translation` and
`rotation` a MIAPI module writes, in model pixels and in degrees about the
model's origin. Three arrows on that origin move it along an axis; the rotate
toggle swaps them for three rings. Dragging one and typing a number are the
same edit seen from two sides, so the panel always reads what the model is
doing, and `Copy as JSON` hands over the block to paste into the module.
`Write to source` is the one thing here that changes a module file. It writes
every model that has moved, not only the one in front of you - a row that has
been moved and not yet written is marked with a `*`, so what is pending is on
the list rather than in your memory - putting each model's numbers back into
the module entry they were read from, and leaving its scale and its origin
alone. Placement lives in the module, so a model no module names - a sword
part, an icon of its own - has nowhere to write to and is named in the report
instead. When it is the geometry itself that wants moving, ARMOUR_GUI edits the
boxes and ARMOUR_EDITOR poses the result in a scene.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import guiplatform
guiplatform.configure(prefer=os.environ.get('ARMOUR_GUI_PLATFORM'))
import numpy as np # noqa: E402
from PySide6 import QtCore, QtWidgets # noqa: E402
import mcmodel as mc # noqa: E402
import ARMOUR_EDITOR as ae # noqa: E402
import ARMOUR_GUI as ag # noqa: E402
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# A model's directory says which slot it dresses; the slot says which body part
# MIAPI draws it under, and that is what decides where it lands and what it
# swings with. Slots Armory has that we do not are here too, because a jar on
# the command line brings them along.
# When a model's module data does not say which body part it rides on, the
# folder it sits in is the next best thing: worn armour is filed by the Armory
# slot that dresses each part, and those names map onto the parts one to one.
SLOT_PARTS = {
'arm_left': 'left_arm', 'arm_right': 'right_arm',
'leg_left': 'left_leg', 'leg_right': 'right_leg',
'chest_front': 'body', 'chest_back': 'body',
'belt': 'body', 'cape': 'body',
'helmet': 'head', 'hat': 'head',
}
# Every model in the tree, whatever it dresses: `assets/<ns>/models/<rest>.json`.
# Armour, icons, sword parts, the loose item models - all of it, because a
# blanket load that quietly skips a folder is worse than no blanket at all.
MODEL_PATH = re.compile(r'^assets/(?P<ns>[^/]+)/models/(?P<rest>.+)\.json$')
class Entry:
"""One model: where it is read from, and where it belongs."""
def __init__(self, ns, rest, placement=None, loose=True):
self.ns, self.rest, self.loose = ns, rest, loose
self.ref = f'{ns}:models/{rest}.json'
# What the module data says beats what the directory is called: the
# `origin` is the part MIAPI draws the model under, and the folder is
# a filing convention that only usually agrees. Anything that names no
# part is drawn in item space, which is where icons and held pieces
# are modelled anyway.
origin, self.matrix, self.declared, self.source = \
placement or (None, np.eye(4), False, None)
# The transform is editable - that is what the handles in the viewport
# move - so the declared one is kept to go back to.
self.given = self.matrix.copy()
self.part = origin or _part_from_path(rest) or 'item'
name = re.sub(r'^item/', '', rest)
name = re.sub(r'/(\[material\.texture\]|[^/]+)$', '', name) if '/' in name else name
self.label = name + ('' if loose and ns == 'cmmodular' else f' [{ns}]')
def quads(self, res, variant):
return mc.model_quads(res.model(self.ref, variant), res)
def shapes(self, res, variant):
"""How many boxes the model has - what its unwrap was coloured against."""
return len(res.model(self.ref, variant).get('elements', []))
def _part_from_path(rest):
"""The body part a worn armour path implies, if it is one."""
m = re.match(r'^item/armor/model/([^/]+)/', rest)
return SLOT_PARTS.get(m.group(1)) if m else None
def placements(res):
"""Where each model rides, as the mod's own module data declares it.
A model file is boxes and nothing else; the part it is drawn under and any
offset it carries live in the MIAPI module that names it. Those are in this
tree - `packs/*/data/*/miapi/modules/**` - so the placement is read from
the same working copy as the geometry rather than guessed from a folder
name. Keyed by model path with the material placeholder left in, which is
the form modules write.
"""
out = {}
for path in res.paths('', '.json', loose=True):
if '/miapi/modules/' not in path:
continue
try:
doc = res.json(path)
except (KeyError, ValueError):
continue
for section in ('merge', 'replace'):
entries = doc.get('data', {}).get(section, {}).get('model') or []
entries = entries if isinstance(entries, list) else [entries]
for i, entry in enumerate(entries):
ref = isinstance(entry, dict) and entry.get('path')
if not ref:
continue
transform = entry.get('transform') or {}
key = _key(ref)
# A module can name the same model twice - once for the body
# part and once, origin-less, for the inventory icon. The one
# that names a part is the placement being looked at, so it
# wins; the icon entry only fills in when it is all there is.
if key in out and not transform.get('origin'):
continue
out[key] = (transform.get('origin'), mc.transform_matrix(transform),
True, (path, section, i))
return out
def _texture_key(doc):
"""The `#key` a model's faces are textured through, or the only one there is."""
for element in doc.get('elements') or []:
for face in (element.get('faces') or {}).values():
if isinstance(face, dict) and face.get('texture'):
return face['texture']
for name in doc.get('textures') or {}:
if name != 'particle':
return f'#{name}'
return '#0'
def _texture_path(ref):
"""Where a namespaced texture id lives, as a path into the resource tree."""
ns, _, rest = ref.partition(':')
if not _:
ns, rest = 'minecraft', ref
if rest.startswith('textures/'):
rest = rest[len('textures/'):]
return f'assets/{ns}/textures/{rest}.png'
def _round(value):
"""A number fit to be written down.
No float dust and no negative zero, and a whole number stays whole - a
module that said `0` should not come back saying `0.0` for the sake of a
drag that never touched that axis.
"""
value = round(float(value), 4) + 0.0
return int(value) if value == int(value) else value
def _key(ref):
"""A model reference cut back to its folder.
A module names `.../socket/[material.texture].json` and the file on disk is
`.../socket/default.json`; the folder is the part they agree on.
"""
return ref.rsplit('/', 1)[0]
def discover(res, variant='default'):
"""Every model in the sources, in a stable order.
Two kinds of file are picked up. Most models are filed one folder per
model with a file per material - `socket/default.json`, or the
`[material.texture].json` a module names - and those count once, drawn in
the variant asked for. The rest are plain item models with no variants at
all, and they count once too.
"""
# Files on disk are the ones being edited, so they are listed first and
# win any tie. A loaded jar is listed too, after them: a socket module is
# geometry cut into a plate that lives in Armory's jar, and a socket with
# nothing to sit on is half a picture.
on_disk = res.paths('assets/', '.json', loose=True)
in_jars = [p for p in res.paths('assets/', '.json') if p not in set(on_disk)]
variants = {p.rsplit('/', 1)[0] for p in on_disk + in_jars
if p.rsplit('/', 1)[1] in (f'{variant}.json', '[material.texture].json')}
declared, out = placements(res), []
for path in on_disk + in_jars:
m = MODEL_PATH.match(path)
if not m:
continue
folder, file = path.rsplit('/', 1)
if file in (f'{variant}.json', '[material.texture].json'):
key = _key(f"{m['ns']}:models/{m['rest']}")
elif folder in variants:
continue # another material of a model already listed once
else:
key = f"{m['ns']}:models/{m['rest']}"
out.append(Entry(m['ns'], m['rest'], declared.get(key), loose=path in on_disk))
# Worn armour first and in body order - head down to legs - then whatever
# is drawn in item space, so the first row is something on the wearer.
parts = ['head', 'body', 'left_arm', 'right_arm', 'left_leg', 'right_leg']
return sorted(out, key=lambda e: (e.ns != 'cmmodular', e.part == 'item',
parts.index(e.part) if e.part in parts else 9,
e.label))
# The conversion from a model's own space to a body part's. Armory's slot
# transforms carry it explicitly, as `"rotation": {"z": 180}`, and it is the
# whole of the conversion - so when the jar those transforms live in is not
# loaded, the flip is still the right thing to assume. What is lost with the
# jar is the offset alongside it, not the flip.
FLIP = mc.rot('z', 180)
def place(res, entry):
"""The matrix and pivot that put one model on the wearer.
Three things: the transform the module data gives the model, the transform
of the Armory slot the piece sits in - when its jar is loaded - and the
pivot of the body part the model named as its origin.
"""
return slot_matrix(res, entry.part) @ entry.matrix, \
mc.PIVOTS.get(entry.part, (0.0, 0.0, 0.0))
def slot_matrix(res, part):
"""Armory's transform for the slot that dresses a part, or the flip alone.
The icon pass gets neither: it is drawn in item space, where there is no
body part to be converted onto.
"""
spec = ag.PARTS.get(part)
if part == 'item' or spec is None or spec['piece'] is None:
return np.eye(4)
try:
piece = json.loads(res.read(ag.PIECE_FILES[spec['piece']]))['slots']
return mc.transform_matrix(piece[spec['slot']]['transform'])
except (KeyError, TypeError, ValueError):
return FLIP
class Quickstart(QtWidgets.QMainWindow):
def __init__(self, res, entries, args):
super().__init__()
self.res, self.entries, self.args = res, entries, args
self.actors = {}
# Before the panel is built: selecting its first row asks to aim the
# camera, and there is no camera to aim until the window is up.
self._realised = False
self._toggled = None
self._loading = False
self.gizmo = None
self.setWindowTitle('armour models')
splitter = QtWidgets.QSplitter()
splitter.addWidget(self._transform_panel())
splitter.addWidget(self._panel())
self.view = guiplatform.viewport(self)
splitter.addWidget(self.view)
splitter.setSizes([220, 250, 1000])
self.setCentralWidget(splitter)
self.view.set_background(args.background)
self._add_lights()
self._say_selected()
# --------------------------------------------------------- transform
def _transform_panel(self):
"""The numbers the handles in the viewport move, and the other way round.
These are a MIAPI module transform, in the units a module writes:
translation in model pixels, rotation in degrees about the model's own
origin. So what this panel reads is what goes in the JSON - the point
of dragging a model into place is the number you are left holding.
"""
box = QtWidgets.QWidget()
lay = QtWidgets.QVBoxLayout(box)
self.transform_label = QtWidgets.QLabel('transform')
self.transform_label.setWordWrap(True)
lay.addWidget(self.transform_label)
# Move or rotate, never both: the rings and the arrows sit in the same
# place on screen, and a drag that grabs the wrong one is a model
# somewhere unexpected with no way back but the numbers.
row = QtWidgets.QHBoxLayout()
self.mode = QtWidgets.QButtonGroup(box)
for i, label in enumerate(('move', 'rotate')):
button = QtWidgets.QRadioButton(label)
button.setChecked(i == 0)
self.mode.addButton(button, i)
row.addWidget(button)
self.mode.idToggled.connect(lambda _i, on: on and self._show_handles())
lay.addLayout(row)
self.spins = {}
for group, label, limit, step in (('translation', 'position', 64.0, 0.25),
('rotation', 'rotation', 360.0, 5.0)):
lay.addWidget(QtWidgets.QLabel(label))
form = QtWidgets.QFormLayout()
for axis in 'xyz':
spin = QtWidgets.QDoubleSpinBox()
spin.setRange(-limit, limit)
spin.setSingleStep(step)
spin.setDecimals(4)
spin.valueChanged.connect(self._typed)
self.spins[(group, axis)] = spin
form.addRow(axis, spin)
lay.addLayout(form)
for label, slot in (('Write to source', self.write_to_source),
('Reset to declared', self.reset_transform),
('Copy as JSON', self.copy_transform),
('Unwrap UVs', self.unwrap_uvs)):
button = QtWidgets.QPushButton(label)
button.clicked.connect(slot)
lay.addWidget(button)
note = QtWidgets.QLabel('drag a handle in the viewport, or type. Only '
'`Write to source` touches the tree')
note.setWordWrap(True)
note.setStyleSheet('color: #888;')
lay.addWidget(note)
lay.addStretch(1)
return box
def _transform_of(self, entry):
"""The selected model's transform as MIAPI would write it down."""
return mc.decompose(entry.matrix if entry is not None else np.eye(4))
def _sync_transform(self):
"""Put the selected model's numbers in the boxes."""
entry = self._current()
self.transform_label.setText(
f'transform - {entry.label}' if entry else 'transform')
written = self._transform_of(entry)
self._loading = True
for (group, axis), spin in self.spins.items():
spin.setEnabled(entry is not None)
spin.setValue(round(float(written[group][axis]), 4))
self._loading = False
def _typed(self):
"""A number was edited: rebuild the model where it now says it goes."""
entry = self._current()
if self._loading or entry is None:
return
entry.matrix = mc.transform_matrix(
{group: {axis: self.spins[(group, axis)].value() for axis in 'xyz'}
for group in ('translation', 'rotation')})
self._mark_moved()
self.rebuild()
def reset_transform(self):
"""Back to what the module data declares - the state it opened in."""
entry = self._current()
if entry is None:
return
entry.matrix = entry.given.copy()
self._sync_transform()
self._mark_moved()
self.rebuild()
def moved(self):
"""Every model whose transform is no longer what its module declares."""
return [e for e in self.entries if not np.allclose(e.matrix, e.given)]
def write_to_source(self):
"""Put the moved models' numbers back into the modules that declare them.
Everything that has moved, not just the row in front of you: a session
is spent nudging several things into place, and a button that saved one
of them would leave the rest to be noticed later or lost. Placement
lives in the module, so what is written is the `translation` and the
`rotation` of the model entry each placement was read from, with its
scale and its origin left as the module had them.
A model no module names has nowhere to write to - its geometry is its
position - and is named in the report rather than passed over.
This is the one thing here that changes a module file.
"""
moved = self.moved()
if not moved:
self.statusBar().showMessage('nothing has moved - nothing to write')
return
written, refused = [], []
for entry in moved:
if entry.source is None or not entry.loose:
refused.append(entry.label)
continue
path, section, index = entry.source
full = self._on_disk(path)
if full is None:
refused.append(entry.label)
continue
with open(full) as fh:
doc = json.load(fh)
model = doc['data'][section]['model']
target = (model[index] if isinstance(model, list) else model)
transform = target.setdefault('transform', {})
numbers = mc.decompose(entry.matrix)
for group in ('translation', 'rotation'):
transform[group] = {a: _round(numbers[group][a]) for a in 'xyz'}
with open(full, 'w') as fh:
fh.write(ae._dumps(doc))
fh.write('\n')
# What the file says is what is declared now, so this is the state
# `Reset to declared` comes back to and the row stops being marked.
entry.given = entry.matrix.copy()
entry.declared = True
written.append(os.path.basename(path))
self._mark_moved()
said = f'wrote {len(written)}: ' + ', '.join(written) if written else ''
if refused:
said += ('; ' if said else '') + 'no module names ' + ', '.join(refused)
self.statusBar().showMessage(said)
def unwrap_uvs(self, _checked=False):
"""Re-cut the selected model's texture so every face has its own patch.
The same unwrap ARMOUR_GUI does when it saves, on a model that is
already drawn rather than one being built: every face gets a rectangle
of its own, the atlas size is written back beside the boxes, and a
template is painted to match - but only where there is no texture yet.
A guide is scaffolding, and overwriting art someone has painted because
the boxes moved would be the tool destroying the work it exists for.
"""
entry = self._current()
if entry is None:
return
model_path = f'assets/{entry.ns}/models/{entry.rest}.json'
full = self._on_disk(model_path) if entry.loose else None
if full is None:
self.statusBar().showMessage(f'{entry.label}: not a file in this tree')
return
with open(full) as fh:
doc = json.load(fh)
if not doc.get('elements'):
self.statusBar().showMessage(
f'{entry.label}: a sprite, not boxes - there is nothing to unwrap')
return
# Unwrap onto the key the model's own faces already name, so a model
# textured through `#1` does not come back pointing at `#0`.
key = _texture_key(doc)
size, nets = mc.unwrap(doc['elements'], texture=key)
doc['texture_size'] = [int(size[0]), int(size[1])]
with open(full, 'w') as fh:
fh.write(ae._dumps(doc))
fh.write('\n')
written = [model_path]
ref = (doc.get('textures') or {}).get(key.lstrip('#'))
template = _texture_path(ref) if ref else None
if template and self._on_disk(template) is None:
beside = os.path.join(full[:-len(model_path)], template)
os.makedirs(os.path.dirname(beside), exist_ok=True)
mc.unwrap_template(size, nets, doc['elements']).save(beside)
written.append(template)
self.rebuild()
self.statusBar().showMessage(
f'unwrapped onto {int(size[0])}x{int(size[1])} - wrote '
+ ', '.join(written))
def _on_disk(self, path):
"""The writable file behind a resource path, if one of the sources has it."""
for directory in self.res.dirs:
full = os.path.join(directory, path)
if os.path.isfile(full):
return full
return None
def copy_transform(self):
"""The transform block, ready to paste into the module that names it."""
entry = self._current()
if entry is None:
return
written = {k: {a: round(float(v[a]), 4) for a in 'xyz'}
for k, v in self._transform_of(entry).items()}
written['origin'] = entry.part
text = json.dumps({'transform': written}, indent=2)
QtWidgets.QApplication.clipboard().setText(text)
self.statusBar().showMessage(f'copied the transform for {entry.label}')
# ---------------------------------------------------------- handles
def _place_gizmo(self):
"""Put the move and rotate handles on the selected model.
The widget hangs off one actor but a model can be several - one per
texture - so a drag is mirrored onto the rest of them, and folded into
the model's own transform when the mouse comes up.
"""
self._drop_gizmo()
entry = self._current()
actors = self._actors_of(entry)
if not actors or not self._realised:
return
matrix, pivot = place(self.res, entry)
# The handles sit on the model's own origin rather than the middle of
# its geometry, because that is the point MIAPI rotates a model about
# and the point the numbers are measured from.
origin = tuple(np.asarray(matrix)[:3, 3] + np.asarray(pivot, float))
self.gizmo = self.view.add_affine_transform_widget(
actors[0], origin=origin, scale=0.35,
interact_callback=self._dragging, release_callback=self._dropped)
self._show_handles()
def _drop_gizmo(self):
if self.gizmo is not None:
try:
self.gizmo.remove()
except (AttributeError, RuntimeError):
pass
self.gizmo = None
def _show_handles(self):
"""Arrows for moving, rings for rotating - one set at a time."""
if self.gizmo is None:
return
rotating = self.mode.checkedId() == 1
for actor in self.gizmo._arrows:
actor.SetVisibility(not rotating)
for actor in self.gizmo._circles:
actor.SetVisibility(rotating)
self.view.render()
def _actors_of(self, entry):
"""Every actor drawing one model, in the order they were added."""
if entry is None:
return []
try:
i = self._visible().index(entry)
except ValueError:
return []
return [a for name, a in self.actors.items() if name.startswith(f'model{i}_')]
def _dragging(self, user_matrix):
"""Mid-drag: move the model's other actors with the one being dragged."""
entry = self._current()
for actor in self._actors_of(entry)[1:]:
actor.user_matrix = user_matrix
self._show_typed(self._folded(entry, user_matrix))
def _dropped(self, user_matrix):
"""Mouse up: fold the drag into the transform and redraw from it."""
entry = self._current()
if entry is None:
return
entry.matrix = self._folded(entry, user_matrix)
for actor in self._actors_of(entry):
actor.user_matrix = np.eye(4)
self._sync_transform()
self._mark_moved()
self.rebuild()
def _folded(self, entry, user_matrix):
"""A world-space drag, expressed as the model's own transform.
The widget moves the actor where it stands, and where it stands is the
model already carried onto the wearer - part pivot, slot flip and all.
Undoing those two leaves the drag in the space the module writes in.
"""
if entry is None:
return np.eye(4)
onto = slot_matrix(self.res, entry.part)
pivot = np.eye(4)
pivot[:3, 3] = mc.PIVOTS.get(entry.part, (0.0, 0.0, 0.0))
drag = np.linalg.inv(onto) @ np.linalg.inv(pivot) @ np.asarray(user_matrix) \
@ pivot @ onto
return drag @ entry.matrix
def _show_typed(self, matrix):
"""Live numbers during a drag, without redrawing the scene."""
written = mc.decompose(matrix)
self._loading = True
for (group, axis), spin in self.spins.items():
spin.setValue(round(float(written[group][axis]), 4))
self._loading = False
# ------------------------------------------------------------- panel
def _panel(self):
box = QtWidgets.QWidget()
lay = QtWidgets.QVBoxLayout(box)
self.count_label = QtWidgets.QLabel()
lay.addWidget(self.count_label)
# A click anywhere on a row toggles it, checkbox or label, because a
# list of things to show is read as a list of switches and half of a
# switch is worse than none: a label click that only ever *added* left
# no way to take anything off the screen except a four-pixel target.
# Arrow keys move the selection without toggling, for looking through
# the list without changing what is drawn.
self.list = QtWidgets.QListWidget()
for i, entry in enumerate(self.entries):
item = QtWidgets.QListWidgetItem(entry.label)
item.setData(QtCore.Qt.UserRole, i)
item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable)
first = i == 0 or self.args.all
item.setCheckState(QtCore.Qt.Checked if first else QtCore.Qt.Unchecked)
self.list.addItem(item)
self.list.itemChanged.connect(self._ticked)
self.list.itemClicked.connect(self._clicked)
self.list.currentItemChanged.connect(self._selected)
self._count()
self.list.setCurrentRow(0)
lay.addWidget(self.list, 1)
row = QtWidgets.QHBoxLayout()
for label, state in (('All', QtCore.Qt.Checked), ('None', QtCore.Qt.Unchecked)):
button = QtWidgets.QPushButton(label)
button.clicked.connect(lambda _=None, st=state: self._set_all(st))
row.addWidget(button)
lay.addLayout(row)
self.textured = QtWidgets.QCheckBox('textures')
self.textured.setChecked(not self.args.no_textures)
self.textured.toggled.connect(lambda _: self.rebuild())
lay.addWidget(self.textured)
self.wearer = QtWidgets.QCheckBox('the wearer')
self.wearer.setChecked(self.args.body)
self.wearer.toggled.connect(lambda _: self.rebuild())
lay.addWidget(self.wearer)
for label, slot in (('Aim at selection', lambda: self._aim(self._current())),
('Reload from disk', self.reload)):
button = QtWidgets.QPushButton(label)
button.clicked.connect(slot)
lay.addWidget(button)
return box
def reload(self):
"""Read the tree again, keeping whatever is ticked that still exists."""
ticked = {e.ref for e in self._visible()}
selected = self._current()
self.entries = discover(self.res, self.args.variant)
self.list.blockSignals(True)
self.list.clear()
for i, entry in enumerate(self.entries):
item = QtWidgets.QListWidgetItem(entry.label)
item.setData(QtCore.Qt.UserRole, i)
item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable)
keep = entry.ref in ticked or (not ticked and i == 0)
item.setCheckState(QtCore.Qt.Checked if keep else QtCore.Qt.Unchecked)
self.list.addItem(item)
if selected is not None and entry.ref == selected.ref:
self.list.setCurrentRow(i)
self.list.blockSignals(False)
self._count()
self._sync_transform()
self.rebuild()
self.statusBar().showMessage(f'reloaded - {len(self.entries)} models')
def _count(self):
self.count_label.setText(f'{len(self.entries)} armour models')
def _mark_moved(self):
"""Mark the rows whose model has been moved but not yet written.
Editing the text is what makes a row say it; doing that quietly is
what keeps it from being read back as a tick and redrawing the scene
on every drag.
"""
moved = {id(e) for e in self.moved()}
self.list.blockSignals(True)
for item in self._rows():
entry = self.entries[item.data(QtCore.Qt.UserRole)]
item.setText(entry.label + (' *' if id(entry) in moved else ''))
self.list.blockSignals(False)
def _rows(self):
for i in range(self.list.count()):
yield self.list.item(i)
def _current(self):
item = self.list.currentItem()
return None if item is None else self.entries[item.data(QtCore.Qt.UserRole)]
def _visible(self):
return [self.entries[item.data(QtCore.Qt.UserRole)] for item in self._rows()
if item.checkState() == QtCore.Qt.Checked]
def _set_all(self, state):
self.list.blockSignals(True)
for item in self._rows():
item.setCheckState(state)
self.list.blockSignals(False)
self.rebuild()
def _clicked(self, item):
"""A click on the label toggles the row, the way one on the box does.
Qt has already toggled the row when the click landed on the checkbox
itself, and `_ticked` leaves word that it did - so the two paths do not
cancel each other out and clicking a box stays one toggle, not two.
"""
by_box, self._toggled = self._toggled, None
if by_box is item:
return
item.setCheckState(QtCore.Qt.Unchecked
if item.checkState() == QtCore.Qt.Checked
else QtCore.Qt.Checked)
self._toggled = None
def _ticked(self, item):
"""A tick draws a model and looks at it. An untick only takes it away."""
self._toggled = item
self.rebuild()
if item.checkState() == QtCore.Qt.Checked:
self.list.setCurrentItem(item)
self._aim(self.entries[item.data(QtCore.Qt.UserRole)])
self._say_selected()
def _selected(self, item, _previous=None):
"""Selecting a row looks at it, and hands it to the transform panel."""
if item is None:
return
self._sync_transform()
self._place_gizmo()
self._aim(self._current())
self._say_selected()
def _say_selected(self):
entry = self._current()
if entry is None:
return
hidden = '' if entry.ref in {e.ref for e in self._visible()} else ' (hidden)'
self.statusBar().showMessage(f'{entry.ref} under {entry.part}{hidden}')
# ----------------------------------------------------------- drawing
def showEvent(self, event):
"""First draw waits for the window, as it does in the other tools."""
super().showEvent(event)
if not self._realised:
self._realised = True
QtCore.QTimer.singleShot(0, self._first_draw)
def _first_draw(self):
# Depth peeling needs a GL context, so it waits for one too.
self.view.enable_depth_peeling(number_of_peels=8, occlusion_ratio=0.0)
self._sync_transform()
self.rebuild()
self._aim(self._current())
def _add_lights(self):
import pyvista as pv
for direction, intensity in (((0.2, -1.0, -0.7), 0.62),
((-0.2, -1.0, 0.7), 0.44)):
v = np.asarray(direction, float)
light = pv.Light(position=tuple(-v * 100), focal_point=(0, 0, 0),
light_type='scene light')
light.intensity = intensity
self.view.add_light(light)
def rebuild(self):
for name in list(self.actors):
self.view.remove_actor(self.actors.pop(name))
textured = self.textured.isChecked()
for i, entry in enumerate(self._visible()):
matrix, pivot = place(self.res, entry)
try:
quads = [q.transformed(matrix, pivot)
for q in entry.quads(self.res, self.args.variant)]
except (KeyError, ValueError) as exc:
# A model that names a texture or a parent no loaded jar has.
# Worth saying rather than drawing a hole, and worth carrying
# on from: the rest of the list is still readable.
print(f'{entry.ref}: {exc}', file=sys.stderr)
continue
# Untextured, every model gets a hue of its own and every side of
# a box its own saturation and brightness - so the colour says two
# things at once, which model this is and which way the face
# points, and opposite sides never read alike. The hue is indexed
# on the model's place in the whole list rather than on what is
# visible, or hiding one model would repaint the others.
colour = ae.PALETTE[i % len(ae.PALETTE)]
hue = mc.object_hue(self.entries.index(entry))
count = entry.shapes(self.res, self.args.variant)
for j, (mesh, tex, face) in enumerate(
ae.build_meshes(self.res, quads, count,
by_colour=not textured, hue=hue)):
kw = dict(smooth_shading=False, ambient=0.42, diffuse=0.78,
specular=0.0)
if textured and tex is not None:
try:
kw['texture'] = ae.make_texture(self.res, tex)
kw['color'] = 'white'
except KeyError:
# A texture no source has. The shape is still worth
# seeing, so it falls back to a colour rather than
# taking the model out of the scene.
kw['color'] = face or colour
else:
kw['color'] = face or colour
name = f'model{i}_{j}'
self.actors[name] = self.view.add_mesh(mesh, name=name, **kw)
if self.wearer.isChecked():
import pyvista as pv
body = mc.humanoid_body()
pts = np.concatenate([q.pts for q in body])
faces = np.hstack([[4, *range(4 * k, 4 * k + 4)] for k in range(len(body))])
self.actors['body'] = self.view.add_mesh(
pv.PolyData(pts, faces), name='body', color=(0.30, 0.32, 0.38),
opacity=0.25, smooth_shading=False, specular=0.0)
# The handles hang off actors that have just been replaced, so they
# are put back on the new ones rather than left pointing at the old.
self._place_gizmo()
if self._realised:
self.view.render()
# From the front, from the wearer's right, and a little above - which is
# -z, -x and -y, because model space has +y going down.
_EYE = np.array([-0.52, -0.19, -0.83])
def _aim(self, entry):
"""Frame one model: where it sits, and far enough back to see all of it.
Distance comes from the model rather than a constant, because these
range from a 2px socket to a whole chestplate and a camera pinned 26
pixels out puts you inside the big ones.
"""
if entry is None or not self._realised:
return
matrix, pivot = place(self.res, entry)
try:
quads = [q.transformed(matrix, pivot)
for q in entry.quads(self.res, self.args.variant)]
except (KeyError, ValueError):
quads = []
if quads:
pts = np.concatenate([q.pts for q in quads])
lo, hi = pts.min(axis=0), pts.max(axis=0)
centre, span = (lo + hi) / 2.0, float(np.linalg.norm(hi - lo))
else:
centre = np.array([pivot[0], pivot[1] + 2.0, 0.0])
span = 8.0
self.view.camera.focal_point = tuple(centre)
self.view.camera.position = tuple(centre + self._EYE * (span * 1.8 + 10.0))
self.view.camera.up = (0.0, -1.0, 0.0)
self.view.camera.view_angle = 34.0
self.view.camera_set = True
self.view.render()
def main(argv=None):
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--jar', action='append', default=[], metavar='PATH',
help='mod jar or resource directory; repeatable, searched in order')
ap.add_argument('--variant', default='default', help='material texture variant')
ap.add_argument('--all', action='store_true',
help='start with every model visible instead of just the first')
ap.add_argument('--body', action='store_true', help='draw the wearer as a reference')
ap.add_argument('--background', default='#1a1a1e')
ap.add_argument('--no-textures', action='store_true')
args = ap.parse_args(argv)
sources = list(args.jar)
sources.append(os.path.join(REPO, 'src/main/resources'))
res = mc.Resources(sources)
entries = discover(res, args.variant)
if not entries:
raise SystemExit('no armour models found - is `--jar` pointing at a mod jar?')
mine = sum(1 for e in entries if e.loose)
borrowed = len(entries) - mine
print(f'models: {mine} read from src/main/resources'
+ (f', {borrowed} more out of the jars' if borrowed else ''),
file=sys.stderr)
if not args.jar:
print('models: no jar, so a worn model is flipped onto the part its module'
" names but not offset - Armory's slot transforms carry the offset,"
' and they are in its jar', file=sys.stderr)
app = QtWidgets.QApplication(sys.argv[:1])
window = Quickstart(res, entries, args)
window.resize(1420, 820)
window.show()
return app.exec()
if __name__ == '__main__':
sys.exit(main())

View File

@ -1,480 +0,0 @@
#!/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())

View File

@ -1,342 +0,0 @@
#!/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 of that in principle - hand VTK a
`vtkGenericOpenGLRenderWindow` and let *Qt* own the GL context, which is what
VTK's C++ `QVTKOpenGLNativeWidget` does - but not from here. pyvistaqt's
interactor is the Python `QVTKRenderWindowInteractor`, and it paints from
`paintEvent`: it never implements `paintGL`, never binds Qt's framebuffer and
never makes a context current. A generic render window given to it draws into
no context at all - a black viewport, and a VTK that will not even read its
own buffer back ("render window is not current"). So VTK keeps a window of its
own, which is an X window, and a Wayland session is served through XWayland.
The order of preference is therefore Wayland, then X11, then offscreen, with
Wayland having to prove itself first - 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.
The other half of saying why: a Qt that will not import fails the Wayland test
frame the same way a bad GL stack does, and blaming the compositor for that is
how an afternoon disappears. So the binding is checked first, and a broken one
is named as broken rather than dressed up as a display problem.
Not all of that stack is packaged everywhere - pyvista and pyvistaqt usually
are not - so the tools keep their own virtualenv at `tools/.venv`, built with
`--system-site-packages` so the distribution's Qt is still the Qt in use. If
the interpreter a tool was started with cannot find the stack and that
virtualenv can, the tool is re-run there rather than failed with a recipe to
type out.
"""
from __future__ import annotations
import ctypes
import ctypes.util
import importlib
import importlib.util
import os
import re
import subprocess
import sys
import tempfile
import textwrap
# The tools' own virtualenv, the modules it exists to provide, and the flag
# that keeps a hop to it from happening twice.
_VENV = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.venv')
_NO_VENV = 'MIAPI_TOOLS_NO_VENV'
_STACK = ('PySide6', 'vtkmodules', 'pyvista', 'pyvistaqt')
# The platform this process settled on, so a tool that imports another tool
# does not re-decide and re-announce it.
_chosen = None
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.
"""
# Before asking which display server to draw on, ask whether there is
# anything to draw with. A stack that will not import fails every probe
# too, and a probe that fails is read as "this platform does not work" -
# which is how a broken install ends up wearing a Wayland costume.
ensure_stack(quiet)
global _chosen
if _chosen:
# A second caller - one tool importing another - is asking a question
# that has already been answered, and answering it twice on stderr
# reads as indecision.
return _chosen
chosen = os.environ.get('QT_QPA_PLATFORM')
note = ''
if not chosen:
chosen, note = prefer, ''
if not chosen:
chosen, note = _detect()
os.environ['QT_QPA_PLATFORM'] = chosen
origin = 'detected'
else:
origin = 'from QT_QPA_PLATFORM'
if not quiet:
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)
_chosen = chosen
return chosen
def ensure_stack(quiet=False):
"""Import the drawing stack, or re-run in the interpreter that has it.
`configure` calls this; so may a tool that draws without asking for a
platform. Exits with something worth reading if neither interpreter can
import the stack.
"""
# qtpy - which pyvistaqt imports - reads QT_API to choose a binding, and
# left to its own order it finds PyQt6 first on a machine that has both.
# Two bindings in one process is a crash rather than a mismatch, so ours
# is named here, before anything goes looking.
os.environ.setdefault('QT_API', 'pyside6')
missing = [m for m in _STACK if importlib.util.find_spec(m) is None]
if missing:
_hop_to_venv(missing, quiet) # replaces this process, if it can
raise SystemExit(_stack_message(missing=missing))
broken = _import_stack()
if broken:
raise SystemExit(_stack_message(broken))
def _hop_to_venv(missing, quiet):
"""Re-run this tool in `tools/.venv`, if that is where the stack lives.
Only what is *absent* sends us there. A PySide6 that is installed but will
not load is a system problem, and the venv is deliberately built on the
system's Qt, so hopping would be asking the same question twice.
"""
python = os.path.join(_VENV, 'bin', 'python')
here = os.path.realpath(sys.prefix) == os.path.realpath(_VENV)
if here or os.environ.get(_NO_VENV) or not os.path.exists(python):
return
if not quiet:
print(f'python: no {", ".join(missing)} here - using {_VENV}',
file=sys.stderr)
os.environ[_NO_VENV] = '1' # one hop: let the venv fail in plain sight
try:
os.execv(python, [python, *sys.argv])
except OSError as exc:
del os.environ[_NO_VENV]
print(f'python: {python} would not start ({exc})', file=sys.stderr)
def _import_stack():
"""Import what has to be imported early, in the order that matters.
PySide6 comes first because `vtkmodules.qt` picks its binding from
whatever is already imported, and it makes that choice once. The widget
base class is left alone: `QVTKRWIBase = 'QOpenGLWidget'` is only sound
with a render window Qt drives, and nothing here drives one. Returns the
first failure, or None.
"""
for module in ('PySide6.QtCore', 'PySide6.QtWidgets', 'vtkmodules.qt'):
try:
importlib.import_module(module)
except ImportError as exc:
return f'{module}: {exc}'
return None
def _stack_message(err=None, missing=()):
"""Say what is actually broken, and what fixes it."""
if missing:
return '\n'.join([
'display: the drawing stack is incomplete, so nothing can be drawn.',
f' no module named {", ".join(missing)}',
'', *textwrap.wrap(
'pyvista and pyvistaqt are packaged by hardly any distribution, so '
"the tools keep a virtualenv of their own for them - built on the "
"system's Qt and VTK rather than over the top of them. The tools "
'use it by themselves once it is there:', 78),
f' python -m venv --system-site-packages {_VENV}',
f' {os.path.join(_VENV, "bin", "pip")} install pyvista pyvistaqt',
])
lines = ['display: the Qt stack will not import, so no window can be opened.',
f' {err}']
# The signature of a half-finished upgrade. PySide6 links Qt's *private*
# ABI, which is versioned build-for-build on purpose, so a PySide6 and a
# libQt6Core from two different releases will not load together even
# though their public API is identical. The symbol names the release
# PySide6 was compiled against; libQt6Core will tell us its own.
want = re.search(r'QtPrivate_(\d+)_(\d+)_(\d+)', err)
if 'undefined symbol' in err and (want or 'Qt_6_PRIVATE_API' in err):
built = '.'.join(want.groups()) if want else 'another release'
have = _qt_runtime_version()
found = f', but the Qt libraries here are {have}' if have else ''
lines += ['', *textwrap.wrap(
f'PySide6 was built against Qt {built}{found}. The two share a '
'private ABI and have to be upgraded together, so this is a partial '
'upgrade rather than a display problem.', 78),
' Arch: sudo pacman -Syu',
' (upgrading qt6-base alone leaves the rest of the system behind)',
]
return '\n'.join(lines)
def _qt_runtime_version():
"""Ask libQt6Core its version, without going through the broken binding."""
try:
path = ctypes.util.find_library('Qt6Core') or 'libQt6Core.so.6'
lib = ctypes.CDLL(path)
lib.qVersion.restype = ctypes.c_char_p
return lib.qVersion().decode()
except (OSError, AttributeError, UnicodeDecodeError):
return None
def _detect():
"""Choose a platform, and carry back the reason for anything given up."""
wayland = os.environ.get('WAYLAND_DISPLAY') and _socket_exists()
x11 = bool(os.environ.get('DISPLAY'))
why = ''
if wayland:
ok, why = _probe('wayland')
if ok:
return 'wayland', ''
if x11:
return 'xcb', f' - Wayland {why}' if why else ''
if wayland:
return 'wayland', (f' - Wayland {why}, and there is no X11 to fall'
' back to') if why else ''
return '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
from PySide6 import QtCore, QtWidgets
import pyvistaqt, pyvista as pv
# Everything above is toolchain, not display. Past this line - a window, a
# context, a frame - a failure really is the display's, and the caller may say
# so. The marker goes here rather than after the window is up because opening
# the window is itself one of the things that fails.
open(sys.argv[1] + '.started', 'w').write('ok')
app = QtWidgets.QApplication(['probe'])
win = QtWidgets.QMainWindow()
view = pyvistaqt.QtInteractor(win)
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()
# Read the frame back out of VTK's own window. It is proof of two things
# at once: that the paint survived, and that something was drawn into a
# buffer VTK can find - the failure that leaves the viewport black does
# not raise, it just renders nowhere, and this is where that shows up.
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?
Returns `(ok, why)`, where `why` finishes the sentence "Wayland ...". The
reason matters: a probe that died before it ever opened a window says
nothing about the display, and reporting it as a failed frame sends the
next hour after the wrong bug.
"""
if os.environ.get('MIAPI_TOOLS_NO_PROBE'):
return False, 'was not tried (MIAPI_TOOLS_NO_PROBE)'
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:
done = subprocess.run([sys.executable, '-c', _PROBE, flag], env=env,
timeout=timeout, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE)
except subprocess.TimeoutExpired:
return False, f'did not finish a test frame within {timeout}s'
except OSError as exc:
return False, f'could not be tested ({exc})'
if os.path.exists(flag):
return True, ''
if os.path.exists(flag + '.started'):
return False, ('is present but VTK cannot draw there - '
+ _last_error(done.stderr))
return False, f'could not be tested - {_last_error(done.stderr)}'
def _last_error(stderr):
"""The one line worth repeating out of whatever the probe printed."""
lines = [ln.strip() for ln in stderr.decode('utf-8', 'replace').splitlines()
if ln.strip()]
# An X protocol error is not raised, it is printed - five lines of it, of
# which the first and the opcode are the ones that say anything. Xlib then
# takes the process down, so this is also the last word on what happened.
for i, line in enumerate(lines):
if line.startswith('X Error of failed request:'):
what = line.split(':', 1)[1].strip()
opcode = next((ln.split(':', 1)[1].strip() for ln in lines[i:]
if ln.startswith('Major opcode')), '')
where = f" on {opcode.split(None, 1)[-1].strip('()')}" if opcode else ''
return f'X {what}{where}'
for line in reversed(lines):
if not line.startswith(('File "', 'Traceback', '^', '~', '|')):
return line
return 'the probe failed with no output'
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 viewport(parent):
"""The 3D view the tools draw into. Import Qt first.
Deliberately plain: pyvistaqt is left to make its own render window, since
the only one it knows how to drive is the one it makes.
"""
import pyvistaqt
return pyvistaqt.QtInteractor(parent)

View File

@ -1,716 +0,0 @@
#!/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 paths(self, prefix='', suffix='', loose=False):
"""Every path in the sources between a prefix and a suffix, sorted.
For finding what is there rather than reading what you already knew
about. Duplicates collapse the way `read` resolves them - one path,
whichever source answers for it first. `loose` limits the answer to
files on disk, for a caller that means the working tree rather than
whatever a build happened to zip up.
"""
found = set()
for d in self.dirs:
root = os.path.join(d, prefix)
start = root if os.path.isdir(root) else os.path.dirname(root)
for base, _dirs, files in os.walk(start):
rel = os.path.relpath(base, d)
found.update(f'{rel}/{f}' for f in files
if f'{rel}/{f}'.startswith(prefix) and f.endswith(suffix))
for z in ([] if loose else self.zips):
found.update(n for n in z.namelist()
if n.startswith(prefix) and n.endswith(suffix))
return sorted(found)
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, arranged so that no two faces of a box read as
# the same colour and, above all, that opposite faces do not: a face is told
# from its opposite by both axes at once, never by brightness alone, because
# the pair you most need to tell apart is the one you can only ever see one of
# at a time. Every face sits at its own rung of the value ladder as well, so a
# box seen against a bright background is still read the same way.
#
# up pale and brightest down vivid and darkest
# north vivid and bright south washed and dim
# west palest and mid-bright east vivid and mid-dark
#
# 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 = {
'up': (0.45, 1.00), 'down': (1.00, 0.24),
'north': (1.00, 0.86), 'south': (0.35, 0.44),
'west': (0.30, 0.78), 'east': (0.90, 0.50),
'top': (0.45, 1.00), 'bottom': (1.00, 0.24),
}
# How far the boxes of one object spread from its hue. Enough to tell one box
# from the next, far less than the gap between objects, so a box is never
# mistaken for something else on screen.
BOX_BAND = 0.10
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 object_hue(index):
"""A hue of its own for each object in a scene, however many there are.
Successive golden-ratio turns around the wheel: any two are far apart, the
third is not squeezed between the first two, and it never runs out. What it
is not is stable against reordering - an object keeps its colour only as
long as it keeps its index, so index by something that does not move.
"""
return (int(index) * 0.6180339887498949) % 1.0
def face_colour(shape, count, face, hue=None):
"""The colour of one face of one shape, as floats in 0..1.
Left alone, the hue says which box of one model this is - the wheel split
per shape, which is what an unwrap template is drawn against. Given a
`hue`, it says which *object* instead, and the boxes of that object sit in
a narrow band around it. Either way the saturation and value say which of
the six faces it is.
"""
count = max(1, int(count))
if hue is None:
hue = shape_hues(count)[int(shape) % count]
else:
hue = (float(hue) + BOX_BAND * (int(shape) % count) / count) % 1.0
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 when it is drawing
one model - 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. A scene of several models hands each
one a hue of its own instead, since there the question is which model a
face belongs to; the shades are the same either way, so the sheet is still
read by them.
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

View File

@ -1,233 +0,0 @@
#!/usr/bin/env python3
"""Render where a gem lands on Armory's worn armour, without starting the game.
MIAPI places a module by composing its slot's transform with the ones above it
and then with the model part it names, so the only way to know whether a gem sits
on a pauldron or in mid air is to do that composition and look. This does it: it
reads Armory's own models out of its jar, applies the same chain MIAPI does, and
draws the result from as many angles as asked for.
python3 tools/preview_armour.py <armory.jar> [out.png]
Two things it does not simplify away, because both change the answer:
*Perspective.* An orthographic view flatters a placement - a gem floating a pixel
off a plate looks welded to it head on. Views are taken from several yaws and
pitches with a real camera so the gap shows.
*MIAPI's arithmetic.* `Transform.merge` does not compose matrices; it multiplies
them and then decomposes the product back into translation, Euler angles and
scale. That is lossy the moment a rotation meets a non-uniform scale, which is
exactly what Armory's limb slots are, so the shear is dropped and the gem comes
out turned and stretched differently from what the matrix says. `--exact` renders
the composition MIAPI does not do, for comparison.
Everything is in model pixels, the units the module JSON is written in: +x is the
wearer's left, +y is down, -z is forward, and the origin is the base of the neck.
"""
import argparse, json, math, zipfile
# ---------------------------------------------------------------- linear algebra
def ident():
return [[1.0 if r == c else 0.0 for c in range(4)] for r in range(4)]
def matmul(a, b):
return [[sum(a[r][k] * b[k][c] for k in range(4)) for c in range(4)] for r in range(4)]
def apply(m, p):
v = list(p) + [1.0]
return tuple(sum(m[r][c] * v[c] for c in range(4)) for r in range(3))
def translate(t):
m = ident()
for i in range(3):
m[i][3] = t[i]
return m
def scale(s):
m = ident()
for i in range(3):
m[i][i] = s[i]
return m
def rot(axis, deg):
a = math.radians(deg)
c, s = math.cos(a), math.sin(a)
m = ident()
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."""
g = lambda d, k, dflt: float(d.get(k, dflt)) if isinstance(d, dict) else dflt
t, r, s = tr.get('translation', {}), tr.get('rotation', {}), tr.get('scale', {})
m = translate((g(t, 'x', 0), g(t, 'y', 0), g(t, 'z', 0)))
for axis in 'xyz':
m = matmul(m, rot(axis, g(r, axis, 0)))
return matmul(m, scale((g(s, 'x', 1), g(s, 'y', 1), g(s, 'z', 1))))
# ------------------------------------------------- MIAPI's lossy merge, reproduced
def decompose(m):
"""MIAPI's Transform.fromMatrix: translation, XYZ Euler, per-column scale.
Any shear in the product is silently discarded, which is the whole reason
this function is here rather than a plain matrix multiply.
"""
t = (m[0][3], m[1][3], m[2][3])
cols = [[m[r][c] for r in range(3)] for c in range(3)]
s = [math.sqrt(sum(v * v for v in col)) or 1.0 for col in cols]
r = [[cols[c][row] / s[c] for c in range(3)] for row in range(3)]
# R = Rx*Ry*Rz, so sin(y) is r[0][2] and the other two follow from it.
y = math.asin(max(-1.0, min(1.0, r[0][2])))
x = math.atan2(-r[1][2], r[2][2])
z = math.atan2(-r[0][1], r[0][0])
return {'translation': {'x': t[0], 'y': t[1], 'z': 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, exact=False):
"""MIAPI's Transform.merge: the parent applies first, then the product is
decomposed and rebuilt. `exact` keeps the product instead."""
product = matmul(child, parent)
return product if exact else transform_matrix(decompose(product))
# ------------------------------------------------------------------- the figure
# HumanoidModel part pivots, the frame each origin resolves against. Checked
# against HumanoidModel.createMesh rather than remembered.
PIVOTS = {
'head': (0, 0, 0), 'hat': (0, 0, 0), 'body': (0, 0, 0),
'left_arm': (5, 2, 0), 'right_arm': (-5, 2, 0),
'left_leg': (1.9, 12, 0), 'right_leg': (-1.9, 12, 0),
}
ARMORY = 'assets/miapi/models/item/armor/model'
def boxes_from_model(jar, path):
with zipfile.ZipFile(jar) as z:
data = json.loads(z.read(path))
return [(tuple(e['from']), tuple(e['to'])) for e in data.get('elements', [])]
def place(boxes, chain, pivot):
"""Local boxes through the chain and into the part's frame, as 8 corners."""
out = []
for lo, hi in boxes:
pts = []
for i in range(8):
corner = (hi[0] if i & 1 else lo[0], hi[1] if i & 2 else lo[1], hi[2] if i & 4 else lo[2])
x, y, z = apply(chain, corner)
pts.append((x + pivot[0], y + pivot[1], z + pivot[2]))
out.append(pts)
return out
def gem_box(half_xy, half_z=0.5):
"""A gem sprite as a slab centred on its own origin, the way MIAPI bakes it."""
return [((-half_xy, -half_xy, -half_z), (half_xy, half_xy, half_z))]
# ---------------------------------------------------------------------- drawing
# The 6 faces of a box as indices into the 8 corners produced by place().
FACES = [(0, 1, 3, 2), (4, 5, 7, 6), (0, 1, 5, 4), (2, 3, 7, 6), (0, 2, 6, 4), (1, 3, 7, 5)]
def camera(yaw, pitch, dist, target=(0, 8, 0)):
"""World -> eye, looking at the figure's middle from yaw/pitch degrees out.
Yaw 0 is the wearer's front. Minecraft faces a model down -z, so the camera
starts on that side rather than on +z, which would label the back "front" and
is exactly the sort of quiet inversion this tool exists to avoid.
"""
m = translate((0, 0, -dist))
m = matmul(m, rot('x', pitch))
m = matmul(m, rot('y', yaw + 180))
return matmul(m, translate((-target[0], -target[1], -target[2])))
def render(groups, out_path, views=None, size=300, fov=38.0):
"""One panel per view. `groups` is (fill, outline, label, [boxes])."""
from PIL import Image, ImageDraw
views = views or [('front', 0, 0), ('front-left', 35, 10), ('left', 90, 0),
('above-left', 45, 35), ('back', 180, 0), ('below-left', 40, -30)]
cols = min(3, len(views))
rows = (len(views) + cols - 1) // cols
img = Image.new('RGB', (size * cols, size * rows), (26, 26, 30))
d = ImageDraw.Draw(img, 'RGBA')
f = (size / 2) / math.tan(math.radians(fov) / 2)
for i, (label, yaw, pitch) in enumerate(views):
ox, oy = (i % cols) * size, (i // cols) * size
view = camera(yaw, pitch, 46.0)
def project(p):
x, y, z = apply(view, p)
# +y is down in model space, and the camera looks down -z.
depth = max(0.1, -z)
return (ox + size / 2 + x * f / depth, oy + size / 2 + y * f / depth), depth
polys = []
for fill, outline, _, boxes in groups:
for pts in boxes:
eye = [apply(view, p) for p in pts]
if all(e[2] > -0.1 for e in eye):
continue # entirely behind the camera
for face in FACES:
quad = [project(pts[k])[0] for k in face]
z = sum(project(pts[k])[1] for k in face) / 4
polys.append((z, quad, fill, outline))
for _, quad, fill, outline in sorted(polys, key=lambda t: -t[0]):
d.polygon(quad, fill=fill, outline=outline)
d.text((ox + 8, oy + 6), f"{label} yaw {yaw} pitch {pitch}", fill=(190, 190, 200))
img.save(out_path)
return out_path
# ------------------------------------------------------------------- the scene
def scene(jar, repo, exact=False):
"""Armory's heavy pieces plus a gem in each of this mod's four sockets."""
def socket(name):
with open(f'{repo}/src/main/resources/packs/armory/data/cmmodular/miapi'
f'/modules/armor/socket/{name}.json') as fh:
return json.load(fh)['data']['replace']['slots']['gem']['transform']
with zipfile.ZipFile(jar) as z:
chest = json.loads(z.read('data/tm_armory/miapi/modules/armor/chestplate.json'))['slots']
pants = json.loads(z.read('data/tm_armory/miapi/modules/armor/pants.json'))['slots']
plate, gems = [], []
for part, chain_json, model, sock in (
('left_arm', chest['arm_left'], 'arm_left/heavy', 'arm_left'),
('right_arm', chest['arm_right'], 'arm_right/heavy', 'arm_right'),
('left_leg', pants['leg_left'], 'leg_left/heavy', 'leg_left'),
('right_leg', pants['leg_right'], 'leg_right/heavy', 'leg_right'),
):
limb = transform_matrix(chain_json['transform'])
plate += place(boxes_from_model(jar, f'{ARMORY}/{model}/default.json'), limb, PIVOTS[part])
gems += place(gem_box(1.0), merge(transform_matrix(socket(sock)), limb, exact), PIVOTS[part])
# Armory's own socketed chest, as a control: its gem is known to sit on the
# sternum, so if this lands there the chain above is being modelled right.
body = transform_matrix(chest['chest_front']['transform'])
plate += place(boxes_from_model(jar, f'{ARMORY}/chest_front/socket/default.json'), body, PIVOTS['body'])
gems += place(gem_box(1.0), merge(transform_matrix(
{'translation': {'y': -1}, 'scale': {'x': 1.1, 'y': 1.1, 'z': 1.1}}), body, exact), PIVOTS['body'])
return plate, gems
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('jar')
ap.add_argument('out', nargs='?', default='armour-preview.png')
ap.add_argument('--exact', action='store_true',
help="compose matrices properly instead of reproducing MIAPI's lossy merge")
ap.add_argument('--repo', default='.')
args = ap.parse_args()
plate, gems = scene(args.jar, args.repo, args.exact)
print(render([((78, 62, 104, 255), (120, 104, 150, 255), 'plate', plate),
((86, 216, 122, 255), (235, 255, 240, 255), 'gem', gems)], args.out))
if __name__ == '__main__':
main()