Skip to content

visualdynamics.rotate

rotate

Turning and sliding a coordinate system by dragging it in the 3-D view.

The geometry and the angle arithmetic are kept here, apart from Qt and VTK, so which way a drag turns or moves the frame, and by how much, can be tested without a window.

A coordinate system is stored as four rows: three orthonormal basis vectors and an origin. Dragging a ring around one of the frame's axes turns the frame about that axis; a rotation changes only the basis, and the origin stays where it is.

Dragging an arrow along one of the frame's axes slides the origin along that axis and snaps it to a grid in the geometry's own axes: a tenth of an inch for the inch family of units, a centimeter for the metric one. A frame or a block then lands on round coordinates whichever way it is turned.

Functions:

Name Description
rotation_about

The 3x3 rotation of angle radians about axis, by Rodrigues'

ring_points

steps points evenly spaced on a circle of radius around one

rotate_frame

A copy of matrix with its basis turned angle radians about one

angle_in_plane

Where a world point sits around one principal axis, in radians.

wrapped

An angle in radians folded into [-pi, pi), so a drag across the

ring_under_cursor

The axis of the ring a pixel is over, or None.

plane_hit

Where the ray from eye along direction meets the plane

identity_frame

The frame with its rotation undone: the identity basis at the

grid_step

The grid a slide lands on, in the display unit: a tenth of an

arrow_points

steps points along the arrow for one principal axis of a frame,

translate_frame

A copy of matrix with its origin moved distance along one of

axis_hit

The point on the axis line (through origin along direction)

distance_along

How far a world point is from the frame's origin along one of

snapped

point moved to the nearest multiple of step in each

frame_from_angles

A frame at origin turned by angles, in degrees, about the

angles_of

The fixed-axis X, Y, Z angles in degrees that turn the geometry's

Functions:

rotation_about

rotation_about(axis: ArrayLike, angle: float) -> ndarray

The 3x3 rotation of angle radians about axis, by Rodrigues' formula.

axis is normalized here, so it need not be a unit vector. A positive angle turns counterclockwise looking down the axis toward the origin (the right-hand rule).

Source code in src/visualdynamics/rotate.py
def rotation_about(axis: ArrayLike, angle: float) -> np.ndarray:
    """The 3x3 rotation of `angle` radians about `axis`, by Rodrigues'
    formula.

    `axis` is normalized here, so it need not be a unit vector. A
    positive angle turns counterclockwise looking down the axis toward
    the origin (the right-hand rule).
    """
    axis = np.asarray(axis, dtype=np.float64)
    axis = axis / np.linalg.norm(axis)
    cross = np.array([[0.0, -axis[2], axis[1]],
                      [axis[2], 0.0, -axis[0]],
                      [-axis[1], axis[0], 0.0]])
    return (np.eye(3) + np.sin(angle) * cross
            + (1.0 - np.cos(angle)) * (cross @ cross))

ring_points

ring_points(matrix: ArrayLike, axis: int, radius: float, steps: int = RING_STEPS) -> ndarray

steps points evenly spaced on a circle of radius around one principal axis of a frame, centered on its origin.

The ring for the X axis lies in the frame's own Y-Z plane, so it is the circle swept by turning about X. It starts on the next axis (Y for X) and the first point is not repeated at the end.

Source code in src/visualdynamics/rotate.py
def ring_points(matrix: ArrayLike, axis: int, radius: float,
                steps: int = RING_STEPS) -> np.ndarray:
    """`steps` points evenly spaced on a circle of `radius` around one
    principal axis of a frame, centered on its origin.

    The ring for the X axis lies in the frame's own Y-Z plane, so it is
    the circle swept by turning about X. It starts on the next axis (Y
    for X) and the first point is not repeated at the end.
    """
    matrix = np.asarray(matrix, dtype=np.float64)
    origin = matrix[3]
    first, second = ((axis + 1) % 3, (axis + 2) % 3)
    angle = np.linspace(0.0, 2.0 * np.pi, steps, endpoint=False)[:, np.newaxis]
    return origin + radius * (np.cos(angle) * matrix[first]
                              + np.sin(angle) * matrix[second])

rotate_frame

rotate_frame(matrix: ArrayLike, axis: int, angle: float) -> ndarray

A copy of matrix with its basis turned angle radians about one of its own axes.

