Repository: pygfx/pygfx
Pull request #787 (2024-06-14 08:18:23)

Add Ruler object
Relates to #739 

### Introduction

This PR adds a new world object that shows a ruler. The primary purpose is to function as a base component for axii, but it can also be used to measure stuff interactively in a scene.

### The idea

Use a `gfx.Line` to draw the line, a `gfx.Points` to draw the tickmarks, and a set of `gfx.Text`s to draw the tick texts (I want to eventually use a single text object for this). These objects are wrapped in a new `gfx.Ruler` world object. That object is like a group object, and provides the API.

### What makes the API hard

We can establish a few steps that the ruler must perform, in order.

* User must provide start_pos, end_pos, start_value.
* The user must provide ticks, or `None` for automatic ticks.
* The camera must be used to determine the size on screen.
* From this the distance between ticks must be calculated.
* The sub-objects (line, points, text objects) must be updated to show line, ticks, and tick labels.

On top of that we want to let the user know the calculated tick-distance, and the values for the visible ticks, so that gridlines can be rendered in the right place.

In a previous version, I more or less implemented each step as a method, and let the user call things in correct order. But this was rather cumbersome. Instead, there's now a single  `update()` method, and it returns a dict with information that the user might want to use.

### API

The API is up for discussion. Ideas welcome. ~~Right now I used relatively simple methods. One needs to at least two calls to "apply" the ruler. This leads to somewhat more verbose code, but makes it more modular too. For example, the result of `calculate_tick_step()` can be used to set the step size in the grid!~~

Quick example:
```py
ruler = gfx.Ruler(**any_props)
ruler.start_pos = ...
ruler.end_pos = ...
ruler.other_props = ...

def animate():
    rulery.update(camera, canvas.get_logical_size())
    # Or, when you want the tick values:
    #ruler_stats = rulery.update(camera, canvas.get_logical_size())
```

### Notes on the "visible range"

The ruler has logic to determine a good distance between ticks so that ticks are not too far apart or too close. In other words, the tick density on screen remains more or less constant. But this means that the density of ticks in world space increases as one zooms in. So when zooming in with a factor 10, when there would normally be 20 ticks visible, there would be 200 on the total line. And possibly much more with a perspective camera. 

Therefore, the rulers detects the part of the line that is visible on screen, and only renders ticks that are in that range. Implementing this took some time to get right.

### Tasks

* [x] Add `anchor_offset` to `TextGeometry`, so the text does not hug the line.
* [x] Basic implementation.
* [x] Re-apply ticks as you zoom in and out. 
* [x] Discuss and flesh out the API.
* [x] Example use as a measuring tape.
* [x] Example use in a plot.
* [x] Hide tickmarks at beginning and end of the line.
* [x] Optimize tick step selection.
* [x] Implement exponential notation and umKM units.
* [x] Automatically determine text anchor from the line orientation.
* [x] Better tick spacing for perspective cameras.

### Tasks for later

* (try to) implement a `TextGeometry` that can show multiple single-line string at different positions in the scene. Apart from a performance benefit (less world objects), this also has an API advantage, as users can then change the material to change the appearance of the ticks.
* Implement a points material to draw tickmarks (using simple points for now).

### Some screenshots

<img width="612" alt="image" src="https://github.com/pygfx/pygfx/assets/3015475/4e62d680-b728-4877-8b6c-4335261a213b">

<img width="743" alt="image" src="https://github.com/pygfx/pygfx/assets/3015475/2b2bb502-4e46-4c7d-aa96-864e8cf1427c">





kushalkolar: This is amazing! :smile: :partying_face: I played with it a bit, I wonder if we could use resize and controller or camera callbacks instead of the animation function to set any change in ticks etc. I'll try this out in a few days, currently travelling/conferencing.

almarklein: > I wonder if we could use resize and controller or camera callbacks instead of the animation function to set any change in ticks etc.

Our camera's and controllers don't have callbacks. This discussion regarding updates/animations relates to #495 too
kushalkolar: Taking a look at this again, I can say that it's now very performant! :smile: . I'd like to take a few days to try out some things to see if there are any minor additional things that I may need.

It's easily possible to make the ruler follow the camera by modifying the animation function, probably makes sense to have this as an option in fastplotlib rather than here I guess :thinking: . This type of functionality is common in matplotlib, matlab etc.

https://github.com/pygfx/pygfx/assets/9403332/0f53ef9a-ee73-4d48-a121-6692cae53590

