Source code for wolfhece._overlays

"""
Overlay and panel classes extracted from PyDraw.py.

These classes provide on-canvas HUD overlays and floating control panels
for the WolfMapViewer.

Author: HECE - University of Liege, Pierre Archambeau
Date: 2024

Copyright (c) 2024 University of Liege. All rights reserved.

This script and its content are protected by copyright law. Unauthorized
copying or distribution of this file, via any medium, is strictly prohibited.
"""
from __future__ import annotations

import math
import logging
import numpy as np
import wx

from typing import TYPE_CHECKING

from .PyTranslate import _
from .wolf_array import WolfArray, WolfArrayMB
from .Results2DGPU import Wolfresults_2D

if TYPE_CHECKING:
    from .PyDraw import WolfMapViewer

try:
    from OpenGL.GL import *
except ImportError as e:
    raise ImportError("Error importing OpenGL library") from e


__all__ = [
    'HillshadePanel',
    'HillshadeOverlay',
    '_ToolButton',
    'CutFillOverlay',
    'ToolbarOverlay',
    'PaletteOverlay',
]

# ---------------------------------------------------------------------------
# The classes below were originally in PyDraw.py (lines 880-3651).
# "WolfMapViewer" is referenced only as a forward-reference string annotation,
# so no circular import occurs.
# ---------------------------------------------------------------------------

