Skip to content

visualdynamics.viz.pick

pick

Finding the entity under the cursor.

Picking is done in screen space: project the geometry through the camera once, then ask which entity is nearest the cursor in pixels. That is the question the user is actually asking — "what is under my pointer" — and it answers it whether or not a ray would have hit anything, which matters for a wireframe where most of the screen is empty space between thin lines.

The projection is cached and rebuilt only when the camera moves, so a mouse move costs one vectorized distance computation.

Pure VTK and numpy: no picking hardware, no extra dependencies.

Classes:

Name Description
ScreenProjector

Projects a geometry's nodes to pixels, cached against camera moves.

EntityPicker

Which node, coordinate system, traceline or element is under a pixel.

Classes

ScreenProjector

ScreenProjector(renderer: Any, points: ArrayLike)

Projects a geometry's nodes to pixels, cached against camera moves.

Methods:

Name Description
screen

(pixels (N,2), depth (N,)) for every node, from the cache.

reference_depth

Depth to place a new point at: that of the nearest existing node.

place_point

Where to put a new point clicked at (x, y).

unproject

The world point under a pixel, at a given (or inferred) depth.

Source code in src/visualdynamics/viz/pick.py
def __init__(self, renderer: Any, points: ArrayLike) -> None:
    self.renderer: Any = renderer
    self.points: np.ndarray = np.ascontiguousarray(points, dtype=np.float64)
    self._homogeneous = np.column_stack(
        [self.points, np.ones(len(self.points))])
    self._key = None
    self._screen: np.ndarray | None = None
    self._depth: np.ndarray | None = None
    self._inverse = None
    self._size = (1, 1)
Methods:
screen
screen() -> tuple[ndarray, ndarray]

(pixels (N,2), depth (N,)) for every node, from the cache.

Source code in src/visualdynamics/viz/pick.py
def screen(self) -> tuple[np.ndarray, np.ndarray]:
    """(pixels (N,2), depth (N,)) for every node, from the cache."""
    key = self._camera_key()
    if key != self._key:
        self._project()
        self._key = key
    screen, depth = self._screen, self._depth
    assert screen is not None and depth is not None
    return screen, depth
reference_depth
reference_depth(x: float, y: float) -> float

Depth to place a new point at: that of the nearest existing node.

A click gives two dimensions; the third has to come from somewhere. Borrowing the nearest node's depth puts the new point on a plane through the model near where the user clicked, which is where they are looking.

Source code in src/visualdynamics/viz/pick.py
def reference_depth(self, x: float, y: float) -> float:
    """Depth to place a new point at: that of the nearest existing node.

    A click gives two dimensions; the third has to come from somewhere.
    Borrowing the nearest node's depth puts the new point on a plane
    through the model near where the user clicked, which is where they
    are looking.
    """
    screen, depth = self.screen()
    if not len(screen):
        return 0.0                 # empty model: the focal plane
    distances = np.linalg.norm(screen - np.array([x, y]), axis=1)
    return float(depth[int(np.argmin(distances))])
place_point
place_point(x: float, y: float) -> ndarray

Where to put a new point clicked at (x, y).

The click gives two dimensions. The third comes from the model: the click ray is intersected with a plane through the nearest node. For a flat model — a plate, a panel — that plane is the model's own, so the new point lands on it rather than floating above; otherwise the plane faces the camera, which keeps the point near what was clicked.

Source code in src/visualdynamics/viz/pick.py
def place_point(self, x: float, y: float) -> np.ndarray:
    """Where to put a new point clicked at (x, y).

    The click gives two dimensions. The third comes from the model: the
    click ray is intersected with a plane through the nearest node. For a
    flat model — a plate, a panel — that plane is the model's own, so the
    new point lands *on* it rather than floating above; otherwise the
    plane faces the camera, which keeps the point near what was clicked.
    """
    screen, _ = self.screen()
    if not len(screen):
        return self.unproject(x, y, 0.0)

    distances = np.linalg.norm(screen - np.array([x, y]), axis=1)
    anchor = self.points[int(np.argmin(distances))]

    near = self.unproject(x, y, -1.0)
    far = self.unproject(x, y, 1.0)
    direction = far - near
    normal = self._plane_normal()
    if normal is None:
        return self.unproject(x, y)          # not planar: use the depth
    denominator = float(np.dot(direction, normal))
    if abs(denominator) < 1e-9:              # looking along the plane
        return self.unproject(x, y)
    t = float(np.dot(anchor - near, normal)) / denominator
    return near + t * direction
unproject
unproject(
    x: float, y: float, depth: float | None = None
) -> ndarray

The world point under a pixel, at a given (or inferred) depth.

Source code in src/visualdynamics/viz/pick.py
def unproject(self, x: float, y: float,
              depth: float | None = None) -> np.ndarray:
    """The world point under a pixel, at a given (or inferred) depth."""
    self.screen()                  # make sure the transform is current
    if depth is None:
        depth = self.reference_depth(x, y)
    width, height = self._size
    ndc = np.array([2.0 * x / width - 1.0,
                    2.0 * y / height - 1.0,
                    depth, 1.0])
    world = self._inverse @ ndc
    return world[:3] / world[3]

EntityPicker

EntityPicker(
    geometry: Geometry,
    kind: str,
    projector: ScreenProjector,
)

Which node, coordinate system, traceline or element is under a pixel.

Candidate geometry is reduced once to points or segments over node rows; a pick then projects, measures in pixels, and takes the nearest within the tolerance, breaking ties by depth so the front-most wins.

Methods:

Name Description
pick

The entity under (x, y) in pixels, or None.

Source code in src/visualdynamics/viz/pick.py
def __init__(self, geometry: Geometry, kind: str,
             projector: ScreenProjector) -> None:
    self.geometry: Any = geometry
    self.kind: str = kind
    self.projector: ScreenProjector = projector
    self.rows: np.ndarray | None = None      # for point-like kinds
    self.segments: tuple[np.ndarray, np.ndarray] | None = None
    self.owner: np.ndarray | None = None     # entity per segment
    #: face interiors, so clicking inside a face works
    self.triangles: np.ndarray | None = None
    self.triangle_owner: np.ndarray | None = None
    self._prepare()
Methods:
pick
pick(
    x: float, y: float, tolerance: float = 12.0
) -> int | None

The entity under (x, y) in pixels, or None.

Returns a node id, a coordinate system id, or a traceline/element index, matching what the rest of the code uses to identify each kind.

Source code in src/visualdynamics/viz/pick.py
def pick(self, x: float, y: float,
         tolerance: float = 12.0) -> int | None:
    """The entity under (x, y) in pixels, or None.

    Returns a node id, a coordinate system id, or a traceline/element
    index, matching what the rest of the code uses to identify each kind.
    """
    screen, depth = self.projector.screen()
    cursor = np.array([float(x), float(y)])

    if self.kind == 'coordinate_systems':
        return self._pick_origin(cursor, tolerance)

    if self.kind == 'nodes':
        if not len(screen):
            return None
        distances = np.linalg.norm(screen - cursor, axis=1)
        nearest = _nearest_candidate(distances, depth, tolerance)
        if nearest is None:
            return None
        return int(self.geometry.node_id[nearest])

    inside = self._pick_face(cursor, screen, depth)
    if inside is not None:
        return inside

    starts, ends = self.segments
    if not len(starts):
        return None
    distances = _segment_distances(cursor, screen[starts], screen[ends])
    midpoint_depth = 0.5 * (depth[starts] + depth[ends])
    nearest = _nearest_candidate(distances, midpoint_depth, tolerance)
    return None if nearest is None else int(self.owner[nearest])