Skip to content

Robot Highlighter

RobotHighlighter lights up joints and links in the PyBullet 3D viewport the moment the user moves their mouse over any related element in the UI — Explorer rows, Properties sliders, buttons, or custom widgets.

Quickstart

from bulletlab import Simulation, Robot, RobotHighlighter
from bulletlab.ui import BulletLabUI

sim = Simulation(mode="gui").start()
robot = Robot.load("kuka_iiwa/model.urdf", sim=sim)

# One-liner — pass to BulletLabUI and it works automatically
hl = RobotHighlighter(robot, sim)
app = BulletLabUI(sim=sim, robots=[robot], highlighter=hl)
app.run()

No other code required. The UI wires up the hover detection and 3D colour changes internally.


What triggers a highlight?

Where you hover What lights up
Joint name in the Explorer tree The joint's child link in 3D
Link name in the Explorer tree That link in 3D
Any slider/drag in the Properties panel for a Joint Same joint's 3D link
Any slider/drag in the Properties panel for a Link That link in 3D

As soon as the mouse leaves the element, the colour is instantly restored.


Parameters

Parameter Type Default Description
robot Robot required Robot whose parts will be highlighted
sim Simulation required Simulation instance
color tuple[float,float,float,float] (1.0, 0.55, 0.05, 1.0) RGBA highlight colour (orange)
pulse bool True Whether the glow breathes/pulses

Custom colours and pulse

# Blue highlight, no pulse
hl = RobotHighlighter(robot, sim, color=(0.2, 0.6, 1.0, 1.0), pulse=False)

# Change colour at runtime
hl.color = (0.0, 1.0, 0.4, 1.0)   # green
hl.pulse = True

Manual API (custom panels)

Call set_hover() in your own panel after any ImGui widget:

@app.custom_panel("Joint Control")
def my_panel():
    changed, val = imgui.slider_float("Speed", speed, -20, 20)
    if imgui.is_item_hovered():
        hl.set_hover(robot.joints["wheel_left"])   # highlight that joint

    if imgui.button("Reset"):
        robot.reset()
    if imgui.is_item_hovered():
        hl.set_hover(robot.links["base_link"])     # highlight base

To clear all highlights immediately:

hl.clear()

API Reference

Highlights joints and links in the PyBullet 3D viewport on hover.

Attach to :class:~bulletlab.ui.app.BulletLabUI via highlighter= and BulletLab automatically highlights the corresponding 3D part whenever the user hovers over a joint or link in the Explorer, Properties panel, or any interactive widget.

Parameters:

Name Type Description Default
robot 'Robot'

The robot whose parts will be highlighted.

required
sim 'Simulation'

The :class:~bulletlab.core.simulation.Simulation instance.

required
color tuple[float, float, float, float]

RGBA highlight colour. Default: orange (1.0, 0.55, 0.05, 1.0).

(1.0, 0.55, 0.05, 1.0)
pulse bool

If True, the highlight colour pulses (breathes) gently. Default: True.

True

Example::

from bulletlab import Simulation, Robot, RobotHighlighter
from bulletlab.ui import BulletLabUI

sim = Simulation(mode="gui").start()
robot = Robot.load("kuka_iiwa/model.urdf", sim=sim)

