Compare commits

..

No commits in common. "gem-placement" and "main" have entirely different histories.

52 changed files with 305 additions and 4147 deletions

View File

@ -166,29 +166,17 @@ 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.
What made them hard is now known, and it was an arithmetic mistake rather than
a matter of taste. A slot transform does not act in the model's own space. MIAPI
accumulates transforms per origin with `TransformMap.add`, which calls
`Transform.merge(what is there, what is being added)`, and that merge computes
`new.toMatrix().mul(accumulated.toMatrix())` - so the plate's transform applies
to the geometry first and the slot's afterwards, in the body part's frame. An
offset written as though it were measured on the model lands somewhere else
entirely, which is why the gems sat at the collar with their sides swapped and
the knee gems ended up at the belly.
So they are solved in that frame instead: take the middle of the outward face of
the socket this repo draws, carry it through Armory's own plate transform into
the limb's frame, turn the gem's sprite onto that face, and undo the plate's
non-uniform stretch so the stone comes out the size it was drawn. The numbers in
`packs/armory/.../socket/*.json` are that solution, and each one is checked by
composing it the way MIAPI does - including the `fromMatrix` round trip, which
2.3.8 performs on *every* merge and which drops shear wherever a rotation meets
a non-uniform scale. Left and right are exact mirrors of one another, and each
gem lands on its socket.
`tools/preview_armour.py` and `ARMOUR_EDITOR.py` read Armory's own geometry for
the same purpose - where the plate bands are, which face is which, how far the
chain scales a limb.
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
@ -224,19 +212,10 @@ 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. They are all filed under `packs/apotheosis` rather than with
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.
Every socket that names a body part needs a case of its own, because a slot
takes only what its `allowed` list names and each of those lists names one key:
the four limbs, the space helmet's visor and the backtank. That is the cost of
placing a gem on the limb it was cut into - the generic case answers for
`gem_armor_medium` and cannot answer for a key nothing else uses. The counting
is by module id, so a new case has to be added to `CASES` in
`ApothicSockets.java` as well as written; one that is missing from that list
still fits its slot and still wears its gemstone, and grants nothing.
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
@ -407,15 +386,6 @@ of them lands somewhere interpolated and muddy.
`textures/*/templates/` holds blanks to draw over - the same size as the real
thing, with every region a model samples outlined and the seven shades parked in
a corner no model reads, so they can be picked straight out of the file.
`atlases/blocks.json` is what puts these two directories on the block atlas, and
without it a worn module is drawn untextured. Minecraft stitches
`textures/block` and `textures/item` and nothing else - everything else reaches
the atlas only by being referenced from a model the game itself loads, and a
module model is loaded by MIAPI rather than by Minecraft, so nothing of ours is
referenced by the time the atlas is built. Armory sidesteps it by filing its
module art under `miapi:item/...`, inside the directory vanilla already takes;
naming our own two directories is the same fix without moving the art.
`templates_guide.png` is a single sheet showing the shades with their values,
the cube net with each face labelled, and the vanilla armour layout, for when a
new piece is wanted rather than a repaint of an existing one.

File diff suppressed because it is too large Load Diff

View File