The axis moves with the frame, so turning about X twice by 45 degrees is turning about the frame's X by 90 degrees, not about the world's X. The origin is unchanged.

Source code in src/visualdynamics/rotate.py
def rotate_frame(matrix: ArrayLike, axis: int, angle: float) -> np.ndarray:
    """A copy of `matrix` with its basis turned `angle` radians about one
    of its own axes.

    The axis moves with the frame, so turning about X twice by 45
    degrees is turning about the frame's X by 90 degrees, not about the
    world's X. The origin is unchanged.
    """
    matrix = np.array(matrix, dtype=np.float64)
    turned = np.array(matrix)
    turned[:3] = matrix[:3] @ rotation_about(matrix[axis], angle).T
    return turned

angle_in_plane

angle_in_plane(matrix: ArrayLike, axis: int, point: ArrayLike) -> float

Where a world point sits around one principal axis, in radians.

Measured in the ring's own plane from the first of the two axes it spans, so it pairs with ring_points.

Source code in src/visualdynamics/rotate.py
def angle_in_plane(matrix: ArrayLike, axis: int,
                   point: ArrayLike) -> float:
    """Where a world point sits around one principal axis, in radians.

    Measured in the ring's own plane from the first of the two axes it
    spans, so it pairs with `ring_points`.
    """
    matrix = np.asarray(matrix, dtype=np.float64)
    offset = np.asarray(point, dtype=np.float64) - matrix[3]
    first, second = ((axis + 1) % 3, (axis + 2) % 3)
    return float(np.arctan2(offset @ matrix[second], offset @ matrix[first]))

wrapped

wrapped(angle: float) -> float

An angle in radians folded into [-pi, pi), so a drag across the +/-180 degree line stays small: from 179 to -179 degrees is 2 degrees, not 358.

Source code in src/visualdynamics/rotate.py
def wrapped(angle: float) -> float:
    """An angle in radians folded into [-pi, pi), so a drag across the
    +/-180 degree line stays small: from 179 to -179 degrees is 2
    degrees, not 358.
    """
    return float((angle + np.pi) % (2.0 * np.pi) - np.pi)

ring_under_cursor

ring_under_cursor(rings: Sequence[tuple[int, ndarray]], cursor: ArrayLike, tolerance: float = 14.0) -> int | None

The axis of the ring a pixel is over, or None.

rings is a list of (axis, screen points), each a polyline; the caller closes a ring by repeating its first point. The distance is measured to the polyline's segments, not to its points. The rings cross each other, so the nearest one closer than tolerance pixels wins, not the first one listed. An arrow from arrow_points is picked the same way.

Source code in src/visualdynamics/rotate.py
def ring_under_cursor(rings: Sequence[tuple[int, np.ndarray]],
                      cursor: ArrayLike,
                      tolerance: float = 14.0) -> int | None:
    """The axis of the ring a pixel is over, or None.

    `rings` is a list of (axis, screen points), each a polyline; the
    caller closes a ring by repeating its first point. The distance is
    measured to the polyline's segments, not to its points. The rings
    cross each other, so the nearest one closer than `tolerance` pixels
    wins, not the first one listed. An arrow from `arrow_points` is
    picked the same way.
    """
    # Distance is to the line, not to its points. It was to the points
    # until 2026-10-10, and a ring 400 pixels across drawn from 96 of
    # them has 26 pixels between neighbors, so a click on the ring
    # itself, midway between two, could miss a 14-pixel tolerance.
    from .viz.pick import segment_distances

    cursor = np.asarray(cursor, dtype=np.float64)
    best, best_distance = None, tolerance
    for axis, points in rings:
        points = np.asarray(points, dtype=np.float64)
        if not len(points):
            continue
        if len(points) == 1:
            distance = float(np.linalg.norm(points[0] - cursor))
        else:
            distance = float(segment_distances(cursor, points[:-1],
                                               points[1:]).min())
        if distance < best_distance:
            best, best_distance = axis, distance
    return best

plane_hit

plane_hit(origin: ArrayLike, normal: ArrayLike, eye: ArrayLike, direction: ArrayLike) -> ndarray | None

Where the ray from eye along direction meets the plane through origin with normal normal, or None when they are parallel.

Dragging a ring follows the cursor around the plane the ring lies in, and this is the point on that plane.

