Skip to content

viser

Composable 3D visualization primitives using viser.

This module provides building blocks for creating interactive 3D trajectory visualizations. The design philosophy is to give you useful primitives that you can mix and match - not a monolithic plotting function that tries to handle every case.

Basic Pattern: 1. Create a viser.ViserServer (use our helper, or make your own!) 2. Add static scene elements (obstacles, gates, ground planes, etc.) 3. Add animated elements - each returns (handle, update_callback) 4. Wire up animation controls with your list of update callbacks 5. Call server.sleep_forever() to keep the visualization running

Example - Building Your Own Visualization::

from openscvx.plotting import viser

# Step 1: Create server (or just use viser.ViserServer() directly!)
server = viser.create_server(positions)

# Step 2: Add static elements
viser.add_gates(server, gate_vertices)
viser.add_ellipsoid_obstacles(server, centers, radii)
viser.add_ghost_trajectory(server, positions, colors)

# Step 3: Add animated elements (collect the update callbacks)
_, update_trail = viser.add_animated_trail(server, positions, colors)
_, update_marker = viser.add_position_marker(server, positions)
_, update_thrust = viser.add_thrust_vector(server, positions, thrust)

# Step 4: Wire up animation controls
viser.add_animation_controls(
    server, time_array,
    [update_trail, update_marker, update_thrust]
)

# Step 5: Keep server running
server.sleep_forever()

Available Primitives: - Server: create_server, compute_velocity_colors, compute_grid_size - Static: add_gates, add_ellipsoid_obstacles, add_glideslope_cone, add_ghost_trajectory - Animated: add_animated_trail, add_position_marker, add_thrust_vector, add_attitude_frame, add_viewcone, add_target_marker(s) - Plotly: add_animated_plotly_marker, add_animated_vector_norm_plot - SCP iteration: add_scp_animation_controls, add_scp_iteration_nodes, etc.

For problem-specific examples (drones with viewcones, rockets with glideslope constraints, etc.), see examples/plotting_viser.py.

add_animated_plotly_marker(server: viser.ViserServer, fig: go.Figure, time_array: np.ndarray, marker_x_data: np.ndarray, marker_y_data: np.ndarray, use_trajectory_indexing: bool = True, marker_name: str = 'Current', marker_color: str = 'red', marker_size: int = 12, folder_name: str | None = None, aspect: float = 1.5) -> tuple

Add a plotly figure to viser GUI with an animated time marker.

This function takes any plotly figure and adds an animated marker that synchronizes with viser's 3D animation timeline. The marker shows the current position on the plot as the animation plays.

Parameters:

Name Type Description Default
server ViserServer

ViserServer instance

required
fig Figure

Plotly figure to display

required
time_array ndarray

Time values corresponding to animation frames (N,). This should match the time array passed to add_animation_controls().

required
marker_x_data ndarray

X-axis values for marker position (N,)

required
marker_y_data ndarray

Y-axis values for marker position (N,)

required
use_trajectory_indexing bool

If True, frame_idx maps directly to data indices. If False, searches for nearest time value (use for node-only data).

True
marker_name str

Legend name for the marker trace

'Current'
marker_color str

Color of the animated marker

'red'
marker_size int

Size of the animated marker in points

12
folder_name str | None

Optional GUI folder name to organize plots

None
aspect float

Aspect ratio for plot display (width/height)

1.5

Returns:

Type Description
tuple

Tuple of (plot_handle, update_callback)

Example::

from openscvx.plotting import plot_vector_norm, viser

# Create any plotly figure
fig = plot_vector_norm(results, "thrust")
thrust_norms = np.linalg.norm(results.trajectory["thrust"], axis=1)

# Add to viser with animated marker
_, update_plot = viser.add_animated_plotly_marker(
    server, fig,
    time_array=results.trajectory["time"].flatten(),
    marker_x_data=results.trajectory["time"].flatten(),
    marker_y_data=thrust_norms,
)

# Add to animation callbacks
update_callbacks.append(update_plot)
Source code in openscvx/plotting/viser/plotly_integration.py
def add_animated_plotly_marker(
    server: viser.ViserServer,
    fig: go.Figure,
    time_array: np.ndarray,
    marker_x_data: np.ndarray,
    marker_y_data: np.ndarray,
    use_trajectory_indexing: bool = True,
    marker_name: str = "Current",
    marker_color: str = "red",
    marker_size: int = 12,
    folder_name: str | None = None,
    aspect: float = 1.5,
) -> tuple:
    """Add a plotly figure to viser GUI with an animated time marker.

    This function takes any plotly figure and adds an animated marker that
    synchronizes with viser's 3D animation timeline. The marker shows the
    current position on the plot as the animation plays.

    Args:
        server: ViserServer instance
        fig: Plotly figure to display
        time_array: Time values corresponding to animation frames (N,).
            This should match the time array passed to add_animation_controls().
        marker_x_data: X-axis values for marker position (N,)
        marker_y_data: Y-axis values for marker position (N,)
        use_trajectory_indexing: If True, frame_idx maps directly to data indices.
            If False, searches for nearest time value (use for node-only data).
        marker_name: Legend name for the marker trace
        marker_color: Color of the animated marker
        marker_size: Size of the animated marker in points
        folder_name: Optional GUI folder name to organize plots
        aspect: Aspect ratio for plot display (width/height)

    Returns:
        Tuple of (plot_handle, update_callback)

    Example::

        from openscvx.plotting import plot_vector_norm, viser

        # Create any plotly figure
        fig = plot_vector_norm(results, "thrust")
        thrust_norms = np.linalg.norm(results.trajectory["thrust"], axis=1)

        # Add to viser with animated marker
        _, update_plot = viser.add_animated_plotly_marker(
            server, fig,
            time_array=results.trajectory["time"].flatten(),
            marker_x_data=results.trajectory["time"].flatten(),
            marker_y_data=thrust_norms,
        )

        # Add to animation callbacks
        update_callbacks.append(update_plot)
    """
    # Add marker trace to figure
    marker_trace = go.Scatter(
        x=[marker_x_data[0]],
        y=[marker_y_data[0]],
        mode="markers",
        marker={"color": marker_color, "size": marker_size, "symbol": "circle"},
        name=marker_name,
    )
    fig.add_trace(marker_trace)
    marker_trace_idx = len(fig.data) - 1

    # Add to viser GUI
    if folder_name:
        with server.gui.add_folder(folder_name):
            plot_handle = server.gui.add_plotly(figure=fig, aspect=aspect)
    else:
        plot_handle = server.gui.add_plotly(figure=fig, aspect=aspect)

    # Create update callback
    def update(frame_idx: int) -> None:
        """Update marker position based on current frame."""
        if use_trajectory_indexing:
            # Direct indexing: frame_idx corresponds to data index
            idx = min(frame_idx, len(marker_y_data) - 1)
        else:
            # Search for nearest time (for node-only data)
            current_time = time_array[frame_idx]
            idx = min(np.searchsorted(marker_x_data, current_time), len(marker_y_data) - 1)

        # Update marker position
        fig.data[marker_trace_idx].x = [marker_x_data[idx]]
        fig.data[marker_trace_idx].y = [marker_y_data[idx]]

        # Trigger viser update
        plot_handle.figure = fig

    return plot_handle, update

add_animated_plotly_vline(server: viser.ViserServer, fig: go.Figure, time_array: np.ndarray, use_trajectory_indexing: bool = True, line_color: str = 'red', line_width: int = 2, line_dash: str = 'dash', annotation_text: str = 'Current', annotation_position: str = 'top', folder_name: str | None = None, aspect: float = 1.5) -> tuple

Add a plotly figure to viser GUI with an animated vertical line.

This function takes any plotly figure and adds an animated vertical line that synchronizes with viser's 3D animation timeline. The line shows the current time position as the animation plays.

This is more generic than add_animated_plotly_marker() as it works for any number of traces without needing to specify y-data for each.

Parameters:

Name Type Description Default
server ViserServer

ViserServer instance

required
fig Figure

Plotly figure to display

required
time_array ndarray

Time values corresponding to animation frames (N,). This should match the time array passed to add_animation_controls().

required
use_trajectory_indexing bool

If True, frame_idx maps directly to time indices. If False, searches for nearest time value (use for node-only data).

True
line_color str

Color of the vertical line

'red'
line_width int

Width of the vertical line in pixels

2
line_dash str

Dash style - "solid", "dash", "dot", "dashdot"

'dash'
annotation_text str

Text to show on the line

'Current'
annotation_position str

Position of annotation - "top", "bottom", "top left", etc.

'top'
folder_name str | None

Optional GUI folder name to organize plots

None
aspect float

Aspect ratio for plot display (width/height)

1.5

Returns:

Type Description
tuple

Tuple of (plot_handle, update_callback)

Example::

from openscvx.plotting import plot_control, viser

# Create any plotly figure
fig = plot_control(results, "thrust_force")

# Add to viser with animated vertical line
_, update_plot = viser.add_animated_plotly_vline(
    server, fig,
    time_array=results.trajectory["time"].flatten(),
)