@ -46,14 +46,11 @@ public final class ApothicSockets {
private static final String MIAPI_MODULE = "smartin.miapi.modules.ItemModule";
/**
* Module ids that are a gem case: one per gem size, and one per socket that
* names a body part - the four limbs, the space helmet's visor and the
* backtank. 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. A case missing from this
* list still fits its slot and still wears its gemstone; it simply grants
* nothing, which is the failure that brought the space cases here.
* 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",
@ -62,9 +59,7 @@ public final class ApothicSockets {
"cmmodular:gem/limb/case_arm_left",
"cmmodular:gem/limb/case_arm_right",
"cmmodular:gem/limb/case_leg_left",
"cmmodular:gem/limb/case_leg_right",
"cmmodular:gem/space/case_helmet",
"cmmodular:gem/space/case_backtank");
"cmmodular:gem/limb/case_leg_right");
/** MIAPI's module tree, reached without naming its types at compile time. */
private static Method getModules;

View File

@ -35,10 +35,6 @@
"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.module.cmmodular.gem.space.gem_helmet.name": "%s Visor Gemstone",
"miapi.module.cmmodular.gem.space.case_helmet.name": "Visor Apothic Gem Case",
"miapi.module.cmmodular.gem.space.gem_backtank.name": "%s Backtank Gemstone",
"miapi.module.cmmodular.gem.space.case_backtank.name": "Backtank 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",

View File

@ -1,5 +1,6 @@
{
"comment": "Life support pack, worn. Twin tanks sitting on the back plate; the same 32x32 cube net as the helmet, so both are edited the same way.",
"texture_size": [32, 32],
"textures": {
"1": "cmmodular:equipment/life_support_pack",
"particle": "cmmodular:equipment/life_support_pack"
@ -7,86 +8,24 @@
"elements": [
{
"name": "tanks",
"from": [
-3.5,
-6.5,
2
],
"to": [
3.5,
1.5,
5
],
"from": [-3.5, -6.5, 2],
"to": [3.5, 1.5, 5],
"faces": {
"north": {
"uv": [
1.5,
3.0,
5.0,
11.0
],
"texture": "#1"
},
"east": {
"uv": [
5.0,
3.0,
6.5,
11.0
],
"texture": "#1"
},
"south": {
"uv": [
6.5,
3.0,
10.0,
11.0
],
"texture": "#1"
},
"west": {
"uv": [
0.0,
3.0,
1.5,
11.0
],
"texture": "#1"
},
"up": {
"uv": [
1.5,
0.0,
5.0,
3.0
],
"texture": "#1"
},
"down": {
"uv": [
5.0,
0.0,
8.5,
3.0
],
"texture": "#1"
}
"north": {"uv": [8, 8, 16, 16], "texture": "#1"},
"east": {"uv": [16, 8, 24, 16], "texture": "#1"},
"south": {"uv": [24, 8, 32, 16], "texture": "#1"},
"west": {"uv": [0, 8, 8, 16], "texture": "#1"},
"up": {"uv": [8, 0, 16, 8], "texture": "#1"},
"down": {"uv": [16, 0, 24, 8], "texture": "#1"}
}
}
],
"groups": [
{
"name": "Life Support Pack",
"origin": [
0,
0,
0
],
"origin": [0, 0, 0],
"color": 0,
"children": [
0
]
"children": [0]
}
]
}

View File

@ -1,5 +1,9 @@
{
"comment": "Life support pack with a gem setting between the tanks, worn. Same box as the plain pack, socket stamped into the back face of the net.",
"texture_size": [
32,
32
],
"textures": {
"1": "cmmodular:equipment/life_support_pack_socket",
"particle": "cmmodular:equipment/life_support_pack_socket"
@ -20,55 +24,55 @@
"faces": {
"north": {
"uv": [
1.5,
3.0,
5.0,
11.0
8,
8,
16,
16
],
"texture": "#1"
},
"east": {
"uv": [
5.0,
3.0,
6.5,
11.0
16,
8,
24,
16
],
"texture": "#1"
},
"south": {
"uv": [
6.5,
3.0,
10.0,
11.0
24,
8,
32,
16
],
"texture": "#1"
},
"west": {
"uv": [
0.0,
3.0,
1.5,
11.0
0,
8,
8,
16
],
"texture": "#1"
},
"up": {
"uv": [
1.5,
0.0,
5.0,
3.0
8,
0,
16,
8
],
"texture": "#1"
},
"down": {
"uv": [
5.0,
0.0,
8.5,
3.0
16,
0,
24,
8
],
"texture": "#1"
}

View File

@ -1,5 +1,6 @@
{
"comment": "Sealed helmet, worn. One cube around the head, textured from a 32x32 cube net so the faces can be edited without touching a vanilla armour atlas.",
"texture_size": [32, 32],
"textures": {
"1": "cmmodular:equipment/space_helmet",
"particle": "cmmodular:equipment/space_helmet"
@ -7,86 +8,24 @@
"elements": [
{
"name": "dome",
"from": [
-4.25,
-4.25,
-4.25
],
"to": [
4.25,
4.25,
4.25
],
"from": [-4.25, -4.25, -4.25],
"to": [4.25, 4.25, 4.25],
"faces": {
"north": {
"uv": [
8.0,
8.0,
4.0,
4.0
],
"texture": "#1"
},
"east": {
"uv": [
0.0,
8.0,
4.0,
4.0
],
"texture": "#1"
},
"south": {
"uv": [
16.0,
8.0,
12.0,
4.0
],
"texture": "#1"
},
"west": {
"uv": [
8.0,
8.0,
12.0,
4.0
],
"texture": "#1"
},
"up": {
"uv": [
12.0,
0.0,
8.0,
4.0
],
"texture": "#1"
},
"down": {
"uv": [
8.0,
4.0,
4.0,
0.0
],
"texture": "#1"
}
"north": {"uv": [8, 8, 16, 16], "texture": "#1"},
"east": {"uv": [16, 8, 24, 16], "texture": "#1"},
"south": {"uv": [24, 8, 32, 16], "texture": "#1"},
"west": {"uv": [0, 8, 8, 16], "texture": "#1"},
"up": {"uv": [8, 0, 16, 8], "texture": "#1"},
"down": {"uv": [16, 0, 24, 8], "texture": "#1"}
}
}
],
"groups": [
{
"name": "Space Helmet",
"origin": [
0,
0,
0
],
"origin": [0, 0, 0],
"color": 0,
"children": [
0
]
"children": [0]
}
]
}

View File

@ -1,5 +1,9 @@
{
"comment": "Sealed helmet with a gem setting on the crown, worn. Same cube as the plain helmet, socket stamped into the top face of the net.",
"texture_size": [
32,
32
],
"textures": {
"1": "cmmodular:equipment/space_helmet_socket",
"particle": "cmmodular:equipment/space_helmet_socket"
@ -20,55 +24,55 @@
"faces": {
"north": {
"uv": [
8.0,
8.0,
4.0,
4.0
8,
8,
16,
16
],
"texture": "#1"
},
"east": {
"uv": [
0.0,
8.0,
4.0,
4.0
16,
8,
24,
16
],
"texture": "#1"
},
"south": {
"uv": [
16.0,
8.0,
12.0,
4.0
24,
8,
32,
16
],
"texture": "#1"
},
"west": {
"uv": [
8.0,
8.0,
12.0,
4.0
0,
8,
8,
16
],
"texture": "#1"
},
"up": {
"uv": [
12.0,
0.0,
8.0,
4.0
8,
0,
16,
8
],
"texture": "#1"
},
"down": {
"uv": [
8.0,
4.0,
4.0,
0.0
16,
0,
24,
8
],
"texture": "#1"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 B

After

Width:  |  Height:  |  Size: 141 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 B

After

Width:  |  Height:  |  Size: 141 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 B

After

Width:  |  Height:  |  Size: 126 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 B

After

Width:  |  Height:  |  Size: 126 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 B

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 B

After

Width:  |  Height:  |  Size: 235 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 501 B

After

Width:  |  Height:  |  Size: 217 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 B

After

Width:  |  Height:  |  Size: 272 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 636 B

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 B

After

Width:  |  Height:  |  Size: 219 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 B

After

Width:  |  Height:  |  Size: 257 B

View File

@ -1,14 +0,0 @@
{
"sources": [
{
"type": "directory",
"source": "equipment",
"prefix": "equipment/"
},
{
"type": "directory",
"source": "items",
"prefix": "items/"
}
]
}

View File

@ -4,23 +4,19 @@
{
"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
},
"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": [

View File

@ -4,23 +4,19 @@
{
"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
},
"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": [

View File

@ -4,23 +4,19 @@
{
"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
},
"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": [

View File

@ -4,23 +4,19 @@
{
"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
},
"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": [

View File

@ -1,51 +0,0 @@
{
"display_name": "miapi.module.cmmodular.gem.space.case_backtank.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": "body"
}
}
],
"allowed_in_slots": [
"gem_armor_medium_backtank"
],
"tag": [
"gem_armor",
"gem_armor_medium",
"gem_armor_generic",
"gem_armor_medium_generic"
],
"material_property": [
"default"
],
"priority": 0,
"repair_priority": 0,
"rarity": "rare",
"allowed_material": {
"allowedMaterials": [
"metal",
"crystal",
"glass",
"bone",
"stone"
],
"cost": 2
}
}

View File

@ -1,51 +0,0 @@
{
"display_name": "miapi.module.cmmodular.gem.space.case_helmet.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": "head"
}
}
],
"allowed_in_slots": [
"gem_armor_medium_helmet"
],
"tag": [
"gem_armor",
"gem_armor_medium",
"gem_armor_generic",
"gem_armor_medium_generic"
],
"material_property": [
"default"
],
"priority": 0,
"repair_priority": 0,
"rarity": "rare",
"allowed_material": {
"allowedMaterials": [
"metal",
"crystal",
"glass",
"bone",
"stone"
],
"cost": 2
}
}

View File

@ -12,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": {
@ -24,21 +36,9 @@
"slotType": "cosmetic",
"translationKey": "miapi.slot.arm_patch.name",
"transform": {
"rotation": {
"x": 0,
"y": 90,
"z": 0
},
"translation": {
"x": -2.15,
"y": -1,
"z": 0
},
"scale": {
"x": 1.1,
"y": 1,
"z": 1
},
"rotation": {"x": 0, "y": 90, "z": 0},
"translation": {"x": -2.15, "y": -1, "z": 0},
"scale": {"x": 1.1, "y": 1, "z": 1},
"origin": "left_arm"
}
},
@ -48,21 +48,9 @@
],
"translationKey": "miapi.slot.gemstone.medium",
"transform": {
"rotation": {
"x": 90.0,
"y": -55.7523,
"z": 90.0
},
"translation": {
"x": 5.0891,
"y": -2.1965,
"z": -5.3336
},
"scale": {
"x": 0.6667,
"y": 0.8171,
"z": 0.7123
},
"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"
}
}

View File

@ -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": {
@ -24,21 +36,9 @@
"slotType": "cosmetic",
"translationKey": "miapi.slot.arm_patch.name",
"transform": {
"rotation": {
"x": 0,
"y": -90,
"z": 0
},
"translation": {
"x": 2.15,
"y": -1,
"z": 0
},
"scale": {
"x": 1.1,
"y": 1,
"z": 1
},
"rotation": {"x": 0, "y": -90, "z": 0},
"translation": {"x": 2.15, "y": -1, "z": 0},
"scale": {"x": 1.1, "y": 1, "z": 1},
"origin": "right_arm"
}
},
@ -48,21 +48,9 @@
],
"translationKey": "miapi.slot.gemstone.medium",
"transform": {
"rotation": {
"x": 90.0,
"y": 55.7523,
"z": -90.0
},
"translation": {
"x": -5.0891,
"y": -2.1965,
"z": 5.3336
},
"scale": {
"x": 0.6667,
"y": 0.8171,
"z": 0.7123
},
"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"
}
}

View File

@ -12,6 +12,18 @@
"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": {
@ -22,21 +34,9 @@
],
"translationKey": "miapi.slot.gemstone.medium",
"transform": {
"rotation": {
"x": 0.0,
"y": 0.0,
"z": -180.0
},
"translation": {
"x": 6.6664,
"y": 7.0,
"z": -9.1664
},
"scale": {
"x": 0.8333,
"y": 1.0,
"z": 0.8333
},
"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"
}
}

View File

@ -12,6 +12,18 @@
"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": {
@ -22,21 +34,9 @@
],
"translationKey": "miapi.slot.gemstone.medium",
"transform": {
"rotation": {
"x": 0.0,
"y": 0.0,
"z": -180.0
},
"translation": {
"x": 6.6664,
"y": 7.0,
"z": -9.1664
},
"scale": {
"x": 0.8333,
"y": 1.0,
"z": 0.8333
},
"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"
}
}

View File

@ -21,42 +21,19 @@
"path": "cmmodular:models/item/armor/model/helmet/space/[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
},
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 0, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1},
"origin": "head"
}
},
{
"path": "cmmodular:models/item/armor/gui/space/helmet/[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
}
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 0, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1},
"origin": "item"
}
}
],

View File

@ -20,42 +20,19 @@
"path": "cmmodular:models/item/armor/model/helmet/space_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
},
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 4.1387, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1},
"origin": "head"
}
},
{
"path": "cmmodular:models/item/armor/gui/space/helmet_socket/[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
}
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 0, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1},
"origin": "item"
}
}
],
@ -67,46 +44,21 @@
],
"translationKey": "miapi.slot.hat.name",
"transform": {
"rotation": {
"x": 0,
"y": 0,
"z": 0
},
"translation": {
"x": 0,
"y": -1,
"z": 0
},
"scale": {
"x": 1.1,
"y": 1.1,
"z": 1.1
},
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": -1, "z": 0},
"scale": {"x": 1.1, "y": 1.1, "z": 1.1},
"origin": "head"
}
},
"gem": {
"allowed": [
"gem_armor_medium_helmet"
"gem_armor_medium"
],
"translationKey": "miapi.slot.gemstone.medium",
"transform": {
"rotation": {
"x": 0,
"y": 0,
"z": 0
},
"translation": {
"x": -8.0,
"y": -12.06,
"z": -12.64
},
"scale": {
"x": 1,
"y": 1,
"z": 1
},
"origin": "head"
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 3, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1}
}
}
},

View File

@ -21,21 +21,9 @@
"path": "cmmodular:models/item/armor/model/chest_back/space/[material.texture].json",
"trim_mode": "ARMOR_LAYER_ONE",
"transform": {
"rotation": {
"x": 0,
"y": 0,
"z": 0
},
"translation": {
"x": 0,
"y": 0,
"z": 0.6667
},
"scale": {
"x": 0.8,
"y": 0.9091,
"z": 0.6667
},
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": 0, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1},
"origin": "body"
}
}

View File

