Skip to content

Draggable Rect#

Draggable rectangle with corner handles overlaid on a grayscale image.

Pixels contained within the rectangle are inverted in the background image. Note that, for a pixel to be considered "contained" its center must be contained.

This example demonstrates: - Composing a rectangle from three nodes: a semi-transparent mesh (fill), a solid line (outline), and disc point markers (handles). - Using an event filter to implement click-and-drag interactions. - Changing cursor shapes to signal behaviors.

Screenshot of Draggable Rect

import cmap
import numpy as np

import scenex as snx
from scenex.app import CursorType
from scenex.app.events import (
    Event,
    MouseButton,
    MouseLeaveEvent,
    MouseMoveEvent,
    MousePressEvent,
    MouseReleaseEvent,
)

# -- Background image -- #
IMG_W, IMG_H = 100, 100

xx, yy = np.meshgrid(np.linspace(0, 6 * np.pi, IMG_W), np.linspace(0, 6 * np.pi, IMG_H))
img_data = ((np.sin(xx + yy) * 0.5 + 0.5) * 255).astype(np.uint8)

bg = snx.Image(
    data=img_data,
    cmap=cmap.Colormap("grays"),
    clims=(0, 255),
    order=0,
)

# -- Rectangle nodes -- #
RECT_VERTICES = np.array([[0, 0, 0], [20, 0, 0], [20, 20, 0], [0, 20, 0]], dtype=float)

rect_mesh = snx.Mesh(
    vertices=RECT_VERTICES,
    faces=np.array([[0, 1, 2], [0, 2, 3]]),
    color=snx.UniformColor(color=cmap.Color("royalblue")),
    opacity=0.25,
    order=1,
)

rect_line = snx.Line(
    parent=rect_mesh,
    vertices=RECT_VERTICES[[0, 1, 2, 3, 0]],
    color=snx.UniformColor(color=cmap.Color("royalblue")),
    width=2.0,
    order=2,
)

handles = snx.Points(
    parent=rect_mesh,
    vertices=RECT_VERTICES,
    size=14,
    face_color=snx.UniformColor(color=cmap.Color("white")),
    symbol="disc",
    scaling="fixed",
    order=3,
)

# ── Scene / View ──────────────────────────────────────────────────────────────
scene = snx.Scene(children=[bg, rect_mesh])
view = snx.View(
    scene=scene,
    camera=snx.Camera(controller=snx.PanZoom(), interactive=True),
)
canvas = snx.show(view)

# ── Drag state ────────────────────────────────────────────────────────────────
_anchor: tuple[float, float] | None = None  # resize: fixed opposite corner
_drag_start: np.ndarray | None = None  # translate: cursor pos last frame


def _cursor_for_pos(wx: float, wy: float) -> CursorType:
    # Even corners (BL=0, TR=2) are on the main diagonal
    # Odd corners (BR=1, TL=3) on the anti-diagonal.
    return (
        CursorType.BDIAG_ARROW
        if _nearest_corner(wx, wy) % 2 == 0
        else CursorType.FDIAG_ARROW
    )


# ── Helpers ───────────────────────────────────────────────────────────────────


def _vertices_from_corners(x0: float, y0: float, x1: float, y1: float) -> np.ndarray:
    """Takes any two opposite corners of a rectangle and returns all four vertices.

    These vertices are returned in counter-clockwise order as
    bottom-left, bottom-right, top-right, top-left.
    """
    mi = (min(x0, x1), min(y0, y1))
    ma = (max(x0, x1), max(y0, y1))
    return np.asarray(
        [
            (mi[0], mi[1], 0),
            (ma[0], mi[1], 0),
            (ma[0], ma[1], 0),
            (mi[0], ma[1], 0),
        ]
    )


def _nearest_corner(wx: float, wy: float) -> int:
    """Index of the corner handle nearest to world position (wx, wy)."""
    world = rect_mesh.transform.map(rect_mesh.vertices)[:, :2]
    return int(np.argmin(np.linalg.norm(world - [wx, wy], axis=1)))