# Add to animation callbacks
update_callbacks.append(update_plot)
Source code in openscvx/plotting/viser/plotly_integration.py
def add_animated_plotly_vline(
    server: viser.ViserServer,
    fig: go.Figure,
    time_array: np.ndarray,
    use_trajectory_indexing: bool = True,
    line_color: str = "red",
    line_width: int = 2,
    line_dash: str = "dash",
    annotation_text: str = "Current",
    annotation_position: str = "top",
    folder_name: str | None = None,
    aspect: float = 1.5,
) -> tuple:
    """Add a plotly figure to viser GUI with an animated vertical line.

    This function takes any plotly figure and adds an animated vertical line that
    synchronizes with viser's 3D animation timeline. The line shows the current
    time position as the animation plays.

    This is more generic than add_animated_plotly_marker() as it works for any
    number of traces without needing to specify y-data for each.

    Args:
        server: ViserServer instance
        fig: Plotly figure to display
        time_array: Time values corresponding to animation frames (N,).
            This should match the time array passed to add_animation_controls().
        use_trajectory_indexing: If True, frame_idx maps directly to time indices.
            If False, searches for nearest time value (use for node-only data).
        line_color: Color of the vertical line
        line_width: Width of the vertical line in pixels
        line_dash: Dash style - "solid", "dash", "dot", "dashdot"
        annotation_text: Text to show on the line
        annotation_position: Position of annotation - "top", "bottom", "top left", etc.
        folder_name: Optional GUI folder name to organize plots
        aspect: Aspect ratio for plot display (width/height)

    Returns:
        Tuple of (plot_handle, update_callback)

    Example::

        from openscvx.plotting import plot_control, viser

        # Create any plotly figure
        fig = plot_control(results, "thrust_force")

        # Add to viser with animated vertical line
        _, update_plot = viser.add_animated_plotly_vline(
            server, fig,
            time_array=results.trajectory["time"].flatten(),
        )

        # Add to animation callbacks
        update_callbacks.append(update_plot)
    """
    # Detect number of subplots in the figure
    # Count unique xaxis/yaxis references in the layout
    n_subplots = 1
    if hasattr(fig, "_grid_ref") and fig._grid_ref is not None:
        # Figure created with make_subplots - use grid dimensions
        n_rows = len(fig._grid_ref)
        n_cols = len(fig._grid_ref[0]) if n_rows > 0 else 1
        n_subplots = n_rows * n_cols

    # Track which shapes are our vlines (before adding new ones)
    n_existing_shapes = len(fig.layout.shapes) if fig.layout.shapes else 0

    # Add vertical line to each subplot
    if n_subplots == 1:
        # Single plot - add one vline
        fig.add_vline(
            x=time_array[0],
            line_dash=line_dash,
            line_color=line_color,
            line_width=line_width,
            annotation_text=annotation_text,
            annotation_position=annotation_position,
        )
    else:
        # Multiple subplots - add vline to each
        # Determine grid layout
        n_rows = len(fig._grid_ref)
        n_cols = len(fig._grid_ref[0]) if n_rows > 0 else 1

        for row_idx in range(n_rows):
            for col_idx in range(n_cols):
                # Add vline to this subplot
                # Only add annotation to first subplot to avoid clutter
                show_annotation = row_idx == 0 and col_idx == 0
                fig.add_vline(
                    x=time_array[0],
                    line_dash=line_dash,
                    line_color=line_color,
                    line_width=line_width,
                    annotation_text=annotation_text if show_annotation else None,
                    annotation_position=annotation_position if show_annotation else None,
                    row=row_idx + 1,
                    col=col_idx + 1,
                )

    # Track indices of shapes we added
    n_new_shapes = len(fig.layout.shapes) - n_existing_shapes
    vline_shape_indices = list(range(n_existing_shapes, n_existing_shapes + n_new_shapes))

    # Add to viser GUI
    if folder_name:
        with server.gui.add_folder(folder_name):
            plot_handle = server.gui.add_plotly(figure=fig, aspect=aspect)
    else:
        plot_handle = server.gui.add_plotly(figure=fig, aspect=aspect)

    # Create update callback
    def update(frame_idx: int) -> None:
        """Update vertical line position based on current frame."""
        if use_trajectory_indexing:
            # Direct indexing: frame_idx corresponds to time index
            idx = min(frame_idx, len(time_array) - 1)
        else:
            # Search for nearest time (for node-only data)
            current_time = time_array[frame_idx]
            idx = min(frame_idx, len(time_array) - 1)

        # Update all vertical line positions
        current_time = time_array[idx]
        for shape_idx in vline_shape_indices:
            fig.layout.shapes[shape_idx].x0 = current_time
            fig.layout.shapes[shape_idx].x1 = current_time

        # Trigger viser update
        plot_handle.figure = fig

    return plot_handle, update

add_animated_trail(server: viser.ViserServer, pos: np.ndarray, colors: np.ndarray, point_size: float = 0.15) -> tuple[viser.PointCloudHandle, UpdateCallback]

Add an animated trail that grows with the animation.

Parameters:

Name Type Description Default
server ViserServer

ViserServer instance

required
pos ndarray

Position array of shape (N, 3)

required
colors ndarray

RGB color array of shape (N, 3)

required
point_size float

Size of trail points

0.15

Returns:

Type Description
tuple[PointCloudHandle, UpdateCallback]

Tuple of (handle, update_callback)

Source code in openscvx/plotting/viser/animated.py
def add_animated_trail(
    server: viser.ViserServer,
    pos: np.ndarray,
    colors: np.ndarray,
    point_size: float = 0.15,
) -> tuple[viser.PointCloudHandle, UpdateCallback]:
    """Add an animated trail that grows with the animation.

    Args:
        server: ViserServer instance
        pos: Position array of shape (N, 3)
        colors: RGB color array of shape (N, 3)
        point_size: Size of trail points

    Returns:
        Tuple of (handle, update_callback)
    """
    handle = server.scene.add_point_cloud(
        "/trail",
        points=pos[:1],
        colors=colors[:1],
        point_size=point_size,
    )

    def update(frame_idx: int) -> None:
        idx = frame_idx + 1  # Include current frame
        handle.points = pos[:idx]
        handle.colors = colors[:idx]

    return handle, update

add_animated_vector_norm_plot(server: viser.ViserServer, results: OptimizationResults, var_name: str, bounds: tuple[float, float] | None = None, title: str | None = None, folder_name: str | None = None, aspect: float = 1.5, marker_color: str = 'red', marker_size: int = 12) -> tuple

Add animated norm plot for a state or control variable.

Convenience wrapper around add_animated_plotly_marker() that uses the existing plot_vector_norm() function to create the base plot.

Parameters:

Name Type Description Default
server ViserServer

ViserServer instance

required
results OptimizationResults

Optimization results containing variable data

required
var_name str

Name of the state or control variable to plot

required
bounds tuple[float, float] | None

Optional (min, max) bounds to display on plot

None
title str | None

Optional custom title for the plot (defaults to "‖{var_name}‖₂")

None
folder_name str | None

Optional GUI folder name to organize plots

None
aspect float

Aspect ratio for plot display (width/height)

1.5
marker_color str

Color of the animated marker

'red'
marker_size int

Size of the animated marker in points

12

Returns:

Type Description
tuple

Tuple of (plot_handle, update_callback), or (None, None) if variable not found

Example::

from openscvx.plotting import viser

# Add animated thrust norm plot
_, update_thrust = viser.add_animated_vector_norm_plot(
    server, results, "thrust",
    title="Thrust Magnitude",
    bounds=(0.0, max_thrust),
    folder_name="Control Plots"
)
if update_thrust is not None:
    update_callbacks.append(update_thrust)
Source code in openscvx/plotting/viser/plotly_integration.py
def add_animated_vector_norm_plot(
    server: viser.ViserServer,
    results: OptimizationResults,
    var_name: str,
    bounds: tuple[float, float] | None = None,
    title: str | None = None,
    folder_name: str | None = None,
    aspect: float = 1.5,
    marker_color: str = "red",
    marker_size: int = 12,
) -> tuple:
    """Add animated norm plot for a state or control variable.

    Convenience wrapper around add_animated_plotly_marker() that uses
    the existing plot_vector_norm() function to create the base plot.

    Args:
        server: ViserServer instance
        results: Optimization results containing variable data
        var_name: Name of the state or control variable to plot
        bounds: Optional (min, max) bounds to display on plot
        title: Optional custom title for the plot (defaults to "‖{var_name}‖₂")
        folder_name: Optional GUI folder name to organize plots
        aspect: Aspect ratio for plot display (width/height)
        marker_color: Color of the animated marker
        marker_size: Size of the animated marker in points

    Returns:
        Tuple of (plot_handle, update_callback), or (None, None) if variable not found

    Example::

        from openscvx.plotting import viser

        # Add animated thrust norm plot
        _, update_thrust = viser.add_animated_vector_norm_plot(
            server, results, "thrust",
            title="Thrust Magnitude",
            bounds=(0.0, max_thrust),
            folder_name="Control Plots"
        )
        if update_thrust is not None:
            update_callbacks.append(update_thrust)
    """
    from openscvx.plotting import plot_vector_norm

    # Check if variable exists in results
    has_in_trajectory = bool(results.trajectory) and var_name in results.trajectory
    has_in_nodes = var_name in results.nodes

    if not (has_in_trajectory or has_in_nodes):
        import warnings

        warnings.warn(f"Variable '{var_name}' not found in results, skipping plot")
        return None, None

    # Create figure using existing plotting function
    fig = plot_vector_norm(results, var_name, bounds=bounds)

    # Update title if custom title provided
    if title is not None:
        fig.update_layout(title_text=title)

    # Determine data source and compute norms
    if has_in_trajectory:
        time_data = results.trajectory["time"].flatten()
        var_data = results.trajectory[var_name]
        use_trajectory_indexing = True
    else:
        time_data = results.nodes["time"].flatten()
        var_data = results.nodes[var_name]
        use_trajectory_indexing = False

    # Compute norms
    norm_data = np.linalg.norm(var_data, axis=1) if var_data.ndim > 1 else np.abs(var_data)

    # Add animated marker
    return add_animated_plotly_marker(
        server,
        fig,
        time_array=time_data,
        marker_x_data=time_data,
        marker_y_data=norm_data,
        use_trajectory_indexing=use_trajectory_indexing,
        marker_name="Current",
        marker_color=marker_color,
        marker_size=marker_size,
        folder_name=folder_name,
        aspect=aspect,
    )