@ -20,21 +20,9 @@
"path": "cmmodular:models/item/armor/model/chest_back/space_socket/[material.texture].json",
"trim_mode": "ARMOR_LAYER_ONE",
"transform": {
"rotation": {
"x": 0,
"y": 0,
"z": 0
},
"translation": {
"x": 0,
"y": 0,
"z": 0.6667
},
"scale": {
"x": 0.8,
"y": 0.9091,
"z": 0.6667
},
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": -2.1901, "z": 0},
"scale": {"x": 1, "y": 1, "z": 1},
"origin": "body"
}
}
@ -48,45 +36,21 @@
"slotType": "cosmetic",
"translationKey": "miapi.slot.chest_back_center.name",
"transform": {
"rotation": {
"x": 180,
"y": 0,
"z": 180
},
"translation": {
"x": 0,
"y": 1.7,
"z": 2.35
},
"scale": {
"x": 1,
"y": 0.775,
"z": 1.1
},
"rotation": {"x": 180, "y": 0, "z": 180},
"translation": {"x": 0, "y": 1.7, "z": 2.35},
"scale": {"x": 1, "y": 0.775, "z": 1.1},
"origin": "body"
}
},
"gem": {
"allowed": [
"gem_armor_medium_backtank"
"gem_armor_medium"
],
"translationKey": "miapi.slot.gemstone.medium",
"transform": {
"rotation": {
"x": 0,
"y": 0,
"z": 0
},
"translation": {
"x": -8.8,
"y": -11.07,
"z": -4.3
},
"scale": {
"x": 1.1,
"y": 1.1,
"z": 1.1
},
"rotation": {"x": 0, "y": 0, "z": 0},
"translation": {"x": 0, "y": -1, "z": 0},
"scale": {"x": 1.1, "y": 1.1, "z": 1.1},
"origin": "body"
}
}

View File

@ -4,23 +4,19 @@
{
"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
},
"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": [

View File

@ -4,23 +4,19 @@
{
"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
},
"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": [

View File

@ -4,23 +4,19 @@
{
"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
},
"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": [

View File

@ -4,23 +4,19 @@
{
"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
},
"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": [

View File

@ -1,52 +0,0 @@
{
"display_name": "miapi.module.cmmodular.gem.space.gem_backtank.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": "body"
}
}
],
"allowed_in_slots": [
"gem_armor_medium_backtank"
],
"material_property": [
"default",
"gem_armor_handheld",
"gem_armor_generic"
],
"module_stats": {
"gem_power": 1
},
"tag": [
"gem_armor",
"gem_armor_medium",
"gem_armor_generic",
"gem_armor_medium_generic"
],
"priority": 0,
"repair_priority": 0,
"rarity": "uncommon",
"allowed_material": {
"allowedMaterials": [
"gem_armor"
],
"cost": 2
}
}

View File

@ -1,52 +0,0 @@
{
"display_name": "miapi.module.cmmodular.gem.space.gem_helmet.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": "head"
}
}
],
"allowed_in_slots": [
"gem_armor_medium_helmet"
],
"material_property": [
"default",
"gem_armor_handheld",
"gem_armor_generic"
],
"module_stats": {
"gem_power": 1
},
"tag": [
"gem_armor",
"gem_armor_medium",
"gem_armor_generic",
"gem_armor_medium_generic"
],
"priority": 0,
"repair_priority": 0,
"rarity": "uncommon",
"allowed_material": {
"allowedMaterials": [
"gem_armor"
],
"cost": 2
}
}

View File

