Source code for dazpy.math3

"""Pure-Python 3D math for DAZ Studio workflows.

No external dependencies.  Provides :class:`Vec3`, :class:`Quat`, and
:class:`BoundingBox` with direct support for the dict formats returned by the
dazpy API.

Typical usage::

    from dazpy.math3 import Vec3, Quat, BoundingBox
    # or
    from dazpy import Vec3, Quat, BoundingBox

    # Convert API dicts to math objects
    pos  = Vec3.from_dict(node.position)       # {"x":...,"y":...,"z":...}
    rot  = Quat.from_dict(bone.local_rotation) # {"x":...,"y":...,"z":...,"w":...}
    bbox = BoundingBox.from_dict(node.bounding_box())

    # Slerp between two bone orientations
    a   = Quat.from_euler(*bone_a.local_euler, order="XYZ")
    b   = Quat.from_euler(*bone_b.local_euler, order="XYZ")
    mid = a.slerp(b, 0.5)
    x, y, z = mid.to_euler(order="XYZ")
    bone_a.set_local_rotation(x, y, z)
"""

from __future__ import annotations

import math


# ── Vec3 ──────────────────────────────────────────────────────────────────────

[docs] class Vec3: """Immutable 3-component vector. All arithmetic operators return new :class:`Vec3` instances. Args: x: X component. y: Y component. z: Z component. """ __slots__ = ("x", "y", "z") def __init__(self, x: float, y: float, z: float) -> None: object.__setattr__(self, "x", float(x)) object.__setattr__(self, "y", float(y)) object.__setattr__(self, "z", float(z)) def __setattr__(self, name, value): raise AttributeError("Vec3 is immutable") # ── construction ──────────────────────────────────────────────────────────
[docs] @classmethod def from_dict(cls, d: dict) -> "Vec3": """Create from an ``{"x", "y", "z"}`` dict (e.g. from :attr:`~dazpy.DazNode.position`).""" return cls(d["x"], d["y"], d["z"])
[docs] @classmethod def from_list(cls, v: list[float]) -> "Vec3": """Create from a ``[x, y, z]`` list (e.g. a vertex from :meth:`~dazpy.DazGeometry.vertex_positions_all`).""" return cls(v[0], v[1], v[2])
[docs] @classmethod def zero(cls) -> "Vec3": """Return the zero vector ``(0, 0, 0)``.""" return cls(0.0, 0.0, 0.0)
# ── serialisation ─────────────────────────────────────────────────────────
[docs] def to_dict(self) -> dict: """Return ``{"x": float, "y": float, "z": float}``.""" return {"x": self.x, "y": self.y, "z": self.z}
[docs] def to_list(self) -> list[float]: """Return ``[x, y, z]``.""" return [self.x, self.y, self.z]
# ── arithmetic ──────────────────────────────────────────────────────────── def __add__(self, other: "Vec3") -> "Vec3": return Vec3(self.x + other.x, self.y + other.y, self.z + other.z) def __sub__(self, other: "Vec3") -> "Vec3": return Vec3(self.x - other.x, self.y - other.y, self.z - other.z) def __mul__(self, scalar: float) -> "Vec3": return Vec3(self.x * scalar, self.y * scalar, self.z * scalar) def __rmul__(self, scalar: float) -> "Vec3": return self.__mul__(scalar) def __truediv__(self, scalar: float) -> "Vec3": return Vec3(self.x / scalar, self.y / scalar, self.z / scalar) def __neg__(self) -> "Vec3": return Vec3(-self.x, -self.y, -self.z) def __eq__(self, other: object) -> bool: if not isinstance(other, Vec3): return NotImplemented return self.x == other.x and self.y == other.y and self.z == other.z def __repr__(self) -> str: return f"Vec3({self.x:.4g}, {self.y:.4g}, {self.z:.4g})" # ── geometry ──────────────────────────────────────────────────────────────
[docs] def dot(self, other: "Vec3") -> float: """Dot product.""" return self.x * other.x + self.y * other.y + self.z * other.z
[docs] def cross(self, other: "Vec3") -> "Vec3": """Cross product (right-hand rule).""" return Vec3( self.y * other.z - self.z * other.y, self.z * other.x - self.x * other.z, self.x * other.y - self.y * other.x, )
[docs] def length_sq(self) -> float: """Squared length (avoids a sqrt).""" return self.x * self.x + self.y * self.y + self.z * self.z
[docs] def length(self) -> float: """Euclidean length.""" return math.sqrt(self.length_sq())
[docs] def normalize(self) -> "Vec3": """Return a unit-length copy. Returns the zero vector unchanged.""" mag = self.length() if mag < 1e-10: return Vec3(0.0, 0.0, 0.0) return self / mag
[docs] def distance_sq(self, other: "Vec3") -> float: """Squared distance to *other*.""" return (self - other).length_sq()
[docs] def distance(self, other: "Vec3") -> float: """Euclidean distance to *other*.""" return math.sqrt(self.distance_sq(other))
[docs] def lerp(self, other: "Vec3", t: float) -> "Vec3": """Linearly interpolate toward *other*. ``t=0`` → self, ``t=1`` → other.""" return Vec3( self.x + (other.x - self.x) * t, self.y + (other.y - self.y) * t, self.z + (other.z - self.z) * t, )
[docs] def reflect(self, normal: "Vec3") -> "Vec3": """Reflect this vector about *normal* (normal must be unit length).""" return self - normal * (2.0 * self.dot(normal))
# ── Quat ────────────────────────────────────────────────────────────────────── def _euler_to_quat(hx: float, hy: float, hz: float, order: str) -> tuple: """Return (x, y, z, w) given half-angles in radians and a rotation order.""" cx, sx = math.cos(hx), math.sin(hx) cy, sy = math.cos(hy), math.sin(hy) cz, sz = math.cos(hz), math.sin(hz) # Formulae derived from composing per-axis quaternions: # Q_order = Q_first * Q_second * Q_third # where Q_X=(sx,0,0,cx), Q_Y=(0,sy,0,cy), Q_Z=(0,0,sz,cz). if order == "XYZ": return (sx*cy*cz + cx*sy*sz, cx*sy*cz - sx*cy*sz, cx*cy*sz + sx*sy*cz, cx*cy*cz - sx*sy*sz) if order == "XZY": return (sx*cy*cz - cx*sy*sz, cx*sy*cz - sx*cy*sz, cx*cy*sz + sx*sy*cz, cx*cy*cz + sx*sy*sz) if order == "YXZ": return (sx*cy*cz + cx*sy*sz, cx*sy*cz - sx*cy*sz, cx*cy*sz - sx*sy*cz, cx*cy*cz + sx*sy*sz) if order == "YZX": return (sx*cy*cz + cx*sy*sz, cx*sy*cz + sx*cy*sz, cx*cy*sz - sx*sy*cz, cx*cy*cz - sx*sy*sz) if order == "ZXY": return (sx*cy*cz - cx*sy*sz, cx*sy*cz + sx*cy*sz, cx*cy*sz + sx*sy*cz, cx*cy*cz - sx*sy*sz) if order == "ZYX": return (sx*cy*cz - cx*sy*sz, cx*sy*cz + sx*cy*sz, cx*cy*sz - sx*sy*cz, cx*cy*cz + sx*sy*sz) raise ValueError(f"Unknown rotation order: {order!r}. Expected one of XYZ XZY YXZ YZX ZXY ZYX.") def _quat_to_euler(m: list, order: str) -> tuple: """Extract Euler angles (degrees) from a 3×3 rotation matrix and order.""" # Matrix rows/columns: m[row][col] SAFE = 1.0 - 1e-6 # threshold for gimbal-lock detection if order == "XYZ": # m[0][2] = sin(y) sy = max(-1.0, min(1.0, m[0][2])) y = math.degrees(math.asin(sy)) if abs(sy) < SAFE: x = math.degrees(math.atan2(-m[1][2], m[2][2])) z = math.degrees(math.atan2(-m[0][1], m[0][0])) else: x = math.degrees(math.atan2(m[2][1], m[1][1])) z = 0.0 return x, y, z if order == "XZY": # m[0][1] = -sin(z) sz = max(-1.0, min(1.0, -m[0][1])) z = math.degrees(math.asin(sz)) if abs(sz) < SAFE: x = math.degrees(math.atan2(m[2][1], m[1][1])) y = math.degrees(math.atan2(m[0][2], m[0][0])) else: x = math.degrees(math.atan2(-m[1][2], m[2][2])) y = 0.0 return x, y, z if order == "YXZ": # m[1][2] = -sin(x) sx = max(-1.0, min(1.0, -m[1][2])) x = math.degrees(math.asin(sx)) if abs(sx) < SAFE: y = math.degrees(math.atan2(m[0][2], m[2][2])) z = math.degrees(math.atan2(m[1][0], m[1][1])) else: y = math.degrees(math.atan2(-m[0][1], m[0][0])) z = 0.0 return x, y, z if order == "YZX": # m[1][0] = sin(z) sz = max(-1.0, min(1.0, m[1][0])) z = math.degrees(math.asin(sz)) if abs(sz) < SAFE: x = math.degrees(math.atan2(-m[1][2], m[1][1])) y = math.degrees(math.atan2(-m[2][0], m[0][0])) else: x = math.degrees(math.atan2(m[0][1], m[2][1])) y = 0.0 return x, y, z if order == "ZXY": # m[2][1] = sin(x) sx = max(-1.0, min(1.0, m[2][1])) x = math.degrees(math.asin(sx)) if abs(sx) < SAFE: y = math.degrees(math.atan2(-m[2][0], m[2][2])) z = math.degrees(math.atan2(-m[0][1], m[1][1])) else: y = math.degrees(math.atan2(m[0][2], m[0][0])) z = 0.0 return x, y, z if order == "ZYX": # m[2][0] = -sin(y) sy = max(-1.0, min(1.0, -m[2][0])) y = math.degrees(math.asin(sy)) if abs(sy) < SAFE: x = math.degrees(math.atan2(m[2][1], m[2][2])) z = math.degrees(math.atan2(m[1][0], m[0][0])) else: x = math.degrees(math.atan2(-m[0][1], m[1][1])) z = 0.0 return x, y, z raise ValueError(f"Unknown rotation order: {order!r}. Expected one of XYZ XZY YXZ YZX ZXY ZYX.")
[docs] class Quat: """Unit quaternion representing a 3D rotation. Stored as ``(x, y, z, w)`` — imaginary components first, scalar last. This matches the dict format returned by the dazpy API (``DazNode.rotation``, ``DazBone.local_rotation``). All methods return new :class:`Quat` instances. The object is immutable. Args: x: i component. y: j component. z: k component. w: Scalar component. """ __slots__ = ("x", "y", "z", "w") def __init__(self, x: float, y: float, z: float, w: float) -> None: object.__setattr__(self, "x", float(x)) object.__setattr__(self, "y", float(y)) object.__setattr__(self, "z", float(z)) object.__setattr__(self, "w", float(w)) def __setattr__(self, name, value): raise AttributeError("Quat is immutable") # ── construction ──────────────────────────────────────────────────────────
[docs] @classmethod def identity(cls) -> "Quat": """Return the identity quaternion ``(0, 0, 0, 1)`` (no rotation).""" return cls(0.0, 0.0, 0.0, 1.0)
[docs] @classmethod def from_dict(cls, d: dict) -> "Quat": """Create from an ``{"x", "y", "z", "w"}`` dict (e.g. from :attr:`~dazpy.DazNode.rotation`).""" return cls(d["x"], d["y"], d["z"], d["w"])
[docs] @classmethod def from_euler(cls, x: float, y: float, z: float, order: str = "XYZ") -> "Quat": """Create from Euler angles in degrees using intrinsic rotations. Intrinsic means each rotation is applied in the frame left by the previous rotation — the same convention DAZ Studio uses for bone rotation channels. Args: x: Rotation around the X axis in degrees. y: Rotation around the Y axis in degrees. z: Rotation around the Z axis in degrees. order: Application order — one of ``XYZ``, ``XZY``, ``YXZ``, ``YZX``, ``ZXY``, ``ZYX``. Matches :attr:`~dazpy.DazBone.rotation_order`. Returns: A unit quaternion. """ hx = math.radians(x) * 0.5 hy = math.radians(y) * 0.5 hz = math.radians(z) * 0.5 qx, qy, qz, qw = _euler_to_quat(hx, hy, hz, order) return cls(qx, qy, qz, qw)
[docs] @classmethod def from_axis_angle(cls, axis: "Vec3", angle_deg: float) -> "Quat": """Create from an axis and angle in degrees. Args: axis: Rotation axis (need not be unit length — normalised internally). angle_deg: Rotation magnitude in degrees. Returns: A unit quaternion. """ n = axis.normalize() half = math.radians(angle_deg) * 0.5 s = math.sin(half) return cls(n.x * s, n.y * s, n.z * s, math.cos(half))
# ── serialisation ─────────────────────────────────────────────────────────
[docs] def to_dict(self) -> dict: """Return ``{"x": float, "y": float, "z": float, "w": float}``.""" return {"x": self.x, "y": self.y, "z": self.z, "w": self.w}
# ── conversion ────────────────────────────────────────────────────────────
[docs] def to_matrix(self) -> list[list[float]]: """Return the equivalent 3×3 rotation matrix (row-major, right-handed).""" x, y, z, w = self.x, self.y, self.z, self.w x2 = 2.0 * x * x; y2 = 2.0 * y * y; z2 = 2.0 * z * z xy = 2.0 * x * y; xz = 2.0 * x * z; yz = 2.0 * y * z wx = 2.0 * w * x; wy = 2.0 * w * y; wz = 2.0 * w * z return [ [1.0-y2-z2, xy-wz, xz+wy ], [xy+wz, 1.0-x2-z2, yz-wx ], [xz-wy, yz+wx, 1.0-x2-y2], ]
[docs] def to_euler(self, order: str = "XYZ") -> tuple[float, float, float]: """Extract Euler angles in degrees. The inverse of :meth:`from_euler` for the same *order*. Gimbal-lock configurations (singularities) are handled by setting the last rotation in the order to zero. Args: order: One of ``XYZ``, ``XZY``, ``YXZ``, ``YZX``, ``ZXY``, ``ZYX``. Returns: ``(x, y, z)`` in degrees. """ return _quat_to_euler(self.to_matrix(), order)
# ── arithmetic ────────────────────────────────────────────────────────────
[docs] def multiply(self, other: "Quat") -> "Quat": """Hamilton product ``self * other``. Composing rotations: ``a.multiply(b)`` applies *a* first, then *b* in the rotated frame (intrinsic), or equivalently *b* first then *a* in the world frame (extrinsic). """ ax, ay, az, aw = self.x, self.y, self.z, self.w bx, by, bz, bw = other.x, other.y, other.z, other.w return Quat( aw*bx + ax*bw + ay*bz - az*by, aw*by - ax*bz + ay*bw + az*bx, aw*bz + ax*by - ay*bx + az*bw, aw*bw - ax*bx - ay*by - az*bz, )
[docs] def conjugate(self) -> "Quat": """Return the conjugate ``(-x, -y, -z, w)``.""" return Quat(-self.x, -self.y, -self.z, self.w)
[docs] def length(self) -> float: """Quaternion norm.""" return math.sqrt(self.x**2 + self.y**2 + self.z**2 + self.w**2)
[docs] def normalize(self) -> "Quat": """Return a unit-length copy.""" mag = self.length() if mag < 1e-10: return Quat.identity() return Quat(self.x/mag, self.y/mag, self.z/mag, self.w/mag)
[docs] def dot(self, other: "Quat") -> float: """4D dot product (used internally by :meth:`slerp`).""" return self.x*other.x + self.y*other.y + self.z*other.z + self.w*other.w
# ── rotation ──────────────────────────────────────────────────────────────
[docs] def rotate(self, v: "Vec3") -> "Vec3": """Rotate vector *v* by this quaternion. Uses the sandwich product ``q * v * q⁻¹``. """ # Equivalent to: p' = q * (0,v) * q* (pure quaternion sandwich) qx, qy, qz, qw = self.x, self.y, self.z, self.w vx, vy, vz = v.x, v.y, v.z # t = 2 * cross(q.xyz, v) tx = 2.0 * (qy*vz - qz*vy) ty = 2.0 * (qz*vx - qx*vz) tz = 2.0 * (qx*vy - qy*vx) return Vec3( vx + qw*tx + qy*tz - qz*ty, vy + qw*ty + qz*tx - qx*tz, vz + qw*tz + qx*ty - qy*tx, )
# ── interpolation ─────────────────────────────────────────────────────────
[docs] def slerp(self, other: "Quat", t: float) -> "Quat": """Spherical linear interpolation. Always takes the shortest arc between the two orientations. Args: other: Target rotation (``t=1.0`` produces an exact copy of *other*). t: Blend factor in [0, 1]. Returns: A unit quaternion at the interpolated orientation. """ d = self.dot(other) # Ensure shortest-path by negating one operand if they're on opposite hemispheres. if d < 0.0: other = Quat(-other.x, -other.y, -other.z, -other.w) d = -d d = min(1.0, d) # For nearly identical quaternions fall back to normalised linear interpolation # to avoid division by sin(θ) ≈ 0. if d > 0.9995: result = Quat( self.x + t * (other.x - self.x), self.y + t * (other.y - self.y), self.z + t * (other.z - self.z), self.w + t * (other.w - self.w), ) return result.normalize() theta_0 = math.acos(d) sin_theta_0 = math.sin(theta_0) theta = theta_0 * t sin_theta = math.sin(theta) cos_theta = math.cos(theta) s0 = cos_theta - d * sin_theta / sin_theta_0 s1 = sin_theta / sin_theta_0 return Quat( s0 * self.x + s1 * other.x, s0 * self.y + s1 * other.y, s0 * self.z + s1 * other.z, s0 * self.w + s1 * other.w, )
def __eq__(self, other: object) -> bool: if not isinstance(other, Quat): return NotImplemented return (self.x == other.x and self.y == other.y and self.z == other.z and self.w == other.w) def __repr__(self) -> str: return f"Quat({self.x:.4g}, {self.y:.4g}, {self.z:.4g}, {self.w:.4g})"
# ── BoundingBox ───────────────────────────────────────────────────────────────
[docs] class BoundingBox: """Axis-aligned bounding box. Args: min: Corner with the smallest coordinates. max: Corner with the largest coordinates. """ __slots__ = ("min", "max") def __init__(self, min: "Vec3", max: "Vec3") -> None: object.__setattr__(self, "min", min) object.__setattr__(self, "max", max) def __setattr__(self, name, value): raise AttributeError("BoundingBox is immutable") # ── construction ──────────────────────────────────────────────────────────
[docs] @classmethod def from_dict(cls, d: dict) -> "BoundingBox": """Create from the dict returned by :meth:`~dazpy.DazNode.bounding_box`. Expected shape: ``{"min": {"x":..., "y":..., "z":...}, "max": {...}}``. """ return cls(Vec3.from_dict(d["min"]), Vec3.from_dict(d["max"]))
[docs] @classmethod def from_points(cls, points: list["Vec3"]) -> "BoundingBox": """Compute the tight bounding box around a list of :class:`Vec3` points.""" if not points: raise ValueError("from_points requires at least one point") xs = [p.x for p in points] ys = [p.y for p in points] zs = [p.z for p in points] return cls(Vec3(min(xs), min(ys), min(zs)), Vec3(max(xs), max(ys), max(zs)))
# ── serialisation ─────────────────────────────────────────────────────────
[docs] def to_dict(self) -> dict: """Return ``{"min": {...}, "max": {...}}``.""" return {"min": self.min.to_dict(), "max": self.max.to_dict()}
# ── geometry ────────────────────────────────────────────────────────────── @property def center(self) -> "Vec3": """Geometric center of the box.""" return Vec3( (self.min.x + self.max.x) * 0.5, (self.min.y + self.max.y) * 0.5, (self.min.z + self.max.z) * 0.5, ) @property def size(self) -> "Vec3": """Width, height, and depth as a :class:`Vec3`.""" return self.max - self.min @property def volume(self) -> float: """Signed volume (positive for valid boxes where min ≤ max on all axes).""" s = self.size return s.x * s.y * s.z
[docs] def contains(self, point: "Vec3") -> bool: """Return ``True`` if *point* is inside or on the surface of the box.""" return (self.min.x <= point.x <= self.max.x and self.min.y <= point.y <= self.max.y and self.min.z <= point.z <= self.max.z)
[docs] def overlaps(self, other: "BoundingBox") -> bool: """Return ``True`` if this box and *other* share any volume.""" return (self.min.x <= other.max.x and self.max.x >= other.min.x and self.min.y <= other.max.y and self.max.y >= other.min.y and self.min.z <= other.max.z and self.max.z >= other.min.z)
[docs] def expand(self, amount: float) -> "BoundingBox": """Return a box uniformly expanded by *amount* on all sides.""" v = Vec3(amount, amount, amount) return BoundingBox(self.min - v, self.max + v)
[docs] def union(self, other: "BoundingBox") -> "BoundingBox": """Return the smallest box that contains both this box and *other*.""" return BoundingBox( Vec3(min(self.min.x, other.min.x), min(self.min.y, other.min.y), min(self.min.z, other.min.z)), Vec3(max(self.max.x, other.max.x), max(self.max.y, other.max.y), max(self.max.z, other.max.z)), )
def __repr__(self) -> str: return f"BoundingBox(min={self.min}, max={self.max})"