openscvx 0.3.2.dev170__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of openscvx might be problematic. Click here for more details.

Files changed (79) hide show
  1. openscvx/__init__.py +123 -0
  2. openscvx/_version.py +34 -0
  3. openscvx/algorithms/__init__.py +92 -0
  4. openscvx/algorithms/autotuning.py +24 -0
  5. openscvx/algorithms/base.py +351 -0
  6. openscvx/algorithms/optimization_results.py +215 -0
  7. openscvx/algorithms/penalized_trust_region.py +384 -0
  8. openscvx/config.py +437 -0
  9. openscvx/discretization/__init__.py +47 -0
  10. openscvx/discretization/discretization.py +236 -0
  11. openscvx/expert/__init__.py +23 -0
  12. openscvx/expert/byof.py +326 -0
  13. openscvx/expert/lowering.py +419 -0
  14. openscvx/expert/validation.py +357 -0
  15. openscvx/integrators/__init__.py +48 -0
  16. openscvx/integrators/runge_kutta.py +281 -0
  17. openscvx/lowered/__init__.py +30 -0
  18. openscvx/lowered/cvxpy_constraints.py +23 -0
  19. openscvx/lowered/cvxpy_variables.py +124 -0
  20. openscvx/lowered/dynamics.py +34 -0
  21. openscvx/lowered/jax_constraints.py +133 -0
  22. openscvx/lowered/parameters.py +54 -0
  23. openscvx/lowered/problem.py +70 -0
  24. openscvx/lowered/unified.py +718 -0
  25. openscvx/plotting/__init__.py +63 -0
  26. openscvx/plotting/plotting.py +756 -0
  27. openscvx/plotting/scp_iteration.py +299 -0
  28. openscvx/plotting/viser/__init__.py +126 -0
  29. openscvx/plotting/viser/animated.py +605 -0
  30. openscvx/plotting/viser/plotly_integration.py +333 -0
  31. openscvx/plotting/viser/primitives.py +355 -0
  32. openscvx/plotting/viser/scp.py +459 -0
  33. openscvx/plotting/viser/server.py +112 -0
  34. openscvx/problem.py +734 -0
  35. openscvx/propagation/__init__.py +60 -0
  36. openscvx/propagation/post_processing.py +104 -0
  37. openscvx/propagation/propagation.py +248 -0
  38. openscvx/solvers/__init__.py +51 -0
  39. openscvx/solvers/cvxpy.py +226 -0
  40. openscvx/symbolic/__init__.py +9 -0
  41. openscvx/symbolic/augmentation.py +630 -0
  42. openscvx/symbolic/builder.py +492 -0
  43. openscvx/symbolic/constraint_set.py +92 -0
  44. openscvx/symbolic/expr/__init__.py +222 -0
  45. openscvx/symbolic/expr/arithmetic.py +517 -0
  46. openscvx/symbolic/expr/array.py +632 -0
  47. openscvx/symbolic/expr/constraint.py +796 -0
  48. openscvx/symbolic/expr/control.py +135 -0
  49. openscvx/symbolic/expr/expr.py +720 -0
  50. openscvx/symbolic/expr/lie/__init__.py +87 -0
  51. openscvx/symbolic/expr/lie/adjoint.py +357 -0
  52. openscvx/symbolic/expr/lie/se3.py +172 -0
  53. openscvx/symbolic/expr/lie/so3.py +138 -0
  54. openscvx/symbolic/expr/linalg.py +279 -0
  55. openscvx/symbolic/expr/math.py +699 -0
  56. openscvx/symbolic/expr/spatial.py +209 -0
  57. openscvx/symbolic/expr/state.py +607 -0
  58. openscvx/symbolic/expr/stl.py +136 -0
  59. openscvx/symbolic/expr/variable.py +321 -0
  60. openscvx/symbolic/hashing.py +112 -0
  61. openscvx/symbolic/lower.py +760 -0
  62. openscvx/symbolic/lowerers/__init__.py +106 -0
  63. openscvx/symbolic/lowerers/cvxpy.py +1302 -0
  64. openscvx/symbolic/lowerers/jax.py +1382 -0
  65. openscvx/symbolic/preprocessing.py +757 -0
  66. openscvx/symbolic/problem.py +110 -0
  67. openscvx/symbolic/time.py +116 -0
  68. openscvx/symbolic/unified.py +420 -0
  69. openscvx/utils/__init__.py +20 -0
  70. openscvx/utils/cache.py +131 -0
  71. openscvx/utils/caching.py +210 -0
  72. openscvx/utils/printing.py +301 -0
  73. openscvx/utils/profiling.py +37 -0
  74. openscvx/utils/utils.py +100 -0
  75. openscvx-0.3.2.dev170.dist-info/METADATA +350 -0
  76. openscvx-0.3.2.dev170.dist-info/RECORD +79 -0
  77. openscvx-0.3.2.dev170.dist-info/WHEEL +5 -0
  78. openscvx-0.3.2.dev170.dist-info/licenses/LICENSE +201 -0
  79. openscvx-0.3.2.dev170.dist-info/top_level.txt +1 -0