```python
"""
Line plot
=========

Show a line plot, with axii and a grid.
"""

# sphinx_gallery_pygfx_docs = 'screenshot'
# sphinx_gallery_pygfx_test = 'run'

import numpy as np
from wgpu.gui.auto import WgpuCanvas, run
import pygfx as gfx
from pylinalg import vec_transform, vec_unproject


canvas = WgpuCanvas()
renderer = gfx.WgpuRenderer(canvas)

scene = gfx.Scene()

background = gfx.Background.from_color("#000")

grid = gfx.Grid(
    None,
    gfx.GridMaterial(
        major_step=1,
        minor_step=0,
        thickness_space="screen",
        major_thickness=2,
        minor_thickness=0.5,
        infinite=True,
    ),
    orientation="xy",
)
grid.local.z = -1001

rulerx = gfx.Ruler(tick_side="right")
rulery = gfx.Ruler(tick_side="left", min_tick_distance=40)

x = np.linspace(20, 980, 200, dtype=np.float32)
y = np.sin(x / 30) * 4
positions = np.column_stack([x, y, np.zeros_like(x)])

line = gfx.Line(
    gfx.Geometry(positions=positions),
    gfx.LineMaterial(thickness=4.0, color="#aaf"),
)
scene.add(background, grid, rulerx, rulery, line)

camera = gfx.OrthographicCamera(maintain_aspect=False)
camera.show_rect(0, 1000, -5, 5)

controller = gfx.PanZoomController(camera, register_events=renderer)


def animate():
    vs = renderer.logical_size
    # place the ruler close to the left and bottom edges of the viewport
    xscreen, yscreen = 0.1 * vs[0], 0.9 * vs[1]

    pos_ndc = (xscreen / vs[0] * 2 - 1, -(yscreen / vs[1] * 2 - 1), 0)

    pos_ndc += vec_transform(camera.world.position, camera.camera_matrix)
    xworld, yworld, _ = vec_unproject(pos_ndc[:2], camera.camera_matrix)

    rulerx.start_pos = camera.world.x - camera.width / 2, yworld, -1000
    rulerx.end_pos = camera.world.x + camera.width / 2, yworld, -1000
    rulerx.start_value = rulerx.start_pos[0]
    statsx = rulerx.update(camera, canvas.get_logical_size())

    rulery.start_pos = xworld, camera.world.y - camera.height / 2, -1000
    rulery.end_pos = xworld, camera.world.y + camera.height / 2, -1000
    rulery.start_value = rulery.start_pos[1]
    statsy = rulery.update(camera, canvas.get_logical_size())

    major_step_x, major_step_y = statsx["tick_step"], statsy["tick_step"]
    grid.material.major_step = major_step_x, major_step_y
    grid.material.minor_step = 0.2 * major_step_x, 0.2 * major_step_y

    # print(statsx)

    renderer.render(scene, camera)


if __name__ == "__main__":
    canvas.request_draw(animate)
    run()
```
kushalkolar: Oh idea! Mapping the world space to the underlying dataunits. For example, the x-axis is often time and integer steps on the x-axis may be video frames, but the user actually wants to display timepoints. What's the best way to do this? One idea is allowing a user function to be passed that maps from world space to the data space. This space mapping function could be used in `Ruler._get_ticks_uniform()`? 
almarklein: @kushalkolar Yeah the performance is much better than the first iteration. Nice suggestions! I added them to #739. For now I just want to get this merged. The mapping for the ticks should be an easy addition though!

