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
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PropertiesPanel – editable property inspector for the selected object.
|
|
3
|
+
|
|
4
|
+
Detects the type of the selected object (Simulation, Robot, Joint, or Link)
|
|
5
|
+
and renders appropriate editable fields. All changes are applied immediately
|
|
6
|
+
to the simulation.
|
|
7
|
+
|
|
8
|
+
Example::
|
|
9
|
+
|
|
10
|
+
from bulletlab.ui.panels.properties import PropertiesPanel
|
|
11
|
+
|
|
12
|
+
props = PropertiesPanel()
|
|
13
|
+
props.set_target(robot.links["wheel"])
|
|
14
|
+
props.render()
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
from typing import Any, TYPE_CHECKING
|
|
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
|
+
from bulletlab.robot.joint import Joint
|
|
34
|
+
from bulletlab.robot.link import Link
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PropertiesPanel:
|
|
38
|
+
"""Renders editable properties for the currently selected object.
|
|
39
|
+
|
|
40
|
+
Automatically detects whether the target is a :class:`~bulletlab.core.simulation.Simulation`,
|
|
41
|
+
:class:`~bulletlab.robot.robot.Robot`, :class:`~bulletlab.robot.joint.Joint`, or
|
|
42
|
+
:class:`~bulletlab.robot.link.Link` and renders the appropriate fields.
|
|
43
|
+
|
|
44
|
+
Example::
|
|
45
|
+
|
|
46
|
+
props = PropertiesPanel()
|
|
47
|
+
props.set_target(selected_object)
|
|
48
|
+
props.render()
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self._target: Any = None
|
|
53
|
+
|
|
54
|
+
def set_target(self, obj: Any) -> None:
|
|
55
|
+
"""Set the object whose properties will be displayed.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
obj: A Simulation, Robot, Joint, Link, or ``None`` to clear.
|
|
59
|
+
|
|
60
|
+
Example::
|
|
61
|
+
|
|
62
|
+
props.set_target(robot.joints["wheel_left"])
|
|
63
|
+
"""
|
|
64
|
+
self._target = obj
|
|
65
|
+
|
|
66
|
+
def render(self) -> None:
|
|
67
|
+
"""Draw the properties panel contents.
|
|
68
|
+
|
|
69
|
+
Must be called inside an active ImGui window context.
|
|
70
|
+
"""
|
|
71
|
+
if not _HAS_IMGUI:
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
if self._target is None:
|
|
75
|
+
imgui.text_colored("Select an item in the Explorer.", 0.5, 0.5, 0.5, 1.0)
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
# Route to appropriate renderer
|
|
79
|
+
type_name = type(self._target).__name__
|
|
80
|
+
imgui.text(f"Type: {type_name}")
|
|
81
|
+
imgui.separator()
|
|
82
|
+
|
|
83
|
+
if type_name == "Simulation":
|
|
84
|
+
self._render_simulation(self._target)
|
|
85
|
+
elif type_name == "Robot":
|
|
86
|
+
self._render_robot(self._target)
|
|
87
|
+
elif type_name == "Joint":
|
|
88
|
+
self._render_joint(self._target)
|
|
89
|
+
elif type_name == "Link":
|
|
90
|
+
self._render_link(self._target)
|
|
91
|
+
else:
|
|
92
|
+
imgui.text(str(self._target))
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
# Simulation properties
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def _render_simulation(self, sim: "Simulation") -> None:
|
|
99
|
+
imgui.text(f"Status: {'Paused' if sim.is_paused else 'Running'}")
|
|
100
|
+
imgui.text(f"Steps: {sim.step_count}")
|
|
101
|
+
imgui.text(f"Sim Time: {sim.elapsed_time:.3f} s")
|
|
102
|
+
imgui.separator()
|
|
103
|
+
|
|
104
|
+
# Gravity
|
|
105
|
+
gx, gy, gz = sim.gravity
|
|
106
|
+
changed_gz, new_gz = imgui.drag_float("Gravity Z##sim_gz", gz, 0.1, -20.0, 20.0)
|
|
107
|
+
if changed_gz:
|
|
108
|
+
sim.gravity = (gx, gy, new_gz)
|
|
109
|
+
|
|
110
|
+
# Timestep
|
|
111
|
+
changed_ts, new_ts = imgui.drag_float("Timestep##sim_ts", sim.timestep, 0.0001, 0.0001, 0.1, "%.5f")
|
|
112
|
+
if changed_ts:
|
|
113
|
+
sim.timestep = float(new_ts)
|
|
114
|
+
|
|
115
|
+
imgui.separator()
|
|
116
|
+
if sim.is_paused:
|
|
117
|
+
if imgui.button("Resume##sim_resume"):
|
|
118
|
+
sim.resume()
|
|
119
|
+
else:
|
|
120
|
+
if imgui.button("Pause##sim_pause"):
|
|
121
|
+
sim.pause()
|
|
122
|
+
imgui.same_line()
|
|
123
|
+
if imgui.button("Reset##sim_reset"):
|
|
124
|
+
sim.reset()
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
# Robot properties
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def _render_robot(self, robot: "Robot") -> None:
|
|
131
|
+
imgui.text(f"Name: {robot.name}")
|
|
132
|
+
imgui.text(f"Joints: {robot.num_joints} (controllable: {robot.num_controllable_joints})")
|
|
133
|
+
imgui.text(f"Links: {len(robot.links)}")
|
|
134
|
+
imgui.separator()
|
|
135
|
+
|
|
136
|
+
pos = robot.base_position
|
|
137
|
+
vel = robot.base_velocity
|
|
138
|
+
roll_deg = math.degrees(robot.roll)
|
|
139
|
+
pitch_deg = math.degrees(robot.pitch)
|
|
140
|
+
yaw_deg = math.degrees(robot.yaw)
|
|
141
|
+
|
|
142
|
+
imgui.text(f"Position: ({pos[0]:.3f}, {pos[1]:.3f}, {pos[2]:.3f})")
|
|
143
|
+
imgui.text(f"Velocity: ({vel[0]:.3f}, {vel[1]:.3f}, {vel[2]:.3f})")
|
|
144
|
+
imgui.text(f"Roll: {roll_deg:.2f}°")
|
|
145
|
+
imgui.text(f"Pitch: {pitch_deg:.2f}°")
|
|
146
|
+
imgui.text(f"Yaw: {yaw_deg:.2f}°")
|
|
147
|
+
imgui.text(f"Speed: {robot.speed:.3f} m/s")
|
|
148
|
+
imgui.separator()
|
|
149
|
+
|
|
150
|
+
if imgui.button("Reset Robot##robot_reset"):
|
|
151
|
+
robot.reset()
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
# Joint properties
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def _render_joint(self, joint: "Joint") -> None:
|
|
158
|
+
imgui.text(f"Name: {joint.name}")
|
|
159
|
+
imgui.text(f"Index: {joint.index}")
|
|
160
|
+
imgui.text(f"Type: {joint.joint_type.name if hasattr(joint.joint_type, 'name') else joint.joint_type}")
|
|
161
|
+
imgui.separator()
|
|
162
|
+
|
|
163
|
+
imgui.text(f"Position: {joint.position:.4f} rad")
|
|
164
|
+
imgui.text(f"Velocity: {joint.velocity:.4f} rad/s")
|
|
165
|
+
imgui.text(f"Torque: {joint.torque:.4f} N·m")
|
|
166
|
+
lo, hi = joint.limits
|
|
167
|
+
imgui.text(f"Limits: [{lo:.3f}, {hi:.3f}]")
|
|
168
|
+
imgui.separator()
|
|
169
|
+
|
|
170
|
+
# Max force
|
|
171
|
+
changed_mf, new_mf = imgui.drag_float(
|
|
172
|
+
"Max Force##jnt_mf", joint.max_force, 1.0, 0.0, 5000.0
|
|
173
|
+
)
|
|
174
|
+
if changed_mf:
|
|
175
|
+
joint.max_force = float(new_mf)
|
|
176
|
+
|
|
177
|
+
# Max velocity
|
|
178
|
+
changed_mv, new_mv = imgui.drag_float(
|
|
179
|
+
"Max Velocity##jnt_mv", joint.max_velocity, 0.1, 0.0, 200.0
|
|
180
|
+
)
|
|
181
|
+
if changed_mv:
|
|
182
|
+
joint.max_velocity = float(new_mv)
|
|
183
|
+
|
|
184
|
+
imgui.separator()
|
|
185
|
+
|
|
186
|
+
# Target velocity control
|
|
187
|
+
changed_vel, new_vel = imgui.drag_float(
|
|
188
|
+
"Target Velocity##jnt_vel", joint.velocity, 0.1, -200.0, 200.0
|
|
189
|
+
)
|
|
190
|
+
if changed_vel:
|
|
191
|
+
joint.velocity = float(new_vel)
|
|
192
|
+
|
|
193
|
+
# Position control
|
|
194
|
+
lo2, hi2 = joint.limits
|
|
195
|
+
range_lo = lo2 if lo2 != 0.0 or hi2 != 0.0 else -6.28
|
|
196
|
+
range_hi = hi2 if lo2 != 0.0 or hi2 != 0.0 else 6.28
|
|
197
|
+
changed_pos, new_pos = imgui.slider_float(
|
|
198
|
+
"Target Position##jnt_pos", joint.position, range_lo, range_hi
|
|
199
|
+
)
|
|
200
|
+
if changed_pos:
|
|
201
|
+
joint.set_position(float(new_pos))
|
|
202
|
+
|
|
203
|
+
imgui.separator()
|
|
204
|
+
if imgui.button("Reset Joint##jnt_reset"):
|
|
205
|
+
joint.reset()
|
|
206
|
+
imgui.same_line()
|
|
207
|
+
if joint.is_enabled:
|
|
208
|
+
if imgui.button("Disable##jnt_disable"):
|
|
209
|
+
joint.disable()
|
|
210
|
+
else:
|
|
211
|
+
if imgui.button("Enable##jnt_enable"):
|
|
212
|
+
joint.enable()
|
|
213
|
+
|
|
214
|
+
# ------------------------------------------------------------------
|
|
215
|
+
# Link properties
|
|
216
|
+
# ------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
def _render_link(self, link: "Link") -> None:
|
|
219
|
+
imgui.text(f"Name: {link.name}")
|
|
220
|
+
imgui.text(f"Index: {link.index}")
|
|
221
|
+
imgui.separator()
|
|
222
|
+
|
|
223
|
+
# Mass
|
|
224
|
+
changed_mass, new_mass = imgui.drag_float(
|
|
225
|
+
"Mass (kg)##lnk_mass", link.mass, 0.01, 0.0001, 1000.0
|
|
226
|
+
)
|
|
227
|
+
if changed_mass:
|
|
228
|
+
link.mass = float(new_mass)
|
|
229
|
+
|
|
230
|
+
# Friction
|
|
231
|
+
changed_fric, new_fric = imgui.drag_float(
|
|
232
|
+
"Lateral Friction##lnk_fric", link.friction, 0.01, 0.0, 10.0
|
|
233
|
+
)
|
|
234
|
+
if changed_fric:
|
|
235
|
+
link.friction = float(new_fric)
|
|
236
|
+
|
|
237
|
+
# Restitution
|
|
238
|
+
changed_rest, new_rest = imgui.slider_float(
|
|
239
|
+
"Restitution##lnk_rest", link.restitution, 0.0, 1.0
|
|
240
|
+
)
|
|
241
|
+
if changed_rest:
|
|
242
|
+
link.restitution = float(new_rest)
|
|
243
|
+
|
|
244
|
+
# Linear damping
|
|
245
|
+
changed_ld, new_ld = imgui.drag_float(
|
|
246
|
+
"Linear Damping##lnk_ld", link.linear_damping, 0.001, 0.0, 10.0
|
|
247
|
+
)
|
|
248
|
+
if changed_ld:
|
|
249
|
+
link.linear_damping = float(new_ld)
|
|
250
|
+
|
|
251
|
+
# Angular damping
|
|
252
|
+
changed_ad, new_ad = imgui.drag_float(
|
|
253
|
+
"Angular Damping##lnk_ad", link.angular_damping, 0.001, 0.0, 10.0
|
|
254
|
+
)
|
|
255
|
+
if changed_ad:
|
|
256
|
+
link.angular_damping = float(new_ad)
|
|
257
|
+
|
|
258
|
+
imgui.separator()
|
|
259
|
+
pos = link.position
|
|
260
|
+
vel = link.velocity
|
|
261
|
+
imgui.text(f"Position: ({pos[0]:.3f}, {pos[1]:.3f}, {pos[2]:.3f})")
|
|
262
|
+
imgui.text(f"Velocity: ({vel[0]:.3f}, {vel[1]:.3f}, {vel[2]:.3f})")
|
|
263
|
+
|
|
264
|
+
def __repr__(self) -> str:
|
|
265
|
+
return f"PropertiesPanel(target={type(self._target).__name__ if self._target else None})"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TelemetryPanel – displays live robot telemetry data.
|
|
3
|
+
|
|
4
|
+
Shows all channels registered in a TelemetryManager as a table with
|
|
5
|
+
the most recent values. Optionally shows units.
|
|
6
|
+
|
|
7
|
+
Example::
|
|
8
|
+
|
|
9
|
+
from bulletlab.ui.panels.telemetry import TelemetryPanel
|
|
10
|
+
from bulletlab.telemetry import TelemetryManager
|
|
11
|
+
|
|
12
|
+
telemetry = TelemetryManager()
|
|
13
|
+
telemetry.watch("Speed", lambda: robot.speed, unit="m/s")
|
|
14
|
+
|
|
15
|
+
panel = TelemetryPanel(telemetry)
|
|
16
|
+
panel.render()
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
from typing import Any, TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import imgui
|
|
26
|
+
|
|
27
|
+
_HAS_IMGUI = True
|
|
28
|
+
except ImportError: # pragma: no cover
|
|
29
|
+
imgui = None # type: ignore[assignment]
|
|
30
|
+
_HAS_IMGUI = False
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from bulletlab.telemetry.manager import TelemetryManager
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TelemetryPanel:
|
|
37
|
+
"""Renders a live key-value table of telemetry channel values.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
telemetry: The :class:`~bulletlab.telemetry.manager.TelemetryManager` to display.
|
|
41
|
+
|
|
42
|
+
Example::
|
|
43
|
+
|
|
44
|
+
panel = TelemetryPanel(telemetry)
|
|
45
|
+
# In render loop:
|
|
46
|
+
panel.render()
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, telemetry: "TelemetryManager") -> None:
|
|
50
|
+
self._telemetry = telemetry
|
|
51
|
+
|
|
52
|
+
def render(self) -> None:
|
|
53
|
+
"""Draw the telemetry panel contents.
|
|
54
|
+
|
|
55
|
+
Must be called inside an active ImGui window context.
|
|
56
|
+
"""
|
|
57
|
+
if not _HAS_IMGUI:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
if not self._telemetry.channel_names:
|
|
61
|
+
imgui.text_colored("No channels registered.", 0.5, 0.5, 0.5, 1.0)
|
|
62
|
+
imgui.text("Use telemetry.watch(...) to add channels.")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
# Table header
|
|
66
|
+
imgui.columns(3, "telemetry_table", border=True)
|
|
67
|
+
imgui.text("Channel")
|
|
68
|
+
imgui.next_column()
|
|
69
|
+
imgui.text("Value")
|
|
70
|
+
imgui.next_column()
|
|
71
|
+
imgui.text("Unit")
|
|
72
|
+
imgui.next_column()
|
|
73
|
+
imgui.separator()
|
|
74
|
+
|
|
75
|
+
for name, channel in self._telemetry.channels.items():
|
|
76
|
+
val = channel.latest
|
|
77
|
+
unit = channel.unit
|
|
78
|
+
|
|
79
|
+
imgui.text(name)
|
|
80
|
+
imgui.next_column()
|
|
81
|
+
|
|
82
|
+
# Format value
|
|
83
|
+
if val is None:
|
|
84
|
+
val_str = "—"
|
|
85
|
+
elif isinstance(val, float):
|
|
86
|
+
if math.isnan(val):
|
|
87
|
+
val_str = "NaN"
|
|
88
|
+
elif math.isinf(val):
|
|
89
|
+
val_str = "Inf"
|
|
90
|
+
else:
|
|
91
|
+
val_str = f"{val:.4f}"
|
|
92
|
+
elif isinstance(val, (list, tuple)):
|
|
93
|
+
val_str = "(" + ", ".join(f"{v:.3f}" for v in val) + ")"
|
|
94
|
+
else:
|
|
95
|
+
val_str = str(val)
|
|
96
|
+
|
|
97
|
+
imgui.text(val_str)
|
|
98
|
+
imgui.next_column()
|
|
99
|
+
imgui.text(unit)
|
|
100
|
+
imgui.next_column()
|
|
101
|
+
|
|
102
|
+
imgui.columns(1)
|
|
103
|
+
|
|
104
|
+
def __repr__(self) -> str:
|
|
105
|
+
return f"TelemetryPanel(channels={self._telemetry.channel_names})"
|
bulletlab/ui/widgets.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Widget helper functions for building BulletLab custom panels.
|
|
3
|
+
|
|
4
|
+
Provides a minimal, boilerplate-free API for common ImGui widgets.
|
|
5
|
+
All functions must be called from within an ImGui window context (i.e.,
|
|
6
|
+
between imgui.begin() and imgui.end()).
|
|
7
|
+
|
|
8
|
+
Example::
|
|
9
|
+
|
|
10
|
+
from bulletlab.ui import widgets as ui
|
|
11
|
+
|
|
12
|
+
@app.custom_panel("My Controls")
|
|
13
|
+
def my_panel():
|
|
14
|
+
ui.button("Reset", robot.reset)
|
|
15
|
+
ui.slider("Wheel Mass", robot.links["wheel"].mass, 0.1, 20,
|
|
16
|
+
setter=lambda v: setattr(robot.links["wheel"], "mass", v))
|
|
17
|
+
ui.checkbox("Motors On", lambda: motors_on,
|
|
18
|
+
setter=lambda v: set_motors(v))
|
|
19
|
+
ui.text("Speed", f"{robot.speed:.2f} m/s")
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any, Callable, Optional
|
|
25
|
+
|
|
26
|
+
# ImGui is optional — graceful fallback
|
|
27
|
+
try:
|
|
28
|
+
import imgui
|
|
29
|
+
|
|
30
|
+
_HAS_IMGUI = True
|
|
31
|
+
except ImportError: # pragma: no cover
|
|
32
|
+
imgui = None # type: ignore[assignment]
|
|
33
|
+
_HAS_IMGUI = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _check_imgui() -> bool:
|
|
37
|
+
"""Return True if imgui is available, warn otherwise."""
|
|
38
|
+
if not _HAS_IMGUI:
|
|
39
|
+
return False
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ------------------------------------------------------------------
|
|
44
|
+
# Basic widgets
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def button(label: str, callback: Callable[[], Any] | None = None) -> bool:
|
|
49
|
+
"""Render a clickable button.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
label: Button text label.
|
|
53
|
+
callback: Function to call when the button is clicked.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
``True`` if the button was clicked this frame.
|
|
57
|
+
|
|
58
|
+
Example::
|
|
59
|
+
|
|
60
|
+
ui.button("Reset Robot", robot.reset)
|
|
61
|
+
"""
|
|
62
|
+
if not _check_imgui():
|
|
63
|
+
return False
|
|
64
|
+
clicked = imgui.button(label)
|
|
65
|
+
if clicked and callback is not None:
|
|
66
|
+
callback()
|
|
67
|
+
return clicked
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def text(label: str, value: Any = "") -> None:
|
|
71
|
+
"""Render a read-only text label with an optional value.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
label: Field label.
|
|
75
|
+
value: Value to display (converted to string).
|
|
76
|
+
|
|
77
|
+
Example::
|
|
78
|
+
|
|
79
|
+
ui.text("Speed", f"{robot.speed:.2f} m/s")
|
|
80
|
+
"""
|
|
81
|
+
if not _check_imgui():
|
|
82
|
+
return
|
|
83
|
+
if value != "":
|
|
84
|
+
imgui.text(f"{label}: {value}")
|
|
85
|
+
else:
|
|
86
|
+
imgui.text(str(label))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def separator(label: str = "") -> None:
|
|
90
|
+
"""Render a horizontal separator, optionally with a label.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
label: Optional section label.
|
|
94
|
+
|
|
95
|
+
Example::
|
|
96
|
+
|
|
97
|
+
ui.separator("Physics")
|
|
98
|
+
"""
|
|
99
|
+
if not _check_imgui():
|
|
100
|
+
return
|
|
101
|
+
imgui.separator()
|
|
102
|
+
if label:
|
|
103
|
+
imgui.text(label)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def same_line() -> None:
|
|
107
|
+
"""Place the next widget on the same line."""
|
|
108
|
+
if _check_imgui():
|
|
109
|
+
imgui.same_line()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
# Input widgets
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def slider(
|
|
118
|
+
label: str,
|
|
119
|
+
getter: Callable[[], float] | float,
|
|
120
|
+
min_val: float,
|
|
121
|
+
max_val: float,
|
|
122
|
+
setter: Callable[[float], None] | None = None,
|
|
123
|
+
fmt: str = "%.3f",
|
|
124
|
+
) -> float:
|
|
125
|
+
"""Render a float slider.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
label: Widget label.
|
|
129
|
+
getter: Current value or a callable returning the current value.
|
|
130
|
+
min_val: Minimum value.
|
|
131
|
+
max_val: Maximum value.
|
|
132
|
+
setter: Called with the new value when the slider changes.
|
|
133
|
+
fmt: Printf format string for display.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Current slider value.
|
|
137
|
+
|
|
138
|
+
Example::
|
|
139
|
+
|
|
140
|
+
ui.slider("Wheel Mass", lambda: robot.links["wheel"].mass, 0.1, 20,
|
|
141
|
+
setter=lambda v: setattr(robot.links["wheel"], "mass", v))
|
|
142
|
+
"""
|
|
143
|
+
if not _check_imgui():
|
|
144
|
+
return 0.0
|
|
145
|
+
current = float(getter()) if callable(getter) else float(getter)
|
|
146
|
+
changed, new_val = imgui.slider_float(label, current, min_val, max_val, fmt)
|
|
147
|
+
if changed and setter is not None:
|
|
148
|
+
setter(float(new_val))
|
|
149
|
+
return float(new_val) if changed else current
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def drag_float(
|
|
153
|
+
label: str,
|
|
154
|
+
getter: Callable[[], float] | float,
|
|
155
|
+
setter: Callable[[float], None] | None = None,
|
|
156
|
+
speed: float = 0.1,
|
|
157
|
+
min_val: float = 0.0,
|
|
158
|
+
max_val: float = 0.0,
|
|
159
|
+
fmt: str = "%.3f",
|
|
160
|
+
) -> float:
|
|
161
|
+
"""Render a drag-to-edit float field.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
label: Widget label.
|
|
165
|
+
getter: Current value or callable returning current value.
|
|
166
|
+
setter: Called with the new value when changed.
|
|
167
|
+
speed: Drag sensitivity.
|
|
168
|
+
min_val: Minimum value (0 = no clamp).
|
|
169
|
+
max_val: Maximum value (0 = no clamp).
|
|
170
|
+
fmt: Printf format string for display.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Current value.
|
|
174
|
+
|
|
175
|
+
Example::
|
|
176
|
+
|
|
177
|
+
ui.drag_float("Mass", lambda: link.mass, setter=lambda v: setattr(link, "mass", v))
|
|
178
|
+
"""
|
|
179
|
+
if not _check_imgui():
|
|
180
|
+
return 0.0
|
|
181
|
+
current = float(getter()) if callable(getter) else float(getter)
|
|
182
|
+
changed, new_val = imgui.drag_float(label, current, speed, min_val, max_val, fmt)
|
|
183
|
+
if changed and setter is not None:
|
|
184
|
+
setter(float(new_val))
|
|
185
|
+
return float(new_val) if changed else current
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def input_float(
|
|
189
|
+
label: str,
|
|
190
|
+
getter: Callable[[], float] | float,
|
|
191
|
+
setter: Callable[[float], None] | None = None,
|
|
192
|
+
step: float = 0.1,
|
|
193
|
+
fmt: str = "%.3f",
|
|
194
|
+
) -> float:
|
|
195
|
+
"""Render a float input field.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
label: Widget label.
|
|
199
|
+
getter: Current value or callable.
|
|
200
|
+
setter: Called with the new value when committed.
|
|
201
|
+
step: Increment step for +/- buttons.
|
|
202
|
+
fmt: Display format string.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Current value.
|
|
206
|
+
|
|
207
|
+
Example::
|
|
208
|
+
|
|
209
|
+
ui.input_float("Friction", lambda: link.friction,
|
|
210
|
+
setter=lambda v: setattr(link, "friction", v))
|
|
211
|
+
"""
|
|
212
|
+
if not _check_imgui():
|
|
213
|
+
return 0.0
|
|
214
|
+
current = float(getter()) if callable(getter) else float(getter)
|
|
215
|
+
changed, new_val = imgui.input_float(label, current, step, step * 10, fmt)
|
|
216
|
+
if changed and setter is not None:
|
|
217
|
+
setter(float(new_val))
|
|
218
|
+
return float(new_val) if changed else current
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def checkbox(
|
|
222
|
+
label: str,
|
|
223
|
+
getter: Callable[[], bool] | bool,
|
|
224
|
+
setter: Callable[[bool], None] | None = None,
|
|
225
|
+
) -> bool:
|
|
226
|
+
"""Render a checkbox.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
label: Widget label.
|
|
230
|
+
getter: Current state or callable returning current state.
|
|
231
|
+
setter: Called with the new state when toggled.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Current checkbox state.
|
|
235
|
+
|
|
236
|
+
Example::
|
|
237
|
+
|
|
238
|
+
ui.checkbox("Motors Enabled", lambda: motors_on,
|
|
239
|
+
setter=lambda v: set_motors(v))
|
|
240
|
+
"""
|
|
241
|
+
if not _check_imgui():
|
|
242
|
+
return False
|
|
243
|
+
current = bool(getter()) if callable(getter) else bool(getter)
|
|
244
|
+
changed, new_val = imgui.checkbox(label, current)
|
|
245
|
+
if changed and setter is not None:
|
|
246
|
+
setter(bool(new_val))
|
|
247
|
+
return bool(new_val) if changed else current
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def color_edit(
|
|
251
|
+
label: str,
|
|
252
|
+
getter: Callable[[], tuple[float, float, float]] | tuple[float, float, float],
|
|
253
|
+
setter: Callable[[tuple[float, float, float]], None] | None = None,
|
|
254
|
+
) -> tuple[float, float, float]:
|
|
255
|
+
"""Render an RGB color picker.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
label: Widget label.
|
|
259
|
+
getter: Current color ``(r, g, b)`` normalized to [0, 1] or callable.
|
|
260
|
+
setter: Called with the new color when changed.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
Current color ``(r, g, b)``.
|
|
264
|
+
|
|
265
|
+
Example::
|
|
266
|
+
|
|
267
|
+
ui.color_edit("Light Color", lambda: color, setter=lambda c: set_color(c))
|
|
268
|
+
"""
|
|
269
|
+
if not _check_imgui():
|
|
270
|
+
return (1.0, 1.0, 1.0)
|
|
271
|
+
current = tuple(getter()) if callable(getter) else tuple(getter)
|
|
272
|
+
r, g, b = float(current[0]), float(current[1]), float(current[2])
|
|
273
|
+
changed, (nr, ng, nb) = imgui.color_edit3(label, r, g, b)
|
|
274
|
+
result = (float(nr), float(ng), float(nb))
|
|
275
|
+
if changed and setter is not None:
|
|
276
|
+
setter(result)
|
|
277
|
+
return result if changed else (r, g, b)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def combo(
|
|
281
|
+
label: str,
|
|
282
|
+
items: list[str],
|
|
283
|
+
getter: Callable[[], int] | int,
|
|
284
|
+
setter: Callable[[int], None] | None = None,
|
|
285
|
+
) -> int:
|
|
286
|
+
"""Render a dropdown combo box.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
label: Widget label.
|
|
290
|
+
items: List of selectable items.
|
|
291
|
+
getter: Current selected index or callable.
|
|
292
|
+
setter: Called with new index when changed.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
Current selected index.
|
|
296
|
+
|
|
297
|
+
Example::
|
|
298
|
+
|
|
299
|
+
ui.combo("Mode", ["Velocity", "Position", "Torque"], lambda: mode_idx,
|
|
300
|
+
setter=lambda i: set_mode(i))
|
|
301
|
+
"""
|
|
302
|
+
if not _check_imgui():
|
|
303
|
+
return 0
|
|
304
|
+
current = int(getter()) if callable(getter) else int(getter)
|
|
305
|
+
changed, new_idx = imgui.combo(label, current, items)
|
|
306
|
+
if changed and setter is not None:
|
|
307
|
+
setter(int(new_idx))
|
|
308
|
+
return int(new_idx) if changed else current
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def collapsing_header(label: str, default_open: bool = True) -> bool:
|
|
312
|
+
"""Render a collapsible section header.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
label: Section title.
|
|
316
|
+
default_open: Whether the section starts expanded.
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
``True`` if the section is currently expanded.
|
|
320
|
+
|
|
321
|
+
Example::
|
|
322
|
+
|
|
323
|
+
if ui.collapsing_header("Physics Parameters"):
|
|
324
|
+
ui.drag_float("Mass", ...)
|
|
325
|
+
"""
|
|
326
|
+
if not _check_imgui():
|
|
327
|
+
return True
|
|
328
|
+
flags = imgui.TREE_NODE_DEFAULT_OPEN if default_open else 0
|
|
329
|
+
return imgui.collapsing_header(label, flags=flags)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def tooltip(text_str: str) -> None:
|
|
333
|
+
"""Show a tooltip when the previous widget is hovered.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
text_str: Tooltip text.
|
|
337
|
+
|
|
338
|
+
Example::
|
|
339
|
+
|
|
340
|
+
ui.drag_float("Mass", ...)
|
|
341
|
+
ui.tooltip("Mass of the link in kilograms")
|
|
342
|
+
"""
|
|
343
|
+
if not _check_imgui():
|
|
344
|
+
return
|
|
345
|
+
if imgui.is_item_hovered():
|
|
346
|
+
imgui.begin_tooltip()
|
|
347
|
+
imgui.text(text_str)
|
|
348
|
+
imgui.end_tooltip()
|