"""
Clipping zones for WolfArray OpenGL rendering.
Provides :class:`WolfArrayClipZone` (extends :class:`header_wolf`) to
restrict rendering to predefined or dynamically-defined world-coordinate
regions using the OpenGL scissor test.
A typical use case is a "curtain" comparison: two overlapping arrays are
stacked, and a draggable :class:`ClipSlider` lets the user reveal one
array vs. the other by sliding a horizontal or vertical bar.
Author: HECE - University of Liege, Pierre Archambeau
Date: 2024
Copyright (c) 2024 University of Liege. All rights reserved.
"""
import json
import logging
from enum import Enum
from typing import Optional
try:
from OpenGL.GL import (
glEnable, glDisable, glScissor,
glBegin, glEnd, glVertex2f,
glColor4f, glLineWidth,
GL_SCISSOR_TEST, GL_LINE_LOOP, GL_LINES,
)
except ImportError:
pass
from ._header_wolf import header_wolf
[docs]
DEFAULT_COLOR = (1., 0., 1., 0.8) # Magenta with some transparency
[docs]
class ClipSliderEdge(Enum):
"""Which edge of the clip zone the slider controls."""
[docs]
class WolfArrayClipZone(header_wolf):
"""Rectangular clipping zone in world coordinates.
Extends :class:`header_wolf` so that existing spatial tools
(:meth:`find_intersection`, :meth:`get_bounds`, …) work seamlessly.
The zone is defined by world-coordinate bounds
``[xmin, xmax] × [ymin, ymax]``. When *active*, the zone is
applied as an OpenGL scissor rectangle during rendering:
only the pixels that fall inside the zone are drawn.
Usage::
clip = WolfArrayClipZone(xmin=160000, ymin=130000,
xmax=170000, ymax=140000)
my_array.add_clip_zone(clip)
Or from an existing header (another WolfArray for instance)::
clip = WolfArrayClipZone.from_header(other_array)
"""
def __init__(self,
xmin: float = 0., ymin: float = 0.,
xmax: float = 1., ymax: float = 1.,
active: bool = True,
owner_name: str = '',
invert: bool = False):
"""
:param xmin: Left edge in world coordinates.
:param ymin: Bottom edge in world coordinates.
:param xmax: Right edge in world coordinates.
:param ymax: Top edge in world coordinates.
:param active: Whether the clip zone is applied during rendering.
:param owner_name: Identifier of the owning WolfArray (for display).
:param invert: If True, the *exterior* of the zone is drawn
instead of the interior.
"""
super().__init__()
[docs]
self.owner_name = owner_name
[docs]
self._sliders: list["ClipSlider"] = []
self.set_bounds_clip(xmin, ymin, xmax, ymax)
# ----------------------------------------------------------------
# Bounds helpers
# ----------------------------------------------------------------
[docs]
def set_bounds_clip(self, xmin: float, ymin: float,
xmax: float, ymax: float):
"""Set the clipping zone from world-coordinate bounds."""
self.origx = xmin
self.origy = ymin
self.translx = 0.
self.transly = 0.
self.dx = xmax - xmin
self.dy = ymax - ymin
self.nbx = 1
self.nby = 1
# Convenience properties ------------------------------------------
@property
[docs]
def clip_xmin(self) -> float:
return self.origx + self.translx
@clip_xmin.setter
def clip_xmin(self, value: float):
cur_xmax = self.clip_xmax
self.origx = value
self.dx = cur_xmax - value
self.nbx = 1
@property
[docs]
def clip_xmax(self) -> float:
return self.origx + self.translx + self.nbx * self.dx
@clip_xmax.setter
def clip_xmax(self, value: float):
self.dx = value - self.clip_xmin
self.nbx = 1
@property
[docs]
def clip_ymin(self) -> float:
return self.origy + self.transly
@clip_ymin.setter
def clip_ymin(self, value: float):
cur_ymax = self.clip_ymax
self.origy = value
self.dy = cur_ymax - value
self.nby = 1
@property
[docs]
def clip_ymax(self) -> float:
return self.origy + self.transly + self.nby * self.dy
@clip_ymax.setter
def clip_ymax(self, value: float):
self.dy = value - self.clip_ymin
self.nby = 1
# ----------------------------------------------------------------
# World → screen conversion
# ----------------------------------------------------------------
[docs]
def world_to_scissor(self,
view_xmin: float, view_ymin: float,
view_xmax: float, view_ymax: float,
vp_width: int, vp_height: int,
) -> tuple[int, int, int, int]:
"""Convert world-coordinate bounds to a ``glScissor`` rectangle.
The returned ``(sx, sy, sw, sh)`` tuple is in window-pixel
coordinates, clamped to the current viewport.
:param view_xmin: Current viewport left (world coords).
:param view_ymin: Current viewport bottom (world coords).
:param view_xmax: Current viewport right (world coords).
:param view_ymax: Current viewport top (world coords).
:param vp_width: Viewport width in pixels.
:param vp_height: Viewport height in pixels.
"""
inv_w = 1.0 / (view_xmax - view_xmin)
inv_h = 1.0 / (view_ymax - view_ymin)
fx0 = (self.clip_xmin - view_xmin) * inv_w
fy0 = (self.clip_ymin - view_ymin) * inv_h
fx1 = (self.clip_xmax - view_xmin) * inv_w
fy1 = (self.clip_ymax - view_ymin) * inv_h
# Clamp to [0, 1]
fx0 = max(0., min(1., fx0))
fy0 = max(0., min(1., fy0))
fx1 = max(0., min(1., fx1))
fy1 = max(0., min(1., fy1))
sx = int(fx0 * vp_width)
sy = int(fy0 * vp_height)
sw = max(0, int(fx1 * vp_width) - sx)
sh = max(0, int(fy1 * vp_height) - sy)
return sx, sy, sw, sh
[docs]
def enable_scissor(self,
view_xmin: float, view_ymin: float,
view_xmax: float, view_ymax: float,
vp_width: int, vp_height: int):
"""Enable ``GL_SCISSOR_TEST`` restricted to this clip zone."""
sx, sy, sw, sh = self.world_to_scissor(
view_xmin, view_ymin, view_xmax, view_ymax,
vp_width, vp_height)
glEnable(GL_SCISSOR_TEST)
glScissor(sx, sy, sw, sh)
@staticmethod
[docs]
def disable_scissor():
"""Disable ``GL_SCISSOR_TEST``."""
glDisable(GL_SCISSOR_TEST)
# ----------------------------------------------------------------
# Visual feedback
# ----------------------------------------------------------------
[docs]
def draw_border(self, color: tuple = DEFAULT_COLOR,
width: float = 2.):
"""Draw the clip-zone border as a world-coordinate rectangle."""
glColor4f(*color)
glLineWidth(width)
glBegin(GL_LINE_LOOP)
glVertex2f(self.clip_xmin, self.clip_ymin)
glVertex2f(self.clip_xmax, self.clip_ymin)
glVertex2f(self.clip_xmax, self.clip_ymax)
glVertex2f(self.clip_xmin, self.clip_ymax)
glEnd()
glLineWidth(1.)
# ----------------------------------------------------------------
# Slider attachment (supports multiple sliders)
# ----------------------------------------------------------------
@property
[docs]
def sliders(self) -> list["ClipSlider"]:
"""All :class:`ClipSlider` objects attached to this zone."""
return self._sliders
# Keep legacy single-slider property for backward compat
@property
[docs]
def slider(self) -> Optional["ClipSlider"]:
"""First attached slider, or ``None``."""
return self._sliders[0] if self._sliders else None
[docs]
def attach_slider(self, edge: "ClipSliderEdge" = ClipSliderEdge.RIGHT,
**kwargs) -> "ClipSlider":
"""Create and attach a :class:`ClipSlider` controlling *edge*.
Extra *kwargs* are forwarded to the :class:`ClipSlider` constructor.
:return: The newly created slider.
"""
s = ClipSlider(self, edge=edge, owner_name=self.owner_name, **kwargs)
self._sliders.append(s)
return s
# ----------------------------------------------------------------
# Serialization
# ----------------------------------------------------------------
[docs]
def serialize(self) -> dict:
"""Return a JSON-compatible dict describing this clip zone."""
return {
'xmin': self.clip_xmin,
'ymin': self.clip_ymin,
'xmax': self.clip_xmax,
'ymax': self.clip_ymax,
'active': self.active,
'owner_name': self.owner_name,
'invert': self.invert,
'sliders': [s.serialize() for s in self._sliders],
}
@classmethod
[docs]
def deserialize(cls, data: dict) -> "WolfArrayClipZone":
"""Reconstruct a clip zone from a serialized dict."""
cz = cls(xmin=data['xmin'], ymin=data['ymin'],
xmax=data['xmax'], ymax=data['ymax'],
active=data.get('active', True),
owner_name=data.get('owner_name', ''),
invert=data.get('invert', False))
for sd in data.get('sliders', []):
edge = ClipSliderEdge(sd['edge'])
cz.attach_slider(edge=edge,
color=tuple(sd.get('color', DEFAULT_COLOR)),
line_width=sd.get('line_width', 3.))
return cz
# ----------------------------------------------------------------
# Factory methods
# ----------------------------------------------------------------
@classmethod
@classmethod
[docs]
def from_extent(cls, xmin: float, ymin: float,
xmax: float, ymax: float,
active: bool = True,
owner_name: str = '') -> "WolfArrayClipZone":
"""Alias for the regular constructor (explicit factory)."""
return cls(xmin=xmin, ymin=ymin, xmax=xmax, ymax=ymax,
active=active, owner_name=owner_name)
# ====================================================================
# Interactive slider
# ====================================================================
[docs]
class ClipSlider:
"""Draggable horizontal or vertical bar controlling a clip-zone edge.
Provides the visual bar, hit testing, and drag logic. Mouse-event
dispatch must be wired from the host viewer (e.g.
:class:`WolfMapViewer`).
"""
def __init__(self,
clip_zone: WolfArrayClipZone,
edge: ClipSliderEdge = ClipSliderEdge.RIGHT,
color: tuple = DEFAULT_COLOR,
line_width: float = 3.,
grab_tolerance_px: int = 8,
owner_name: str = ''):
[docs]
self.clip_zone = clip_zone
[docs]
self.line_width = line_width
[docs]
self.grab_tolerance_px = grab_tolerance_px
[docs]
self.owner_name = owner_name
# ----------------------------------------------------------------
# Position
# ----------------------------------------------------------------
@property
[docs]
def position(self) -> float:
"""Current world-coordinate position of the controlled edge."""
if self.edge == ClipSliderEdge.LEFT:
return self.clip_zone.clip_xmin
elif self.edge == ClipSliderEdge.RIGHT:
return self.clip_zone.clip_xmax
elif self.edge == ClipSliderEdge.BOTTOM:
return self.clip_zone.clip_ymin
elif self.edge == ClipSliderEdge.TOP:
return self.clip_zone.clip_ymax
@position.setter
def position(self, value: float):
if self.edge == ClipSliderEdge.LEFT:
self.clip_zone.clip_xmin = value
elif self.edge == ClipSliderEdge.RIGHT:
self.clip_zone.clip_xmax = value
elif self.edge == ClipSliderEdge.BOTTOM:
self.clip_zone.clip_ymin = value
elif self.edge == ClipSliderEdge.TOP:
self.clip_zone.clip_ymax = value
@property
[docs]
def is_horizontal(self) -> bool:
"""``True`` if the bar is horizontal (top / bottom edge)."""
return self.edge in (ClipSliderEdge.TOP, ClipSliderEdge.BOTTOM)
@property
[docs]
def label(self) -> str:
"""Human-readable label for status-bar display."""
edge_name = self.edge.value.capitalize()
if self.owner_name:
return f"{self.owner_name} [{edge_name}]"
return f"Clip [{edge_name}]"
# ----------------------------------------------------------------
# Drawing
# ----------------------------------------------------------------
[docs]
def draw(self, view_xmin: float, view_ymin: float,
view_xmax: float, view_ymax: float):
"""Draw the slider bar across the full viewport extent.
Must be called **outside** any active scissor test so the bar
is always fully visible.
"""
pos = self.position
glColor4f(*self.color)
glLineWidth(self.line_width)
glBegin(GL_LINES)
if self.is_horizontal:
glVertex2f(view_xmin, pos)
glVertex2f(view_xmax, pos)
else:
glVertex2f(pos, view_ymin)
glVertex2f(pos, view_ymax)
glEnd()
glLineWidth(1.)
# ----------------------------------------------------------------
# Interaction
# ----------------------------------------------------------------
[docs]
def hit_test(self, world_x: float, world_y: float,
sx: float, sy: float) -> bool:
"""Check whether a world-coordinate point is close enough to grab.
:param world_x: Pointer X in world coordinates.
:param world_y: Pointer Y in world coordinates.
:param sx: Pixels per world-unit (horizontal scale).
:param sy: Pixels per world-unit (vertical scale).
:return: ``True`` if within :attr:`grab_tolerance_px` pixels.
"""
pos = self.position
if self.is_horizontal:
dist_px = abs(world_y - pos) * sy
else:
dist_px = abs(world_x - pos) * sx
return dist_px <= self.grab_tolerance_px
[docs]
def start_drag(self):
"""Begin a drag operation."""
self._dragging = True
[docs]
def update_drag(self, world_x: float, world_y: float):
"""Update the controlled edge position during a drag."""
if not self._dragging:
return
if self.is_horizontal:
self.position = world_y
else:
self.position = world_x
[docs]
def end_drag(self):
"""Finish a drag operation."""
self._dragging = False
@property
[docs]
def is_dragging(self) -> bool:
"""``True`` while a drag is in progress."""
return self._dragging
[docs]
def serialize(self) -> dict:
"""Return a JSON-compatible dict."""
return {
'edge': self.edge.value,
'color': list(self.color),
'line_width': self.line_width,
'position': self.position,
}
# ====================================================================
# Clip configuration save / load
# ====================================================================
[docs]
class ClipConfig:
"""Named snapshot of clip zones for one array."""
def __init__(self, name: str, owner_name: str,
zones: list[WolfArrayClipZone]):
[docs]
self.owner_name = owner_name
[docs]
self.zones = list(zones)
[docs]
def serialize(self) -> dict:
return {
'name': self.name,
'owner_name': self.owner_name,
'zones': [z.serialize() for z in self.zones],
}
@classmethod
[docs]
def deserialize(cls, data: dict) -> "ClipConfig":
zones = [WolfArrayClipZone.deserialize(zd)
for zd in data.get('zones', [])]
return cls(name=data['name'],
owner_name=data.get('owner_name', ''),
zones=zones)
[docs]
class ClipConfigs:
"""Collection of named :class:`ClipConfig` snapshots.
Follows the same pattern as :class:`Memory_Views`.
"""
def __init__(self):
[docs]
self.configs: dict[str, ClipConfig] = {}
def __len__(self):
return len(self.configs)
def __getitem__(self, name: str) -> Optional[ClipConfig]:
return self.configs.get(name)
[docs]
def add(self, cfg: ClipConfig):
self.configs[cfg.name] = cfg
[docs]
def remove(self, name: str):
self.configs.pop(name, None)
[docs]
def reset(self):
self.configs.clear()
[docs]
def save(self, filename: str):
data = {name: cfg.serialize() for name, cfg in self.configs.items()}
with open(filename, 'w') as f:
json.dump(data, f, indent=4)
[docs]
def load(self, filename: str):
with open(filename, 'r') as f:
data = json.load(f)
self.configs.clear()
for name, cdata in data.items():
self.configs[name] = ClipConfig.deserialize(cdata)