model adjustments

main
Amir Alexander Abdelbaki 2026-08-18 17:50:53 +02:00
parent 87a78136ec
commit 532c3a0da8
11 changed files with 480 additions and 40 deletions

113
README.md
View File

@ -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 back off the equipment as well, so the answer is the same whether or not the map
ever lost them. ever lost them.
**Counting gem cases.** `ApothicSockets.java` makes each Apothic gem case worth
an Apotheosis socket, which data cannot do at all - every route through MIAPI's
component properties is either dead or crashes the crafting screen, see below.
It is the one place this mod reaches into another's internals, and it does it
reflectively so that neither Apotheosis nor Truly Modular becomes something to
build against.
**One item.** The arcana core, which is the only thing here that is not a **One item.** The arcana core, which is the only thing here that is not a
material or a module - see below. material or a module - see below.
@ -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. the same price, 5% of the piece's armour.
They inherit Armory's heavy pieces whole and change two things: the slot, and They inherit Armory's heavy pieces whole and change two things: the slot, and
where the gem sits in it. Armory's own gems are placed on the body - the chest's where the gem sits in it.
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 Where the gem sits is the unfinished part of these four, and the honest summary
the shoulder, in the pauldron; on a leg it faces forward, halfway down, in the is that it is placed by trial rather than by arithmetic. MIAPI draws a worn piece
knee. The numbers come from Armory's own: a sprite lies proud of the side of an once per body part; which part a module is drawn under comes from its own model's
arm at 2.15 and of the front of a body at 2.35, which is what its banner patches `origin`, and where it sits comes from the matching entry of its slot's transform
use, so the gems use it too. They are eyeball values on someone else's model stack. Armory's gemstone declares no origin, so a gem is drawn once, in the
and the four transforms are the only thing to touch if a gem sits proud or `body` pass, from the `body` entry - which means a gem slot is placed in body
sinks in. 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 The pieces take any medium armour gemstone, which includes the Apothic gem case
below - so a pauldron can hold an Apotheosis gem as readily as a Truly Modular below - so a pauldron can hold an Apotheosis gem as readily as a Truly Modular
@ -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 makes one worth having is its own rarity and purity data, which a MIAPI
material cannot carry - so the two systems cannot share a slot. What they can material cannot carry - so the two systems cannot share a slot. What they can
share is the item. Apotheosis stores how many gems a thing holds in a plain share is the item. Apotheosis stores how many gems a thing holds in a plain
data component, `apotheosis:sockets`, and MIAPI's `component` property sets any data component, `apotheosis:sockets`, and MIAPI can set any component a module
component a module likes, so the case is one line of data: put it in an armour likes: put a case in an armour piece's `gem_armor_medium` slot instead of a
piece's `gem_armor_medium` slot instead of a gemstone, and the piece reports one gemstone, and the piece reports an Apotheosis socket. The gem itself goes in at
Apotheosis socket. The gem itself goes in at a smithing table, through a smithing table, through Apotheosis' own socketing recipe, not at the workbench.
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 That leaves the two systems where they each work best. Apotheosis reads the
socket count through `SocketHelper`, which classifies the item with socket count through `SocketHelper`, which classifies the item with
@ -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 which is where MIAPI is not looking; MIAPI writes its own numbers into the
`ATTRIBUTE_MODIFIERS` component, and the two add up rather than overwrite. `ATTRIBUTE_MODIFIERS` component, and the two add up rather than overwrite.
It wears Armory's own medium gemstone model rather than art of its own, which It is drawn here rather than borrowed. It used to wear Armory's medium gemstone
is both the least work and the right picture: a case is a setting cut for a model, which was the least work and the wrong picture: a case is the setting,
gem, in whatever material it was built from, and it sits exactly where a not the stone, and one that looks like a gemstone is indistinguishable from the
gemstone would. Nothing is copied to do it - the module points at Armory's gemstone it is an alternative to. So `items/gem_case.png` is a four-by-four
model path, and Armory is required for the pack to load at all. 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 The bezel is twice a gemstone across, which would put a case at twice a
worth knowing: the component is the module's while the module is in, so sockets gemstone's size in a slot sized for one, so the module's own model transform
added with a Sigil of Socketing are overwritten by it and come back when the carries a scale of 0.5 and hands the slot back a gemstone's footprint. That is
case comes out, and a gem sitting in a case stops counting if the case 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. removed - it is not destroyed, it is just in a socket that no longer exists.
**The space set** (`packs/armory`) is Create: Cosmonautics' answer to vacuum, **The space set** (`packs/armory`) is Create: Cosmonautics' answer to vacuum,