# ── Event filter ──────────────────────────────────────────────────────────────


def _event_filter(event: Event) -> bool:
    global _anchor, _drag_start

    if isinstance(event, MouseMoveEvent):
        if not (ray := view.to_ray(event.pos)):
            return False
        pos = np.array(ray.origin[:2])

        # -- Dragging a handle -- #
        if _anchor is not None:
            # Determine the new rectangle bounding box from the anchor point and the
            # mouse position
            new_vertices = _vertices_from_corners(pos[0], pos[1], *_anchor)
            # Update the nodes
            rect_mesh.vertices = new_vertices
            rect_line.vertices = new_vertices[[0, 1, 2, 3, 0]]
            handles.vertices = new_vertices
            # Reset the cursor in case we are "flipping" the rectangle
            # by dragging a corner past an edge it is not connected to.
            snx.set_cursor(canvas, _cursor_for_pos(*pos))
            return True

        # -- Dragging the whole rectangle -- #
        if _drag_start is not None:
            # Determine the offset since the last mouse event
            delta = pos - _drag_start
            # Offset two vertices to get the new rectangle bounding box
            # NOTE we just need two opposite corners, doesn't matter which two.
            v0 = rect_mesh.vertices[0, :2] + delta
            v2 = rect_mesh.vertices[2, :2] + delta
            new_vertices = _vertices_from_corners(*v0, *v2)
            # Update the nodes
            rect_mesh.vertices = new_vertices
            rect_line.vertices = new_vertices[[0, 1, 2, 3, 0]]
            handles.vertices = new_vertices
            # Record the new position for a future drag
            _drag_start = pos
            return True

        # -- Hover -- #
        if ray.intersections(handles):
            snx.set_cursor(canvas, _cursor_for_pos(*pos))
        elif ray.intersections(rect_mesh):
            snx.set_cursor(canvas, CursorType.ALL_ARROW)
        else:
            snx.set_cursor(canvas, CursorType.DEFAULT)

    elif isinstance(event, MousePressEvent):
        if event.buttons & MouseButton.LEFT:
            if not (ray := view.to_ray(event.pos)):
                return False
            pos = np.array(ray.origin[:2])
            # -- Start a handle drag -- #
            if ray.intersections(handles):
                # Find the clicked point
                clicked = _nearest_corner(*pos)
                # And record the other point
                opp = (clicked + 2) % 4
                _anchor = rect_mesh.vertices[opp, :2]
                return True
            # -- Start a rectangle drag -- #
            if ray.intersections(rect_mesh):
                _drag_start = pos
                return True

    elif isinstance(event, MouseReleaseEvent):
        # -- End a drag -- #
        _anchor = None
        _drag_start = None
        return True

    elif isinstance(event, MouseLeaveEvent):
        # -- End a drag -- #
        _anchor = None
        _drag_start = None
        snx.set_cursor(canvas, CursorType.DEFAULT)

    return False


def _on_vertices_changed(vertices: np.ndarray) -> None:
    # Offset by 0.5 because pixels are at integers, not half-integers.
    xs = vertices[:, 0]
    ys = vertices[:, 1]
    x0 = int(np.clip(np.ceil(xs.min()), 0, IMG_W))
    x1 = int(np.clip(np.ceil(xs.max()), 0, IMG_W))
    y0 = int(np.clip(np.ceil(ys.min()), 0, IMG_H))
    y1 = int(np.clip(np.ceil(ys.max()), 0, IMG_H))

    display = img_data.copy()
    display[y0:y1, x0:x1] = 255 - img_data[y0:y1, x0:x1]
    bg.data = display


# Connect the event filter to listen for user events
view.set_event_filter(_event_filter)

# Connect the vertex change callback
_on_vertices_changed(rect_mesh.vertices)  # initialize display
rect_mesh.events.vertices.connect(_on_vertices_changed)

# Run!
snx.run()