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/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
+ ]