add_animation_controls(server: viser.ViserServer, traj_time: np.ndarray, update_callbacks: list[UpdateCallback], loop: bool = True, folder_name: str = 'Animation') -> None

Add animation GUI controls and start the animation loop.

Creates play/pause button, reset button, time slider, speed slider, and loop checkbox. Runs animation in a background daemon thread.

Parameters:

Name Type Description Default
server ViserServer

ViserServer instance

required
traj_time ndarray

Time array of shape (N,) with timestamps for each frame

required
update_callbacks list[UpdateCallback]

List of update functions to call each frame

required
loop bool

Whether to loop animation by default

True
folder_name str

Name for the GUI folder

'Animation'
Source code in openscvx/plotting/viser/animated.py
def add_animation_controls(
    server: viser.ViserServer,
    traj_time: np.ndarray,
    update_callbacks: list[UpdateCallback],
    loop: bool = True,
    folder_name: str = "Animation",
) -> None:
    """Add animation GUI controls and start the animation loop.

    Creates play/pause button, reset button, time slider, speed slider, and loop checkbox.
    Runs animation in a background daemon thread.

    Args:
        server: ViserServer instance
        traj_time: Time array of shape (N,) with timestamps for each frame
        update_callbacks: List of update functions to call each frame
        loop: Whether to loop animation by default
        folder_name: Name for the GUI folder
    """
    traj_time = traj_time.flatten()
    n_frames = len(traj_time)
    t_start, t_end = float(traj_time[0]), float(traj_time[-1])
    duration = t_end - t_start

    # Filter out None callbacks
    callbacks = [cb for cb in update_callbacks if cb is not None]

    def time_to_frame(t: float) -> int:
        """Convert simulation time to frame index."""
        return int(np.clip(np.searchsorted(traj_time, t, side="right") - 1, 0, n_frames - 1))

    def update_all(sim_t: float) -> None:
        """Update all visualization components."""
        idx = time_to_frame(sim_t)
        for callback in callbacks:
            callback(idx)

    # --- GUI Controls ---
    with server.gui.add_folder(folder_name):
        play_button = server.gui.add_button("Play")
        reset_button = server.gui.add_button("Reset")
        time_slider = server.gui.add_slider(
            "Time (s)",
            min=t_start,
            max=t_end,
            step=duration / 100,
            initial_value=t_start,
        )
        speed_slider = server.gui.add_slider(
            "Speed",
            min=0.1,
            max=5.0,
            step=0.1,
            initial_value=1.0,
        )
        loop_checkbox = server.gui.add_checkbox("Loop", initial_value=loop)

    # Animation state
    state = {"playing": False, "sim_time": t_start}

    @play_button.on_click
    def _(_) -> None:
        state["playing"] = not state["playing"]
        play_button.name = "Pause" if state["playing"] else "Play"

    @reset_button.on_click
    def _(_) -> None:
        state["sim_time"] = t_start
        time_slider.value = t_start
        update_all(t_start)

    @time_slider.on_update
    def _(_) -> None:
        if not state["playing"]:
            state["sim_time"] = float(time_slider.value)
            update_all(state["sim_time"])

    def animation_loop() -> None:
        """Background thread for realtime animation playback."""
        last_time = time.time()
        while True:
            time.sleep(0.016)  # ~60 fps
            current_time = time.time()
            dt = current_time - last_time
            last_time = current_time

            if state["playing"]:
                # Advance simulation time (speed=1.0 is realtime)
                state["sim_time"] += dt * speed_slider.value

                if state["sim_time"] >= t_end:
                    if loop_checkbox.value:
                        state["sim_time"] = t_start
                    else:
                        state["sim_time"] = t_end
                        state["playing"] = False
                        play_button.name = "Play"

                time_slider.value = state["sim_time"]
                update_all(state["sim_time"])

    # Start animation thread
    thread = threading.Thread(target=animation_loop, daemon=True)
    thread.start()

add_attitude_frame(server: viser.ViserServer, pos: np.ndarray, attitude: np.ndarray | None, axes_length: float = 2.0, axes_radius: float = 0.05) -> tuple[viser.FrameHandle | None, UpdateCallback | None]

Add an animated body coordinate frame showing attitude.

Parameters:

Name Type Description Default
server ViserServer

ViserServer instance

required
pos ndarray

Position array of shape (N, 3)

required
attitude ndarray | None

Quaternion array of shape (N, 4) in [w, x, y, z] format, or None to skip

required
axes_length float

Length of the coordinate axes

2.0
axes_radius float

Radius of the axes cylinders

0.05

Returns:

Type Description
tuple[FrameHandle | None, UpdateCallback | None]

Tuple of (handle, update_callback), or (None, None) if attitude is None

Source code in openscvx/plotting/viser/animated.py
def add_attitude_frame(
    server: viser.ViserServer,
    pos: np.ndarray,
    attitude: np.ndarray | None,
    axes_length: float = 2.0,
    axes_radius: float = 0.05,
) -> tuple[viser.FrameHandle | None, UpdateCallback | None]:
    """Add an animated body coordinate frame showing attitude.

    Args:
        server: ViserServer instance
        pos: Position array of shape (N, 3)
        attitude: Quaternion array of shape (N, 4) in [w, x, y, z] format, or None to skip
        axes_length: Length of the coordinate axes
        axes_radius: Radius of the axes cylinders

    Returns:
        Tuple of (handle, update_callback), or (None, None) if attitude is None
    """
    if attitude is None:
        return None, None

    # Viser uses wxyz quaternion format
    handle = server.scene.add_frame(
        "/body_frame",
        wxyz=attitude[0],
        position=pos[0],
        axes_length=axes_length,
        axes_radius=axes_radius,
    )

    def update(frame_idx: int) -> None:
        handle.wxyz = attitude[frame_idx]
        handle.position = pos[frame_idx]

    return handle, update

add_ellipsoid_obstacles(server: viser.ViserServer, centers: list[np.ndarray], radii: list[np.ndarray], axes: list[np.ndarray] | None = None, color: tuple[int, int, int] = (255, 100, 100), opacity: float = 0.6, wireframe: bool = False, subdivisions: int = 2) -> list

Add ellipsoidal obstacles to the scene.

Parameters:

Name Type Description Default
server ViserServer

ViserServer instance

required
centers list[ndarray]

List of center positions, each shape (3,)

required
radii list[ndarray]

List of radii along principal axes, each shape (3,)

required
axes list[ndarray] | None

List of rotation matrices (3, 3) defining principal axes. If None, ellipsoids are axis-aligned.

None
color tuple[int, int, int]

RGB color tuple

(255, 100, 100)
opacity float

Opacity (0-1), only used when wireframe=False

0.6
wireframe bool

If True, render as wireframe instead of solid

False
subdivisions int

Icosphere subdivisions (higher = smoother, 2 is usually good)

2

Returns:

Type Description
list

List of mesh handles

Source code in openscvx/plotting/viser/primitives.py
def add_ellipsoid_obstacles(
    server: viser.ViserServer,
    centers: list[np.ndarray],
    radii: list[np.ndarray],
    axes: list[np.ndarray] | None = None,
    color: tuple[int, int, int] = (255, 100, 100),
    opacity: float = 0.6,
    wireframe: bool = False,
    subdivisions: int = 2,
) -> list:
    """Add ellipsoidal obstacles to the scene.

    Args:
        server: ViserServer instance
        centers: List of center positions, each shape (3,)
        radii: List of radii along principal axes, each shape (3,)
        axes: List of rotation matrices (3, 3) defining principal axes.
            If None, ellipsoids are axis-aligned.
        color: RGB color tuple
        opacity: Opacity (0-1), only used when wireframe=False
        wireframe: If True, render as wireframe instead of solid
        subdivisions: Icosphere subdivisions (higher = smoother, 2 is usually good)

    Returns:
        List of mesh handles
    """
    handles = []

    if axes is None:
        axes = [None] * len(centers)

    for i, (center, rad, ax) in enumerate(zip(centers, radii, axes)):
        # Convert JAX arrays to numpy if needed
        center = np.asarray(center, dtype=np.float64)
        rad = np.asarray(rad, dtype=np.float64)
        if ax is not None:
            ax = np.asarray(ax, dtype=np.float64)

        vertices, faces = _generate_ellipsoid_mesh(center, rad, ax, subdivisions)

        handle = server.scene.add_mesh_simple(
            f"/obstacles/ellipsoid_{i}",
            vertices=vertices,
            faces=faces,
            color=color,
            wireframe=wireframe,
            opacity=opacity if not wireframe else 1.0,
        )
        handles.append(handle)

    return handles