@@ -0,0 +1,333 @@
1
+ """Plotly integration for viser - animated 2D plots synchronized with 3D visualization.
2
+
3
+ This module provides utilities for embedding plotly figures in viser's GUI with
4
+ animated markers that synchronize with the 3D animation timeline.
5
+ """
6
+
7
+ import numpy as np
8
+ import plotly.graph_objects as go
9
+ import viser
10
+
11
+ from openscvx.algorithms import OptimizationResults
12
+
13
+
14
+ def add_animated_plotly_vline(
15
+ server: viser.ViserServer,
16
+ fig: go.Figure,
17
+ time_array: np.ndarray,
18
+ use_trajectory_indexing: bool = True,
19
+ line_color: str = "red",
20
+ line_width: int = 2,
21
+ line_dash: str = "dash",
22
+ annotation_text: str = "Current",
23
+ annotation_position: str = "top",
24
+ folder_name: str | None = None,
25
+ aspect: float = 1.5,
26
+ ) -> tuple:
27
+ """Add a plotly figure to viser GUI with an animated vertical line.
28
+
29
+ This function takes any plotly figure and adds an animated vertical line that
30
+ synchronizes with viser's 3D animation timeline. The line shows the current
31
+ time position as the animation plays.
32
+
33
+ This is more generic than add_animated_plotly_marker() as it works for any
34
+ number of traces without needing to specify y-data for each.
35
+
36
+ Args:
37
+ server: ViserServer instance
38
+ fig: Plotly figure to display
39
+ time_array: Time values corresponding to animation frames (N,).
40
+ This should match the time array passed to add_animation_controls().
41
+ use_trajectory_indexing: If True, frame_idx maps directly to time indices.
42
+ If False, searches for nearest time value (use for node-only data).
43
+ line_color: Color of the vertical line
44
+ line_width: Width of the vertical line in pixels
45
+ line_dash: Dash style - "solid", "dash", "dot", "dashdot"
46
+ annotation_text: Text to show on the line
47
+ annotation_position: Position of annotation - "top", "bottom", "top left", etc.
48
+ folder_name: Optional GUI folder name to organize plots
49
+ aspect: Aspect ratio for plot display (width/height)
50
+
51
+ Returns:
52
+ Tuple of (plot_handle, update_callback)
53
+
54
+ Example::
55
+
56
+ from openscvx.plotting import plot_control, viser
57
+
58
+ # Create any plotly figure
59
+ fig = plot_control(results, "thrust_force")
60
+
61
+ # Add to viser with animated vertical line
62
+ _, update_plot = viser.add_animated_plotly_vline(
63
+ server, fig,
64
+ time_array=results.trajectory["time"].flatten(),
65
+ )
66
+
67
+ # Add to animation callbacks
68
+ update_callbacks.append(update_plot)
69
+ """
70
+ # Detect number of subplots in the figure
71
+ # Count unique xaxis/yaxis references in the layout
72
+ n_subplots = 1
73
+ if hasattr(fig, "_grid_ref") and fig._grid_ref is not None:
74
+ # Figure created with make_subplots - use grid dimensions
75
+ n_rows = len(fig._grid_ref)
76
+ n_cols = len(fig._grid_ref[0]) if n_rows > 0 else 1
77
+ n_subplots = n_rows * n_cols
78
+
79
+ # Track which shapes are our vlines (before adding new ones)
80
+ n_existing_shapes = len(fig.layout.shapes) if fig.layout.shapes else 0
81
+
82
+ # Add vertical line to each subplot
83
+ if n_subplots == 1:
84
+ # Single plot - add one vline
85
+ fig.add_vline(
86
+ x=time_array[0],
87
+ line_dash=line_dash,
88
+ line_color=line_color,
89
+ line_width=line_width,
90
+ annotation_text=annotation_text,
91
+ annotation_position=annotation_position,
92
+ )
93
+ else:
94
+ # Multiple subplots - add vline to each
95
+ # Determine grid layout
96
+ n_rows = len(fig._grid_ref)
97
+ n_cols = len(fig._grid_ref[0]) if n_rows > 0 else 1
98
+
99
+ for row_idx in range(n_rows):
100
+ for col_idx in range(n_cols):
101
+ # Add vline to this subplot
102
+ # Only add annotation to first subplot to avoid clutter
103
+ show_annotation = row_idx == 0 and col_idx == 0
104
+ fig.add_vline(
105
+ x=time_array[0],
106
+ line_dash=line_dash,
107
+ line_color=line_color,
108
+ line_width=line_width,
109
+ annotation_text=annotation_text if show_annotation else None,
110
+ annotation_position=annotation_position if show_annotation else None,
111
+ row=row_idx + 1,
112
+ col=col_idx + 1,
113
+ )
114
+
115
+ # Track indices of shapes we added
116
+ n_new_shapes = len(fig.layout.shapes) - n_existing_shapes
117
+ vline_shape_indices = list(range(n_existing_shapes, n_existing_shapes + n_new_shapes))
118
+
119
+ # Add to viser GUI
120
+ if folder_name:
121
+ with server.gui.add_folder(folder_name):
122
+ plot_handle = server.gui.add_plotly(figure=fig, aspect=aspect)
123
+ else:
124
+ plot_handle = server.gui.add_plotly(figure=fig, aspect=aspect)
125
+
126
+ # Create update callback
127
+ def update(frame_idx: int) -> None:
128
+ """Update vertical line position based on current frame."""
129
+ if use_trajectory_indexing:
130
+ # Direct indexing: frame_idx corresponds to time index
131
+ idx = min(frame_idx, len(time_array) - 1)
132
+ else:
133
+ # Search for nearest time (for node-only data)
134
+ current_time = time_array[frame_idx]
135
+ idx = min(frame_idx, len(time_array) - 1)
136
+
137
+ # Update all vertical line positions
138
+ current_time = time_array[idx]
139
+ for shape_idx in vline_shape_indices:
140
+ fig.layout.shapes[shape_idx].x0 = current_time
141
+ fig.layout.shapes[shape_idx].x1 = current_time
142
+
143
+ # Trigger viser update
144
+ plot_handle.figure = fig
145
+
146
+ return plot_handle, update
147
+
148
+
149
+ def add_animated_plotly_marker(
150
+ server: viser.ViserServer,
151
+ fig: go.Figure,
152
+ time_array: np.ndarray,
153
+ marker_x_data: np.ndarray,
154
+ marker_y_data: np.ndarray,
155
+ use_trajectory_indexing: bool = True,
156
+ marker_name: str = "Current",
157
+ marker_color: str = "red",
158
+ marker_size: int = 12,
159
+ folder_name: str | None = None,
160
+ aspect: float = 1.5,
161
+ ) -> tuple:
162
+ """Add a plotly figure to viser GUI with an animated time marker.
163
+
164
+ This function takes any plotly figure and adds an animated marker that
165
+ synchronizes with viser's 3D animation timeline. The marker shows the
166
+ current position on the plot as the animation plays.
167
+
168
+ Args:
169
+ server: ViserServer instance
170
+ fig: Plotly figure to display
171
+ time_array: Time values corresponding to animation frames (N,).
172
+ This should match the time array passed to add_animation_controls().
173
+ marker_x_data: X-axis values for marker position (N,)
174
+ marker_y_data: Y-axis values for marker position (N,)
175
+ use_trajectory_indexing: If True, frame_idx maps directly to data indices.
176
+ If False, searches for nearest time value (use for node-only data).
177
+ marker_name: Legend name for the marker trace
178
+ marker_color: Color of the animated marker
179
+ marker_size: Size of the animated marker in points
180
+ folder_name: Optional GUI folder name to organize plots
181
+ aspect: Aspect ratio for plot display (width/height)
182
+
183
+ Returns:
184
+ Tuple of (plot_handle, update_callback)
185
+
186
+ Example::
187
+
188
+ from openscvx.plotting import plot_vector_norm, viser
189
+
190
+ # Create any plotly figure
191
+ fig = plot_vector_norm(results, "thrust")
192
+ thrust_norms = np.linalg.norm(results.trajectory["thrust"], axis=1)
193
+
194
+ # Add to viser with animated marker
195
+ _, update_plot = viser.add_animated_plotly_marker(
196
+ server, fig,
197
+ time_array=results.trajectory["time"].flatten(),
198
+ marker_x_data=results.trajectory["time"].flatten(),
199
+ marker_y_data=thrust_norms,
200
+ )
201
+
202
+ # Add to animation callbacks
203
+ update_callbacks.append(update_plot)
204
+ """
205
+ # Add marker trace to figure
206
+ marker_trace = go.Scatter(
207
+ x=[marker_x_data[0]],
208
+ y=[marker_y_data[0]],
209
+ mode="markers",
210
+ marker={"color": marker_color, "size": marker_size, "symbol": "circle"},
211
+ name=marker_name,
212
+ )
213
+ fig.add_trace(marker_trace)
214
+ marker_trace_idx = len(fig.data) - 1
215
+
216
+ # Add to viser GUI
217
+ if folder_name:
218
+ with server.gui.add_folder(folder_name):
219
+ plot_handle = server.gui.add_plotly(figure=fig, aspect=aspect)
220
+ else:
221
+ plot_handle = server.gui.add_plotly(figure=fig, aspect=aspect)
222
+
223
+ # Create update callback
224
+ def update(frame_idx: int) -> None:
225
+ """Update marker position based on current frame."""
226
+ if use_trajectory_indexing:
227
+ # Direct indexing: frame_idx corresponds to data index
228
+ idx = min(frame_idx, len(marker_y_data) - 1)
229
+ else:
230
+ # Search for nearest time (for node-only data)
231
+ current_time = time_array[frame_idx]
232
+ idx = min(np.searchsorted(marker_x_data, current_time), len(marker_y_data) - 1)
233
+
234
+ # Update marker position
235
+ fig.data[marker_trace_idx].x = [marker_x_data[idx]]
236
+ fig.data[marker_trace_idx].y = [marker_y_data[idx]]
237
+
238
+ # Trigger viser update
239
+ plot_handle.figure = fig
240
+
241
+ return plot_handle, update
242
+
243
+
244
+ def add_animated_vector_norm_plot(
245
+ server: viser.ViserServer,
246
+ results: OptimizationResults,
247
+ var_name: str,
248
+ bounds: tuple[float, float] | None = None,
249
+ title: str | None = None,
250
+ folder_name: str | None = None,
251
+ aspect: float = 1.5,
252
+ marker_color: str = "red",
253
+ marker_size: int = 12,
254
+ ) -> tuple:
255
+ """Add animated norm plot for a state or control variable.
256
+
257
+ Convenience wrapper around add_animated_plotly_marker() that uses
258
+ the existing plot_vector_norm() function to create the base plot.
259
+
260
+ Args:
261
+ server: ViserServer instance
262
+ results: Optimization results containing variable data
263
+ var_name: Name of the state or control variable to plot
264
+ bounds: Optional (min, max) bounds to display on plot
265
+ title: Optional custom title for the plot (defaults to "‖{var_name}‖₂")
266
+ folder_name: Optional GUI folder name to organize plots
267
+ aspect: Aspect ratio for plot display (width/height)
268
+ marker_color: Color of the animated marker
269
+ marker_size: Size of the animated marker in points
270
+
271
+ Returns:
272
+ Tuple of (plot_handle, update_callback), or (None, None) if variable not found
273
+
274
+ Example::
275
+
276
+ from openscvx.plotting import viser
277
+
278
+ # Add animated thrust norm plot
279
+ _, update_thrust = viser.add_animated_vector_norm_plot(
280
+ server, results, "thrust",
281
+ title="Thrust Magnitude",
282
+ bounds=(0.0, max_thrust),
283
+ folder_name="Control Plots"
284
+ )
285
+ if update_thrust is not None:
286
+ update_callbacks.append(update_thrust)
287
+ """
288
+ from openscvx.plotting import plot_vector_norm
289
+
290
+ # Check if variable exists in results
291
+ has_in_trajectory = bool(results.trajectory) and var_name in results.trajectory
292
+ has_in_nodes = var_name in results.nodes
293
+
294
+ if not (has_in_trajectory or has_in_nodes):
295
+ import warnings
296
+
297
+ warnings.warn(f"Variable '{var_name}' not found in results, skipping plot")
298
+ return None, None
299
+
300
+ # Create figure using existing plotting function
301
+ fig = plot_vector_norm(results, var_name, bounds=bounds)
302
+
303
+ # Update title if custom title provided
304
+ if title is not None:
305
+ fig.update_layout(title_text=title)
306
+
307
+ # Determine data source and compute norms
308
+ if has_in_trajectory:
309
+ time_data = results.trajectory["time"].flatten()
310
+ var_data = results.trajectory[var_name]
311
+ use_trajectory_indexing = True
312
+ else:
313
+ time_data = results.nodes["time"].flatten()
314
+ var_data = results.nodes[var_name]
315
+ use_trajectory_indexing = False
316
+
317
+ # Compute norms
318
+ norm_data = np.linalg.norm(var_data, axis=1) if var_data.ndim > 1 else np.abs(var_data)
319
+
320
+ # Add animated marker
321
+ return add_animated_plotly_marker(
322
+ server,
323
+ fig,
324
+ time_array=time_data,
325
+ marker_x_data=time_data,
326
+ marker_y_data=norm_data,
327
+ use_trajectory_indexing=use_trajectory_indexing,
328
+ marker_name="Current",
329
+ marker_color=marker_color,
330
+ marker_size=marker_size,
331
+ folder_name=folder_name,
332
+ aspect=aspect,
333
+ )
@@ -0,0 +1,355 @@
1
+ """Static scene primitives for viser visualization.
2
+
3
+ Functions for adding non-animated elements: obstacles, gates, constraint cones,
4
+ ghost trajectories, etc. Called once during scene setup.
5
+ """
6
+
7
+ import numpy as np
8
+ import viser
9
+
10
+
11
+ def _generate_ellipsoid_mesh(
12
+ center: np.ndarray,
13
+ radii: np.ndarray,
14
+ axes: np.ndarray | None = None,
15
+ subdivisions: int = 2,
16
+ ) -> tuple[np.ndarray, np.ndarray]:
17
+ """Generate ellipsoid mesh vertices and faces via icosphere subdivision.
18
+
19
+ Args:
20
+ center: Center position (3,)
21
+ radii: Radii along each principal axis (3,)
22
+ axes: Rotation matrix (3, 3) defining principal axes. If None, uses identity.
23
+ subdivisions: Number of icosphere subdivisions (higher = smoother)
24
+
25
+ Returns:
26
+ Tuple of (vertices, faces) where vertices is (V, 3) and faces is (F, 3)
27
+ """
28
+ # Start with icosahedron vertices
29
+ phi = (1.0 + np.sqrt(5.0)) / 2.0 # Golden ratio
30
+ icosahedron_verts = np.array(
31
+ [
32
+ [-1, phi, 0],
33
+ [1, phi, 0],
34
+ [-1, -phi, 0],
35
+ [1, -phi, 0],
36
+ [0, -1, phi],
37
+ [0, 1, phi],
38
+ [0, -1, -phi],
39
+ [0, 1, -phi],
40
+ [phi, 0, -1],
41
+ [phi, 0, 1],
42
+ [-phi, 0, -1],
43
+ [-phi, 0, 1],
44
+ ],
45
+ dtype=np.float64,
46
+ )
47
+ # Normalize to unit sphere
48
+ icosahedron_verts /= np.linalg.norm(icosahedron_verts[0])
49
+
50
+ icosahedron_faces = np.array(
51
+ [
52
+ [0, 11, 5],
53
+ [0, 5, 1],
54
+ [0, 1, 7],
55
+ [0, 7, 10],
56
+ [0, 10, 11],
57
+ [1, 5, 9],
58
+ [5, 11, 4],
59
+ [11, 10, 2],
60
+ [10, 7, 6],
61
+ [7, 1, 8],
62
+ [3, 9, 4],
63
+ [3, 4, 2],
64
+ [3, 2, 6],
65
+ [3, 6, 8],
66
+ [3, 8, 9],
67
+ [4, 9, 5],
68
+ [2, 4, 11],
69
+ [6, 2, 10],
70
+ [8, 6, 7],
71
+ [9, 8, 1],
72
+ ],
73
+ dtype=np.int32,
74
+ )
75
+
76
+ vertices = icosahedron_verts
77
+ faces = icosahedron_faces
78
+
79
+ # Subdivide faces
80
+ for _ in range(subdivisions):
81
+ new_faces = []
82
+ midpoint_cache = {}
83
+
84
+ def get_midpoint(i1: int, i2: int) -> int:
85
+ """Get or create midpoint vertex between two vertices."""
86
+ key = (min(i1, i2), max(i1, i2))
87
+ if key in midpoint_cache:
88
+ return midpoint_cache[key]
89
+
90
+ nonlocal vertices
91
+ p1, p2 = vertices[i1], vertices[i2]
92
+ mid = (p1 + p2) / 2.0
93
+ mid = mid / np.linalg.norm(mid) # Project onto unit sphere
94
+
95
+ idx = len(vertices)
96
+ vertices = np.vstack([vertices, mid])
97
+ midpoint_cache[key] = idx
98
+ return idx
99
+
100
+ for tri in faces:
101
+ v0, v1, v2 = tri
102
+ a = get_midpoint(v0, v1)
103
+ b = get_midpoint(v1, v2)
104
+ c = get_midpoint(v2, v0)
105
+ new_faces.extend([[v0, a, c], [v1, b, a], [v2, c, b], [a, b, c]])
106
+
107
+ faces = np.array(new_faces, dtype=np.int32)
108
+
109
+ # Scale by radii to create ellipsoid
110
+ vertices = vertices / radii
111
+
112
+ # Rotate by principal axes if provided
113
+ if axes is not None:
114
+ vertices = vertices @ axes.T
115
+
116
+ # Translate to center
117
+ vertices = vertices + center
118
+
119
+ return vertices.astype(np.float32), faces
120
+
121
+
122
+ def add_ellipsoid_obstacles(
123
+ server: viser.ViserServer,
124
+ centers: list[np.ndarray],
125
+ radii: list[np.ndarray],
126
+ axes: list[np.ndarray] | None = None,
127
+ color: tuple[int, int, int] = (255, 100, 100),
128
+ opacity: float = 0.6,
129
+ wireframe: bool = False,
130
+ subdivisions: int = 2,
131
+ ) -> list:
132
+ """Add ellipsoidal obstacles to the scene.
133
+
134
+ Args:
135
+ server: ViserServer instance
136
+ centers: List of center positions, each shape (3,)
137
+ radii: List of radii along principal axes, each shape (3,)
138
+ axes: List of rotation matrices (3, 3) defining principal axes.
139
+ If None, ellipsoids are axis-aligned.
140
+ color: RGB color tuple
141
+ opacity: Opacity (0-1), only used when wireframe=False
142
+ wireframe: If True, render as wireframe instead of solid
143
+ subdivisions: Icosphere subdivisions (higher = smoother, 2 is usually good)
144
+
145
+ Returns:
146
+ List of mesh handles
147
+ """
148
+ handles = []
149
+
150
+ if axes is None:
151
+ axes = [None] * len(centers)
152
+
153
+ for i, (center, rad, ax) in enumerate(zip(centers, radii, axes)):
154
+ # Convert JAX arrays to numpy if needed
155
+ center = np.asarray(center, dtype=np.float64)
156
+ rad = np.asarray(rad, dtype=np.float64)
157
+ if ax is not None:
158
+ ax = np.asarray(ax, dtype=np.float64)
159
+
160
+ vertices, faces = _generate_ellipsoid_mesh(center, rad, ax, subdivisions)
161
+
162
+ handle = server.scene.add_mesh_simple(
163
+ f"/obstacles/ellipsoid_{i}",
164
+ vertices=vertices,
165
+ faces=faces,
166
+ color=color,
167
+ wireframe=wireframe,
168
+ opacity=opacity if not wireframe else 1.0,
169
+ )
170
+ handles.append(handle)
171
+
172
+ return handles
173
+
174
+
175
+ def add_gates(
176
+ server: viser.ViserServer,
177
+ vertices: list,
178
+ color: tuple[int, int, int] = (255, 165, 0),
179
+ line_width: float = 3.0,
180
+ ) -> None:
181
+ """Add gate/obstacle wireframes to the scene.
182
+
183
+ Args:
184
+ server: ViserServer instance
185
+ vertices: List of vertex arrays (4 vertices for planar gate, 8 for box)
186
+ color: RGB color tuple
187
+ line_width: Line width for wireframe
188
+ """
189
+ for i, verts in enumerate(vertices):
190
+ verts = np.array(verts)
191
+ n_verts = len(verts)
192
+
193
+ if n_verts == 4:
194
+ # Planar gate: 4 vertices forming a closed loop
195
+ edges = [[0, 1], [1, 2], [2, 3], [3, 0]]
196
+ elif n_verts == 8:
197
+ # 3D box: 8 vertices
198
+ edges = [
199
+ [0, 1],
200
+ [1, 2],
201
+ [2, 3],
202
+ [3, 0], # front face
203
+ [4, 5],
204
+ [5, 6],
205
+ [6, 7],
206
+ [7, 4], # back face
207
+ [0, 4],
208
+ [1, 5],
209
+ [2, 6],
210
+ [3, 7], # connecting edges
211
+ ]
212
+ else:
213
+ # Unknown format, skip
214
+ continue
215
+
216
+ # Shape (N, 2, 3) for N line segments
217
+ points = np.array([[verts[e[0]], verts[e[1]]] for e in edges])
218
+ server.scene.add_line_segments(
219
+ f"/gates/gate_{i}",
220
+ points=points,
221
+ colors=color,
222
+ line_width=line_width,
223
+ )
224
+
225
+
226
+ def _generate_cone_mesh(
227
+ apex: np.ndarray,
228
+ height: float,
229
+ half_angle_deg: float,
230
+ n_segments: int = 32,
231
+ ) -> tuple[np.ndarray, np.ndarray]:
232
+ """Generate a cone mesh with apex at given position, opening upward.
233
+
234
+ Args:
235
+ apex: Apex position (3,) - the tip of the cone
236
+ height: Height of the cone (extends in +Z direction from apex)
237
+ half_angle_deg: Half-angle of the cone from the vertical axis in degrees
238
+ n_segments: Number of segments around the circumference
239
+
240
+ Returns:
241
+ Tuple of (vertices, faces) where vertices is (V, 3) and faces is (F, 3)
242
+ """
243
+ half_angle_rad = np.radians(half_angle_deg)
244
+ base_radius = height * np.tan(half_angle_rad)
245
+
246
+ # Vertices: apex + base circle points
247
+ vertices = [apex.copy()] # Apex at index 0
248
+
249
+ # Base circle vertices
250
+ for i in range(n_segments):
251
+ angle = 2 * np.pi * i / n_segments
252
+ x = apex[0] + base_radius * np.cos(angle)
253
+ y = apex[1] + base_radius * np.sin(angle)
254
+ z = apex[2] + height
255
+ vertices.append([x, y, z])
256
+
257
+ # Center of base for closing the bottom
258
+ base_center = apex.copy()
259
+ base_center[2] += height
260
+ vertices.append(base_center) # Index n_segments + 1
261
+
262
+ vertices = np.array(vertices, dtype=np.float32)
263
+
264
+ # Faces: triangles from apex to base edge pairs, plus base cap
265
+ faces = []
266
+
267
+ # Side faces (apex to each edge of base)
268
+ for i in range(n_segments):
269
+ next_i = (i + 1) % n_segments
270
+ # Triangle: apex, base[i], base[next_i]
271
+ faces.append([0, i + 1, next_i + 1])
272
+
273
+ # Base cap faces (center to each edge)
274
+ base_center_idx = n_segments + 1
275
+ for i in range(n_segments):
276
+ next_i = (i + 1) % n_segments
277
+ # Triangle: center, base[next_i], base[i] (reverse winding for outward normal)
278
+ faces.append([base_center_idx, next_i + 1, i + 1])
279
+
280
+ faces = np.array(faces, dtype=np.int32)
281
+
282
+ return vertices, faces
283
+
284
+
285
+ def add_glideslope_cone(
286
+ server: viser.ViserServer,
287
+ apex: np.ndarray | tuple = (0.0, 0.0, 0.0),
288
+ height: float = 2000.0,
289
+ glideslope_angle_deg: float = 86.0,
290
+ color: tuple[int, int, int] = (100, 200, 100),
291
+ opacity: float = 0.2,
292
+ wireframe: bool = False,
293
+ n_segments: int = 32,
294
+ ) -> viser.MeshHandle:
295
+ """Add a glideslope constraint cone to the scene.
296
+
297
+ The glideslope constraint typically has the form:
298
+ ||position_xy|| <= tan(angle) * position_z
299
+
300
+ This creates a cone with apex at the landing site, opening upward.
301
+
302
+ Args:
303
+ server: ViserServer instance
304
+ apex: Apex position (landing site), default is origin
305
+ height: Height of the cone visualization
306
+ glideslope_angle_deg: Glideslope angle in degrees (measured from vertical).
307
+ For constraint ||r_xy|| <= tan(theta) * z, pass theta here.
308
+ Common values: 86 deg (very wide), 70 deg (moderate), 45 deg (steep)
309
+ color: RGB color tuple
310
+ opacity: Opacity (0-1)
311
+ wireframe: If True, render as wireframe
312
+ n_segments: Number of segments for cone smoothness
313
+
314
+ Returns:
315
+ Mesh handle for the cone
316
+ """
317
+ apex = np.asarray(apex, dtype=np.float32)
318
+
319
+ vertices, faces = _generate_cone_mesh(apex, height, glideslope_angle_deg, n_segments)
320
+
321
+ handle = server.scene.add_mesh_simple(
322
+ "/constraints/glideslope_cone",
323
+ vertices=vertices,
324
+ faces=faces,
325
+ color=color,
326
+ wireframe=wireframe,
327
+ opacity=opacity if not wireframe else 1.0,
328
+ )
329
+
330
+ return handle
331
+
332
+
333
+ def add_ghost_trajectory(
334
+ server: viser.ViserServer,
335
+ pos: np.ndarray,
336
+ colors: np.ndarray,
337
+ opacity: float = 0.3,
338
+ point_size: float = 0.05,
339
+ ) -> None:
340
+ """Add a faint ghost trajectory showing the full path.
341
+
342
+ Args:
343
+ server: ViserServer instance
344
+ pos: Position array of shape (N, 3)
345
+ colors: RGB color array of shape (N, 3)
346
+ opacity: Opacity factor (0-1) applied to colors
347
+ point_size: Size of trajectory points
348
+ """
349
+ ghost_colors = (colors * opacity).astype(np.uint8)
350
+ server.scene.add_point_cloud(
351
+ "/ghost_traj",
352
+ points=pos,
353
+ colors=ghost_colors,
354
+ point_size=point_size,
355
+ )