Patch:
diff --git a/examples/feature_demo/line_plot.py b/examples/feature_demo/line_plot.py
new file mode 100644
index 0000000..45dd22f
--- /dev/null
+++ b/examples/feature_demo/line_plot.py
@@ -0,0 +1,78 @@
+"""
+Line plot
+=========
+
+Show a line plot, with axii and a grid.
+"""
+
+# sphinx_gallery_pygfx_docs = 'screenshot'
+# sphinx_gallery_pygfx_test = 'run'
+
+import numpy as np
+from wgpu.gui.auto import WgpuCanvas, run
+import pygfx as gfx
+
+
+canvas = WgpuCanvas()
+renderer = gfx.WgpuRenderer(canvas)
+
+scene = gfx.Scene()
+
+background = gfx.Background.from_color("#000")
+
+grid = gfx.Grid(
+    None,
+    gfx.GridMaterial(
+        major_step=1,
+        minor_step=0,
+        thickness_space="screen",
+        major_thickness=2,
+        minor_thickness=0.5,
+        infinite=True,
+    ),
+    orientation="xy",
+)
+grid.local.z = -1001
+
+rulerx = gfx.Ruler(tick_side="right")
+rulery = gfx.Ruler(tick_side="left", min_tick_distance=40)
+
+x = np.linspace(20, 980, 200, dtype=np.float32)
+y = np.sin(x / 30) * 4
+positions = np.column_stack([x, y, np.zeros_like(x)])
+
+line = gfx.Line(
+    gfx.Geometry(positions=positions),
+    gfx.LineMaterial(thickness=4.0, color="#aaf"),
+)
+scene.add(background, grid, rulerx, rulery, line)
+
+camera = gfx.OrthographicCamera(maintain_aspect=False)
+camera.show_rect(0, 1000, -5, 5)
+
+controller = gfx.PanZoomController(camera, register_events=renderer)
+
+
+def animate():
+    rulerx.start_pos = camera.world.x - camera.width / 2, 0, -1000
+    rulerx.end_pos = camera.world.x + camera.width / 2, 0, -1000
+    rulerx.start_value = rulerx.start_pos[0]
+    statsx = rulerx.update(camera, canvas.get_logical_size())
+
+    rulery.start_pos = 0, camera.world.y - camera.height / 2, -1000
+    rulery.end_pos = 0, camera.world.y + camera.height / 2, -1000
+    rulery.start_value = rulery.start_pos[1]
+    statsy = rulery.update(camera, canvas.get_logical_size())
+
+    major_step_x, major_step_y = statsx["tick_step"], statsy["tick_step"]
+    grid.material.major_step = major_step_x, major_step_y
+    grid.material.minor_step = 0.2 * major_step_x, 0.2 * major_step_y
+
+    # print(statsx)
+
+    renderer.render(scene, camera)
+
+
+if __name__ == "__main__":
+    canvas.request_draw(animate)
+    run()
diff --git a/examples/feature_demo/measure_distances_2d.py b/examples/feature_demo/measure_distances_2d.py
new file mode 100644
index 0000000..15d9f1e
--- /dev/null
+++ b/examples/feature_demo/measure_distances_2d.py
@@ -0,0 +1,56 @@
+"""
+Measure distances in 2D
+=======================
+
+Example to do measurements in a 2D image. Use LMB and RMB to place the
+end-points.
+"""
+
+# sphinx_gallery_pygfx_docs = 'screenshot'
+# sphinx_gallery_pygfx_test = 'run'
+
+import numpy as np
+import imageio.v3 as iio
+from wgpu.gui.auto import WgpuCanvas, run
+import pygfx as gfx
+
+
+canvas = WgpuCanvas()
+renderer = gfx.renderers.WgpuRenderer(canvas)
+scene = gfx.Scene()
+
+
+im = iio.imread("imageio:astronaut.png")
+image = gfx.Image(
+    gfx.Geometry(grid=gfx.Texture(im, dim=2)),
+    gfx.ImageBasicMaterial(clim=(0, 255), pick_write=True),
+)
+scene.add(image)
+
+ruler = gfx.Ruler(ticks_at_end_points=True)
+ruler.local.z = 0.1  # move on top of the image
+scene.add(ruler)
+
+camera = gfx.OrthographicCamera(512, 512)
+camera.local.position = (256, 256, 0)
+camera.local.scale_y = -1
+
+
+@image.add_event_handler("click")
+def event_handler(event):
+    pos = np.array([*event.pick_info["index"], 0])
+    if event.button == 1:
+        ruler.start_pos = pos
+    elif event.button == 2:
+        ruler.end_pos = pos
+    renderer.request_draw()
+
+
+def animate():
+    ruler.update(camera, canvas.get_logical_size())
+    renderer.render(scene, camera)
+
+
+if __name__ == "__main__":
+    canvas.request_draw(animate)
+    run()
diff --git a/examples/feature_demo/measure_distances_3d.py b/examples/feature_demo/measure_distances_3d.py
new file mode 100644
index 0000000..c275126
--- /dev/null
+++ b/examples/feature_demo/measure_distances_3d.py
@@ -0,0 +1,76 @@
+"""
+Measure distances in 3D
+=======================
+
+Example to do measurements in a 3D scene. Click on the surface of the
+two objects to see the distance betweeen these points.
+"""
+
+# sphinx_gallery_pygfx_docs = 'screenshot'
+# sphinx_gallery_pygfx_test = 'run'
+
+import numpy as np
+import imageio.v3 as iio
+from wgpu.gui.auto import WgpuCanvas, run
+import pygfx as gfx
+
+
+canvas = WgpuCanvas()
+renderer = gfx.renderers.WgpuRenderer(canvas)
+scene = gfx.Scene()
+
+im = iio.imread("imageio:bricks.jpg")
+tex = gfx.Texture(im, dim=2)
+
+geometry = gfx.torus_knot_geometry(1, 0.3, 128, 32)
+geometry.texcoords.data[:, 0] *= 10  # stretch the texture
+material = gfx.MeshPhongMaterial(map=tex, pick_write=True)
+
+obj1 = gfx.Mesh(geometry, material)
+obj2 = gfx.Mesh(geometry, material)
+obj1.local.x = -3
+obj2.local.x = 3
+scene.add(obj1, obj2)
+
+ruler = gfx.Ruler(ticks_at_end_points=True)
+scene.add(ruler)
+
+camera = gfx.PerspectiveCamera(70, 1)
+camera.show_object(scene)
+
+scene.add(gfx.AmbientLight(), camera.add(gfx.DirectionalLight()))
+
+controller = gfx.OrbitController(camera, register_events=renderer)
+
+
+@obj1.add_event_handler("pointer_down")
+@obj2.add_event_handler("pointer_down")
+def handle_clicks(event):
+    if event.target is obj1 or event.target is obj2:
+        face_index = event.pick_info["face_index"]
+        face_coord = event.pick_info["face_coord"]
+        vertex_indices = geometry.indices.data[face_index]
+        pos = np.sum(
+            [
+                geometry.positions.data[i] * w
+                for i, w in zip(vertex_indices, face_coord)
+            ],
+            axis=0,
+        )
+        pos += event.target.world.position
+
+        if event.target is obj1:
+            ruler.start_pos = pos
+        else:
+            ruler.end_pos = pos
+
+
+def animate():
+
+    ruler.update(camera, canvas.get_logical_size())
+    renderer.render(scene, camera)
+
+
+if __name__ == "__main__":
+    canvas.request_draw(animate)
+    run()
diff --git a/pygfx/geometries/_text.py b/pygfx/geometries/_text.py
index b8940ed..2468200 100644
--- a/pygfx/geometries/_text.py
+++ b/pygfx/geometries/_text.py
@@ -184,6 +184,8 @@ class TextGeometry(Geometry):
         depending on the value of the ``screen_space`` property. Default 12.
     anchor : str
         The position of the origin of the text. Default "middle-center".
