#!/usr/bin/env python3 """Read a Blockbench project back into the module JSON. bb_import.py [--write] Only cubes tagged `[edit:#]` 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))