264 lines
9.8 KiB
Python
264 lines
9.8 KiB
Python
"""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 [],
|
|
}
|