Source code for analytic_continuation.space_adapter
"""
Space adapter for transforming between screen and logical (complex plane) coordinates.
The adapter handles:
- Offset (translation)
- Scale (uniform or non-uniform)
- Y-axis flip (screen Y increases downward, logical Y increases upward)
"""
from dataclasses import dataclass
from typing import List, Optional, Tuple, Union
import math
from .types import Point, Spline, SplineExport
[docs]
@dataclass
class TransformParams:
"""
Parameters defining the screen-to-logical coordinate transformation.
Screen space: origin at top-left, Y increases downward, pixel units
Logical space: complex plane, Y increases upward, mathematical units
The transform is:
logical_x = (screen_x - offset_x) / scale_x
logical_y = (offset_y - screen_y) / scale_y (note Y flip)
Or equivalently:
screen_x = logical_x * scale_x + offset_x
screen_y = offset_y - logical_y * scale_y
"""
# Screen coordinates of the logical origin (0, 0)
offset_x: float = 0.0
offset_y: float = 0.0
# Pixels per logical unit (scale factors)
scale_x: float = 1.0
scale_y: Optional[float] = None # If None, use scale_x (uniform scaling)
@property
def scale_y_effective(self) -> float:
"""Get the effective Y scale (defaults to scale_x if not set)."""
return self.scale_y if self.scale_y is not None else self.scale_x
@property
def is_uniform(self) -> bool:
"""Check if scaling is uniform in both axes."""
return self.scale_y is None or self.scale_x == self.scale_y
[docs]
def to_dict(self) -> dict:
d = {
"offset_x": self.offset_x,
"offset_y": self.offset_y,
"scale_x": self.scale_x,
}
if self.scale_y is not None:
d["scale_y"] = self.scale_y
return d
[docs]
@classmethod
def from_dict(cls, d: dict) -> "TransformParams":
return cls(
offset_x=d.get("offset_x", 0.0),
offset_y=d.get("offset_y", 0.0),
scale_x=d.get("scale_x", 1.0),
scale_y=d.get("scale_y"),
)
[docs]
@classmethod
def from_view_bounds(
cls,
screen_width: float,
screen_height: float,
logical_x_range: Tuple[float, float],
logical_y_range: Tuple[float, float],
uniform: bool = True,
) -> "TransformParams":
"""
Create transform params from screen dimensions and logical view bounds.
Parameters
----------
screen_width, screen_height : float
Screen dimensions in pixels
logical_x_range : tuple
(x_min, x_max) in logical coordinates
logical_y_range : tuple
(y_min, y_max) in logical coordinates
uniform : bool
If True, use the same scale for both axes (may add margins)
Returns
-------
TransformParams
"""
x_min, x_max = logical_x_range
y_min, y_max = logical_y_range
logical_width = x_max - x_min
logical_height = y_max - y_min
scale_x = screen_width / logical_width
scale_y = screen_height / logical_height
if uniform:
# Use the smaller scale to fit everything, center the view
scale = min(scale_x, scale_y)
scale_x = scale_y = scale
# Compute offset to center the view
actual_logical_width = screen_width / scale
actual_logical_height = screen_height / scale
x_margin = (actual_logical_width - logical_width) / 2
y_margin = (actual_logical_height - logical_height) / 2
offset_x = -((x_min - x_margin) * scale)
offset_y = (y_max + y_margin) * scale
else:
offset_x = -(x_min * scale_x)
offset_y = y_max * scale_y
return cls(
offset_x=offset_x,
offset_y=offset_y,
scale_x=scale_x,
scale_y=None if uniform else scale_y,
)
[docs]
class SpaceAdapter:
"""
Transforms coordinates between screen space and logical (complex plane) space.
Screen space:
- Origin at top-left
- X increases rightward
- Y increases downward
- Units are pixels
Logical space:
- Complex plane
- X is the real axis
- Y is the imaginary axis (increases upward)
- Units are mathematical units
"""
[docs]
def __init__(self, params: Optional[TransformParams] = None):
"""
Initialize the space adapter.
Parameters
----------
params : TransformParams, optional
Transform parameters. If None, uses identity transform.
"""
self.params = params or TransformParams()
@property
def offset(self) -> Tuple[float, float]:
"""Get the offset as (x, y) tuple."""
return (self.params.offset_x, self.params.offset_y)
@property
def scale(self) -> Tuple[float, float]:
"""Get the scale as (x, y) tuple."""
return (self.params.scale_x, self.params.scale_y_effective)
[docs]
def screen_to_logical(self, screen_x: float, screen_y: float) -> Tuple[float, float]:
"""
Transform a point from screen space to logical space.
Parameters
----------
screen_x, screen_y : float
Screen coordinates
Returns
-------
tuple
(logical_x, logical_y)
"""
p = self.params
logical_x = (screen_x - p.offset_x) / p.scale_x
logical_y = (p.offset_y - screen_y) / p.scale_y_effective
return (logical_x, logical_y)
[docs]
def logical_to_screen(self, logical_x: float, logical_y: float) -> Tuple[float, float]:
"""
Transform a point from logical space to screen space.
Parameters
----------
logical_x, logical_y : float
Logical coordinates
Returns
-------
tuple
(screen_x, screen_y)
"""
p = self.params
screen_x = logical_x * p.scale_x + p.offset_x
screen_y = p.offset_y - logical_y * p.scale_y_effective
return (screen_x, screen_y)
[docs]
def screen_to_complex(self, screen_x: float, screen_y: float) -> complex:
"""Transform screen coordinates to a complex number."""
lx, ly = self.screen_to_logical(screen_x, screen_y)
return complex(lx, ly)
[docs]
def complex_to_screen(self, z: complex) -> Tuple[float, float]:
"""Transform a complex number to screen coordinates."""
return self.logical_to_screen(z.real, z.imag)
[docs]
def transform_point_to_logical(self, point: Point) -> Point:
"""Transform a Point from screen to logical space."""
lx, ly = self.screen_to_logical(point.x, point.y)
return Point(x=lx, y=ly, index=point.index)
[docs]
def transform_point_to_screen(self, point: Point) -> Point:
"""Transform a Point from logical to screen space."""
sx, sy = self.logical_to_screen(point.x, point.y)
return Point(x=sx, y=sy, index=point.index)
[docs]
def transform_points_to_logical(self, points: List[Point]) -> List[Point]:
"""Transform a list of points from screen to logical space."""
return [self.transform_point_to_logical(p) for p in points]
[docs]
def transform_points_to_screen(self, points: List[Point]) -> List[Point]:
"""Transform a list of points from logical to screen space."""
return [self.transform_point_to_screen(p) for p in points]
[docs]
def transform_spline_to_logical(self, spline: Spline) -> Spline:
"""Transform a Spline from screen to logical space."""
return Spline(
points=self.transform_points_to_logical(spline.points),
closed=spline.closed,
)
[docs]
def transform_spline_to_screen(self, spline: Spline) -> Spline:
"""Transform a Spline from logical to screen space."""
return Spline(
points=self.transform_points_to_screen(spline.points),
closed=spline.closed,
)
[docs]
def transform_spline_export_to_logical(self, export: SplineExport) -> SplineExport:
"""
Transform a full SplineExport from screen to logical space.
Transforms all point arrays (controlPoints, spline, adaptivePolyline).
Also scales the parameters.minDistance accordingly.
"""
# Scale minDistance by the average scale factor
avg_scale = (self.params.scale_x + self.params.scale_y_effective) / 2
new_min_distance = export.parameters.minDistance / avg_scale
from .types import SplineParameters
new_params = SplineParameters(
tension=export.parameters.tension,
adaptiveTolerance=export.parameters.adaptiveTolerance,
minDistance=new_min_distance,
)
return SplineExport(
version=export.version,
timestamp=export.timestamp,
closed=export.closed,
parameters=new_params,
controlPoints=self.transform_points_to_logical(export.controlPoints),
spline=self.transform_points_to_logical(export.spline) if export.spline else [],
adaptivePolyline=self.transform_points_to_logical(export.adaptivePolyline) if export.adaptivePolyline else [],
stats=export.stats,
)
[docs]
def screen_distance_to_logical(self, screen_distance: float) -> float:
"""
Convert a distance from screen units to logical units.
For non-uniform scaling, uses the geometric mean of scale factors.
"""
if self.params.is_uniform:
return screen_distance / self.params.scale_x
else:
avg_scale = math.sqrt(self.params.scale_x * self.params.scale_y_effective)
return screen_distance / avg_scale
[docs]
def logical_distance_to_screen(self, logical_distance: float) -> float:
"""
Convert a distance from logical units to screen units.
For non-uniform scaling, uses the geometric mean of scale factors.
"""
if self.params.is_uniform:
return logical_distance * self.params.scale_x
else:
avg_scale = math.sqrt(self.params.scale_x * self.params.scale_y_effective)
return logical_distance * avg_scale
[docs]
def with_params(self, **kwargs) -> "SpaceAdapter":
"""
Create a new SpaceAdapter with modified parameters.
Parameters
----------
**kwargs
Parameters to override (offset_x, offset_y, scale_x, scale_y)
Returns
-------
SpaceAdapter
New adapter with modified parameters
"""
new_params = TransformParams(
offset_x=kwargs.get("offset_x", self.params.offset_x),
offset_y=kwargs.get("offset_y", self.params.offset_y),
scale_x=kwargs.get("scale_x", self.params.scale_x),
scale_y=kwargs.get("scale_y", self.params.scale_y),
)
return SpaceAdapter(new_params)
[docs]
def zoom(self, factor: float, center_screen: Optional[Tuple[float, float]] = None) -> "SpaceAdapter":
"""
Create a new adapter with zoomed view.
Parameters
----------
factor : float
Zoom factor (>1 zooms in, <1 zooms out)
center_screen : tuple, optional
Screen coordinates of zoom center. If None, zooms around logical origin.
Returns
-------
SpaceAdapter
"""
new_scale_x = self.params.scale_x * factor
new_scale_y = self.params.scale_y_effective * factor if self.params.scale_y is not None else None
if center_screen is not None:
# Keep the center point fixed
cx, cy = center_screen
# Logical position of center
lx, ly = self.screen_to_logical(cx, cy)
# New offset to keep center fixed
new_offset_x = cx - lx * new_scale_x
new_offset_y = cy + ly * (new_scale_y or new_scale_x)
else:
new_offset_x = self.params.offset_x * factor
new_offset_y = self.params.offset_y * factor
return SpaceAdapter(TransformParams(
offset_x=new_offset_x,
offset_y=new_offset_y,
scale_x=new_scale_x,
scale_y=new_scale_y,
))
[docs]
def pan(self, delta_screen_x: float, delta_screen_y: float) -> "SpaceAdapter":
"""
Create a new adapter with panned view.
Parameters
----------
delta_screen_x, delta_screen_y : float
Pan amounts in screen pixels
Returns
-------
SpaceAdapter
"""
return SpaceAdapter(TransformParams(
offset_x=self.params.offset_x + delta_screen_x,
offset_y=self.params.offset_y + delta_screen_y,
scale_x=self.params.scale_x,
scale_y=self.params.scale_y,
))
[docs]
@classmethod
def from_dict(cls, d: dict) -> "SpaceAdapter":
"""Deserialize from dictionary."""
return cls(TransformParams.from_dict(d))