"""
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.
"""
import numpy as np
import math
import logging
import io
import importlib
from pathlib import Path
from OpenGL.GL import *
import wx
import wx.propgrid as pg
import wx.dataview as dv
from wx.dataview import TreeListCtrl, EVT_TREELIST_ITEM_CHECKED, EVT_TREELIST_ITEM_ACTIVATED, EVT_TREELIST_ITEM_CONTEXT_MENU, TL_CHECKBOX, TreeListEvent
from ..PyParams import Wolf_Param, key_Param, Type_Param, new_json, PREFIX_DEFAULT
from ..PyTranslate import _
from ..wolf_texture import Text_Image_Texture
from ..textpillow import Text_Infos
from ..drawing_obj import Element_To_Draw
from ..color_constants import getRGBfromI, getIfromRGB
from ._model import (wolfvertex, cloud_vertices as cloud_vertices_model,
cloudproperties as cloudproperties_model,
cloud_of_clouds as cloud_of_clouds_model)
from ..opengl.text_renderer2d import TextRenderer2D, GlyphAtlas, measure_text
from ..opengl.cloud_points_shader2d import CloudPointsShader2D
from ..CpGrid import CpGrid
from PIL import Image
from enum import Enum
[docs]
class Cloud_Styles(Enum):
"""Rendering styles for a cloud of vertices in OpenGL.
Each member maps to an integer used by :class:`cloudproperties` ``style``
attribute and the :meth:`cloud_vertices.plot` method.
"""
[docs]
class Cloud_OGLRenderer(Enum):
"""Rendering backend for cloud vertices."""
[docs]
class Cloud_AnimationMode(Enum):
"""Animation modes for cloud rendering."""
[docs]
def circle(x: float, y: float, r: float, numseg: int = 20):
"""Draw a filled circle using an OpenGL polygon.
:param x: X coordinate of the center.
:param y: Y coordinate of the center.
:param r: radius of the circle (in map units).
:param numseg: number of segments used to approximate the circle.
"""
glBegin(GL_POLYGON)
for ii in range(numseg + 1):
theta = 2.0 * 3.1415926 * float(ii) / float(numseg)
x1 = r * math.cos(theta)
y1 = r * math.sin(theta)
glVertex2f(x + x1, y + y1)
glEnd()
[docs]
def circle_outline(x: float, y: float, r: float, numseg: int = 20):
"""Draw a hollow circle using an OpenGL line loop."""
glBegin(GL_LINE_LOOP)
for ii in range(numseg):
theta = 2.0 * 3.1415926 * float(ii) / float(numseg)
x1 = r * math.cos(theta)
y1 = r * math.sin(theta)
glVertex2f(x + x1, y + y1)
glEnd()
[docs]
def cross(x: float, y: float, r: float):
"""Draw a cross (two perpendicular lines) using OpenGL.
:param x: X coordinate of the center.
:param y: Y coordinate of the center.
:param r: half-length of each arm (in map units).
"""
glBegin(GL_LINES)
glVertex2f(x - r, y)
glVertex2f(x + r, y)
glEnd()
glBegin(GL_LINES)
glVertex2f(x, y - r)
glVertex2f(x, y + r)
glEnd()
[docs]
def quad(x: float, y: float, r: float):
"""Draw a filled square (quad) using OpenGL.
:param x: X coordinate of the center.
:param y: Y coordinate of the center.
:param r: half-side length of the square (in map units).
"""
glBegin(GL_QUADS)
glVertex2f(x - r, y - r)
glVertex2f(x + r, y - r)
glVertex2f(x + r, y + r)
glVertex2f(x - r, y + r)
glEnd()
[docs]
class cloudproperties(cloudproperties_model):
"""Cloud properties with wx GUI support.
Extends :class:`cloudproperties_model` with a ``Wolf_Param`` dialog
for interactive editing of drawing and legend parameters.
:ivar myprops: ``Wolf_Param`` dialog instance, ``None`` until
:meth:`defaultprop` or :meth:`show` is called.
"""
[docs]
myprops: Wolf_Param = None
[docs]
SYMBOLS_DIR = Path(__file__).resolve().parent.parent / 'symbols'
[docs]
CUSTOM_SYMBOL_CHOICE = '<custom file>'
[docs]
PACKAGE_SYMBOL_PROP_NAME = 'DrawPackage symbol'
[docs]
SYMBOL_SOURCE_PROP_NAME = 'DrawSymbol source'
[docs]
RESOLVED_SYMBOL_PROP_NAME = 'DrawResolved symbol'
@classmethod
[docs]
def _package_symbol_map(cls) -> dict[str, str]:
"""Return ``{display_label: relative_path}`` for bundled symbols."""
exts = ('.svg', '.png', '.jpg', '.jpeg', '.webp')
symbols: dict[str, str] = {cls.CUSTOM_SYMBOL_CHOICE: ''}
if cls.SYMBOLS_DIR.exists():
for path in cls.SYMBOLS_DIR.rglob('*'):
if path.is_file() and path.suffix.lower() in exts:
rel = path.relative_to(cls.SYMBOLS_DIR).as_posix()
parts = rel.split('/')
if len(parts) > 1:
label = f'[{parts[0]}] ' + '/'.join(parts[1:])
else:
label = rel
symbols[label] = rel
return dict(sorted(symbols.items(), key=lambda item: (item[0] != cls.CUSTOM_SYMBOL_CHOICE, item[0].lower())))
@classmethod
[docs]
def _list_package_symbols(cls) -> list[str]:
"""Return bundled symbol choices formatted for GUI selection."""
return list(cls._package_symbol_map().keys())
@classmethod
[docs]
def _resolve_symbolpreset_to_relpath(cls, preset: str | None) -> str:
"""Resolve a GUI preset label or legacy relative path to a relative path."""
if not preset or preset == cls.CUSTOM_SYMBOL_CHOICE:
return ''
mapping = cls._package_symbol_map()
if preset in mapping:
return mapping[preset]
# Backward compatibility: old stored value may already be the relative path.
return preset
@classmethod
[docs]
def _infer_symbolpreset_from_source(cls, symbolsource: str | None) -> str:
"""Infer the bundled symbol preset from a source path when possible."""
if not symbolsource:
return cls.CUSTOM_SYMBOL_CHOICE
try:
path = Path(symbolsource).resolve()
rel = path.relative_to(cls.SYMBOLS_DIR.resolve()).as_posix()
for label, currel in cls._package_symbol_map().items():
if currel == rel:
return label
return cls.CUSTOM_SYMBOL_CHOICE
except Exception:
return cls.CUSTOM_SYMBOL_CHOICE
[docs]
def get_effective_symbol_source(self) -> str:
"""Return the actual symbol path used for rendering.
Package presets take priority. When no preset is selected, the custom
file path stored in ``symbolsource`` is used.
"""
rel = self._resolve_symbolpreset_to_relpath(getattr(self, 'symbolpreset', ''))
if rel:
return str(self.SYMBOLS_DIR / rel)
return getattr(self, 'symbolsource', '')
[docs]
def _effective_symbol_source_from_values(self, preset: str | None, symbolsource: str | None) -> str:
"""Resolve effective symbol path from explicit preset/source values."""
rel = self._resolve_symbolpreset_to_relpath(preset)
if rel:
return str(self.SYMBOLS_DIR / rel)
return symbolsource or ''
[docs]
def _make_placeholder_bitmap(self, message: str, size: int = 96) -> wx.Bitmap:
"""Build a neutral placeholder bitmap for missing/invalid previews."""
bmp = wx.Bitmap(size, size, 32)
dc = wx.MemoryDC(bmp)
dc.SetBackground(wx.Brush(wx.Colour(245, 245, 245)))
dc.Clear()
dc.SetPen(wx.Pen(wx.Colour(190, 190, 190), 1))
dc.SetBrush(wx.TRANSPARENT_BRUSH)
dc.DrawRectangle(0, 0, size, size)
dc.DrawLine(0, 0, size, size)
dc.DrawLine(0, size, size, 0)
dc.SetTextForeground(wx.Colour(90, 90, 90))
dc.DrawLabel(message, wx.Rect(6, 6, size - 12, size - 12), alignment=wx.ALIGN_CENTER)
dc.SelectObject(wx.NullBitmap)
return bmp
[docs]
def _load_symbol_preview_bitmap(self, symbol_path: str, size: int = 96) -> wx.Bitmap:
"""Load a symbol preview bitmap from raster image or SVG."""
if not symbol_path:
return self._make_placeholder_bitmap('No symbol', size=size)
path = Path(symbol_path)
if not path.exists():
return self._make_placeholder_bitmap('Missing\nfile', size=size)
try:
if path.suffix.lower() == '.svg':
try:
cairosvg = importlib.import_module('cairosvg')
except Exception:
return self._make_placeholder_bitmap('SVG\nneeds\ncairosvg', size=size)
png_bytes = cairosvg.svg2png(url=str(path), output_width=size, output_height=size)
with Image.open(io.BytesIO(png_bytes)) as src:
image = src.convert('RGBA')
else:
with Image.open(path) as src:
image = src.convert('RGBA')
image.thumbnail((size, size), Image.LANCZOS)
canvas = Image.new('RGBA', (size, size), (255, 255, 255, 0))
ox = (size - image.width) // 2
oy = (size - image.height) // 2
canvas.paste(image, (ox, oy), image)
return wx.Bitmap.FromBufferRGBA(size, size, canvas.tobytes())
except Exception:
return self._make_placeholder_bitmap('Preview\nerror', size=size)
[docs]
def _update_symbol_preview(self, symbol_path: str | None = None) -> None:
"""Refresh the preview panel with the currently selected symbol."""
if self.myprops is None or getattr(self.myprops, '_symbol_preview_panel', None) is None:
return
if symbol_path is None:
symbol_path = self.get_effective_symbol_source()
bitmap = self._load_symbol_preview_bitmap(symbol_path)
self.myprops._symbol_preview_bitmap.SetBitmap(bitmap)
info_text = self._format_preview_info(symbol_path)
self.myprops._symbol_preview_info.SetLabel(info_text)
self.myprops._symbol_preview_info.SetToolTip(symbol_path or '')
self.myprops._symbol_preview_info.Wrap(320)
try:
self.myprops._symbol_preview_updating = True
if self.myprops.prop.GetPropertyByName(self.RESOLVED_SYMBOL_PROP_NAME) is not None:
self.myprops.prop.SetPropertyValue(self.RESOLVED_SYMBOL_PROP_NAME, symbol_path or '')
finally:
self.myprops._symbol_preview_updating = False
self.myprops.Layout()
[docs]
def _on_symbol_property_changed(self, event) -> None:
"""Update symbol preview when package/custom symbol selection changes."""
if self.myprops is None or getattr(self.myprops, '_symbol_preview_updating', False):
event.Skip()
return
prop = event.GetProperty()
if prop is None:
event.Skip()
return
name = prop.GetName()
if name not in (self.PACKAGE_SYMBOL_PROP_NAME, self.SYMBOL_SOURCE_PROP_NAME):
event.Skip()
return
try:
preset = self.myprops.prop.GetPropertyValueAsString(self.PACKAGE_SYMBOL_PROP_NAME)
source = self.myprops.prop.GetPropertyValueAsString(self.SYMBOL_SOURCE_PROP_NAME)
self._update_symbol_preview(self._effective_symbol_source_from_values(preset, source))
except Exception:
pass
event.Skip()
[docs]
def defaultprop(self):
"""Create the ``Wolf_Param`` dialog and populate it with default values.
If the dialog already exists, it is reused. Parameters for drawing
style (color, width, style, transparency…) and legend (text, font,
position…) are added.
"""
if self.myprops is None:
self.myprops = Wolf_Param(title='Cloud Properties', to_read=False, force_even_if_same_default=True)
self.myprops.hide_selected_buttons()
self.myprops._callbackdestroy = self.destroyprop
self.myprops._callback = self.fill_property
self.myprops.saveme.Disable()
self.myprops.loadme.Disable()
self.myprops.reloadme.Disable()
self.myprops.addparam('Draw', 'Color', getRGBfromI(0), Type_Param.Color, 'Drawing color', whichdict='Default')
self.myprops.addparam('Draw', 'Width', 10, Type_Param.Float, 'Drawing width', whichdict='Default')
jsonstr = new_json({_('Point'):Cloud_Styles.POINT.value,
_('Circle'):Cloud_Styles.CIRCLE.value,
_('Cross'):Cloud_Styles.CROSS.value,
_('Quad'):Cloud_Styles.QUAD.value,
_('Symbol (shared image)'):Cloud_Styles.SYMBOL.value})
self.myprops.addparam('Draw', 'Style', 0, Type_Param.Integer, 'Drawing style', whichdict='Default', jsonstr=jsonstr)
jsonstr = new_json({_('Display list'): Cloud_OGLRenderer.LIST.value,
_('Shader'): Cloud_OGLRenderer.SHADER.value})
self.myprops.addparam('Draw', 'Rendering mode', Cloud_OGLRenderer.LIST.value,
Type_Param.Integer, 'OpenGL rendering backend',
whichdict='Default', jsonstr=jsonstr)
self.myprops.addparam('Draw', 'Filled', False, Type_Param.Logical, '', whichdict='Default')
self.myprops.addparam('Draw', 'Package symbol', self.CUSTOM_SYMBOL_CHOICE, Type_Param.Enum,
_('Bundled symbol from wolfhece/symbols (used before custom file if selected)'),
whichdict='Default', enum_choices=self._list_package_symbols())
self.myprops.addparam('Draw', 'Resolved symbol', '', Type_Param.String,
_('Effective symbol path used for rendering (informative)'),
whichdict='Default')
self.myprops.addparam('Draw', 'Symbol source', '', Type_Param.String,
_('Path to shared icon image (png/jpg/webp) used when style=Symbol'),
whichdict='Default')
self.myprops.addparam('Draw', 'Symbol raster size [px]', 128, Type_Param.Integer,
_('Target rasterization size for SVG symbols'), whichdict='Default')
self.myprops.addparam('Draw', 'Symbol rotation [\u00b0]', 0.0, Type_Param.Float,
_('Per-cloud symbol rotation in degrees (counter-clockwise)'), whichdict='Default')
self.myprops.addparam('Draw', 'Symbol scale', 1.0, Type_Param.Float,
_('Per-cloud symbol scale factor (1.0 = nominal size)'), whichdict='Default')
self.myprops.addparam('Draw', 'Tint symbol with color', False, Type_Param.Logical,
_('Multiply symbol texture by Draw color'), whichdict='Default')
self.myprops.addparam('Draw', 'Highlight selected point', True, Type_Param.Logical,
_('Highlight point selected by interactive cloud tools (move/pick)'), whichdict='Default')
self.myprops.addparam('Draw', 'Highlight size factor', 1.8, Type_Param.Float,
_('Scale factor applied to selected-point highlight marker size'), whichdict='Default')
self.myprops.addparam('Draw', 'Highlight color', (255, 220, 40), Type_Param.Color,
_('Color used for selected-point highlight marker'), whichdict='Default')
self.myprops.addparam('Draw', 'Transparent', False, Type_Param.Logical, '', whichdict='Default')
self.myprops.addparam('Draw', 'Alpha', 0, Type_Param.Integer, 'Transparent intensity', whichdict='Default')
self.myprops.addparam('Draw', 'Animation speed', 1.0, Type_Param.Float,
_('Animation speed multiplier (cycles per second)'), whichdict='Default')
self.myprops.addparam('Draw', 'Animation amplitude', 1.0, Type_Param.Float,
_('Animation intensity factor'), whichdict='Default')
jsonstr = new_json({_('None'): Cloud_AnimationMode.NONE.value,
_('Blink'): Cloud_AnimationMode.BLINK.value,
_('Fade'): Cloud_AnimationMode.FADE.value,
_('Grow'): Cloud_AnimationMode.GROW.value,
_('Seasons'): Cloud_AnimationMode.SEASONS.value,
_('Pulse'): Cloud_AnimationMode.PULSE.value})
self.myprops.addparam('Draw', 'Animation mode', Cloud_AnimationMode.NONE.value,
Type_Param.Integer, _('Cloud animation mode'),
whichdict='Default', jsonstr=jsonstr)
self.myprops.addparam('Legend', 'Visible', False, Type_Param.Logical, '', whichdict='Default')
self.myprops.addparam('Legend', 'Text', '', Type_Param.String, '', whichdict='Default')
#1--4--7
#| | |
#2--5--8
#| | |
#3--6--9
jsonstr = new_json({_('Left'): 2, _('Right'):8, _('Top'):4, _('Bottom'):6, _('Center'):5, _('Top left'):1, _('Top right'):7, _('Bottom left'):3, _('Bottom right'):9}, _('Relative position of the legend'))
self.myprops.addparam('Legend','Relative position',5,'Integer','',whichdict='Default', jsonstr=jsonstr)
self.myprops.addparam('Legend', 'X', 0, 'Float', '', whichdict='Default')
self.myprops.addparam('Legend', 'Y', 0, 'Float', '', whichdict='Default')
self.myprops.addparam('Legend', 'Bold', False, Type_Param.Logical, '', whichdict='Default')
self.myprops.addparam('Legend', 'Italic', False, Type_Param.Logical, '', whichdict='Default')
self.myprops.addparam('Legend', 'Font name', 'Arial', Type_Param.String, '', whichdict='Default')
self.myprops.addparam('Legend', 'Font size', 10, Type_Param.Integer, '', whichdict='Default')
self.myprops.addparam('Legend', 'Color', getRGBfromI(0), Type_Param.Color, '', whichdict='Default')
self.myprops.addparam('Legend', 'Underlined', False, Type_Param.Logical, '', whichdict='Default')
self.myprops.addparam('Legend', 'Width', 100, 'Integer', _('Width of the legend [m]'), whichdict='Default')
self.myprops.addparam('Legend', 'Height', 100, 'Integer', _('Height of the legend [m]'), whichdict='Default')
self.myprops.addparam('Legend', 'Orientation', 0, 'Integer', _('Orientation of the legend [Degree]'), whichdict='Default')
jsonstr = new_json({_('Width'): 1, _('Height'):2, _('Fontsize'):3}, _('Priority will be respected during the OpenGL plot rendering'))
self.myprops.addparam('Legend','Priority', 3 ,'Integer', whichdict='Default', jsonstr=jsonstr)
[docs]
def destroyprop(self):
"""Release the ``Wolf_Param`` dialog (set ``myprops`` to ``None``)."""
self.myprops = None
[docs]
def fill_property(self):
"""Read all current values from the ``Wolf_Param`` dialog into attributes.
Called automatically by ``Wolf_Param`` when the user validates
a parameter change. Also triggers an OpenGL refresh on the parent
cloud if a mapviewer is available.
"""
self.color = getIfromRGB(self.myprops[('Draw', 'Color')])
self.width = self.myprops[('Draw', 'Width')]
self.style = self.myprops[('Draw', 'Style')]
self.renderingmode = self.myprops[('Draw', 'Rendering mode')]
self.filled = self.myprops[('Draw', 'Filled')]
self.symbolpreset = self.myprops[('Draw', 'Package symbol')]
self.symbolsource = self.myprops[('Draw', 'Symbol source')]
self.symbolrastersize = self.myprops[('Draw', 'Symbol raster size [px]')]
self.symbolrotation = self.myprops[('Draw', 'Symbol rotation [\u00b0]')]
self.symbolscale = self.myprops[('Draw', 'Symbol scale')]
self.symboltintwithcolor = self.myprops[('Draw', 'Tint symbol with color')]
self.highlightselectedpoint = self.myprops[('Draw', 'Highlight selected point')]
self.highlightselectedpointsizefactor = self.myprops[('Draw', 'Highlight size factor')]
self.highlightselectedpointcolor = getIfromRGB(self.myprops[('Draw', 'Highlight color')])
self.transparent = self.myprops[('Draw', 'Transparent')]
self.alpha = self.myprops[('Draw', 'Alpha')]
self.animationspeed = self.myprops[('Draw', 'Animation speed')]
self.animationamplitude = self.myprops[('Draw', 'Animation amplitude')]
self.animationmode = self.myprops[('Draw', 'Animation mode')]
try:
self.myprops[('Draw', 'Resolved symbol')] = self.get_effective_symbol_source()
except Exception:
pass
self._update_symbol_preview(self.get_effective_symbol_source())
self.legendvisible = self.myprops[('Legend', 'Visible')]
self.legendtext = self.myprops[('Legend', 'Text')]
self.legendrelpos = self.myprops[('Legend', 'Relative position')]
self.legendx = self.myprops[('Legend', 'X')]
self.legendy = self.myprops[('Legend', 'Y')]
self.legendbold = self.myprops[('Legend', 'Bold')]
self.legenditalic = self.myprops[('Legend', 'Italic')]
self.legendfontname = self.myprops[('Legend', 'Font name')]
self.legendfontsize = self.myprops[('Legend', 'Font size')]
self.legendcolor = getIfromRGB(self.myprops[('Legend', 'Color')])
self.legendunderlined = self.myprops[('Legend', 'Underlined')]
self.legendorientation = self.myprops[('Legend', 'Orientation')]
self.legendwidth = self.myprops[('Legend', 'Width')]
self.legendheight = self.myprops[('Legend', 'Height')]
self.legendpriority = self.myprops[('Legend', 'Priority')]
if self.parent is not None:
self.parent.forceupdategl = True
if hasattr(self.parent, 'mapviewer') and self.parent.mapviewer is not None:
self.parent.mapviewer.Refresh()
[docs]
def show(self):
"""Populate the ``Wolf_Param`` dialog with current attribute values and display it.
Creates the dialog via :meth:`defaultprop` if it does not exist yet,
then synchronises all fields and calls ``Populate()`` / ``Show()``.
"""
self.defaultprop()
self.myprops[('Draw', 'Color')] = getRGBfromI(self.color)
self.myprops[('Draw', 'Width')] = self.width
self.myprops[('Draw', 'Style')] = self.style
self.myprops[('Draw', 'Rendering mode')] = getattr(self, 'renderingmode', Cloud_OGLRenderer.LIST.value)
self.myprops[('Draw', 'Filled')] = self.filled
preset_choices = self._list_package_symbols()
current_preset = getattr(self, 'symbolpreset', '') or self._infer_symbolpreset_from_source(getattr(self, 'symbolsource', ''))
if current_preset not in preset_choices:
current_preset = self.CUSTOM_SYMBOL_CHOICE
self.myprops.addparam('Draw', 'Package symbol', current_preset, Type_Param.Enum,
_('Bundled symbol from wolfhece/symbols (used before custom file if selected)'),
whichdict='Default', enum_choices=preset_choices)
self.myprops[('Draw', 'Package symbol')] = current_preset
self.myprops[('Draw', 'Symbol source')] = getattr(self, 'symbolsource', '')
self.myprops[('Draw', 'Resolved symbol')] = self.get_effective_symbol_source()
self.myprops[('Draw', 'Symbol raster size [px]')] = getattr(self, 'symbolrastersize', 128)
self.myprops[('Draw', 'Symbol rotation [\u00b0]')] = getattr(self, 'symbolrotation', 0.0)
self.myprops[('Draw', 'Symbol scale')] = getattr(self, 'symbolscale', 1.0)
self.myprops[('Draw', 'Tint symbol with color')] = getattr(self, 'symboltintwithcolor', False)
self.myprops[('Draw', 'Highlight selected point')] = getattr(self, 'highlightselectedpoint', True)
self.myprops[('Draw', 'Highlight size factor')] = getattr(self, 'highlightselectedpointsizefactor', 0.5)
self.myprops[('Draw', 'Highlight color')] = getRGBfromI(getattr(self, 'highlightselectedpointcolor', getIfromRGB((255, 220, 40))))
self.myprops[('Draw', 'Transparent')] = self.transparent
self.myprops[('Draw', 'Alpha')] = self.alpha
self.myprops[('Draw', 'Animation speed')] = getattr(self, 'animationspeed', 1.0)
self.myprops[('Draw', 'Animation amplitude')] = getattr(self, 'animationamplitude', 1.0)
self.myprops[('Draw', 'Animation mode')] = getattr(self, 'animationmode', Cloud_AnimationMode.NONE.value)
self.myprops[('Legend', 'Visible')] = self.legendvisible
self.myprops[('Legend', 'Text')] = self.legendtext
self.myprops[('Legend', 'Relative position')] = self.legendrelpos
self.myprops[('Legend', 'X')] = self.legendx
self.myprops[('Legend', 'Y')] = self.legendy
self.myprops[('Legend', 'Bold')] = self.legendbold
self.myprops[('Legend', 'Italic')] = self.legenditalic
self.myprops[('Legend', 'Font name')] = self.legendfontname
self.myprops[('Legend', 'Font size')] = self.legendfontsize
self.myprops[('Legend', 'Color')] = getRGBfromI(self.legendcolor)
self.myprops[('Legend', 'Underlined')] = self.legendunderlined
self.myprops[('Legend', 'Width')] = self.legendwidth
self.myprops[('Legend', 'Height')] = self.legendheight
self.myprops[('Legend', 'Orientation')] = self.legendorientation
self.myprops[('Legend', 'Priority')] = self.legendpriority
self._ensure_symbol_preview_widgets()
self._update_symbol_preview(self.get_effective_symbol_source())
self.myprops.Populate()
self.myprops.Show()
[docs]
class cloud_vertices(cloud_vertices_model, Element_To_Draw):
"""Cloud of vertices with OpenGL rendering and wx GUI support.
Inherits data handling from :class:`cloud_vertices_model` and drawable
behaviour from :class:`Element_To_Draw`.
:ivar gllist: OpenGL display list identifier (0 = not compiled yet).
:ivar forceupdategl: if ``True``, the display list will be recompiled
on the next :meth:`plot` call.
:ivar ongoing: guard flag to prevent recursive display-list compilation.
"""
[docs]
ANIM_NONE = Cloud_AnimationMode.NONE.value
[docs]
ANIM_BLINK = Cloud_AnimationMode.BLINK.value
[docs]
ANIM_FADE = Cloud_AnimationMode.FADE.value
[docs]
ANIM_GROW = Cloud_AnimationMode.GROW.value
[docs]
ANIM_SEASONS = Cloud_AnimationMode.SEASONS.value
[docs]
ANIM_PULSE = Cloud_AnimationMode.PULSE.value
def __init__(self,
fname='',
fromxls='',
header=False,
toload=True,
idx='',
plotted=True,
mapviewer=None,
need_for_wx=False,
bbox=None,
dxf_imported_elts=['MTEXT', 'INSERT']):
"""Create a GUI-aware cloud of vertices.
Initialises both :class:`Element_To_Draw` (for mapviewer integration)
and :class:`cloud_vertices_model` (for data loading).
:param fname: source file path (DXF, Shapefile or ASCII).
Empty string = empty cloud.
:param fromxls: raw string from an XLS file to be parsed.
:param header: ``True`` if the first line of the ASCII file contains
column names.
:param toload: ``True`` to load the file at initialisation.
:param idx: text identifier of the cloud.
:param plotted: ``True`` if the cloud should be drawn in the mapviewer.
:param mapviewer: reference to the active ``WolfMapViewer`` (or ``None``).
:param need_for_wx: ``True`` to force wx initialisation even without a
running wx App.
:param bbox: Shapely polygon delimiting the area of interest
(passed to :meth:`import_from_shapefile`).
:param dxf_imported_elts: list of DXF entity types to import.
"""
# Init Element_To_Draw (sets mapviewer, wx_exists, etc.)
Element_To_Draw.__init__(self, idx, plotted, mapviewer, need_for_wx)
# GUI-specific attributes
[docs]
self.forceupdategl = False
# Init model (handles data loading)
cloud_vertices_model.__init__(self, fname=fname, fromxls=fromxls,
header=header, toload=toload, idx=idx,
bbox=bbox,
dxf_imported_elts=dxf_imported_elts)
# Ensure default rendering mode for GUI
if not hasattr(self.myprop, 'renderingmode'):
self.myprop.renderingmode = Cloud_OGLRenderer.LIST.value
[docs]
def check_plot(self):
"""Mark the cloud as plotted and lazy-load data if needed.
Sets ``self.plotted = True``. If the cloud has not been loaded yet,
the source file is read before the first draw.
"""
self.plotted = True
if not self.loaded:
self.readfile(self.filename, self._header)
self.loaded = True
[docs]
def on_changed_vertices(self):
"""Invalidate rendering caches after vertex edits and request redraw.
If this cloud belongs to a :class:`cloud_of_clouds` whose editor
frame is open and this cloud is the active one, the CpGrid is
refreshed automatically.
"""
try:
self.reset_listogl()
except Exception:
# Keep GUI state coherent even if no GL context is currently active.
self.gllist = 0
self.forceupdategl = True
self.ongoing = False
if hasattr(self, 'mapviewer') and self.mapviewer is not None:
self.mapviewer.Refresh()
# Notify parent cloud_of_clouds editor to refresh its CpGrid
pc = getattr(self, 'parent_collection', None)
if pc is not None and getattr(pc, 'active_cloud', None) is self:
if getattr(pc, 'xls', None) is not None:
pc._fill_grid()
# ----------------------------------------------------------------
# Factory override
# ----------------------------------------------------------------
[docs]
def _make_cloud_vertices(self, **kwargs) -> "cloud_vertices":
"""Create a sibling GUI-aware cloud_vertices."""
return cloud_vertices(**kwargs)
[docs]
def _make_cloudproperties(self, **kwargs) -> cloudproperties:
"""Create a GUI-aware cloudproperties."""
return cloudproperties(**kwargs)
[docs]
def _make_cloudproperties_from_dict(self, d: dict, **kwargs) -> cloudproperties:
"""Create a GUI-aware cloudproperties from a dictionary."""
return cloudproperties.from_dict(d, **kwargs)
[docs]
def _effective_animation_mode(self) -> int:
"""Return the active cloud animation mode."""
mode = int(getattr(self.myprop, 'animationmode', self.ANIM_NONE) or self.ANIM_NONE)
if mode in (self.ANIM_NONE, self.ANIM_BLINK, self.ANIM_FADE, self.ANIM_GROW, self.ANIM_SEASONS, self.ANIM_PULSE):
return mode
return self.ANIM_NONE
[docs]
def _resolve_animation_phase(self, xmin=None, ymin=None, xmax=None, ymax=None) -> tuple[float, int]:
"""Return animation phase/mode and manage animation-clock subscription."""
mode = self._effective_animation_mode()
if mode == self.ANIM_NONE:
mapviewer = self.get_mapviewer()
if mapviewer is not None and hasattr(mapviewer, 'anim_clock'):
try:
mapviewer.anim_clock.unsubscribe(self)
except Exception:
pass
return 0.0, mode
mapviewer = self.get_mapviewer()
if mapviewer is None or not hasattr(mapviewer, 'anim_clock'):
return 0.0, mode
in_view = True
try:
if None not in (xmin, ymin, xmax, ymax):
self.find_minmax(False)
in_view = not (
self.xbounds[1] < xmin or self.xbounds[0] > xmax or
self.ybounds[1] < ymin or self.ybounds[0] > ymax
)
except Exception:
in_view = True
try:
if in_view:
mapviewer.anim_clock.subscribe(self, load=1)
speed = float(getattr(self.myprop, 'animationspeed', 1.0))
speed = max(0.05, min(speed, 20.0))
return float(mapviewer.anim_clock.get_phase(speed)), mode
mapviewer.anim_clock.unsubscribe(self)
except Exception:
return 0.0, mode
return 0.0, mode
@staticmethod
[docs]
def _apply_animation_effect(base_rgb: tuple[float, float, float],
alpha: float,
point_size_px: float,
phase: float,
mode: int,
amplitude: float = 1.0) -> tuple[bool, tuple[float, float, float], float, float]:
"""Apply animation effect and return (visible, rgb, alpha, size_px)."""
phase = float(phase) % 1.0
rgb = (float(base_rgb[0]), float(base_rgb[1]), float(base_rgb[2]))
alpha = float(alpha)
size_px = float(point_size_px)
amp = max(0.0, min(float(amplitude), 2.0))
if mode == Cloud_AnimationMode.BLINK.value:
return phase < 0.5, rgb, alpha, size_px
if mode == Cloud_AnimationMode.FADE.value:
fade = 1.0 - (0.75 * amp) * (0.5 * (1.0 + math.sin(2.0 * math.pi * phase)))
fade = max(0.15, min(fade, 1.0))
rgb = (rgb[0] * fade, rgb[1] * fade, rgb[2] * fade)
return True, rgb, alpha * fade, size_px
if mode == Cloud_AnimationMode.GROW.value:
scale = 1.0 + (0.70 * amp) * math.sin(2.0 * math.pi * phase)
scale = max(0.25, scale)
return True, rgb, alpha, size_px * scale
if mode == Cloud_AnimationMode.SEASONS.value:
palette = (
(0.40, 0.70, 0.35), # spring
(0.98, 0.78, 0.30), # summer
(0.88, 0.42, 0.20), # autumn
(0.70, 0.86, 1.00), # winter
)
t = phase * 4.0
i = int(t) % 4
u = t - float(int(t))
c0 = palette[i]
c1 = palette[(i + 1) % 4]
seasonal = (
c0[0] + (c1[0] - c0[0]) * u,
c0[1] + (c1[1] - c0[1]) * u,
c0[2] + (c1[2] - c0[2]) * u,
)
rgb = (
0.35 * rgb[0] + 0.65 * seasonal[0],
0.35 * rgb[1] + 0.65 * seasonal[1],
0.35 * rgb[2] + 0.65 * seasonal[2],
)
return True, rgb, alpha, size_px
if mode == Cloud_AnimationMode.PULSE.value:
pulse = 0.5 * (1.0 + math.sin(2.0 * math.pi * phase))
scale = 1.0 + (0.45 * amp) * pulse
fade = 1.0 - (0.55 * amp) * pulse
fade = max(0.2, min(fade, 1.0))
rgb = (rgb[0] * fade, rgb[1] * fade, rgb[2] * fade)
return True, rgb, alpha * fade, size_px * scale
return True, rgb, alpha, size_px
[docs]
def uncheck_plot(self, unload=True):
"""Mark the cloud as not plotted.
:param unload: reserved for future use (currently unused).
"""
self.plotted = False
mapviewer = self.get_mapviewer()
if mapviewer is not None and hasattr(mapviewer, 'anim_clock'):
try:
mapviewer.anim_clock.unsubscribe(self)
except Exception:
pass
[docs]
def _resolve_shapefile_column(self, gdf, targetcolumn:str) -> str:
"""Present a wx dialog to let the user choose a column interactively.
Overrides :meth:`cloud_vertices_model._resolve_shapefile_column`.
Falls back to logging an error if wx is not available.
:param gdf: ``GeoDataFrame`` already loaded from the Shapefile.
:param targetcolumn: the originally requested column name (not found).
:return: column name chosen by the user, or ``None`` to abort.
"""
if self.wx_exists:
dlg = wx.SingleChoiceDialog(
None,
_('Choose the column to be used for XYZ coordinates'),
_('Column choice'),
list(gdf.columns))
if dlg.ShowModal() == wx.ID_OK:
col = dlg.GetStringSelection()
dlg.Destroy()
return col
else:
dlg.Destroy()
return None
else:
return super()._resolve_shapefile_column(gdf, targetcolumn)
@staticmethod
[docs]
def _legend_alignment_from_relpos(relpos: int) -> str:
"""Map keypad-like relative position to text horizontal alignment."""
if relpos in (1, 2, 3):
return 'right'
if relpos in (7, 8, 9):
return 'left'
return 'center'
@staticmethod
[docs]
def _is_shader_mode(rendering_machine) -> bool:
"""Return True when rendering mode requests the shader pipeline.
Accepted inputs are:
- Cloud_OGLRenderer enum value
- integer backend id (0=list, 1=shader)
- string name ('shader')
"""
if rendering_machine is None:
return False
if isinstance(rendering_machine, Cloud_OGLRenderer):
return rendering_machine is Cloud_OGLRenderer.SHADER
if isinstance(rendering_machine, (int, np.integer)):
return int(rendering_machine) == Cloud_OGLRenderer.SHADER.value
if isinstance(rendering_machine, str):
return rendering_machine.strip().lower() == 'shader'
return False
[docs]
def _resolve_mvp_viewport(self, xmin=None, ymin=None, xmax=None, ymax=None,
mvp=None, viewport=None) -> tuple[np.ndarray | None, tuple[int, int] | None]:
"""Resolve MVP/viewport with the following priority:
1) explicit function arguments,
2) mapviewer projection/viewport,
3) orthographic MVP from bounds + canvas viewport fallback.
"""
# 1) Explicit inputs from caller
if mvp is not None:
mvp = np.ascontiguousarray(mvp, dtype=np.float32)
if viewport is not None:
viewport = (int(viewport[0]), int(viewport[1]))
if mvp is not None and viewport is not None:
return mvp, viewport
# 2) Try mapviewer matrices when available
mapviewer = self.get_mapviewer()
if mapviewer is not None:
getter = getattr(mapviewer, 'get_MVP_Viewport_matrix', None)
if callable(getter):
try:
_, p, vp = getter()
if mvp is None and p is not None:
mvp = np.ascontiguousarray(p, dtype=np.float32)
if viewport is None and vp is not None:
viewport = (int(vp[2]), int(vp[3]))
except Exception:
# Keep silent fallback behavior: legacy callers may use mapviewer
# instances without a ready GL state.
pass
# 3) Fallbacks for whichever value is still missing
if mvp is None and None not in (xmin, ymin, xmax, ymax):
mvp = self._build_ortho_mvp(xmin, ymin, xmax, ymax)
if viewport is None:
viewport = self._get_viewport_size()
if mvp is None or viewport is None:
return None, None
return np.ascontiguousarray(mvp, dtype=np.float32), (int(viewport[0]), int(viewport[1]))
@staticmethod
[docs]
def _compute_shader_point_size_px(style: int, width: float,
mvp: np.ndarray,
viewport: tuple[int, int]) -> float:
"""Convert cloud width semantics to point-size pixels for shader draw."""
return float(width)
[docs]
def _width_world_for_pixel_size(self, width_px: float) -> float:
"""Convert a marker width expressed in pixels to world units."""
mapviewer = self.get_mapviewer()
if mapviewer is None:
return float(width_px)
sx = abs(float(getattr(mapviewer, 'sx', 0.0)))
sy = abs(float(getattr(mapviewer, 'sy', 0.0)))
px_per_world = max(sx, sy, 1e-12)
return float(width_px) / px_per_world
[docs]
def _build_points_xy(self) -> np.ndarray:
"""Build an ``(N, 2)`` float32 array from model coordinates.
This relies on the model-level ``xyz`` accessor so both storage
backends (dict or numpy) are handled transparently.
"""
xyz = self.xyz
if xyz.size == 0:
return np.empty((0, 2), dtype=np.float32)
return np.ascontiguousarray(xyz[:, :2], dtype=np.float32)
[docs]
def _plot_shader(self, xmin=None, ymin=None, xmax=None, ymax=None,
mvp=None, viewport=None,
anim_phase: float = 0.0,
anim_mode: int = ANIM_NONE):
"""Render cloud points with the shader pipeline."""
if self.nbvertices == 0:
return
style = self.myprop.style
if style not in (Cloud_Styles.POINT.value,
Cloud_Styles.CIRCLE.value,
Cloud_Styles.CROSS.value,
Cloud_Styles.QUAD.value,
Cloud_Styles.SYMBOL.value):
raise RuntimeError('Unsupported cloud style for shader rendering')
mvp, viewport = self._resolve_mvp_viewport(
xmin=xmin, ymin=ymin, xmax=xmax, ymax=ymax,
mvp=mvp, viewport=viewport,
)
if mvp is None or viewport is None:
raise RuntimeError('Missing MVP/viewport for cloud shader plot')
rgb = getRGBfromI(self.myprop.color)
tint_symbol = bool(getattr(self.myprop, 'symboltintwithcolor', False))
if style == Cloud_Styles.SYMBOL.value and not tint_symbol:
base_rgb = (1.0, 1.0, 1.0)
else:
base_rgb = (rgb[0] / 255., rgb[1] / 255., rgb[2] / 255.)
# Avoid fully black symbols when tinting is enabled and draw color stays
# at the historical default black value.
if style == Cloud_Styles.SYMBOL.value and tint_symbol and base_rgb == (0.0, 0.0, 0.0):
base_rgb = (1.0, 1.0, 1.0)
if self.myprop.transparent:
alpha = self.myprop.alpha / 255.
else:
alpha = 1.0
point_size_px = self._compute_shader_point_size_px(
style=style,
width=float(self.myprop.width),
mvp=mvp,
viewport=viewport,
)
draw_visible, base_rgb, alpha, point_size_px = self._apply_animation_effect(
base_rgb=base_rgb,
alpha=alpha,
point_size_px=point_size_px,
phase=anim_phase,
mode=anim_mode,
amplitude=float(getattr(self.myprop, 'animationamplitude', 1.0)),
)
if not draw_visible:
return
color = (base_rgb[0], base_rgb[1], base_rgb[2], alpha)
pts = self._build_points_xy()
shader = CloudPointsShader2D.get_instance()
shader.draw_points(
pts_xy=pts,
mvp=mvp,
point_size_px=point_size_px,
color=color,
style=style,
filled=bool(self.myprop.filled),
symbol_path=self.myprop.get_effective_symbol_source(),
symbol_raster_size=int(getattr(self.myprop, 'symbolrastersize', 128)),
symbol_rotation=float(math.radians(getattr(self.myprop, 'symbolrotation', 0.0))),
symbol_scale=float(getattr(self.myprop, 'symbolscale', 1.0)),
pts_transform=getattr(self, '_pts_transform', None),
)
@staticmethod
[docs]
def _build_ortho_mvp(xmin: float, ymin: float, xmax: float, ymax: float) -> np.ndarray | None:
"""Build a 2D orthographic MVP matching the current world view bounds."""
dx = float(xmax - xmin)
dy = float(ymax - ymin)
if abs(dx) < 1e-12 or abs(dy) < 1e-12:
return None
tx = -(xmax + xmin) / dx
ty = -(ymax + ymin) / dy
# IMPORTANT: keep C-contiguous layout and store OpenGL *columns* in
# numpy rows (PyOpenGL convention used in this codebase with GL_FALSE).
return np.ascontiguousarray(np.array([
[2.0 / dx, 0.0, 0.0, 0.0],
[0.0, 2.0 / dy, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[tx, ty, 0.0, 1.0],
], dtype=np.float32))
[docs]
def _get_viewport_size(self) -> tuple[int, int] | None:
"""Return viewport size from mapviewer/canvas if available."""
mapviewer = self.get_mapviewer()
if mapviewer is None:
return None
try:
if hasattr(mapviewer, 'canvas') and mapviewer.canvas is not None:
w, h = mapviewer.canvas.GetSize()
return int(w), int(h)
except Exception:
pass
try:
w, h = mapviewer.GetClientSize()
return int(w), int(h)
except Exception:
return None
[docs]
def _legend_anchor_for_vertex(self, vx: float, vy: float,
text_height_world: float = 0.0) -> tuple[float, float]:
"""Compute legend anchor around a vertex from ``legendrelpos``.
Horizontal placement is handled by text alignment (left/center/right),
so X stays anchored on the vertex. Vertical placement is approximated
with a half-height shift to match historical relative-position behavior.
"""
relpos = int(getattr(self.myprop, 'legendrelpos', 5))
height = max(float(text_height_world or 0.0), 0.0)
x = vx
if relpos in (1, 4, 7):
y = vy + 0.5 * height
elif relpos in (3, 6, 9):
y = vy - 0.5 * height
else:
y = vy
return x, y
@staticmethod
[docs]
def _legend_text_height_world(
text: str,
atlas: GlyphAtlas,
mvp: np.ndarray,
viewport: tuple[int, int],
*,
font_size: float,
size_in_pixels: bool,
world_height: float | None,
world_width: float | None,
line_spacing: float = 1.2,
) -> float:
"""Estimate rendered text height in world units for relpos offsets."""
if not text:
return 0.0
if world_height is not None:
scale = float(world_height)
elif world_width is not None:
ref_width, _ = measure_text(
text.replace('\\n', '\n'),
atlas,
scale=1.0,
line_spacing=line_spacing,
)
if ref_width <= 1e-12:
return 0.0
scale = float(world_width) / float(ref_width)
elif size_in_pixels:
# Keep exactly the same conversion as TextRenderer2D.draw_text.
ppwu = abs(float(mvp[0, 0])) * float(viewport[0]) * 0.5
world_per_px = 1.0 / max(ppwu, 1e-12)
scale = float(font_size) * world_per_px
else:
scale = float(font_size)
if scale <= 0.0:
return 0.0
_, text_h = measure_text(
text.replace('\\n', '\n'),
atlas,
scale=scale,
line_spacing=line_spacing,
)
return max(float(text_h), 0.0)
[docs]
def plot_legend(self, sx=None, sy=None, xmin=None, ymin=None, xmax=None, ymax=None, size=None,
rendering_machine=None, mvp=None, viewport=None, anim_phase=0.0):
"""Render the legend for each visible vertex using the SDF text renderer.
For every vertex inside the current view bounds, text is rendered
directly in OpenGL using :class:`TextRenderer2D`.
:param sx: unused (reserved).
:param sy: unused (reserved).
:param xmin: left boundary of the current view (map units).
:param ymin: bottom boundary of the current view (map units).
:param xmax: right boundary of the current view (map units).
:param ymax: top boundary of the current view (map units).
:param size: unused (reserved).
:param rendering_machine: optional renderer hint (unused).
:param mvp: optional 4x4 MVP matrix.
:param viewport: optional ``(width_px, height_px)``.
:param anim_phase: animation phase in ``[0, 1]``.
"""
if self.get_mapviewer() is None:
logging.warning(_('No mapviewer available for legend plot'))
return
if self.myprop.legendvisible:
if xmin is None or ymin is None or xmax is None or ymax is None:
logging.warning(_('Missing view bounds for legend plot -- skipping'))
return
dx = xmax - xmin
dy = ymax - ymin
if dx > 50_000. or dy > 50_000.:
logging.warning(_('Too large bounds for legend plot -- skipping'))
return
if mvp is None:
mvp = self._build_ortho_mvp(xmin, ymin, xmax, ymax)
if viewport is None:
viewport = self._get_viewport_size()
if mvp is None or viewport is None:
logging.warning(_('Missing MVP/viewport for legend shader plot -- skipping'))
return
alignment = self._legend_alignment_from_relpos(int(self.myprop.legendrelpos))
tr = TextRenderer2D.get_instance()
atlas = GlyphAtlas.get(self.myprop.legendfontname)
r, g, b = getRGBfromI(self.myprop.legendcolor)
color = (r / 255.0, g / 255.0, b / 255.0, 1.0)
# Priority modes: 1=width, 2=height, 3=fontsize
if self.myprop.legendpriority == 3:
size_in_pixels = True
font_size = float(self.myprop.legendfontsize)
world_width = None
world_height = None
elif self.myprop.legendpriority == 1:
size_in_pixels = False
font_size = 0.0
world_width = float(self.myprop.legendwidth)
world_height = None
else:
size_in_pixels = False
font_size = 0.0
world_width = None
world_height = float(self.myprop.legendheight)
which_legend = self.myprop.legendtext
for id, (row_id, row) in enumerate(self.iter_rows()):
curvert = row['vertex']
if curvert.x > xmin and curvert.x < xmax and curvert.y > ymin and curvert.y < ymax:
if which_legend == '':
text_legend = str(row_id)
elif which_legend == 'ID':
text_legend = str(id)
elif which_legend == 'X':
text_legend = str(curvert.x)
elif which_legend == 'Y':
text_legend = str(curvert.y)
elif which_legend == 'Z':
text_legend = str(curvert.z)
elif which_legend in row:
text_legend = row[which_legend]
else:
text_legend = which_legend
text_height_world = self._legend_text_height_world(
str(text_legend),
atlas,
mvp,
viewport,
font_size=font_size,
size_in_pixels=size_in_pixels,
world_height=world_height,
world_width=world_width,
line_spacing=1.2,
)
draw_x, draw_y = self._legend_anchor_for_vertex(
curvert.x,
curvert.y,
text_height_world=text_height_world,
)
tr.draw_text(
str(text_legend),
draw_x,
draw_y,
mvp,
viewport,
font_name=self.myprop.legendfontname,
font_size=font_size,
color=color,
size_in_pixels=size_in_pixels,
world_height=world_height,
world_width=world_width,
rotation=self.myprop.legendorientation,
alignment=alignment,
glow_enabled=False,
anim_mode=0,
anim_phase=anim_phase,
)
[docs]
def reset_listogl(self):
"""Delete the compiled OpenGL display list and schedule recompilation.
Sets ``forceupdategl = True`` so the next :meth:`plot` call rebuilds
the list.
"""
if self.gllist != 0:
glDeleteLists(self.gllist, 1)
self.gllist = 0
self.forceupdategl = True
self.ongoing = False
[docs]
def _plot_list(self, update: bool = False,
color_override: int | None = None,
width_override: float | None = None):
"""Render the cloud with the legacy display-list / immediate-mode path."""
if update or self.gllist == 0 or self.forceupdategl and not self.ongoing:
self.ongoing = True
color = self.myprop.color if color_override is None else int(color_override)
width = self.myprop.width if width_override is None else float(width_override)
style = self.myprop.style
filled = self.myprop.filled
pts_xy = self._build_points_xy()
if self.gllist != 0:
glDeleteLists(self.gllist, 1)
self.gllist = glGenLists(1)
glNewList(self.gllist, GL_COMPILE)
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)
if filled:
glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)
if style == Cloud_Styles.POINT.value:
glPointSize(width)
rgb = getRGBfromI(color)
glBegin(GL_POINTS)
for x, y in pts_xy:
glColor3ub(int(rgb[0]), int(rgb[1]), int(rgb[2]))
glVertex2f(float(x), float(y))
glEnd()
elif style == Cloud_Styles.CIRCLE.value:
glPointSize(1)
rgb = getRGBfromI(color)
half_size_world = max(self._width_world_for_pixel_size(width) * 0.5, 1e-12)
for x, y in pts_xy:
glColor3ub(int(rgb[0]), int(rgb[1]), int(rgb[2]))
circle(float(x), float(y), half_size_world)
elif style == Cloud_Styles.CROSS.value:
glPointSize(1)
rgb = getRGBfromI(color)
half_size_world = max(self._width_world_for_pixel_size(width) * 0.5, 1e-12)
for x, y in pts_xy:
glColor3ub(int(rgb[0]), int(rgb[1]), int(rgb[2]))
cross(float(x), float(y), half_size_world)
elif style == Cloud_Styles.QUAD.value:
glPointSize(1)
rgb = getRGBfromI(color)
half_size_world = max(self._width_world_for_pixel_size(width) * 0.5, 1e-12)
for x, y in pts_xy:
glColor3ub(int(rgb[0]), int(rgb[1]), int(rgb[2]))
quad(float(x), float(y), half_size_world)
elif style == Cloud_Styles.SYMBOL.value:
# Legacy fallback: approximate symbol as a filled quad.
glPointSize(1)
rgb = getRGBfromI(color)
half_size_world = max(self._width_world_for_pixel_size(width) * 0.5, 1e-12)
for x, y in pts_xy:
glColor3ub(int(rgb[0]), int(rgb[1]), int(rgb[2]))
quad(float(x), float(y), half_size_world)
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)
glEndList()
self.forceupdategl = False
self.ongoing = False
else:
glCallList(self.gllist)
[docs]
def plot_highlight_vertex(self, row_id, size_factor:float|None = None, color_rgb:tuple[int, int, int]|None = None):
"""Draw ring/cross marker around one cloud vertex identified by row id."""
if row_id is None:
return
target = None
for curid, row in self.iter_rows():
if curid == row_id:
target = row.get('vertex')
break
if target is None:
return
if size_factor is None:
size_factor = float(getattr(self.myprop, 'highlightselectedpointsizefactor', 0.5))
if color_rgb is None:
color_rgb = getRGBfromI(getattr(self.myprop, 'highlightselectedpointcolor', getIfromRGB((255, 220, 40))))
r = max(self._width_world_for_pixel_size(float(self.myprop.width) * float(size_factor)) * 0.5, 1e-12)
glPushAttrib(GL_CURRENT_BIT | GL_LINE_BIT | GL_ENABLE_BIT)
glDisable(GL_TEXTURE_2D)
glDisable(GL_BLEND)
glColor3ub(int(color_rgb[0]), int(color_rgb[1]), int(color_rgb[2]))
glLineWidth(2.0)
circle_outline(float(target.x), float(target.y), r, numseg=28)
cross(float(target.x), float(target.y), r * 0.85)
glPopAttrib()
[docs]
def plot(self, update:bool=False, *args, rendering_machine=None, mvp=None, viewport=None, **kwargs):
"""Render the cloud of vertices using OpenGL (dispatcher).
:param update: if ``True``, force recompilation of the display list
even if one already exists.
:param args: additional positional arguments (unused).
:param rendering_machine: backend selector (list/shader).
:param mvp: optional 4x4 MVP matrix for shader mode.
:param viewport: optional ``(width_px, height_px)`` for shader mode.
:param kwargs: keyword arguments forwarded to :meth:`plot_legend`
(typically ``xmin``, ``ymin``, ``xmax``, ``ymax``).
"""
if rendering_machine is None:
rendering_machine = getattr(self.myprop, 'renderingmode', Cloud_OGLRenderer.LIST.value)
anim_phase, anim_mode = self._resolve_animation_phase(
xmin=kwargs.get('xmin'), ymin=kwargs.get('ymin'),
xmax=kwargs.get('xmax'), ymax=kwargs.get('ymax'),
)
base_rgb_255 = getRGBfromI(self.myprop.color)
draw_visible, anim_rgb, _anim_alpha, anim_size = self._apply_animation_effect(
base_rgb=(base_rgb_255[0] / 255.0, base_rgb_255[1] / 255.0, base_rgb_255[2] / 255.0),
alpha=(self.myprop.alpha / 255.0) if self.myprop.transparent else 1.0,
point_size_px=float(self.myprop.width),
phase=anim_phase,
mode=anim_mode,
amplitude=float(getattr(self.myprop, 'animationamplitude', 1.0)),
)
if not draw_visible:
return
if self._is_shader_mode(rendering_machine):
try:
self._plot_shader(
xmin=kwargs.get('xmin'), ymin=kwargs.get('ymin'),
xmax=kwargs.get('xmax'), ymax=kwargs.get('ymax'),
mvp=mvp, viewport=viewport,
anim_phase=anim_phase, anim_mode=anim_mode,
)
except Exception as exc:
logging.warning(_('Cloud shader rendering failed -- fallback to list: {}').format(exc))
anim_color_i = getIfromRGB([int(max(0, min(255, round(c * 255.0)))) for c in anim_rgb])
self._plot_list(update=True, color_override=anim_color_i, width_override=anim_size)
else:
anim_color_i = getIfromRGB([int(max(0, min(255, round(c * 255.0)))) for c in anim_rgb])
list_needs_rebuild = anim_mode in (self.ANIM_FADE, self.ANIM_GROW, self.ANIM_SEASONS, self.ANIM_PULSE)
self._plot_list(update=(update or list_needs_rebuild),
color_override=anim_color_i,
width_override=anim_size)
self.plot_legend(rendering_machine=rendering_machine, mvp=mvp, viewport=viewport, **kwargs)
[docs]
def show_properties(self):
"""Open the interactive properties dialog for this cloud.
Delegates to :meth:`cloudproperties.show`.
"""
self.myprop.show()
[docs]
def plot_matplotlib(self, ax=None, **kwargs):
"""Display the cloud using Matplotlib (2D scatter plot).
:param ax: existing Matplotlib axes. If ``None``, a new figure
and axes are created.
:param kwargs: extra keyword arguments forwarded to
``ax.scatter(x, y, **kwargs)`` (e.g. ``c=``, ``s=``,
``marker=``, ``cmap=``…).
:return: tuple ``(fig, ax)``.
"""
import matplotlib.pyplot as plt
if ax is None:
fig, ax = plt.subplots()
else:
fig = ax.get_figure()
x = [cur.x for cur in self.iter_on_vertices()]
y = [cur.y for cur in self.iter_on_vertices()]
ax.scatter(x, y, **kwargs)
return fig, ax
[docs]
def set_mapviewer(self, newmapviewer=None):
self.mapviewer = newmapviewer
[docs]
class cloud_of_clouds(cloud_of_clouds_model, wx.Frame, Element_To_Draw):
"""GUI-enabled collection of :class:`cloud_vertices` instances.
Inherits data handling from :class:`cloud_of_clouds_model` and drawable
behaviour from :class:`Element_To_Draw`.
Also inherits from ``wx.Frame`` to provide an interactive editor
with a tree list of clouds and a CpGrid for vertex coordinates,
following the same pattern as ``Zones``.
Each child cloud is a GUI-aware :class:`cloud_vertices` (with OpenGL
rendering). The collection delegates ``plot``, ``check_plot``,
``uncheck_plot`` and ``set_mapviewer`` to every child cloud so that
toggling visibility on the collection toggles all children at once.
"""
def __init__(self,
idx: str = '',
clouds: list[cloud_vertices] | None = None,
plotted: bool = True,
mapviewer=None,
need_for_wx: bool = False,
parent=None) -> None:
"""Create a GUI-aware cloud collection.
:param idx: text identifier for the collection.
:param clouds: optional initial list of clouds to include.
:param plotted: whether the collection should be drawn.
:param mapviewer: reference to the active ``WolfMapViewer``.
:param need_for_wx: ``True`` to force wx initialisation.
:param parent: parent object (e.g. mapviewer).
"""
Element_To_Draw.__init__(self, idx, plotted, mapviewer, need_for_wx)
cloud_of_clouds_model.__init__(self, idx=idx, clouds=clouds)
self.xls = None
self.treelist = None
[docs]
self.labelactcloud = None
[docs]
self._wx_frame_initialized = False
[docs]
self.init_struct = True
[docs]
self.active_cloud: cloud_vertices | None = None
[docs]
self.last_active = None
# ----------------------------------------------------------------
# Factory override
# ----------------------------------------------------------------
[docs]
def _make_cloud_vertices(self, **kwargs) -> cloud_vertices:
"""Create a GUI-enabled cloud_vertices."""
return cloud_vertices(**kwargs)
[docs]
def _make_cloud_vertices_from_dict(self, d: dict, **kwargs) -> cloud_vertices:
"""Create a GUI-enabled cloud_vertices from a dictionary."""
return cloud_vertices.from_dict(d, **kwargs)
[docs]
def create_cloud(self, idx: str = '', **kwargs) -> cloud_vertices:
"""Create a new GUI-enabled cloud and add it to the collection.
:param idx: identifier for the new cloud.
:param kwargs: forwarded to :class:`cloud_vertices` constructor.
:return: the newly created cloud.
"""
c = self._make_cloud_vertices(idx=idx, mapviewer=self.mapviewer, **kwargs)
self.add_cloud(c)
return c
# ----------------------------------------------------------------
# Mapviewer propagation
# ----------------------------------------------------------------
[docs]
def set_mapviewer(self, newmapviewer=None):
"""Attach a mapviewer to the collection and propagate to all children."""
self.mapviewer = newmapviewer
for c in self.myclouds:
c.set_mapviewer(newmapviewer)
# ----------------------------------------------------------------
# Check / uncheck
# ----------------------------------------------------------------
[docs]
def check_plot(self):
"""Mark the collection and all children as plotted."""
self.plotted = True
for c in self.myclouds:
c.check_plot()
[docs]
def uncheck_plot(self, unload=True):
"""Mark the collection and all children as not plotted."""
self.plotted = False
for c in self.myclouds:
c.uncheck_plot(unload=unload)
# ----------------------------------------------------------------
# Bounds
# ----------------------------------------------------------------
[docs]
def find_minmax(self, force: bool = True):
"""Recompute bounds for all clouds and update Element_To_Draw extent."""
cloud_of_clouds_model.find_minmax(self, force=force)
if self.myclouds:
self.xmin, self.xmax = self.xbounds
self.ymin, self.ymax = self.ybounds
# ----------------------------------------------------------------
# OpenGL rendering
# ----------------------------------------------------------------
[docs]
def reset_listogl(self):
"""Reset OpenGL display lists for all child clouds."""
for c in self.myclouds:
c.reset_listogl()
[docs]
def plot(self, sx=None, sy=None, xmin=None, ymin=None, xmax=None, ymax=None,
size=None, rendering_machine=None, mvp=None, viewport=None, **kwargs):
"""Render all child clouds.
Delegates to each cloud's :meth:`plot` method.
:param rendering_machine: backend selector (list/shader).
:param mvp: optional 4×4 MVP matrix for shader mode.
:param viewport: optional viewport tuple for shader mode.
"""
if not self.plotted:
return
for c in self.myclouds:
if c.plotted:
c.plot(rendering_machine=rendering_machine,
mvp=mvp, viewport=viewport,
xmin=xmin, ymin=ymin, xmax=xmax, ymax=ymax,
**kwargs)
# ----------------------------------------------------------------
# Properties dialog
# ----------------------------------------------------------------
[docs]
def show_properties(self, parent=None, forceupdate=False):
"""Open the interactive editor frame.
If the editor has not been built yet, create the wx.Frame and
its widget tree (tree list, CpGrid, buttons).
:param parent: parent object (e.g. mapviewer).
:param forceupdate: if ``True``, rebuild the whole UI.
"""
if parent is not None:
self.parent = parent
self.wx_exists = wx.App.Get() is not None
if forceupdate:
self.init_struct = True
if self.wx_exists:
if not self._wx_frame_initialized:
try:
wx.Frame.__init__(self, None, size=(650, 500))
self._wx_frame_initialized = True
self.Bind(wx.EVT_CLOSE, self._on_close)
except Exception:
logging.warning(_('Bad wx context -- see cloud_of_clouds.show_properties'))
return
if self.init_struct:
self._init_ui()
self.Show()
self.Center()
self.Raise()
# ----------------------------------------------------------------
# Cloud management override
# ----------------------------------------------------------------
[docs]
def add_cloud(self, cloud: cloud_vertices) -> None:
"""Append a cloud and propagate current mapviewer."""
super().add_cloud(cloud)
if self.mapviewer is not None:
cloud.set_mapviewer(self.mapviewer)
# ----------------------------------------------------------------
# Matplotlib
# ----------------------------------------------------------------
[docs]
def plot_matplotlib(self, ax=None, **kwargs):
"""Display all clouds using Matplotlib.
:param ax: existing Matplotlib axes. If ``None``, a new figure
and axes are created.
:param kwargs: forwarded to each cloud's ``plot_matplotlib``.
:return: tuple ``(fig, ax)``.
"""
import matplotlib.pyplot as plt
if ax is None:
fig, ax = plt.subplots()
else:
fig = ax.get_figure()
for c in self.myclouds:
c.plot_matplotlib(ax=ax, **kwargs)
return fig, ax
# ================================================================
# wx.Frame editor
# ================================================================
[docs]
def _on_close(self, event):
"""Hide the editor frame instead of destroying it."""
self.Hide()
[docs]
def _init_ui(self):
"""Build the editor widgets: tree list, CpGrid, and button panels."""
if not self.wx_exists:
return
box = wx.BoxSizer(wx.HORIZONTAL)
boxleft = wx.BoxSizer(wx.VERTICAL)
boxright = wx.BoxSizer(wx.VERTICAL)
# -- TreeList (left) --
self.treelist = TreeListCtrl(self, style=TL_CHECKBOX | wx.TR_FULL_ROW_HIGHLIGHT | wx.TR_EDIT_LABELS)
self.treelist.AppendColumn(_('Clouds'))
self.treelist.Bind(EVT_TREELIST_ITEM_CHECKED, self._on_check_item)
self.treelist.Bind(EVT_TREELIST_ITEM_ACTIVATED, self._on_activate_item)
self.treelist.Bind(EVT_TREELIST_ITEM_CONTEXT_MENU, self._on_rdown)
self.treelist.Bind(wx.EVT_CHAR, self._on_edit_label)
self.labelactcloud = wx.StaticText(self, wx.ID_ANY, _('None'),
style=wx.ALIGN_CENTER_HORIZONTAL)
self.labelactcloud.SetToolTip(_('Name of the active cloud'))
# -- Left buttons (collection operations) --
sizer_add_del = wx.BoxSizer(wx.HORIZONTAL)
btn_add_cloud = wx.Button(self, label=_('Add cloud'))
btn_add_cloud.SetToolTip(_('Add a new empty cloud to the collection'))
btn_add_cloud.Bind(wx.EVT_BUTTON, self._on_add_cloud)
btn_del_cloud = wx.Button(self, label=_('Delete cloud'))
btn_del_cloud.SetToolTip(_('Remove the active cloud from the collection'))
btn_del_cloud.Bind(wx.EVT_BUTTON, self._on_delete_cloud)
sizer_add_del.Add(btn_add_cloud, 1, wx.EXPAND)
sizer_add_del.Add(btn_del_cloud, 1, wx.EXPAND)
sizer_updown = wx.BoxSizer(wx.HORIZONTAL)
btn_up = wx.Button(self, label=_('Up'))
btn_up.SetToolTip(_('Move the active cloud up in the list'))
btn_up.Bind(wx.EVT_BUTTON, self._on_up_cloud)
btn_down = wx.Button(self, label=_('Down'))
btn_down.SetToolTip(_('Move the active cloud down in the list'))
btn_down.Bind(wx.EVT_BUTTON, self._on_down_cloud)
sizer_updown.Add(btn_up, 1, wx.EXPAND)
sizer_updown.Add(btn_down, 1, wx.EXPAND)
sizer_io = wx.BoxSizer(wx.HORIZONTAL)
btn_save = wx.Button(self, label=_('Save JSON'))
btn_save.SetToolTip(_('Save the entire collection to a JSON file'))
btn_save.Bind(wx.EVT_BUTTON, self._on_save_json)
btn_load = wx.Button(self, label=_('Load JSON'))
btn_load.SetToolTip(_('Load a collection from a JSON file'))
btn_load.Bind(wx.EVT_BUTTON, self._on_load_json)
sizer_io.Add(btn_save, 1, wx.EXPAND)
sizer_io.Add(btn_load, 1, wx.EXPAND)
btn_merge = wx.Button(self, label=_('Merge all'))
btn_merge.SetToolTip(_('Merge all clouds into a single cloud'))
btn_merge.Bind(wx.EVT_BUTTON, self._on_merge)
btn_bulk_props = wx.Button(self, label=_('Bulk properties'))
btn_bulk_props.SetToolTip(_('Edit display properties for all clouds at once'))
btn_bulk_props.Bind(wx.EVT_BUTTON, self._on_bulk_properties)
boxleft.Add(self.treelist, 1, wx.EXPAND)
boxleft.Add(self.labelactcloud, 0, wx.EXPAND)
boxleft.Add(sizer_add_del, 0, wx.EXPAND)
boxleft.Add(sizer_updown, 0, wx.EXPAND)
boxleft.Add(sizer_io, 0, wx.EXPAND)
boxleft.Add(btn_merge, 0, wx.EXPAND)
boxleft.Add(btn_bulk_props, 0, wx.EXPAND)
# -- CpGrid (right) --
self.xls = CpGrid(self, -1, wx.WANTS_CHARS)
self.xls.CreateGrid(10, 3)
self.xls.SetColLabelValue(0, 'X')
self.xls.SetColLabelValue(1, 'Y')
self.xls.SetColLabelValue(2, 'Z')
# -- Right buttons (per-cloud / vertex operations) --
sizer_grid_ops = wx.BoxSizer(wx.HORIZONTAL)
btn_addrows = wx.Button(self, label=_('Rows+'))
btn_addrows.SetToolTip(_('Add rows to the vertex grid'))
btn_addrows.Bind(wx.EVT_BUTTON, self._on_addrows)
btn_update = wx.Button(self, label=_('Update'))
btn_update.SetToolTip(_('Transfer coordinates from the grid to the cloud'))
btn_update.Bind(wx.EVT_BUTTON, self._on_update_vertices)
sizer_grid_ops.Add(btn_addrows, 1, wx.EXPAND)
sizer_grid_ops.Add(btn_update, 1, wx.EXPAND)
sizer_vertex_ops = wx.BoxSizer(wx.HORIZONTAL)
btn_add_vertex = wx.Button(self, label=_('Add'))
btn_add_vertex.SetToolTip(_('Add vertices to the active cloud'))
btn_add_vertex.Bind(wx.EVT_BUTTON, self._on_add_vertex)
btn_mod_vertex = wx.Button(self, label=_('Modify'))
btn_mod_vertex.SetToolTip(_('Modify vertices in the active cloud'))
btn_mod_vertex.Bind(wx.EVT_BUTTON, self._on_modify_vertex)
sizer_vertex_ops.Add(btn_add_vertex, 1, wx.EXPAND)
sizer_vertex_ops.Add(btn_mod_vertex, 1, wx.EXPAND)
sizer_plot = wx.BoxSizer(wx.HORIZONTAL)
btn_plot_mpl = wx.Button(self, label=_('Plot xy'))
btn_plot_mpl.SetToolTip(_('Plot the active cloud in a Matplotlib window'))
btn_plot_mpl.Bind(wx.EVT_BUTTON, self._on_plot_mpl)
btn_zoom = wx.Button(self, label=_('Zoom'))
btn_zoom.SetToolTip(_('Zoom on the active cloud in the mapviewer'))
btn_zoom.Bind(wx.EVT_BUTTON, self._on_zoom)
sizer_plot.Add(btn_plot_mpl, 1, wx.EXPAND)
sizer_plot.Add(btn_zoom, 1, wx.EXPAND)
btn_cloud_props = wx.Button(self, label=_('Properties'))
btn_cloud_props.SetToolTip(_('Edit display properties of the active cloud'))
btn_cloud_props.Bind(wx.EVT_BUTTON, self._on_cloud_properties)
boxright.Add(self.xls, 1, wx.EXPAND)
boxright.Add(sizer_grid_ops, 0, wx.EXPAND)
boxright.Add(sizer_vertex_ops, 0, wx.EXPAND)
boxright.Add(sizer_plot, 0, wx.EXPAND)
boxright.Add(btn_cloud_props, 0, wx.EXPAND)
box.Add(boxleft, 1, wx.EXPAND)
box.Add(boxright, 1, wx.EXPAND)
self._fill_structure()
self.SetSizer(box)
if self.idx:
self.SetTitle(_('Cloud of clouds : {}').format(self.idx))
else:
self.SetTitle(_('Cloud of clouds'))
self.init_struct = False
# ----------------------------------------------------------------
# Tree structure
# ----------------------------------------------------------------
[docs]
def _fill_structure(self):
"""Populate the tree list with current clouds."""
if not self.wx_exists or self.treelist is None:
return
self.treelist.DeleteAllItems()
root = self.treelist.GetRootItem()
mynode = self.treelist.AppendItem(root, self.idx or _('All clouds'), data=self)
self.treelist.CheckItem(mynode)
for c in self.myclouds:
child = self.treelist.AppendItem(mynode, c.idx or _('(unnamed)'), data=c)
if c.plotted:
self.treelist.CheckItem(child)
self.treelist.Expand(mynode)
[docs]
def _activate_cloud(self, cloud: cloud_vertices | None):
"""Set the active cloud and update the grid."""
self.active_cloud = cloud
if cloud is None:
if self.labelactcloud is not None:
self.labelactcloud.SetLabel(_('None'))
if self.xls is not None:
self.xls.ClearGrid()
return
if self.labelactcloud is not None:
self.labelactcloud.SetLabel(cloud.idx or _('(unnamed)'))
self._fill_grid()
# Propagate information about the active cloud to the mapviewer for potential use in vertex selection, etc.
if self.mapviewer is not None:
self.mapviewer.SetActiveCloud(cloud)
[docs]
def _fill_grid(self):
"""Fill the CpGrid with the active cloud's vertices."""
if self.xls is None or self.active_cloud is None:
return
self.xls.ClearGrid()
n = self.active_cloud.nbvertices
nrows = self.xls.GetNumberRows()
if n > nrows:
self.xls.AppendRows(n - nrows)
elif n < nrows:
self.xls.DeleteRows(0, nrows - n)
for i, v in enumerate(self.active_cloud.iter_on_vertices()):
self.xls.SetCellValue(i, 0, str(v.x))
self.xls.SetCellValue(i, 1, str(v.y))
self.xls.SetCellValue(i, 2, str(v.z))
# ----------------------------------------------------------------
# Tree event handlers
# ----------------------------------------------------------------
[docs]
def _on_check_item(self, event):
"""Handle check/uncheck of a tree item."""
myitem = event.GetItem()
check = self.treelist.GetCheckedState(myitem)
myitemdata = self.treelist.GetItemData(myitem)
if myitemdata is self:
if check:
self.check_plot()
else:
self.uncheck_plot()
elif isinstance(myitemdata, cloud_vertices):
if check:
myitemdata.check_plot()
else:
myitemdata.uncheck_plot()
if self.mapviewer is not None:
self.mapviewer.Refresh()
[docs]
def _on_activate_item(self, event):
"""Handle activation (double-click) of a tree item."""
myitem = event.GetItem()
myitemdata = self.treelist.GetItemData(myitem)
if isinstance(myitemdata, cloud_vertices):
self._activate_cloud(myitemdata)
else:
self._activate_cloud(None)
self.last_active = myitemdata
[docs]
def _on_rdown(self, event):
"""Handle right-click on a tree item: show properties."""
if isinstance(self.last_active, cloud_vertices):
self.last_active.show_properties()
elif self.last_active is self:
self._on_bulk_properties(None)
[docs]
def _on_edit_label(self, event):
"""Handle F2 key to rename a cloud."""
key = event.GetKeyCode()
if key == wx.WXK_F2:
if isinstance(self.last_active, cloud_vertices):
dlg = wx.TextEntryDialog(None, _('Choose a new name'),
value=self.last_active.myname)
if dlg.ShowModal() == wx.ID_OK:
self.last_active.myname = dlg.GetValue()
self._fill_structure()
dlg.Destroy()
else:
event.Skip()
# ----------------------------------------------------------------
# Left button handlers (collection operations)
# ----------------------------------------------------------------
[docs]
def _on_add_cloud(self, event):
"""Add a new empty cloud to the collection."""
dlg = wx.TextEntryDialog(None, _('Name for the new cloud'),
value=_('cloud_{}').format(self.nbclouds))
if dlg.ShowModal() == wx.ID_OK:
name = dlg.GetValue()
c = self.create_cloud(idx=name)
self._fill_structure()
self._activate_cloud(c)
dlg.Destroy()
[docs]
def _on_delete_cloud(self, event):
"""Remove the active cloud from the collection."""
if self.active_cloud is None:
wx.MessageBox(_('No active cloud to delete.'),
_('Warning'), wx.OK | wx.ICON_WARNING)
return
name = self.active_cloud.idx
dlg = wx.MessageDialog(None,
_('Delete cloud "{}"?').format(name),
_('Confirm'),
wx.YES_NO | wx.ICON_QUESTION)
if dlg.ShowModal() == wx.ID_YES:
idx = self.myclouds.index(self.active_cloud)
self.remove_cloud(idx)
self._activate_cloud(None)
self._fill_structure()
dlg.Destroy()
if self.mapviewer is not None:
self.mapviewer.Refresh()
[docs]
def _on_up_cloud(self, event):
"""Move the active cloud one position up."""
if self.active_cloud is None:
return
idx = self.myclouds.index(self.active_cloud)
if idx > 0:
self.myclouds[idx], self.myclouds[idx - 1] = self.myclouds[idx - 1], self.myclouds[idx]
self._fill_structure()
[docs]
def _on_down_cloud(self, event):
"""Move the active cloud one position down."""
if self.active_cloud is None:
return
idx = self.myclouds.index(self.active_cloud)
if idx < len(self.myclouds) - 1:
self.myclouds[idx], self.myclouds[idx + 1] = self.myclouds[idx + 1], self.myclouds[idx]
self._fill_structure()
[docs]
def _on_save_json(self, event):
"""Save the collection to a JSON file."""
dlg = wx.FileDialog(self, _('Save JSON'), wildcard='JSON files (*.json)|*.json',
style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT)
if dlg.ShowModal() == wx.ID_OK:
self.save_json(dlg.GetPath())
dlg.Destroy()
[docs]
def _on_load_json(self, event):
"""Load a collection from a JSON file."""
dlg = wx.FileDialog(self, _('Load JSON'), wildcard='JSON files (*.json)|*.json',
style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST)
if dlg.ShowModal() == wx.ID_OK:
loaded = type(self).load_json(dlg.GetPath(), mapviewer=self.mapviewer)
self.myclouds.clear()
self.idx = loaded.idx
for c in loaded.myclouds:
self.myclouds.append(c)
self._activate_cloud(None)
self._fill_structure()
self.find_minmax(force=True)
if self.idx:
self.SetTitle(_('Cloud of clouds : {}').format(self.idx))
dlg.Destroy()
if self.mapviewer is not None:
self.mapviewer.Refresh()
[docs]
def _on_merge(self, event):
"""Merge all clouds into a single new cloud and add it to the mapviewer."""
if self.nbclouds == 0:
return
dlg = wx.TextEntryDialog(None, _('Name for the merged cloud'),
value=_('merged'))
if dlg.ShowModal() == wx.ID_OK:
name = dlg.GetValue()
c = self.merge(idx=name)
if self.mapviewer is not None:
self.mapviewer.add_object(which='cloud', newobj=c, id=name)
else:
wx.MessageBox(_('No mapviewer available to add the merged cloud.'),
_('Warning'), wx.OK | wx.ICON_WARNING)
dlg.Destroy()
[docs]
def _on_bulk_properties(self, event):
"""Edit display properties for all clouds at once.
Only properties whose value is common across every cloud are
placed as *Active* parameters. Non-common properties appear
only in the *Default* page. The user can promote a Default
value by editing it; only Active values are propagated to
the individual clouds upon *Apply*.
"""
if self.nbclouds == 0:
wx.MessageBox(_('No clouds in the collection.'),
_('Warning'), wx.OK | wx.ICON_WARNING)
return
# (group, param_name, attribute_name, is_color_attr)
_BULK_PROPS = [
('Draw', 'Color', 'color', True),
('Draw', 'Width', 'width', False),
('Draw', 'Style', 'style', False),
('Draw', 'Rendering mode', 'renderingmode', False),
('Draw', 'Filled', 'filled', False),
('Draw', 'Package symbol', 'symbolpreset', False),
('Draw', 'Symbol source', 'symbolsource', False),
('Draw', 'Symbol raster size [px]', 'symbolrastersize', False),
('Draw', 'Symbol rotation [\u00b0]', 'symbolrotation', False),
('Draw', 'Symbol scale', 'symbolscale', False),
('Draw', 'Tint symbol with color', 'symboltintwithcolor', False),
('Draw', 'Highlight selected point', 'highlightselectedpoint', False),
('Draw', 'Highlight size factor', 'highlightselectedpointsizefactor', False),
('Draw', 'Highlight color', 'highlightselectedpointcolor', True),
('Draw', 'Transparent', 'transparent', False),
('Draw', 'Alpha', 'alpha', False),
('Draw', 'Animation speed', 'animationspeed', False),
('Draw', 'Animation amplitude', 'animationamplitude', False),
('Draw', 'Animation mode', 'animationmode', False),
('Legend', 'Visible', 'legendvisible', False),
('Legend', 'Text', 'legendtext', False),
('Legend', 'Relative position', 'legendrelpos', False),
('Legend', 'X', 'legendx', False),
('Legend', 'Y', 'legendy', False),
('Legend', 'Bold', 'legendbold', False),
('Legend', 'Italic', 'legenditalic', False),
('Legend', 'Font name', 'legendfontname', False),
('Legend', 'Font size', 'legendfontsize', False),
('Legend', 'Color', 'legendcolor', True),
('Legend', 'Underlined', 'legendunderlined', False),
('Legend', 'Width', 'legendwidth', False),
('Legend', 'Height', 'legendheight', False),
('Legend', 'Orientation', 'legendorientation', False),
('Legend', 'Priority', 'legendpriority', False),
]
def _to_wp(val, is_color):
"""Convert raw attribute value to Wolf_Param format."""
return tuple(getRGBfromI(val)) if is_color else val
def _from_wp(val, is_color):
"""Convert Wolf_Param value to raw attribute format."""
return getIfromRGB(val) if is_color else val
def _color_key(val):
"""Normalize a colour value to a comparable 3-tuple."""
if isinstance(val, (list, tuple)):
return tuple(int(x) for x in val[:3])
return val
# ---- build a temporary dialog --------------------------------
bulk_prop = cloudproperties()
bulk_prop.defaultprop()
wp = bulk_prop.myprops
wp.myparams.clear()
# Sanitize symbolpreset: legacy values not in enum choices -> fallback
preset_choices = cloudproperties._list_package_symbols()
custom_choice = cloudproperties.CUSTOM_SYMBOL_CHOICE
initial_defaults = {} # (group, param) -> Wolf_Param-formatted value
for group, param, attr, is_color in _BULK_PROPS:
raw_values = [getattr(c.myprop, attr) for c in self.myclouds]
if attr == 'symbolpreset':
raw_values = [(v if v in preset_choices else custom_choice) for v in raw_values]
wp_values = [_to_wp(v, is_color) for v in raw_values]
# Set Default to first cloud's value (as reference)
if group in wp.myparams_default and param in wp.myparams_default[group]:
wp.myparams_default[group][param][key_Param.VALUE] = wp_values[0]
initial_defaults[(group, param)] = wp_values[0]
# Common value -> promote to Active
if is_color:
is_common = all(_color_key(v) == _color_key(wp_values[0]) for v in wp_values)
else:
is_common = all(v == wp_values[0] for v in wp_values)
if is_common:
wp[(group, param)] = wp_values[0]
clouds = self.myclouds
def _bulk_callback():
"""Propagate Active values to all clouds."""
for group, param, attr, is_color in _BULK_PROPS:
if wp.is_in_active(group, param):
val = _from_wp(wp[(group, param)], is_color)
for c in clouds:
setattr(c.myprop, attr, val)
c.forceupdategl = True
if self.mapviewer is not None:
self.mapviewer.Refresh()
def _bulk_apply(event):
"""Handle Active edits and Default-to-Active promotions."""
modified_active = wp.prop.IsPageModified(0)
modified_default = wp.prop.IsPageModified(1)
if not modified_active and not modified_default:
dlg = wx.MessageDialog(None, _('Nothing to do!'))
dlg.ShowModal()
dlg.Destroy()
return
# Standard processing of Active params
for group in wp.myparams_default.keys():
for param_name in wp.myparams_default[group].keys():
wp._Apply1ParamtoMemory(group, param_name)
# Promote edited Default values to Active
if modified_default:
for group, param, __, is_color in _BULK_PROPS:
if not wp.is_in_active(group, param) and wp.is_in_default(group, param):
try:
prop_obj = wp.prop.GetPropertyByName(PREFIX_DEFAULT + group + param)
if prop_obj is None:
continue
raw_val = prop_obj.m_value
param_dict = wp.myparams_default[group][param]
typed_val = wp.value_as_type(
raw_val,
param_dict[key_Param.TYPE],
param_dict.get(key_Param.ENUM_CHOICES))
initial = initial_defaults[(group, param)]
if is_color:
changed = _color_key(typed_val) != _color_key(initial)
else:
changed = typed_val != initial
if changed:
wp[(group, param)] = typed_val
except Exception:
pass
_bulk_callback()
wp._callback = _bulk_callback
# Replace the Apply button handler with our custom one
wp.applychange.Unbind(wx.EVT_BUTTON)
wp.applychange.Bind(wx.EVT_BUTTON, _bulk_apply)
wp.Populate()
wp.Show()
# ----------------------------------------------------------------
# Right button handlers (per-cloud / vertex operations)
# ----------------------------------------------------------------
[docs]
def _on_addrows(self, event):
"""Add rows to the vertex grid."""
dlg = wx.TextEntryDialog(None, _('How many rows?'), value='1')
if dlg.ShowModal() == wx.ID_OK:
try:
n = int(dlg.GetValue())
self.xls.AppendRows(n)
except ValueError:
pass
dlg.Destroy()
[docs]
def _on_update_vertices(self, event):
"""Transfer coordinates from the grid back to the active cloud."""
if self.active_cloud is None:
wx.MessageBox(_('No active cloud.'),
_('Warning'), wx.OK | wx.ICON_WARNING)
return
vertices = []
nrows = self.xls.GetNumberRows()
for i in range(nrows):
sx = self.xls.GetCellValue(i, 0).strip()
sy = self.xls.GetCellValue(i, 1).strip()
sz = self.xls.GetCellValue(i, 2).strip()
if sx == '' and sy == '':
continue
try:
x = float(sx) if sx else 0.0
y = float(sy) if sy else 0.0
z = float(sz) if sz else 0.0
vertices.append(wolfvertex(x, y, z))
except ValueError:
continue
self.active_cloud.myvertices.clear()
for i, v in enumerate(vertices):
self.active_cloud.add_vertex(vertextoadd=v, id=i)
self.active_cloud.find_minmax(force=True)
self.active_cloud.reset_listogl()
self.find_minmax(force=True)
self._fill_grid()
if self.mapviewer is not None:
self.mapviewer.Refresh()
[docs]
def _on_add_vertex(self, event):
"""Add vertices to the active cloud."""
if self.active_cloud is None:
wx.MessageBox(_('No active cloud.'),
_('Warning'), wx.OK | wx.ICON_WARNING)
return
if self.mapviewer is None:
wx.MessageBox(_('No mapviewer attached'),
_('Information'), wx.OK | wx.ICON_INFORMATION)
self.mapviewer.add_points_to_cloud()
self.active_cloud.reset_listogl()
self._fill_grid()
if self.mapviewer is not None:
self.mapviewer.Refresh()
[docs]
def _on_modify_vertex(self, event):
"""Remove the selected vertex from the active cloud."""
if self.active_cloud is None:
wx.MessageBox(_('No active cloud.'),
_('Warning'), wx.OK | wx.ICON_WARNING)
return
if self.mapviewer is None:
wx.MessageBox(_('No mapviewer attached'),
_('Information'), wx.OK | wx.ICON_INFORMATION)
self.mapviewer.move_point_in_cloud()
[docs]
def _on_plot_mpl(self, event):
"""Plot the active cloud in a Matplotlib window."""
if self.active_cloud is None:
wx.MessageBox(_('No active cloud.'),
_('Warning'), wx.OK | wx.ICON_WARNING)
return
import matplotlib.pyplot as plt
fig, ax = self.active_cloud.plot_matplotlib()
ax.set_title(self.active_cloud.idx or _('Cloud'))
ax.set_aspect('equal')
plt.show()
[docs]
def _on_zoom(self, event):
"""Zoom the mapviewer to the active cloud's extent."""
if self.active_cloud is None or self.mapviewer is None:
return
self.active_cloud.find_minmax(force=True)
xmin, xmax = self.active_cloud.xbounds
ymin, ymax = self.active_cloud.ybounds
if xmin == xmax and ymin == ymax:
return
self.mapviewer.zoom_on(zoom_dict={'xmin': xmin, 'xmax': xmax,
'ymin': ymin, 'ymax': ymax})
[docs]
def _on_cloud_properties(self, event):
"""Open the properties dialog for the active cloud."""
if self.active_cloud is None:
wx.MessageBox(_('No active cloud.'),
_('Warning'), wx.OK | wx.ICON_WARNING)
return
self.active_cloud.show_properties()