add_gates(server: viser.ViserServer, vertices: list, color: tuple[int, int, int] = (255, 165, 0), line_width: float = 3.0) -> None

Add gate/obstacle wireframes to the scene.

Parameters:

Name Type Description Default
server ViserServer

ViserServer instance

required
vertices list

List of vertex arrays (4 vertices for planar gate, 8 for box)

required
color tuple[int, int, int]

RGB color tuple

(255, 165, 0)
line_width float

Line width for wireframe

3.0
Source code in openscvx/plotting/viser/primitives.py
def add_gates(
    server: viser.ViserServer,
    vertices: list,
    color: tuple[int, int, int] = (255, 165, 0),
    line_width: float = 3.0,
) -> None:
    """Add gate/obstacle wireframes to the scene.

    Args:
        server: ViserServer instance
        vertices: List of vertex arrays (4 vertices for planar gate, 8 for box)
        color: RGB color tuple
        line_width: Line width for wireframe
    """
    for i, verts in enumerate(vertices):
        verts = np.array(verts)
        n_verts = len(verts)

        if n_verts == 4:
            # Planar gate: 4 vertices forming a closed loop
            edges = [[0, 1], [1, 2], [2, 3], [3, 0]]
        elif n_verts == 8:
            # 3D box: 8 vertices
            edges = [
                [0, 1],
                [1, 2],
                [2, 3],
                [3, 0],  # front face
                [4, 5],
                [5, 6],
                [6, 7],
                [7, 4],  # back face
                [0, 4],
                [1, 5],
                [2, 6],
                [3, 7],  # connecting edges
            ]
        else:
            # Unknown format, skip
            continue

        # Shape (N, 2, 3) for N line segments
        points = np.array([[verts[e[0]], verts[e[1]]] for e in edges])
        server.scene.add_line_segments(
            f"/gates/gate_{i}",
            points=points,
            colors=color,
            line_width=line_width,
        )

add_ghost_trajectory(server: viser.ViserServer, pos: np.ndarray, colors: np.ndarray, opacity: float = 0.3, point_size: float = 0.05) -> None

Add a faint ghost trajectory showing the full path.

Parameters:

Name Type Description Default
server ViserServer

ViserServer instance

required
pos ndarray

Position array of shape (N, 3)

required
colors ndarray

RGB color array of shape (N, 3)

required
opacity float

Opacity factor (0-1) applied to colors

0.3
point_size float

Size of trajectory points

0.05
Source code in openscvx/plotting/viser/primitives.py
def add_ghost_trajectory(
    server: viser.ViserServer,
    pos: np.ndarray,
    colors: np.ndarray,
    opacity: float = 0.3,
    point_size: float = 0.05,
) -> None:
    """Add a faint ghost trajectory showing the full path.

    Args:
        server: ViserServer instance
        pos: Position array of shape (N, 3)
        colors: RGB color array of shape (N, 3)
        opacity: Opacity factor (0-1) applied to colors
        point_size: Size of trajectory points
    """
    ghost_colors = (colors * opacity).astype(np.uint8)
    server.scene.add_point_cloud(
        "/ghost_traj",
        points=pos,
        colors=ghost_colors,
        point_size=point_size,
    )

add_glideslope_cone(server: viser.ViserServer, apex: np.ndarray | tuple = (0.0, 0.0, 0.0), height: float = 2000.0, glideslope_angle_deg: float = 86.0, axis: np.ndarray | tuple = (0.0, 0.0, 1.0), color: tuple[int, int, int] = (100, 200, 100), opacity: float = 0.2, wireframe: bool = False, n_segments: int = 32) -> viser.MeshHandle

Add a glideslope/approach constraint cone to the scene.

The glideslope constraint typically has the form

||position_perp|| <= tan(angle) * position_along_axis

This creates a cone with apex at the target, opening along the specified axis.

Parameters:

Name Type Description Default
server ViserServer

ViserServer instance

required
apex ndarray | tuple

Apex position (docking/landing site), default is origin

(0.0, 0.0, 0.0)
height float

Height of the cone visualization

2000.0
glideslope_angle_deg float

Glideslope angle in degrees (measured from axis). For constraint ||r_perp|| <= tan(theta) * r_axis, pass theta here. Common values: 86 deg (very wide), 70 deg (moderate), 45 deg (steep)

86.0
axis ndarray | tuple

Unit vector direction the cone opens toward. Default (0,0,1) for +Z. Use (-1,0,0) for R-bar approach (from below in radial direction).

(0.0, 0.0, 1.0)
color tuple[int, int, int]

RGB color tuple

(100, 200, 100)
opacity float

Opacity (0-1)

0.2
wireframe bool

If True, render as wireframe

False
n_segments int

Number of segments for cone smoothness

32

Returns:

Type Description
MeshHandle

Mesh handle for the cone

Source code in openscvx/plotting/viser/primitives.py
def add_glideslope_cone(
    server: viser.ViserServer,
    apex: np.ndarray | tuple = (0.0, 0.0, 0.0),
    height: float = 2000.0,
    glideslope_angle_deg: float = 86.0,
    axis: np.ndarray | tuple = (0.0, 0.0, 1.0),
    color: tuple[int, int, int] = (100, 200, 100),
    opacity: float = 0.2,
    wireframe: bool = False,
    n_segments: int = 32,
) -> viser.MeshHandle:
    """Add a glideslope/approach constraint cone to the scene.

    The glideslope constraint typically has the form:
        ||position_perp|| <= tan(angle) * position_along_axis

    This creates a cone with apex at the target, opening along the specified axis.

    Args:
        server: ViserServer instance
        apex: Apex position (docking/landing site), default is origin
        height: Height of the cone visualization
        glideslope_angle_deg: Glideslope angle in degrees (measured from axis).
            For constraint ||r_perp|| <= tan(theta) * r_axis, pass theta here.
            Common values: 86 deg (very wide), 70 deg (moderate), 45 deg (steep)
        axis: Unit vector direction the cone opens toward. Default (0,0,1) for +Z.
            Use (-1,0,0) for R-bar approach (from below in radial direction).
        color: RGB color tuple
        opacity: Opacity (0-1)
        wireframe: If True, render as wireframe
        n_segments: Number of segments for cone smoothness

    Returns:
        Mesh handle for the cone
    """
    apex = np.asarray(apex, dtype=np.float32)

    vertices, faces = _generate_cone_mesh(apex, height, glideslope_angle_deg, n_segments, axis=axis)

    handle = server.scene.add_mesh_simple(
        "/constraints/glideslope_cone",
        vertices=vertices,
        faces=faces,
        color=color,
        wireframe=wireframe,
        opacity=opacity if not wireframe else 1.0,
    )

    return handle

add_position_marker(server: viser.ViserServer, pos: np.ndarray, radius: float = 0.5, color: tuple[int, int, int] = (100, 200, 255)) -> tuple[viser.IcosphereHandle, UpdateCallback]

Add an animated position marker (sphere at current position).

Parameters:

Name Type Description Default
server ViserServer

ViserServer instance

required
pos ndarray

Position array of shape (N, 3)

required
radius float

Marker radius

0.5
color tuple[int, int, int]

RGB color tuple

(100, 200, 255)

Returns:

Type Description
tuple[IcosphereHandle, UpdateCallback]

Tuple of (handle, update_callback)

Source code in openscvx/plotting/viser/animated.py
def add_position_marker(
    server: viser.ViserServer,
    pos: np.ndarray,
    radius: float = 0.5,
    color: tuple[int, int, int] = (100, 200, 255),
) -> tuple[viser.IcosphereHandle, UpdateCallback]:
    """Add an animated position marker (sphere at current position).

    Args:
        server: ViserServer instance
        pos: Position array of shape (N, 3)
        radius: Marker radius
        color: RGB color tuple

    Returns:
        Tuple of (handle, update_callback)
    """
    handle = server.scene.add_icosphere(
        "/current_pos",
        radius=radius,
        color=color,
        position=pos[0],
    )

    def update(frame_idx: int) -> None:
        handle.position = pos[frame_idx]

    return handle, update

add_scp_animation_controls(server: viser.ViserServer, n_iterations: int, update_callbacks: list[UpdateCallback], autoplay: bool = False, frame_duration_ms: int = 500, folder_name: str = 'SCP Animation') -> None

Add GUI controls for stepping through SCP iterations.

Creates play/pause button, step buttons, iteration slider, and speed control.

Parameters:

Name Type Description Default
server ViserServer

ViserServer instance

required
n_iterations int

Total number of SCP iterations

required
update_callbacks list[UpdateCallback]

List of update functions to call each iteration

required
autoplay bool

Whether to start playing automatically

False
frame_duration_ms int

Default milliseconds per iteration frame

500
folder_name str

Name for the GUI folder

