model adjustments
parent
87a78136ec
commit
532c3a0da8
113
README.md
113
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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
* <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, generic and one per limb socket. */
|
||||
private static final List<String> 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<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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"parent": "item/generated",
|
||||
"textures": {
|
||||
"layer0": "cmmodular:items/gem_case"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 142 B |
|
|
@ -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"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue