181 lines
6.7 KiB
Python
181 lines
6.7 KiB
Python
"""Plot interaction port. All numerical operations use templates' set_plot.c.
|
|
|
|
This module has no Qt dependency. The application supplies its SETProtocol CDLL.
|
|
Units, clocks, acquisition, colours and rendering belong to the application.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ctypes
|
|
from dataclasses import dataclass, replace
|
|
from enum import IntEnum
|
|
from typing import Optional
|
|
|
|
|
|
class Axis(IntEnum):
|
|
X = 1
|
|
Y = 2
|
|
|
|
|
|
class Marker(IntEnum):
|
|
A = 0
|
|
B = 1
|
|
C = 2
|
|
D = 3
|
|
E = 4
|
|
F = 5
|
|
G = 6
|
|
H = 7
|
|
|
|
@property
|
|
def horizontal(self) -> bool:
|
|
return self in (Marker.E, Marker.F, Marker.G, Marker.H)
|
|
|
|
|
|
class PlotMath:
|
|
"""Typed operations over the versioned allocation-free C ABI."""
|
|
def __init__(self, library: ctypes.CDLL) -> None:
|
|
self.library = library
|
|
library.set_plot_abi_version.restype = ctypes.c_uint32
|
|
library.set_plot_abi_version.argtypes = []
|
|
if library.set_plot_abi_version() != 1:
|
|
raise RuntimeError("Unsupported plot ABI")
|
|
library.set_plot_eval.argtypes = [ctypes.c_uint32, ctypes.POINTER(ctypes.c_double),
|
|
ctypes.c_size_t, ctypes.POINTER(ctypes.c_double), ctypes.c_size_t]
|
|
library.set_plot_eval.restype = ctypes.c_size_t
|
|
|
|
def call(self, operation: int, *values: float) -> tuple:
|
|
inputs = (ctypes.c_double * len(values))(*values)
|
|
output = (ctypes.c_double * 4)()
|
|
count = self.library.set_plot_eval(operation, inputs, len(values), output, 4)
|
|
if not count:
|
|
raise ValueError("Invalid plot operation %s" % operation)
|
|
return tuple(output[:count])
|
|
|
|
def pinch_axis(self, dx: float, dy: float, slop: float) -> Optional[Axis]:
|
|
value = int(self.call(1, dx, dy, slop)[0])
|
|
return Axis(value) if value else None
|
|
|
|
def tick_step(self, span: float, pixels: float) -> float:
|
|
return self.call(5, span, pixels)[0]
|
|
|
|
def delta(self, a: float, b: float, multiplier: float = 1) -> float:
|
|
return self.call(6, a, b, multiplier)[0]
|
|
|
|
def db_delta(self, a: float, b: float) -> Optional[float]:
|
|
try:
|
|
return self.call(7, a, b)[0]
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Viewport:
|
|
x: float = 0.0
|
|
y: float = 0.0
|
|
width: float = 1.0
|
|
height: float = 1.0
|
|
locked: bool = False
|
|
|
|
def transform(self, core: PlotMath, zoom_x: float = 1, zoom_y: float = 1,
|
|
pan_x: float = 0, pan_y: float = 0,
|
|
focus_x: float = 0.5, focus_y: float = 0.5) -> "Viewport":
|
|
if self.locked:
|
|
return self
|
|
return Viewport(*core.call(0, self.x, self.y, self.width, self.height,
|
|
zoom_x, zoom_y, pan_x, pan_y, focus_x, focus_y), locked=self.locked)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Bounds:
|
|
left: float
|
|
right: float
|
|
bottom: float
|
|
top: float
|
|
|
|
def fraction(self, core: PlotMath, value: float, horizontal: bool) -> float:
|
|
return core.call(2, value, self.bottom if horizontal else self.left,
|
|
self.top if horizontal else self.right, int(horizontal))[0]
|
|
|
|
def value(self, core: PlotMath, fraction: float, horizontal: bool) -> float:
|
|
return core.call(3, fraction, self.bottom if horizontal else self.left,
|
|
self.top if horizontal else self.right, int(horizontal))[0]
|
|
|
|
def visible(self, core: PlotMath, viewport: Viewport) -> "Bounds":
|
|
return Bounds(self.value(core, viewport.x, False),
|
|
self.value(core, viewport.x + viewport.width, False),
|
|
self.value(core, viewport.y + viewport.height, True),
|
|
self.value(core, viewport.y, True))
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Markers:
|
|
x_enabled: bool = True
|
|
y_enabled: bool = False
|
|
x_pairs: int = 1
|
|
y_pairs: int = 1
|
|
selected: Marker = Marker.A
|
|
a: Optional[float] = None
|
|
b: Optional[float] = None
|
|
c: Optional[float] = None
|
|
d: Optional[float] = None
|
|
e: Optional[float] = None
|
|
f: Optional[float] = None
|
|
g: Optional[float] = None
|
|
h: Optional[float] = None
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.x_pairs not in (1, 2) or self.y_pairs not in (1, 2):
|
|
raise ValueError("Marker pair count must be 1 or 2")
|
|
|
|
def value(self, marker: Marker) -> Optional[float]:
|
|
return getattr(self, marker.name.lower())
|
|
|
|
def enabled(self, marker: Marker) -> bool:
|
|
if marker in (Marker.A, Marker.B):
|
|
return self.x_enabled
|
|
if marker in (Marker.C, Marker.D):
|
|
return self.x_enabled and self.x_pairs >= 2
|
|
if marker in (Marker.E, Marker.F):
|
|
return self.y_enabled
|
|
return self.y_enabled and self.y_pairs >= 2
|
|
|
|
def move(self, marker: Marker, value: float) -> "Markers":
|
|
return replace(self, **{marker.name.lower(): value})
|
|
|
|
def positioned(self, core: PlotMath, bounds: Bounds) -> "Markers":
|
|
result = self
|
|
for marker, fraction in ((Marker.A, .2), (Marker.B, .4), (Marker.C, .6), (Marker.D, .8),
|
|
(Marker.E, .2), (Marker.F, .4), (Marker.G, .6), (Marker.H, .8)):
|
|
if result.value(marker) is None:
|
|
result = result.move(marker, bounds.value(core, fraction, marker.horizontal))
|
|
return result
|
|
|
|
def reset(self, core: PlotMath, bounds: Bounds) -> "Markers":
|
|
return replace(self, a=None, b=None, c=None, d=None, e=None, f=None, g=None, h=None).positioned(core, bounds)
|
|
|
|
def fraction(self, core: PlotMath, marker: Marker, bounds: Bounds) -> float:
|
|
value = self.value(marker)
|
|
if value is None:
|
|
raise ValueError("Marker has no position")
|
|
return bounds.fraction(core, value, marker.horizontal)
|
|
|
|
def drag(self, core: PlotMath, marker: Marker, delta: float, length: float, bounds: Bounds) -> "Markers":
|
|
value = core.call(4, self.value(marker), delta, length,
|
|
bounds.bottom if marker.horizontal else bounds.left,
|
|
bounds.top if marker.horizontal else bounds.right, int(marker.horizontal))[0]
|
|
return replace(self.move(marker, value), selected=marker)
|
|
|
|
def hit(self, core: PlotMath, x: float, y: float, width: float, height: float,
|
|
radius: float, bounds: Bounds) -> Optional[Marker]:
|
|
if not (0 <= x <= width and 0 <= y <= height):
|
|
return None
|
|
candidates = []
|
|
for marker in Marker:
|
|
if not self.enabled(marker) or self.value(marker) is None:
|
|
continue
|
|
fraction = self.fraction(core, marker, bounds)
|
|
distance = abs(y - fraction * height if marker.horizontal else x - fraction * width)
|
|
if 0 <= fraction <= 1 and distance <= radius:
|
|
candidates.append((distance, marker != self.selected, marker))
|
|
return min(candidates)[2] if candidates else None
|