@ -22,12 +22,9 @@ 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. A merge multiplies the two matrices and then
decomposes the product back into translation, Euler angles and scale - 2.3.8
does that on every merge, not only the old versions, so shear is dropped
wherever a rotation meets a non-uniform scale, which is what Armory's limb
slots are. That is the default here; `--exact-merge` keeps the product whole,
for seeing what the shear would have done.
`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.
@ -85,20 +82,8 @@ class Placement:
@property
def origin(self):
"""The body part this module is drawn under, or None for none of them.
Absent does **not** mean the torso. MIAPI renders armour by walking a
fixed list of seven part names and matching models to each, so a
transform that names nothing - or names `item` - matches no part and is
never drawn on the wearer at all. That entry is the inventory model,
which is why `scene_icons` picks it out by its missing origin.
"""
return self.transform.get('origin')
@property
def worn(self):
"""Whether MIAPI would draw this on the body rather than in the slot UI."""
return self.origin in mc.BODY_PARTS
"""The body part this module is drawn under. Absent means the torso."""
return self.transform.get('origin', 'body')
@property
def auto_chain(self):
@ -210,7 +195,6 @@ PLATES = (
)
SOCKETS = 'src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/socket'
SPACE = 'src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/space'
LIMB_GEMS = 'src/main/resources/packs/armory/data/cmmodular/miapi/modules/gem/limb'
@ -294,74 +278,6 @@ def scene_armory(res, args):
return out
def scene_space(res, args):
"""The space set, composed the way MIAPI composes it rather than by slot alone.
A module's model carries a transform of its own, and the slot above it
carries another; `ModuleModel` looks up `SlotProperty.getTransformStack(key)`
and the model's matrix applies inside it. Drawing the slot transform alone -
which an earlier version of this scene did - shows the model at whatever size
it was drawn rather than the size it renders at, and the chest_back slot
scales by (1.25, 1.1, 1.5), so that is the difference between a backpack and
a slab wider than the torso. Both transforms are read from the module JSON
here, so the picture moves when the files do, and the module transform is the
editable one.
Entries whose origin is not a body part are skipped rather than drawn at the
torso: MIAPI matches a model to a part by name, so those never appear on a
wearer at all - they are the inventory model.
"""
chest = json.loads(res.read('data/tm_armory/miapi/modules/armor/chestplate.json'))['slots']
helmet = json.loads(res.read('data/tm_armory/miapi/modules/armor/helmet.json'))['slots']
# part, module file, and the slot transform carrying it. Nothing here is a
# root module: a helmet plate sits in the helmet item's `hat` slot, whose
# transform lifts it from the neck onto the head (`y -3.925`, the head's own
# centre) and scales it up a quarter. Leaving that out drops every helmet
# four pixels and makes it look like the model is wrong when it is not.
pieces = (
('body', 'life_support_socket', chest['chest_back']['transform']),
('head', 'helmet_socket', helmet['hat']['transform']),
)
out = []
for i, (part, name, outer_tr) in enumerate(pieces):
path = os.path.join(REPO, SPACE, f'{name}.json')
with open(path) as fh:
module = json.load(fh)
entries = module['data']['replace']['model']
for j, entry in enumerate(entries):
tr = entry['transform']
if tr.get('origin') not in mc.BODY_PARTS:
continue
try:
quads = mc.model_quads(res.model(entry['path'], args.variant), res)
except KeyError:
print(f'missing model {entry["path"]}')
continue
out.append(Placement(
f'{name} ({tr["origin"]})', quads, tr,
outer=mc.transform_matrix(outer_tr) if outer_tr else None,
chain_origin=part,
source=(path, ['data', 'replace', 'model', j, 'transform']),
colour=PALETTE[i % len(PALETTE)]))
# Armory's own back plate and helmet, unedited, as the controls: both are
# known to sit right, so a piece of ours that lands somewhere else is a
# number to change rather than a chain to doubt.
out.append(Placement(
'armory back plate (control)',
mc.model_quads(res.model('miapi:models/item/armor/model/chest_back/base/'
'[material.texture].json', args.variant), res),
chest['chest_back']['transform'], colour=(0.55, 0.55, 0.62)))
out.append(Placement(
'armory helmet (control)',
mc.model_quads(res.model('miapi:models/item/armor/model/helmet/base/'
'[material.texture].json', args.variant), res),
{'rotation': {'x': 0, 'y': 0, 'z': 0}, 'translation': {'x': 0, 'y': 0, 'z': 0},
'scale': {'x': 1, 'y': 1, 'z': 1}, 'origin': 'head'},
colour=(0.62, 0.62, 0.68)))
return out
def scene_icons(res, args):
"""The four inventory icons, side by side, each with its gem on it.
@ -403,7 +319,7 @@ def scene_icons(res, args):
SCENES = {'vanilla': scene_vanilla, 'sockets': scene_sockets,
'armory': scene_armory, 'icons': scene_icons, 'space': scene_space}
'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
@ -527,7 +443,7 @@ class Viewport:
self.res, self.placements, self.args = res, placements, args
self.force_chain = args.chain
self.textured = not args.no_textures
self.lossy = not args.exact_merge
self.lossy = args.lossy_merge
self.step = args.step
self.turn_step = 7.5
self.show_body = args.body
@ -625,7 +541,7 @@ class Viewport:
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 {"as MIAPI does" if self.lossy else "exact, shear kept"}',
f' merge {"lossy (1.20)" if self.lossy else "exact (1.21)"}',
'',
HELP,
]
@ -1020,9 +936,8 @@ def main(argv=None):
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('--exact-merge', action='store_true',
help='keep the whole matrix product instead of the Euler round trip'
' MIAPI does on every merge')
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:

View File

@ -1,12 +0,0 @@
#!/usr/bin/env bash
# Read the Blockbench project back into the module JSON, then push it in game.
#
# bb-apply.sh dry run: show what each cube would become
# bb-apply.sh --write write the module files and live-reload the game
set -euo pipefail
REPO=/home/themiro/code.dev/create-mekanism-modular
JAR=$(ls "/home/themiro/.var/app/org.prismlauncher.PrismLauncher/data/PrismLauncher/instances/AbdelPack Delta/minecraft/mods/"Truly-Modular-armory-*.jar | head -1)
"$REPO/tools/.venv/bin/python" "$REPO/tools/bb_import.py" "$JAR" "$REPO/placement.bbmodel" "$@"
if [ "${1:-}" = "--write" ]; then
"$REPO/tools/live-reload.sh" "$REPO/src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/socket/arm_left.json"
fi

View File

@ -1,16 +0,0 @@
#!/usr/bin/env bash
# Regenerate the placement project from the live JSON and open it.
#
# bb-open.sh regenerate from the module files, then open
# bb-open.sh --keep open what is already there, without regenerating
#
# Regenerating is the default because the project is a view of the module JSON,
# not a second copy of it: anything changed in the files since the last export
# should be what you see.
set -euo pipefail
REPO=/home/themiro/code.dev/create-mekanism-modular
JAR=$(ls "/home/themiro/.var/app/org.prismlauncher.PrismLauncher/data/PrismLauncher/instances/AbdelPack Delta/minecraft/mods/"Truly-Modular-armory-*.jar | head -1)
OUT=$REPO/placement.bbmodel
[ "${1:-}" = "--keep" ] || "$REPO/tools/.venv/bin/python" "$REPO/tools/bb_export.py" "$JAR" "$OUT"
setsid flatpak run net.blockbench.Blockbench "$OUT" >/dev/null 2>&1 < /dev/null &
echo "opening $OUT"

View File

@ -1,168 +0,0 @@
#!/usr/bin/env python3
"""Write the armour placement out as a Blockbench project.
bb_export.py <armory.jar> [out.bbmodel]
Everything that positions a piece is drawn: the wearer, Armory's plates, this
mod's bezels, and - as the only unlocked cubes - the pieces whose transforms are
ours to change. Each of those carries its destination in its own name, in an
`[edit:...]` tag, so the file is the only thing `bb_import.py` needs.
The reference geometry is locked rather than merely present. A gem is aimed at a
bezel, and a bezel that can be nudged by accident is a bezel that stops being an
answer.
"""
import json
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import mcmodel as mc
import ARMOUR_EDITOR as ed
import blockbench as bb
REPO = ed.REPO
SOCKETS = ed.SOCKETS
SPACE = 'src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/space'
# hue per body part, so a group reads as a unit in the outliner
COLOURS = {'left_arm': 1, 'right_arm': 2, 'left_leg': 3, 'right_leg': 4,
'body': 5, 'head': 6}
def box_cube(name, quads, matrix, pivot, colour, locked):
"""One drawn thing, already composed, as a Blockbench cube."""
pts = np.vstack([q.pts for q in quads])
pts = (np.column_stack([pts, np.ones(len(pts))]) @ np.asarray(matrix).T)[:, :3]
pts = pts + np.asarray(pivot, float)
centre, lo, hi, r = bb.oriented_box(pts, matrix)
return bb.cube(name,
origin=bb.to_bb(centre),
lo=bb.to_bb(centre)[0] * 0 + bb.to_bb(centre) + lo, # placeholder
hi=bb.to_bb(centre) + hi,
rotation=bb.matrix_to_euler_zyx(bb.rot_to_bb(r)),
colour=colour, locked=locked)
def main(jar, out):
class A:
pass
args = A()
args.scene = 'sockets'; args.jar = [jar]; args.variant = 'default'
args.gem = 'medium'; args.chain = False; args.exact_merge = False
args.layer1 = args.layer2 = 'miapi:item/armor/base/iron/layer_1'
res = mc.Resources([jar, os.path.join(REPO, 'src/main/resources')])
elements, outliner = [], []
textures, tex_index = [], {}
def texture(ref):
"""Embed a texture once, and hand back the index a face refers to it by."""
if ref is None:
return None
if ref not in tex_index:
try:
img = res.image(ref)
except (KeyError, OSError):
tex_index[ref] = None
return None
tex_index[ref] = len(textures)
textures.append(bb.texture_entry(ref.split('/')[-1], img, len(textures)))
return tex_index[ref]
def add(grp_children, name, quads, matrix, pivot, colour, locked):
pts = np.vstack([q.pts for q in quads])
pts = (np.column_stack([pts, np.ones(len(pts))]) @ np.asarray(matrix).T)[:, :3]
pts = pts + np.asarray(pivot, float)
centre, lo, hi, r = bb.oriented_box(pts, matrix)
c = bb.to_bb(centre)
for q in quads:
texture(q.texture)
faces = bb.faces_from_quads(quads, matrix, r, tex_index)
cu = bb.cube(name, origin=c, lo=c + lo, hi=c + hi,
rotation=bb.matrix_to_euler_zyx(bb.rot_to_bb(r)),
colour=colour, locked=locked, faces=faces)
elements.append(cu)
grp_children.append(cu['uuid'])
# ---------------------------------------------------------------- wearer
body = []
for part, (o, s, off, mirror) in mc.HUMANOID.items():
if part == 'hat':
continue
q = mc.cube_quads(o, s, off, 0.0, mirror, (64, 64),
'minecraft:entity/player/wide/steve', 0)
add(body, f'{part} (wearer)', q, np.eye(4), mc.PIVOTS[part], 8, True)
outliner.append(bb.group('WEARER (locked reference)', body))
# ------------------------------------------------------------ the limbs
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']
slots = {'arm_left': chest['arm_left'], 'arm_right': chest['arm_right'],
'leg_left': pants['leg_left'], 'leg_right': pants['leg_right']}
gem_q = mc.model_quads(res.model('miapi:models/item/armor/gems/medium/'
'[material.texture].json', 'default'), res)
for part, slot, model in ed.PLATES:
kids = []
limb_tr = slots[slot]['transform']
limb = mc.transform_matrix(limb_tr)
piv = mc.PIVOTS[part]
col = COLOURS[part]
plate = mc.model_quads(res.model(f'miapi:models/item/armor/model/{model}/'
'[material.texture].json', 'default'), res)
add(kids, f'{slot} plate', plate, limb, piv, col, True)
try:
bez = mc.model_quads(res.model(f'cmmodular:models/item/armor/model/{slot}/'
'socket/[material.texture].json', 'default'), res)
add(kids, f'{slot} bezel', bez, limb, piv, col, True)
except KeyError:
pass
path = os.path.join(REPO, SOCKETS, f'{slot}.json')
with open(path) as fh:
gem_tr = json.load(fh)['data']['replace']['slots']['gem']['transform']
world = mc.merge(mc.transform_matrix(gem_tr), limb, True)
add(kids, f'{slot} GEM [edit:socket/{slot}#gem]', gem_q, world, piv, col, False)
outliner.append(bb.group(part, kids, origin=bb.to_bb(piv)))
# ------------------------------------------------------------ space gear
helmet = json.loads(res.read('data/tm_armory/miapi/modules/armor/helmet.json'))['slots']
# The `hat` slot is what puts a helmet on the head rather than the neck.
for part, name, outer_tr, path_in_repo in (
('body', 'life_support_socket', chest['chest_back']['transform'],
f'{SPACE}/life_support_socket.json'),
('head', 'helmet_socket', helmet['hat']['transform'],
f'{SPACE}/helmet_socket.json')):
kids = []
piv = mc.PIVOTS[part]
outer = mc.transform_matrix(outer_tr)
with open(os.path.join(REPO, path_in_repo)) as fh:
module = json.load(fh)
for i, entry in enumerate(module['data']['replace']['model']):
tr = entry['transform']
if tr.get('origin') not in mc.BODY_PARTS:
continue # inventory model; MIAPI never draws it on a body
q = mc.model_quads(res.model(entry['path'], 'default'), res)
world = mc.merge(mc.transform_matrix(tr), outer, True)
add(kids, f'{name} [edit:space/{name}#model{i}]', q, world, piv,
COLOURS[part], False)
if kids:
outliner.append(bb.group(f'{part} - {name}', kids, origin=bb.to_bb(piv)))
doc = bb.project('cmmodular placement', outliner, elements, textures)
with open(out, 'w') as fh:
json.dump(doc, fh, indent=1)
editable = [e['name'] for e in elements if not e['locked']]
print(f'{out}\n {len(elements)} cubes, {len(outliner)} groups, '
f'{len(textures)} textures')
print(' editable:')
for n in editable:
print(f' {n}')
if __name__ == '__main__':
main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else 'placement.bbmodel')

View File

@ -1,146 +0,0 @@
#!/usr/bin/env python3
"""Read a Blockbench project back into the module JSON.
bb_import.py <armory.jar> <placement.bbmodel> [--write]
Only cubes tagged `[edit:<file>#<target>]` are read; everything else in the file
is reference geometry. For each one the cube's box is turned back into the
transform that would produce it:
D = T(t) @ R @ S the world matrix MIAPI must end up with
own = inv(slot_chain) @ D the transform the module file actually stores
`own` is then decomposed to translation/Euler/scale, which is all a MIAPI
transform can hold. That step can lose something: the limb slots are a rotation
against a non-uniform scale, and inverting one puts shear into `own` that the
triple cannot carry - so the result is measured against the cube it came from
and the error is printed in pixels. A tenth of a pixel is nothing; a whole one
means the pose asked for cannot be expressed and wants a different rotation.
Blockbench composes a cube's rotation as ZYX and MIAPI composes its own as XYZ,
so nothing here passes Euler triples between the two - only matrices.
"""
import json
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import mcmodel as mc
import ARMOUR_EDITOR as ed
import blockbench as bb
REPO = ed.REPO
SOCKETS = ed.SOCKETS
SPACE = 'src/main/resources/packs/armory/data/cmmodular/miapi/modules/armor/space'
def local_box(res, path, variant='default'):
"""The model's own extents, before anything is applied to it."""
q = mc.model_quads(res.model(path, variant), res)
pts = np.vstack([x.pts for x in q])
return (pts.min(0) + pts.max(0)) / 2, (pts.max(0) - pts.min(0)) / 2, q
def target_info(res, spec):
"""Where a tag points: the module file, its model, and the chain above it."""
where, key = spec.split('#')
kind, name = where.split('/')
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']
if kind == 'socket':
path = os.path.join(REPO, SOCKETS, f'{name}.json')
slot = (chest if name.startswith('arm') else pants)[name]['transform']
part = {'arm_left': 'left_arm', 'arm_right': 'right_arm',
'leg_left': 'left_leg', 'leg_right': 'right_leg'}[name]
keys = ['data', 'replace', 'slots', key, 'transform']
model = 'miapi:models/item/armor/gems/medium/[material.texture].json'
return path, keys, mc.transform_matrix(slot), mc.PIVOTS[part], model
path = os.path.join(REPO, SPACE, f'{name}.json')
i = int(key.replace('model', ''))
with open(path) as fh:
entry = json.load(fh)['data']['replace']['model'][i]
origin = entry['transform'].get('origin')
if origin == 'body':
outer = mc.transform_matrix(chest['chest_back']['transform'])
elif origin == 'head':
helmet = json.loads(res.read('data/tm_armory/miapi/modules/armor/helmet.json'))['slots']
outer = mc.transform_matrix(helmet['hat']['transform'])
else:
outer = np.eye(4)
keys = ['data', 'replace', 'model', i, 'transform']
return path, keys, outer, mc.PIVOTS[origin], entry['path']
def main(jar, model_path, write):
res = mc.Resources([jar, os.path.join(REPO, 'src/main/resources')])
doc = json.load(open(model_path))
np.set_printoptions(precision=3, suppress=True)
edits = [e for e in doc['elements'] if '[edit:' in e.get('name', '')]
if not edits:
print('no [edit:...] cubes in that file'); return 1
print(f'{len(edits)} editable cube(s)\n')
for cu in edits:
spec = cu['name'].split('[edit:')[1].split(']')[0]
path, keys, outer, pivot, model = target_info(res, spec)
c_local, h_local, quads = local_box(res, model)
# the cube, back in world coordinates
lo = np.asarray(cu['from'], float); hi = np.asarray(cu['to'], float)
c_bb = (lo + hi) / 2
h_bb = (hi - lo) / 2
r_bb = bb.euler_zyx_to_matrix(cu.get('rotation', [0, 0, 0]))
c_world = bb.to_world(c_bb)
r_world = bb.rot_to_bb(r_bb) # conjugation is its own inverse
scale = np.where(h_local > 1e-9, h_bb / np.where(h_local > 1e-9, h_local, 1), 1.0)
s = np.diag(scale)
t = (c_world - np.asarray(pivot, float)) - r_world @ s @ c_local
d = np.eye(4); d[:3, :3] = r_world @ s; d[:3, 3] = t
own = np.linalg.inv(outer) @ d
tr = mc.decompose(own)
# what MIAPI will actually render, after the lossy merge
got = mc.merge(mc.transform_matrix(tr), outer, True)
pts = np.vstack([q.pts for q in quads])
want = (np.column_stack([pts, np.ones(len(pts))]) @ d.T)[:, :3]
have = (np.column_stack([pts, np.ones(len(pts))]) @ got.T)[:, :3]
err = np.linalg.norm(want - have, axis=1).max()
flag = '' if err < 0.05 else (' <-- lossy, check it' if err < 0.5 else ' <-- CANNOT BE EXPRESSED')
print(f'{spec}')
print(f' world centre {(c_world).round(3)} size {(h_bb*2).round(3)}')
print(f' -> t {[round(v,4) for v in tr["translation"].values()]}'
f' r {[round(v,4) for v in tr["rotation"].values()]}'
f' s {[round(v,4) for v in tr["scale"].values()]}')
print(f' residual after MIAPI\'s merge: {err:.4f}px{flag}')
if write:
with open(path) as fh:
doc_j = json.load(fh)
node = doc_j
for k in keys[:-1]:
node = node[k]
keep = node[keys[-1]].get('origin')
node[keys[-1]] = {
'rotation': {a: round(float(tr['rotation'][a]), 4) for a in 'xyz'},
'translation': {a: round(float(tr['translation'][a]), 4) for a in 'xyz'},
'scale': {a: round(float(tr['scale'][a]), 4) for a in 'xyz'},
}
if keep:
node[keys[-1]]['origin'] = keep
with open(path, 'w') as fh:
json.dump(doc_j, fh, indent=2); fh.write('\n')
print(f' wrote {os.path.relpath(path, REPO)}')
print()
if not write:
print('(dry run - pass --write to apply)')
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1], sys.argv[2], '--write' in sys.argv))

