Source code for pygfx.objects._ruler

from math import floor, ceil, log10
from typing import Literal, Callable

import numpy as np

from ._base import WorldObject
from ._more import Line, Points
from ._text import MultiText
from ..resources import Buffer
from ..materials import LineMaterial, PointsMarkerMaterial, TextMaterial
from ..utils.compgeo import get_visible_part_of_line_ndc


class Ruler(WorldObject):
    """An object to represent a ruler with tickmarks.

    Can be used to measure distances in a scene, or as an axis in a plot.
    The ruler object is a "compound" object; it has text, lines, and points as child objects.

    Usage:

    * Use the properties (most notably ``start_pos`` and ``end_pos``).
    * Call ``update()`` on each draw.

    Parameters
    ----------
    start_pos : tuple[float, float, float]
        The initial position of the start of the ruler.
    end_pos : tuple[float, float, float]
        The initial position of the end of the ruler.
    start_value : float
        The value (offset) at the start of the ruler.
    ticks : array-like or None
        A list of values, in world-space from the ruler's start_pos, where ticks should be drawn.
        If not given or None, this will be determined automatically.
    tick_format : str | Callable
        The format to represent ticks with, or a function to convert floats to str. Default "0.4g".
    tick_side : str
        Whether the tick texts are on the 'left' or 'right' of the line (from the p.o.v. of the ruler). Default left.
    tick_marker : str
        Any marker from the PointsMarkerMaterial. Sensible values are 'tick', 'tick_left', and 'tick_right'.
    tick_size : float
        The size of the tickmarks (in logical screen pixels). When using half ticks
        (tick_marker is 'tick_left' or 'tick_right', the effective size is halved). Default 8.
    ticks_at_end_points : bool
        Whether to draw ticks at the ruler's strat and end. Default False
    min_tick_distance: float
        The minimal distance between ticks in screen pixels, when using auto-ticks. Default 50.
    color : str | tuple[float, float, float, float]
        The color for the internal line, points and text objects. Default white.
    line_width : float
        The width of the line and tickmarks. Default 2.0.
    alpha_mode : str | None
        Override the default alpha mode for the line, points and text.
    render_queue : int | None
        Override the default render queue for the line, points and text.
    """

    def __init__(
        self,
        *,
        start_pos: tuple[float, float, float] = (0, 0, 0),
        end_pos: tuple[float, float, float] = (0, 0, 0),
        start_value: float = 0.0,
        ticks: list | None = None,
        tick_format: str | Callable = "0.4g",
        tick_side: Literal["left", "right"] = "left",
        tick_marker: Literal["tick", "tick_left", "tick_right"] = "tick",
        tick_size: float = 8.0,
        ticks_at_end_points=False,
        min_tick_distance: float = 50.0,
        color: str | tuple[float, float, float, float] = "#fff",
        line_width: float = 2.0,
        alpha_mode: str | None = None,
        render_queue: int | None = None,
    ):
        super().__init__()

        self.start_pos = start_pos
        self.end_pos = end_pos
        self.start_value = start_value
        self.ticks = ticks
        self._tick_size = float(tick_size)

        self.tick_format = tick_format
        self.tick_side = tick_side
        self.min_tick_distance = min_tick_distance
        self.ticks_at_end_points = ticks_at_end_points

        # Common kwargs for the materials of the child objects
        material_kwargs = dict(
            alpha_mode=alpha_mode,
            render_queue=render_queue,
        )

        # Create a line and points object, with a shared geometry.
        # The 'tick' marker is a hair-line (i.e. no body), so you only
        # see the edge of the marker; we can simply match the edge_width with the
        # thickness of the line!
        self._text = MultiText(
            material=TextMaterial(color=color, aa=True, **material_kwargs),
            screen_space=True,
        )
        geometry = self._text.geometry  # has .positions buffer
        geometry.sizes = Buffer(np.zeros(geometry.positions.nitems, "f4"))
        self._line = Line(
            geometry,
            LineMaterial(
                color=color, thickness=line_width, aa=False, **material_kwargs
            ),
        )
        self._points = Points(
            geometry,
            PointsMarkerMaterial(
                marker=tick_marker,
                color="red",
                edge_color=color,
                edge_width=line_width,
                size_mode="vertex",
                rotation_mode="curve",
                aa=False,
                **material_kwargs,
            ),
        )

        # NOTE: a potential improvement, for the marker material's support for ticks, would
        # be be able to make every nth tick  (longer/ticker).

        self.add(self._line, self._points, self._text)

        # todo: a material to draw proper tick marks