+    anchor_offset : float
+        The offset (extra margin) for the 'top', 'bottom', 'left', and 'right' anchors.
     max_width : float
         The maximum width of the text. Words are wrapped if necessary. A value
         of zero means no wrapping. Default zero.
@@ -220,6 +222,7 @@ class TextGeometry(Geometry):
         screen_space=False,
         font_size=12,
         anchor="middle-center",
+        anchor_offset=0,
         max_width=0,
         line_height=1.2,
         text_align="left",
@@ -258,6 +261,7 @@ class TextGeometry(Geometry):
         # Set layout props
         self.font_size = font_size
         self.anchor = anchor
+        self.anchor_offset = anchor_offset
         self.max_width = max_width
         self.line_height = line_height
         self.text_align = text_align
@@ -794,21 +798,22 @@ class TextGeometry(Geometry):
 
         # Anchoring
 
+        anchor_offset = self.anchor_offset
         if anchor.endswith("left"):
-            pos_offset_x = -left
+            pos_offset_x = -left + anchor_offset
         elif anchor.endswith("right"):
-            pos_offset_x = -right
-        else:  # if anchor.endswith("center"):
+            pos_offset_x = -right - anchor_offset
+        else:
             pos_offset_x = -0.5 * (left + right)
 
         if anchor.startswith("top"):
-            pos_offset_y = -top
+            pos_offset_y = -top - anchor_offset
         elif anchor.startswith("middle"):
             pos_offset_y = -0.5 * (top + bottom)
         elif anchor.startswith("baseline"):
             pos_offset_y = -vertical_offset
         elif anchor.startswith("bottom"):
-            pos_offset_y = -bottom
+            pos_offset_y = -bottom + anchor_offset
         else:
             pos_offset_y = 0
 
@@ -1005,3 +1010,13 @@ class TextGeometry(Geometry):
         # Apply
         self._anchor = f"{anchory}-{anchorx}"
         self.apply_layout()
+
+    @property
+    def anchor_offset(self):
+        """The offset (extra margin) for the 'top', 'bottom', 'left', and 'right' anchors."""
+        return self._anchor_offset
+
+    @anchor_offset.setter
+    def anchor_offset(self, value):
+        self._anchor_offset = float(value)
+        self.apply_layout()
diff --git a/pygfx/objects/__init__.py b/pygfx/objects/__init__.py
index bb2ce26..c89c274 100644
--- a/pygfx/objects/__init__.py
+++ b/pygfx/objects/__init__.py
@@ -18,6 +18,7 @@
     Image
     Volume
     Text
+    Ruler
     InstancedMesh
     Light
     PointLight
@@ -73,6 +74,7 @@ from ._more import (
     Volume,
     Text,
 )
