Compare commits
11 Commits
gem-slots-
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
8f253a848a | |
|
|
8e5178ece6 | |
|
|
44bd5e597c | |
|
|
6dba9d9f24 | |
|
|
9449af92b1 | |
|
|
1f1ca94a17 | |
|
|
e7b211932f | |
|
|
7442ee7ac7 | |
|
|
742705839d | |
|
|
5e1b62fd9b | |
|
|
532c3a0da8 |
|
|
@ -5,3 +5,6 @@ runs/
|
|||
repo/
|
||||
*.class
|
||||
__pycache__/
|
||||
|
||||
# Python virtualenv for the tools in tools/
|
||||
tools/.venv/
|
||||
|
|
|
|||
183
README.md
|
|
@ -106,6 +106,13 @@ 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
|
||||
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
|
||||
material or a module - see below.
|
||||
|
||||
|
|
@ -133,6 +140,7 @@ 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
|
||||
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.
|
||||
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.
|
||||
Armory sockets four pieces - helmet, chestplate, backplate and belt - and stops
|
||||
|
|
@ -142,15 +150,40 @@ 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.
|
||||
|
||||
They inherit Armory's heavy pieces whole and change two things: the slot, and
|
||||
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
|
||||
the origin. On an arm the gem is turned to face outwards and set at the top of
|
||||
the shoulder, in the pauldron; on a leg it faces forward, halfway down, in the
|
||||
knee. The numbers come from Armory's own: a sprite lies proud of the side of an
|
||||
arm at 2.15 and of the front of a body at 2.35, which is what its banner patches
|
||||
use, so the gems use it too. They are eyeball values on someone else's model
|
||||
and the four transforms are the only thing to touch if a gem sits proud or
|
||||
sinks in.
|
||||
where the gem sits in it.
|
||||
|
||||
Where the gem sits is the unfinished part of these four, and the honest summary
|
||||
is that it is placed by trial rather than by arithmetic. MIAPI draws a worn piece
|
||||
once per body part; which part a module is drawn under comes from its own model's
|
||||
`origin`, and where it sits comes from the matching entry of its slot's transform
|
||||
stack. Armory's gemstone declares no origin, so a gem is drawn once, in the
|
||||
`body` pass, from the `body` entry - which means a gem slot is placed in body
|
||||
space no matter which limb it is cut into, and is pinned to the torso rather than
|
||||
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
|
||||
below - so a pauldron can hold an Apotheosis gem as readily as a Truly Modular
|
||||
|
|
@ -161,11 +194,56 @@ 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
|
||||
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
|
||||
data component, `apotheosis:sockets`, and MIAPI's `component` property sets any
|
||||
component a module likes, so the case is one line of data: put it in an armour
|
||||
piece's `gem_armor_medium` slot instead of a gemstone, and the piece reports one
|
||||
Apotheosis socket. The gem itself goes in at a smithing table, through
|
||||
Apotheosis' own socketing recipe, not at the workbench.
|
||||
data component, `apotheosis:sockets`, and MIAPI can set any component a module
|
||||
likes: put a case in an armour piece's `gem_armor_medium` slot instead of a
|
||||
gemstone, and the piece reports an 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
|
||||
socket count through `SocketHelper`, which classifies the item with
|
||||
|
|
@ -175,16 +253,23 @@ arrive as attribute modifiers on the stack, through `ItemAttributeModifierEvent`
|
|||
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.
|
||||
|
||||
It wears Armory's own medium gemstone model rather than art of its own, which
|
||||
is both the least work and the right picture: a case is a setting cut for a
|
||||
gem, in whatever material it was built from, and it sits exactly where a
|
||||
gemstone would. Nothing is copied to do it - the module points at Armory's
|
||||
model path, and Armory is required for the pack to load at all.
|
||||
It is drawn here rather than borrowed. It used to wear Armory's medium gemstone
|
||||
model, which was the least work and the wrong picture: a case is the setting,
|
||||
not the stone, and one that looks like a gemstone is indistinguishable from the
|
||||
gemstone it is an alternative to. So `items/gem_case.png` is a four-by-four
|
||||
bezel around an empty two-by-two socket - a gemstone's own footprint, with the
|
||||
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.
|
||||
|
||||
One socket a case, which is the one-for-one trade the module is. Two things
|
||||
worth knowing: the component is the module's while the module is in, so sockets
|
||||
added with a Sigil of Socketing are overwritten by it and come back when the
|
||||
case comes out, and a gem sitting in a case stops counting if the case is
|
||||
The bezel is twice a gemstone across, which would put a case at twice a
|
||||
gemstone's size in a slot sized for one, so the module's own model transform
|
||||
carries a scale of 0.5 and hands the slot back a gemstone's footprint. That is
|
||||
the module's business rather than the slot's: a socket that holds either should
|
||||
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.
|
||||
|
||||
**The space set** (`packs/armory`) is Create: Cosmonautics' answer to vacuum,
|
||||
|
|
@ -358,6 +443,58 @@ Requires `pillow` and `numpy`.
|
|||
Module JSON and textures are not generated. A run only clears the material
|
||||
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
|
||||
|
||||
- Titanium's palette comes from Cosmonautics' ingot, which is violet. Air War's
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -87,6 +87,7 @@ public class CMModular {
|
|||
SandPhasing.register(modBus);
|
||||
EquipmentAttributes.register(modBus);
|
||||
ArcanaCore.register(modBus);
|
||||
ApothicSockets.register();
|
||||
modBus.addListener(this::addMaterialPacks);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,16 @@
|
|||
"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.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.hippogryph_talon": "Talon",
|
||||
"miapi.material.cmmodular.bone.sea_serpent_fang": "Sea Serpent Fang",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"parent": "item/generated",
|
||||
"textures": {
|
||||
"layer0": "cmmodular:items/gem_case"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"parent": "item/generated",
|
||||
"textures": {
|
||||
"layer0": "cmmodular:items/gem_case_great"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"parent": "item/generated",
|
||||
"textures": {
|
||||
"layer0": "cmmodular:items/gem_case_small"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
{
|
||||
"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
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
{
|
||||
"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
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
{
|
||||
"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
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
{
|
||||
"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
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 141 B |
|
After Width: | Height: | Size: 141 B |
|
After Width: | Height: | Size: 126 B |
|
After Width: | Height: | Size: 126 B |
|
After Width: | Height: | Size: 142 B |
|
After Width: | Height: | Size: 147 B |
|
After Width: | Height: | Size: 119 B |
|
|
@ -9,17 +9,19 @@
|
|||
}
|
||||
},
|
||||
"allowed_in_slots": [
|
||||
"gem_armor_medium"
|
||||
"gem_armor_medium",
|
||||
"gem_melee_medium"
|
||||
],
|
||||
"tag": [
|
||||
"gem_armor",
|
||||
"gem_armor_medium",
|
||||
"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": [
|
||||
"default"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,18 @@
|
|||
"operation": "*",
|
||||
"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": {
|
||||
|
|
@ -32,13 +44,13 @@
|
|||
},
|
||||
"gem": {
|
||||
"allowed": [
|
||||
"gem_armor_medium"
|
||||
"gem_armor_medium_arm_left"
|
||||
],
|
||||
"translationKey": "miapi.slot.gemstone.medium",
|
||||
"transform": {
|
||||
"rotation": {"x": 0, "y": 90, "z": 0},
|
||||
"translation": {"x": -2.15, "y": -2, "z": 0},
|
||||
"scale": {"x": 1, "y": 1, "z": 1},
|
||||
"rotation": {"x": 90.0, "y": 55.7523, "z": -90.0},
|
||||
"translation": {"x": -1.7667, "y": 1.5983, "z": 0.0},
|
||||
"scale": {"x": 0.6667, "y": 0.8171, "z": 0.7123},
|
||||
"origin": "left_arm"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,18 @@
|
|||
"operation": "*",
|
||||
"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": {
|
||||
|
|
@ -32,13 +44,13 @@
|
|||
},
|
||||
"gem": {
|
||||
"allowed": [
|
||||
"gem_armor_medium"
|
||||
"gem_armor_medium_arm_right"
|
||||
],
|
||||
"translationKey": "miapi.slot.gemstone.medium",
|
||||
"transform": {
|
||||
"rotation": {"x": 0, "y": -90, "z": 0},
|
||||
"translation": {"x": 2.15, "y": -2, "z": 0},
|
||||
"scale": {"x": 1, "y": 1, "z": 1},
|
||||
"rotation": {"x": 90.0, "y": -55.7523, "z": 90.0},
|
||||
"translation": {"x": 1.7667, "y": 1.5983, "z": 0.0},
|
||||
"scale": {"x": 0.6667, "y": 0.8171, "z": 0.7123},
|
||||
"origin": "right_arm"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,19 +12,31 @@
|
|||
"operation": "*",
|
||||
"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": {
|
||||
"slots": {
|
||||
"gem": {
|
||||
"allowed": [
|
||||
"gem_armor_medium"
|
||||
"gem_armor_medium_leg_left"
|
||||
],
|
||||
"translationKey": "miapi.slot.gemstone.medium",
|
||||
"transform": {
|
||||
"rotation": {"x": 0, "y": 0, "z": 0},
|
||||
"translation": {"x": 0, "y": 5, "z": -2.35},
|
||||
"scale": {"x": 1, "y": 1, "z": 1},
|
||||
"rotation": {"x": 0.0, "y": 0.0, "z": -180.0},
|
||||
"translation": {"x": 0.0, "y": -1.95, "z": -2.4917},
|
||||
"scale": {"x": 0.8333, "y": 1.0, "z": 0.8333},
|
||||
"origin": "left_leg"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,19 +12,31 @@
|
|||
"operation": "*",
|
||||
"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": {
|
||||
"slots": {
|
||||
"gem": {
|
||||
"allowed": [
|
||||
"gem_armor_medium"
|
||||
"gem_armor_medium_leg_right"
|
||||
],
|
||||
"translationKey": "miapi.slot.gemstone.medium",
|
||||
"transform": {
|
||||
"rotation": {"x": 0, "y": 0, "z": 0},
|
||||
"translation": {"x": 0, "y": 5, "z": -2.35},
|
||||
"scale": {"x": 1, "y": 1, "z": 1},
|
||||
"rotation": {"x": 0.0, "y": 0.0, "z": -180.0},
|
||||
"translation": {"x": 0.0, "y": -1.95, "z": -2.4917},
|
||||
"scale": {"x": 0.8333, "y": 1.0, "z": 0.8333},
|
||||
"origin": "right_leg"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
"trim_mode": "ARMOR_LAYER_ONE",
|
||||
"transform": {
|
||||
"rotation": {"x": 0, "y": 0, "z": 0},
|
||||
"translation": {"x": 0, "y": 0, "z": 0},
|
||||
"translation": {"x": 0, "y": 4.1387, "z": 0},
|
||||
"scale": {"x": 1, "y": 1, "z": 1},
|
||||
"origin": "head"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
"trim_mode": "ARMOR_LAYER_ONE",
|
||||
"transform": {
|
||||
"rotation": {"x": 0, "y": 0, "z": 0},
|
||||
"translation": {"x": 0, "y": 0, "z": 0},
|
||||
"translation": {"x": 0, "y": -2.1901, "z": 0},
|
||||
"scale": {"x": 1, "y": 1, "z": 1},
|
||||
"origin": "body"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,968 @@
|
|||
#!/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()
|
||||
|
|
@ -0,0 +1,565 @@
|
|||
#!/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())
|
||||
|
|
@ -0,0 +1,935 @@
|
|||
#!/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())
|
||||
|
|
@ -0,0 +1,480 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Edit the material definitions in `materials.py` without opening it.
|
||||
|
||||
The materials are Python, not data: an entry is an `M(...)` call, its
|
||||
ingredients are usually `ingots(...)` rather than a list, and its effects are
|
||||
`speed(-0.12)` rather than a dictionary. A generated JSON file is downstream of
|
||||
all that and gets overwritten by the next `generate_materials.py` run, so this
|
||||
edits the source instead.
|
||||
|
||||
It does that by rewriting one argument at a time, in place. Each field shows
|
||||
the *source text* of the argument it stands for rather than a rendering of its
|
||||
value, and saving replaces exactly that span of the file - so `ingots("mekanism",
|
||||
"ingot_tin", ...)` stays a call, the comments under every material stay where
|
||||
they were, and a diff shows the number that changed and nothing else.
|
||||
|
||||
tools/MATERIAL_EDITOR.py
|
||||
|
||||
Materials built by a loop rather than written out - the dragon scales, the gem
|
||||
families - have no literal `M(...)` to edit and are shown read-only, because the
|
||||
thing to change for those is the loop.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import guiplatform
|
||||
|
||||
guiplatform.configure(prefer=os.environ.get('ARMOUR_GUI_PLATFORM'), quiet=True)
|
||||
|
||||
from PySide6 import QtCore, QtGui, QtWidgets # noqa: E402
|
||||
|
||||
TOOLS = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.dirname(TOOLS)
|
||||
SOURCE = os.path.join(TOOLS, 'materials.py')
|
||||
|
||||
# The signature of M(), in order, with the type each argument carries. The type
|
||||
# is shown beside the field because these are Python literals being edited as
|
||||
# text: "3" and "3.0" are not the same thing to a stat that is declared a float,
|
||||
# and a tuple of one string needs its trailing comma.
|
||||
POSITIONAL = ['name', 'pack', 'group', 'translation', 'palette_from', 'items',
|
||||
'tier', 'hardness', 'density', 'flexibility', 'durability',
|
||||
'enchantability', 'mining_speed']
|
||||
|
||||
FIELDS = [
|
||||
('name', 'str'), ('pack', 'str'), ('group', 'str'),
|
||||
('translation', 'str'), ('palette_from', 'str'), ('icon', 'str | None'),
|
||||
('items', 'list[dict]'),
|
||||
('tier', 'int'), ('hardness', 'float'), ('density', 'float'),
|
||||
('flexibility', 'float'), ('durability', 'int'), ('enchantability', 'int'),
|
||||
('mining_speed', 'int'), ('mining_level', 'str | None'),
|
||||
('toughness', 'int'), ('armor_durability', 'int | None'),
|
||||
('armor_toughness', 'float | None'), ('knockback_resistance', 'float | None'),
|
||||
('groups', 'tuple[str] | None'), ('hidden_groups', 'tuple[str] | None'),
|
||||
('textures', 'tuple[str]'), ('properties', 'dict | None'),
|
||||
]
|
||||
|
||||
# The ones worth more than a single line to look at.
|
||||
TALL = {'items', 'properties', 'groups', 'hidden_groups', 'textures'}
|
||||
|
||||
DEFAULTS = {'toughness': '0', 'textures': '("metallic",)', 'properties': '{}',
|
||||
'groups': 'None', 'hidden_groups': 'None', 'icon': 'None',
|
||||
'mining_level': 'None', 'armor_durability': 'None',
|
||||
'armor_toughness': 'None', 'knockback_resistance': 'None'}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the source
|
||||
|
||||
|
||||
class MaterialSource:
|
||||
"""`materials.py`, parsed so that a single argument can be replaced.
|
||||
|
||||
Everything is done in bytes. `ast` reports column offsets as byte offsets
|
||||
into the encoded line, so working in characters would put every span one
|
||||
place out the first time somebody writes a material with an accent in its
|
||||
name.
|
||||
"""
|
||||
|
||||
def __init__(self, path=SOURCE):
|
||||
self.path = path
|
||||
self.reload()
|
||||
|
||||
def reload(self):
|
||||
with open(self.path, 'rb') as fh:
|
||||
self.data = fh.read()
|
||||
self.lines = self.data.split(b'\n')
|
||||
self.starts, at = [], 0
|
||||
for line in self.lines:
|
||||
self.starts.append(at)
|
||||
at += len(line) + 1
|
||||
tree = ast.parse(self.data.decode('utf-8'), self.path)
|
||||
|
||||
self.calls = {}
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
||||
and node.func.id == 'M' and node.args
|
||||
and isinstance(node.args[0], ast.Constant)
|
||||
and isinstance(node.args[0].value, str)):
|
||||
self.calls[node.args[0].value] = node
|
||||
|
||||
# Where a new material can be appended: just before the `]` that closes
|
||||
# the MATERIALS list.
|
||||
self.list_end = None
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Assign) and node.targets
|
||||
and isinstance(node.targets[0], ast.Name)
|
||||
and node.targets[0].id == 'MATERIALS'
|
||||
and isinstance(node.value, ast.List)):
|
||||
self.list_end = self._offset(node.value.end_lineno,
|
||||
node.value.end_col_offset) - 1
|
||||
|
||||
def _offset(self, lineno, col):
|
||||
return self.starts[lineno - 1] + col
|
||||
|
||||
def span(self, node):
|
||||
return (self._offset(node.lineno, node.col_offset),
|
||||
self._offset(node.end_lineno, node.end_col_offset))
|
||||
|
||||
def names(self):
|
||||
return set(self.calls)
|
||||
|
||||
def argument(self, name, field):
|
||||
"""The node for one argument of one material, or None if not passed."""
|
||||
call = self.calls.get(name)
|
||||
if call is None:
|
||||
return None
|
||||
for kw in call.keywords:
|
||||
if kw.arg == field:
|
||||
return kw.value
|
||||
if field in POSITIONAL:
|
||||
index = POSITIONAL.index(field)
|
||||
if index < len(call.args):
|
||||
return call.args[index]
|
||||
return None
|
||||
|
||||
def text(self, name, field):
|
||||
node = self.argument(name, field)
|
||||
if node is None:
|
||||
return None
|
||||
start, end = self.span(node)
|
||||
return self.data[start:end].decode('utf-8')
|
||||
|
||||
# ------------------------------------------------------------- writing
|
||||
|
||||
def apply(self, edits):
|
||||
"""Replace argument spans. `edits` is [(material, field, source)].
|
||||
|
||||
Applied back to front so that an earlier edit never moves a later
|
||||
one's offsets, and re-parsed afterwards so the next edit is measured
|
||||
against what is now on disk.
|
||||
"""
|
||||
patches = []
|
||||
for name, field, new in edits:
|
||||
node = self.argument(name, field)
|
||||
if node is not None:
|
||||
start, end = self.span(node)
|
||||
patches.append((start, end, new.encode('utf-8')))
|
||||
else:
|
||||
patches.append(self._insert_keyword(name, field, new))
|
||||
|
||||
data = self.data
|
||||
for start, end, blob in sorted(patches, key=lambda p: -p[0]):
|
||||
data = data[:start] + blob + data[end:]
|
||||
with open(self.path, 'wb') as fh:
|
||||
fh.write(data)
|
||||
self.reload()
|
||||
|
||||
def _insert_keyword(self, name, field, new):
|
||||
"""A keyword the call does not pass yet, added before its closing paren."""
|
||||
call = self.calls[name]
|
||||
last = call.keywords[-1].value if call.keywords else call.args[-1]
|
||||
_, end = self.span(last)
|
||||
return (end, end, f', {field}={new}'.encode('utf-8'))
|
||||
|
||||
def add_material(self, source):
|
||||
"""Append a whole `M(...)` call to the end of MATERIALS."""
|
||||
if self.list_end is None:
|
||||
raise RuntimeError('could not find the end of MATERIALS')
|
||||
blob = ('\n ' + source.strip().rstrip(',') + ',\n').encode('utf-8')
|
||||
data = self.data[:self.list_end] + blob + self.data[self.list_end:]
|
||||
with open(self.path, 'wb') as fh:
|
||||
fh.write(data)
|
||||
self.reload()
|
||||
|
||||
|
||||
TEMPLATE = '''M("{name}", "{pack}", "metal", "{title}", "{pack}:ingot_{name}",
|
||||
ingots("{pack}", "ingot_{name}"),
|
||||
tier=3, hardness=5.0, density=4.0, flexibility=1, durability=300,
|
||||
enchantability=12, mining_speed=6)'''
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- window
|
||||
|
||||
|
||||
class MaterialEditor(QtWidgets.QMainWindow):
|
||||
def __init__(self, source, materials):
|
||||
super().__init__()
|
||||
self.source = source
|
||||
self.materials = materials
|
||||
self.pending = {}
|
||||
self.current = None
|
||||
self.setWindowTitle('materials')
|
||||
|
||||
splitter = QtWidgets.QSplitter()
|
||||
splitter.addWidget(self._left())
|
||||
splitter.addWidget(self._right())
|
||||
splitter.setSizes([320, 780])
|
||||
self.setCentralWidget(splitter)
|
||||
self._fill_list()
|
||||
self._status()
|
||||
|
||||
# -------------------------------------------------------------- panes
|
||||
|
||||
def _left(self):
|
||||
box = QtWidgets.QWidget()
|
||||
lay = QtWidgets.QVBoxLayout(box)
|
||||
self.filter = QtWidgets.QLineEdit()
|
||||
self.filter.setPlaceholderText('filter by name, pack or group')
|
||||
self.filter.textChanged.connect(self._fill_list)
|
||||
lay.addWidget(self.filter)
|
||||
|
||||
self.list = QtWidgets.QListWidget()
|
||||
self.list.currentItemChanged.connect(self._select)
|
||||
lay.addWidget(self.list, 1)
|
||||
|
||||
row = QtWidgets.QHBoxLayout()
|
||||
for label, slot in (('Add', self.on_add), ('Save', self.on_save),
|
||||
('Revert', self.on_revert)):
|
||||
button = QtWidgets.QPushButton(label)
|
||||
button.clicked.connect(slot)
|
||||
row.addWidget(button)
|
||||
lay.addLayout(row)
|
||||
|
||||
self.jars = QtWidgets.QLineEdit(os.path.expanduser('~/.cache/abdelpak-jars'))
|
||||
lay.addWidget(QtWidgets.QLabel('mod jars, for regenerating the JSON'))
|
||||
lay.addWidget(self.jars)
|
||||
regen = QtWidgets.QPushButton('Regenerate material JSON')
|
||||
regen.clicked.connect(self.on_regenerate)
|
||||
lay.addWidget(regen)
|
||||
return box
|
||||
|
||||
def _right(self):
|
||||
outer = QtWidgets.QWidget()
|
||||
lay = QtWidgets.QVBoxLayout(outer)
|
||||
self.heading = QtWidgets.QLabel('-')
|
||||
font = self.heading.font()
|
||||
font.setBold(True)
|
||||
self.heading.setFont(font)
|
||||
lay.addWidget(self.heading)
|
||||
self.note = QtWidgets.QLabel('')
|
||||
self.note.setWordWrap(True)
|
||||
self.note.setStyleSheet('color: #b08;')
|
||||
lay.addWidget(self.note)
|
||||
|
||||
scroll = QtWidgets.QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
inner = QtWidgets.QWidget()
|
||||
form = QtWidgets.QGridLayout(inner)
|
||||
form.setColumnStretch(1, 1)
|
||||
|
||||
self.editors = {}
|
||||
for row, (field, kind) in enumerate(FIELDS):
|
||||
form.addWidget(QtWidgets.QLabel(field), row, 0)
|
||||
if field in TALL:
|
||||
widget = QtWidgets.QPlainTextEdit()
|
||||
widget.setFixedHeight(58)
|
||||
widget.textChanged.connect(lambda f=field: self._changed(f))
|
||||
else:
|
||||
widget = QtWidgets.QLineEdit()
|
||||
widget.textChanged.connect(lambda _=None, f=field: self._changed(f))
|
||||
widget.setFont(QtGui.QFont('monospace'))
|
||||
form.addWidget(widget, row, 1)
|
||||
# The type sits at the end of the field, because these are literals
|
||||
# typed by hand and "3" against "3.0" is a real difference.
|
||||
kind_label = QtWidgets.QLabel(kind)
|
||||
kind_label.setStyleSheet('color: #888;')
|
||||
form.addWidget(kind_label, row, 2)
|
||||
self.editors[field] = widget
|
||||
|
||||
scroll.setWidget(inner)
|
||||
lay.addWidget(scroll, 1)
|
||||
return outer
|
||||
|
||||
# ------------------------------------------------------------ contents
|
||||
|
||||
def _fill_list(self):
|
||||
want = self.filter.text().strip().lower()
|
||||
self.list.blockSignals(True)
|
||||
self.list.clear()
|
||||
for mat in self.materials:
|
||||
label = f"{mat['name']} [{mat['pack']} / {mat['group']}]"
|
||||
if want and want not in label.lower():
|
||||
continue
|
||||
item = QtWidgets.QListWidgetItem(label)
|
||||
item.setData(QtCore.Qt.UserRole, mat['name'])
|
||||
if mat['name'] not in self.source.names():
|
||||
item.setForeground(QtGui.QColor('#888'))
|
||||
self.list.addItem(item)
|
||||
self.list.blockSignals(False)
|
||||
if self.list.count():
|
||||
self.list.setCurrentRow(0)
|
||||
|
||||
def _select(self, item, _previous=None):
|
||||
if item is None:
|
||||
return
|
||||
self.current = item.data(QtCore.Qt.UserRole)
|
||||
editable = self.current in self.source.names()
|
||||
self.heading.setText(self.current)
|
||||
self.note.setText('' if editable else
|
||||
'Built by a loop rather than written out - read only '
|
||||
'here; edit the loop that makes it.')
|
||||
self._loading = True
|
||||
for field, _kind in FIELDS:
|
||||
text = self.source.text(self.current, field) if editable else None
|
||||
key = (self.current, field)
|
||||
if key in self.pending:
|
||||
text = self.pending[key]
|
||||
shown = text if text is not None else DEFAULTS.get(field, '')
|
||||
widget = self.editors[field]
|
||||
widget.setReadOnly(not editable)
|
||||
if isinstance(widget, QtWidgets.QPlainTextEdit):
|
||||
widget.setPlainText(shown)
|
||||
else:
|
||||
widget.setText(shown)
|
||||
self._loading = False
|
||||
self._status()
|
||||
|
||||
def _value(self, field):
|
||||
widget = self.editors[field]
|
||||
if isinstance(widget, QtWidgets.QPlainTextEdit):
|
||||
return widget.toPlainText().strip()
|
||||
return widget.text().strip()
|
||||
|
||||
def _changed(self, field):
|
||||
if getattr(self, '_loading', False) or self.current is None:
|
||||
return
|
||||
if self.current not in self.source.names():
|
||||
return
|
||||
new = self._value(field)
|
||||
old = self.source.text(self.current, field)
|
||||
key = (self.current, field)
|
||||
# An untouched optional argument stays untouched: writing `toughness=0`
|
||||
# into every material that never mentioned it would be a diff of noise.
|
||||
if new == (old if old is not None else DEFAULTS.get(field, '')):
|
||||
self.pending.pop(key, None)
|
||||
else:
|
||||
self.pending[key] = new
|
||||
self._mark(field, ok=self._parses(new))
|
||||
self._status()
|
||||
|
||||
@staticmethod
|
||||
def _parses(text):
|
||||
if not text:
|
||||
return False
|
||||
try:
|
||||
ast.parse(text, mode='eval')
|
||||
return True
|
||||
except SyntaxError:
|
||||
return False
|
||||
|
||||
def _mark(self, field, ok):
|
||||
widget = self.editors[field]
|
||||
widget.setStyleSheet('' if ok else 'background: #5a2230;')
|
||||
|
||||
def _status(self):
|
||||
bad = [f'{n}.{f}' for (n, f), v in self.pending.items() if not self._parses(v)]
|
||||
msg = f'{len(self.pending)} unsaved change(s)' if self.pending else 'no changes'
|
||||
if bad:
|
||||
msg += f' - will not parse: {", ".join(bad)}'
|
||||
self.statusBar().showMessage(msg)
|
||||
|
||||
# ------------------------------------------------------------- actions
|
||||
|
||||
def on_save(self):
|
||||
bad = [k for k, v in self.pending.items() if not self._parses(v)]
|
||||
if bad:
|
||||
QtWidgets.QMessageBox.warning(
|
||||
self, 'materials',
|
||||
'These are not valid Python and were not saved:\n\n'
|
||||
+ '\n'.join(f'{n}.{f}' for n, f in bad))
|
||||
return
|
||||
if not self.pending:
|
||||
return
|
||||
edits = [(n, f, v) for (n, f), v in self.pending.items()]
|
||||
self.source.apply(edits)
|
||||
self.pending.clear()
|
||||
self._select(self.list.currentItem())
|
||||
self.statusBar().showMessage(
|
||||
f'wrote {len(edits)} change(s) to {_short(self.source.path)}')
|
||||
|
||||
def on_revert(self):
|
||||
self.pending.clear()
|
||||
self._select(self.list.currentItem())
|
||||
|
||||
def on_add(self):
|
||||
name, ok = QtWidgets.QInputDialog.getText(
|
||||
self, 'new material', 'id, lowercase with underscores:')
|
||||
if not ok or not name.strip():
|
||||
return
|
||||
name = name.strip()
|
||||
if name in self.source.names():
|
||||
QtWidgets.QMessageBox.warning(self, 'materials',
|
||||
f'{name} already exists.')
|
||||
return
|
||||
pack, ok = QtWidgets.QInputDialog.getText(self, 'new material',
|
||||
'pack:', text='mekanism')
|
||||
if not ok:
|
||||
return
|
||||
self.source.add_material(TEMPLATE.format(
|
||||
name=name, pack=pack.strip() or 'mekanism',
|
||||
title=name.replace('_', ' ').title()))
|
||||
self.materials = load_materials(self.source.path)
|
||||
self._fill_list()
|
||||
for row in range(self.list.count()):
|
||||
if self.list.item(row).data(QtCore.Qt.UserRole) == name:
|
||||
self.list.setCurrentRow(row)
|
||||
break
|
||||
self.statusBar().showMessage(f'added {name} - it still needs real items '
|
||||
f'and stats')
|
||||
|
||||
def on_regenerate(self):
|
||||
folder = self.jars.text().strip()
|
||||
if not os.path.isdir(folder):
|
||||
QtWidgets.QMessageBox.warning(self, 'materials',
|
||||
f'not a folder: {folder}')
|
||||
return
|
||||
self.statusBar().showMessage('regenerating...')
|
||||
QtWidgets.QApplication.processEvents()
|
||||
run = subprocess.run([sys.executable,
|
||||
os.path.join(TOOLS, 'generate_materials.py'),
|
||||
'--jars', folder],
|
||||
capture_output=True, text=True, cwd=REPO)
|
||||
tail = (run.stdout + run.stderr).strip().splitlines()
|
||||
self.statusBar().showMessage(tail[-1] if tail else 'done')
|
||||
if run.returncode:
|
||||
QtWidgets.QMessageBox.warning(self, 'generate_materials.py',
|
||||
'\n'.join(tail[-25:]))
|
||||
|
||||
|
||||
def _short(path):
|
||||
"""Repo-relative when it is in the repo, absolute when it is not."""
|
||||
rel = os.path.relpath(path, REPO)
|
||||
return path if rel.startswith(os.pardir) else rel
|
||||
|
||||
|
||||
def load_materials(path=SOURCE):
|
||||
"""The evaluated list, which is the only thing that knows the full set.
|
||||
|
||||
Loaded from the same file that is being edited, and freshly each time - a
|
||||
material added during the session has to appear in the list, and a listing
|
||||
taken from a different file than the edits go to would be a quiet lie.
|
||||
"""
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location('_materials_under_edit', path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return list(module.MATERIALS)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument('--source', default=SOURCE, help='materials.py to edit')
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
app = QtWidgets.QApplication(sys.argv[:1])
|
||||
window = MaterialEditor(MaterialSource(args.source),
|
||||
load_materials(args.source))
|
||||
window.resize(1180, 760)
|
||||
window.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,342 @@
|
|||
#!/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)
|
||||
|
|
@ -0,0 +1,716 @@
|
|||
#!/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
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
#!/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()
|
||||