View File

@ -1,263 +0,0 @@
"""Take armour placement out to Blockbench and bring it back.
Three coordinate systems meet here and none of them agree, so each conversion is
written down rather than assumed.
**MIAPI 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. Those are the raw `ModelPart` coordinates: vanilla builds the torso as
`addBox(-4, 0, -2, 8, 12, 4)`, which is why the body runs y 0..12 downward from
the neck and the head runs y -8..0 above it. A translation is in pixels;
`Transform.toModelTransformation` multiplies by 1/16 at render time, not before.
**The MIAPI chain** - `Transform.toMatrix` is `T * Rx * Ry * Rz * S`, and
`Transform.merge(parent, child)` computes `child.mul(parent)` and then
`fromMatrix`, which decomposes back to translation/Euler/scale and **drops any
shear**. `TransformMap.add(id, t)` files that per origin and calls
`merge(existing, t)`. So a module's world matrix is
world = slot_chain(origin) @ own (own applies first, in its own frame)
and the pivot of the body part named by `origin` is added on top.
**Blockbench** - `+y` is up, and cube rotations compose as **ZYX**
(`Format.euler_order` defaults to `"ZYX"`), not the XYZ that MIAPI uses. Getting
that pair backwards silently mangles every rotated piece, which is why both
directions here go through matrices and never through raw Euler triples.
The bridge between the first and the last is a 180 degree turn about z,
`diag(-1, -1, 1)`. Flipping y alone would also stand the model up, but it is a
mirror: it would send a rotation back as its opposite sense, and the wearer's
left arm would come home as the right. The turn is a proper rotation, and it is
its own inverse, so one constant serves both ways.
"""
import json
import math
import uuid as _uuid
import numpy as np
import mcmodel as mc
# World <-> Blockbench. A 180 degree turn about z: head up, handedness intact,
# and self-inverse so export and import share it.
FLIP = np.diag([-1.0, -1.0, 1.0])
# ...and then dropped so the wearer stands on the grid rather than hanging under
# it. The world origin is the base of the neck, so a whole body sits 24 pixels
# *below* y=0 and lands outside the view Blockbench opens with - the model is
# there, just off the top of nothing. 24 puts the feet on zero, which is where
# Blockbench expects a humanoid and where its default camera is aimed.
#
# This is a constant rather than something written into the file on purpose:
# both directions read it from here, so they cannot disagree.
ORIGIN = np.array([0.0, 24.0, 0.0])
def to_bb(points):
"""World model pixels -> Blockbench coordinates."""
return np.asarray(points, float) @ FLIP.T + ORIGIN
def to_world(points):
"""Blockbench coordinates -> world model pixels."""
return (np.asarray(points, float) - ORIGIN) @ FLIP.T
def rot_to_bb(r):
"""Conjugate a world rotation into Blockbench's frame."""
return FLIP @ r @ FLIP
def _rx(a):
c, s = math.cos(a), math.sin(a)
return np.array([[1, 0, 0], [0, c, -s], [0, s, c]], float)
def _ry(a):
c, s = math.cos(a), math.sin(a)
return np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]], float)
def _rz(a):
c, s = math.cos(a), math.sin(a)
return np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]], float)
def euler_zyx_to_matrix(deg):
"""Blockbench's rotation triple -> a matrix.
`Format.euler_order` is `"ZYX"`, which in three.js means the intrinsic
sequence z then y then x, and an intrinsic sequence composes by
right-multiplication: `Rz @ Ry @ Rx`.
"""
x, y, z = (math.radians(float(v)) for v in deg)
return _rz(z) @ _ry(y) @ _rx(x)
def matrix_to_euler_zyx(m):
"""The inverse of `euler_zyx_to_matrix`, in degrees."""
m = np.asarray(m, float)
sy = -m[2, 0]
sy = max(-1.0, min(1.0, sy))
if abs(sy) < 0.999999:
x = math.atan2(m[2, 1], m[2, 2])
y = math.asin(sy)
z = math.atan2(m[1, 0], m[0, 0])
else: # gimbal lock: fold x into z
x = 0.0
y = math.asin(sy)
z = math.atan2(-m[0, 1], m[1, 1])
return [round(math.degrees(v), 4) for v in (x, y, z)]
def oriented_box(points, matrix):
"""Fit a Blockbench cube to a transformed box.
A cube there is an axis-aligned box plus a rotation about a pivot, so it can
carry a rotated box and a box scaled unevenly along its own axes - which is
what a limb transform makes of a plate - but not a sheared one. The rotation
is taken from the composed matrix by polar decomposition and the extents are
measured in that frame, so a shear shows up as a box slightly too big rather
than as silence.
"""
pts = np.asarray(points, float)
u, _, vt = np.linalg.svd(np.asarray(matrix, float)[:3, :3])
r = u @ vt
if np.linalg.det(r) < 0:
u[:, -1] *= -1
r = u @ vt
centre = (pts.min(0) + pts.max(0)) / 2
local = (pts - centre) @ r
return centre, local.min(0), local.max(0), r
def new_uuid():
return str(_uuid.uuid4())
# Which Blockbench face a direction points at. The list is in Blockbench's own
# axis order, and the signs are read in *Blockbench* space, so a source face is
# matched by turning its normal into the cube's local frame first.
FACE_AXES = (('east', (1, 0, 0)), ('west', (-1, 0, 0)),
('up', (0, 1, 0)), ('down', (0, -1, 0)),
('south', (0, 0, 1)), ('north', (0, 0, -1)))
def faces_from_quads(quads, matrix, rot_local, tex_index):
"""Per-face uv and texture for a fitted box, matched by normal.
A cube here is a box fitted to geometry that has already been rotated and
scaled, so the source model's faces are not in the places a cube expects
them. Each source quad is matched to the cube face its normal ends up
pointing at, which is the only correspondence that survives an arbitrary
rotation.
UVs come across normalised and are written in the project's 0-16 space, so
one number works for a 16x16 gem and a 64x64 wearer alike - Blockbench reads
a face uv as a fraction of whatever texture the face names, and the project
resolution is 16. Nothing has to know how big any png is.
"""
faces = {name: {'uv': [0, 0, 16, 16], 'texture': None} for name, _ in FACE_AXES}
for q in quads:
n = np.cross(q.pts[1] - q.pts[0], q.pts[2] - q.pts[0])
ln = np.linalg.norm(n)
if ln < 1e-9:
continue
n = (np.asarray(matrix, float)[:3, :3] @ (n / ln))
ln = np.linalg.norm(n)
if ln < 1e-9:
continue
n = rot_local.T @ (n / ln) # into the cube's own frame
n = FLIP @ n # and into Blockbench's
name = max(FACE_AXES, key=lambda fa: float(np.dot(n, fa[1])))[0]
idx = tex_index.get(q.texture)
if idx is None:
continue
u0, v0 = q.uv.min(axis=0)
u1, v1 = q.uv.max(axis=0)
faces[name] = {'uv': [round(u0 * 16, 4), round(v0 * 16, 4),
round(u1 * 16, 4), round(v1 * 16, 4)],
'texture': idx}
return faces
def texture_entry(name, image, index):
"""One embedded texture, as Blockbench stores an internal one."""
import base64
import io
buf = io.BytesIO()
image.save(buf, format='PNG')
return {
'path': '', 'name': f'{name}.png', 'folder': '',
'namespace': '', 'id': str(index), 'width': image.width,
'height': image.height, 'uv_width': 16, 'uv_height': 16,
'particle': False, 'use_as_default': False, 'layers_enabled': False,
'sync_to_project': '', 'render_mode': 'default', 'render_sides': 'auto',
'frame_time': 1, 'frame_order_type': 'loop', 'frame_order': '',
'frame_interpolate': False, 'visible': True, 'internal': True,
'saved': False, 'uuid': new_uuid(),
'relative_path': '',
'source': 'data:image/png;base64,' + base64.b64encode(buf.getvalue()).decode(),
}
def cube(name, origin, lo, hi, rotation, colour=0, locked=False, faces=None):
"""One Blockbench cube, in Blockbench coordinates."""
return {
'name': name,
'box_uv': False,
'rescale': False,
'locked': locked,
'render_order': 'default',
'allow_mirror_modeling': True,
'from': [round(float(v), 4) for v in lo],
'to': [round(float(v), 4) for v in hi],
'autouv': 0,
'color': colour,
'origin': [round(float(v), 4) for v in origin],
'rotation': [round(float(v), 4) for v in rotation],
'faces': faces or {f: {'uv': [0, 0, 16, 16], 'texture': None}
for f in ('north', 'east', 'south', 'west', 'up', 'down')},
'type': 'cube',
'uuid': new_uuid(),
}
def group(name, children, origin=(0, 0, 0)):
return {
'name': name,
'origin': [round(float(v), 4) for v in origin],
'rotation': [0, 0, 0],
'color': 0,
'uuid': new_uuid(),
'export': True,
'mirror_uv': False,
'isOpen': True,
'locked': False,
'visibility': True,
'autouv': 0,
'children': children,
}
def project(name, outliner, elements, textures=None):
"""A .bbmodel in the shape `Codecs.project.parse` reads.
`free` is the format because it is the only one that lets a cube hold a
rotation on all three axes at once; the java block format snaps to a single
axis in 22.5 degree steps and would quietly round every placement here.
"""
return {
'meta': {'format_version': '4.10', 'model_format': 'free', 'box_uv': False},
'name': name,
'model_identifier': '',
'visible_box': [4, 4, 0],
'variable_placeholders': '',
'variable_placeholder_buttons': [],
'unhandled_root_fields': {},
'resolution': {'width': 16, 'height': 16},
'elements': elements,
'outliner': outliner,
'textures': textures or [],
}