# One-liner — attach to UI and it works automatically
hl = RobotHighlighter(robot, sim)
app = BulletLabUI(sim=sim, robots=[robot], highlighter=hl)
Source code in bulletlab/core/highlighter.py
class RobotHighlighter:
    """Highlights joints and links in the PyBullet 3D viewport on hover.

    Attach to :class:`~bulletlab.ui.app.BulletLabUI` via ``highlighter=``
    and BulletLab automatically highlights the corresponding 3D part whenever
    the user hovers over a joint or link in the Explorer, Properties panel,
    or any interactive widget.

    Args:
        robot: The robot whose parts will be highlighted.
        sim: The :class:`~bulletlab.core.simulation.Simulation` instance.
        color: RGBA highlight colour. Default: orange ``(1.0, 0.55, 0.05, 1.0)``.
        pulse: If ``True``, the highlight colour pulses (breathes) gently.
               Default: ``True``.

    Example::

        from bulletlab import Simulation, Robot, RobotHighlighter
        from bulletlab.ui import BulletLabUI

        sim = Simulation(mode="gui").start()
        robot = Robot.load("kuka_iiwa/model.urdf", sim=sim)

        # One-liner — attach to UI and it works automatically
        hl = RobotHighlighter(robot, sim)
        app = BulletLabUI(sim=sim, robots=[robot], highlighter=hl)
    """

    def __init__(
        self,
        robot: "Robot",
        sim: "Simulation",
        *,
        color: tuple[float, float, float, float] = (1.0, 0.55, 0.05, 1.0),
        pulse: bool = True,
    ) -> None:
        self._robot    = robot
        self._sim      = sim
        self._color    = color
        self._pulse    = pulse

        # PyBullet IDs
        self._body_id  = robot._body_id
        self._cid      = sim.client_id

        # Highlighted state
        self._current: Any = None     # currently highlighted obj
        self._pending: Any = None     # set during a frame by panels/widgets
        self._saved_colors: dict[int, list] = {}  # link_index → original color list

    # ------------------------------------------------------------------
    # Frame lifecycle  (called by BulletLabUI.step())
    # ------------------------------------------------------------------

    def begin_frame(self) -> None:
        """Reset the pending hover target at the start of each UI frame.

        Called automatically by :class:`~bulletlab.ui.app.BulletLabUI`.
        """
        self._pending = None

    def end_frame(self) -> None:
        """Commit the pending hover target and update 3D highlights.

        Called automatically by :class:`~bulletlab.ui.app.BulletLabUI`.
        """
        if self._pending is not self._current:
            # Clear old highlight
            if self._current is not None:
                self._clear_obj(self._current)
            self._current = self._pending
            # Apply new highlight
            if self._current is not None:
                self._apply_obj(self._current)

        # Pulse: re-apply colour every frame when something is highlighted
        elif self._current is not None and self._pulse:
            self._apply_obj(self._current)

    # ------------------------------------------------------------------
    # Public API for panels / custom widgets
    # ------------------------------------------------------------------

    def set_hover(self, obj: Any) -> None:
        """Signal that *obj* is currently being hovered.

        Call this right after any ImGui widget that corresponds to a Joint
        or Link when ``imgui.is_item_hovered()`` returns ``True``.

        Args:
            obj: A :class:`~bulletlab.robot.joint.Joint`,
                 :class:`~bulletlab.robot.link.Link`, or ``None`` to clear.

        Example::

            imgui.slider_float("Velocity", joint.velocity, -20, 20)
            if imgui.is_item_hovered():
                hl.set_hover(joint)
        """
        self._pending = obj

    def clear(self) -> None:
        """Immediately remove all highlights from the robot.

        Example::

            hl.clear()
        """
        if self._current is not None:
            self._clear_obj(self._current)
        self._current = None
        self._pending = None

    @property
    def color(self) -> tuple[float, float, float, float]:
        """RGBA highlight colour."""
        return self._color

    @color.setter
    def color(self, value: tuple[float, float, float, float]) -> None:
        self._color = tuple(value)

    @property
    def pulse(self) -> bool:
        """Whether the highlight colour pulses/breathes."""
        return self._pulse

    @pulse.setter
    def pulse(self, value: bool) -> None:
        self._pulse = bool(value)

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _resolve_link_index(self, obj: Any) -> int | None:
        """Return the PyBullet link index for a Joint or Link object."""
        type_name = type(obj).__name__
        if type_name == "Joint":
            return obj.index        # joint index == child link index in PyBullet
        if type_name == "Link":
            return obj.index        # -1 for base link, ≥0 for others
        return None

    def _get_pulse_color(self) -> tuple[float, float, float, float]:
        """Return the highlight colour, potentially modulated by a pulse."""
        if not self._pulse:
            return self._color
        t = time.time()
        # Gentle sinusoidal pulse between 70% and 100% brightness
        scale = 0.70 + 0.30 * (0.5 + 0.5 * math.sin(t * 5.0))
        r, g, b, a = self._color
        return (r * scale, g * scale, b * scale, a)

    def _get_original_color(self, link_index: int) -> list | None:
        """Query PyBullet for the current visual colour of a link."""
        try:
            shapes = p.getVisualShapeData(
                self._body_id, physicsClientId=self._cid
            )
            for shape in shapes:
                if shape[1] == link_index:
                    return list(shape[7])   # [r, g, b, a]
        except Exception:
            pass
        return None

    def _apply_obj(self, obj: Any) -> None:
        """Apply highlight colour to the 3D link corresponding to *obj*."""
        link_index = self._resolve_link_index(obj)
        if link_index is None:
            return
        try:
            # Save original colour once (first highlight)
            if link_index not in self._saved_colors:
                orig = self._get_original_color(link_index)
                self._saved_colors[link_index] = orig or [0.7, 0.7, 0.7, 1.0]

            col = self._get_pulse_color()
            p.changeVisualShape(
                self._body_id,
                link_index,
                rgbaColor=list(col),
                physicsClientId=self._cid,
            )
        except Exception:
            pass   # non-fatal — 3D viewport may not have visual shapes

    def _clear_obj(self, obj: Any) -> None:
        """Restore the original colour of the 3D link for *obj*."""
        link_index = self._resolve_link_index(obj)
        if link_index is None:
            return
        try:
            orig = self._saved_colors.get(link_index, [0.7, 0.7, 0.7, 1.0])
            p.changeVisualShape(
                self._body_id,
                link_index,
                rgbaColor=orig,
                physicsClientId=self._cid,
            )
        except Exception:
            pass

    # ------------------------------------------------------------------
    # Repr
    # ------------------------------------------------------------------

    def __repr__(self) -> str:
        current_name = getattr(self._current, "name", None)
        return (
            f"RobotHighlighter(robot={self._robot.name!r}, "
            f"current={current_name!r}, pulse={self._pulse})"
        )