'SCP Animation'
Source code in openscvx/plotting/viser/scp.py
def add_scp_animation_controls(
    server: viser.ViserServer,
    n_iterations: int,
    update_callbacks: list[UpdateCallback],
    autoplay: bool = False,
    frame_duration_ms: int = 500,
    folder_name: str = "SCP Animation",
) -> None:
    """Add GUI controls for stepping through SCP iterations.

    Creates play/pause button, step buttons, iteration slider, and speed control.

    Args:
        server: ViserServer instance
        n_iterations: Total number of SCP iterations
        update_callbacks: List of update functions to call each iteration
        autoplay: Whether to start playing automatically
        frame_duration_ms: Default milliseconds per iteration frame
        folder_name: Name for the GUI folder
    """
    # Filter out None callbacks
    callbacks = [cb for cb in update_callbacks if cb is not None]

    def update_all(iter_idx: int) -> None:
        """Update all visualization components."""
        for callback in callbacks:
            callback(iter_idx)

    # --- GUI Controls ---
    with server.gui.add_folder(folder_name):
        play_button = server.gui.add_button("Play")
        with server.gui.add_folder("Step Controls", expand_by_default=False):
            prev_button = server.gui.add_button("< Previous")
            next_button = server.gui.add_button("Next >")
        iter_slider = server.gui.add_slider(
            "Iteration",
            min=0,
            max=n_iterations - 1,
            step=1,
            initial_value=0,
        )
        speed_slider = server.gui.add_slider(
            "Speed (ms/iter)",
            min=50,
            max=2000,
            step=50,
            initial_value=frame_duration_ms,
        )
        loop_checkbox = server.gui.add_checkbox("Loop", initial_value=True)

    # Animation state
    state = {"playing": autoplay, "iteration": 0, "needs_update": True}

    @play_button.on_click
    def _(_) -> None:
        state["playing"] = not state["playing"]
        state["needs_update"] = True  # Trigger immediate update on play
        play_button.name = "Pause" if state["playing"] else "Play"

    @prev_button.on_click
    def _(_) -> None:
        if state["iteration"] > 0:
            state["iteration"] -= 1
            iter_slider.value = state["iteration"]
            update_all(state["iteration"])

    @next_button.on_click
    def _(_) -> None:
        if state["iteration"] < n_iterations - 1:
            state["iteration"] += 1
            iter_slider.value = state["iteration"]
            update_all(state["iteration"])

    @iter_slider.on_update
    def _(_) -> None:
        if not state["playing"]:
            state["iteration"] = int(iter_slider.value)
            update_all(state["iteration"])

    def animation_loop() -> None:
        """Background thread for SCP iteration playback."""
        last_update = time.time()
        while True:
            time.sleep(0.016)  # ~60 fps check rate

            # Handle immediate update requests (e.g., on play button click)
            if state["needs_update"]:
                state["needs_update"] = False
                last_update = time.time()
                update_all(state["iteration"])
                continue

            if state["playing"]:
                current_time = time.time()
                elapsed_ms = (current_time - last_update) * 1000

                if elapsed_ms >= speed_slider.value:
                    last_update = current_time
                    state["iteration"] += 1

                    if state["iteration"] >= n_iterations:
                        if loop_checkbox.value:
                            state["iteration"] = 0
                        else:
                            state["iteration"] = n_iterations - 1
                            state["playing"] = False
                            play_button.name = "Play"

                    iter_slider.value = state["iteration"]
                    update_all(state["iteration"])

    # Start animation thread
    thread = threading.Thread(target=animation_loop, daemon=True)
    thread.start()

    # Initial update to ensure first frame is fully rendered
    update_all(0)

add_scp_ghost_iterations(server: viser.ViserServer, positions: list[np.ndarray], point_size: float = 0.15, cmap_name: str = 'viridis') -> tuple[list[viser.PointCloudHandle], UpdateCallback]

Add ghost trails showing all previous SCP iterations.

Pre-buffers point clouds for all iterations and toggles visibility for performance. Shows all previous iterations with viridis coloring to visualize convergence.

Parameters:

Name Type Description Default
server ViserServer

ViserServer instance

required
positions list[ndarray]

List of position arrays per iteration, each shape (N, 3)

required
point_size float

Size of ghost points

0.15
cmap_name str

Matplotlib colormap name for ghost colors

'viridis'

Returns:

Type Description
tuple[list[PointCloudHandle], UpdateCallback]

Tuple of (list of handles, update_callback)

Source code in openscvx/plotting/viser/scp.py
def add_scp_ghost_iterations(
    server: viser.ViserServer,
    positions: list[np.ndarray],
    point_size: float = 0.15,
    cmap_name: str = "viridis",
) -> tuple[list[viser.PointCloudHandle], UpdateCallback]:
    """Add ghost trails showing all previous SCP iterations.

    Pre-buffers point clouds for all iterations and toggles visibility for performance.
    Shows all previous iterations with viridis coloring to visualize convergence.

    Args:
        server: ViserServer instance
        positions: List of position arrays per iteration, each shape (N, 3)
        point_size: Size of ghost points
        cmap_name: Matplotlib colormap name for ghost colors

    Returns:
        Tuple of (list of handles, update_callback)
    """
    n_iterations = len(positions)
    cmap = plt.get_cmap(cmap_name)

    # Pre-create point clouds for all iterations with their colors
    # (all initially hidden, shown progressively as ghosts)
    handles = []
    for i in range(n_iterations):
        t = i / max(n_iterations - 1, 1)
        rgb = cmap(t)[:3]
        color = np.array([int(c * 255) for c in rgb], dtype=np.uint8)
        pos = np.asarray(positions[i], dtype=np.float32)

        handle = server.scene.add_point_cloud(
            f"/scp/ghosts/iter_{i}",
            points=pos,
            colors=color,
            point_size=point_size,
            visible=False,  # All start hidden
        )
        handles.append(handle)

    # Track which iterations are currently visible as ghosts
    state = {"visible_up_to": -1}

    def update(iter_idx: int) -> None:
        idx = min(iter_idx, n_iterations - 1)
        # Ghosts are iterations 0 through idx-1 (everything before current)
        new_visible_up_to = idx - 1

        if new_visible_up_to != state["visible_up_to"]:
            # Show/hide only the iterations that changed
            if new_visible_up_to > state["visible_up_to"]:
                # Show newly visible ghosts
                for i in range(state["visible_up_to"] + 1, new_visible_up_to + 1):
                    handles[i].visible = True
            else:
                # Hide ghosts that should no longer be visible
                for i in range(new_visible_up_to + 1, state["visible_up_to"] + 1):
                    handles[i].visible = False
            state["visible_up_to"] = new_visible_up_to

    return handles, update

add_scp_iteration_attitudes(server: viser.ViserServer, positions: list[np.ndarray], attitudes: list[np.ndarray] | None, axes_length: float = 1.5, axes_radius: float = 0.03, stride: int = 1) -> tuple[list[viser.FrameHandle], UpdateCallback | None]

Add animated attitude frames at each node that update per SCP iteration.

Parameters:

Name Type Description Default
server ViserServer

ViserServer instance

required
positions list[ndarray]

List of position arrays per iteration, each shape (N, 3)

required
attitudes list[ndarray] | None

List of quaternion arrays per iteration, each shape (N, 4) in wxyz format. If None, returns empty list and None callback.

required
axes_length float

Length of coordinate frame axes

1.5
axes_radius float

Radius of axes cylinders

0.03
stride int

Show attitude frame every stride nodes (1 = all nodes)

1

Returns:

Type Description
tuple[list[FrameHandle], UpdateCallback | None]

Tuple of (list of frame handles, update_callback)

Source code in openscvx/plotting/viser/scp.py
def add_scp_iteration_attitudes(
    server: viser.ViserServer,
    positions: list[np.ndarray],
    attitudes: list[np.ndarray] | None,
    axes_length: float = 1.5,
    axes_radius: float = 0.03,
    stride: int = 1,
) -> tuple[list[viser.FrameHandle], UpdateCallback | None]:
    """Add animated attitude frames at each node that update per SCP iteration.

    Args:
        server: ViserServer instance
        positions: List of position arrays per iteration, each shape (N, 3)
        attitudes: List of quaternion arrays per iteration, each shape (N, 4) in wxyz format.
            If None, returns empty list and None callback.
        axes_length: Length of coordinate frame axes
        axes_radius: Radius of axes cylinders
        stride: Show attitude frame every `stride` nodes (1 = all nodes)

    Returns:
        Tuple of (list of frame handles, update_callback)
    """
    if attitudes is None:
        return [], None

    n_iterations = len(positions)
    n_nodes = len(positions[0])

    # Create frame handles for nodes at stride intervals
    node_indices = list(range(0, n_nodes, stride))
    handles = []

    for i, node_idx in enumerate(node_indices):
        handle = server.scene.add_frame(
            f"/scp/attitudes/frame_{i}",
            wxyz=attitudes[0][node_idx],
            position=positions[0][node_idx],
            axes_length=axes_length,
            axes_radius=axes_radius,
        )
        handles.append(handle)

    def update(iter_idx: int) -> None:
        idx = min(iter_idx, n_iterations - 1)
        pos = positions[idx]
        att = attitudes[idx]

        for i, node_idx in enumerate(node_indices):
            # Handle case where number of nodes changes between iterations
            if node_idx < len(pos) and node_idx < len(att):
                handles[i].position = pos[node_idx]
                handles[i].wxyz = att[node_idx]

    return handles, update