[docs] def get_bounding_box(self): # A ruler does not have a bounding box by itself return None
# -- Properties to easily access sub-objects @property def line(self): """The line object that shows the ruler's path.""" return self._line @property def points(self): """The points object that shows the ruler's tickmarks.""" return self._points @property def text(self): """The text object that shows the ruler's tick labels.""" return self._text # Note: text should also be here eventually # -- Main properties @property def start_pos(self): """The start posision of the ruler, in model space. Note that the ruler's transform also affects positioning, but should generally not be used. """ return self._start_pos @start_pos.setter def start_pos(self, pos): start_pos = np.array(pos, np.float64).reshape((3,)) if start_pos.shape != (3,): raise ValueError("Ruler.start_pos must be a 3-element position.") self._start_pos = start_pos self._end_value = None @property def end_pos(self): """The end posision of the ruler, in model space.""" return self._end_pos @end_pos.setter def end_pos(self, pos): end_pos = np.array(pos, np.float64).reshape((3,)) if end_pos.shape != (3,): raise ValueError("Ruler.end_pos must be a 3-element position.") self._end_pos = end_pos self._end_value = None @property def start_value(self) -> float: """The value of the ruler at the start position (i.e. the offset).""" return self._start_value @start_value.setter def start_value(self, value: float): self._start_value = float(value) self._end_value = None @property def end_value(self) -> float: """The value at the end of the ruler (read-only).""" # Little caching mechanic. Props that affect the end_value set ._end_value to None if self._end_value is None: self._end_value = float( np.linalg.norm(self._end_pos - self._start_pos) + self._start_value ) return self._end_value @property def ticks(self): """The ticks to show. * ``None`` for automatic ticks. * ``dict`` for explicit ticks. Values can be str or float. If float, they are formatted with ``tick_format``. * ``list`` / ``tuple`` / ``ndarray`` for a list of values. """ return self._ticks @ticks.setter def ticks(self, ticks): if ticks is None: self._ticks = None elif isinstance(ticks, dict): # Copy the object, resolving keys and values to float and str self._ticks = {float(k): v for k, v in ticks.items()} elif isinstance(ticks, (tuple, list, np.ndarray)): self._ticks = [float(x) for x in ticks] else: raise TypeError("Ruler.ticks must be None, dict or list(like).") # -- Properties for tweaking @property def tick_format(self) -> str: """The format to display the tick values. * A string to use as the second arg in ``format()``, default "0.4g". * "km" to use mili/Kilo/Mega/Giga suffixes. * A function that maps (value, min_value, max_value) to a str. """ return self._tick_format @tick_format.setter def tick_format(self, tick_format: str): if isinstance(tick_format, str): self._tick_format = str(tick_format) elif callable(tick_format): # Emperically check the given function try: r = tick_format(0, -1, 1) except TypeError as err: raise ValueError( f"Incompatible tick_format function: {err!s}" ) from None if not isinstance(r, str): raise ValueError( f"Incompatible tick_format function: it must return str, not {r.__class__.__name__}" ) self._tick_format = tick_format @property def tick_side(self) -> str: """Whether the ticks are on the 'left' or 'right' of the line. Imagine standing on the start position, with the line in front of you. """ return self._tick_side @tick_side.setter def tick_side(self, side: str): side = str(side).lower() if side in ("left", "right"): self._tick_side = side else: raise ValueError("Tick side must be 'left' or 'right'.") @property def tick_marker(self) -> str: """The marker used for the ticks. Alias for ``ruler.points.material.marker``. This can be any value in :obj:`pygfx.utils.enums.MarkerShape`. Sensible values include 'tick', 'tick_left', and 'tick_right'. """ return self._points.material.marker @tick_marker.setter def tick_marker(self, marker: str): self._points.material.marker = marker @property def tick_size(self) -> float: """The size of the tick marker, i.e. the length of the little line segment.""" return self._tick_size @tick_size.setter def tick_size(self, size: float): self._tick_size = float(size) @property def ticks_at_end_points(self) -> bool: """Whether to show tickmarks at the end-points.""" return self._ticks_at_end_points @ticks_at_end_points.setter def ticks_at_end_points(self, value: bool): self._ticks_at_end_points = bool(value) @property def min_tick_distance(self) -> float: """The minimal distance between ticks in screen pixels, when using auto-ticks.""" return self._min_tick_dist @min_tick_distance.setter def min_tick_distance(self, value: float): value = float(value) if value < 0.0: raise ValueError("tick distance must be larger than zero.") self._min_tick_dist = value @property def color(self): """The color of the ruler components. The getter is an alias for ``ruler.text.material.color``. Setting this value sets ``ruler.text.material.color``, ``ruler.line.material.color``, and ``ruler.points.material.edge_color``. """ return self._text.material.color @color.setter def color(self, color): self._text.material.color = color self._line.material.color = color self._points.material.edge_color = color @property def line_width(self) -> float: """The width of the line and tickmarks. The getter is an alias for ``ruler.line.material.thickness``. Setting this value sets ``ruler.line.material.thickness`` and ``ruler.points.material.edge_width``. """ return self._line.material.thickness @line_width.setter def line_width(self, line_width: float): self._line.material.thickness = line_width self._points.material.edge_width = line_width # -- Methods def update(self, camera, canvas_size): """Update the ruler. This must be called on every draw, right before rendering. Returns a dictionary with the following fields: * "tick_step": the calculated auto-tick-step. * "tick_values": the tick values that will be shown. """ # Determine which part of the ruler is on screen and its length in screen pixels self._configure_for_screen(camera, canvas_size) # Anchor screen_vec = self._visible_part_screen_vec self._calculate_text_anchor(np.arctan2(screen_vec[1], screen_vec[0])) # Get the dict with visible ticks tick_auto_step = self._calculate_tick_step() visible_ticks = self._get_ticks_dict(tick_auto_step) # Update objects to show these ticks self._update_sub_objects(visible_ticks, tick_auto_step) # Return stats. This is a dict, so we can add more stuff later, if needed. return { "tick_step": tick_auto_step, "tick_values": list(visible_ticks.keys()), } def _configure_for_screen(self, camera, canvas_size): """Make the ruler aware of the camera and viewport size.""" half_canvas_size = 0.5 * np.array(canvas_size, np.float64).reshape(1, 2) # Get ndc coords for begin and end pos. Use numpy broadcasting for performance and compactness. positions = np.column_stack( [ np.vstack([self._start_pos, self._end_pos]), np.ones((2, 1), np.float64), ] ) ndc_full = ( camera.camera_matrix @ self.world.matrix @ positions[..., None] ).reshape(-1, 4) screen_full = (ndc_full[:, :2] / ndc_full[:, 3:4]) * half_canvas_size # Get what part of the line is visible t1, t2 = get_visible_part_of_line_ndc(ndc_full[0], ndc_full[1]) # Get screen coords for visible selection. ndc_sel = np.array( [ ndc_full[0] * (1 - t1) + ndc_full[1] * t1, ndc_full[0] * (1 - t2) + ndc_full[1] * t2, ] ) screen_sel = (ndc_sel[:, :2] / ndc_sel[:, 3:4]) * half_canvas_size # Store values self._screen_vec = screen_full[1] - screen_full[0] start_value, end_value = self._start_value, self.end_value self._visible_part_coords = t1, t2 self._visible_part_values = ( start_value * (1.0 - t1) + end_value * t1, start_value * (1.0 - t2) + end_value * t2, ) self._visible_part_screen_vec = screen_sel[1] - screen_sel[0] def _calculate_text_anchor(self, angle): """Calculate the best place to anchor the text labels. With this anchor, the text labels move smoothly without a jump to the other side, as the ruler is rotated. """ if self._tick_side == "left": if abs(angle) <= 0.25 * np.pi: self._text_anchor = "bottom-center" self._text_anchor_offset = 5 elif abs(angle) >= 0.75 * np.pi: self._text_anchor = "top-center" self._text_anchor_offset = 5 elif angle < 0: self._text_anchor = "middle-left" self._text_anchor_offset = 10 else: self._text_anchor = "middle-right" self._text_anchor_offset = 10 else: if abs(angle) <= 0.25 * np.pi: self._text_anchor = "top-center" self._text_anchor_offset = 5 elif abs(angle) >= 0.75 * np.pi: self._text_anchor = "bottom-center" self._text_anchor_offset = 5 elif angle < 0: self._text_anchor = "middle-right" self._text_anchor_offset = 10 else: self._text_anchor = "middle-left" self._text_anchor_offset = 10 def _get_ticks_dict(self, tick_auto_step): """Get a tick-dict, derived from the user-given tick value, and constrained to the visual part of the ruler. """ min_value, max_value = self._visible_part_values tick_format = self._tick_format def default_tick_format_func(val, min_val, max_val): if val == 0: return "0" else: return format(val, tick_format) # Select funtion to format the tick values if tick_format == "km": tick_format_func = kmg_tick_format_func elif isinstance(tick_format, str): tick_format_func = default_tick_format_func elif callable(tick_format): tick_format_func = tick_format else: # Fallback tick_format = "0.4g" tick_format_func = default_tick_format_func # Triage based on what ticks are given ticks = self._ticks if ticks is None: # Auto-ticks tick_values = self._get_ticks_uniform(min_value, max_value, tick_auto_step) return {t: tick_format_func(t, min_value, max_value) for t in tick_values} elif isinstance(ticks, list): # A sequence of ticks return { t: tick_format_func(t, min_value, max_value) for t in ticks if min_value <= t <= max_value } else: # isinstance(ticks, dict): # A dict with specified ticks, values can be str or float result = {} for t, v in ticks.items(): if min_value <= t <= max_value: if isinstance(v, (float, int)): v = tick_format_func(v, min_value, max_value) elif not isinstance(v, str): v = str(v) result[t] = v return result def _calculate_tick_step(self): """Calculate the tick step from the min_tick_distance.""" min_tick_dist = self._min_tick_dist # Determine distances for visible selection world_dist = self._visible_part_values[1] - self._visible_part_values[0] screen_dist = float(np.linalg.norm(self._visible_part_screen_vec)) # Fall back to full size if selection is zero. This way, the # value of step still makes sense, even when the ruler itself # is not on screen, and calling code may still use it to e.g. # configure a grid. Account for roundoff errors resulting in a nonzero value. if screen_dist < 1e-9: world_dist = self.end_value - self._start_value screen_dist = np.linalg.norm(self._screen_vec) # Determine step step = 0 if world_dist > 0 and screen_dist > 0: scale = screen_dist / world_dist approx_step = min_tick_dist / scale power10 = 10 ** floor(log10(approx_step)) for i in (1, 2, 2.5, 5, 10): maybe_step = i * power10 if maybe_step > approx_step: step = maybe_step break return step def _get_ticks_uniform(self, min_value, max_value, step): """Get a uniformly distributed set of ticks.""" if not step: return [] first_tick = ceil(min_value / step) * step last_tick = floor(max_value / step) * step ticks = [] t = first_tick while t <= last_tick: ticks.append(t) t += step return ticks def _update_sub_objects(self, ticks, tick_auto_step): """Update the sub-objects to show the given ticks.""" assert isinstance(ticks, dict) tick_size = self._tick_size # Load config start_pos = self._start_pos end_pos = self._end_pos start_value = self._start_value end_value = self.end_value # Derive some more variables length = end_value - start_value vec = end_pos - start_pos if length: vec /= length # Get number of positions that we need n_positions = len(ticks) + 2 # Apply anchor props if self._text._anchor != self._text_anchor: self._text.anchor = self._text_anchor if self._text._anchor_offset != self._text_anchor_offset: self._text.anchor_offset = self._text_anchor_offset # Get the geometry to provide us with enough slots. Keep sizes array in sync self._text.set_text_block_count(n_positions) positions_buffer = self._text.geometry.positions sizes_buffer = self._text.geometry.sizes if sizes_buffer.nitems != positions_buffer.nitems: sizes_buffer = self._text.geometry.sizes = Buffer( np.zeros(positions_buffer.nitems, "f4") ) # Get arrays / list that we can write to positions = positions_buffer.data sizes = sizes_buffer.data text_blocks = self._text._text_blocks # Apply start point index = 0 positions[index] = start_pos if self._ticks_at_end_points: sizes[index] = tick_size text_blocks[index].set_text(f"{self._start_value:0.4g}") else: sizes[index] = 0 text_blocks[index].set_text("") # Collect ticks index += 1 for value, text in ticks.items(): pos = start_pos + vec * (value - start_value) positions[index] = pos sizes[index] = tick_size text_blocks[index].set_text(text) index += 1 # Apply end point positions[index] = end_pos if self._ticks_at_end_points: sizes[index] = tick_size text_blocks[index].set_text(f"{end_value:0.4g}") else: sizes[index] = 0 text_blocks[index].set_text("") # Hide the ticks close to the ends? if self._ticks_at_end_points and ticks: tick_values = list(ticks.keys()) if abs(tick_values[0] - start_value) < 0.5 * tick_auto_step: text_blocks[1].set_text("") if abs(tick_values[-1] - end_value) < 0.5 * tick_auto_step: text_blocks[index - 1].set_text("") # Make sure that the subset is drawn, and that the buffers are synced positions[n_positions:] = ( np.nan ) # prevent a partial join to be drawn in the line if positions_buffer.draw_range[1] != n_positions: positions_buffer.draw_range = 0, n_positions positions_buffer.update_full() sizes_buffer.update_full() # ---- Helper functions def kmg_tick_format_func(val, min_val, max_val): ref_value = max(abs(min_val), abs(max_val)) if ref_value >= 10_000_000_000: mult, unit = 1 / 1_000_000_000, "G" elif ref_value >= 10_000_000: mult, unit = 1 / 1_000_000, "M" elif ref_value >= 10000: mult, unit = 1 / 1000, "K" elif ref_value < 0.0001: mult, unit = 1_000_000, "u" elif ref_value < 0.1: mult, unit = 1000, "m" else: mult, unit = 1, "" if val == 0: return "0" else: return format(mult * val, "0.4g") + unit