""" math3d.py Pure numpy / scipy replacement for Blender's mathutils. Quaternion convention throughout: (w, x, y, z) Matrix convention: row-major, right-multiplied (Numpy default) """ from __future__ import annotations import numpy as np from scipy.spatial.transform import Rotation # --------------------------------------------------------------------------- # Quaternion helpers (w, x, y, z) # --------------------------------------------------------------------------- def quat_identity() -> np.ndarray: return np.array([1.0, 0.0, 0.0, 0.0]) def quat_normalize(q: np.ndarray) -> np.ndarray: n = np.linalg.norm(q) return q / n if n > 1e-12 else quat_identity() def quat_conjugate(q: np.ndarray) -> np.ndarray: """Conjugate == inverse for unit quaternion.""" return np.array([q[0], -q[1], -q[2], -q[3]]) def quat_mul(q1: np.ndarray, q2: np.ndarray) -> np.ndarray: """Quaternion multiplication (Blender @ operator).""" w1, x1, y1, z1 = q1 w2, x2, y2, z2 = q2 return np.array([ w1*w2 - x1*x2 - y1*y2 - z1*z2, w1*x2 + x1*w2 + y1*z2 - z1*y2, w1*y2 - x1*z2 + y1*w2 + z1*x2, w1*z2 + x1*y2 - y1*x2 + z1*w2, ]) def quat_rotation_difference(q1: np.ndarray, q2: np.ndarray) -> np.ndarray: """ Rotation that takes q1 to q2. r such that q1 @ r == q2 r = conj(q1) @ q2 Matches Blender's Quaternion.rotation_difference() """ return quat_normalize(quat_mul(quat_conjugate(q1), q2)) def quat_dot(q1: np.ndarray, q2: np.ndarray) -> float: """Dot product of two quaternions (used for scale retargeting).""" return float(np.dot(q1, q2)) def quat_to_matrix4(q: np.ndarray) -> np.ndarray: """Unit quaternion (w,x,y,z) → 4×4 rotation matrix.""" w, x, y, z = q m = np.array([ [1 - 2*(y*y + z*z), 2*(x*y - z*w), 2*(x*z + y*w), 0], [ 2*(x*y + z*w), 1 - 2*(x*x + z*z), 2*(y*z - x*w), 0], [ 2*(x*z - y*w), 2*(y*z + x*w), 1 - 2*(x*x + y*y), 0], [ 0, 0, 0, 1], ], dtype=float) return m def matrix4_to_quat(m: np.ndarray) -> np.ndarray: """4×4 matrix → unit quaternion (w,x,y,z).""" r = Rotation.from_matrix(m[:3, :3]) x, y, z, w = r.as_quat() # scipy uses (x,y,z,w) q = np.array([w, x, y, z]) # Ensure positive w to match Blender convention if q[0] < 0: q = -q return quat_normalize(q) # --------------------------------------------------------------------------- # Euler ↔ Quaternion # --------------------------------------------------------------------------- def euler_to_quat(rx: float, ry: float, rz: float, order: str = "XYZ") -> np.ndarray: """Euler angles (radians) to quaternion (w,x,y,z).""" r = Rotation.from_euler(order, [rx, ry, rz]) x, y, z, w = r.as_quat() return quat_normalize(np.array([w, x, y, z])) def quat_to_euler(q: np.ndarray, order: str = "XYZ") -> np.ndarray: """Quaternion (w,x,y,z) to Euler angles (radians).""" w, x, y, z = q r = Rotation.from_quat([x, y, z, w]) return r.as_euler(order) # --------------------------------------------------------------------------- # Matrix constructors # --------------------------------------------------------------------------- def translation_matrix(v) -> np.ndarray: m = np.eye(4) m[0, 3] = v[0] m[1, 3] = v[1] m[2, 3] = v[2] return m def scale_matrix(s) -> np.ndarray: m = np.eye(4) m[0, 0] = s[0] m[1, 1] = s[1] m[2, 2] = s[2] return m def trs_to_matrix4(t, r_quat, s) -> np.ndarray: """Combine translation, rotation (w,x,y,z quat), scale into 4×4.""" T = translation_matrix(t) R = quat_to_matrix4(r_quat) S = scale_matrix(s) return T @ R @ S def matrix4_to_trs(m: np.ndarray): """Decompose 4×4 into (translation[3], rotation_quat[4], scale[3]).""" t = m[:3, 3].copy() sx = np.linalg.norm(m[:3, 0]) sy = np.linalg.norm(m[:3, 1]) sz = np.linalg.norm(m[:3, 2]) s = np.array([sx, sy, sz]) rot_m = m[:3, :3].copy() if sx > 1e-12: rot_m[:, 0] /= sx if sy > 1e-12: rot_m[:, 1] /= sy if sz > 1e-12: rot_m[:, 2] /= sz r = Rotation.from_matrix(rot_m) x, y, z, w = r.as_quat() q = np.array([w, x, y, z]) if q[0] < 0: q = -q return t, quat_normalize(q), s # --------------------------------------------------------------------------- # Vector helpers # --------------------------------------------------------------------------- def vec3(x=0.0, y=0.0, z=0.0) -> np.ndarray: return np.array([x, y, z], dtype=float) def get_point_on_vector(initial_pt: np.ndarray, terminal_pt: np.ndarray, distance: float) -> np.ndarray: """ Point at 'distance' from initial_pt along (initial_pt → terminal_pt). Matches Blender's get_point_on_vector helper in KeeMapBoneOperators. """ n = initial_pt - terminal_pt norm = np.linalg.norm(n) if norm < 1e-12: return initial_pt.copy() n = n / norm return initial_pt - distance * n def apply_rotation_matrix4(m: np.ndarray, v: np.ndarray) -> np.ndarray: """Apply only the rotation part of a 4×4 matrix to a 3-vector.""" return m[:3, :3] @ v