BulletLab 0.1.2__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.
- bulletlab/__init__.py +48 -0
- bulletlab/core/__init__.py +10 -0
- bulletlab/core/simulation.py +377 -0
- bulletlab/core/world.py +173 -0
- bulletlab/logging/__init__.py +9 -0
- bulletlab/logging/csv_writer.py +54 -0
- bulletlab/logging/json_writer.py +70 -0
- bulletlab/logging/logger.py +258 -0
- bulletlab/plotting/__init__.py +9 -0
- bulletlab/plotting/live_plot.py +364 -0
- bulletlab/robot/__init__.py +12 -0
- bulletlab/robot/joint.py +402 -0
- bulletlab/robot/link.py +401 -0
- bulletlab/robot/robot.py +533 -0
- bulletlab/telemetry/__init__.py +10 -0
- bulletlab/telemetry/channel.py +124 -0
- bulletlab/telemetry/manager.py +227 -0
- bulletlab/ui/__init__.py +31 -0
- bulletlab/ui/app.py +543 -0
- bulletlab/ui/panels/__init__.py +16 -0
- bulletlab/ui/panels/console.py +189 -0
- bulletlab/ui/panels/explorer.py +144 -0
- bulletlab/ui/panels/plots.py +143 -0
- bulletlab/ui/panels/properties.py +265 -0
- bulletlab/ui/panels/telemetry.py +105 -0
- bulletlab/ui/widgets.py +348 -0
- bulletlab/utils/__init__.py +26 -0
- bulletlab/utils/math_utils.py +183 -0
- bulletlab/utils/timer.py +107 -0
- bulletlab/utils/urdf_utils.py +114 -0
- bulletlab-0.1.2.dist-info/METADATA +284 -0
- bulletlab-0.1.2.dist-info/RECORD +35 -0
- bulletlab-0.1.2.dist-info/WHEEL +5 -0
- bulletlab-0.1.2.dist-info/licenses/LICENSE +21 -0
- bulletlab-0.1.2.dist-info/top_level.txt +1 -0
bulletlab/ui/app.py
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BulletLabUI – the main ImGui control window for BulletLab.
|
|
3
|
+
|
|
4
|
+
Opens a separate Dear ImGui window (using GLFW + OpenGL backend) alongside
|
|
5
|
+
the PyBullet simulation window. All built-in panels are shown by default;
|
|
6
|
+
custom panels can be added via decorators or direct registration.
|
|
7
|
+
|
|
8
|
+
Architecture note:
|
|
9
|
+
This window is completely independent of PyBullet's renderer.
|
|
10
|
+
PyBullet handles physics + 3D visualization.
|
|
11
|
+
BulletLabUI handles parameter editing, telemetry, and console.
|
|
12
|
+
|
|
13
|
+
Example::
|
|
14
|
+
|
|
15
|
+
from bulletlab.ui import BulletLabUI
|
|
16
|
+
|
|
17
|
+
app = BulletLabUI(sim=sim, robots=[robot], telemetry=telemetry)
|
|
18
|
+
app.run() # blocking
|
|
19
|
+
|
|
20
|
+
Non-blocking step mode::
|
|
21
|
+
|
|
22
|
+
app = BulletLabUI(sim=sim, robots=[robot])
|
|
23
|
+
app.start()
|
|
24
|
+
while True:
|
|
25
|
+
sim.step()
|
|
26
|
+
telemetry.update(t=sim.elapsed_time)
|
|
27
|
+
app.step() # render one ImGui frame
|
|
28
|
+
if app.should_close:
|
|
29
|
+
break
|
|
30
|
+
app.stop()
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import sys
|
|
36
|
+
from typing import Any, Callable, TYPE_CHECKING
|
|
37
|
+
|
|
38
|
+
# ImGui with GLFW backend
|
|
39
|
+
try:
|
|
40
|
+
import imgui
|
|
41
|
+
import imgui.integrations.glfw as imgui_glfw
|
|
42
|
+
import glfw
|
|
43
|
+
import OpenGL.GL as gl
|
|
44
|
+
|
|
45
|
+
_HAS_IMGUI = True
|
|
46
|
+
except ImportError as _imgui_err: # pragma: no cover
|
|
47
|
+
_HAS_IMGUI = False
|
|
48
|
+
imgui = None # type: ignore[assignment]
|
|
49
|
+
imgui_glfw = None # type: ignore[assignment]
|
|
50
|
+
glfw = None # type: ignore[assignment]
|
|
51
|
+
gl = None # type: ignore[assignment]
|
|
52
|
+
_IMGUI_IMPORT_ERROR = str(_imgui_err)
|
|
53
|
+
|
|
54
|
+
from bulletlab.ui.panels.explorer import ExplorerPanel
|
|
55
|
+
from bulletlab.ui.panels.properties import PropertiesPanel
|
|
56
|
+
from bulletlab.ui.panels.telemetry import TelemetryPanel
|
|
57
|
+
from bulletlab.ui.panels.console import ConsolePanel
|
|
58
|
+
from bulletlab.ui.panels.plots import PlotsPanel
|
|
59
|
+
|
|
60
|
+
if TYPE_CHECKING:
|
|
61
|
+
from bulletlab.core.simulation import Simulation
|
|
62
|
+
from bulletlab.robot.robot import Robot
|
|
63
|
+
from bulletlab.telemetry.manager import TelemetryManager
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _CustomPanel:
|
|
67
|
+
"""Container for a user-defined panel."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, title: str, render_fn: Callable[[], None]) -> None:
|
|
70
|
+
self.title = title
|
|
71
|
+
self.render_fn = render_fn
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class BulletLabUI:
|
|
75
|
+
"""Main ImGui control window for BulletLab.
|
|
76
|
+
|
|
77
|
+
Opens a GLFW + OpenGL window with Dear ImGui. Provides five built-in
|
|
78
|
+
panels (Explorer, Properties, Telemetry, Console, Plots) and allows
|
|
79
|
+
registering custom panels via :meth:`custom_panel` decorator or
|
|
80
|
+
:meth:`register_panel`.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
sim: The :class:`~bulletlab.core.simulation.Simulation` instance.
|
|
84
|
+
robots: List of robots to display in the UI.
|
|
85
|
+
telemetry: Optional :class:`~bulletlab.telemetry.manager.TelemetryManager`.
|
|
86
|
+
width: Initial window width in pixels.
|
|
87
|
+
height: Initial window height in pixels.
|
|
88
|
+
title: Window title.
|
|
89
|
+
|
|
90
|
+
Example::
|
|
91
|
+
|
|
92
|
+
app = BulletLabUI(sim=sim, robots=[robot], telemetry=telemetry)
|
|
93
|
+
app.run()
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
sim: "Simulation",
|
|
99
|
+
robots: list["Robot"] | None = None,
|
|
100
|
+
telemetry: "TelemetryManager | None" = None,
|
|
101
|
+
width: int = 600,
|
|
102
|
+
height: int = 800,
|
|
103
|
+
title: str = "BulletLab",
|
|
104
|
+
) -> None:
|
|
105
|
+
self._sim = sim
|
|
106
|
+
self._robots: list["Robot"] = list(robots or [])
|
|
107
|
+
self._telemetry = telemetry
|
|
108
|
+
self._width = width
|
|
109
|
+
self._height = height
|
|
110
|
+
self._title = title
|
|
111
|
+
|
|
112
|
+
self._window: Any = None
|
|
113
|
+
self._impl: Any = None
|
|
114
|
+
self._running = False
|
|
115
|
+
self._should_close = False
|
|
116
|
+
|
|
117
|
+
# Built-in panels
|
|
118
|
+
self._explorer: ExplorerPanel | None = None
|
|
119
|
+
self._properties: PropertiesPanel | None = None
|
|
120
|
+
self._telemetry_panel: TelemetryPanel | None = None
|
|
121
|
+
self._console: ConsolePanel | None = None
|
|
122
|
+
self._plots_panel: PlotsPanel | None = None
|
|
123
|
+
|
|
124
|
+
# Custom panels
|
|
125
|
+
self._custom_panels: list[_CustomPanel] = []
|
|
126
|
+
|
|
127
|
+
# Panel visibility flags
|
|
128
|
+
self._show_explorer = True
|
|
129
|
+
self._show_properties = True
|
|
130
|
+
self._show_telemetry = True
|
|
131
|
+
self._show_console = True
|
|
132
|
+
self._show_plots = True
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
# Lifecycle
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def start(self) -> "BulletLabUI":
|
|
139
|
+
"""Initialize the GLFW window and ImGui context.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
self, for method chaining.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ImportError: If pyimgui[glfw] or glfw is not installed.
|
|
146
|
+
|
|
147
|
+
Example::
|
|
148
|
+
|
|
149
|
+
app.start()
|
|
150
|
+
"""
|
|
151
|
+
if not _HAS_IMGUI:
|
|
152
|
+
print(
|
|
153
|
+
f"[BulletLab] UI disabled: pyimgui[glfw] not available.\n"
|
|
154
|
+
f" Install with: pip install imgui[glfw]\n"
|
|
155
|
+
f" Error: {getattr(sys.modules[__name__], '_IMGUI_IMPORT_ERROR', 'unknown')}"
|
|
156
|
+
)
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
if self._running:
|
|
160
|
+
return self
|
|
161
|
+
|
|
162
|
+
# Init GLFW
|
|
163
|
+
if not glfw.init():
|
|
164
|
+
raise RuntimeError("GLFW initialization failed.")
|
|
165
|
+
|
|
166
|
+
glfw.window_hint(glfw.CONTEXT_VERSION_MAJOR, 3)
|
|
167
|
+
glfw.window_hint(glfw.CONTEXT_VERSION_MINOR, 3)
|
|
168
|
+
glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE)
|
|
169
|
+
glfw.window_hint(glfw.OPENGL_FORWARD_COMPAT, gl.GL_TRUE)
|
|
170
|
+
|
|
171
|
+
self._window = glfw.create_window(
|
|
172
|
+
self._width, self._height, self._title, None, None
|
|
173
|
+
)
|
|
174
|
+
if not self._window:
|
|
175
|
+
glfw.terminate()
|
|
176
|
+
raise RuntimeError("Failed to create GLFW window.")
|
|
177
|
+
|
|
178
|
+
# Set window icon from assets/logo.png
|
|
179
|
+
self._set_window_icon()
|
|
180
|
+
|
|
181
|
+
glfw.make_context_current(self._window)
|
|
182
|
+
glfw.swap_interval(1) # vsync
|
|
183
|
+
|
|
184
|
+
# Style ImGui
|
|
185
|
+
imgui.create_context()
|
|
186
|
+
self._apply_style()
|
|
187
|
+
|
|
188
|
+
self._impl = imgui_glfw.GlfwRenderer(self._window)
|
|
189
|
+
|
|
190
|
+
# Build panels
|
|
191
|
+
self._build_panels()
|
|
192
|
+
self._running = True
|
|
193
|
+
return self
|
|
194
|
+
|
|
195
|
+
def stop(self) -> None:
|
|
196
|
+
"""Shut down the ImGui window and free GLFW resources.
|
|
197
|
+
|
|
198
|
+
Example::
|
|
199
|
+
|
|
200
|
+
app.stop()
|
|
201
|
+
"""
|
|
202
|
+
if not self._running:
|
|
203
|
+
return
|
|
204
|
+
self._running = False
|
|
205
|
+
if self._impl is not None:
|
|
206
|
+
self._impl.shutdown()
|
|
207
|
+
if self._window is not None and glfw is not None:
|
|
208
|
+
glfw.destroy_window(self._window)
|
|
209
|
+
glfw.terminate()
|
|
210
|
+
self._window = None
|
|
211
|
+
self._impl = None
|
|
212
|
+
|
|
213
|
+
def _set_window_icon(self) -> None:
|
|
214
|
+
"""Load assets/logo.png and set it as the GLFW window icon.
|
|
215
|
+
|
|
216
|
+
Silently skips if Pillow is not installed or the file is missing.
|
|
217
|
+
The icon is displayed in the OS taskbar and the window title bar.
|
|
218
|
+
"""
|
|
219
|
+
try:
|
|
220
|
+
from PIL import Image
|
|
221
|
+
import numpy as np
|
|
222
|
+
from pathlib import Path
|
|
223
|
+
|
|
224
|
+
# Search: next to this file, then from CWD, then from repo root
|
|
225
|
+
candidates = [
|
|
226
|
+
Path(__file__).parent.parent.parent / "assets" / "logo.png",
|
|
227
|
+
Path.cwd() / "assets" / "logo.png",
|
|
228
|
+
]
|
|
229
|
+
icon_path = next((p for p in candidates if p.exists()), None)
|
|
230
|
+
if icon_path is None:
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
img = Image.open(icon_path).convert("RGBA").resize((64, 64), Image.LANCZOS)
|
|
234
|
+
pixels = np.array(img, dtype=np.uint8)
|
|
235
|
+
glfw.set_window_icon(self._window, 1, [pixels])
|
|
236
|
+
except Exception:
|
|
237
|
+
pass # non-fatal — icon is cosmetic only
|
|
238
|
+
|
|
239
|
+
# ------------------------------------------------------------------
|
|
240
|
+
# Main loops
|
|
241
|
+
# ------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def run(self) -> None:
|
|
244
|
+
"""Start the BulletLabUI event loop (blocking).
|
|
245
|
+
|
|
246
|
+
This loop runs until the window is closed. For non-blocking usage,
|
|
247
|
+
call :meth:`start` and then :meth:`step` in your own simulation loop.
|
|
248
|
+
|
|
249
|
+
Example::
|
|
250
|
+
|
|
251
|
+
app.run()
|
|
252
|
+
"""
|
|
253
|
+
self.start()
|
|
254
|
+
if not _HAS_IMGUI or not self._running:
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
while not glfw.window_should_close(self._window):
|
|
258
|
+
self.step()
|
|
259
|
+
|
|
260
|
+
self.stop()
|
|
261
|
+
|
|
262
|
+
def step(self) -> None:
|
|
263
|
+
"""Render one ImGui frame.
|
|
264
|
+
|
|
265
|
+
Call this once per simulation step in your own loop.
|
|
266
|
+
|
|
267
|
+
Example::
|
|
268
|
+
|
|
269
|
+
while True:
|
|
270
|
+
sim.step()
|
|
271
|
+
telemetry.update(t=sim.elapsed_time)
|
|
272
|
+
app.step()
|
|
273
|
+
if app.should_close:
|
|
274
|
+
break
|
|
275
|
+
"""
|
|
276
|
+
if not _HAS_IMGUI or not self._running:
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
if glfw.window_should_close(self._window):
|
|
280
|
+
self._should_close = True
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
glfw.poll_events()
|
|
284
|
+
self._impl.process_inputs()
|
|
285
|
+
|
|
286
|
+
imgui.new_frame()
|
|
287
|
+
self._render_frame()
|
|
288
|
+
imgui.render()
|
|
289
|
+
|
|
290
|
+
gl.glClearColor(0.1, 0.1, 0.12, 1.0)
|
|
291
|
+
gl.glClear(gl.GL_COLOR_BUFFER_BIT)
|
|
292
|
+
self._impl.render(imgui.get_draw_data())
|
|
293
|
+
glfw.swap_buffers(self._window)
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def should_close(self) -> bool:
|
|
297
|
+
"""``True`` if the UI window has been closed by the user."""
|
|
298
|
+
return self._should_close
|
|
299
|
+
|
|
300
|
+
# ------------------------------------------------------------------
|
|
301
|
+
# Frame rendering
|
|
302
|
+
# ------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
def _render_frame(self) -> None:
|
|
305
|
+
"""Render all panels inside a single full-screen ImGui window."""
|
|
306
|
+
self._render_main_menu()
|
|
307
|
+
|
|
308
|
+
w, h = glfw.get_window_size(self._window)
|
|
309
|
+
menu_h = 20 # approx height of the main menu bar
|
|
310
|
+
|
|
311
|
+
# One full-screen, non-movable, non-resizable window that fills the
|
|
312
|
+
# entire GLFW client area below the menu bar.
|
|
313
|
+
imgui.set_next_window_position(0, menu_h)
|
|
314
|
+
imgui.set_next_window_size(w, h - menu_h)
|
|
315
|
+
imgui.begin(
|
|
316
|
+
"##main",
|
|
317
|
+
flags=(
|
|
318
|
+
imgui.WINDOW_NO_TITLE_BAR
|
|
319
|
+
| imgui.WINDOW_NO_RESIZE
|
|
320
|
+
| imgui.WINDOW_NO_MOVE
|
|
321
|
+
),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# ── Custom panels (shown first so they're immediately visible) ──────
|
|
325
|
+
for cp in self._custom_panels:
|
|
326
|
+
label = cp.title
|
|
327
|
+
if imgui.collapsing_header(label, flags=imgui.TREE_NODE_DEFAULT_OPEN)[0]:
|
|
328
|
+
imgui.indent(8)
|
|
329
|
+
cp.render_fn()
|
|
330
|
+
imgui.unindent(8)
|
|
331
|
+
imgui.spacing()
|
|
332
|
+
|
|
333
|
+
# ── Built-in panels ──────────────────────────────────────────────────
|
|
334
|
+
if self._show_explorer and self._explorer is not None:
|
|
335
|
+
if imgui.collapsing_header("Explorer", flags=imgui.TREE_NODE_DEFAULT_OPEN)[0]:
|
|
336
|
+
imgui.indent(8)
|
|
337
|
+
self._explorer.render()
|
|
338
|
+
imgui.unindent(8)
|
|
339
|
+
imgui.spacing()
|
|
340
|
+
|
|
341
|
+
if self._show_properties and self._properties is not None:
|
|
342
|
+
if self._explorer is not None:
|
|
343
|
+
self._properties.set_target(self._explorer.selected_object)
|
|
344
|
+
if imgui.collapsing_header("Properties", flags=imgui.TREE_NODE_DEFAULT_OPEN)[0]:
|
|
345
|
+
imgui.indent(8)
|
|
346
|
+
self._properties.render()
|
|
347
|
+
imgui.unindent(8)
|
|
348
|
+
imgui.spacing()
|
|
349
|
+
|
|
350
|
+
if self._show_telemetry and self._telemetry_panel is not None:
|
|
351
|
+
if imgui.collapsing_header("Telemetry", flags=imgui.TREE_NODE_DEFAULT_OPEN)[0]:
|
|
352
|
+
imgui.indent(8)
|
|
353
|
+
self._telemetry_panel.render()
|
|
354
|
+
imgui.unindent(8)
|
|
355
|
+
imgui.spacing()
|
|
356
|
+
|
|
357
|
+
if self._show_plots and self._plots_panel is not None:
|
|
358
|
+
if imgui.collapsing_header("Live Plots", flags=imgui.TREE_NODE_DEFAULT_OPEN)[0]:
|
|
359
|
+
imgui.indent(8)
|
|
360
|
+
self._plots_panel.render()
|
|
361
|
+
imgui.unindent(8)
|
|
362
|
+
imgui.spacing()
|
|
363
|
+
|
|
364
|
+
if self._show_console and self._console is not None:
|
|
365
|
+
if imgui.collapsing_header("Console", flags=imgui.TREE_NODE_DEFAULT_OPEN)[0]:
|
|
366
|
+
imgui.indent(8)
|
|
367
|
+
self._console.render()
|
|
368
|
+
imgui.unindent(8)
|
|
369
|
+
imgui.spacing()
|
|
370
|
+
|
|
371
|
+
imgui.end()
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _render_main_menu(self) -> None:
|
|
375
|
+
"""Render the main menu bar."""
|
|
376
|
+
if imgui.begin_main_menu_bar():
|
|
377
|
+
if imgui.begin_menu("View"):
|
|
378
|
+
_, self._show_explorer = imgui.menu_item(
|
|
379
|
+
"Explorer", selected=self._show_explorer
|
|
380
|
+
)
|
|
381
|
+
_, self._show_properties = imgui.menu_item(
|
|
382
|
+
"Properties", selected=self._show_properties
|
|
383
|
+
)
|
|
384
|
+
_, self._show_telemetry = imgui.menu_item(
|
|
385
|
+
"Telemetry", selected=self._show_telemetry
|
|
386
|
+
)
|
|
387
|
+
_, self._show_plots = imgui.menu_item(
|
|
388
|
+
"Plots", selected=self._show_plots
|
|
389
|
+
)
|
|
390
|
+
_, self._show_console = imgui.menu_item(
|
|
391
|
+
"Console", selected=self._show_console
|
|
392
|
+
)
|
|
393
|
+
imgui.end_menu()
|
|
394
|
+
|
|
395
|
+
if imgui.begin_menu("Simulation"):
|
|
396
|
+
if imgui.menu_item("Pause")[0] and not self._sim.is_paused:
|
|
397
|
+
self._sim.pause()
|
|
398
|
+
if imgui.menu_item("Resume")[0] and self._sim.is_paused:
|
|
399
|
+
self._sim.resume()
|
|
400
|
+
if imgui.menu_item("Reset")[0]:
|
|
401
|
+
self._sim.reset()
|
|
402
|
+
imgui.end_menu()
|
|
403
|
+
|
|
404
|
+
# Status bar
|
|
405
|
+
sim_status = "⏸ Paused" if self._sim.is_paused else "▶ Running"
|
|
406
|
+
imgui.same_line(spacing=20)
|
|
407
|
+
imgui.text(
|
|
408
|
+
f" {sim_status} | "
|
|
409
|
+
f"Step: {self._sim.step_count} | "
|
|
410
|
+
f"t={self._sim.elapsed_time:.2f}s | "
|
|
411
|
+
f"Robots: {len(self._robots)}"
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
imgui.end_main_menu_bar()
|
|
415
|
+
|
|
416
|
+
# ------------------------------------------------------------------
|
|
417
|
+
# Panel management
|
|
418
|
+
# ------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
def _build_panels(self) -> None:
|
|
421
|
+
"""Instantiate all built-in panels."""
|
|
422
|
+
self._explorer = ExplorerPanel(
|
|
423
|
+
sim=self._sim,
|
|
424
|
+
robots=self._robots,
|
|
425
|
+
)
|
|
426
|
+
self._properties = PropertiesPanel()
|
|
427
|
+
|
|
428
|
+
if self._telemetry is not None:
|
|
429
|
+
self._telemetry_panel = TelemetryPanel(self._telemetry)
|
|
430
|
+
self._plots_panel = PlotsPanel(self._telemetry)
|
|
431
|
+
else:
|
|
432
|
+
# Create empty telemetry so panels render gracefully
|
|
433
|
+
from bulletlab.telemetry import TelemetryManager
|
|
434
|
+
_empty = TelemetryManager()
|
|
435
|
+
self._telemetry_panel = TelemetryPanel(_empty)
|
|
436
|
+
self._plots_panel = PlotsPanel(_empty)
|
|
437
|
+
|
|
438
|
+
ns = {"sim": self._sim}
|
|
439
|
+
for i, r in enumerate(self._robots):
|
|
440
|
+
ns[r.name] = r
|
|
441
|
+
if i == 0:
|
|
442
|
+
ns["robot"] = r
|
|
443
|
+
if self._telemetry is not None:
|
|
444
|
+
ns["telemetry"] = self._telemetry
|
|
445
|
+
self._console = ConsolePanel(namespace=ns)
|
|
446
|
+
|
|
447
|
+
def register_panel(self, title: str, render_fn: Callable[[], None]) -> None:
|
|
448
|
+
"""Register a custom panel.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
title: Panel window title.
|
|
452
|
+
render_fn: Function that renders the panel content using
|
|
453
|
+
``bulletlab.ui.widgets`` or raw imgui calls.
|
|
454
|
+
|
|
455
|
+
Example::
|
|
456
|
+
|
|
457
|
+
def my_controls():
|
|
458
|
+
ui.button("Reset", robot.reset)
|
|
459
|
+
ui.slider("Speed", lambda: target_speed, 0, 20,
|
|
460
|
+
setter=lambda v: set_target_speed(v))
|
|
461
|
+
|
|
462
|
+
app.register_panel("My Controls", my_controls)
|
|
463
|
+
"""
|
|
464
|
+
self._custom_panels.append(_CustomPanel(title=title, render_fn=render_fn))
|
|
465
|
+
|
|
466
|
+
def custom_panel(self, title: str) -> Callable[[Callable[[], None]], Callable[[], None]]:
|
|
467
|
+
"""Decorator for registering a custom panel.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
title: Panel window title.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
Decorator that registers the function as a panel.
|
|
474
|
+
|
|
475
|
+
Example::
|
|
476
|
+
|
|
477
|
+
@app.custom_panel("My Controls")
|
|
478
|
+
def my_controls():
|
|
479
|
+
ui.button("Reset", robot.reset)
|
|
480
|
+
"""
|
|
481
|
+
def decorator(fn: Callable[[], None]) -> Callable[[], None]:
|
|
482
|
+
self.register_panel(title, fn)
|
|
483
|
+
return fn
|
|
484
|
+
return decorator
|
|
485
|
+
|
|
486
|
+
def add_robot(self, robot: "Robot") -> None:
|
|
487
|
+
"""Add a robot to the UI (explorer and console namespace).
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
robot: The robot to add.
|
|
491
|
+
"""
|
|
492
|
+
if robot not in self._robots:
|
|
493
|
+
self._robots.append(robot)
|
|
494
|
+
if self._explorer is not None:
|
|
495
|
+
self._explorer.add_robot(robot)
|
|
496
|
+
if self._console is not None:
|
|
497
|
+
self._console.update_namespace({robot.name: robot, "robot": robot})
|
|
498
|
+
|
|
499
|
+
# ------------------------------------------------------------------
|
|
500
|
+
# Styling
|
|
501
|
+
# ------------------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
def _apply_style(self) -> None:
|
|
504
|
+
"""Apply a dark, modern ImGui theme."""
|
|
505
|
+
style = imgui.get_style()
|
|
506
|
+
|
|
507
|
+
# Colors
|
|
508
|
+
style.colors[imgui.COLOR_WINDOW_BACKGROUND] = (0.10, 0.10, 0.13, 0.98)
|
|
509
|
+
style.colors[imgui.COLOR_TITLE_BACKGROUND] = (0.15, 0.15, 0.20, 1.0)
|
|
510
|
+
style.colors[imgui.COLOR_TITLE_BACKGROUND_ACTIVE] = (0.20, 0.25, 0.35, 1.0)
|
|
511
|
+
style.colors[imgui.COLOR_BUTTON] = (0.20, 0.40, 0.65, 0.8)
|
|
512
|
+
style.colors[imgui.COLOR_BUTTON_HOVERED] = (0.30, 0.55, 0.80, 1.0)
|
|
513
|
+
style.colors[imgui.COLOR_BUTTON_ACTIVE] = (0.15, 0.30, 0.55, 1.0)
|
|
514
|
+
style.colors[imgui.COLOR_FRAME_BACKGROUND] = (0.18, 0.18, 0.22, 1.0)
|
|
515
|
+
style.colors[imgui.COLOR_FRAME_BACKGROUND_HOVERED] = (0.22, 0.22, 0.28, 1.0)
|
|
516
|
+
style.colors[imgui.COLOR_HEADER] = (0.20, 0.30, 0.45, 0.8)
|
|
517
|
+
style.colors[imgui.COLOR_HEADER_HOVERED] = (0.25, 0.38, 0.55, 1.0)
|
|
518
|
+
style.colors[imgui.COLOR_HEADER_ACTIVE] = (0.15, 0.25, 0.40, 1.0)
|
|
519
|
+
style.colors[imgui.COLOR_SLIDER_GRAB] = (0.40, 0.65, 0.90, 1.0)
|
|
520
|
+
style.colors[imgui.COLOR_SLIDER_GRAB_ACTIVE] = (0.55, 0.80, 1.0, 1.0)
|
|
521
|
+
style.colors[imgui.COLOR_CHECK_MARK] = (0.40, 0.90, 0.40, 1.0)
|
|
522
|
+
style.colors[imgui.COLOR_SEPARATOR] = (0.30, 0.30, 0.40, 1.0)
|
|
523
|
+
style.colors[imgui.COLOR_MENUBAR_BACKGROUND] = (0.12, 0.12, 0.16, 1.0)
|
|
524
|
+
style.colors[imgui.COLOR_POPUP_BACKGROUND] = (0.12, 0.12, 0.16, 0.98)
|
|
525
|
+
style.colors[imgui.COLOR_TEXT] = (0.90, 0.90, 0.95, 1.0)
|
|
526
|
+
|
|
527
|
+
# Sizing
|
|
528
|
+
style.window_rounding = 6.0
|
|
529
|
+
style.frame_rounding = 4.0
|
|
530
|
+
style.scrollbar_rounding = 4.0
|
|
531
|
+
style.grab_rounding = 4.0
|
|
532
|
+
style.tab_rounding = 4.0
|
|
533
|
+
style.window_padding = (10.0, 8.0)
|
|
534
|
+
style.frame_padding = (6.0, 4.0)
|
|
535
|
+
style.item_spacing = (8.0, 6.0)
|
|
536
|
+
|
|
537
|
+
# ------------------------------------------------------------------
|
|
538
|
+
# Repr
|
|
539
|
+
# ------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
def __repr__(self) -> str:
|
|
542
|
+
status = "running" if self._running else "stopped"
|
|
543
|
+
return f"BulletLabUI({self._title!r}, {status})"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
UI panels subpackage.
|
|
3
|
+
"""
|
|
4
|
+
from bulletlab.ui.panels.explorer import ExplorerPanel
|
|
5
|
+
from bulletlab.ui.panels.properties import PropertiesPanel
|
|
6
|
+
from bulletlab.ui.panels.telemetry import TelemetryPanel
|
|
7
|
+
from bulletlab.ui.panels.console import ConsolePanel
|
|
8
|
+
from bulletlab.ui.panels.plots import PlotsPanel
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ExplorerPanel",
|
|
12
|
+
"PropertiesPanel",
|
|
13
|
+
"TelemetryPanel",
|
|
14
|
+
"ConsolePanel",
|
|
15
|
+
"PlotsPanel",
|
|
16
|
+
]
|