Coordinate Visualization

This example provides interactive 3D visualizations of coordinate transforms.

Overview

Visualizing coordinate transformations helps understand:

  • Rotation effects: How rotations change frame orientation

  • Frame relationships: ECEF, ENU, NED orientations

  • Interpolation paths: Quaternion vs Euler interpolation

  • Spherical coordinates: Range, azimuth, elevation geometry

Key Concepts

  • Frame axes: Visualizing coordinate system orientation

  • Transformation chains: Sequential rotations

  • Geodetic surface: Earth ellipsoid visualization

  • Great circles: Shortest paths on sphere

Rotation Axes: Visualizing how rotations affect coordinate frame orientation.

Euler Sequences: ZYX, ZXZ, and other Euler angle conventions produce different rotation paths.

SLERP Interpolation: Spherical linear interpolation provides smooth rotation paths between orientations.

Spherical Coordinates: Converting between Cartesian and spherical coordinate systems.

Code Highlights

The example demonstrates:

  • 3D plotting of coordinate frames on the Earth ellipsoid

  • Euler angle sequence visualization

  • Quaternion SLERP animation

  • Spherical coordinate geometry views

Source Code

  1"""
  2Interactive 3D Coordinate System Visualization.
  3
  4This example demonstrates:
  51. 3D visualization of coordinate transformations
  62. Interactive rotation matrix visualization
  73. Quaternion SLERP animation
  84. Geodetic to ECEF coordinate plotting
  9
 10Run with: python examples/coordinate_visualization.py
 11"""
 12
 13import sys
 14from pathlib import Path
 15
 16sys.path.insert(0, str(Path(__file__).parent.parent))
 17
 18# Output directory for generated plots
 19OUTPUT_DIR = Path(__file__).parent.parent / "docs" / "_static" / "images" / "examples"
 20OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
 21
 22import os
 23
 24import numpy as np  # noqa: E402
 25import plotly.graph_objects as go  # noqa: E402
 26from plotly.subplots import make_subplots  # noqa: E402
 27
 28from pytcl.coordinate_systems import (  # noqa: E402
 29    euler2rotmat,
 30    quat2rotmat,
 31    rotx,
 32    roty,
 33    rotz,
 34    slerp,
 35    sphere2cart,
 36)
 37from pytcl.navigation import geodetic_to_ecef  # noqa: E402
 38
 39SHOW_PLOTS = os.environ.get("PYTCL_SHOW_PLOTS", "1") != "0"
 40
 41
 42def plot_rotation_axes() -> go.Figure:
 43    """
 44    Visualize how rotation matrices transform coordinate axes.
 45
 46    Creates an interactive 3D plot showing:
 47    - Original coordinate axes (XYZ)
 48    - Rotated axes after applying rotx, roty, rotz
 49    """
 50    fig = make_subplots(
 51        rows=1,
 52        cols=3,
 53        specs=[[{"type": "scene"}, {"type": "scene"}, {"type": "scene"}]],
 54        subplot_titles=[
 55            "Rotation about X (45°)",
 56            "Rotation about Y (45°)",
 57            "Rotation about Z (45°)",
 58        ],
 59    )
 60
 61    # Original axes
 62    _origin = np.array([0, 0, 0])  # noqa: F841
 63    x_axis = np.array([1, 0, 0])
 64    y_axis = np.array([0, 1, 0])
 65    z_axis = np.array([0, 0, 1])
 66
 67    def add_axes(fig, R, col, original_alpha=0.3):
 68        """Add original and rotated axes to subplot."""
 69        # Original axes (faded)
 70        for axis, color, name in [
 71            (x_axis, "red", "X"),
 72            (y_axis, "green", "Y"),
 73            (z_axis, "blue", "Z"),
 74        ]:
 75            fig.add_trace(
 76                go.Scatter3d(
 77                    x=[0, axis[0]],
 78                    y=[0, axis[1]],
 79                    z=[0, axis[2]],
 80                    mode="lines",
 81                    line=dict(color=color, width=3),
 82                    opacity=original_alpha,
 83                    name=f"Original {name}",
 84                    showlegend=(col == 1),
 85                ),
 86                row=1,
 87                col=col,
 88            )
 89
 90        # Rotated axes
 91        for axis, color, name in [
 92            (x_axis, "red", "X'"),
 93            (y_axis, "green", "Y'"),
 94            (z_axis, "blue", "Z'"),
 95        ]:
 96            rotated = R @ axis
 97            fig.add_trace(
 98                go.Scatter3d(
 99                    x=[0, rotated[0]],
100                    y=[0, rotated[1]],
101                    z=[0, rotated[2]],
102                    mode="lines+markers",
103                    line=dict(color=color, width=5),
104                    marker=dict(size=4),
105                    name=f"Rotated {name}",
106                    showlegend=(col == 1),
107                ),
108                row=1,
109                col=col,
110            )
111
112    # Apply rotations
113    angle = np.pi / 4  # 45 degrees
114    add_axes(fig, rotx(angle), col=1)
115    add_axes(fig, roty(angle), col=2)
116    add_axes(fig, rotz(angle), col=3)
117
118    # Update layout for each scene
119    for i in range(1, 4):
120        fig.update_scenes(
121            dict(
122                xaxis=dict(range=[-1.5, 1.5], title="X"),
123                yaxis=dict(range=[-1.5, 1.5], title="Y"),
124                zaxis=dict(range=[-1.5, 1.5], title="Z"),
125                aspectmode="cube",
126            ),
127            row=1,
128            col=i,
129        )
130
131    fig.update_layout(
132        title="Rotation Matrix Visualization (45° rotations)",
133        width=1400,
134        height=500,
135    )
136
137    return fig
138
139
140def plot_euler_rotation_sequence() -> go.Figure:
141    """
142    Visualize Euler angle rotation sequence (ZYX - aerospace convention).
143
144    Shows how yaw, pitch, roll are applied sequentially.
145    """
146    fig = make_subplots(
147        rows=1,
148        cols=4,
149        specs=[[{"type": "scene"}] * 4],
150        subplot_titles=[
151            "Initial",
152            "After Yaw (Z)",
153            "After Pitch (Y)",
154            "After Roll (X)",
155        ],
156    )
157
158    # Define Euler angles
159    yaw = np.radians(30)
160    pitch = np.radians(20)
161    roll = np.radians(15)
162
163    # Rotation matrices at each stage
164    R0 = np.eye(3)
165    R1 = rotz(yaw)
166    R2 = R1 @ roty(pitch)
167    R3 = R2 @ rotx(roll)
168
169    # Also compute full rotation for comparison
170    R_full = euler2rotmat([yaw, pitch, roll], "ZYX")
171    assert np.allclose(R3, R_full), "Rotation matrices should match"
172
173    # Create a simple airplane shape
174    def airplane_points():
175        """Generate points representing an airplane."""
176        # Fuselage
177        fuselage = np.array(
178            [
179                [1, 0, 0],  # nose
180                [-1, 0, 0],  # tail
181            ]
182        )
183        # Wings
184        wings = np.array(
185            [
186                [0, 0.8, 0],  # left wing
187                [0, -0.8, 0],  # right wing
188            ]
189        )
190        # Tail fin
191        tail = np.array(
192            [
193                [-0.8, 0, 0],
194                [-1, 0, 0.3],
195            ]
196        )
197        return fuselage, wings, tail
198
199    def add_airplane(fig, R, col):
200        """Add rotated airplane to subplot."""
201        fuselage, wings, tail = airplane_points()
202
203        # Rotate all points
204        fuselage_rot = (R @ fuselage.T).T
205        wings_rot = (R @ wings.T).T
206        tail_rot = (R @ tail.T).T
207
208        # Fuselage
209        fig.add_trace(
210            go.Scatter3d(
211                x=fuselage_rot[:, 0],
212                y=fuselage_rot[:, 1],
213                z=fuselage_rot[:, 2],
214                mode="lines",
215                line=dict(color="blue", width=8),
216                name="Fuselage",
217                showlegend=(col == 1),
218            ),
219            row=1,
220            col=col,
221        )
222
223        # Wings
224        fig.add_trace(
225            go.Scatter3d(
226                x=wings_rot[:, 0],
227                y=wings_rot[:, 1],
228                z=wings_rot[:, 2],
229                mode="lines",
230                line=dict(color="red", width=6),
231                name="Wings",
232                showlegend=(col == 1),
233            ),
234            row=1,
235            col=col,
236        )
237
238        # Tail
239        fig.add_trace(
240            go.Scatter3d(
241                x=tail_rot[:, 0],
242                y=tail_rot[:, 1],
243                z=tail_rot[:, 2],
244                mode="lines",
245                line=dict(color="green", width=4),
246                name="Tail",
247                showlegend=(col == 1),
248            ),
249            row=1,
250            col=col,
251        )
252
253    # Add airplane at each rotation stage
254    for i, R in enumerate([R0, R1, R2, R3], 1):
255        add_airplane(fig, R, col=i)
256
257    # Update layout
258    for i in range(1, 5):
259        fig.update_scenes(
260            dict(
261                xaxis=dict(range=[-1.5, 1.5], title="X"),
262                yaxis=dict(range=[-1.5, 1.5], title="Y"),
263                zaxis=dict(range=[-1.5, 1.5], title="Z"),
264                aspectmode="cube",
265                camera=dict(eye=dict(x=1.5, y=1.5, z=1.0)),
266            ),
267            row=1,
268            col=i,
269        )
270
271    fig.update_layout(
272        title=(
273            f"Euler Rotation Sequence (ZYX): Yaw={np.degrees(yaw):.0f}°, "
274            f"Pitch={np.degrees(pitch):.0f}°, Roll={np.degrees(roll):.0f}°"
275        ),
276        width=1600,
277        height=500,
278    )
279
280    return fig
281
282
283def plot_quaternion_slerp() -> go.Figure:
284    """
285    Visualize quaternion SLERP interpolation.
286
287    Shows smooth interpolation between two orientations.
288    """
289    fig = go.Figure()
290
291    # Start and end quaternions
292    # Identity (no rotation)
293    q1 = np.array([1, 0, 0, 0])
294
295    # 90 degree rotation about Z axis
296    angle = np.pi / 2
297    q2 = np.array([np.cos(angle / 2), 0, 0, np.sin(angle / 2)])
298
299    # Interpolation steps
300    n_steps = 20
301    t_values = np.linspace(0, 1, n_steps)
302
303    # Colors for interpolation
304    colors = [
305        f"rgb({int(255 * (1 - t))}, {int(100 + 155 * t)}, {int(255 * t)})"
306        for t in t_values
307    ]
308
309    # Reference vector to rotate
310    v = np.array([1, 0, 0])
311
312    # Add interpolated vectors
313    for i, t in enumerate(t_values):
314        q_interp = slerp(q1, q2, t)
315        R = quat2rotmat(q_interp)
316        v_rot = R @ v
317
318        fig.add_trace(
319            go.Scatter3d(
320                x=[0, v_rot[0]],
321                y=[0, v_rot[1]],
322                z=[0, v_rot[2]],
323                mode="lines+markers",
324                line=dict(color=colors[i], width=4),
325                marker=dict(size=4, color=colors[i]),
326                name=f"t={t:.2f}",
327                showlegend=(i % 5 == 0),
328            )
329        )
330
331    # Add arc showing the path
332    arc_points = []
333    for t in np.linspace(0, 1, 100):
334        q_interp = slerp(q1, q2, t)
335        R = quat2rotmat(q_interp)
336        arc_points.append(R @ v)
337    arc_points = np.array(arc_points)
338
339    fig.add_trace(
340        go.Scatter3d(
341            x=arc_points[:, 0],
342            y=arc_points[:, 1],
343            z=arc_points[:, 2],
344            mode="lines",
345            line=dict(color="gray", width=2, dash="dash"),
346            name="SLERP path",
347        )
348    )
349
350    fig.update_layout(
351        title="Quaternion SLERP Interpolation (0° to 90° about Z-axis)",
352        scene=dict(
353            xaxis=dict(range=[-1.5, 1.5], title="X"),
354            yaxis=dict(range=[-1.5, 1.5], title="Y"),
355            zaxis=dict(range=[-1.5, 1.5], title="Z"),
356            aspectmode="cube",
357        ),
358        width=800,
359        height=700,
360    )
361
362    return fig
363
364
365def plot_spherical_coordinates() -> go.Figure:
366    """
367    Visualize spherical coordinate system.
368
369    Shows the relationship between Cartesian and spherical coordinates.
370    """
371    fig = go.Figure()
372
373    # Generate a grid of points on a sphere
374    n_az = 24
375    n_el = 12
376    r = 1.0
377
378    # Azimuth lines (constant azimuth, varying elevation)
379    for az in np.linspace(0, 2 * np.pi, n_az, endpoint=False):
380        el_range = np.linspace(-np.pi / 2, np.pi / 2, 50)
381        points = np.array(
382            [sphere2cart(r, az, el, system_type="az-el") for el in el_range]
383        )
384        fig.add_trace(
385            go.Scatter3d(
386                x=points[:, 0],
387                y=points[:, 1],
388                z=points[:, 2],
389                mode="lines",
390                line=dict(color="lightblue", width=1),
391                showlegend=False,
392            )
393        )
394
395    # Elevation lines (constant elevation, varying azimuth)
396    for el in np.linspace(-np.pi / 2 + 0.1, np.pi / 2 - 0.1, n_el):
397        az_range = np.linspace(0, 2 * np.pi, 50)
398        points = np.array(
399            [sphere2cart(r, az, el, system_type="az-el") for az in az_range]
400        )
401        fig.add_trace(
402            go.Scatter3d(
403                x=points[:, 0],
404                y=points[:, 1],
405                z=points[:, 2],
406                mode="lines",
407                line=dict(color="lightgreen", width=1),
408                showlegend=False,
409            )
410        )
411
412    # Highlight a specific point
413    test_az = np.radians(45)
414    test_el = np.radians(30)
415    test_point = sphere2cart(r, test_az, test_el, system_type="az-el")
416
417    # Add the point
418    fig.add_trace(
419        go.Scatter3d(
420            x=[test_point[0]],
421            y=[test_point[1]],
422            z=[test_point[2]],
423            mode="markers",
424            marker=dict(size=10, color="red"),
425            name=f"Point (az={np.degrees(test_az):.0f}°, el={np.degrees(test_el):.0f}°)",
426        )
427    )
428
429    # Add lines showing the coordinates
430    # Line from origin to projection on xy-plane
431    proj_xy = np.array([test_point[0], test_point[1], 0])
432    fig.add_trace(
433        go.Scatter3d(
434            x=[0, proj_xy[0]],
435            y=[0, proj_xy[1]],
436            z=[0, 0],
437            mode="lines",
438            line=dict(color="blue", width=3, dash="dash"),
439            name="XY projection",
440        )
441    )
442
443    # Line from projection to point (showing elevation)
444    fig.add_trace(
445        go.Scatter3d(
446            x=[proj_xy[0], test_point[0]],
447            y=[proj_xy[1], test_point[1]],
448            z=[0, test_point[2]],
449            mode="lines",
450            line=dict(color="green", width=3, dash="dash"),
451            name="Elevation",
452        )
453    )
454
455    # Line from origin to point (range)
456    fig.add_trace(
457        go.Scatter3d(
458            x=[0, test_point[0]],
459            y=[0, test_point[1]],
460            z=[0, test_point[2]],
461            mode="lines",
462            line=dict(color="red", width=3),
463            name="Range vector",
464        )
465    )
466
467    # Add coordinate axes
468    axis_len = 1.3
469    for axis, color, name in [
470        ([axis_len, 0, 0], "red", "X"),
471        ([0, axis_len, 0], "green", "Y"),
472        ([0, 0, axis_len], "blue", "Z"),
473    ]:
474        fig.add_trace(
475            go.Scatter3d(
476                x=[0, axis[0]],
477                y=[0, axis[1]],
478                z=[0, axis[2]],
479                mode="lines+text",
480                line=dict(color=color, width=4),
481                text=["", name],
482                textposition="top center",
483                name=f"{name}-axis",
484                showlegend=False,
485            )
486        )
487
488    fig.update_layout(
489        title="Spherical Coordinate System (az-el convention)",
490        scene=dict(
491            xaxis=dict(range=[-1.5, 1.5], title="X"),
492            yaxis=dict(range=[-1.5, 1.5], title="Y"),
493            zaxis=dict(range=[-1.5, 1.5], title="Z"),
494            aspectmode="cube",
495        ),
496        width=900,
497        height=800,
498    )
499
500    return fig
501
502
503def plot_earth_coordinates() -> go.Figure:
504    """
505    Visualize geodetic coordinates on Earth.
506
507    Shows major cities and their ECEF coordinates.
508    """
509    fig = go.Figure()
510
511    # Create a sphere representing Earth
512    u = np.linspace(0, 2 * np.pi, 100)
513    v = np.linspace(0, np.pi, 50)
514    R_earth = 6371000  # meters (approximate)
515    scale = 1e-6  # Scale to make numbers manageable
516
517    x = R_earth * scale * np.outer(np.cos(u), np.sin(v))
518    y = R_earth * scale * np.outer(np.sin(u), np.sin(v))
519    z = R_earth * scale * np.outer(np.ones(np.size(u)), np.cos(v))
520
521    fig.add_trace(
522        go.Surface(
523            x=x,
524            y=y,
525            z=z,
526            colorscale=[[0, "lightblue"], [1, "lightblue"]],
527            opacity=0.6,
528            showscale=False,
529            name="Earth",
530        )
531    )
532
533    # Major cities with their geodetic coordinates
534    cities = {
535        "New York": (40.7128, -74.0060),
536        "London": (51.5074, -0.1278),
537        "Tokyo": (35.6762, 139.6503),
538        "Sydney": (-33.8688, 151.2093),
539        "São Paulo": (-23.5505, -46.6333),
540        "Cairo": (30.0444, 31.2357),
541    }
542
543    # Convert to ECEF and plot
544    city_x, city_y, city_z = [], [], []
545    city_names = []
546
547    for name, (lat_deg, lon_deg) in cities.items():
548        lat = np.radians(lat_deg)
549        lon = np.radians(lon_deg)
550        ecef = geodetic_to_ecef(lat, lon, 0)
551        city_x.append(ecef[0] * scale)
552        city_y.append(ecef[1] * scale)
553        city_z.append(ecef[2] * scale)
554        city_names.append(name)
555
556    fig.add_trace(
557        go.Scatter3d(
558            x=city_x,
559            y=city_y,
560            z=city_z,
561            mode="markers+text",
562            marker=dict(size=8, color="red"),
563            text=city_names,
564            textposition="top center",
565            name="Cities",
566        )
567    )
568
569    # Add equator
570    eq_lon = np.linspace(0, 2 * np.pi, 100)
571    eq_ecef = np.array([geodetic_to_ecef(0, lon, 0) for lon in eq_lon])
572    fig.add_trace(
573        go.Scatter3d(
574            x=eq_ecef[:, 0] * scale,
575            y=eq_ecef[:, 1] * scale,
576            z=eq_ecef[:, 2] * scale,
577            mode="lines",
578            line=dict(color="yellow", width=3),
579            name="Equator",
580        )
581    )
582
583    # Add prime meridian
584    pm_lat = np.linspace(-np.pi / 2, np.pi / 2, 100)
585    pm_ecef = np.array([geodetic_to_ecef(lat, 0, 0) for lat in pm_lat])
586    fig.add_trace(
587        go.Scatter3d(
588            x=pm_ecef[:, 0] * scale,
589            y=pm_ecef[:, 1] * scale,
590            z=pm_ecef[:, 2] * scale,
591            mode="lines",
592            line=dict(color="orange", width=3),
593            name="Prime Meridian",
594        )
595    )
596
597    fig.update_layout(
598        title="Earth: Geodetic to ECEF Coordinate Conversion",
599        scene=dict(
600            xaxis=dict(title="X (1000 km)"),
601            yaxis=dict(title="Y (1000 km)"),
602            zaxis=dict(title="Z (1000 km)"),
603            aspectmode="data",
604        ),
605        width=900,
606        height=800,
607    )
608
609    return fig
610
611
612def main():
613    """Run coordinate visualization examples."""
614    print("Coordinate System Visualization Examples")
615    print("=" * 50)
616
617    # 1. Rotation axes
618    print("\n1. Generating rotation axes visualization...")
619    fig1 = plot_rotation_axes()
620    fig1.write_html(
621        str(OUTPUT_DIR / "coord_viz_rotation_axes.html"),
622        include_plotlyjs="cdn",
623        div_id="coord_viz_rotation_axes",
624    )
625    print(f"   Saved to {OUTPUT_DIR / 'coord_viz_rotation_axes.html'}")
626
627    # 2. Euler rotation sequence
628    print("\n2. Generating Euler rotation sequence...")
629    fig2 = plot_euler_rotation_sequence()
630    fig2.write_html(
631        str(OUTPUT_DIR / "coord_viz_euler_sequence.html"),
632        include_plotlyjs="cdn",
633        div_id="coord_viz_euler_sequence",
634    )
635    print(f"   Saved to {OUTPUT_DIR / 'coord_viz_euler_sequence.html'}")
636
637    # 3. Quaternion SLERP
638    print("\n3. Generating quaternion SLERP visualization...")
639    fig3 = plot_quaternion_slerp()
640    fig3.write_html(
641        str(OUTPUT_DIR / "coord_viz_slerp.html"),
642        include_plotlyjs="cdn",
643        div_id="coord_viz_slerp",
644    )
645    print(f"   Saved to {OUTPUT_DIR / 'coord_viz_slerp.html'}")
646
647    # 4. Spherical coordinates
648    print("\n4. Generating spherical coordinates visualization...")
649    fig4 = plot_spherical_coordinates()
650    fig4.write_html(
651        str(OUTPUT_DIR / "coord_viz_spherical.html"),
652        include_plotlyjs="cdn",
653        div_id="coord_viz_spherical",
654    )
655    print(f"   Saved to {OUTPUT_DIR / 'coord_viz_spherical.html'}")
656
657    # 5. Earth coordinates
658    print("\n5. Generating Earth coordinates visualization...")
659    fig5 = plot_earth_coordinates()
660    fig5.write_html(
661        str(OUTPUT_DIR / "coord_viz_earth.html"),
662        include_plotlyjs="cdn",
663        div_id="coord_viz_earth",
664    )
665    print(f"   Saved to {OUTPUT_DIR / 'coord_viz_earth.html'}")
666
667    # Show all figures
668    print("\nOpening visualizations in browser...")
669    if SHOW_PLOTS:
670        fig1.show()
671    if SHOW_PLOTS:
672        fig2.show()
673    if SHOW_PLOTS:
674        fig3.show()
675    if SHOW_PLOTS:
676        fig4.show()
677    if SHOW_PLOTS:
678        fig5.show()
679
680    print("\nDone!")
681
682
683if __name__ == "__main__":
684    main()

Running the Example

python examples/coordinate_visualization.py

See Also