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.
@@ -0,0 +1,189 @@
1
+ """
2
+ ConsolePanel – interactive Python command console in the BulletLab UI.
3
+
4
+ Provides a single-line input field and a scrollable output log. Commands
5
+ are executed via exec() in a configurable namespace, making robot objects
6
+ and sim directly accessible.
7
+
8
+ Example::
9
+
10
+ from bulletlab.ui.panels.console import ConsolePanel
11
+
12
+ console = ConsolePanel(namespace={"sim": sim, "robot": robot})
13
+ console.render()
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import traceback
19
+ from collections import deque
20
+ from typing import Any
21
+
22
+ try:
23
+ import imgui
24
+
25
+ _HAS_IMGUI = True
26
+ except ImportError: # pragma: no cover
27
+ imgui = None # type: ignore[assignment]
28
+ _HAS_IMGUI = False
29
+
30
+
31
+ class ConsolePanel:
32
+ """Interactive Python console panel.
33
+
34
+ Executes commands via ``exec()`` in a provided namespace, with output
35
+ captured and displayed in a scrollable log.
36
+
37
+ Args:
38
+ namespace: Dictionary of variables available in the console namespace.
39
+ Typically includes ``sim``, ``robot``, ``telemetry``, etc.
40
+ max_history: Maximum number of output lines to retain.
41
+
42
+ Example::
43
+
44
+ console = ConsolePanel(namespace={"sim": sim, "robot": robot})
45
+ console.execute("robot.links['wheel'].mass = 5")
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ namespace: dict[str, Any] | None = None,
51
+ max_history: int = 200,
52
+ ) -> None:
53
+ self._namespace: dict[str, Any] = namespace if namespace is not None else {}
54
+ self._history: deque[str] = deque(maxlen=max_history)
55
+ self._input_buf: list[str] = [""] # mutable for imgui input
56
+ self._cmd_history: list[str] = []
57
+ self._cmd_index: int = -1
58
+ self._scroll_to_bottom: bool = False
59
+
60
+ # Add some helpful builtins
61
+ import builtins
62
+ self._namespace.setdefault("__builtins__", builtins)
63
+
64
+ # ------------------------------------------------------------------
65
+ # API
66
+ # ------------------------------------------------------------------
67
+
68
+ def update_namespace(self, updates: dict[str, Any]) -> None:
69
+ """Update the console execution namespace.
70
+
71
+ Args:
72
+ updates: Key-value pairs to add or overwrite.
73
+
74
+ Example::
75
+
76
+ console.update_namespace({"robot": new_robot})
77
+ """
78
+ self._namespace.update(updates)
79
+
80
+ def execute(self, command: str) -> None:
81
+ """Execute a Python command string in the console namespace.
82
+
83
+ Appends both the command and the result/error to the output log.
84
+
85
+ Args:
86
+ command: Python source code to execute.
87
+
88
+ Example::
89
+
90
+ console.execute("robot.links['wheel'].mass = 10")
91
+ """
92
+ command = command.strip()
93
+ if not command:
94
+ return
95
+
96
+ self._history.append(f">>> {command}")
97
+ self._cmd_history.append(command)
98
+ self._cmd_index = -1
99
+
100
+ try:
101
+ # Try eval first (for expressions)
102
+ try:
103
+ result = eval(command, self._namespace) # noqa: S307
104
+ if result is not None:
105
+ self._history.append(f" {result!r}")
106
+ except SyntaxError:
107
+ # Pass namespace as both globals AND locals so that
108
+ # assignments (x = 42) are written back into the shared dict.
109
+ exec(command, self._namespace, self._namespace) # noqa: S102
110
+ except Exception:
111
+ for line in traceback.format_exc().splitlines():
112
+ self._history.append(f" {line}")
113
+
114
+ self._scroll_to_bottom = True
115
+
116
+ def log(self, message: str) -> None:
117
+ """Append an informational message to the console log.
118
+
119
+ Args:
120
+ message: Text to display in the log.
121
+ """
122
+ self._history.append(f"# {message}")
123
+ self._scroll_to_bottom = True
124
+
125
+ # ------------------------------------------------------------------
126
+ # Render
127
+ # ------------------------------------------------------------------
128
+
129
+ def render(self) -> None:
130
+ """Draw the console panel contents.
131
+
132
+ Must be called inside an active ImGui window context.
133
+ """
134
+ if not _HAS_IMGUI:
135
+ return
136
+
137
+ # Output area (child window for scrolling)
138
+ avail_height = imgui.get_content_region_available()[1] - 30
139
+ imgui.begin_child(
140
+ "console_output",
141
+ width=0,
142
+ height=max(avail_height, 50),
143
+ border=True,
144
+ )
145
+
146
+ for line in self._history:
147
+ if line.startswith(">>>"):
148
+ imgui.text_colored(line, 0.4, 0.9, 0.4, 1.0)
149
+ elif line.startswith(" Traceback") or "Error" in line:
150
+ imgui.text_colored(line, 1.0, 0.3, 0.3, 1.0)
151
+ elif line.startswith("#"):
152
+ imgui.text_colored(line, 0.6, 0.6, 1.0, 1.0)
153
+ else:
154
+ imgui.text(line)
155
+
156
+ if self._scroll_to_bottom:
157
+ imgui.set_scroll_here_y(1.0)
158
+ self._scroll_to_bottom = False
159
+
160
+ imgui.end_child()
161
+
162
+ # Input field
163
+ imgui.push_item_width(-65)
164
+ enter_pressed = False
165
+
166
+ # We need to use input_text - imgui binding style
167
+ input_flags = imgui.INPUT_TEXT_ENTER_RETURNS_TRUE
168
+ changed, new_text = imgui.input_text(
169
+ "##console_input",
170
+ self._input_buf[0],
171
+ 256,
172
+ flags=input_flags,
173
+ )
174
+ self._input_buf[0] = new_text
175
+
176
+ if changed: # Enter was pressed
177
+ enter_pressed = True
178
+
179
+ imgui.pop_item_width()
180
+ imgui.same_line()
181
+
182
+ if imgui.button("Run##console_run") or enter_pressed:
183
+ cmd = self._input_buf[0].strip()
184
+ if cmd:
185
+ self.execute(cmd)
186
+ self._input_buf[0] = ""
187
+
188
+ def __repr__(self) -> str:
189
+ return f"ConsolePanel(history={len(self._history)} lines)"
@@ -0,0 +1,144 @@
1
+ """
2
+ ExplorerPanel – displays the simulation scene tree.
3
+
4
+ Shows the hierarchy: Simulation → Robots → Joints / Links.
5
+ Clicking an item fires a selection callback that the PropertiesPanel
6
+ (and other panels) can subscribe to.
7
+
8
+ Example::
9
+
10
+ from bulletlab.ui.panels.explorer import ExplorerPanel
11
+
12
+ explorer = ExplorerPanel(sim=sim, robots=[robot])
13
+ # In your render loop:
14
+ explorer.render()
15
+ selected = explorer.selected_object
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import TYPE_CHECKING, Any, Callable, Optional
21
+
22
+ try:
23
+ import imgui
24
+
25
+ _HAS_IMGUI = True
26
+ except ImportError: # pragma: no cover
27
+ imgui = None # type: ignore[assignment]
28
+ _HAS_IMGUI = False
29
+
30
+ if TYPE_CHECKING:
31
+ from bulletlab.core.simulation import Simulation
32
+ from bulletlab.robot.robot import Robot
33
+
34
+
35
+ class ExplorerPanel:
36
+ """Renders a tree view of the simulation scene.
37
+
38
+ Args:
39
+ sim: The :class:`~bulletlab.core.simulation.Simulation` instance.
40
+ robots: List of robots to display in the tree.
41
+ on_select: Optional callback called with the selected object
42
+ whenever the user clicks on a tree item.
43
+
44
+ Example::
45
+
46
+ explorer = ExplorerPanel(sim=sim, robots=[robot])
47
+ explorer.render()
48
+ selected = explorer.selected_object
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ sim: "Simulation",
54
+ robots: list["Robot"] | None = None,
55
+ on_select: Callable[[Any], None] | None = None,
56
+ ) -> None:
57
+ self._sim = sim
58
+ self._robots: list["Robot"] = robots or []
59
+ self._on_select = on_select
60
+ self._selected: Any = None
61
+
62
+ # ------------------------------------------------------------------
63
+ # Public API
64
+ # ------------------------------------------------------------------
65
+
66
+ @property
67
+ def selected_object(self) -> Any:
68
+ """The currently selected object (Robot, Joint, Link, or None)."""
69
+ return self._selected
70
+
71
+ def add_robot(self, robot: "Robot") -> None:
72
+ """Add a robot to the explorer tree.
73
+
74
+ Args:
75
+ robot: The robot to add.
76
+ """
77
+ if robot not in self._robots:
78
+ self._robots.append(robot)
79
+
80
+ def render(self) -> None:
81
+ """Draw the explorer panel contents.
82
+
83
+ Must be called inside an active ImGui window context.
84
+
85
+ Example::
86
+
87
+ imgui.begin("Explorer")
88
+ explorer.render()
89
+ imgui.end()
90
+ """
91
+ if not _HAS_IMGUI:
92
+ return
93
+
94
+ # Simulation node
95
+ sim_open = imgui.tree_node("Simulation##root")
96
+ if imgui.is_item_clicked():
97
+ self._select(self._sim)
98
+ if sim_open:
99
+ for robot in self._robots:
100
+ self._render_robot(robot)
101
+ imgui.tree_pop()
102
+
103
+ def _render_robot(self, robot: "Robot") -> None:
104
+ """Render tree node for a single robot."""
105
+ robot_label = f"\U0001F916 {robot.name}##robot_{id(robot)}"
106
+ robot_open = imgui.tree_node(robot_label)
107
+ if imgui.is_item_clicked():
108
+ self._select(robot)
109
+
110
+ if robot_open:
111
+ # Joints subtree
112
+ joints_open = imgui.tree_node(f"Joints ({len(robot.joints)})##joints_{id(robot)}")
113
+ if joints_open:
114
+ for name, joint in robot.joints.items():
115
+ clicked = imgui.selectable(
116
+ f" \U0001F517 {name}##joint_{id(joint)}",
117
+ self._selected is joint,
118
+ )[0]
119
+ if clicked:
120
+ self._select(joint)
121
+ imgui.tree_pop()
122
+
123
+ # Links subtree
124
+ links_open = imgui.tree_node(f"Links ({len(robot.links)})##links_{id(robot)}")
125
+ if links_open:
126
+ for name, link in robot.links.items():
127
+ clicked = imgui.selectable(
128
+ f" \U0001F9F1 {name}##link_{id(link)}",
129
+ self._selected is link,
130
+ )[0]
131
+ if clicked:
132
+ self._select(link)
133
+ imgui.tree_pop()
134
+
135
+ imgui.tree_pop()
136
+
137
+ def _select(self, obj: Any) -> None:
138
+ """Handle selection of an object."""
139
+ self._selected = obj
140
+ if self._on_select is not None:
141
+ self._on_select(obj)
142
+
143
+ def __repr__(self) -> str:
144
+ return f"ExplorerPanel(robots={[r.name for r in self._robots]})"
@@ -0,0 +1,143 @@
1
+ """
2
+ PlotsPanel – renders inline live plots using ImGui's plot_lines primitive.
3
+
4
+ Displays sparkline-style charts for telemetry channel histories directly
5
+ inside the BulletLab ImGui control window. These are lightweight inline
6
+ plots (not PyQtGraph). For full-featured windowed plots, use LivePlot.
7
+
8
+ Example::
9
+
10
+ from bulletlab.ui.panels.plots import PlotsPanel
11
+ from bulletlab.telemetry import TelemetryManager
12
+
13
+ telemetry = TelemetryManager()
14
+ telemetry.watch("Speed", lambda: robot.speed)
15
+
16
+ plots = PlotsPanel(telemetry)
17
+ plots.render()
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import array
23
+ from typing import TYPE_CHECKING
24
+
25
+ try:
26
+ import imgui
27
+
28
+ _HAS_IMGUI = True
29
+ except ImportError: # pragma: no cover
30
+ imgui = None # type: ignore[assignment]
31
+ _HAS_IMGUI = False
32
+
33
+ if TYPE_CHECKING:
34
+ from bulletlab.telemetry.manager import TelemetryManager
35
+
36
+
37
+ class PlotsPanel:
38
+ """Renders inline sparkline plots for all telemetry channels.
39
+
40
+ Uses ImGui's built-in ``plot_lines`` which requires no external
41
+ plotting library. For production-quality plots, use
42
+ :class:`~bulletlab.plotting.live_plot.LivePlot`.
43
+
44
+ Args:
45
+ telemetry: The :class:`~bulletlab.telemetry.manager.TelemetryManager`
46
+ providing channel histories.
47
+ plot_height: Height of each individual sparkline in pixels.
48
+ max_display: Maximum number of channels to plot simultaneously.
49
+
50
+ Example::
51
+
52
+ plots_panel = PlotsPanel(telemetry)
53
+ plots_panel.render()
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ telemetry: "TelemetryManager",
59
+ plot_height: float = 60.0,
60
+ max_display: int = 8,
61
+ ) -> None:
62
+ self._telemetry = telemetry
63
+ self._plot_height = plot_height
64
+ self._max_display = max_display
65
+
66
+ def render(self) -> None:
67
+ """Draw the plots panel contents.
68
+
69
+ Must be called inside an active ImGui window context.
70
+ """
71
+ if not _HAS_IMGUI:
72
+ return
73
+
74
+ channels = self._telemetry.channels
75
+ if not channels:
76
+ imgui.text_colored("No channels to plot.", 0.5, 0.5, 0.5, 1.0)
77
+ imgui.text("Add channels with telemetry.watch(...)")
78
+ return
79
+
80
+ shown = 0
81
+ for name, channel in channels.items():
82
+ if shown >= self._max_display:
83
+ imgui.text_colored(
84
+ f"... and {len(channels) - shown} more channels",
85
+ 0.5, 0.5, 0.5, 1.0,
86
+ )
87
+ break
88
+
89
+ values = channel.values
90
+ if not values:
91
+ imgui.text(f"{name}: (no data)")
92
+ shown += 1
93
+ continue
94
+
95
+ # Build a contiguous C-float buffer.
96
+ # imgui.plot_lines() requires a bytes-like / buffer object —
97
+ # a plain Python list raises TypeError.
98
+ float_vals: list[float] = []
99
+ for v in values:
100
+ try:
101
+ float_vals.append(float(v))
102
+ except (TypeError, ValueError):
103
+ float_vals.append(0.0)
104
+
105
+ if not float_vals:
106
+ shown += 1
107
+ continue
108
+
109
+ buf = array.array("f", float_vals) # contiguous single-precision floats
110
+
111
+ latest = float_vals[-1]
112
+ unit = channel.unit
113
+ unit_str = f" {unit}" if unit else ""
114
+ vmin = min(float_vals)
115
+ vmax = max(float_vals)
116
+ overlay = f"{latest:.3f}{unit_str}"
117
+ avail_w = imgui.get_content_region_available()[0]
118
+
119
+ try:
120
+ imgui.plot_lines(
121
+ f"##{name}_plot",
122
+ buf,
123
+ overlay_text=overlay,
124
+ scale_min=vmin - abs(vmin) * 0.1 - 1e-6,
125
+ scale_max=vmax + abs(vmax) * 0.1 + 1e-6,
126
+ graph_size=(avail_w, self._plot_height),
127
+ )
128
+ except Exception:
129
+ # Graceful fallback if this imgui version's plot_lines binding
130
+ # uses a different signature
131
+ imgui.text(f"{name}: {latest:.4f}{unit_str}")
132
+ shown += 1
133
+ continue
134
+
135
+ imgui.text_colored(
136
+ f"{name}: {latest:.4f}{unit_str} [{vmin:.3f} – {vmax:.3f}]",
137
+ 0.7, 0.9, 0.7, 1.0,
138
+ )
139
+ imgui.separator()
140
+ shown += 1
141
+
142
+ def __repr__(self) -> str:
143
+ return f"PlotsPanel(channels={self._telemetry.channel_names})"