Source code for reasitic.gui.viewport

"""Pan / zoom viewport math for the layout view.

Mirrors the world↔screen transformation used by the original ASITIC X11
front-end (see ``decomp/output/asitic_repl.c`` lines ~6920 / 21800):

.. code-block:: c

    /* world coords (μm) → screen coords (px) */
    sx =  (wx + g_pan_x) * g_zoom_scale + g_x11_canvas_width  / 2
    sy = -(wy + g_pan_y) * g_zoom_scale + g_x11_canvas_height / 2

The screen Y axis points down, the world Y axis points up, hence the sign
flip on the Y term. The transformation is implemented here with no
Tkinter dependency so that it can be unit-tested headlessly.
"""

from __future__ import annotations

from dataclasses import dataclass


[docs] @dataclass class Viewport: """A 2-D pan/zoom transform from layout (μm) to canvas (px) coordinates. Mirrors the binary's view-state globals: ``zoom`` is ``g_zoom_scale`` and ``pan_x`` / ``pan_y`` are ``g_pan_x`` / ``g_pan_y``. ``canvas_width`` / ``canvas_height`` track the live canvas widget size in pixels (the binary uses ``g_x11_canvas_width`` / ``g_x11_canvas_height``). """ canvas_width: int = 800 canvas_height: int = 600 zoom: float = 1.0 pan_x: float = 0.0 pan_y: float = 0.0 # ----- Forward transform ---------------------------------------------
[docs] def world_to_screen(self, wx: float, wy: float) -> tuple[float, float]: """Map a world-space (μm) point to canvas pixel coordinates.""" sx = (wx + self.pan_x) * self.zoom + self.canvas_width / 2.0 sy = -(wy + self.pan_y) * self.zoom + self.canvas_height / 2.0 return sx, sy
[docs] def screen_to_world(self, sx: float, sy: float) -> tuple[float, float]: """Inverse of :meth:`world_to_screen`.""" wx = (sx - self.canvas_width / 2.0) / self.zoom - self.pan_x wy = -(sy - self.canvas_height / 2.0) / self.zoom - self.pan_y return wx, wy
# ----- Mutators ------------------------------------------------------
[docs] def pan_by_pixels(self, dx_px: float, dy_px: float) -> None: """Shift the view by ``(dx, dy)`` pixels (pixel-space).""" self.pan_x += dx_px / self.zoom self.pan_y -= dy_px / self.zoom
[docs] def zoom_at_screen(self, sx: float, sy: float, factor: float) -> None: """Multiply ``zoom`` by ``factor``, keeping the world point under ``(sx, sy)`` fixed on the canvas (matches the binary's ``cmd_scale_clamp_view`` zoom behaviour).""" if factor <= 0.0: raise ValueError("zoom factor must be positive") wx, wy = self.screen_to_world(sx, sy) self.zoom *= factor # After scaling, push pan so that (wx, wy) maps back to (sx, sy). new_sx, new_sy = self.world_to_screen(wx, wy) self.pan_x += (sx - new_sx) / self.zoom self.pan_y -= (sy - new_sy) / self.zoom
[docs] def fit_bbox(self, x_min: float, y_min: float, x_max: float, y_max: float, *, margin: float = 0.05) -> None: """Set ``zoom`` and ``pan`` so the bbox fits the canvas with margin.""" if x_max <= x_min or y_max <= y_min: return bw = (x_max - x_min) * (1.0 + 2 * margin) bh = (y_max - y_min) * (1.0 + 2 * margin) sx = self.canvas_width / bw sy = self.canvas_height / bh self.zoom = min(sx, sy) cx = 0.5 * (x_min + x_max) cy = 0.5 * (y_min + y_max) self.pan_x = -cx self.pan_y = -cy
[docs] def reset(self) -> None: """Reset to identity (zoom=1, pan=0).""" self.zoom = 1.0 self.pan_x = 0.0 self.pan_y = 0.0
[docs] def world_bbox(self) -> tuple[float, float, float, float]: """Return the current world-space (x_min, y_min, x_max, y_max).""" x0, y0 = self.screen_to_world(0, self.canvas_height) x1, y1 = self.screen_to_world(self.canvas_width, 0) return x0, y0, x1, y1