add_scp_iteration_nodes(server: viser.ViserServer, positions: list[np.ndarray], colors: list[tuple[int, int, int]] | None = None, point_size: float = 0.3, cmap_name: str = 'viridis') -> tuple[list[viser.PointCloudHandle], UpdateCallback]

Add animated optimization nodes that update per SCP iteration.

Pre-buffers point clouds for all iterations and toggles visibility for performance. This avoids transmitting point data on every frame update.

Parameters:

Name Type Description Default
server ViserServer

ViserServer instance

required
positions list[ndarray]

List of position arrays per iteration, each shape (N, 3)

required
colors list[tuple[int, int, int]] | None

Optional list of RGB colors per iteration. If None, uses viridis colormap.

None
point_size float

Size of node markers

0.3
cmap_name str

Matplotlib colormap name (default: "viridis")

'viridis'

Returns:

Type Description
tuple[list[PointCloudHandle], UpdateCallback]

Tuple of (list of point_handles, update_callback)

Source code in openscvx/plotting/viser/scp.py
def add_scp_iteration_nodes(
    server: viser.ViserServer,
    positions: list[np.ndarray],
    colors: list[tuple[int, int, int]] | None = None,
    point_size: float = 0.3,
    cmap_name: str = "viridis",
) -> tuple[list[viser.PointCloudHandle], UpdateCallback]:
    """Add animated optimization nodes that update per SCP iteration.

    Pre-buffers point clouds for all iterations and toggles visibility for performance.
    This avoids transmitting point data on every frame update.

    Args:
        server: ViserServer instance
        positions: List of position arrays per iteration, each shape (N, 3)
        colors: Optional list of RGB colors per iteration. If None, uses viridis colormap.
        point_size: Size of node markers
        cmap_name: Matplotlib colormap name (default: "viridis")

    Returns:
        Tuple of (list of point_handles, update_callback)
    """
    n_iterations = len(positions)

    # Default: use viridis colormap
    if colors is None:
        cmap = plt.get_cmap(cmap_name)
        colors = []
        for i in range(n_iterations):
            t = i / max(n_iterations - 1, 1)
            rgb = cmap(t)[:3]
            colors.append(tuple(int(c * 255) for c in rgb))

    # Convert colors to numpy arrays for viser compatibility
    colors_np = [np.array([c[0], c[1], c[2]], dtype=np.uint8) for c in colors]

    # Pre-create point clouds for all iterations (only first visible initially)
    handles = []
    for i in range(n_iterations):
        pos = np.asarray(positions[i], dtype=np.float32)
        handle = server.scene.add_point_cloud(
            f"/scp/nodes/iter_{i}",
            points=pos,
            colors=colors_np[i],
            point_size=point_size,
            visible=(i == 0),
        )
        handles.append(handle)

    # Track current visible iteration to minimize visibility toggles
    state = {"current_idx": 0}

    def update(iter_idx: int) -> None:
        idx = min(iter_idx, n_iterations - 1)
        if idx != state["current_idx"]:
            handles[state["current_idx"]].visible = False
            handles[idx].visible = True
            state["current_idx"] = idx

    return handles, update

add_scp_propagation_lines(server: viser.ViserServer, propagations: list[list[np.ndarray]], line_width: float = 2.0, cmap_name: str = 'viridis') -> tuple[list, UpdateCallback]

Add animated nonlinear propagation lines that update per SCP iteration.

Shows the actual integrated trajectory between optimization nodes, revealing defects (gaps) in early iterations that close as SCP converges. All iterations up to the current one are shown with viridis coloring, similar to ghost iterations for nodes.

Parameters:

Name Type Description Default
server ViserServer

ViserServer instance

required
propagations list[list[ndarray]]

List of propagation trajectories per iteration from extract_propagation_positions(). Each iteration contains a list of (n_substeps, 3) position arrays, one per segment.

required
line_width float

Width of propagation lines

2.0
cmap_name str

Matplotlib colormap name (default: "viridis")

'viridis'

Returns:

Type Description
tuple[list, UpdateCallback]

Tuple of (list of line handles, update_callback)

Source code in openscvx/plotting/viser/scp.py
def add_scp_propagation_lines(
    server: viser.ViserServer,
    propagations: list[list[np.ndarray]],
    line_width: float = 2.0,
    cmap_name: str = "viridis",
) -> tuple[list, UpdateCallback]:
    """Add animated nonlinear propagation lines that update per SCP iteration.

    Shows the actual integrated trajectory between optimization nodes,
    revealing defects (gaps) in early iterations that close as SCP converges.
    All iterations up to the current one are shown with viridis coloring,
    similar to ghost iterations for nodes.

    Args:
        server: ViserServer instance
        propagations: List of propagation trajectories per iteration from
            extract_propagation_positions(). Each iteration contains a list
            of (n_substeps, 3) position arrays, one per segment.
        line_width: Width of propagation lines
        cmap_name: Matplotlib colormap name (default: "viridis")

    Returns:
        Tuple of (list of line handles, update_callback)
    """
    if not propagations:
        return [], lambda _: None

    n_iterations = len(propagations)
    n_segments = len(propagations[0])
    cmap = plt.get_cmap(cmap_name)

    # Pre-compute colors for each iteration
    iteration_colors = []
    for i in range(n_iterations):
        t = i / max(n_iterations - 1, 1)
        rgb = cmap(t)[:3]
        iteration_colors.append(np.array([int(c * 255) for c in rgb], dtype=np.uint8))

    # Create line handles for each (iteration, segment) pair
    # Structure: handles[iter_idx][seg_idx]
    all_handles = []

    for iter_idx in range(n_iterations):
        iter_handles = []
        color = iteration_colors[iter_idx]

        for seg_idx in range(n_segments):
            seg_pos = propagations[iter_idx][seg_idx]  # Shape (n_substeps, 3)

            if len(seg_pos) < 2:
                iter_handles.append(None)
                continue

            # Create line segments connecting consecutive substeps
            segments = np.array(
                [[seg_pos[i], seg_pos[i + 1]] for i in range(len(seg_pos) - 1)],
                dtype=np.float32,
            )

            handle = server.scene.add_line_segments(
                f"/scp/propagation/iter_{iter_idx}/segment_{seg_idx}",
                points=segments,
                colors=color,
                line_width=line_width,
                visible=(iter_idx == 0),  # Only first iteration visible initially
            )
            iter_handles.append(handle)

        all_handles.append(iter_handles)

    def update(iter_idx: int) -> None:
        idx = min(iter_idx, n_iterations - 1)

        # Show all iterations up to and including current, hide the rest
        for i in range(n_iterations):
            should_show = i <= idx
            for handle in all_handles[i]:
                if handle is not None:
                    handle.visible = should_show

    return all_handles, update

add_target_marker(server: viser.ViserServer, target_pos: np.ndarray, name: str = 'target', radius: float = 0.8, color: tuple[int, int, int] = (255, 50, 50), show_trail: bool = True, trail_color: tuple[int, int, int] | None = None) -> tuple[viser.IcosphereHandle, UpdateCallback | None]

Add a viewplanning target marker (static or moving).

Parameters:

Name Type Description Default
server ViserServer

ViserServer instance

required
target_pos ndarray

Target position - either shape (3,) for static or (N, 3) for moving

required
name str

Unique name for this target (used in scene path)

'target'
radius float

Marker radius

0.8
color tuple[int, int, int]

RGB color tuple for marker

(255, 50, 50)
show_trail bool

If True and target is moving, show trajectory trail

True
trail_color tuple[int, int, int] | None

RGB color for trail (defaults to dimmed marker color)

None

Returns:

Type Description
tuple[IcosphereHandle, UpdateCallback | None]

Tuple of (handle, update_callback). update_callback is None for static targets.

Source code in openscvx/plotting/viser/animated.py
def add_target_marker(
    server: viser.ViserServer,
    target_pos: np.ndarray,
    name: str = "target",
    radius: float = 0.8,
    color: tuple[int, int, int] = (255, 50, 50),
    show_trail: bool = True,
    trail_color: tuple[int, int, int] | None = None,
) -> tuple[viser.IcosphereHandle, UpdateCallback | None]:
    """Add a viewplanning target marker (static or moving).

    Args:
        server: ViserServer instance
        target_pos: Target position - either shape (3,) for static or (N, 3) for moving
        name: Unique name for this target (used in scene path)
        radius: Marker radius
        color: RGB color tuple for marker
        show_trail: If True and target is moving, show trajectory trail
        trail_color: RGB color for trail (defaults to dimmed marker color)

    Returns:
        Tuple of (handle, update_callback). update_callback is None for static targets.
    """
    target_pos = np.asarray(target_pos)

    # Check if static (single position) or moving (trajectory)
    is_moving = target_pos.ndim == 2 and target_pos.shape[0] > 1

    initial_pos = target_pos[0] if is_moving else target_pos

    # Add marker
    handle = server.scene.add_icosphere(
        f"/targets/{name}/marker",
        radius=radius,
        color=color,
        position=initial_pos,
    )

    # For moving targets, optionally show trail
    if is_moving and show_trail:
        if trail_color is None:
            trail_color = tuple(int(c * 0.5) for c in color)
        server.scene.add_point_cloud(
            f"/targets/{name}/trail",
            points=target_pos,
            colors=trail_color,
            point_size=0.1,
        )

    if not is_moving:
        # Static target - no update needed
        return handle, None

    def update(frame_idx: int) -> None:
        # Clamp to valid range for target trajectory
        idx = min(frame_idx, len(target_pos) - 1)
        handle.position = target_pos[idx]

    return handle, update

