"""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 [], }