+from ._ruler import Ruler
 from ._instanced import InstancedMesh
 from ._lights import Light, PointLight, DirectionalLight, AmbientLight, SpotLight
 from ._lights import (
diff --git a/pygfx/objects/_ruler.py b/pygfx/objects/_ruler.py
new file mode 100644
index 0000000..820685d
--- /dev/null
+++ b/pygfx/objects/_ruler.py
@@ -0,0 +1,479 @@
+from math import floor, ceil, log10
+
+import numpy as np
+
+from ._base import WorldObject
+from ._more import Line, Points, Text
+from ..resources import Buffer
+from ..geometries import Geometry, TextGeometry
+from ..materials import LineMaterial, PointsMaterial, 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.
+
+    Usage:
+
+    * Use the properties (most notably ``start_pos`` and ``end_pos``).
+    * Call ``update()`` on each draw.
+    """
+
+    def __init__(
+        self,
+        *,
+        tick_format="0.4g",
+        tick_side="left",
+        min_tick_distance=50,
+        ticks_at_end_points=False,
+    ):
+        super().__init__()
+
+        self.start_pos = 0, 0, 0
+        self.end_pos = 0, 0, 0
+        self.start_value = 0.0
+        self.ticks = None
+
+        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
+
+        # Create a line and poins object, with a shared geometry
+        geometry = Geometry(
+            positions=np.zeros((0, 3), np.float32),
+            sizes=np.zeros((0,), np.float32),
+        )
+        self._line = Line(geometry, LineMaterial(color="w", thickness=2))
+        self._points = Points(geometry, PointsMaterial(color="w", size_mode="vertex"))
+        self.add(self._line, self._points)
+        self._text_object_pool = []
+
+        # todo: a material to draw proper tick marks
+        # todo: Text object that draws each line at a position from geometry
+
+    # -- 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
+
+    # 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):
+        """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):
+        self._start_value = float(value)
+        self._end_value = None
+
+    @property
+    def end_value(self):
+        """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 = (
+                np.linalg.norm(self._end_pos - self._start_pos) + self._start_value
+            )
+        return self._end_value
+
+    @property
+    def ticks(self):
+        """The ticks to show.
+
+        Can be:
+
+        * ``None`` for automatic ticks.
+        * ``dict`` for explicit ticks (float -> str).
+        * ``list`` / ``tupple`` / ``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): str(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):
+        """The format to display the tick values."""
+        return self._tick_format
+
+    @tick_format.setter
+    def tick_format(self, format):
+        self._tick_format = str(format)
+
+    @property
+    def tick_side(self):
+        """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):
+        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 min_tick_distance(self):
+        """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):
+        value = float(value)
+        if value < 0.0:
+            raise ValueError("tick distance must be larger than zero.")
+        self._min_tick_dist = value
+
+    @property
+    def ticks_at_end_points(self):
+        """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):
+        self._ticks_at_end_points = bool(value)
+
+    # -- 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."""
+
+        # Yuk, but needed
+        camera.update_projection_matrix()
+
+        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.row_stack([self._start_pos, self._end_pos]),
+                np.ones((2, 1), np.float64),
+            ]
+        )
+        ndc_full = (camera.camera_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
+
+        ticks = self._ticks
+        if ticks is None:
+            return self._get_ticks_uniform(min_value, max_value, tick_auto_step)
+        elif isinstance(ticks, dict):
+            return {x: v for x, v in ticks.items() if min_value <= x <= max_value}
+        else:
+            return {x: str(x) for x in ticks if min_value <= x <= max_value}
+
+    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 = 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.
+        if not screen_dist:
+            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 {}
+
+        tick_format = self._tick_format
+
+        # Apply some form of scaling
+        ref_value = max(abs(min_value), abs(max_value))
+        if tick_format.lower() == "km":  # use mk units
+            tick_format = "0.4g"  # for the remaining number
+            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, ""
+        else:
+            mult, unit = 1, ""
+
+        # Calculate tick values
+        first_tick = ceil(min_value / step) * step
+        last_tick = floor(max_value / step) * step
+        ticks = {}
+        t = first_tick
+        while t <= last_tick:
+            if t == 0:
+                s = "0"
+            else:
+                s = format(mult * t, tick_format)
+            ticks[t] = s + unit
+            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 = 5
+        min_n_slots = 8  # todo: can be (much) higher when we use a single text object!
+
+        # 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 array to store positions
+        n_slots = self.points.geometry.positions.nitems
+        n_positions = len(ticks) + 2
+        if n_positions <= n_slots <= max(min_n_slots, 2 * n_positions):
+            # Re-use existing buffers
+            positions = self.points.geometry.positions.data
+            sizes = self.points.geometry.sizes.data
+            self.points.geometry.positions.update_range()
+            self.points.geometry.sizes.update_range()
+        else:
+            # Allocate new buffers
+            new_n_slots = max(min_n_slots, int(n_positions * 1.2))
+            positions = np.zeros((new_n_slots, 3), np.float32)
+            sizes = np.zeros((new_n_slots,), np.float32)
+            self.points.geometry.positions = Buffer(positions)
+            self.points.geometry.sizes = Buffer(sizes)
+            # Allocate text objects
+            while len(self._text_object_pool) < new_n_slots:
+                ob = Text(
+                    TextGeometry("", screen_space=True),
+                    TextMaterial(),
+                )
+                self._text_object_pool.append(ob)
+            self._text_object_pool[new_n_slots:] = []
+            # Reset children
+            self.clear()
+            self.add(self._line, self._points, *self._text_object_pool)
+
+        def define_text(pos, text):
+            ob = self._text_object_pool[index]
+            ob.geometry.anchor = self._text_anchor
+            ob.geometry.anchor_offset = self._text_anchor_offset
+            ob.geometry.set_text(text)
+            ob.local.position = pos
+
+        # Apply start point
+        index = 0
+        positions[0] = start_pos
+        if self._ticks_at_end_points:
+            sizes[0] = tick_size
+            define_text(start_pos, f"{self._start_value:0.4g}")
+        else:
+            sizes[0] = 0
+            define_text(start_pos, f"")
+
+        # Collect ticks
+        index += 1
+        for value, text in ticks.items():
+            pos = start_pos + vec * (value - start_value)
+            positions[index] = pos
+            sizes[index] = tick_size
+            define_text(pos, text)
+            index += 1
+
+        # Handle end point, and nullify remaining slots
+        positions[index:] = end_pos
+        sizes[index:] = 0
+        for ob in self._text_object_pool[index:]:
+            ob.geometry.set_text("")
+
+        # Show last tick?
+        if self._ticks_at_end_points:
+            sizes[index] = tick_size
+            define_text(end_pos, f"{end_value:0.4g}")
+
+        # 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:
+                self._text_object_pool[1].geometry.set_text("")
+            if abs(tick_values[-1] - end_value) < 0.5 * tick_auto_step:
+                self._text_object_pool[index - 1].geometry.set_text("")
diff --git a/pygfx/utils/compgeo.py b/pygfx/utils/compgeo.py
new file mode 100644
index 0000000..bb46df6
--- /dev/null
+++ b/pygfx/utils/compgeo.py
@@ -0,0 +1,165 @@
+"""Computational geometry.
+
+This may at some pointe be moved to https://github.com/pygfx/pycompgeo.
+"""
+
+
+def get_visible_part_of_line_ndc(ndc1, ndc2):
+    """Get the visible part of the line, given by two homogeneous ndc coords.
+
+    Returns (t1, t2) representing the visible line. If the two values
+    are equal, the line is not in the viewport.
+    """
+
+    # Uncomment for performance measurements
+    # import time
+    # time_0 = time.perf_counter()
+
+    # Get closest t for all 4 edges
+    tx1 = binary_search_for_ndc_edge(ndc1, ndc2, -1, 0)
+    tx2 = binary_search_for_ndc_edge(ndc1, ndc2, +1, 0)
+    ty1 = binary_search_for_ndc_edge(ndc1, ndc2, -1, 1)
+    ty2 = binary_search_for_ndc_edge(ndc1, ndc2, +1, 1)
+
+    # Sort (because line can be oriented in any way)
+    tx1, tx2 = min(tx1, tx2), max(tx1, tx2)
+    ty1, ty2 = min(ty1, ty2), max(ty1, ty2)
+
+    # Combine dimensions
+    t1, t2 = max(tx1, ty1), min(tx2, ty2)
+
+    # Sort again
+    t1, t2 = min(t1, t2), max(t1, t2)
+
+    # print(f"{time.perf_counter() - time_0:f}")
+
+    return t1, t2
+
+
+def binary_search_for_ndc_edge(ndc1, ndc2, ref, dim, *, n_iters=10):
+    """Get the t that results in the smallest distance to ref in dimension dim.
+
+    The ndc1 and ndc2 arguments must be in homogeneous ndc coords, so
+    that they can be interpolated. The resulting t represents the point
+    on the line between ndc1 and ncd2.
+
+    This algorithm operates either over x or y, depending on dim (0 or1).
+    So in order to determine the visible piece of the line, this
+    function must be called 4 times.
+    """
+
+    # This algorithm performs a binary search, so that we can cover a
+    # fine grid without doing an exhaustive search.
+    #
+    # Note that (with a perspective camera) the distance to the
+    # reference does not scale linearly with t. The consequence is that
+    # there is not a closed form solution to this problem. However, for
+    # an orthographic camera there is!
+    #
+    # The binary search is done by sampling three points, then determining
+    # whether the ref value is in 0..1 or 1..2, and then sampling one new
+    # value in between. Repeat.
+    #
+    #  |------|------|
+    #  0      1      2
+
+    # Reduce the ndc positions to two scalars per position
+    x1, x2 = float(ndc1[dim]), float(ndc2[dim])
+    w1, w2 = float(ndc1[3]), float(ndc2[3])
+
+    # Get the starting t's.
+    # Things get iffy when the w is zero or negative, so we first find
+    # the range where w is positive.
+    initial_t1, initial_t2 = 0.0, 1.0
+    eps = 1e-8
+    if w1 < eps or w2 < eps:
+        if w1 < eps and w2 < eps:
+            # Camera is perspective and the end-points are both behind the camera.
+            return 0.0 if w1 >= w2 else 1.0
+        elif w1 < eps:
+            # The first point is behind, move it.
+            initial_t1 = (eps - w1) / (w2 - w1)
+        else:  # w2 < eps:
+            # The second point is behind, move it.
+            initial_t2 = 1.0 - (eps - w2) / (w1 - w2)
+
+    # Function to get a value corresponding to a given t
+    def value_from_t(t):
+        x = x1 * (1 - t) + x2 * t
+        w = w1 * (1 - t) + w2 * t
+        return x / w
+
+    # Produce 3 tuples (t, value) representing the relative t-values as shown in above ascii diagram
+    samples_t = [initial_t1, 0.5 * (initial_t1 + initial_t2), initial_t2]
+    samples_val = [value_from_t(t) for t in samples_t]
+
+    # If the values are very close, the line could be a point, or orthogonal to this dim
+    if abs(samples_val[0] - samples_val[2]) < 1e-9:
+        return 1.0 if samples_val[0] < ref else 0.0
+
+    # Determine function for bisect.
+    values_are_ascending = samples_val[0] <= samples_val[2]
+    bisect = bisect_asc if values_are_ascending else bisect_desc
+
+    # Do the first bisection!
+    i = bisect(samples_val, ref)
+
+    # Go deeper, if we must
+    if w1 == 1.0 and w2 == 1.0:
+        pass  # Ortho camera
+    elif i == 0 or i == 3:
+        pass  # The whole line is on one side of the ref
+    else:
+        # Binary search. With 10 iters we cover over 1K samples.
+        for _ in range(n_iters):
+            if i <= 1:
+                samples_t[2] = samples_t[1]
+                samples_val[2] = samples_val[1]
+            else:
+                samples_t[0] = samples_t[1]
+                samples_val[0] = samples_val[1]
+            new_t = 0.5 * (samples_t[0] + samples_t[2])
+            samples_t[1] = new_t
+            samples_val[1] = value_from_t(new_t)
+            i = bisect(samples_val, ref)
+
+    # Fine tune using a linear fit
+    t_step = samples_t[1] - samples_t[0]
+    if i == 0:
+        t = samples_t[0]
+    elif i == 3:
+        t = samples_t[2]
+    elif i == 1:
+        y1 = samples_val[0]
+        y2 = samples_val[1]
+        dt = (ref - y1) / (y2 - y1)
+        t = samples_t[0] + dt * t_step
+    else:  # i == 2
+        y1 = samples_val[1]
+        y2 = samples_val[2]
+        dt = (ref - y1) / (y2 - y1)
+        t = samples_t[1] + dt * t_step
+
+    return t
+
+
+# We use our own bisect functions. Otherwise, to use bisect.bisect with
+# descending values, we need the key param, which adds complexity, but
+# also does not work pre py 3.10. This solution even seems a wee bit
+# faster than using builtin bisect.
+
+
+def bisect_asc(values, ref):
+    """Simple version of bisect.bisect(). Values are assumed to be in ascending order."""
+    for i, v in enumerate(values):
+        if v > ref:
+            return i
+    return i + 1
+
+
+def bisect_desc(values, ref):
+    """Like bisect(), but the values are assumed in descnding order."""
+    for i, v in enumerate(values):
+        if v <= ref:
+            return i
+    return i + 1


Test patch:
diff --git a/tests/utils/test_compgeo.py b/tests/utils/test_compgeo.py
new file mode 100644
index 0000000..72016f7
--- /dev/null
+++ b/tests/utils/test_compgeo.py
@@ -0,0 +1,104 @@
+from pygfx.utils.compgeo import binary_search_for_ndc_edge, bisect_asc, bisect_desc
+
+
+import bisect
+
+
+def test_bisect():
+
+    values = [0, 1, 2, 3, 3.2, 4, 5]
+
+    for ref in [-9, -1, 0, 1, 2, 3, 4, 5, 6, 9, 2.1, 2.5, 2.9, 3.1, 3.2, 3.3]:
+        i0 = bisect.bisect(values, ref)
+        i1 = bisect_asc(values, ref)
+        i2 = bisect_desc(reversed(values), ref)
+        assert i0 == i1
+        assert i0 == len(values) - i2
+
+
+def test_binary_search_for_ndc_edge_fit():
+
+    for ndc1, ndc2, ref, real_t in [
+        #
+        ((4, 0, 0, 1), (7, 0, 0, 1), 5, 1 / 3),
+        ((4, 0, 0, 1), (7, 0, 0, 1), 5.5, 0.5),
+        ((4, 0, 0, 1), (7, 0, 0, 1), 6, 2 / 3),
+        #
+        ((7, 0, 0, 1), (4, 0, 0, 1), 5, 2 / 3),
+        ((7, 0, 0, 1), (4, 0, 0, 1), 5.5, 0.5),
+        ((7, 0, 0, 1), (4, 0, 0, 1), 6, 1 / 3),
+        #
+        ((-100, 0, 0, 1), (-110, 0, 0, 1), -102, 2 / 10),
+        ((-100, 0, 0, 1), (-110, 0, 0, 1), -106, 6 / 10),
+        ((-100, 0, 0, 1), (-110, 0, 0, 1), -107, 7 / 10),
+        #
+        ((-110, 0, 0, 1), (-100, 0, 0, 1), -102, 8 / 10),
+        ((-110, 0, 0, 1), (-100, 0, 0, 1), -106, 4 / 10),
+        ((-110, 0, 0, 1), (-100, 0, 0, 1), -107, 3 / 10),
+    ]:
+
+        # With only a quadratic fit
+        t = binary_search_for_ndc_edge(ndc1, ndc2, ref, 0, n_iters=0)
+        fault = abs(real_t - t)
+        assert fault < 1e-6
+
+        # Result should get better as iters is increased
+        last_t = t
+        for iters in range(1, 12):
+            t = binary_search_for_ndc_edge(ndc1, ndc2, ref, 0, n_iters=iters)
+            fault = abs(real_t - t)
+            if fault > 1e-15:
+                last_fault = abs(real_t - last_t)
+                assert fault < last_fault
+            last_t = t
+
+        assert fault < 3e-5
+
+
+def test_binary_search_for_ndc_edge_perspective():
+
+    ndc1 = (1, 0, 0, 1)  # 1
+    ndc2 = (2, 0, 0, 1)  # 2
+    t = binary_search_for_ndc_edge(ndc1, ndc2, 1.5, 0, n_iters=10)
+    assert t == 0.5
+
+    ndc1 = (2, 0, 0, 2)  # 1
+    ndc2 = (8, 0, 0, 4)  # 2
+    t = binary_search_for_ndc_edge(ndc1, ndc2, 1.5, 0, n_iters=10)
+    assert abs(t - 1 / 3) < 0.0001
+
+    ndc1 = (3, 0, 0, 3)  # 1
+    ndc2 = (10, 0, 0, 5)  # 2
+    t = binary_search_for_ndc_edge(ndc1, ndc2, 1.5, 0, n_iters=10)
+    assert abs(t - 0.375) < 0.0001
+
+
+def test_binary_search_for_ndc_edge_ortho():
+
+    # This line is on-screen, also in y dimension!
+    ndc1 = (0.1, 0, 0, 1)  # 1
+    ndc2 = (0.8, 0, 0, 1)  # 2
+    t1 = binary_search_for_ndc_edge(ndc1, ndc2, -1, 1, n_iters=10)
+    t2 = binary_search_for_ndc_edge(ndc1, ndc2, +1, 1, n_iters=10)
+    assert t1 == 0.0 and t2 == 1.0
+
+    # This line is above viewport
+    ndc1 = (0.1, -1.2, 0, 1)  # 1
+    ndc2 = (0.8, -1.2, 0, 1)  # 2
+    t1 = binary_search_for_ndc_edge(ndc1, ndc2, -1, 1, n_iters=10)
+    t2 = binary_search_for_ndc_edge(ndc1, ndc2, +1, 1, n_iters=10)
+    assert t1 == 1.0 and t2 == 1.0
+
+    # This line is below viewport
+    ndc1 = (0.1, 1.2, 0, 1)  # 1
+    ndc2 = (0.8, 1.2, 0, 1)  # 2
+    t1 = binary_search_for_ndc_edge(ndc1, ndc2, -1, 1, n_iters=10)
+    t2 = binary_search_for_ndc_edge(ndc1, ndc2, +1, 1, n_iters=10)
+    assert t1 == 0.0 and t2 == 0.0
+
+
+if __name__ == "__main__":
+    test_bisect()
+    test_binary_search_for_ndc_edge_fit()
+    test_binary_search_for_ndc_edge_ortho()
+    test_binary_search_for_ndc_edge_perspective()