Example 02: Frame Hierarchies and Dynamic Transformations

Building on Example 01’s coordinate transformations, this example shows the power of hierarchical coordinate frames. We’ll create a laser mounted on a rotating disk - when the disk rotates, the laser automatically rotates with it. This demonstrates:

  1. Parent-child frame relationships: child frames inherit parent transformations

  2. Persistent primitives: Points/Vectors update automatically when their frame is modified

  3. Cache invalidation: transformations stay correct even when modifying frames after creating primitives

Setup

[1]:
from hazy import Frame, Point, Vector
from dataclasses import dataclass
import matplotlib.pyplot as plt
from matplotlib.patches import Circle
import numpy as np

Build the frame hierarchy

We create three frames in a parent-child hierarchy:

  1. root: The world coordinate system (origin of everything)

  2. disk_frame: Child of root - will rotate around z-axis

  3. laser_frame: Child of disk_frame, offset by 1 unit in y direction

When disk_frame rotates, laser_frame automatically rotates with it because of the parent-child relationship.

[2]:
@dataclass
class Laser:
    origin: Point
    direction: Vector


root = Frame.make_root("root")
disk_frame = root.make_child("disk")
laser_frame = disk_frame.make_child("laser").translate(y=1)

laser = Laser(origin=laser_frame.point(0, 0, 0), direction=laser_frame.vector(1, 0, 0))

Key insight: Just like in Example 01, the laser is defined once in its local frame as origin=(0,0,0) and direction=(1,0,0). But now when we rotate disk_frame, calling .to_global() on these primitives automatically returns the updated transformed coordinates - even though laser_frame is a child of disk_frame, not a direct child of root.

Rotate the disk and visualize laser positions

We rotate the disk in small increments and draw the laser at each position. The laser primitives (origin and direction) were created once above - they don’t need to be recreated in each iteration.

How it works:

  • disk_frame.rotate_euler(z=step) rotates the disk by one step

  • Because laser_frame is a child of disk_frame, it rotates too

  • .to_global() always returns current transformed coordinates (cache is invalidated automatically)

  • Each laser beam has a different color to show the rotation sequence

  • Beam length increases with each step for better visualization (purely aesthetic)

[3]:
fig, ax = plt.subplots(figsize=(8, 8))

angles, step = np.linspace(0, 2 * np.pi, 15, retstep=True, endpoint=False)
colors = plt.cm.viridis(np.linspace(0, 1, len(angles)))

for i, angle in enumerate(angles):
    origin_global = laser.origin.to_global()
    end_point = laser.origin + laser.direction * (i + 1) / 5
    end_global = end_point.to_global()

    laser_line = np.vstack([origin_global, end_global])

    ax.plot(*laser_line[:, :2].T, color=colors[i], linewidth=2, alpha=0.8)

    disk_frame.rotate_euler(z=step)

ax.add_artist(
    Circle((0, 0), 1.0, edgecolor="black", facecolor="lightgray", linewidth=2)
)
ax.set_aspect("equal")
ax.set_xlabel("x", fontsize=12)
ax.set_ylabel("y", fontsize=12)
ax.set_title("Laser on Rotating Disk", fontsize=14, fontweight="bold")
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
../_images/examples_02-frame-hierarchies_7_0.png

The plot shows laser beams emanating from the disk edge (at radius 1) in different directions as the disk rotates around its center. Each beam is a snapshot at a different rotation angle.

Key Takeaways

Frame hierarchies: Parent-child relationships allow natural modeling of connected systems (robot arms, camera rigs, mechanical assemblies). When a parent frame transforms, all descendants transform automatically.

Persistent primitives: Just like in Example 01, create Points and Vectors once in their natural local coordinates, store them in data structures (like the Laser dataclass), and transformations stay current when you modify frames later.

Automatic cache invalidation: When you modify a frame with .rotate_euler(), .translate(), or .scale(), the transformation cache is invalidated recursively through all child frames. No manual bookkeeping needed.

Difference from Example 01: In Example 01, we transformed between independent frames. Here, child frames automatically inherit parent transformations through the hierarchy - a single disk_frame.rotate_euler() affects all descendants.

Without hazy: Track transformation matrices manually, remember multiplication order, update all dependent objects when parent transforms change, compose multi-level transformations explicitly.

With hazy: Define the hierarchy once, modify frames freely, call .to_global() to get current coordinates through any depth of nesting.