add_target_markers(server: viser.ViserServer, target_positions: list[np.ndarray], colors: list[tuple[int, int, int]] | None = None, radius: float = 0.8, show_trails: bool = True) -> list[tuple[viser.IcosphereHandle, UpdateCallback | None]]

Add multiple viewplanning target markers.

Parameters:

Name Type Description Default
server ViserServer

ViserServer instance

required
target_positions list[ndarray]

List of target positions, each either (3,) or (N, 3)

required
colors list[tuple[int, int, int]] | None

List of RGB colors, one per target. Defaults to distinct colors.

None
radius float

Marker radius

0.8
show_trails bool

If True, show trails for moving targets

True

Returns:

Type Description
list[tuple[IcosphereHandle, UpdateCallback | None]]

List of (handle, update_callback) tuples

Source code in openscvx/plotting/viser/animated.py
def add_target_markers(
    server: viser.ViserServer,
    target_positions: list[np.ndarray],
    colors: list[tuple[int, int, int]] | None = None,
    radius: float = 0.8,
    show_trails: bool = True,
) -> list[tuple[viser.IcosphereHandle, UpdateCallback | None]]:
    """Add multiple viewplanning target markers.

    Args:
        server: ViserServer instance
        target_positions: List of target positions, each either (3,) or (N, 3)
        colors: List of RGB colors, one per target. Defaults to distinct colors.
        radius: Marker radius
        show_trails: If True, show trails for moving targets

    Returns:
        List of (handle, update_callback) tuples
    """
    # Default colors if not provided
    if colors is None:
        default_colors = [
            (255, 50, 50),  # Red
            (50, 255, 50),  # Green
            (50, 50, 255),  # Blue
            (255, 255, 50),  # Yellow
            (255, 50, 255),  # Magenta
            (50, 255, 255),  # Cyan
        ]
        colors = [default_colors[i % len(default_colors)] for i in range(len(target_positions))]

    results = []
    for i, (pos, color) in enumerate(zip(target_positions, colors)):
        handle, update = add_target_marker(
            server,
            pos,
            name=f"target_{i}",
            radius=radius,
            color=color,
            show_trail=show_trails,
        )
        results.append((handle, update))

    return results

add_thrust_vector(server: viser.ViserServer, pos: np.ndarray, thrust: np.ndarray | None, attitude: np.ndarray | None = None, scale: float = 0.3, color: tuple[int, int, int] = (255, 100, 100), line_width: float = 4.0) -> tuple[viser.LineSegmentsHandle | None, UpdateCallback | None]

Add an animated thrust/force vector visualization.

Parameters:

Name Type Description Default
server ViserServer

ViserServer instance

required
pos ndarray

Position array of shape (N, 3)

required
thrust ndarray | None

Thrust/force array of shape (N, 3), or None to skip

required
attitude ndarray | None

Quaternion array of shape (N, 4) in [w, x, y, z] format. If provided, thrust is assumed to be in body frame and will be rotated to world frame using the attitude.

None
scale float

Scale factor for thrust vector length

0.3
color tuple[int, int, int]

RGB color tuple

(255, 100, 100)
line_width float

Line width

4.0

Returns:

Type Description
tuple[LineSegmentsHandle | None, UpdateCallback | None]

Tuple of (handle, update_callback), or (None, None) if thrust is None

Source code in openscvx/plotting/viser/animated.py
def add_thrust_vector(
    server: viser.ViserServer,
    pos: np.ndarray,
    thrust: np.ndarray | None,
    attitude: np.ndarray | None = None,
    scale: float = 0.3,
    color: tuple[int, int, int] = (255, 100, 100),
    line_width: float = 4.0,
) -> tuple[viser.LineSegmentsHandle | None, UpdateCallback | None]:
    """Add an animated thrust/force vector visualization.

    Args:
        server: ViserServer instance
        pos: Position array of shape (N, 3)
        thrust: Thrust/force array of shape (N, 3), or None to skip
        attitude: Quaternion array of shape (N, 4) in [w, x, y, z] format.
            If provided, thrust is assumed to be in body frame and will be
            rotated to world frame using the attitude.
        scale: Scale factor for thrust vector length
        color: RGB color tuple
        line_width: Line width

    Returns:
        Tuple of (handle, update_callback), or (None, None) if thrust is None
    """
    if thrust is None:
        return None, None

    def get_thrust_world(frame_idx: int) -> np.ndarray:
        """Get thrust vector in world frame."""
        thrust_body = thrust[frame_idx]
        if attitude is not None:
            return _rotate_vector_by_quaternion(thrust_body, attitude[frame_idx])
        return thrust_body

    thrust_world = get_thrust_world(0)
    thrust_end = pos[0] + thrust_world * scale
    handle = server.scene.add_line_segments(
        "/thrust_vector",
        points=np.array([[pos[0], thrust_end]]),  # Shape (1, 2, 3)
        colors=color,
        line_width=line_width,
    )

    def update(frame_idx: int) -> None:
        thrust_world = get_thrust_world(frame_idx)
        thrust_end = pos[frame_idx] + thrust_world * scale
        handle.points = np.array([[pos[frame_idx], thrust_end]])

    return handle, update

add_viewcone(server: viser.ViserServer, pos: np.ndarray, attitude: np.ndarray | None, half_angle_x: float, half_angle_y: float | None = None, scale: float = 10.0, norm_type: float | str = 2, R_sb: np.ndarray | None = None, color: tuple[int, int, int] = (35, 138, 141), opacity: float = 0.4, wireframe: bool = False, n_segments: int = 32) -> tuple[viser.MeshHandle | None, UpdateCallback | None]

Add an animated viewcone mesh that matches p-norm constraints.

The sensor is assumed to look along +Z in its own frame (boresight = [0,0,1]). The viewcone represents the constraint ||[x,y]||_p <= tan(alpha) * z.

Cross-section shapes by norm
  • p=1: diamond
  • p=2: circle/ellipse
  • p>2: rounded square (superellipse)
  • p=inf: square/rectangle

Parameters:

Name Type Description Default
server ViserServer

ViserServer instance

required
pos ndarray

Position array of shape (N, 3)

required
attitude ndarray | None

Quaternion array of shape (N, 4) in [w, x, y, z] format, or None to skip

required
half_angle_x float

Half-angle of the cone in x direction (radians). For symmetric cones, this is pi/alpha_x where alpha_x is the constraint parameter.

required
half_angle_y float | None

Half-angle in y direction (radians). If None, uses half_angle_x. For asymmetric constraints, this is pi/alpha_y.

None
scale float

Depth/length of the cone visualization

10.0
norm_type float | str

p-norm value (1, 2, 3, ..., or "inf" for infinity norm)

2
R_sb ndarray | None

Body-to-sensor rotation matrix (3x3). If None, sensor is aligned with body z-axis.

None
color tuple[int, int, int]

RGB color tuple

(35, 138, 141)
opacity float

Mesh opacity (0-1), ignored if wireframe=True

0.4
wireframe bool

If True, render as wireframe instead of solid

False
n_segments int

Number of segments for cone smoothness

32

Returns:

Type Description
tuple[MeshHandle | None, UpdateCallback | None]

Tuple of (handle, update_callback), or (None, None) if attitude is None

Source code in openscvx/plotting/viser/animated.py
def add_viewcone(
    server: viser.ViserServer,
    pos: np.ndarray,
    attitude: np.ndarray | None,
    half_angle_x: float,
    half_angle_y: float | None = None,
    scale: float = 10.0,
    norm_type: float | str = 2,
    R_sb: np.ndarray | None = None,
    color: tuple[int, int, int] = (35, 138, 141),  # Viridis at t~0.33 (teal)
    opacity: float = 0.4,
    wireframe: bool = False,
    n_segments: int = 32,
) -> tuple[viser.MeshHandle | None, UpdateCallback | None]:
    """Add an animated viewcone mesh that matches p-norm constraints.

    The sensor is assumed to look along +Z in its own frame (boresight = [0,0,1]).
    The viewcone represents the constraint ||[x,y]||_p <= tan(alpha) * z.

    Cross-section shapes by norm:
        - p=1: diamond
        - p=2: circle/ellipse
        - p>2: rounded square (superellipse)
        - p=inf: square/rectangle

    Args:
        server: ViserServer instance
        pos: Position array of shape (N, 3)
        attitude: Quaternion array of shape (N, 4) in [w, x, y, z] format, or None to skip
        half_angle_x: Half-angle of the cone in x direction (radians).
            For symmetric cones, this is pi/alpha_x where alpha_x is the constraint parameter.
        half_angle_y: Half-angle in y direction (radians). If None, uses half_angle_x.
            For asymmetric constraints, this is pi/alpha_y.
        scale: Depth/length of the cone visualization
        norm_type: p-norm value (1, 2, 3, ..., or "inf" for infinity norm)
        R_sb: Body-to-sensor rotation matrix (3x3). If None, sensor is aligned with body z-axis.
        color: RGB color tuple
        opacity: Mesh opacity (0-1), ignored if wireframe=True
        wireframe: If True, render as wireframe instead of solid
        n_segments: Number of segments for cone smoothness

    Returns:
        Tuple of (handle, update_callback), or (None, None) if attitude is None
    """
    if attitude is None:
        return None, None

    # Convert inputs to numpy arrays (handles JAX arrays)
    pos = np.asarray(pos, dtype=np.float64)
    attitude = np.asarray(attitude, dtype=np.float64)
    if R_sb is not None:
        R_sb = np.asarray(R_sb, dtype=np.float64)

    # Generate base geometry in sensor frame
    base_vertices = _generate_viewcone_vertices(
        half_angle_x, half_angle_y, scale, norm_type, n_segments
    )
    n_base_verts = len(base_vertices) - 1  # Exclude apex
    faces = _generate_viewcone_faces(n_base_verts)

    # Sensor-to-body rotation (transpose of body-to-sensor)
    R_sensor_to_body = R_sb.T if R_sb is not None else np.eye(3)

    def transform_vertices(frame_idx: int) -> np.ndarray:
        """Transform cone vertices from sensor frame to world frame."""
        # Get body-to-world rotation from attitude quaternion
        q_body = attitude[frame_idx]
        R_body_to_world = _quaternion_to_rotation_matrix(q_body)

        # Full transform: sensor -> body -> world
        R_sensor_to_world = R_body_to_world @ R_sensor_to_body

        # Transform vertices and translate to position
        world_vertices = (R_sensor_to_world @ base_vertices.T).T + pos[frame_idx]
        return world_vertices.astype(np.float32)

    # Create initial mesh
    initial_vertices = transform_vertices(0)
    handle = server.scene.add_mesh_simple(
        "/viewcone_mesh",
        vertices=initial_vertices,
        faces=faces,
        color=color,
        wireframe=wireframe,
        opacity=opacity if not wireframe else 1.0,
    )

    def update(frame_idx: int) -> None:
        handle.vertices = transform_vertices(frame_idx)

    return handle, update