View File

@ -1,62 +0,0 @@
-- Live placement editing: save a file, watch it change in game.
--
-- Sourced on top of your own config with `-c luafile`, never with `-u`, so
-- nothing here sets an option or steals a mapping - it only adds the reload.
--
-- Saving any module JSON rebuilds the mod, copies it into whichever instance is
-- running, and tells that game to re-read the half of the jar the file belongs
-- to: `/reload` for module placement under `packs/`, F3+T for models and
-- textures under `assets/`. The build is async, so a save never blocks.
--
-- :Reload rebuild and reload now, without saving
-- :ReloadLog open the build log after a failure
-- :ReloadOff stop reloading on save (:ReloadOn to resume)
local REPO = '/home/themiro/code.dev/create-mekanism-modular'
local SCRIPT = REPO .. '/tools/live-reload.sh'
local running, enabled = false, true
local function reload(file)
if running then
vim.notify('[live] a reload is already in flight', vim.log.levels.WARN)
return
end
running = true
vim.notify('[live] building ' .. vim.fn.fnamemodify(file, ':t') .. ' ...')
local out = {}
local function grab(_, d)
for _, l in ipairs(d or {}) do if l ~= '' then table.insert(out, l) end end
end
vim.fn.jobstart({ SCRIPT, file }, {
stdout_buffered = true, stderr_buffered = true,
on_stdout = grab, on_stderr = grab,
on_exit = function(_, code)
running = false
local msg = '[live] ' .. table.concat(out, ' | ')
vim.notify(code == 0 and msg or (msg .. ' (:ReloadLog)'),
code == 0 and vim.log.levels.INFO or vim.log.levels.ERROR)
end,
})
end
-- Only the files that actually feed placement, so an unrelated save never
-- kicks off a build.
vim.api.nvim_create_autocmd('BufWritePost', {
group = vim.api.nvim_create_augroup('CmmodularLive', { clear = true }),
pattern = { REPO .. '/src/main/resources/packs/*.json',
REPO .. '/src/main/resources/assets/*.json' },
callback = function(a) if enabled then reload(a.match) end end,
})
vim.api.nvim_create_user_command('Reload', function()
reload(vim.api.nvim_buf_get_name(0))
end, { desc = 'rebuild, deploy, and reload in game' })
vim.api.nvim_create_user_command('ReloadLog', function()
vim.cmd('split /tmp/live-reload-build.log')
end, { desc = 'build log from the last live reload' })
vim.api.nvim_create_user_command('ReloadOff', function()
enabled = false; vim.notify('[live] reload-on-save off')
end, {})
vim.api.nvim_create_user_command('ReloadOn', function()
enabled = true; vim.notify('[live] reload-on-save on')
end, {})

View File