Source code in src/visualdynamics/rotate.py
def plane_hit(origin: ArrayLike, normal: ArrayLike, eye: ArrayLike,
              direction: ArrayLike) -> np.ndarray | None:
    """Where the ray from `eye` along `direction` meets the plane
    through `origin` with normal `normal`, or None when they are
    parallel.

    Dragging a ring follows the cursor around the plane the ring lies
    in, and this is the point on that plane.
    """
    normal = np.asarray(normal, dtype=np.float64)
    direction = np.asarray(direction, dtype=np.float64)
    along = float(normal @ direction)
    if abs(along) < 1e-9:
        return None
    distance = float(normal @ (np.asarray(origin, dtype=np.float64)
                               - np.asarray(eye, dtype=np.float64))) / along
    return np.asarray(eye, dtype=np.float64) + distance * direction

identity_frame

identity_frame(matrix: ArrayLike) -> ndarray

The frame with its rotation undone: the identity basis at the same origin.

Source code in src/visualdynamics/rotate.py
def identity_frame(matrix: ArrayLike) -> np.ndarray:
    """The frame with its rotation undone: the identity basis at the
    same origin."""
    matrix = np.asarray(matrix, dtype=np.float64)
    reset = np.zeros_like(matrix)
    reset[:3] = np.eye(3)
    reset[3] = matrix[3]
    return reset

grid_step

grid_step(unit: str) -> float

The grid a slide lands on, in the display unit: a tenth of an inch for the inch family (INCH_FAMILY), a centimeter for every other unit.

Source code in src/visualdynamics/rotate.py
def grid_step(unit: str) -> float:
    """The grid a slide lands on, in the display `unit`: a tenth of an
    inch for the inch family (`INCH_FAMILY`), a centimeter for every
    other unit."""
    from .units import convert

    if unit in INCH_FAMILY:
        return float(convert(0.1, 'in', unit))
    return float(convert(1.0, 'cm', unit))

arrow_points

arrow_points(matrix: ArrayLike, axis: int, length: float, steps: int = ARROW_STEPS) -> ndarray

steps points along the arrow for one principal axis of a frame, from the origin out to length along it. It is a polyline like a ring, so ring_under_cursor picks an arrow the same way.

Source code in src/visualdynamics/rotate.py
def arrow_points(matrix: ArrayLike, axis: int, length: float,
                 steps: int = ARROW_STEPS) -> np.ndarray:
    """`steps` points along the arrow for one principal axis of a frame,
    from the origin out to `length` along it. It is a polyline like a
    ring, so `ring_under_cursor` picks an arrow the same way."""
    matrix = np.asarray(matrix, dtype=np.float64)
    along = np.linspace(0.0, length, steps)[:, None]
    return matrix[3] + along * matrix[axis]

translate_frame

translate_frame(matrix: ArrayLike, axis: int, distance: float) -> ndarray

A copy of matrix with its origin moved distance along one of its own axes. The basis is unchanged; rotate_frame is the opposite case.

Source code in src/visualdynamics/rotate.py
def translate_frame(matrix: ArrayLike, axis: int, distance: float) -> np.ndarray:
    """A copy of `matrix` with its origin moved `distance` along one of
    its own axes. The basis is unchanged; `rotate_frame` is the
    opposite case."""
    moved = np.array(matrix, dtype=np.float64)
    moved[3] = moved[3] + float(distance) * moved[axis]
    return moved

axis_hit

axis_hit(origin: ArrayLike, direction: ArrayLike, eye: ArrayLike, ray: ArrayLike) -> ndarray | None

The point on the axis line (through origin along direction) nearest the cursor's ray (from eye along ray), or None when the two are parallel.

A ray from the cursor almost never meets the axis line exactly, so a drag along an arrow is read at the point where the two lines come closest.

Source code in src/visualdynamics/rotate.py
def axis_hit(origin: ArrayLike, direction: ArrayLike, eye: ArrayLike,
             ray: ArrayLike) -> np.ndarray | None:
    """The point on the axis line (through `origin` along `direction`)
    nearest the cursor's ray (from `eye` along `ray`), or None when the
    two are parallel.

    A ray from the cursor almost never meets the axis line exactly, so a
    drag along an arrow is read at the point where the two lines come
    closest.
    """
    origin = np.asarray(origin, dtype=np.float64)
    direction = np.asarray(direction, dtype=np.float64)
    eye = np.asarray(eye, dtype=np.float64)
    ray = np.asarray(ray, dtype=np.float64)
    w0 = origin - eye
    a, b, c = direction @ direction, direction @ ray, ray @ ray
    d, e = direction @ w0, ray @ w0
    denominator = a * c - b * b
    if abs(denominator) < 1e-12 * max(a * c, 1e-300):
        return None
    along = (b * e - c * d) / denominator
    return origin + along * direction

