Source code for wolfhece.wolf_array._clipping

"""
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] LEFT = 'left'
[docs] RIGHT = 'right'
[docs] BOTTOM = 'bottom'
[docs] TOP = 'top'
[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.active = active
[docs] self.owner_name = owner_name
[docs] self.invert = invert
[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
[docs] def from_header(cls, h: header_wolf, active: bool = True, owner_name: str = '') -> "WolfArrayClipZone": """Create a clip zone matching a :class:`header_wolf` extent.""" bounds = h.get_bounds() return cls(xmin=bounds[0][0], ymin=bounds[1][0], xmax=bounds[0][1], ymax=bounds[1][1], active=active, owner_name=owner_name)
@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.edge = edge
[docs] self.color = color
[docs] self.line_width = line_width
[docs] self.grab_tolerance_px = grab_tolerance_px
[docs] self.owner_name = owner_name
[docs] self._dragging = False
# ---------------------------------------------------------------- # 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.name = name
[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)