"""
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]
_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)
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)
# ======================================================================
# ======================================================================
# 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).
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 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]
_CURSOR_H = 12 # height of the triangle below the bar
[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()