View File

@ -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;
}
}

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 B

View File

@ -1,11 +1,11 @@
{ {
"display_name": "miapi.module.cmmodular.gem.apothic_case.name", "display_name": "miapi.module.cmmodular.gem.apothic_case.name",
"model": { "model": {
"path": "miapi:models/item/armor/gems/medium/[material.texture].json", "path": "cmmodular:models/item/armor/gems/case/[material.texture].json",
"transform": { "transform": {
"rotation": {"x": 0, "y": 0, "z": 0}, "rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"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": [ "allowed_in_slots": [
@ -17,9 +17,6 @@
"gem_armor_generic", "gem_armor_generic",
"gem_armor_medium_generic" "gem_armor_medium_generic"
], ],
"component": {
"apotheosis:sockets": 1
},
"material_property": [ "material_property": [
"default" "default"
], ],

View File

@ -37,9 +37,9 @@
"translationKey": "miapi.slot.gemstone.medium", "translationKey": "miapi.slot.gemstone.medium",
"transform": { "transform": {
"rotation": {"x": 0, "y": 90, "z": 0}, "rotation": {"x": 0, "y": 90, "z": 0},
"translation": {"x": -2.15, "y": -2, "z": 0}, "translation": {"x": 8.6, "y": 1.2, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1}, "scale": {"x": 1.4, "y": 1.4, "z": 1.4},
"origin": "left_arm" "origin": "body"
} }
} }
}, },

View File

@ -37,9 +37,9 @@
"translationKey": "miapi.slot.gemstone.medium", "translationKey": "miapi.slot.gemstone.medium",
"transform": { "transform": {
"rotation": {"x": 0, "y": -90, "z": 0}, "rotation": {"x": 0, "y": -90, "z": 0},
"translation": {"x": 2.15, "y": -2, "z": 0}, "translation": {"x": -8.6, "y": 1.2, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1}, "scale": {"x": 1.4, "y": 1.4, "z": 1.4},
"origin": "right_arm" "origin": "body"
} }
} }
}, },

View File

@ -23,9 +23,9 @@
"translationKey": "miapi.slot.gemstone.medium", "translationKey": "miapi.slot.gemstone.medium",
"transform": { "transform": {
"rotation": {"x": 0, "y": 0, "z": 0}, "rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 5, "z": -2.35}, "translation": {"x": 1.9, "y": 18.5, "z": -2.8},
"scale": {"x": 1, "y": 1, "z": 1}, "scale": {"x": 1.4, "y": 1.4, "z": 1.4},
"origin": "left_leg" "origin": "body"
} }
} }
}, },

View File

@ -23,9 +23,9 @@
"translationKey": "miapi.slot.gemstone.medium", "translationKey": "miapi.slot.gemstone.medium",
"transform": { "transform": {
"rotation": {"x": 0, "y": 0, "z": 0}, "rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 5, "z": -2.35}, "translation": {"x": -1.9, "y": 18.5, "z": -2.8},
"scale": {"x": 1, "y": 1, "z": 1}, "scale": {"x": 1.4, "y": 1.4, "z": 1.4},
"origin": "right_leg" "origin": "body"
} }
} }
}, },

233
tools/preview_armour.py Normal file
View File

@ -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()