compute_grid_size(pos: np.ndarray, padding: float = 1.2) -> float

Compute grid size based on trajectory extent.

Parameters:

Name Type Description Default
pos ndarray

Position array of shape (N, 3)

required
padding float

Padding factor (1.2 = 20% padding)

1.2

Returns:

Type Description
float

Grid size (width and height)

Source code in openscvx/plotting/viser/server.py
def compute_grid_size(pos: np.ndarray, padding: float = 1.2) -> float:
    """Compute grid size based on trajectory extent.

    Args:
        pos: Position array of shape (N, 3)
        padding: Padding factor (1.2 = 20% padding)

    Returns:
        Grid size (width and height)
    """
    max_x = np.abs(pos[:, 0]).max()
    max_y = np.abs(pos[:, 1]).max()
    return max(max_x, max_y) * 2 * padding

compute_velocity_colors(vel: np.ndarray, cmap_name: str = 'viridis') -> np.ndarray

Compute RGB colors based on velocity magnitude.

Parameters:

Name Type Description Default
vel ndarray

Velocity array of shape (N, 3)

required
cmap_name str

Matplotlib colormap name

'viridis'

Returns:

Type Description
ndarray

Array of RGB colors with shape (N, 3), values in [0, 255]

Source code in openscvx/plotting/viser/server.py
def compute_velocity_colors(vel: np.ndarray, cmap_name: str = "viridis") -> np.ndarray:
    """Compute RGB colors based on velocity magnitude.

    Args:
        vel: Velocity array of shape (N, 3)
        cmap_name: Matplotlib colormap name

    Returns:
        Array of RGB colors with shape (N, 3), values in [0, 255]
    """
    vel_norms = np.linalg.norm(vel, axis=1)
    vel_range = vel_norms.max() - vel_norms.min()
    if vel_range < 1e-8:
        vel_normalized = np.zeros_like(vel_norms)
    else:
        vel_normalized = (vel_norms - vel_norms.min()) / vel_range

    cmap = plt.get_cmap(cmap_name)
    colors = np.array([[int(c * 255) for c in cmap(v)[:3]] for v in vel_normalized])
    return colors

create_server(pos: np.ndarray, dark_mode: bool = True, show_grid: bool = True) -> viser.ViserServer

Create a viser server with basic scene setup.

Parameters:

Name Type Description Default
pos ndarray

Position array for computing grid size

required
dark_mode bool

Whether to use dark theme

True
show_grid bool

Whether to show the grid (default True)

True

Returns:

Type Description
ViserServer

ViserServer instance with grid and origin frame

Source code in openscvx/plotting/viser/server.py
def create_server(
    pos: np.ndarray,
    dark_mode: bool = True,
    show_grid: bool = True,
) -> viser.ViserServer:
    """Create a viser server with basic scene setup.

    Args:
        pos: Position array for computing grid size
        dark_mode: Whether to use dark theme
        show_grid: Whether to show the grid (default True)

    Returns:
        ViserServer instance with grid and origin frame
    """
    server = viser.ViserServer()

    # Configure theme with OpenSCvx branding
    # TitlebarButton and TitlebarConfig are TypedDict classes (create as plain dicts)
    buttons = (
        TitlebarButton(
            text="Getting Started",
            icon="Description",
            href="https://openscvx.github.io/OpenSCvx/latest/getting-started/",
        ),
        TitlebarButton(
            text="Docs",
            icon="Description",
            href="https://openscvx.github.io/OpenSCvx/",
        ),
        TitlebarButton(
            text="GitHub",
            icon="GitHub",
            href="https://github.com/OpenSCvx/OpenSCvx",
        ),
    )

    # Add OpenSCvx logo to titlebar (loaded from GitHub)
    logo_url = (
        "https://raw.githubusercontent.com/OpenSCvx/OpenSCvx/main/figures/openscvx_logo_square.png"
    )
    image = TitlebarImage(
        image_url_light=logo_url,
        image_url_dark=logo_url,  # Use same logo for both themes
        image_alt="OpenSCvx",
        href="https://github.com/OpenSCvx/OpenSCvx",
    )

    titlebar_config = TitlebarConfig(buttons=buttons, image=image)

    server.gui.configure_theme(
        titlebar_content=titlebar_config,
        dark_mode=dark_mode,
    )

    if show_grid:
        grid_size = compute_grid_size(pos)
        server.scene.add_grid(
            "/grid",
            width=grid_size,
            height=grid_size,
            position=np.array([0.0, 0.0, 0.0]),
        )
    server.scene.add_frame(
        "/origin",
        wxyz=(1.0, 0.0, 0.0, 0.0),
        position=(0.0, 0.0, 0.0),
    )

    return server

extract_propagation_positions(discretization_history: list[np.ndarray], n_x: int, n_u: int, position_slice: slice, scene_scale: float = 1.0) -> list[list[np.ndarray]]

Extract 3D position trajectories from discretization history.

The discretization history contains the multi-shot integration results. Each V matrix has shape (flattened_size, n_timesteps) where: - flattened_size = (N-1) * i4 - i4 = n_x + n_x*n_x + 2*n_x*n_u (state + STM + control influence matrices) - n_timesteps = number of integration substeps

Parameters:

Name Type Description Default
discretization_history list[ndarray]

List of V matrices from each SCP iteration

required
n_x int

Number of states

required
n_u int

Number of controls

required
position_slice slice

Slice for extracting position from state vector

required
scene_scale float

Divide positions by this factor for visualization

1.0

Returns:

Type Description
list[list[ndarray]]

List of propagation trajectories per iteration.

list[list[ndarray]]

Each iteration contains a list of (n_substeps, 3) arrays, one per segment.

Source code in openscvx/plotting/viser/scp.py
def extract_propagation_positions(
    discretization_history: list[np.ndarray],
    n_x: int,
    n_u: int,
    position_slice: slice,
    scene_scale: float = 1.0,
) -> list[list[np.ndarray]]:
    """Extract 3D position trajectories from discretization history.

    The discretization history contains the multi-shot integration results.
    Each V matrix has shape (flattened_size, n_timesteps) where:
    - flattened_size = (N-1) * i4
    - i4 = n_x + n_x*n_x + 2*n_x*n_u (state + STM + control influence matrices)
    - n_timesteps = number of integration substeps

    Args:
        discretization_history: List of V matrices from each SCP iteration
        n_x: Number of states
        n_u: Number of controls
        position_slice: Slice for extracting position from state vector
        scene_scale: Divide positions by this factor for visualization

    Returns:
        List of propagation trajectories per iteration.
        Each iteration contains a list of (n_substeps, 3) arrays, one per segment.
    """
    if not discretization_history:
        return []

    i4 = n_x + n_x * n_x + 2 * n_x * n_u
    propagations = []

    for V in discretization_history:
        # V shape: (flattened_size, n_timesteps)
        n_timesteps = V.shape[1]
        n_segments = V.shape[0] // i4  # N-1 segments

        iteration_segments = []
        for seg_idx in range(n_segments):
            # Extract this segment's data across all timesteps
            seg_start = seg_idx * i4
            seg_end = seg_start + i4

            # For each timestep, extract the position from the state
            segment_positions = []
            for t_idx in range(n_timesteps):
                # Get full state at this segment and timestep
                state = V[seg_start:seg_end, t_idx][:n_x]
                # Extract position components
                pos = state[position_slice] / scene_scale
                segment_positions.append(pos)

            iteration_segments.append(np.array(segment_positions, dtype=np.float32))

        propagations.append(iteration_segments)

    return propagations