[docs] class HillshadePanel(wx.Frame): """Persistent panel for hillshade / sun-lighting controls. Provides live sliders for altitude, azimuth, intensity, checkboxes for enable and multi-directional mode, and a sunrise animation button. """
[docs] _ANIM_INTERVAL_MS = 33 # ~30 fps
[docs] _ANIM_SPEED_DEG_S = 30.0 # azimuth degrees per second
def __init__(self, mapviewer: "WolfMapViewer"): super().__init__( None, title=_('Hillshade'), style=wx.DEFAULT_FRAME_STYLE & ~(wx.MAXIMIZE_BOX | wx.MINIMIZE_BOX))
[docs] self.mapviewer = mapviewer
[docs] self._panel = wx.Panel(self)
sizer = wx.FlexGridSizer(cols=2, vgap=6, hgap=10) sizer.AddGrowableCol(1) # ---- Enable checkbox ---- sizer.Add(wx.StaticText(self._panel, label=_('Enabled')), flag=wx.ALIGN_CENTER_VERTICAL)
[docs] self._chk_enable = wx.CheckBox(self._panel)
self._chk_enable.SetValue(mapviewer.hillshade) self._chk_enable.Bind(wx.EVT_CHECKBOX, self._on_change) sizer.Add(self._chk_enable) # ---- Altitude ---- sizer.Add(wx.StaticText(self._panel, label=_('Altitude (°)')), flag=wx.ALIGN_CENTER_VERTICAL)
[docs] self._alt = wx.SpinCtrlDouble(self._panel, min=0, max=90, inc=1, value=str(mapviewer.sun_altitude), style=wx.SP_ARROW_KEYS | wx.TE_PROCESS_ENTER)
self._alt.SetDigits(1) self._alt.Bind(wx.EVT_TEXT_ENTER, self._on_change) sizer.Add(self._alt, flag=wx.EXPAND) # ---- Azimuth ---- sizer.Add(wx.StaticText(self._panel, label=_('Azimuth (°)')), flag=wx.ALIGN_CENTER_VERTICAL)
[docs] self._az = wx.SpinCtrlDouble(self._panel, min=0, max=360, inc=1, value=str(mapviewer.sun_azimuth), style=wx.SP_ARROW_KEYS | wx.TE_PROCESS_ENTER)
self._az.SetDigits(1) self._az.Bind(wx.EVT_TEXT_ENTER, self._on_change) sizer.Add(self._az, flag=wx.EXPAND) # ---- Intensity ---- sizer.Add(wx.StaticText(self._panel, label=_('Intensity')), flag=wx.ALIGN_CENTER_VERTICAL)
[docs] self._int = wx.SpinCtrlDouble(self._panel, min=0, max=2, inc=0.05, value=str(mapviewer.sun_intensity), style=wx.SP_ARROW_KEYS | wx.TE_PROCESS_ENTER)
self._int.SetDigits(2) self._int.Bind(wx.EVT_TEXT_ENTER, self._on_change) sizer.Add(self._int, flag=wx.EXPAND) # ---- Multi-directional ---- sizer.Add(wx.StaticText(self._panel, label=_('Multi-directional')), flag=wx.ALIGN_CENTER_VERTICAL)
[docs] self._chk_multi = wx.CheckBox(self._panel)
self._chk_multi.SetValue(mapviewer.hillshade_multidirectional) self._chk_multi.Bind(wx.EVT_CHECKBOX, self._on_change) sizer.Add(self._chk_multi) # ---- Sync / per-array toggle ---- sizer.Add(wx.StaticText(self._panel, label=_('Synchronised')), flag=wx.ALIGN_CENTER_VERTICAL)
[docs] self._chk_sync = wx.CheckBox(self._panel)
self._chk_sync.SetValue(mapviewer.hillshade_sync) self._chk_sync.SetToolTip(_('Checked: all arrays share same material. ' 'Unchecked: each array has its own settings.')) self._chk_sync.Bind(wx.EVT_CHECKBOX, self._on_sync_toggle) sizer.Add(self._chk_sync) # ---- Z exaggeration ---- sizer.Add(wx.StaticText(self._panel, label=_('Z exaggeration')), flag=wx.ALIGN_CENTER_VERTICAL)
[docs] self._z_exag = wx.SpinCtrlDouble(self._panel, min=0.1, max=100, inc=0.5, value=str(mapviewer.hillshade_z_exaggeration), style=wx.SP_ARROW_KEYS | wx.TE_PROCESS_ENTER)
self._z_exag.SetDigits(1) self._z_exag.Bind(wx.EVT_TEXT_ENTER, self._on_change) sizer.Add(self._z_exag, flag=wx.EXPAND) # ---- Specular ---- sizer.Add(wx.StaticText(self._panel, label=_('Specular')), flag=wx.ALIGN_CENTER_VERTICAL)
[docs] self._spec = wx.SpinCtrlDouble(self._panel, min=0, max=1, inc=0.05, value=str(mapviewer.hillshade_specular), style=wx.SP_ARROW_KEYS | wx.TE_PROCESS_ENTER)
self._spec.SetDigits(2) self._spec.Bind(wx.EVT_TEXT_ENTER, self._on_change) sizer.Add(self._spec, flag=wx.EXPAND) # ---- Glossiness ---- sizer.Add(wx.StaticText(self._panel, label=_('Glossiness')), flag=wx.ALIGN_CENTER_VERTICAL)
[docs] self._gloss = wx.SpinCtrlDouble(self._panel, min=0, max=1, inc=0.05, value=str(mapviewer.hillshade_glossiness), style=wx.SP_ARROW_KEYS | wx.TE_PROCESS_ENTER)
self._gloss.SetDigits(2) self._gloss.Bind(wx.EVT_TEXT_ENTER, self._on_change) sizer.Add(self._gloss, flag=wx.EXPAND) # ---- Highlight ---- sizer.Add(wx.StaticText(self._panel, label=_('Highlight')), flag=wx.ALIGN_CENTER_VERTICAL)
[docs] self._hl = wx.SpinCtrlDouble(self._panel, min=0, max=1, inc=0.05, value=str(mapviewer.hillshade_highlight), style=wx.SP_ARROW_KEYS | wx.TE_PROCESS_ENTER)
self._hl.SetDigits(2) self._hl.Bind(wx.EVT_TEXT_ENTER, self._on_change) sizer.Add(self._hl, flag=wx.EXPAND) # ---- Animation speed ---- sizer.Add(wx.StaticText(self._panel, label=_('Anim. speed (°/s)')), flag=wx.ALIGN_CENTER_VERTICAL)
[docs] self._anim_speed = wx.SpinCtrlDouble(self._panel, min=1, max=360, inc=5, value=str(self._ANIM_SPEED_DEG_S), style=wx.SP_ARROW_KEYS | wx.TE_PROCESS_ENTER)
self._anim_speed.SetDigits(0) sizer.Add(self._anim_speed, flag=wx.EXPAND) # ---- Apply button ---- sizer.AddStretchSpacer()
[docs] self._btn_apply = wx.Button(self._panel, label=_('Apply'))
self._btn_apply.Bind(wx.EVT_BUTTON, self._on_change) sizer.Add(self._btn_apply, flag=wx.EXPAND) # ---- Animation button ---- sizer.AddStretchSpacer()
[docs] self._btn_anim = wx.ToggleButton(self._panel, label=_('Sunrise animation'))
self._btn_anim.Bind(wx.EVT_TOGGLEBUTTON, self._on_toggle_anim) sizer.Add(self._btn_anim, flag=wx.EXPAND) # ---- Preset buttons ---- preset_sizer = wx.BoxSizer(wx.HORIZONTAL)
[docs] self._btn_natural = wx.Button(self._panel, label=_('Natural'))
self._btn_natural.SetToolTip(_('NW light (315°), altitude 45°, Z exag. 1 — standard cartographic convention')) self._btn_natural.Bind(wx.EVT_BUTTON, self._on_preset_natural) preset_sizer.Add(self._btn_natural, 1, wx.EXPAND | wx.RIGHT, 4)
[docs] self._btn_pseudo = wx.Button(self._panel, label=_('Pseudoscopic'))
self._btn_pseudo.SetToolTip(_('SE light (135°), altitude 35°, Z exag. 8 — inverted relief illusion')) self._btn_pseudo.Bind(wx.EVT_BUTTON, self._on_preset_pseudoscopic) preset_sizer.Add(self._btn_pseudo, 1, wx.EXPAND | wx.LEFT, 4) sizer.AddStretchSpacer() sizer.Add(preset_sizer, flag=wx.EXPAND) # ---- Save button ---- sizer.AddStretchSpacer()
[docs] self._btn_save = wx.Button(self._panel, label=_('Save params'))
self._btn_save.SetToolTip(_('Save material params alongside each array file')) self._btn_save.Bind(wx.EVT_BUTTON, self._on_save) sizer.Add(self._btn_save, flag=wx.EXPAND) main_sizer = wx.BoxSizer(wx.VERTICAL) main_sizer.Add(sizer, 0, wx.ALL | wx.EXPAND, 10) self._panel.SetSizer(main_sizer) # Layout → Fit → enforce a sensible minimum width self._panel.Layout() self.Fit() best = self.GetBestSize() best.SetWidth(max(best.GetWidth(), 350)) best.SetHeight(max(best.GetHeight(), 550)) self.SetSize(best) self.SetMinSize(best) # Animation timer
[docs] self._timer = wx.Timer(self)
self.Bind(wx.EVT_TIMER, self._on_timer, self._timer) self.Bind(wx.EVT_CLOSE, self._on_close) # ---- Event handlers ----
[docs] def _on_change(self, event): """Called on Enter key, Apply button, or checkbox — push to mapviewer.""" mv = self.mapviewer mv.hillshade = self._chk_enable.GetValue() mv.sun_altitude = self._alt.GetValue() mv.sun_azimuth = self._az.GetValue() mv.sun_intensity = self._int.GetValue() mv.hillshade_multidirectional = self._chk_multi.GetValue() mv.hillshade_z_exaggeration = self._z_exag.GetValue() mv.hillshade_specular = self._spec.GetValue() mv.hillshade_glossiness = self._gloss.GetValue() mv.hillshade_highlight = self._hl.GetValue() mv.Paint()
[docs] def _on_toggle_anim(self, event): if self._btn_anim.GetValue(): self._timer.Start(self._ANIM_INTERVAL_MS) else: self._timer.Stop()
[docs] def _apply_preset(self, altitude, azimuth, intensity, z_exag, multidirectional, specular=0.0, glossiness=0.5, highlight=1.0): """Apply a preset and sync all controls.""" self._chk_enable.SetValue(True) self._alt.SetValue(altitude) self._az.SetValue(azimuth) self._int.SetValue(intensity) self._z_exag.SetValue(z_exag) self._chk_multi.SetValue(multidirectional) self._spec.SetValue(specular) self._gloss.SetValue(glossiness) self._hl.SetValue(highlight) self._on_change(None)
[docs] def _on_preset_natural(self, event): """Standard cartographic hillshade: NW light, moderate settings.""" self._apply_preset(altitude=45.0, azimuth=315.0, intensity=1.0, z_exag=1.0, multidirectional=False)
[docs] def _on_preset_pseudoscopic(self, event): """Pseudoscopic illusion: SE light + strong Z exaggeration.""" self._apply_preset(altitude=35.0, azimuth=135.0, intensity=1.0, z_exag=8.0, multidirectional=False)
[docs] def _on_timer(self, event): speed = self._anim_speed.GetValue() dt = self._ANIM_INTERVAL_MS / 1000.0 self.mapviewer.sun_azimuth = self.mapviewer.sun_azimuth + speed * dt # Keep the spin control in sync self._az.SetValue(self.mapviewer.sun_azimuth) self.mapviewer.Paint()
[docs] def _on_close(self, event): self._timer.Stop() self.mapviewer._hillshade_panel = None self.mapviewer._hillshade_overlay = None self.Destroy()
[docs] def _on_sync_toggle(self, event): """Toggle synchronised / per-array mode.""" self.mapviewer.hillshade_sync = self._chk_sync.GetValue() self.refresh_from_params() self.mapviewer.Paint()
[docs] def _on_save(self, event): """Persist material params to sidecar files.""" mv = self.mapviewer if mv.hillshade_sync: # Save shared params to ALL loaded arrays for wa in mv.myarrays: if hasattr(wa, 'hillshade_params') and wa.filename: wa.hillshade_params.copy_from(mv._hillshade_shared_params) wa.hillshade_params.save(wa.filename) else: # Save only the active array's params aa = mv.active_array if aa is not None and hasattr(aa, 'hillshade_params') and aa.filename: aa.hillshade_params.save(aa.filename)
[docs] def refresh_from_params(self): """Refresh spin controls from the active material params source.""" mv = self.mapviewer self._chk_sync.SetValue(mv.hillshade_sync) self._z_exag.SetValue(mv.hillshade_z_exaggeration) self._spec.SetValue(mv.hillshade_specular) self._gloss.SetValue(mv.hillshade_glossiness) self._hl.SetValue(mv.hillshade_highlight)
[docs] class HillshadeOverlay: """On-canvas HUD for interactive hillshade control. Draws an azimuth ring and altitude bar in the bottom-right corner of the GL canvas. Responds to mouse drag on these widgets. Only active while the :class:`HillshadePanel` is open. """ # Layout constants (pixels)
[docs] _MARGIN = 20
[docs] _RING_RADIUS = 45
[docs] _BAR_WIDTH = 14
[docs] _MINI_BAR_WIDTH = 10
[docs] _GAP = 18 # gap between bar and ring
[docs] _MINI_GAP = 8 # gap between mini bars
# Colours
[docs] _BG_RGBA = (0.1, 0.1, 0.1, 0.5)
[docs] _OUTLINE_RGBA = (0.8, 0.8, 0.8, 0.8)
[docs] _SUN_RGBA = (1.0, 0.9, 0.2, 0.9)
[docs] _SUN_FILL_RGBA = (1.0, 0.9, 0.2, 0.4)
# S / R / M bar colours
[docs] _SPEC_RGBA = (0.9, 0.9, 1.0, 0.9)
[docs] _SPEC_FILL = (0.7, 0.7, 1.0, 0.4)
[docs] _ROUGH_RGBA = (0.6, 0.85, 0.6, 0.9)
[docs] _ROUGH_FILL = (0.4, 0.7, 0.4, 0.4)
[docs] _METAL_RGBA = (0.9, 0.7, 0.3, 0.9)
[docs] _METAL_FILL = (0.8, 0.6, 0.2, 0.4)
def __init__(self, mapviewer: "WolfMapViewer"):
[docs] self.mapviewer = mapviewer
[docs] self._dragging: str | None = None # 'azimuth', 'altitude', 'specular', 'glossiness', 'highlight', or None
# ------------------------------------------------------------------ # Geometry helpers (all in GL pixel coords: origin bottom-left) # ------------------------------------------------------------------
[docs] def _ring_center(self): w, h = self.mapviewer.canvas.GetClientSize() cx = w - self._MARGIN - self._RING_RADIUS cy = self._MARGIN + self._RING_RADIUS return cx, cy
[docs] def _bar_rect(self): """Return (x0, y_bottom, x1, y_top) for the altitude bar.""" cx, cy = self._ring_center() r = self._RING_RADIUS x1 = cx - r - self._GAP x0 = x1 - self._BAR_WIDTH y0 = cy - r y1 = cy + r return x0, y0, x1, y1
[docs] def _mini_bar_rect(self, index): """Return (x0, y0, x1, y1) for mini bar *index* (0=S, 1=R, 2=M). Mini bars sit to the left of the altitude bar. """ alt_x0, y0, __, y1 = self._bar_rect() step = self._MINI_BAR_WIDTH + self._MINI_GAP x1 = alt_x0 - self._MINI_GAP - index * step x0 = x1 - self._MINI_BAR_WIDTH return x0, y0, x1, y1
# ------------------------------------------------------------------ # Hit testing (input: wx pixel coords, origin top-left) # ------------------------------------------------------------------
[docs] def _wx_to_gl(self, wx_x, wx_y): __, h = self.mapviewer.canvas.GetClientSize() return wx_x, h - wx_y
[docs] def hit_test(self, wx_x, wx_y): """Return ``'azimuth'``, ``'altitude'``, ``'specular'``, ``'glossiness'``, ``'highlight'``, or *None*.""" import math gx, gy = self._wx_to_gl(wx_x, wx_y) # Mini bars (S, G, H) — check first (leftmost widgets) for idx, name in enumerate(('specular', 'glossiness', 'highlight')): x0, y0, x1, y1 = self._mini_bar_rect(idx) if x0 - 6 <= gx <= x1 + 6 and y0 - 6 <= gy <= y1 + 6: return name # Altitude bar x0, y0, x1, y1 = self._bar_rect() if x0 - 8 <= gx <= x1 + 8 and y0 - 8 <= gy <= y1 + 8: return 'altitude' # Azimuth ring cx, cy = self._ring_center() dist = math.hypot(gx - cx, gy - cy) if dist <= self._RING_RADIUS + 12: return 'azimuth' return None
# ------------------------------------------------------------------ # Drag handling # ------------------------------------------------------------------
[docs] def on_drag(self, wx_x, wx_y): """Update the hillshade parameter corresponding to ``_dragging``.""" import math gx, gy = self._wx_to_gl(wx_x, wx_y) mv = self.mapviewer if self._dragging == 'azimuth': cx, cy = self._ring_center() angle = math.degrees(math.atan2(gx - cx, gy - cy)) mv.sun_azimuth = angle % 360.0 elif self._dragging == 'altitude': __, y0, __, y1 = self._bar_rect() frac = (gy - y0) / max(y1 - y0, 1) frac = max(0.0, min(1.0, frac)) mv.sun_altitude = frac * 90.0 elif self._dragging in ('specular', 'glossiness', 'highlight'): idx = ('specular', 'glossiness', 'highlight').index(self._dragging) __, y0, __, y1 = self._mini_bar_rect(idx) frac = (gy - y0) / max(y1 - y0, 1) frac = max(0.0, min(1.0, frac)) if self._dragging == 'specular': mv.hillshade_specular = frac elif self._dragging == 'glossiness': mv.hillshade_glossiness = frac else: mv.hillshade_highlight = frac self._sync_panel() mv.Refresh()
[docs] def _sync_panel(self): """Push current mapviewer values back into the HillshadePanel controls.""" panel = self.mapviewer._hillshade_panel if panel is None: return try: panel._alt.SetValue(self.mapviewer.sun_altitude) panel._az.SetValue(self.mapviewer.sun_azimuth) panel._int.SetValue(self.mapviewer.sun_intensity) panel._z_exag.SetValue(self.mapviewer.hillshade_z_exaggeration) panel._spec.SetValue(self.mapviewer.hillshade_specular) panel._gloss.SetValue(self.mapviewer.hillshade_glossiness) panel._hl.SetValue(self.mapviewer.hillshade_highlight) except RuntimeError: pass
# ------------------------------------------------------------------ # Wheel helpers (called from WolfMapViewer.On_Mouse_Button) # ------------------------------------------------------------------
[docs] def handle_wheel(self, rotation, delta, ctrl, shift): """Handle mouse wheel when hillshade panel is open. Returns *True* if the event was consumed. """ step = rotation / max(delta, 1) mv = self.mapviewer if shift and not ctrl: # Shift + wheel → Z exaggeration mv.hillshade_z_exaggeration = mv.hillshade_z_exaggeration * (1.0 + 0.1 * step) elif ctrl and not shift: # Ctrl + wheel → azimuth mv.sun_azimuth = mv.sun_azimuth + 5.0 * step elif ctrl and shift: # Ctrl + Shift + wheel → intensity mv.sun_intensity = mv.sun_intensity + 0.05 * step else: return False # unmodified wheel → let caller handle (zoom) self._sync_panel() mv.Refresh() return True
# ------------------------------------------------------------------ # OpenGL drawing (called at end of Paint, inside GL context) # ------------------------------------------------------------------
[docs] def draw(self): """Draw azimuth ring + altitude bar as a pixel-space HUD.""" import math from OpenGL.GL import (glMatrixMode, glPushMatrix, glPopMatrix, glLoadIdentity, glOrtho, glDisable, glEnable, glIsEnabled, glBlendFunc, glColor4f, glLineWidth, glBegin, glEnd, glVertex2f, glPolygonMode, GL_PROJECTION, GL_MODELVIEW, GL_DEPTH_TEST, GL_BLEND, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_TRIANGLE_FAN, GL_LINE_LOOP, GL_LINES, GL_QUADS, GL_FRONT_AND_BACK, GL_FILL) # Switch to pixel ortho w, h = self.mapviewer.canvas.GetClientSize() glMatrixMode(GL_PROJECTION) glPushMatrix() glLoadIdentity() glOrtho(0, w, 0, h, -1, 1) glMatrixMode(GL_MODELVIEW) glPushMatrix() glLoadIdentity() _depth_was_on = glIsEnabled(GL_DEPTH_TEST) glDisable(GL_DEPTH_TEST) glEnable(GL_BLEND) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) self._draw_azimuth_ring(math, glColor4f, glLineWidth, glBegin, glEnd, glVertex2f, GL_TRIANGLE_FAN, GL_LINE_LOOP, GL_LINES) self._draw_altitude_bar(glColor4f, glLineWidth, glBegin, glEnd, glVertex2f, GL_LINE_LOOP, GL_LINES, GL_QUADS) self._draw_mini_bars(glColor4f, glLineWidth, glBegin, glEnd, glVertex2f, GL_LINE_LOOP, GL_LINES, GL_QUADS) # Restore if _depth_was_on: glEnable(GL_DEPTH_TEST) glMatrixMode(GL_PROJECTION) glPopMatrix() glMatrixMode(GL_MODELVIEW) glPopMatrix() glLineWidth(1.0)
[docs] def _draw_azimuth_ring(self, math, glColor4f, glLineWidth, glBegin, glEnd, glVertex2f, GL_TRIANGLE_FAN, GL_LINE_LOOP, GL_LINES): cx, cy = self._ring_center() r = self._RING_RADIUS n_seg = 64 # Background disc glColor4f(*self.mapviewer.overlay_bg_color) glBegin(GL_TRIANGLE_FAN) glVertex2f(cx, cy) for i in range(n_seg + 1): a = 2.0 * math.pi * i / n_seg glVertex2f(cx + r * math.cos(a), cy + r * math.sin(a)) glEnd() # Outline glColor4f(*self._OUTLINE_RGBA) glLineWidth(1.5) glBegin(GL_LINE_LOOP) for i in range(n_seg): a = 2.0 * math.pi * i / n_seg glVertex2f(cx + r * math.cos(a), cy + r * math.sin(a)) glEnd() # Cardinal ticks (N E S W) tick = 6 for az_deg in (0, 90, 180, 270): az = math.radians(az_deg) dx, dy = math.sin(az), math.cos(az) glBegin(GL_LINES) glVertex2f(cx + (r - tick) * dx, cy + (r - tick) * dy) glVertex2f(cx + (r + tick) * dx, cy + (r + tick) * dy) glEnd() # Sun direction line + dot az = math.radians(self.mapviewer.sun_azimuth) sx, sy = math.sin(az), math.cos(az) glColor4f(*self._SUN_RGBA) glLineWidth(2.5) glBegin(GL_LINES) glVertex2f(cx, cy) glVertex2f(cx + r * sx, cy + r * sy) glEnd() # Sun dot dot_r = 6 dot_cx, dot_cy = cx + r * sx, cy + r * sy glBegin(GL_TRIANGLE_FAN) glVertex2f(dot_cx, dot_cy) for i in range(17): a = 2.0 * math.pi * i / 16 glVertex2f(dot_cx + dot_r * math.cos(a), dot_cy + dot_r * math.sin(a)) glEnd() # Centre dot glColor4f(*self._OUTLINE_RGBA) glBegin(GL_TRIANGLE_FAN) glVertex2f(cx, cy) for i in range(17): a = 2.0 * math.pi * i / 16 glVertex2f(cx + 3 * math.cos(a), cy + 3 * math.sin(a)) glEnd()
[docs] def _draw_altitude_bar(self, glColor4f, glLineWidth, glBegin, glEnd, glVertex2f, GL_LINE_LOOP, GL_LINES, GL_QUADS): x0, y0, x1, y1 = self._bar_rect() frac = self.mapviewer.sun_altitude / 90.0 # Background glColor4f(*self.mapviewer.overlay_bg_color) glBegin(GL_QUADS) glVertex2f(x0, y0); glVertex2f(x1, y0) glVertex2f(x1, y1); glVertex2f(x0, y1) glEnd() # Outline glColor4f(*self._OUTLINE_RGBA) glLineWidth(1.5) glBegin(GL_LINE_LOOP) glVertex2f(x0, y0); glVertex2f(x1, y0) glVertex2f(x1, y1); glVertex2f(x0, y1) glEnd() # Filled portion up to indicator ind_y = y0 + frac * (y1 - y0) glColor4f(*self._SUN_FILL_RGBA) glBegin(GL_QUADS) glVertex2f(x0, y0); glVertex2f(x1, y0) glVertex2f(x1, ind_y); glVertex2f(x0, ind_y) glEnd() # Indicator line glColor4f(*self._SUN_RGBA) glLineWidth(2.5) glBegin(GL_LINES) glVertex2f(x0 - 4, ind_y) glVertex2f(x1 + 4, ind_y) glEnd()
[docs] def _draw_mini_bars(self, glColor4f, glLineWidth, glBegin, glEnd, glVertex2f, GL_LINE_LOOP, GL_LINES, GL_QUADS): """Draw the three S / R / M mini-bars.""" mv = self.mapviewer bars = [ (0, mv.hillshade_specular, self._SPEC_RGBA, self._SPEC_FILL, 'S'), (1, mv.hillshade_glossiness, self._ROUGH_RGBA, self._ROUGH_FILL, 'G'), (2, mv.hillshade_highlight, self._METAL_RGBA, self._METAL_FILL, 'H'), ] for idx, frac, color, fill, label in bars: x0, y0, x1, y1 = self._mini_bar_rect(idx) # Background glColor4f(*self.mapviewer.overlay_bg_color) glBegin(GL_QUADS) glVertex2f(x0, y0); glVertex2f(x1, y0) glVertex2f(x1, y1); glVertex2f(x0, y1) glEnd() # Outline glColor4f(*self._OUTLINE_RGBA) glLineWidth(1.0) glBegin(GL_LINE_LOOP) glVertex2f(x0, y0); glVertex2f(x1, y0) glVertex2f(x1, y1); glVertex2f(x0, y1) glEnd() # Fill ind_y = y0 + frac * (y1 - y0) glColor4f(*fill) glBegin(GL_QUADS) glVertex2f(x0, y0); glVertex2f(x1, y0) glVertex2f(x1, ind_y); glVertex2f(x0, ind_y) glEnd() # Indicator glColor4f(*color) glLineWidth(2.0) glBegin(GL_LINES) glVertex2f(x0 - 3, ind_y) glVertex2f(x1 + 3, ind_y) glEnd()
# ====================================================================== # ToolbarOverlay — contextual floating toolbar (OpenGL HUD) # ======================================================================
[docs] class _ToolButton: """Descriptor for one toolbar button.""" __slots__ = ('icon', 'label', 'action', 'is_toggle', 'callback') def __init__(self, icon: str, label: str, *, action: str | None = None, is_toggle: bool = False, callback=None):
[docs] self.icon = icon # 1-3 char glyph drawn on button
[docs] self.label = label # tooltip text
[docs] self.action = action # action string for start_action (toggle)
[docs] self.is_toggle = is_toggle
[docs] self.callback = callback # callable name (str) on ToolbarOverlay
# Convenience: separator sentinel
[docs] SEPARATOR = None
# ====================================================================== # CutFillOverlay — dual pie-chart HUD for sculpt / profile brush # ======================================================================
[docs] class CutFillOverlay: """Two side-by-side pie-chart HUDs showing cut/fill earthwork volumes. The **Zoom** pie covers only the cells currently visible in the viewport; the **Global** pie covers the entire active array. Both measure the signed difference ``z_ref − z_current``: * **Cut** (orange): cells where the terrain was lowered (ref > current). * **Fill** (teal): cells where the terrain was raised (ref < current). The overlay is visible only when the sculpt or profile brush is active *and* a reference snapshot has been frozen. It is placed at the bottom-right corner, automatically pushed above the hillshade overlay when that one is also on screen. """ # ── Throttle global recompute while brushing (seconds)
[docs] _GLOBAL_THROTTLE_S: float = 0.4
# ── Layout constants (pixels) ─────────────────────────────────────
[docs] PIE_R = 65 # radius of each pie disc
[docs] CARD_PAD = 10 # padding inside each card
[docs] TITLE_H = 15 # height reserved for title label above the pie
[docs] VALUE_H = 36 # height reserved for 2 value lines below the pie
[docs] GAP = 14 # horizontal gap between the two cards
[docs] MARGIN_X = 16 # distance from canvas right edge
[docs] MARGIN_Y = 16 # distance from canvas bottom edge (when hillshade off)
[docs] HS_MARGIN = 12 # extra gap above hillshade overlay
# Card total dimensions (derived) @property
[docs] def CARD_W(self): return self.CARD_PAD + 2 * self.PIE_R + self.CARD_PAD
@property
[docs] def CARD_H(self): return (self.CARD_PAD + self.TITLE_H + 6 + 2 * self.PIE_R + 6 + self.VALUE_H + self.CARD_PAD)
# ── Colours ───────────────────────────────────────────────────────
[docs] BG_RGBA = (0.06, 0.06, 0.06, 0.62)
[docs] OUTLINE_RGBA = (0.70, 0.70, 0.70, 0.65)
[docs] CUT_RGBA = (1.00, 0.52, 0.12, 0.88) # orange
[docs] CUT_FILL = (1.00, 0.52, 0.12, 0.38)
[docs] FILL_RGBA = (0.18, 0.80, 0.88, 0.88) # cyan-teal
[docs] FILL_FILL = (0.18, 0.80, 0.88, 0.38)
[docs] ZERO_RGBA = (0.35, 0.35, 0.35, 0.55) # grey disc when no change
[docs] TITLE_RGBA = (0.88, 0.88, 0.88, 1.00)
[docs] CUT_TEXT = (1.00, 0.65, 0.30, 1.00)
[docs] FILL_TEXT = (0.30, 0.90, 0.95, 1.00)
# Class-level default so __new__-constructed instances have a valid value # before __init__ is called (e.g. in unit tests).
[docs] enabled: bool = True
def __init__(self, mapviewer: "WolfMapViewer") -> None:
[docs] self.mapviewer = mapviewer
self.enabled: bool = True # set to False to skip all computation and drawing
[docs] self._g_cut: float = 0.0
[docs] self._g_fill: float = 0.0
[docs] self._z_cut: float = 0.0
[docs] self._z_fill: float = 0.0
[docs] self._dirty: bool = True
[docs] self._zoom_dirty: bool = True
[docs] self._last_viewport: tuple = ()
[docs] self._last_global_t: float = -1e9 # force recompute on first draw
[docs] self._last_zoom_t: float = -1e9 # force recompute on first draw
# ------------------------------------------------------------------ # Cache management # ------------------------------------------------------------------
[docs] def invalidate(self) -> None: """Mark both caches dirty. Call after each brush stroke.""" if not self.enabled: return self._dirty = True self._zoom_dirty = True
[docs] def force_recompute(self) -> None: """Bypass throttle and recompute global immediately. Call when brushing ends so the final tally is always accurate. """ self._recompute_global() self._last_global_t = -1e9 # reset so next stroke also recomputes promptly self._zoom_dirty = True self._last_zoom_t = -1e9
[docs] def _get_brush_info(self): """Return ``(ref_array, live_data, dx, dy, nullvalue)`` or *None*. Pulls from whichever brush (sculpt or profile) is currently active. """ mv = self.mapviewer if mv.action == 'sculpt': panel = getattr(mv, 'sculpt_panel', None) elif mv.action == 'profile': panel = getattr(mv, 'profile_panel', None) else: return None if panel is None: return None ref = getattr(panel.brush, '_ref_array', None) if ref is None: return None wa = mv.active_array if wa is None: return None return ref, wa.array.data, float(wa.dx), float(wa.dy), float(wa.nullvalue), wa
[docs] def _recompute_global(self) -> None: import numpy as np info = self._get_brush_info() if info is None: self._g_cut = self._g_fill = 0.0 self._dirty = False return ref, cur, dx, dy, null, __ = info valid = (ref != null) & (cur != null) diff = np.where(valid, ref - cur, 0.0) cell = dx * dy self._g_cut = float(np.sum(np.maximum( diff, 0.0))) * cell self._g_fill = float(np.sum(np.maximum(-diff, 0.0))) * cell self._dirty = False
[docs] def _compute_zoom(self): """Return ``(cut_m3, fill_m3)`` restricted to the current viewport.""" import numpy as np info = self._get_brush_info() if info is None: return 0.0, 0.0 ref, cur, dx, dy, null, wa = info mv = self.mapviewer ox = float(wa.origx + wa.translx) oy = float(wa.origy + wa.transly) ix0 = max(0, int(np.floor((mv.xmin - ox) / dx))) ix1 = min(wa.nbx, int(np.ceil ((mv.xmax - ox) / dx))) iy0 = max(0, int(np.floor((mv.ymin - oy) / dy))) iy1 = min(wa.nby, int(np.ceil ((mv.ymax - oy) / dy))) if ix0 >= ix1 or iy0 >= iy1: return 0.0, 0.0 rs = ref[ix0:ix1, iy0:iy1] cs = cur[ix0:ix1, iy0:iy1] valid = (rs != null) & (cs != null) diff = np.where(valid, rs - cs, 0.0) cell = dx * dy return (float(np.sum(np.maximum( diff, 0.0))) * cell, float(np.sum(np.maximum(-diff, 0.0))) * cell)
# ------------------------------------------------------------------ # Layout helpers # ------------------------------------------------------------------ @staticmethod
[docs] def _vol_str(v: float) -> str: if v >= 1.0e6: return f'{v / 1e6:.3f} M·m³' if v >= 1.0e3: return f'{v / 1e3:.2f} k·m³' return f'{v:.1f} m³'
[docs] def _card_bottom_y(self) -> float: """Bottom-left y (GL pixels) for the cards, avoiding the hillshade.""" mv = self.mapviewer hs = getattr(mv, '_hillshade_overlay', None) if hs is not None: # Top of hillshade ring in GL pixels hs_top = hs._MARGIN + 2 * hs._RING_RADIUS return float(hs_top + self.HS_MARGIN) return float(self.MARGIN_Y)
[docs] def _centers(self): """Return ``(cx1, cx2, cy)`` — disc centres for Zoom / Global pies.""" mv = self.mapviewer w, __ = mv.canvas.GetClientSize() bot = self._card_bottom_y() cy = bot + self.CARD_PAD + self.VALUE_H + 6 + self.PIE_R cx2 = w - self.MARGIN_X - self.CARD_PAD - self.PIE_R cx1 = cx2 - 2 * self.PIE_R - self.GAP - self.CARD_W return cx1, cx2, cy
# ------------------------------------------------------------------ # Drawing # ------------------------------------------------------------------ @staticmethod
[docs] def _draw_disc_sector(math, glColor4f, glBegin, glEnd, glVertex2f, GL_TRIANGLE_FAN, cx, cy, r, a_start, a_end, color, n=72): """Draw a filled pie sector from *a_start* to *a_end* (CCW radians).""" glColor4f(*color) glBegin(GL_TRIANGLE_FAN) glVertex2f(cx, cy) steps = max(3, int(abs(a_end - a_start) / (2 * math.pi) * n) + 1) for i in range(steps + 1): a = a_start + (a_end - a_start) * i / steps glVertex2f(cx + r * math.cos(a), cy + r * math.sin(a)) glEnd()
[docs] def draw(self) -> None: """Draw both cut/fill pie chart cards in GL pixel space. Called every repaint from ``WolfMapViewer.Paint()``. """ import math from OpenGL.GL import ( glMatrixMode, glPushMatrix, glPopMatrix, glLoadIdentity, glOrtho, glDisable, glEnable, glIsEnabled, glBlendFunc, glColor4f, glLineWidth, glBegin, glEnd, glVertex2f, glPolygonMode, GL_PROJECTION, GL_MODELVIEW, GL_DEPTH_TEST, GL_BLEND, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_TRIANGLE_FAN, GL_LINE_LOOP, GL_LINES, GL_FRONT_AND_BACK, GL_FILL, ) # Only draw when enabled and a reference is available if not self.enabled: return if self._get_brush_info() is None: return import time as _time _now = _time.perf_counter() # Global: throttled recompute — at most every _GLOBAL_THROTTLE_S seconds. # This prevents a full-array numpy scan on every single brush stroke. if self._dirty and (_now - self._last_global_t) >= self._GLOBAL_THROTTLE_S: self._recompute_global() # clears self._dirty self._last_global_t = _now # Zoom: lazy cache — recompute at most every _GLOBAL_THROTTLE_S seconds, # or when the viewport has panned/zoomed (lightweight slice check). mv = self.mapviewer _vp = (mv.xmin, mv.xmax, mv.ymin, mv.ymax) _vp_changed = _vp != self._last_viewport if (_vp_changed or self._zoom_dirty) and (_now - self._last_zoom_t) >= self._GLOBAL_THROTTLE_S: self._z_cut, self._z_fill = self._compute_zoom() self._last_viewport = _vp self._zoom_dirty = False self._last_zoom_t = _now z_cut, z_fill = self._z_cut, self._z_fill g_cut, g_fill = self._g_cut, self._g_fill cx1, cx2, cy = self._centers() w, h = mv.canvas.GetClientSize() # ── Enter pixel-space ortho ──────────────────────────────────── glMatrixMode(GL_PROJECTION); glPushMatrix(); glLoadIdentity() glOrtho(0, w, 0, h, -1, 1) glMatrixMode(GL_MODELVIEW); glPushMatrix(); glLoadIdentity() depth_was = bool(glIsEnabled(GL_DEPTH_TEST)) glDisable(GL_DEPTH_TEST) glEnable(GL_BLEND) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) for cx, cut, fill, title in ( (cx1, z_cut, z_fill, 'Zoom'), (cx2, g_cut, g_fill, 'Global'), ): self._draw_card(cx, cy, cut, fill, title, math, glColor4f, glLineWidth, glBegin, glEnd, glVertex2f, GL_TRIANGLE_FAN, GL_LINE_LOOP, GL_LINES) # ── Restore GL state ────────────────────────────────────────── if depth_was: glEnable(GL_DEPTH_TEST) glMatrixMode(GL_PROJECTION); glPopMatrix() glMatrixMode(GL_MODELVIEW); glPopMatrix() glLineWidth(1.0) # ── Draw text in pixel-space via TextRenderer2D ─────────────── try: from .opengl.text_renderer2d import TextRenderer2D import numpy as np tr = TextRenderer2D.get_instance() # Build a pixel-space MVP so that x/y passed to draw_text are px px_mvp = np.array([ [2.0 / w, 0.0, 0.0, 0.0], [0.0, 2.0 / h, 0.0, 0.0], [0.0, 0.0, -1.0, 0.0], [-1.0, -1.0, 0.0, 1.0], ], dtype=np.float32) vp = (int(w), int(h)) for cx, cut, fill, title in ( (cx1, z_cut, z_fill, 'Zoom'), (cx2, g_cut, g_fill, 'Global'), ): self._draw_text_card(cx, cy, cut, fill, title, tr, px_mvp, vp, math) except Exception: pass
[docs] def _draw_card(self, cx, cy, cut, fill, title, math, glColor4f, glLineWidth, glBegin, glEnd, glVertex2f, GL_TRIANGLE_FAN, GL_LINE_LOOP, GL_LINES): """Draw the background rect and pie disc for one card.""" r = self.PIE_R pad = self.CARD_PAD bot = self._card_bottom_y() card_h = self.CARD_H card_w = self.CARD_W bx0 = cx - r - pad bx1 = bx0 + card_w by0 = bot by1 = by0 + card_h # Background rect from OpenGL.GL import GL_QUADS glColor4f(*self.BG_RGBA) glBegin(GL_QUADS) glVertex2f(bx0, by0); glVertex2f(bx1, by0) glVertex2f(bx1, by1); glVertex2f(bx0, by1) glEnd() # Outline glColor4f(*self.OUTLINE_RGBA) glLineWidth(1.2) glBegin(GL_LINE_LOOP) glVertex2f(bx0, by0); glVertex2f(bx1, by0) glVertex2f(bx1, by1); glVertex2f(bx0, by1) glEnd() # ── Pie disc ────────────────────────────────────────────────── total = cut + fill if total < 1.0: # No changes yet — grey disc glColor4f(*self.ZERO_RGBA) glBegin(GL_TRIANGLE_FAN) glVertex2f(cx, cy) for i in range(73): a = 2.0 * math.pi * i / 72 glVertex2f(cx + r * math.cos(a), cy + r * math.sin(a)) glEnd() else: cut_frac = cut / total # Cut sector: from π/2 clockwise by cut_frac * 2π # In GL CCW convention: a_end = a_start - θ (clockwise = decreasing angle) a_start_cut = math.pi / 2.0 a_end_cut = a_start_cut - cut_frac * 2.0 * math.pi a_end_fill = a_start_cut - 2.0 * math.pi # = full circle endpoint self._draw_disc_sector(math, glColor4f, glBegin, glEnd, glVertex2f, GL_TRIANGLE_FAN, cx, cy, r, a_end_cut, a_start_cut, self.CUT_FILL) self._draw_disc_sector(math, glColor4f, glBegin, glEnd, glVertex2f, GL_TRIANGLE_FAN, cx, cy, r, a_end_fill, a_end_cut, self.FILL_FILL) # Sector boundary lines glColor4f(*self.CUT_RGBA) glLineWidth(2.2) glBegin(GL_LINES) glVertex2f(cx, cy) glVertex2f(cx + r * math.cos(a_start_cut), cy + r * math.sin(a_start_cut)) glVertex2f(cx, cy) glVertex2f(cx + r * math.cos(a_end_cut), cy + r * math.sin(a_end_cut)) glEnd() # Disc outline glColor4f(*self.OUTLINE_RGBA) glLineWidth(1.5) glBegin(GL_LINE_LOOP) for i in range(72): a = 2.0 * math.pi * i / 72 glVertex2f(cx + r * math.cos(a), cy + r * math.sin(a)) glEnd() # Centre dot glColor4f(*self.OUTLINE_RGBA) glBegin(GL_TRIANGLE_FAN) glVertex2f(cx, cy) for i in range(13): a = 2.0 * math.pi * i / 12 glVertex2f(cx + 3.5 * math.cos(a), cy + 3.5 * math.sin(a)) glEnd()
[docs] def _draw_text_card(self, cx, cy, cut, fill, title, tr, px_mvp, vp, math): """Draw the text labels for one pie card using pixel-space MVP.""" r = self.PIE_R glow_kw = dict( glow_enabled=False, glow_width=0.18, glow_color=(0.0, 0.0, 0.0, 0.95), ) # Title — above the pie tr.draw_text( _(title), cx, cy + r + 8, px_mvp, vp, font_size=16, color=self.TITLE_RGBA, size_in_pixels=True, alignment='center', vertical_alignment='bottom', **glow_kw, ) # Values — below the pie total = cut + fill if total < 1.0: pct_cut = pct_fill = 0.0 else: pct_cut = 100.0 * cut / total pct_fill = 100.0 * fill / total # Separator line between value text lines line1_y = cy - r - 8 # top of value block (first line bottom edge) line2_y = line1_y - 18 # Cut label cut_abs = self._vol_str(cut) tr.draw_text( f'▼ {cut_abs} {pct_cut:.0f}%', cx, line1_y, px_mvp, vp, font_size=16, color=self.CUT_TEXT, size_in_pixels=True, alignment='center', vertical_alignment='top', **glow_kw, ) # Fill label fill_abs = self._vol_str(fill) tr.draw_text( f'▲ {fill_abs} {pct_fill:.0f}%', cx, line2_y, px_mvp, vp, font_size=16, color=self.FILL_TEXT, size_in_pixels=True, alignment='center', vertical_alignment='top', **glow_kw, )
[docs] class ToolbarOverlay: """On-canvas contextual toolbar that appears in the top-left corner. Shows a small indicator (three dots) when collapsed. Expands vertically on hover to show tool buttons relevant to the currently active object type (vectors, arrays, point clouds, cross-sections, tiles). Toggled completely on/off with the ``T`` key. """ # Layout (pixels) --------------------------------------------------
[docs] _INDICATOR_X = 12
[docs] _INDICATOR_Y_TOP = 16 # from canvas top (wx coords)
[docs] _INDICATOR_DOT_R = 3
[docs] _INDICATOR_DOT_GAP = 8
[docs] _INDICATOR_HIT_W = 28
[docs] _INDICATOR_HIT_H = 40
[docs] _BTN_SIZE = 30
[docs] _BTN_GAP = 3
[docs] _BAR_PADDING = 6
[docs] _BAR_LEFT = 8
[docs] _COLLAPSE_DELAY_MS = 600
# Colours ----------------------------------------------------------
[docs] _BG = (0.10, 0.10, 0.12, 0.75)
[docs] _BG_HOVER = (0.25, 0.25, 0.30, 0.85)
[docs] _BG_ACTIVE = (0.15, 0.45, 0.80, 0.90)
[docs] _BORDER = (0.55, 0.55, 0.60, 0.80)
[docs] _TEXT = (0.95, 0.95, 0.95, 1.0)
[docs] _TEXT_ACTIVE = (1.0, 1.0, 1.0, 1.0)
[docs] _SEP_COLOR = (0.45, 0.45, 0.50, 0.60)
[docs] _DOT_COLOR = (0.70, 0.70, 0.75, 0.80)
[docs] _TOOLTIP_BG = (0.08, 0.08, 0.10, 0.90)
[docs] _TOOLTIP_TEXT = (0.95, 0.93, 0.80, 1.0)
# ------------------------------------------------------------------ # Button definitions per context # ------------------------------------------------------------------
[docs] _BUTTONS_VECTOR: list = [ _ToolButton('add_zone', 'Add zone', callback='_tb_add_zone'), _ToolButton('add_vec', 'Add vector', callback='_tb_add_vector'), _ToolButton.SEPARATOR, _ToolButton('capture', 'Capture vertices', action='capture vertices', is_toggle=True), _ToolButton('modify', 'Modify vertices', action='modify vertices', is_toggle=True), _ToolButton('insert', 'Insert vertices', action='insert vertices', is_toggle=True), _ToolButton.SEPARATOR, _ToolButton('move_vec', 'Move vector', action='move vector', is_toggle=True), _ToolButton('rotate', 'Rotate vector', action='rotate vector', is_toggle=True), _ToolButton('parallel', 'Dynamic parallel', action='dynamic parallel', is_toggle=True), ]
[docs] _BUTTONS_ARRAY: list = [ _ToolButton('sel_poly', 'Select inside polygon', action='select by tmp vector inside', is_toggle=True), _ToolButton('sel_line', 'Select along polyline', action='select by tmp vector along', is_toggle=True), _ToolButton('sel_all', 'Select all', callback='_tb_select_all'), _ToolButton('reset_sel', 'Reset selection', callback='_tb_reset_selection'), _ToolButton.SEPARATOR, _ToolButton('dilate', 'Dilate selection', callback='_tb_dilate'), _ToolButton('erode', 'Erode selection', callback='_tb_erode'), _ToolButton.SEPARATOR, _ToolButton('mask_in', 'Mask inside vector', callback='_tb_mask_inside'), _ToolButton('mask_out', 'Mask outside vector', callback='_tb_mask_outside'), _ToolButton.SEPARATOR, _ToolButton('sculpt', 'Sculpting tools', callback='_tb_open_sculpt'), _ToolButton.SEPARATOR, _ToolButton('sculpt_smooth', 'Sculpt: Smooth', callback='_tb_sculpt_smooth'), _ToolButton('sculpt_raise', 'Sculpt: Raise', callback='_tb_sculpt_raise'), _ToolButton('sculpt_lower', 'Sculpt: Lower', callback='_tb_sculpt_lower'), _ToolButton('sculpt_flatten', 'Sculpt: Flatten', callback='_tb_sculpt_flatten'), _ToolButton('sculpt_noise', 'Sculpt: Noise', callback='_tb_sculpt_noise'), _ToolButton.SEPARATOR, _ToolButton('profile_brush', 'Profile brush', callback='_tb_open_profile'), ]
[docs] _BUTTONS_CLOUD: list = [ _ToolButton('add_pts', 'Add points', action='add points to cloud', is_toggle=True), _ToolButton('move_pt', 'Move point', action='move point in cloud', is_toggle=True), ]
[docs] _BUTTONS_CS: list = [ _ToolButton('profile', 'Select nearest profile', action='select nearest profile', is_toggle=True), _ToolButton('plot_cs', 'Plot cross section', action='plot cross section', is_toggle=True), ]
[docs] _BUTTONS_TILE: list = [ _ToolButton('pick_tile', 'Pick tile', action='select active tile', is_toggle=True), ]
# ------------------------------------------------------------------ def __init__(self, mapviewer: "WolfMapViewer"):
[docs] self.mapviewer = mapviewer
[docs] self._expanded = False
[docs] self._hover_btn: int | None = None
[docs] self._text_renderer = None
[docs] self._collapse_timer = None
[docs] self._last_context: str | None = None
[docs] self._cached_buttons: list | None = None
# ------------------------------------------------------------------ # Context detection # ------------------------------------------------------------------
[docs] def _detect_context(self) -> str: """Return a context key based on the selected object in the tree. Multiple ``active_*`` attributes can be non-None simultaneously, so they cannot be used to decide which button set to show. The *selected_object* (tree selection) is the authoritative source. """ obj = self.mapviewer.selected_object if obj is None: return '' from .PyVertexvectors import Zones from .wolf_array import WolfArray, WolfArrayMB, WolfArrayMNAP from .PyVertex import cloud_vertices, cloud_of_clouds from .PyCrosssections import crosssections from .pybridges import Bridge, Weir from .wolf_tiles import Tiles if isinstance(obj, (Zones, Bridge, Weir)): return 'vector' if isinstance(obj, (WolfArray, WolfArrayMB, WolfArrayMNAP)): return 'array' if isinstance(obj, (cloud_vertices, cloud_of_clouds)): return 'cloud' if isinstance(obj, crosssections): return 'cs' if isinstance(obj, Tiles): return 'tile' return ''
[docs] def _get_buttons(self) -> list | None: """Return the button list for the current context (cached).""" ctx = self._detect_context() if ctx != self._last_context: self._last_context = ctx mapping = { 'vector': self._BUTTONS_VECTOR, 'array': self._BUTTONS_ARRAY, 'cloud': self._BUTTONS_CLOUD, 'cs': self._BUTTONS_CS, 'tile': self._BUTTONS_TILE, } self._cached_buttons = mapping.get(ctx) return self._cached_buttons
# ------------------------------------------------------------------ # Geometry helpers (GL pixel coords, origin bottom-left) # ------------------------------------------------------------------
[docs] def _canvas_size(self): w, h = self.mapviewer.canvas.GetClientSize() return int(w), int(h)
@staticmethod
[docs] def _wx_to_gl(wx_x, wx_y, h): return wx_x, h - wx_y
[docs] def _indicator_rect_gl(self): """GL rect (x0, y0, x1, y1) of the 3-dot indicator zone.""" __, h = self._canvas_size() x0 = self._INDICATOR_X - 4 y1 = h - self._INDICATOR_Y_TOP + 4 x1 = x0 + self._INDICATOR_HIT_W y0 = y1 - self._INDICATOR_HIT_H return x0, y0, x1, y1
[docs] def _bar_geometry(self): """Return (x0, y_top, y_bot, bar_w, btn_rects) for expanded bar. *btn_rects* is a list of (bx0, by0, bx1, by1) in GL coords, one per non-separator button. """ buttons = self._get_buttons() if not buttons: return None __, h = self._canvas_size() pad = self._BAR_PADDING bsz = self._BTN_SIZE gap = self._BTN_GAP x0 = self._BAR_LEFT top_y_wx = self._INDICATOR_Y_TOP + self._INDICATOR_HIT_H + 4 y_top_gl = h - top_y_wx btn_rects: list[tuple] = [] cy = y_top_gl - pad for btn in buttons: if btn is None: cy -= gap * 2 continue bx0 = x0 + pad by1 = cy by0 = cy - bsz bx1 = bx0 + bsz btn_rects.append((bx0, by0, bx1, by1)) cy = by0 - gap y_bot = cy - pad + gap bar_w = bsz + 2 * pad return x0, y_top_gl, y_bot, bar_w, btn_rects
# ------------------------------------------------------------------ # Hit testing # ------------------------------------------------------------------
[docs] def is_inside(self, wx_x, wx_y) -> bool: """True if the wx point is over the indicator or the expanded bar.""" __, h = self._canvas_size() gx, gy = self._wx_to_gl(wx_x, wx_y, h) ix0, iy0, ix1, iy1 = self._indicator_rect_gl() if ix0 <= gx <= ix1 and iy0 <= gy <= iy1: return True if not self._expanded: return False geom = self._bar_geometry() if geom is None: return False x0, y_top, y_bot, bw, __ = geom # Include tooltip zone to the right if x0 <= gx <= x0 + bw + 200 and y_bot <= gy <= y_top: return True return False
[docs] def hit_test(self, wx_x, wx_y) -> int | None: """Return button index if wx point is over a button, else None.""" if not self._expanded: return None geom = self._bar_geometry() if geom is None: return None __, h = self._canvas_size() gx, gy = self._wx_to_gl(wx_x, wx_y, h) for i, (bx0, by0, bx1, by1) in enumerate(geom[4]): if bx0 <= gx <= bx1 and by0 <= gy <= by1: return i return None
# ------------------------------------------------------------------ # Mouse events # ------------------------------------------------------------------
[docs] def on_mouse_move(self, wx_x, wx_y) -> bool: """Track hover. Returns True if the event should be consumed.""" # While a toggle action is active (e.g. capture vertices), # do NOT expand the toolbar on hover — it would steal mouse # events and freeze the ongoing interaction. # Exception: 'sculpt' keeps the toolbar accessible so the user # can switch modes without leaving the sculpt action. if self.mapviewer.action and self.mapviewer.action != 'sculpt': if self._expanded: self._expanded = False self._hover_btn = None self._stop_collapse_timer() self.mapviewer.Refresh() return False inside = self.is_inside(wx_x, wx_y) if inside and not self._expanded: self._expanded = True self._stop_collapse_timer() self.mapviewer.Refresh() return True if not inside and self._expanded: self._start_collapse_timer() old = self._hover_btn self._hover_btn = None if old is not None: self.mapviewer.Refresh() return False if inside: self._stop_collapse_timer() new_hover = self.hit_test(wx_x, wx_y) if new_hover != self._hover_btn: self._hover_btn = new_hover self.mapviewer.Refresh() return inside
[docs] def on_click(self, wx_x, wx_y) -> bool: """Handle a left click. Returns True if consumed.""" idx = self.hit_test(wx_x, wx_y) if idx is None: return self.is_inside(wx_x, wx_y) buttons = self._get_buttons() if buttons is None: return False real_buttons = [b for b in buttons if b is not None] if idx < 0 or idx >= len(real_buttons): return False btn = real_buttons[idx] mv = self.mapviewer if btn.is_toggle and btn.action: if mv.action == btn.action: mv.end_action() else: if btn.action in ('capture vertices', 'dynamic parallel'): if mv.active_vector is None or mv.active_zone is None: logging.warning(_('No active vector/zone \u2013 select one before this action')) return True if btn.action == 'dynamic parallel': if mv.active_zones is not None and mv.active_zone is not None and mv.active_zone.nbvectors > 1: import wx as _wx dlg = _wx.MessageDialog(mv, _('You already have more than one vector in the active zone. ' 'This action will conserve only the active vector.\n' 'Do you want to continue?'), _('Warning'), style=_wx.YES_NO | _wx.ICON_WARNING) ret = dlg.ShowModal() dlg.Destroy() if ret == _wx.ID_NO: return True if 'select by tmp vector' in btn.action: if mv.active_array is None: logging.warning(_('No active array \u2013 select one before selecting by vector')) return True if btn.action == 'move vector' and mv.active_vector is not None: mv.active_vector.set_cache() elif btn.action == 'rotate vector' and mv.active_vector is not None: mv.active_vector.set_cache() elif btn.action == 'move zone' and mv.active_zone is not None: mv.active_zone.set_cache() elif btn.action == 'rotate zone' and mv.active_zone is not None: mv.active_zone.set_cache() mv.start_action(btn.action, btn.label) # ---- post-start initialisation ---- if btn.action == 'capture vertices': # Add an initial tracking vertex that follows the mouse. from .PyVertexvectors import wolfvertex mv.active_vector.add_vertex(wolfvertex(0., 0.)) mv.active_vertex = mv.active_vector.myvertices[-1] elif 'select by tmp vector' in btn.action: # Create/reset a tmp vector just like SelectionData does. from .PyVertexvectors import wolfvertex sd = mv.active_array.SelectionData sd.vectmp.reset() sd.Active_vector(sd.vectmp) sd.vectmp.add_vertex(wolfvertex(0., 0.)) elif btn.action == 'dynamic parallel': from .PyVertexvectors import wolfvertex mv.active_vector.add_vertex(wolfvertex(0., 0.)) if mv.active_zones is not None: mv.active_zone.reset_listogl() elif btn.action == 'move point in cloud': mv.active_cloud_vertex_id = None # Collapse immediately so the bar does not block canvas clicks. self._expanded = False self._hover_btn = None self._stop_collapse_timer() mv.Refresh() return True if btn.callback: method = getattr(self, btn.callback, None) if method is not None: method() mv.Refresh() return True return False
# ------------------------------------------------------------------ # Collapse timer # ------------------------------------------------------------------
[docs] def _start_collapse_timer(self): import wx as _wx if self._collapse_timer is not None: return self._collapse_timer = _wx.CallLater( self._COLLAPSE_DELAY_MS, self._on_collapse)
[docs] def _stop_collapse_timer(self): if self._collapse_timer is not None: self._collapse_timer.Stop() self._collapse_timer = None
[docs] def _on_collapse(self): self._collapse_timer = None self._expanded = False self._hover_btn = None self.mapviewer.Refresh()
# ------------------------------------------------------------------ # Instant-action callbacks # ------------------------------------------------------------------
[docs] def _tb_add_zone(self): import wx as _wx mv = self.mapviewer if mv.active_zones is None: return dlg = _wx.TextEntryDialog(mv, _('Name for the new zone'), _('Add zone'), 'New_Zone') if dlg.ShowModal() == _wx.ID_OK: name = dlg.GetValue() newzone = mv.active_zones._make_zone(name=name, parent=mv.active_zones) mv.active_zones.add_zone(newzone) if hasattr(mv.active_zones, 'fill_structure'): mv.active_zones.fill_structure() mv.active_zone = newzone dlg.Destroy()
[docs] def _tb_add_vector(self): import wx as _wx mv = self.mapviewer if mv.active_zone is None: return dlg = _wx.TextEntryDialog(mv, _('Name for the new vector'), _('Add vector'), 'New_Vector') if dlg.ShowModal() == _wx.ID_OK: name = dlg.GetValue() zones = mv.active_zones if zones is not None: newvec = zones._make_vector(name=name, parentzone=mv.active_zone) mv.active_zone.add_vector(newvec) mv.active_vector = newvec if hasattr(zones, 'fill_structure'): zones.fill_structure() if hasattr(zones, 'Activate_vector'): zones.Activate_vector(newvec) dlg.Destroy()
[docs] def _tb_create_parallel(self): mv = self.mapviewer if mv.active_vector is None or mv.active_zones is None: return if hasattr(mv.active_zones, 'OnAddPar'): mv.active_zones.OnAddPar(None)
[docs] def _tb_select_all(self): mv = self.mapviewer if mv.active_array is None: return if mv.active_array.SelectionData is not None: mv.active_array.SelectionData.myselection = 'all'
[docs] def _tb_reset_selection(self): mv = self.mapviewer if mv.active_array is None: return ops = getattr(mv.active_array, 'myops', None) if ops is not None: ops.reset_selection() mv.Refresh()
[docs] def _tb_dilate(self): mv = self.mapviewer if mv.active_array is None: return sel = mv.active_array.SelectionData if sel is not None: sel.dilate_selection(1) mv.active_array.reset_plot()
[docs] def _tb_erode(self): mv = self.mapviewer if mv.active_array is None: return sel = mv.active_array.SelectionData if sel is not None: sel.erode_selection(1) mv.active_array.reset_plot()
[docs] def _tb_mask_inside(self): mv = self.mapviewer if mv.active_array is not None and mv.active_vector is not None: mv.active_array.mask_insidepoly(mv.active_vector) mv.active_array.reset_plot() mv.Refresh()
[docs] def _tb_mask_outside(self): mv = self.mapviewer if mv.active_array is not None and mv.active_vector is not None: mv.active_array.mask_outsidepoly(mv.active_vector) mv.active_array.reset_plot() mv.Refresh()
[docs] def _tb_open_sculpt(self): mv = self.mapviewer if not hasattr(mv, 'sculpt_panel') or mv.sculpt_panel is None: from .wolf_sculpt import SculptPanel mv.sculpt_panel = SculptPanel(mv, mv) mv.sculpt_panel.Show() mv.sculpt_panel.Raise()
[docs] def _tb_sculpt_mode(self, mode_value: str) -> None: """Open the sculpt panel, set brush mode, and activate sculpting.""" mv = self.mapviewer if not hasattr(mv, 'sculpt_panel') or mv.sculpt_panel is None: from .wolf_sculpt import SculptPanel mv.sculpt_panel = SculptPanel(mv, mv) panel = mv.sculpt_panel from .wolf_sculpt import SculptMode try: new_mode = SculptMode(mode_value) except ValueError: return panel.brush.mode = new_mode # Sync mode toggle buttons inside the panel if hasattr(panel, '_mode_btns'): for mode, btn in panel._mode_btns.items(): btn.SetValue(mode == new_mode) panel.Show() panel.Raise() if mv.action != 'sculpt': mv.start_action('sculpt', _('Sculpting — left-click / drag to apply')) if hasattr(panel, '_activate_btn'): panel._activate_btn.SetValue(True)
[docs] def _tb_sculpt_smooth(self): self._tb_sculpt_mode('smooth')
[docs] def _tb_sculpt_raise(self): self._tb_sculpt_mode('raise')
[docs] def _tb_sculpt_lower(self): self._tb_sculpt_mode('lower')
[docs] def _tb_sculpt_flatten(self): self._tb_sculpt_mode('flatten')
[docs] def _tb_sculpt_noise(self): self._tb_sculpt_mode('noise')
[docs] def _tb_open_profile(self): """Open (or raise) the floating ProfilePanel.""" mv = self.mapviewer if not hasattr(mv, 'profile_panel') or mv.profile_panel is None: from .wolf_sculpt import ProfilePanel mv.profile_panel = ProfilePanel(mv, mv) mv.profile_panel.Show() mv.profile_panel.Raise()
# ------------------------------------------------------------------ # Drawing # ------------------------------------------------------------------
[docs] def draw(self): """Draw the toolbar overlay. Called from WolfMapViewer.Paint().""" from OpenGL.GL import ( glEnable, glDisable, glIsEnabled, glBlendFunc, glLineWidth, glBegin, glEnd, glVertex2f, glColor4f, glPointSize, glMatrixMode, glPushMatrix, glPopMatrix, glLoadIdentity, glOrtho, glUseProgram, glPolygonMode, GL_BLEND, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_QUADS, GL_LINES, GL_POINTS, GL_PROJECTION, GL_MODELVIEW, GL_DEPTH_TEST, GL_LINE_SMOOTH, GL_TEXTURE_2D, GL_FRONT_AND_BACK, GL_FILL, ) w, h = self._canvas_size() if w < 60 or h < 60: return glMatrixMode(GL_PROJECTION) glPushMatrix() glLoadIdentity() glOrtho(0, w, 0, h, -1, 1) glMatrixMode(GL_MODELVIEW) glPushMatrix() glLoadIdentity() _depth_was_on = glIsEnabled(GL_DEPTH_TEST) glEnable(GL_BLEND) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) glDisable(GL_DEPTH_TEST) glDisable(GL_TEXTURE_2D) glUseProgram(0) glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) glEnable(GL_LINE_SMOOTH) try: if self._expanded: self._draw_expanded( w, h, glBegin, glEnd, glVertex2f, glColor4f, glLineWidth, glPointSize, GL_QUADS, GL_LINES, GL_POINTS) else: self._draw_indicator( w, h, glColor4f, glPointSize, glBegin, glEnd, glVertex2f, GL_POINTS) finally: glDisable(GL_LINE_SMOOTH) if _depth_was_on: glEnable(GL_DEPTH_TEST) glMatrixMode(GL_MODELVIEW) glPopMatrix() glMatrixMode(GL_PROJECTION) glPopMatrix() glMatrixMode(GL_MODELVIEW)
[docs] def _draw_indicator(self, w, h, glColor4f, glPointSize, glBegin, glEnd, glVertex2f, GL_POINTS): """Draw three small dots in the top-left corner.""" cx = self._INDICATOR_X + self._INDICATOR_DOT_R top_gl = h - self._INDICATOR_Y_TOP gap = self._INDICATOR_DOT_GAP glColor4f(*self._DOT_COLOR) glPointSize(float(self._INDICATOR_DOT_R * 2)) glBegin(GL_POINTS) for i in range(3): glVertex2f(float(cx), float(top_gl - i * gap)) glEnd()
[docs] def _draw_expanded(self, w, h, glBegin, glEnd, glVertex2f, glColor4f, glLineWidth, glPointSize, GL_QUADS, GL_LINES, GL_POINTS): """Draw the full expanded toolbar.""" geom = self._bar_geometry() if geom is None: self._draw_indicator(w, h, glColor4f, glPointSize, glBegin, glEnd, glVertex2f, GL_POINTS) return x0, y_top, y_bot, bar_w, btn_rects = geom # Background glColor4f(*self.mapviewer.overlay_bg_color) glBegin(GL_QUADS) glVertex2f(x0, y_bot) glVertex2f(x0 + bar_w, y_bot) glVertex2f(x0 + bar_w, y_top) glVertex2f(x0, y_top) glEnd() # Border glColor4f(*self._BORDER) glLineWidth(1.0) glBegin(GL_LINES) for a, b in [((x0, y_bot), (x0 + bar_w, y_bot)), ((x0 + bar_w, y_bot), (x0 + bar_w, y_top)), ((x0 + bar_w, y_top), (x0, y_top)), ((x0, y_top), (x0, y_bot))]: glVertex2f(*a); glVertex2f(*b) glEnd() # Buttons buttons = self._get_buttons() if buttons is None: return real_buttons = [b for b in buttons if b is not None] current_action = self.mapviewer.action for i, (bx0, by0, bx1, by1) in enumerate(btn_rects): if i >= len(real_buttons): break btn = real_buttons[i] is_hover = (i == self._hover_btn) is_active = (btn.is_toggle and btn.action and current_action == btn.action) # Sculpt mode buttons: highlight the active mode if not is_active and btn.icon.startswith('sculpt_'): panel = getattr(self.mapviewer, 'sculpt_panel', None) if panel is not None and current_action == 'sculpt': mode_name = btn.icon[len('sculpt_'):] from .wolf_sculpt import SculptMode try: is_active = (panel.brush.mode == SculptMode(mode_name)) except ValueError: pass if is_active: glColor4f(*self._BG_ACTIVE) elif is_hover: glColor4f(*self._BG_HOVER) else: glColor4f(0.18, 0.18, 0.20, 0.60) glBegin(GL_QUADS) glVertex2f(bx0, by0); glVertex2f(bx1, by0) glVertex2f(bx1, by1); glVertex2f(bx0, by1) glEnd() # Border if is_active: glColor4f(0.3, 0.6, 1.0, 1.0) elif is_hover: glColor4f(*self._BORDER) else: glColor4f(0.35, 0.35, 0.38, 0.50) glLineWidth(1.0) glBegin(GL_LINES) for a, b in [((bx0, by0), (bx1, by0)), ((bx1, by0), (bx1, by1)), ((bx1, by1), (bx0, by1)), ((bx0, by1), (bx0, by0))]: glVertex2f(*a); glVertex2f(*b) glEnd() self._draw_button_icon(btn, bx0, by0, bx1, by1, is_active, w, h) # Separators self._draw_separators(buttons, btn_rects, x0, bar_w, glColor4f, glLineWidth, glBegin, glEnd, glVertex2f, GL_LINES) # Tooltip if self._hover_btn is not None and self._hover_btn < len(real_buttons): btn = real_buttons[self._hover_btn] rect = btn_rects[self._hover_btn] self._draw_tooltip(btn.label, rect, w, h, glColor4f, glBegin, glEnd, glVertex2f, GL_QUADS)
[docs] def _draw_button_icon(self, btn, bx0, by0, bx1, by1, is_active, w, h): """Draw the icon from the texture atlas, centred in the button.""" try: from .opengl.toolbar_icons import IconAtlas except Exception: return atlas = IconAtlas.get_instance() inset = 3 color = self._TEXT_ACTIVE if is_active else self._TEXT atlas.draw_icon(btn.icon, bx0 + inset, by0 + inset, bx1 - inset, by1 - inset, color)
[docs] def _draw_separators(self, buttons, btn_rects, x0, bar_w, glColor4f, glLineWidth, glBegin, glEnd, glVertex2f, GL_LINES): """Draw horizontal separator lines between button groups.""" glColor4f(*self._SEP_COLOR) glLineWidth(1.0) real_idx = 0 prev_rect = None for btn in buttons: if btn is None: if prev_rect is not None and real_idx < len(btn_rects): next_rect = btn_rects[real_idx] mid_y = (prev_rect[1] + next_rect[3]) * 0.5 sx0 = x0 + self._BAR_PADDING sx1 = x0 + bar_w - self._BAR_PADDING glBegin(GL_LINES) glVertex2f(sx0, mid_y); glVertex2f(sx1, mid_y) glEnd() else: if real_idx < len(btn_rects): prev_rect = btn_rects[real_idx] real_idx += 1
[docs] def _draw_tooltip(self, text, btn_rect, w, h, glColor4f, glBegin, glEnd, glVertex2f, GL_QUADS): """Draw a tooltip to the right of a button.""" bx1 = btn_rect[2] by0 = btn_rect[1] by1 = btn_rect[3] _fs = float(self.mapviewer.toolbar_tooltip_font_size) tw = max(len(text) * _fs * 0.64, 40.0) th = _fs * 1.6 tip_x0 = bx1 + 6 tip_y0 = (by0 + by1) * 0.5 - th * 0.5 tip_x1 = tip_x0 + tw + 10 tip_y1 = tip_y0 + th glColor4f(*self._TOOLTIP_BG) glBegin(GL_QUADS) glVertex2f(tip_x0, tip_y0); glVertex2f(tip_x1, tip_y0) glVertex2f(tip_x1, tip_y1); glVertex2f(tip_x0, tip_y1) glEnd() try: from .opengl.text_renderer2d import TextRenderer2D except Exception: return if self._text_renderer is None: self._text_renderer = TextRenderer2D.get_instance() mvp = self.mapviewer.get_ortho_mvp_c_contiguous() if mvp is None: return mv = self.mapviewer dx = float(mv.xmax - mv.xmin) dy = float(mv.ymax - mv.ymin) viewport = (int(w), int(h)) tx = float(mv.xmin) + ((tip_x0 + 5) / w) * dx # Anchor is at ascender (top of text); shift up by half font height # so the text is visually centred in the tooltip rectangle. ty = float(mv.ymin) + (((tip_y0 + tip_y1) * 0.5 + _fs * 0.45) / h) * dy try: self._text_renderer.draw_text( text, tx, ty, mvp, viewport, font_size=_fs, color=self._TOOLTIP_TEXT, size_in_pixels=True, alignment='left', glow_enabled=False) except Exception: pass
[docs] class PaletteOverlay: """On-canvas HUD for interactive colour-palette editing. Draws a horizontal gradient bar at the top of the GL canvas showing the active array's palette. Triangular cursors mark the *values* at each colour stop and can be dragged to reshape the palette. Only active while toggled on (key ``K``). """ # Layout constants (pixels)
[docs] _MARGIN = 20
[docs] _BAR_HEIGHT = 24
[docs] _CURSOR_H = 12 # height of the triangle below the bar
[docs] _LABEL_FONT_SIZE = 11
[docs] _LABEL_GAP = 4 # gap between bar and label text
[docs] _MIN_CURSOR_SEP = 3 # min pixel distance between adjacent cursors
[docs] _HANDLE_W = 10 # half-width of the min/max drag handles
# Colours
[docs] _BG_RGBA = (0.12, 0.12, 0.12, 0.55)
[docs] _OUTLINE_RGBA = (0.8, 0.8, 0.8, 0.8)
[docs] _CURSOR_RGBA = (1.0, 1.0, 1.0, 0.9)
[docs] _CURSOR_ACTIVE_RGBA = (1.0, 0.85, 0.0, 1.0)
[docs] _LABEL_COLOR = (0.95, 0.93, 0.80, 1.0) # same as toolbar tooltip text
[docs] _LABEL_BG = (0.08, 0.08, 0.10, 0.90) # same as toolbar tooltip bg
def __init__(self, mapviewer: "WolfMapViewer"):
[docs] self.mapviewer = mapviewer
[docs] self._dragging: int | None = None # index into mypal.values, or -1 for min, -2 for max
[docs] self._hover: int | None = None # same encoding, for highlight
[docs] self._hover_gl_x: float | None = None # GL x-coord of mouse when hovering
[docs] self._text_renderer = None
# ------------------------------------------------------------------ # Palette access # ------------------------------------------------------------------
[docs] def _get_palette_owner(self): """Return the selected object that owns a palette, or *None*. Returns a WolfArray or Wolfresults_2D picked from ``mapviewer.selected_object``. """ so = self.mapviewer.selected_object if so is not None: if isinstance(so, (WolfArray, Wolfresults_2D)): return so return None
[docs] def _get_palette(self): """Return the active array's or active 2D result's palette, or *None*.""" owner = self._get_palette_owner() if owner is not None: return getattr(owner, 'mypal', None) return None
[docs] def _iter_underlying_arrays(self): """Yield every WolfArray that backs the current palette owner. For a plain WolfArray owner, yields just that single array. For a WolfArrayMB owner, yields the parent *and* every sub-block. For a Wolfresults_2D owner, yields the ``_current`` WolfArray of every block via ``iter_current_arrays()``. """ owner = self._get_palette_owner() if owner is None: return if isinstance(owner, Wolfresults_2D): yield from owner.iter_current_arrays() elif isinstance(owner, WolfArrayMB): yield owner for blk in owner.myblocks.values(): yield blk elif isinstance(owner, WolfArray): yield owner
# ------------------------------------------------------------------ # Geometry helpers (GL pixel coords, origin bottom-left) # ------------------------------------------------------------------
[docs] def _bar_rect(self): """Return (x0, y_bottom, x1, y_top) for the gradient bar.""" w, h = self.mapviewer.canvas.GetClientSize() # Start after the toolbar indicator zone to avoid overlap tb = getattr(self.mapviewer, '_toolbar_overlay', None) left_margin = self._MARGIN if tb is not None: left_margin = max(left_margin, tb._INDICATOR_X + tb._INDICATOR_HIT_W + 8) x0 = left_margin x1 = w - self._MARGIN y1 = h - self._MARGIN y0 = y1 - self._BAR_HEIGHT return x0, y0, x1, y1
[docs] def _value_to_x(self, val, pal, x0, x1): """Map a palette value to a pixel x-coordinate.""" vmin, vmax = float(pal.values[0]), float(pal.values[-1]) if abs(vmax - vmin) < 1e-30: return (x0 + x1) * 0.5 frac = (float(val) - vmin) / (vmax - vmin) return x0 + frac * (x1 - x0)
[docs] def _x_to_value(self, px_x, pal, x0, x1): """Map a pixel x-coordinate back to a palette value.""" vmin, vmax = float(pal.values[0]), float(pal.values[-1]) frac = (px_x - x0) / max(x1 - x0, 1) frac = max(0.0, min(1.0, frac)) return vmin + frac * (vmax - vmin)
# ------------------------------------------------------------------ # Coordinate conversion # ------------------------------------------------------------------
[docs] def _wx_to_gl(self, wx_x, wx_y): __, h = self.mapviewer.canvas.GetClientSize() return wx_x, h - wx_y
# ------------------------------------------------------------------ # Hit testing (input: wx pixel coords, origin top-left) # ------------------------------------------------------------------
[docs] def is_inside(self, wx_x, wx_y): """Return *True* if the wx position is inside the overlay zone.""" gx, gy = self._wx_to_gl(wx_x, wx_y) x0, y0, x1, y1 = self._bar_rect() return (y0 - self._CURSOR_H - 8 <= gy <= y1 + 10 and x0 - self._HANDLE_W - 4 <= gx <= x1 + self._HANDLE_W + 4)
[docs] def on_mouse_move(self, wx_x, wx_y): """Track hover position. Returns *True* if mouse is over the bar.""" gx, gy = self._wx_to_gl(wx_x, wx_y) x0, y0, x1, y1 = self._bar_rect() if x0 <= gx <= x1 and y0 - self._CURSOR_H - 8 <= gy <= y1 + 10: self._hover_gl_x = gx self.mapviewer.Refresh() return True if self._hover_gl_x is not None: self._hover_gl_x = None self.mapviewer.Refresh() return False
[docs] def hit_test(self, wx_x, wx_y): """Return cursor index, ``-1`` (min handle), ``-2`` (max handle), or *None*.""" pal = self._get_palette() if pal is None or pal.nb < 2: return None gx, gy = self._wx_to_gl(wx_x, wx_y) x0, y0, x1, y1 = self._bar_rect() # Only react in the vertical band around the bar + cursors if gy < y0 - self._CURSOR_H - 8 or gy > y1 + 10: return None if gx < x0 - self._HANDLE_W - 4 or gx > x1 + self._HANDLE_W + 4: return None # Check individual cursor triangles (below the bar) best_dist = 999.0 best_idx = None for i in range(pal.nb): cx = self._value_to_x(pal.values[i], pal, x0, x1) dist = abs(gx - cx) if dist < max(8.0, self._HANDLE_W) and dist < best_dist: best_dist = dist best_idx = i if best_idx is not None: # -1 encodes min (index 0), -2 encodes max (last index) if best_idx == 0: return -1 elif best_idx == pal.nb - 1: return -2 return best_idx return None
# ------------------------------------------------------------------ # Drag handling # ------------------------------------------------------------------
[docs] def on_drag(self, wx_x, wx_y): """Update palette values based on drag position.""" import numpy as np pal = self._get_palette() if pal is None or pal.nb < 2 or self._dragging is None: return gx, gy = self._wx_to_gl(wx_x, wx_y) x0, y0, x1, y1 = self._bar_rect() new_val = self._x_to_value(gx, pal, x0, x1) idx = self._dragging if idx == -1: # Drag min: shift values[0], clamp below values[1] new_val = min(new_val, float(pal.values[1]) - 1e-6) old_min = float(pal.values[0]) pal.values[0] = new_val # If automatic, disable it so manual changes stick pal.automatic = False elif idx == -2: # Drag max: shift values[-1], clamp above values[-2] new_val = max(new_val, float(pal.values[-2]) + 1e-6) pal.values[-1] = new_val pal.automatic = False else: # Interior cursor: clamp between neighbours lo = float(pal.values[idx - 1]) + 1e-6 if idx > 0 else -1e30 hi = float(pal.values[idx + 1]) - 1e-6 if idx < pal.nb - 1 else 1e30 new_val = max(lo, min(hi, new_val)) pal.values[idx] = new_val pal.automatic = False self._apply_palette_change()
# ------------------------------------------------------------------ # OpenGL drawing # ------------------------------------------------------------------
[docs] def draw(self): """Draw the palette bar + cursors + labels as a pixel-space HUD.""" import math from OpenGL.GL import (glMatrixMode, glPushMatrix, glPopMatrix, glLoadIdentity, glOrtho, glDisable, glEnable, glIsEnabled, glBlendFunc, glColor4f, glColor3f, glLineWidth, glBegin, glEnd, glVertex2f, glPolygonMode, GL_PROJECTION, GL_MODELVIEW, GL_DEPTH_TEST, GL_BLEND, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_TRIANGLE_FAN, GL_TRIANGLES, GL_LINE_LOOP, GL_LINES, GL_QUADS, GL_FRONT_AND_BACK, GL_FILL) pal = self._get_palette() if pal is None or pal.nb < 2: return w, h = self.mapviewer.canvas.GetClientSize() if w <= 0 or h <= 0: return # Switch to pixel ortho glMatrixMode(GL_PROJECTION) glPushMatrix() glLoadIdentity() glOrtho(0, w, 0, h, -1, 1) glMatrixMode(GL_MODELVIEW) glPushMatrix() glLoadIdentity() _depth_was_on = glIsEnabled(GL_DEPTH_TEST) glDisable(GL_DEPTH_TEST) glEnable(GL_BLEND) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) x0, y0, x1, y1 = self._bar_rect() # --- Background --- pad = 6 glColor4f(*self.mapviewer.overlay_bg_color) glBegin(GL_QUADS) glVertex2f(x0 - pad, y0 - self._CURSOR_H - pad) glVertex2f(x1 + pad, y0 - self._CURSOR_H - pad) glVertex2f(x1 + pad, y1 + pad) glVertex2f(x0 - pad, y1 + pad) glEnd() # --- Gradient bar --- self._draw_gradient_bar(pal, x0, y0, x1, y1, glColor4f, glColor3f, glBegin, glEnd, glVertex2f, GL_QUADS) # --- Bar outline --- glColor4f(*self._OUTLINE_RGBA) glLineWidth(1.5) glBegin(GL_LINE_LOOP) glVertex2f(x0, y0); glVertex2f(x1, y0) glVertex2f(x1, y1); glVertex2f(x0, y1) glEnd() # --- Cursor triangles --- self._draw_cursors(pal, x0, y0, x1, y1, glColor4f, glBegin, glEnd, glVertex2f, GL_TRIANGLES) # --- Labels (min/max values) --- self._draw_labels(pal, x0, y0, x1, y1, w, h) # --- Hover value indicator --- self._draw_hover_value(pal, x0, y0, x1, y1, w, h, glColor4f, glLineWidth, glBegin, glEnd, glVertex2f, GL_LINES) # Restore if _depth_was_on: glEnable(GL_DEPTH_TEST) glMatrixMode(GL_PROJECTION) glPopMatrix() glMatrixMode(GL_MODELVIEW) glPopMatrix() glLineWidth(1.0)
[docs] def _draw_gradient_bar(self, pal, x0, y0, x1, y1, glColor4f, glColor3f, glBegin, glEnd, glVertex2f, GL_QUADS): """Draw the colour gradient (or piecewise-constant blocks).""" nb = pal.nb if nb < 2: return for i in range(nb - 1): lx = self._value_to_x(pal.values[i], pal, x0, x1) rx = self._value_to_x(pal.values[i + 1], pal, x0, x1) c0 = pal.colorsflt[i] c1 = pal.colorsflt[i + 1] if not pal.interval_cst else c0 glBegin(GL_QUADS) glColor4f(float(c0[0]), float(c0[1]), float(c0[2]), 1.0) glVertex2f(lx, y0) glVertex2f(lx, y1) glColor4f(float(c1[0]), float(c1[1]), float(c1[2]), 1.0) glVertex2f(rx, y1) glVertex2f(rx, y0) glEnd()
[docs] def _draw_cursors(self, pal, x0, y0, x1, y1, glColor4f, glBegin, glEnd, glVertex2f, GL_TRIANGLES): """Draw triangular cursors below the bar for each colour stop.""" ch = self._CURSOR_H hw = 5 # half-width of triangle base for i in range(pal.nb): cx = self._value_to_x(pal.values[i], pal, x0, x1) # Highlight active/hovered cursor is_active = False if self._dragging is not None: if self._dragging == -1 and i == 0: is_active = True elif self._dragging == -2 and i == pal.nb - 1: is_active = True elif self._dragging == i: is_active = True if is_active: glColor4f(*self._CURSOR_ACTIVE_RGBA) else: glColor4f(*self._CURSOR_RGBA) # Triangle pointing up, tip touching the bar bottom glBegin(GL_TRIANGLES) glVertex2f(cx, y0) # tip glVertex2f(cx - hw, y0 - ch) # bottom-left glVertex2f(cx + hw, y0 - ch) # bottom-right glEnd()
[docs] def _draw_labels(self, pal, x0, y0, x1, y1, w, h): """Draw min/max value labels with tooltip-style background.""" from OpenGL.GL import (glColor4f, glBegin, glEnd, glVertex2f, GL_QUADS) try: from .opengl.text_renderer2d import TextRenderer2D except Exception: return if self._text_renderer is None: self._text_renderer = TextRenderer2D.get_instance() mvp = self.mapviewer.get_ortho_mvp_c_contiguous() if mvp is None: return mv = self.mapviewer dx = float(mv.xmax - mv.xmin) dy = float(mv.ymax - mv.ymin) viewport = (int(w), int(h)) _fs = float(self.mapviewer.toolbar_tooltip_font_size) bar_cy = (y0 + y1) * 0.5 def _gl_to_world(px, py): return (float(mv.xmin) + (float(px) / float(w)) * dx, float(mv.ymin) + (float(py) / float(h)) * dy) for text, gl_x, alignment in ( (f'{pal.values[0]:.4g}', x0, 'left'), (f'{pal.values[-1]:.4g}', x1, 'right'), ): tw = max(len(text) * _fs * 0.64, 20.0) th = _fs * 1.6 pad = 4 if alignment == 'left': bx0 = gl_x + pad bx1 = bx0 + tw + 2 * pad else: bx1 = gl_x - pad bx0 = bx1 - tw - 2 * pad by0 = bar_cy - th * 0.5 by1 = bar_cy + th * 0.5 # Background rectangle glColor4f(*self._LABEL_BG) glBegin(GL_QUADS) glVertex2f(bx0, by0); glVertex2f(bx1, by0) glVertex2f(bx1, by1); glVertex2f(bx0, by1) glEnd() # Text tx, ty = _gl_to_world((bx0 + bx1) * 0.5, bar_cy) try: self._text_renderer.draw_text( text, tx, ty, mvp, viewport, font_size=_fs, color=self._LABEL_COLOR, size_in_pixels=True, alignment='center', vertical_alignment='center', glow_enabled=False, ) except Exception: pass
[docs] def _draw_hover_value(self, pal, x0, y0, x1, y1, w, h, glColor4f, glLineWidth, glBegin, glEnd, glVertex2f, GL_LINES): """Draw a vertical hair-line + tooltip-style value label at hover.""" from OpenGL.GL import GL_QUADS if self._hover_gl_x is None: return gx = self._hover_gl_x if gx < x0 or gx > x1: return val = self._x_to_value(gx, pal, x0, x1) # Vertical indicator line through the bar (white, always visible) glColor4f(1.0, 1.0, 1.0, 0.7) glLineWidth(1.0) glBegin(GL_LINES) glVertex2f(gx, y0) glVertex2f(gx, y1) glEnd() # Tooltip-style value label text = f'{val:.4g}' try: from .opengl.text_renderer2d import TextRenderer2D except Exception: return if self._text_renderer is None: self._text_renderer = TextRenderer2D.get_instance() mvp = self.mapviewer.get_ortho_mvp_c_contiguous() if mvp is None: return mv = self.mapviewer dx = float(mv.xmax - mv.xmin) dy = float(mv.ymax - mv.ymin) viewport = (int(w), int(h)) _fs = float(self.mapviewer.toolbar_tooltip_font_size) bar_cy = (y0 + y1) * 0.5 tw = max(len(text) * _fs * 0.64, 20.0) th = _fs * 1.6 pad = 4 # Centre the tooltip on the cursor; push away from edges bx0 = gx - (tw + 2 * pad) * 0.5 bx1 = gx + (tw + 2 * pad) * 0.5 if bx0 < x0 + pad: bx0 = x0 + pad bx1 = bx0 + tw + 2 * pad elif bx1 > x1 - pad: bx1 = x1 - pad bx0 = bx1 - tw - 2 * pad by0 = bar_cy - th * 0.5 by1 = bar_cy + th * 0.5 # Background rectangle glColor4f(*self._LABEL_BG) glBegin(GL_QUADS) glVertex2f(bx0, by0); glVertex2f(bx1, by0) glVertex2f(bx1, by1); glVertex2f(bx0, by1) glEnd() # Text cx = (bx0 + bx1) * 0.5 tx = float(mv.xmin) + (float(cx) / float(w)) * dx ty = float(mv.ymin) + (float(bar_cy) / float(h)) * dy try: self._text_renderer.draw_text( text, tx, ty, mvp, viewport, font_size=_fs, color=self._LABEL_COLOR, size_in_pixels=True, alignment='center', vertical_alignment='center', glow_enabled=False, ) except Exception: pass
# ------------------------------------------------------------------ # Double-click: input exact value # ------------------------------------------------------------------
[docs] def on_double_click(self, wx_x, wx_y): """Open a dialog to type an exact value for the clicked cursor.""" import wx as _wx hit = self.hit_test(wx_x, wx_y) if hit is None: return False pal = self._get_palette() if pal is None or pal.nb < 2: return False # Map hit index to real palette index if hit == -1: idx = 0 elif hit == -2: idx = pal.nb - 1 else: idx = hit current_val = float(pal.values[idx]) dlg = _wx.TextEntryDialog( self.mapviewer, _('Value for colour stop #{}').format(idx), _('Palette value'), f'{current_val:.6g}') if dlg.ShowModal() == _wx.ID_OK: try: new_val = float(dlg.GetValue()) except ValueError: dlg.Destroy() return True # Clamp between neighbours lo = float(pal.values[idx - 1]) + 1e-6 if idx > 0 else -1e30 hi = float(pal.values[idx + 1]) - 1e-6 if idx < pal.nb - 1 else 1e30 new_val = max(lo, min(hi, new_val)) pal.values[idx] = new_val pal.automatic = False self._apply_palette_change() dlg.Destroy() return True
# ------------------------------------------------------------------ # Right-click: context menu # ------------------------------------------------------------------
[docs] def on_right_click(self, wx_x, wx_y): """Show context menu if click is inside the overlay area.""" import wx as _wx # Only react inside the overlay zone gx, gy = self._wx_to_gl(wx_x, wx_y) x0, y0, x1, y1 = self._bar_rect() if gy < y0 - self._CURSOR_H - 20 or gy > y1 + 10: return False if gx < x0 - 10 or gx > x1 + 10: return False pal = self._get_palette() if pal is None or pal.nb < 2: return False menu = _wx.Menu() id_distribute = _wx.NewIdRef() id_auto = _wx.NewIdRef() id_auto_zoom = _wx.NewIdRef() id_input_minmax = _wx.NewIdRef() menu.Append(id_distribute, _('Distribute evenly')) menu.Append(id_input_minmax, _('Set min / max ...')) menu.AppendSeparator() menu.Append(id_auto, _('Auto-distribute (full extent)')) menu.Append(id_auto_zoom, _('Auto-distribute (current zoom)')) def _on_distribute(evt): vmin, vmax = float(pal.values[0]), float(pal.values[-1]) import numpy as np pal.values = np.linspace(vmin, vmax, pal.nb) pal.automatic = False self._apply_palette_change() def _on_auto(evt): pal.automatic = True owner = self._get_palette_owner() if owner is not None: owner.updatepalette(which=0) for ua in self._iter_underlying_arrays(): shader = getattr(ua, '_shader_2d', None) if shader is not None: shader.invalidate_palette() self.mapviewer.Refresh() def _on_auto_zoom(evt): pal.automatic = True mv = self.mapviewer owner = self._get_palette_owner() if owner is not None: onzoom = [float(mv.xmin), float(mv.xmax), float(mv.ymin), float(mv.ymax)] owner.updatepalette(which=0, onzoom=onzoom) pal.automatic = False # keep the result, don't auto-reset on next paint for ua in self._iter_underlying_arrays(): shader = getattr(ua, '_shader_2d', None) if shader is not None: shader.invalidate_palette() mv.Refresh() def _on_input_minmax(evt): cur_min = float(pal.values[0]) cur_max = float(pal.values[-1]) dlg = _wx.TextEntryDialog( self.mapviewer, _('Min value'), _('Palette range'), f'{cur_min:.6g}') if dlg.ShowModal() != _wx.ID_OK: dlg.Destroy() return try: new_min = float(dlg.GetValue()) except ValueError: dlg.Destroy() return dlg.Destroy() dlg2 = _wx.TextEntryDialog( self.mapviewer, _('Max value'), _('Palette range'), f'{cur_max:.6g}') if dlg2.ShowModal() != _wx.ID_OK: dlg2.Destroy() return try: new_max = float(dlg2.GetValue()) except ValueError: dlg2.Destroy() return dlg2.Destroy() if new_min >= new_max: return import numpy as np pal.values = np.linspace(new_min, new_max, pal.nb) pal.automatic = False self._apply_palette_change() self.mapviewer.Bind(_wx.EVT_MENU, _on_distribute, id=id_distribute) self.mapviewer.Bind(_wx.EVT_MENU, _on_auto, id=id_auto) self.mapviewer.Bind(_wx.EVT_MENU, _on_auto_zoom, id=id_auto_zoom) self.mapviewer.Bind(_wx.EVT_MENU, _on_input_minmax, id=id_input_minmax) # -- Discrete / continuous toggle ------------------------------- id_discrete = _wx.NewIdRef() item_discrete = menu.AppendCheckItem(id_discrete, _('Discrete palette')) item_discrete.Check(bool(pal.interval_cst)) def _on_toggle_discrete(evt): pal.interval_cst = not pal.interval_cst self._apply_palette_change() self.mapviewer.Bind(_wx.EVT_MENU, _on_toggle_discrete, id=id_discrete) # -- Domain-specific preset palettes submenu -------------------- sub_presets = _wx.Menu() for label, preset_key in [ (_('Water (depths)'), 'water'), (_('Terrain / DEM'), 'terrain'), (_('Velocity'), 'velocity'), (_('Difference'), 'difference'), (_('Bathymetry'), 'bathymetry'), ]: pid = _wx.NewIdRef() sub_presets.Append(pid, label) self.mapviewer.Bind( _wx.EVT_MENU, lambda evt, k=preset_key: self._apply_preset(k), id=pid, ) menu.AppendSeparator() menu.AppendSubMenu(sub_presets, _('Domain palettes')) # -- Model palette files submenu -------------------------------- sub_models = _wx.Menu() import os as _os models_dir = _os.path.join(_os.path.dirname(__file__), 'models') if _os.path.isdir(models_dir): pal_files = sorted( f for f in _os.listdir(models_dir) if f.endswith('.pal') ) for pf in pal_files: mid = _wx.NewIdRef() nice = _os.path.splitext(pf)[0].replace('_', ' ') sub_models.Append(mid, nice) self.mapviewer.Bind( _wx.EVT_MENU, lambda evt, fn=pf: self._apply_model_file(fn), id=mid, ) menu.AppendSubMenu(sub_models, _('Model palettes')) self.mapviewer.PopupMenu(menu) menu.Destroy() return True
# ------------------------------------------------------------------ # Preset & model palette helpers # ------------------------------------------------------------------ #: Domain-specific palette definitions. #: Each entry: (values, colours_RGB, interval_cst, #: optional dict of hillshade overrides)
[docs] _PRESETS: dict = { 'water': ( [0.0, 0.02, 0.15, 0.30, 0.50, 0.75, 1.5, 2.5, 5.0], [ (220, 240, 255), # very light blue-white (180, 215, 255), # light sky (130, 190, 255), # sky blue (70, 150, 240), # medium blue (30, 110, 220), # blue (15, 75, 190), # deeper (5, 50, 150), # deep (0, 30, 120), # very deep (0, 15, 80), # darkest ], True, {'glossiness': 0.85, 'highlight': 0.2, 'specular': 0.8}, ), 'terrain': ( [0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0], [ (60, 80, 60), # dark green-gray (valleys) (90, 100, 80), # olive gray (130, 130, 110), # warm gray (160, 155, 135), # light warm gray (185, 180, 165), # sandy (200, 195, 180), # light sand (215, 210, 200), # very light (230, 228, 220), # near white (245, 243, 240), # off-white ], False, {'glossiness': 0.05, 'highlight': 0.9}, ), 'velocity': ( [0.0, 0.10, 0.25, 0.50, 1.0, 2.0, 5.0], [ (255, 255, 200), # pale yellow (255, 240, 120), # yellow (255, 200, 50), # golden (255, 150, 30), # orange (240, 80, 20), # red-orange (200, 30, 15), # red (120, 10, 30), # dark red ], False, None, ), 'difference': ( [-10.0, -5.0, -1.0, 0.0, 1.0, 5.0, 10.0], [ (5, 30, 150), # dark blue (30, 100, 220), # blue (150, 200, 255), # light blue (255, 255, 255), # white (255, 200, 150), # salmon (220, 80, 30), # red-orange (150, 20, 5), # dark red ], False, None, ), 'bathymetry': ( [-50.0, -20.0, -10.0, -5.0, -2.0, -1.0, 0.0], [ (0, 10, 50), # abyss (5, 30, 100), # deep (15, 60, 150), # medium deep (40, 100, 190), # blue (80, 150, 220), # medium (140, 200, 240), # light blue (200, 230, 255), # near surface ], False, None, ), }
[docs] def _apply_preset(self, key: str): """Apply a domain-specific preset palette to the active array.""" preset = self._PRESETS.get(key) if preset is None: return values, colors, interval_cst, hillshade = preset pal = self._get_palette() if pal is None: return pal.set_values_colors(list(values), list(colors)) pal.interval_cst = interval_cst pal.automatic = False # Optionally adjust hillshade rendering parameters if hillshade is not None: for aa in self._iter_underlying_arrays(): if hasattr(aa, 'hillshade_params'): hp = aa.hillshade_params for attr, val in hillshade.items(): if hasattr(hp, attr): setattr(hp, attr, val) self._apply_palette_change()
[docs] def _apply_model_file(self, filename: str): """Load a .pal file from wolfhece/models/ into the active palette.""" import os pal = self._get_palette() if pal is None: return models_dir = os.path.join(os.path.dirname(__file__), 'models') path = os.path.join(models_dir, filename) if os.path.isfile(path): pal.readfile(path) pal.automatic = False self._apply_palette_change()
# ------------------------------------------------------------------ # Shared palette update helper # ------------------------------------------------------------------
[docs] def _apply_palette_change(self): """Rebuild palette internals + invalidate GPU cache + refresh.""" pal = self._get_palette() if pal is not None: pal.fill_segmentdata() owner = self._get_palette_owner() if owner is not None: owner.updatepalette(which=0) for ua in self._iter_underlying_arrays(): shader = getattr(ua, '_shader_2d', None) if shader is not None: shader.invalidate_palette() self.mapviewer.Refresh()