color: tuple[float, float, float, float] property writable

RGBA highlight colour.

pulse: bool property writable

Whether the highlight colour pulses/breathes.

begin_frame() -> None

Reset the pending hover target at the start of each UI frame.

Called automatically by :class:~bulletlab.ui.app.BulletLabUI.

Source code in bulletlab/core/highlighter.py
def begin_frame(self) -> None:
    """Reset the pending hover target at the start of each UI frame.

    Called automatically by :class:`~bulletlab.ui.app.BulletLabUI`.
    """
    self._pending = None

clear() -> None

Immediately remove all highlights from the robot.

Example::

hl.clear()
Source code in bulletlab/core/highlighter.py
def clear(self) -> None:
    """Immediately remove all highlights from the robot.

    Example::

        hl.clear()
    """
    if self._current is not None:
        self._clear_obj(self._current)
    self._current = None
    self._pending = None

end_frame() -> None

Commit the pending hover target and update 3D highlights.

Called automatically by :class:~bulletlab.ui.app.BulletLabUI.

Source code in bulletlab/core/highlighter.py
def end_frame(self) -> None:
    """Commit the pending hover target and update 3D highlights.

    Called automatically by :class:`~bulletlab.ui.app.BulletLabUI`.
    """
    if self._pending is not self._current:
        # Clear old highlight
        if self._current is not None:
            self._clear_obj(self._current)
        self._current = self._pending
        # Apply new highlight
        if self._current is not None:
            self._apply_obj(self._current)

    # Pulse: re-apply colour every frame when something is highlighted
    elif self._current is not None and self._pulse:
        self._apply_obj(self._current)

set_hover(obj: Any) -> None

Signal that obj is currently being hovered.

Call this right after any ImGui widget that corresponds to a Joint or Link when imgui.is_item_hovered() returns True.

Parameters:

Name Type Description Default
obj Any

A :class:~bulletlab.robot.joint.Joint, :class:~bulletlab.robot.link.Link, or None to clear.

required

Example::

imgui.slider_float("Velocity", joint.velocity, -20, 20)
if imgui.is_item_hovered():
    hl.set_hover(joint)
Source code in bulletlab/core/highlighter.py
def set_hover(self, obj: Any) -> None:
    """Signal that *obj* is currently being hovered.

    Call this right after any ImGui widget that corresponds to a Joint
    or Link when ``imgui.is_item_hovered()`` returns ``True``.

    Args:
        obj: A :class:`~bulletlab.robot.joint.Joint`,
             :class:`~bulletlab.robot.link.Link`, or ``None`` to clear.

    Example::

        imgui.slider_float("Velocity", joint.velocity, -20, 20)
        if imgui.is_item_hovered():
            hl.set_hover(joint)
    """
    self._pending = obj