distance_along

distance_along(matrix: ArrayLike, axis: int, world: ArrayLike) -> float

How far a world point is from the frame's origin along one of its axes: the distance a slide reads, before the snap.

Source code in src/visualdynamics/rotate.py
def distance_along(matrix: ArrayLike, axis: int, world: ArrayLike) -> float:
    """How far a world point is from the frame's origin along one of
    its axes: the distance a slide reads, before the snap."""
    matrix = np.asarray(matrix, dtype=np.float64)
    return float((np.asarray(world, dtype=np.float64) - matrix[3]) @ matrix[axis])

snapped

snapped(point: ArrayLike, step: float) -> ndarray

point moved to the nearest multiple of step in each coordinate. The coordinates are the geometry's own, so a turned frame still lands on round coordinates. A step of zero or less returns an unchanged copy.

Source code in src/visualdynamics/rotate.py
def snapped(point: ArrayLike, step: float) -> np.ndarray:
    """`point` moved to the nearest multiple of `step` in each
    coordinate. The coordinates are the geometry's own, so a turned frame
    still lands on round coordinates. A step of zero or less returns an
    unchanged copy."""
    point = np.asarray(point, dtype=np.float64)
    if step <= 0.0:
        return point.copy()
    return np.round(point / step) * step

frame_from_angles

frame_from_angles(angles: ArrayLike, origin: ArrayLike = (0.0, 0.0, 0.0)) -> ndarray

A frame at origin turned by angles, in degrees, about the geometry's fixed X, then Y, then Z axes, the order a person types them in. The first three rows are the frame's axes and the fourth is its origin, as in a coordinate system.

Source code in src/visualdynamics/rotate.py
def frame_from_angles(angles: ArrayLike, origin: ArrayLike = (0.0, 0.0, 0.0)
                      ) -> np.ndarray:
    """A frame at `origin` turned by `angles`, in degrees, about the
    geometry's fixed X, then Y, then Z axes, the order a person types
    them in. The first three rows are the frame's axes and the fourth is
    its origin, as in a coordinate system."""
    ax, ay, az = (np.radians(float(a)) for a in angles)
    turn = (rotation_about((0.0, 0.0, 1.0), az) @ rotation_about((0.0, 1.0, 0.0), ay)
            @ rotation_about((1.0, 0.0, 0.0), ax))
    frame = np.zeros((4, 3))
    frame[:3] = turn.T
    frame[3] = np.asarray(origin, dtype=np.float64)
    return frame

angles_of

angles_of(matrix: ArrayLike) -> tuple[float, float, float]

The fixed-axis X, Y, Z angles in degrees that turn the geometry's axes into the frame's, the inverse of frame_from_angles. X and Z are in (-180, 180] and Y in [-90, 90]. At a quarter turn about Y the X and Z turns are about the same axis, and the angle is given as Z with X at zero.

Source code in src/visualdynamics/rotate.py
def angles_of(matrix: ArrayLike) -> tuple[float, float, float]:
    """The fixed-axis X, Y, Z angles in degrees that turn the geometry's
    axes into the frame's, the inverse of `frame_from_angles`. X and Z
    are in (-180, 180] and Y in [-90, 90]. At a quarter turn about Y the
    X and Z turns are about the same axis, and the angle is given as Z
    with X at zero."""
    turn = np.asarray(matrix, dtype=np.float64)[:3].T
    ay = float(np.arcsin(np.clip(-turn[2, 0], -1.0, 1.0)))
    if abs(np.cos(ay)) > 1e-9:
        ax = float(np.arctan2(turn[2, 1], turn[2, 2]))
        az = float(np.arctan2(turn[1, 0], turn[0, 0]))
    else:
        ax = 0.0
        az = float(np.arctan2(-turn[0, 1], turn[1, 1]))
    return tuple(float(np.round(np.degrees(a), 9)) + 0.0 for a in (ax, ay, az))