create-mekanism-modular/tools/bb_import.py

147 lines
6.2 KiB
Python

#!/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))