diff --git a/README.md b/README.md
index 2121826..d667e87 100644
--- a/README.md
+++ b/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.
@@ -142,15 +149,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 +193,39 @@ 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.
+
+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 +235,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,
diff --git a/src/main/java/eu/abdelbaki/cmmodular/ApothicSockets.java b/src/main/java/eu/abdelbaki/cmmodular/ApothicSockets.java
new file mode 100644
index 0000000..1893db2
--- /dev/null
+++ b/src/main/java/eu/abdelbaki/cmmodular/ApothicSockets.java
@@ -0,0 +1,136 @@
+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.
+ *
+ *
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.
+ *
+ *
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.
+ *
+ *
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, generic and one per limb socket. */
+ private static final List CASES = List.of(
+ "cmmodular:gem/apothic_case",
+ "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 type = (Class) event;
+ NeoForge.EVENT_BUS.addListener(type, (Consumer) 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;
+ }
+}
diff --git a/src/main/java/eu/abdelbaki/cmmodular/CMModular.java b/src/main/java/eu/abdelbaki/cmmodular/CMModular.java
index 34be827..1d48ead 100644
--- a/src/main/java/eu/abdelbaki/cmmodular/CMModular.java
+++ b/src/main/java/eu/abdelbaki/cmmodular/CMModular.java
@@ -87,6 +87,7 @@ public class CMModular {
SandPhasing.register(modBus);
EquipmentAttributes.register(modBus);
ArcanaCore.register(modBus);
+ ApothicSockets.register();
modBus.addListener(this::addMaterialPacks);
}
diff --git a/src/main/resources/assets/cmmodular/models/item/armor/gems/case/default.json b/src/main/resources/assets/cmmodular/models/item/armor/gems/case/default.json
new file mode 100644
index 0000000..d9faeab
--- /dev/null
+++ b/src/main/resources/assets/cmmodular/models/item/armor/gems/case/default.json
@@ -0,0 +1,6 @@
+{
+ "parent": "item/generated",
+ "textures": {
+ "layer0": "cmmodular:items/gem_case"
+ }
+}
diff --git a/src/main/resources/assets/cmmodular/textures/items/gem_case.png b/src/main/resources/assets/cmmodular/textures/items/gem_case.png
new file mode 100644
index 0000000..7b89df4
Binary files /dev/null and b/src/main/resources/assets/cmmodular/textures/items/gem_case.png differ
diff --git a/src/main/resources/packs/apotheosis/data/cmmodular/miapi/modules/gem/apothic_case.json b/src/main/resources/packs/apotheosis/data/cmmodular/miapi/modules/gem/apothic_case.json
index 7a6c8be..d5e9189 100644
--- a/src/main/resources/packs/apotheosis/data/cmmodular/miapi/modules/gem/apothic_case.json
+++ b/src/main/resources/packs/apotheosis/data/cmmodular/miapi/modules/gem/apothic_case.json
@@ -1,11 +1,11 @@
{
"display_name": "miapi.module.cmmodular.gem.apothic_case.name",
"model": {
- "path": "miapi:models/item/armor/gems/medium/[material.texture].json",
+ "path": "cmmodular:models/item/armor/gems/case/[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}
+ "scale": {"x": 0.5, "y": 0.5, "z": 0.5}
}
},
"allowed_in_slots": [
@@ -17,9 +17,6 @@
"gem_armor_generic",
"gem_armor_medium_generic"
],
- "component": {
- "apotheosis:sockets": 1
- },
"material_property": [
"default"
],
diff --git a/src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/socket/arm_left.json b/src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/socket/arm_left.json
index 3e3f717..c2064df 100644
--- a/src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/socket/arm_left.json
+++ b/src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/socket/arm_left.json
@@ -37,9 +37,9 @@
"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},
- "origin": "left_arm"
+ "translation": {"x": 8.6, "y": 1.2, "z": 0},
+ "scale": {"x": 1.4, "y": 1.4, "z": 1.4},
+ "origin": "body"
}
}
},
diff --git a/src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/socket/arm_right.json b/src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/socket/arm_right.json
index fcd4262..7dc9acf 100644
--- a/src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/socket/arm_right.json
+++ b/src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/socket/arm_right.json
@@ -37,9 +37,9 @@
"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},
- "origin": "right_arm"
+ "translation": {"x": -8.6, "y": 1.2, "z": 0},
+ "scale": {"x": 1.4, "y": 1.4, "z": 1.4},
+ "origin": "body"
}
}
},
diff --git a/src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/socket/leg_left.json b/src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/socket/leg_left.json
index 1c80668..1ef1794 100644
--- a/src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/socket/leg_left.json
+++ b/src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/socket/leg_left.json
@@ -23,9 +23,9 @@
"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},
- "origin": "left_leg"
+ "translation": {"x": 1.9, "y": 18.5, "z": -2.8},
+ "scale": {"x": 1.4, "y": 1.4, "z": 1.4},
+ "origin": "body"
}
}
},
diff --git a/src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/socket/leg_right.json b/src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/socket/leg_right.json
index e23270c..13f9a4e 100644
--- a/src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/socket/leg_right.json
+++ b/src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/socket/leg_right.json
@@ -23,9 +23,9 @@
"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},
- "origin": "right_leg"
+ "translation": {"x": -1.9, "y": 18.5, "z": -2.8},
+ "scale": {"x": 1.4, "y": 1.4, "z": 1.4},
+ "origin": "body"
}
}
},
diff --git a/tools/preview_armour.py b/tools/preview_armour.py
new file mode 100644
index 0000000..aca1edb
--- /dev/null
+++ b/tools/preview_armour.py
@@ -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 [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()