@ -1,125 +0,0 @@
#!/usr/bin/env bash
# Push a placement change into the running game.
#
# live-reload.sh <saved-file>
#
# The mod jar is deliberately NOT touched. NeoForge holds every mod jar open for
# the whole session, and `cp` rewrites a file in place - same inode - so
# overwriting the jar under a running game invalidates its view of its own
# archive. The symptom is not a crash but modules quietly going missing:
#
# could not find module cmmodular:armor/socket/arm_left substituting with empty module
#
# ...and every part built on them vanishing off the wearer, with the jar on disk
# still passing `unzip -t`. Only a restart clears it.
#
# So the module JSON goes into the world's own datapack folder instead, which is
# read fresh on every `/reload` and loads after the mod's built-in packs, so the
# copy there wins. Editing is live and nothing is held open.
#
# Models and textures under `assets/` are resourcepack, not datapack, and are
# still read from the jar - those need a restart. The script says so rather than
# pretending F3+T reached them.
set -uo pipefail
REPO=/home/themiro/code.dev/create-mekanism-modular
INSTANCES=/home/themiro/.var/app/org.prismlauncher.PrismLauncher/data/PrismLauncher/instances
export YDOTOOL_SOCKET=${YDOTOOL_SOCKET:-/run/user/1000/.ydotool_socket}
SAVED=${1:-}
PY=$REPO/tools/.venv/bin/python
MCWIN="Minecraft NeoForge"
PACK=cmmodular-live
say() { printf '%s\n' "$*"; }
# `pgrep -f java.*minecraft` also matches a Gradle daemon - the word turns up in
# a classpath - so the instance is identified by the Prism path in the cmdline
# and nothing else. Guessing wrong here means reloading an instance nobody is
# looking at, or claiming the game is up when it is not.
INSTANCE=""
for p in $(pgrep -x java 2>/dev/null); do
c=$(tr '\0' ' ' < /proc/$p/cmdline 2>/dev/null)
case "$c" in
*PrismLauncher/instances/*)
INSTANCE=${c#*PrismLauncher/instances/}
INSTANCE=${INSTANCE%%/*}
break ;;
esac
done
[ -z "$INSTANCE" ] && { say "game not running (no Prism instance in any java process)"; exit 0; }
MC="$INSTANCES/$INSTANCE/minecraft"
WORLD=$(ls -t "$MC/saves" 2>/dev/null | head -1)
[ -z "$WORLD" ] && { say "no world in $INSTANCE"; exit 1; }
case "$SAVED" in
*"/assets/"*)
say "NOTE: $(basename "$SAVED") is a resourcepack file (models/textures)."
say " Those load from the jar and cannot be hot-swapped - restart to see it."
exit 0 ;;
esac
# Validate before shipping: a malformed module is dropped silently by MIAPI and
# looks exactly like the part being mispositioned.
if [ -n "$SAVED" ] && ! "$PY" -c "import json,sys;json.load(open(sys.argv[1]))" "$SAVED" 2>/tmp/live-json.err; then
say "INVALID JSON - not deploying"; sed 's/^/ /' /tmp/live-json.err; exit 1
fi
DEST="$MC/saves/$WORLD/datapacks/$PACK"
FIRST=0; [ -d "$DEST" ] || FIRST=1 # a pack added mid-session needs enabling
mkdir -p "$DEST"
cat > "$DEST/pack.mcmeta" <<'META'
{
"pack": {
"description": "cmmodular live placement edits - overrides the jar's modules while iterating",
"pack_format": 48
}
}
META
rm -rf "$DEST/data"
for p in "$REPO"/src/main/resources/packs/*/data; do
cp -r "$p/." "$DEST/data/" 2>/dev/null || { mkdir -p "$DEST/data"; cp -r "$p/." "$DEST/data/"; }
done
say "staged -> saves/$WORLD/datapacks/$PACK"
wmctrl -a "$MCWIN" >/dev/null 2>&1 || { say "no game window; datapack staged"; exit 0; }
sleep 0.6
menu_open() {
grim /tmp/live-reload-probe.png 2>/dev/null || return 1
"$PY" - <<'EOF'
import sys
import numpy as np
from PIL import Image
a=np.array(Image.open('/tmp/live-reload-probe.png').convert('RGB'))
h,w,_=a.shape
b=a[h//6:h//2, w//3:2*w//3]
g=((abs(b[...,0].astype(int)-b[...,1])<12)&(abs(b[...,1].astype(int)-b[...,2])<12)
&(b[...,0]>110)&(b[...,0]<210)).mean()
sys.exit(0 if g>0.15 else 1)
EOF
}
# One Escape closes chat, closes the menu, or opens it from gameplay; after it
# the only ambiguity left is whether the menu is up, which is checked. Without
# this a stray open chat eats the T and the command posts as literal "t/reload".
ydotool key 1:1 1:0; sleep 0.7
if menu_open; then ydotool key 1:1 1:0; sleep 0.7; fi
# QWERTZ: ydotool speaks US keycodes, so the two punctuation marks these
# commands need are sent by position - '/' is shift+7, '-' is where US puts '/'.
slash() { ydotool key 42:1 8:1 8:0 42:0; sleep 0.2; }
dash() { ydotool key 53:1 53:0; sleep 0.2; }
ydotool key 20:1 20:0; sleep 0.7 # T -> chat
slash
if [ "$FIRST" = 1 ]; then
# A datapack that appeared after the world was loaded is present but not
# enabled; `/reload` alone will not pick it up. Enabling reloads too.
ydotool type -d 28 "datapack enable file"; slash
ydotool type -d 28 "cmmodular"; dash; ydotool type -d 28 "live"
sleep 0.4; ydotool key 28:1 28:0; sleep 2.0
say "enabled datapack $PACK (first run this session)"
else
ydotool type -d 28 "reload"; sleep 0.4
ydotool key 28:1 28:0; sleep 1.5
say "/reload sent - $(basename "${SAVED:-all modules}") live"
fi

View File

@ -1,38 +0,0 @@
#!/usr/bin/env bash
# Type a chat command into Minecraft on a QWERTZ keyboard.
#
# ydotool speaks evdev keycodes, which are *positional* and named for a US
# layout, so `ydotool type` writes whatever the user's layout has at those
# positions. On QWERTZ that swaps y and z, and '/' is shift+7 rather than a key
# of its own. Sending the wrong thing does not fail loudly - it posts as chat,
# which is how "/reload" once went out as "-reload" and "day" as "daz".
#
# mc-type.sh "time set day" types /<command> and presses enter
set -uo pipefail
export YDOTOOL_SOCKET=${YDOTOOL_SOCKET:-/run/user/1000/.ydotool_socket}
declare -A K=(
[a]=30 [b]=48 [c]=46 [d]=32 [e]=18 [f]=33 [g]=34 [h]=35 [i]=23
[j]=36 [k]=37 [l]=38 [m]=50 [n]=49 [o]=24 [p]=25 [q]=16 [r]=19
[s]=31 [t]=20 [u]=22 [v]=47 [w]=17 [x]=45
[y]=44 [z]=21 # QWERTZ: y and z sit on each other's keys
[0]=11 [1]=2 [2]=3 [3]=4 [4]=5 [5]=6 [6]=7 [7]=8 [8]=9 [9]=10
[' ']=57 [.]=52 [_]=12
)
ydotool key 42:0 54:0 # make sure no shift is stuck
sleep 0.2
ydotool key 20:1 20:0 # T -> open chat
sleep 0.9
ydotool key 42:1 8:1 8:0 42:0 # shift+7 -> '/'
sleep 0.3
ydotool key 42:0 54:0 # release shift again, explicitly
sleep 0.2
cmd="$1"
for (( i=0; i<${#cmd}; i++ )); do
ch="${cmd:$i:1}"
code="${K[$ch]:-}"
if [ -n "$code" ]; then ydotool key "$code:1" "$code:0"; sleep 0.04; fi
done
sleep 0.4
ydotool key 28:1 28:0 # enter

View File

@ -213,42 +213,25 @@ def decompose(m):
'scale': {'x': s[0], 'y': s[1], 'z': s[2]}}
def merge(parent, child, lossy=True):
"""MIAPI's `Transform.merge`, as MIAPI 2.3.8 actually has it.
def merge(parent, child, lossy=False):
"""MIAPI's `Transform.merge`.
Read out of the jar rather than assumed, because the assumption was wrong
and cost a set of gem placements. `Transform.merge(a, b)` builds both
matrices, computes `b.mul(a)` - so the accumulated transform applies first
and the new one after it, in the frame the accumulation left off in - and
then hands the product to `fromMatrix`. That last step is the important
one: it decomposes back into translation, Euler angles and per-axis scale,
so every merge drops whatever shear the product had. Armory's limb slots
are a rotation with a non-uniform scale, which is exactly where shear
appears, so this is not a corner case here - it is the case.
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.
`TransformMap.add` calls it as `merge(what is there, what is being added)`,
which is what makes a slot transform act *after* the plate's rather than
inside it. A number written as an offset in the model's own space lands
somewhere else entirely; `packs/armory/.../socket/*.json` are solved in the
frame this describes.
`lossy=False` gives the exact product instead, for seeing what the shear
would have done had MIAPI kept it.
`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.
# The only origins MIAPI will draw on a body. Verified against
# `ArmorModelManager$ModelPartProvider.modelParts` in miapi 2.3.8: the renderer
# walks this fixed list and matches models to each name. A transform naming
# anything else - or naming nothing at all - matches no part and is never drawn
# on the wearer; it is the inventory model. `Transform.repair` leaves a missing
# origin missing, it does not substitute one.
BODY_PARTS = ('head', 'hat', 'body',
'left_arm', 'right_arm', 'left_leg', 'right_leg')
PIVOTS = {
'head': (0.0, 0.0, 0.0),
'hat': (0.0, 0.0, 0.0),

View File

@ -1,45 +0,0 @@
#!/usr/bin/env bash
# Build, swap the jar under a stopped instance, relaunch, wait for the title screen.
#
# The jar is NOT hot-swappable: NeoForge holds every mod jar open for the whole
# session, so overwriting it in place under a running game leaves the archive
# valid on disk but invalid to the running JVM. The symptom is silent -
# "could not find module cmmodular:armor/socket/arm_left substituting with
# empty module" - and every part built on it vanishes off the wearer. Hence the
# kill/copy/relaunch, every time, with no shortcut.
set -uo pipefail
REPO=/home/themiro/code.dev/create-mekanism-modular
INST=/home/themiro/.var/app/org.prismlauncher.PrismLauncher/data/PrismLauncher/instances/Abdelpak-Delta-testing/minecraft
JAR=cmmodular-1.4.0.jar
cd "$REPO" || exit 1
# `gradlew build` fails on this toolchain: the :test task cannot be created
# ("Type T not present"). `jar` is the only target we need anyway.
./gradlew jar -q --console=plain 2>&1 | grep -vi 'warning\|restricted'
[ -f "build/libs/$JAR" ] || { echo "build produced no jar"; exit 1; }
pid=$(for p in $(pgrep -x java 2>/dev/null); do
tr '\0' ' ' < /proc/$p/cmdline 2>/dev/null \
| grep -q 'PrismLauncher/instances/Abdelpak-Delta-testing' && echo "$p"; done | head -1)
if [ -n "$pid" ]; then
kill "$pid"
for _ in $(seq 1 40); do kill -0 "$pid" 2>/dev/null || break; sleep 0.5; done
kill -0 "$pid" 2>/dev/null && kill -9 "$pid"
sleep 2
fi
cp "build/libs/$JAR" "$INST/mods/$JAR" || exit 1
unzip -t "$INST/mods/$JAR" >/dev/null || { echo "deployed jar is corrupt"; exit 1; }
: > "$INST/logs/latest.log" 2>/dev/null
cd /tmp && setsid flatpak run org.prismlauncher.PrismLauncher \
--launch Abdelpak-Delta-testing >/dev/null 2>&1 < /dev/null &
for _ in $(seq 1 120); do
grep -qi 'Sound engine started\|OpenAL initialized' "$INST/logs/latest.log" 2>/dev/null && {
echo "ready (title screen)"
grep -i 'could not find module\|substituting with empty' "$INST/logs/latest.log" | tail -5
exit 0; }
sleep 5
done
echo "timed out waiting for the title screen"

View File

@ -1,106 +0,0 @@
#!/usr/bin/env python3
"""Solve gem-slot translations from where you want the gem to actually appear.
Placement cannot be dialled in by hand because the gem model is `item/generated`:
a 2x2 sprite block sitting at the dead centre (8,8) of a 16x16 sheet, so its
geometry is 8px from the model origin on every axis. MIAPI's toMatrix is
T * Rx * Ry * Rz * S with no centring offset, so the rotation pivots on that
origin and flings the gem up to ~12px away. Translation is therefore NOT the
gem's position - it is whatever cancels that throw.
world = pivot + flip( T + R*S*c ), flip = negate x,y, c = (8,8,8)
`flip` is the block-model(+y up) -> ModelPart(+y down) conversion. Inverting:
T = flip(D) - R*S*c
with D the desired gem centre as an offset from the body part's pivot.
Edit TARGET below (in world coordinates, +x = wearer's LEFT, +y = DOWN,
-z = forward) and run. Rotation and scale are read from the JSON and preserved.
"""
import math, json, collections, os
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE = os.path.join(REPO, "src/main/resources/packs/armory/data/cmmodular"
"/miapi/modules")
# vanilla HumanoidModel pivots
PIVOT = {"arm_left": (5, 2, 0), "arm_right": (-5, 2, 0),
"leg_left": (1.9, 12, 0), "leg_right": (-1.9, 12, 0),
"life_support_socket": (0, 0, 0), "helmet_socket": (0, 0, 0)}
# Which datapack file holds each slot.
FILE = {n: f"armor/socket/{n}.json" for n in
("arm_left", "arm_right", "leg_left", "leg_right")}
FILE["life_support_socket"] = "armor/space/life_support_socket.json"
FILE["helmet_socket"] = "armor/space/helmet_socket.json"
# The helmet gem is the one slot that does NOT go through the z180 flip. It
# chains through the hat slot instead, which scales by 1.25 and lifts by
# -3.925 to sit the helmet on the skull rather than the neck:
# world = (T + R*S*c) * 1.25 + (0, -3.925, 0)
# Solving it with the flip inverts y and drops the gem past the chin to the
# floor. Verified against two in-game observations: T_y -2.0 rendered just
# under the chin (predicted +3.58) and T_y +11.5 at floor height (+20.45).
HAT_SCALE, HAT_LIFT = 1.25, -3.925
NO_FLIP = {"helmet_socket"}
# Desired gem centre, in WORLD model pixels. Reference geometry:
# arms x +-3..7 (surface at +-7), shoulder y 2, hand y 14
# legs x 0..+-4 (surface at +-2), hip y 12, knee y 18, foot y 24
# front face z -2; -z is forward
TARGET = {
"arm_left": ( 8.30, 2.00, 0.00), # outer pauldron face, jutting ~1.3px proud
"arm_right": (-8.30, 2.00, 0.00),
"leg_left": ( 1.90, 13.00, -2.50), # just under the knee
"leg_right": (-1.90, 13.00, -2.50),
# pack occupies x +-2.80, y -1.36..5.91, z 2.00..4.00
"life_support_socket": ( 0.00, 2.27, 4.50), # centre of the rear face
# dome is +-4.25*1.25 about the lift: y -9.24..+1.39, front face z -5.31
"helmet_socket": ( 0.00, -9.00, -5.80), # upper edge of the front face
}
d = math.radians
def rx(a): c, s = math.cos(a), math.sin(a); return [[1,0,0],[0,c,-s],[0,s,c]]
def ry(a): c, s = math.cos(a), math.sin(a); return [[c,0,s],[0,1,0],[-s,0,c]]
def rz(a): c, s = math.cos(a), math.sin(a); return [[c,-s,0],[s,c,0],[0,0,1]]
def mul(A, B): return [[sum(A[i][k]*B[k][j] for k in range(3)) for j in range(3)] for i in range(3)]
def ap(M, v): return [sum(M[i][k]*v[k] for k in range(3)) for i in range(3)]
def rot(r): return mul(rx(d(r["x"])), mul(ry(d(r["y"])), rz(d(r["z"]))))
def flip(v): return [-v[0], -v[1], v[2]]
C = [8.0, 8.0, 8.0]
def main():
for name, world in TARGET.items():
path = os.path.join(BASE, FILE[name])
doc = json.load(open(path), object_pairs_hook=collections.OrderedDict)
t = doc["data"]["replace"]["slots"]["gem"]["transform"]
piv = PIVOT[name]
D = [world[i] - piv[i] for i in range(3)]
S = [t["scale"][k] for k in "xyz"]
throw = ap(rot(t["rotation"]), [C[i]*S[i] for i in range(3)])
if name in NO_FLIP:
lift = [0.0, HAT_LIFT, 0.0]
T = [round((D[i] - lift[i]) / HAT_SCALE - throw[i], 4) + 0.0 for i in range(3)]
else:
T = [round(flip(D)[i] - throw[i], 4) + 0.0 for i in range(3)]
old = dict(t["translation"])
for i, k in enumerate("xyz"):
t["translation"][k] = T[i]
open(path, "w").write(json.dumps(doc, indent=2) + "\n")
if name in NO_FLIP:
landed = [piv[i] + (T[j] + throw[j]) * HAT_SCALE + [0.0, HAT_LIFT, 0.0][j]
for i, j in enumerate(range(3))]
else:
landed = [piv[i] + flip([T[j] + throw[j] for j in range(3)])[i] for i in range(3)]
assert all(abs(landed[i] - world[i]) < 1e-3 for i in range(3)), f"{name} did not solve"
print(f"{name:9} T {old['x']:+8.3f},{old['y']:+8.3f},{old['z']:+8.3f}"
f" -> {T[0]:+8.3f},{T[1]:+8.3f},{T[2]:+8.3f}"
f" world ({landed[0]:+6.2f},{landed[1]:+6.2f},{landed[2]:+6.2f})")
if __name__ == "__main__":
main()