# Copyright (c) 2026, pinkfish
#
# Licensed under the BSD 2-Clause License. See the LICENSE file in the project
# root for the full license text.
# SPDX-License-Identifier: BSD-2-Clause
# LibFile: bosl2/transforms.py
# Pure-Python port of the affine-matrix machinery from BOSL2's
# transforms.scad (reorient()/apply(), plus the rot_from_to()/
# axis_angle_matrix() helpers they build on) and polar_to_xy() from
# coords.scad. No osuse()/BOSL2 runtime dependency.
#
# The point-list transform operations themselves (move/rot/right/left/
# back/forward/mirror/yflip) are NOT here -- they are methods on the
# Path object (bosl2/paths.py) and on Bosl2Solid (bosl2/shapes3d.py).
# What remains is the matrix side used for cuboid reorientation and
# anchoring, which feeds PythonSCAD's .multmatrix().
#
# FileSummary: Affine-matrix reorient/apply and polar_to_xy (BOSL2 transforms.scad, coords.scad).
# FileGroup: BOSL2
import math
import numpy as np
[docs]
def polar_to_xy(radius: float, angle: float) -> list[float]:
"""Convert polar coordinates (radius, angle in degrees) to a 2-D [x, y] point."""
rad = math.radians(angle)
return [radius * math.cos(rad), radius * math.sin(rad)]
def _unit(v) -> np.ndarray:
arr = np.asarray(v, dtype=float)
sides = float(np.linalg.norm(arr))
return arr / sides if sides else arr
[docs]
def rot_from_to(a, b) -> "tuple[float, np.ndarray]":
"""(angle_degrees, axis) rotating direction *a* onto direction *b*.
Matches BOSL2's ``rot(from=, to=)`` axis choice, including the antiparallel case (180
degrees about a perpendicular axis).
"""
au, bu = _unit(a), _unit(b)
dot = float(np.clip(au @ bu, -1.0, 1.0))
if dot > 1 - 1e-9:
return 0.0, np.array([0.0, 0.0, 1.0])
if dot < -1 + 1e-9:
axis = np.cross(au, [1.0, 0.0, 0.0])
if float(np.linalg.norm(axis)) < 1e-9:
axis = np.cross(au, [0.0, 1.0, 0.0])
return 180.0, _unit(axis)
return math.degrees(math.acos(dot)), _unit(np.cross(au, bu))
[docs]
def axis_angle_matrix(angle: float, axis) -> np.ndarray:
"""3x3 rotation matrix for *angle* degrees about *axis* (Rodrigues' rotation formula)."""
rad = math.radians(angle)
x, y, z = _unit(axis)
c, s = math.cos(rad), math.sin(rad)
cc = 1.0 - c
return np.array(
[
[x * x * cc + c, x * y * cc - z * s, x * z * cc + y * s],
[y * x * cc + z * s, y * y * cc + c, y * z * cc - x * s],
[z * x * cc - y * s, z * y * cc + x * s, z * z * cc + c],
]
)
[docs]
def rot_about_axis(angle: float, axis, center: tuple[float, float, float] = (0.0, 0.0, 0.0)) -> np.ndarray:
"""4x4 matrix rotating *angle* degrees about the line through *center* in direction *axis*.
The 4x4 form of BOSL2's ``rot(a=, v=, center=)``: translate *center* to the origin, rotate, translate
back."""
m = np.eye(4)
m[:3, :3] = axis_angle_matrix(angle, axis)
cpv = np.asarray(center, dtype=float)
m[:3, 3] = cpv - m[:3, :3] @ cpv
return m
[docs]
def rot_inverse(t) -> np.ndarray:
"""
Inverse of a rigid 4x4 transform (BOSL2 rot_inverse()): transpose the rotation,
un-translate.
"""
t = np.asarray(t, dtype=float)
radius = t[:3, :3]
inv = np.eye(4)
inv[:3, :3] = radius.T
inv[:3, 3] = -radius.T @ t[:3, 3]
return inv
[docs]
def rot_decode(m, long: bool = False) -> list:
"""Decode a rigid 4x4 transform into its screw motion (BOSL2 rot_decode()).
Returns ``[angle_degrees, axis, center, translation_along_axis]`` -- rotating by *angle* about the
line through *center* in direction *axis* then translating along the axis reproduces *m*. *axis*,
*center* and the axial translation are returned as :class:`~bosl2.constants.Vec3`. With *long*, the
complementary (>180 degree) rotation about the reversed axis is chosen."""
from bosl2.constants import (
Vec3,
) # local: constants is lightweight, avoid a load-order cycle
m = np.asarray(m, dtype=float)
radius = m[:3, :3]
translation = m[:3, 3]
largest = int(np.argmax([radius[0, 0], radius[1, 1], radius[2, 2]]))
axis_matrix = radius + radius.T - (np.trace(radius) - 1) * np.eye(3)
q_im = axis_matrix[largest]
q_re = radius[(largest + 2) % 3][(largest + 1) % 3] - radius[(largest + 1) % 3][(largest + 2) % 3]
c_sin = float(np.linalg.norm(q_im))
c_cos = abs(float(q_re))
if c_sin < 1e-12:
return [
0.0,
Vec3([0.0, 0.0, 1.0]),
Vec3([0.0, 0.0, 0.0]),
Vec3([float(v) for v in translation]),
]
angle = math.degrees(2 * math.atan2(c_sin, c_cos))
axis = (1.0 if q_re >= 0 else -1.0) * q_im / c_sin
tproj = translation - (translation @ axis) * axis
center = (tproj + np.cross(axis, tproj) * c_cos / c_sin) / 2
axial = (translation @ axis) * axis
return [
360 - angle if long else angle,
Vec3([float(v) for v in (-axis if long else axis)]),
Vec3([float(v) for v in center]),
Vec3([float(v) for v in axial]),
]
[docs]
def reorient(anchor=None, spin: float = 0, orient=None, size=None) -> list[list[float]]:
"""The 4x4 matrix that reorients a cuboid of *size* onto *anchor*/*spin*/*orient*.
The Python equivalent of BOSL2's ``reorient(anchor, spin, orient, size)``, for feeding
PythonSCAD's ``.multmatrix()``. Composed as
``R(UP -> orient) * Zrot(spin) * Translate(-anchor * size / 2)``; verified to match
BOSL2's own output exactly across every anchor/orient/spin/size combination the toolkit
uses (see tests/test_bosl2_reorient.py).
Returns plain nested lists, not an ndarray: the result feeds straight into the native
``multmatrix()``, which rejects numpy arrays ("Error during parsing multmatrix(object,
vec16)").
Usage::
tmat = reorient(anchor=CENTER, spin=90, orient=LEFT, size=[10, 20, 30])
shape.multmatrix(tmat)
Args:
anchor: BOSL2 anchor vector (default CENTER)
spin: rotation about Z in degrees, applied after the anchor move (default 0)
orient: direction the shape's UP is rotated onto (default UP)
size: [x, y, z] size the anchor is resolved against (default [0, 0, 0])
"""
anchor = (0.0, 0.0, 0.0) if anchor is None else anchor
orient = (0.0, 0.0, 1.0) if orient is None else orient
size = (0.0, 0.0, 0.0) if size is None else size
angle, axis = rot_from_to((0.0, 0.0, 1.0), orient)
rot_m = np.eye(4)
rot_m[:3, :3] = axis_angle_matrix(angle, axis)
rad = math.radians(spin)
zrot = np.eye(4)
zrot[:2, :2] = [[math.cos(rad), -math.sin(rad)], [math.sin(rad), math.cos(rad)]]
move_m = np.eye(4)
move_m[:3, 3] = [-float(anchor[i]) * float(size[i]) / 2 for i in range(3)]
return (rot_m @ zrot @ move_m).tolist()
[docs]
def apply(transform, points) -> list:
"""Apply a 4x4 (or 3x3, 2-D) *transform* matrix to every point in *points*.
The Python equivalent of BOSL2's ``apply()``. Returns plain nested lists so the result can
cross the native FFI boundary.
Usage::
apply(reorient(anchor=CENTER, orient=LEFT, size=[1, 1, 1]), [[5, 0, 0], [-5, 0, 0]])
"""
m = np.asarray(transform, dtype=float)
pts = np.asarray(points, dtype=float)
single = pts.ndim == 1
if single:
pts = pts[None, :]
dim = m.shape[0] - 1
homogeneous = np.hstack([pts[:, :dim], np.ones((len(pts), 1))])
out = (m @ homogeneous.T).T
w = out[:, dim : dim + 1]
out = out[:, :dim] / np.where(w == 0, 1.0, w)
return out[0].tolist() if single else out.tolist()