Source code for wolfhece.wolf_sculpt

"""
wolf_sculpt.py — Sculpting tools for Wolf digital terrain arrays.

Inspired by Blender's sculpt mode, adapted for DTM/DEM editing:
  • Smooth  — weighted average of neighbours within radius
  • Raise   — progressively raise terrain
  • Lower   — progressively lower (dig) terrain
  • Flatten — project cells toward a target elevation
  • Noise   — add random micro-relief

Each operation is controlled by a :class:`SculptBrush` that stores:
  - brush shape   (circle / square)
  - falloff curve (constant / linear / gaussian / sphere)
  - radius        (world units, typically metres)
  - intensity     (blend factor 0–1)
  - strength      (height delta per stroke for raise/lower/noise, metres)
  - flatten_value (target elevation for flatten mode)

A :class:`SculptPanel` wx.Frame lets the user tweak all parameters and
activate sculpting on the currently selected array in a WolfMapViewer.
"""

from __future__ import annotations

import logging
import math
import numpy as np
from enum import Enum

import wx

try:
    from scipy.ndimage import gaussian_filter as _gaussian_filter
[docs] _SCIPY_AVAILABLE = True
except ImportError: _SCIPY_AVAILABLE = False # Translation placeholder — will resolve at runtime from the application's # gettext setup if this module is imported after the locale is configured. from .PyTranslate import _ # ====================================================================== # Enumerations # ======================================================================
[docs] class SculptMode(Enum):
[docs] SMOOTH = 'smooth'
[docs] RAISE = 'raise'
[docs] LOWER = 'lower'
[docs] FLATTEN = 'flatten'
[docs] FLATTEN_PLANE = 'flatten_plane'
[docs] NOISE = 'noise'
[docs] class FalloffType(Enum):
[docs] CONSTANT = 'constant' # uniform application
[docs] LINEAR = 'linear' # linear fade from centre
[docs] GAUSSIAN = 'gaussian' # smooth bell curve
[docs] SIGMOID = 'sigmoid' # smooth S-curve transition
[docs] SPHERE = 'sphere' # cosine falloff
[docs] class BrushShape(Enum):
[docs] CIRCLE = 'circle'
[docs] SQUARE = 'square'
[docs] RECTANGLE = 'rectangle'
[docs] class ProfileShape(Enum): """Cross-section profile shape for the segment brush."""
[docs] SLOPE_1_1 = '1_1' # run:rise = 1:1 (45°)
[docs] SLOPE_3_2 = '3_2' # run:rise = 3:2
[docs] SLOPE_2_1 = '2_1' # run:rise = 2:1
[docs] SLOPE_3_1 = '3_1' # run:rise = 3:1
[docs] CIRCULAR = 'circular' # quarter-circle arc (concave at foot)
[docs] SEMI_CIRCULAR = 'semi_circular' # half-circle arc (vertical tangent at foot)
[docs] GABION = 'gabion' # stepped profile (gabion walls)
[docs] CUSTOM = 'custom' # user-defined normalised (t, z) pairs
# Horizontal run per unit of vertical rise for each profile shape. # For CUSTOM the caller must supply t ∈ [0,1] → z ∈ [0,1] mapping, # so segment_length = bank_height × 1.0 is a safe default.
[docs] _PROFILE_RUN_RISE: dict[ProfileShape, float] = { ProfileShape.SLOPE_1_1: 1.0, ProfileShape.SLOPE_3_2: 1.5, ProfileShape.SLOPE_2_1: 2.0, ProfileShape.SLOPE_3_1: 3.0, ProfileShape.CIRCULAR: 1.0, # horizontal reach ≈ bank_height (radius) ProfileShape.SEMI_CIRCULAR: 1.0, ProfileShape.GABION: 1.0, ProfileShape.CUSTOM: 1.0, }
# ====================================================================== # SculptBrush # ======================================================================
[docs] class SculptBrush: """Applies sculpting operations to a WolfArray at a given world position. All operations are fully vectorised using NumPy; masked (null) cells are never modified. An internal undo stack (up to *MAX_UNDO* strokes) lets the user step back one stroke at a time. """
[docs] MAX_UNDO: int = 20
def __init__(self) -> None:
[docs] self.mode: SculptMode = SculptMode.SMOOTH
[docs] self.falloff: FalloffType = FalloffType.GAUSSIAN
[docs] self.shape: BrushShape = BrushShape.CIRCLE
[docs] self.radius: float = 20.0 # metres (half-size for circle/square; half-length for rectangle)
[docs] self.rect_width: float = 20.0 # half-width of rectangle (metres, along local X)
[docs] self.rect_height: float = 40.0 # half-height of rectangle (metres, along local Y)
[docs] self.rect_angle: float = 0.0 # rotation of rectangle (degrees, CCW from East)
[docs] self.intensity: float = 0.5 # blend factor [0, 1]
[docs] self.strength: float = 0.5 # metres per full stroke
[docs] self.gaussian_sigma: float = 0.45 # spread of Gaussian falloff [0.1, 1.0]
[docs] self.gaussian_order: int = 1 # super-Gaussian order (1=normal, 2–5=flat+sharp edge)
[docs] self.sigmoid_center: float = 0.60 # transition point in normalised radius [0, 1]
[docs] self.sigmoid_steepness: float = 12.0 # transition steepness (>0)
[docs] self.flatten_value: float = 0.0 # target elevation for FLATTEN
[docs] self.flatten_auto: bool = True # derive target from neighbourhood median
[docs] self.search_zone_factor: float = 2.0 # multiplier applied to radius for auto search zone
# Flatten-plane parameters (FLATTEN_PLANE mode)
[docs] self.plane_slope_x: float = 0.0 # dz/dx (m/m)
[docs] self.plane_slope_y: float = 0.0 # dz/dy (m/m)
[docs] self.plane_z_ref: float = 0.0 # z at reference point
[docs] self.plane_ref_x: float = 0.0 # reference point X (world coords)
[docs] self.plane_ref_y: float = 0.0 # reference point Y (world coords)
[docs] self.plane_auto: bool = True # fit plane from neighbourhood at each stroke
# Optional callback fired after each auto plane-fit (set by SculptPanel)
[docs] self._on_plane_fitted: object = None
[docs] self._undo: list[tuple] = []
# Kernel cache — tuple (key, di, dj, w) reused when brush params unchanged
[docs] self._kernel_cache: tuple | None = None
# Cut/fill reference snapshot (set by freeze_reference, cleared by clear_reference)
[docs] self._ref_array: np.ndarray | None = None
[docs] self._ref_null: float | None = None
# ------------------------------------------------------------------ # Kernel cache (grid-space relative offsets + weights) # ------------------------------------------------------------------
[docs] def _kernel_key(self, dx: float, dy: float) -> tuple: return (round(self.radius, 6), round(dx, 9), round(dy, 9), self.falloff.value, self.shape.value, round(self.intensity, 6), round(self.gaussian_sigma, 6), int(self.gaussian_order), round(self.sigmoid_center, 6), round(self.sigmoid_steepness, 6), round(self.rect_width, 6), round(self.rect_height, 6), round(self.rect_angle, 6))
[docs] def _build_kernel(self, dx: float, dy: float): """Pre-compute (di, dj, weights) in grid coordinates. di, dj are integer offsets from the brush centre cell. weights already include *intensity* scaling. This is called ONCE when any brush parameter changes. """ if self.shape == BrushShape.RECTANGLE: # Bounding box in grid coords: account for rotation via max corner distance hw = self.rect_width hh = self.rect_height ang = math.radians(self.rect_angle) cos_a, sin_a = math.cos(ang), math.sin(ang) # Worst-case half-extents after rotation r_env_x = abs(hw * cos_a) + abs(hh * sin_a) r_env_y = abs(hw * sin_a) + abs(hh * cos_a) ni = int(np.ceil(r_env_x / dx)) nj = int(np.ceil(r_env_y / dy)) else: ni = int(np.ceil(self.radius / dx)) nj = int(np.ceil(self.radius / dy)) di_r = np.arange(-ni, ni + 1) dj_r = np.arange(-nj, nj + 1) di_g, dj_g = np.meshgrid(di_r, dj_r, indexing='ij') # World offsets from brush centre wx_g = di_g * dx wy_g = dj_g * dy if self.shape == BrushShape.RECTANGLE: # Rotate world offsets into brush-local frame cos_a = math.cos(math.radians(self.rect_angle)) sin_a = math.sin(math.radians(self.rect_angle)) # u = local X (East when angle=0), bounded by rect_width # v = local Y (North when angle=0), bounded by rect_height # — matches the GL cursor convention: hw=rect_width (East), hh=rect_height (North) u = wx_g * cos_a + wy_g * sin_a # local X axis (East at angle=0) v = -wx_g * sin_a + wy_g * cos_a # local Y axis (North at angle=0) shape_ok = (np.abs(u) <= self.rect_width) & (np.abs(v) <= self.rect_height) # Normalised distance: Chebyshev in the local frame (0 at centre, 1 at edge) nd = np.clip( np.maximum(np.abs(u) / self.rect_width, np.abs(v) / self.rect_height), 0.0, 1.0) elif self.shape == BrushShape.CIRCLE: dist = np.sqrt(wx_g ** 2 + wy_g ** 2) shape_ok = dist <= self.radius nd = np.clip(dist / self.radius, 0.0, 1.0) else: # SQUARE shape_ok = ((np.abs(wx_g) <= self.radius) & (np.abs(wy_g) <= self.radius)) nd = np.clip( np.maximum(np.abs(wx_g), np.abs(wy_g)) / self.radius, 0.0, 1.0) if self.falloff == FalloffType.CONSTANT: w = np.ones_like(nd) elif self.falloff == FalloffType.LINEAR: w = 1.0 - nd elif self.falloff == FalloffType.GAUSSIAN: sigma = max(self.gaussian_sigma, 0.01) order = max(int(self.gaussian_order), 1) exp_arg = (nd / sigma) ** (2 * order) w = np.exp(-0.5 * exp_arg) w_max = w.max() if w_max > 0.0: w /= w_max elif self.falloff == FalloffType.SIGMOID: c = float(np.clip(self.sigmoid_center, 0.0, 1.0)) k = max(float(self.sigmoid_steepness), 1e-3) raw = 1.0 / (1.0 + np.exp(k * (nd - c))) raw0 = 1.0 / (1.0 + np.exp(k * (0.0 - c))) raw1 = 1.0 / (1.0 + np.exp(k * (1.0 - c))) denom = max(raw0 - raw1, 1e-12) w = np.clip((raw - raw1) / denom, 0.0, 1.0) else: # SPHERE w = np.cos(nd * (np.pi / 2.0)) w *= self.intensity w[~shape_ok] = 0.0 active = shape_ok return di_g[active].astype(np.int32), dj_g[active].astype(np.int32), w[active]
[docs] def _get_kernel(self, dx: float, dy: float): """Return cached (di, dj, w) kernel; rebuild if params changed.""" key = self._kernel_key(dx, dy) if self._kernel_cache is None or self._kernel_cache[0] != key: di, dj, w = self._build_kernel(dx, dy) self._kernel_cache = (key, di, dj, w) return self._kernel_cache[1], self._kernel_cache[2], self._kernel_cache[3]
# ------------------------------------------------------------------ # Geometry — compute affected indices and weights # ------------------------------------------------------------------
[docs] def _compute_weights(self, array, cx: float, cy: float): """Return *(ii_flat, jj_flat, w_flat, ctx)* or *None* if brush misses. Uses the pre-computed kernel in grid coordinates. """ dx = array.dx dy = array.dy ox = array.origx + array.translx oy = array.origy + array.transly nbx = array.nbx nby = array.nby # Centre cell (0-based) ci = int(np.floor((cx - ox) / dx)) cj = int(np.floor((cy - oy) / dy)) di, dj, w_kern = self._get_kernel(dx, dy) ii_all = ci + di jj_all = cj + dj # Clip to array bounds valid = ((ii_all >= 0) & (ii_all < nbx) & (jj_all >= 0) & (jj_all < nby)) if not np.any(valid): return None ii = ii_all[valid] jj = jj_all[valid] w = w_kern[valid].copy() # Exclude masked / null cells not_masked = ~np.ma.getmaskarray(array.array)[ii, jj] if not np.any(not_masked): return None ii = ii[not_masked] jj = jj[not_masked] w = w[not_masked] # ctx carries what smooth needs i_min, i_max = int(ii.min()), int(ii.max()) j_min, j_max = int(jj.min()), int(jj.max()) arr_slice = array.array[i_min:i_max + 1, j_min:j_max + 1] ctx = (i_min, j_min, arr_slice) return ii, jj, w, ctx
# ------------------------------------------------------------------ # Public apply # ------------------------------------------------------------------
[docs] def apply(self, array, cx: float, cy: float, pressure: float = 1.0): """Apply the current brush to *array* at world position (*cx*, *cy*). *pressure* is a tablet stylus value in ``[0, 1]``. When ``1.0`` (default / mouse) the brush behaves normally. Lower values scale *intensity* and *strength* proportionally so that a light touch produces a weaker effect. Returns *(ii, jj)* — the 1-D index arrays of modified cells — or *None* if the brush missed all valid cells. """ result = self._compute_weights(array, cx, cy) if result is None: return None ii, jj, w, ctx = result # Snapshot cells before modification (for undo) self._push_undo(array, ii, jj) # Scale brush weights and strength by stylus pressure pressure = float(np.clip(pressure, 0.0, 1.0)) if pressure < 1.0: w = w * pressure data = array.array.data # underlying ndarray, bypass mask if self.mode == SculptMode.SMOOTH: self._apply_smooth(data, ii, jj, w, ctx) elif self.mode == SculptMode.RAISE: strength = self.strength * pressure data[ii, jj] = ( data[ii, jj].astype(np.float64) + strength * w ).astype(data.dtype) elif self.mode == SculptMode.LOWER: strength = self.strength * pressure data[ii, jj] = ( data[ii, jj].astype(np.float64) - strength * w ).astype(data.dtype) elif self.mode == SculptMode.FLATTEN: if self.flatten_auto: flat_target = self._compute_auto_flatten_target(array, cx, cy) else: flat_target = self.flatten_value orig = data[ii, jj].astype(np.float64) data[ii, jj] = ( orig + w * (flat_target - orig) ).astype(data.dtype) elif self.mode == SculptMode.FLATTEN_PLANE: if self.plane_auto: _result = self._compute_auto_flatten_plane(array, cx, cy) if _result is not None: self.plane_slope_x, self.plane_slope_y, self.plane_z_ref = _result self.plane_ref_x = cx self.plane_ref_y = cy ox_g = array.origx + array.translx oy_g = array.origy + array.transly xs = ox_g + (ii + 0.5) * array.dx ys = oy_g + (jj + 0.5) * array.dy flat_target = ( self.plane_z_ref + self.plane_slope_x * (xs - self.plane_ref_x) + self.plane_slope_y * (ys - self.plane_ref_y) ) orig = data[ii, jj].astype(np.float64) data[ii, jj] = (orig + w * (flat_target - orig)).astype(data.dtype) if self._on_plane_fitted is not None: try: self._on_plane_fitted() except Exception: pass elif self.mode == SculptMode.NOISE: rng = np.random.default_rng() strength = self.strength * pressure noise = rng.uniform(-strength, strength, size=ii.shape) data[ii, jj] = ( data[ii, jj].astype(np.float64) + w * noise ).astype(data.dtype) return ii, jj
# ------------------------------------------------------------------ # Smooth helper # ------------------------------------------------------------------
[docs] def _apply_smooth(self, data, ii, jj, w, ctx): """Smooth local region with a Gaussian (or box) filter, then blend.""" i_min, j_min, arr_slice = ctx region = arr_slice.data.astype(np.float64) if _SCIPY_AVAILABLE: smoothed = _gaussian_filter(region, sigma=1.0, mode='reflect') else: # 3×3 box average via padding pad = np.pad(region, 1, mode='reflect') ni, nj = region.shape smoothed = np.zeros_like(region) for di in range(3): for dj in range(3): smoothed += pad[di:di + ni, dj:dj + nj] smoothed /= 9.0 ri = ii - i_min rj = jj - j_min orig = data[ii, jj].astype(np.float64) target = smoothed[ri, rj] data[ii, jj] = (orig + w * (target - orig)).astype(data.dtype)
# ------------------------------------------------------------------ # Auto flatten target # ------------------------------------------------------------------
[docs] def _compute_auto_flatten_target(self, array, cx: float, cy: float) -> float: """Return the median elevation of unmasked cells in a 2×-radius zone. Used when ``flatten_auto`` is *True*; falls back to ``self.flatten_value`` when no valid cells are found. """ dx = array.dx dy = array.dy ox = array.origx + array.translx oy = array.origy + array.transly nbx = array.nbx nby = array.nby ci = int(np.floor((cx - ox) / dx)) cj = int(np.floor((cy - oy) / dy)) zone_r = self.search_zone_factor * self.radius ni = int(np.ceil(zone_r / dx)) nj = int(np.ceil(zone_r / dy)) i0 = max(0, ci - ni) i1 = min(nbx - 1, ci + ni) j0 = max(0, cj - nj) j1 = min(nby - 1, cj + nj) if i0 > i1 or j0 > j1: return self.flatten_value patch = array.array[i0:i1 + 1, j0:j1 + 1] mask = np.ma.getmaskarray(patch) vals = patch.data[~mask] if vals.size == 0: return self.flatten_value return float(np.median(vals))
# ------------------------------------------------------------------ # Auto flatten-plane target # ------------------------------------------------------------------
[docs] def _compute_auto_flatten_plane( self, array, cx: float, cy: float ) -> tuple[float, float, float] | None: """Fit a least-squares plane z = sx*(x-cx) + sy*(y-cy) + z_ref to the 2×-radius neighbourhood. Returns *(slope_x, slope_y, z_ref)* or *None* if fewer than 3 valid (unmasked) cells are available. """ dx = array.dx dy = array.dy ox = array.origx + array.translx oy = array.origy + array.transly nbx = array.nbx nby = array.nby ci = int(np.floor((cx - ox) / dx)) cj = int(np.floor((cy - oy) / dy)) zone_r = self.search_zone_factor * self.radius ni = int(np.ceil(zone_r / dx)) nj = int(np.ceil(zone_r / dy)) i0 = max(0, ci - ni); i1 = min(nbx - 1, ci + ni) j0 = max(0, cj - nj); j1 = min(nby - 1, cj + nj) if i0 > i1 or j0 > j1: return None patch = array.array[i0:i1 + 1, j0:j1 + 1] mask = np.ma.getmaskarray(patch) ii_loc = np.arange(i0, i1 + 1) jj_loc = np.arange(j0, j1 + 1) ii_g, jj_g = np.meshgrid(ii_loc, jj_loc, indexing='ij') xs = ox + (ii_g + 0.5) * dx ys = oy + (jj_g + 0.5) * dy zs = patch.data.astype(np.float64) valid = ~mask if valid.sum() < 3: return None x_v = xs[valid].ravel() y_v = ys[valid].ravel() z_v = zs[valid].ravel() # Centre coordinates to improve numerical conditioning A = np.column_stack([x_v - cx, y_v - cy, np.ones(len(x_v))]) sol, __, __, __ = np.linalg.lstsq(A, z_v, rcond=None) return float(sol[0]), float(sol[1]), float(sol[2])
# ------------------------------------------------------------------ # Undo # ------------------------------------------------------------------
[docs] def _push_undo(self, array, ii, jj) -> None: snapshot = (ii.copy(), jj.copy(), array.array.data[ii, jj].copy()) self._undo.append(snapshot) if len(self._undo) > self.MAX_UNDO: self._undo.pop(0)
[docs] def undo(self, array) -> tuple | None: """Restore the last saved snapshot. Returns *(ii, jj)* of the restored cells so the caller can do a partial GL update, or *None* if the stack is empty. """ if not self._undo: return None ii, jj, vals = self._undo.pop() array.array.data[ii, jj] = vals return ii, jj
[docs] def clear_undo(self) -> None: self._undo.clear()
# ------------------------------------------------------------------ # Reference snapshot (used by CutFillOverlay for earthwork volumes) # ------------------------------------------------------------------
[docs] def freeze_reference(self, wa) -> None: """Snapshot the current array state as the cut/fill baseline. Call once when sculpting is activated so that ``CutFillOverlay`` can compute cumulative cut/fill volumes relative to the original terrain. Has no effect on sculpt operations themselves. """ self._ref_array = wa.array.data.copy() self._ref_null = float(wa.nullvalue)
[docs] def clear_reference(self) -> None: """Release the frozen reference (call when deactivating).""" self._ref_array = None self._ref_null = None
# ====================================================================== # ProfileBrush — segment-based cross-section sculpting # ======================================================================
[docs] class ProfileBrush: """Segment-based cross-section profile brush. The user clicks a point on the bank (foot **or** head, controlled by ``click_is_foot``). For each click the brush: 1. Estimates the local steepest-ascent direction from a *frozen* reference array (snapshot taken once when the mode is activated). The gradient is Gaussian-weighted over ``gradient_search_radius`` to produce a spatially smooth, stable orientation even when painting progressively along a bank from upstream to downstream. 2. Computes the segment from the clicked point in that direction. Segment length = ``bank_height × run_over_rise`` (deduced from the chosen ``profile_shape``). 3. For every grid cell inside a perpendicular corridor of half-width ``corridor_half_width``, blends the current elevation toward the target elevation given by the normalised profile. A cosine falloff is applied perpendicular to the segment axis so that the modification feathers smoothly into the surrounding terrain. 4. Optionally restricts the modification to excavation only (``allow_cut_only=True``), which is the default so that a natural bank replaces a vertical wall without raising the thalweg. All internal computations use the normalised parameter *t* ∈ [0, 1]. The user always works in world units (metres): ``bank_height``, ``corridor_half_width``, ``gradient_search_radius``. """
[docs] MAX_UNDO: int = 20
def __init__(self) -> None:
[docs] self.profile_shape: ProfileShape = ProfileShape.SLOPE_2_1
[docs] self.bank_height: float = 2.0 # total vertical rise of the bank (m)
[docs] self.corridor_half_width: float = 1.0 # perpendicular corridor half-width (m)
[docs] self.gradient_search_radius: float = 20.0 # gradient smoothing radius (m)
[docs] self.allow_cut_only: bool = True # only excavate, no fill
[docs] self.click_is_foot: bool = True # True → clicked pt = foot; False → head
[docs] self.gabion_step: float = 0.5 # step height (= width) for GABION (m)
[docs] self.custom_profile: list[tuple[float, float]] = [] # normalised (t, z) pairs
# Frozen reference array — set once when mode activates, cleared on deactivate.
[docs] self._ref_array: np.ndarray | None = None
[docs] self._ref_null: float | None = None
# Eikonal-refined direction cache: (cx, cy, ux, uy) # Set by refine_direction_eikonal(), cleared by clear_eikonal_cache(). # When set, _estimate_gradient_dir returns this direction if the # query point is within gradient_search_radius of the cached position.
[docs] self._eikonal_dir_cache: tuple[float, float, float, float] | None = None
# Undo stack: each entry is a list of (ix, iy, old_z, new_z)
[docs] self._undo: list[list[tuple[int, int, float, float]]] = []
# ------------------------------------------------------------------ # Profile geometry # ------------------------------------------------------------------ @property
[docs] def run_over_rise(self) -> float: """Horizontal run per unit of vertical rise (dimensionless).""" return _PROFILE_RUN_RISE.get(self.profile_shape, 1.0)
@property
[docs] def segment_length(self) -> float: """Horizontal length of the profile segment, deduced from ``bank_height`` and the slope ratio of the selected shape (m).""" return self.bank_height * self.run_over_rise
[docs] def profile_z_normalized(self, t: np.ndarray) -> np.ndarray: """Return normalised elevation z ∈ [0, 1] at parameter t ∈ [0, 1]. *t* = 0 corresponds to the foot of the bank (z = z_foot); *t* = 1 corresponds to the head (z = z_foot + bank_height). """ t = np.clip(np.asarray(t, dtype=float), 0.0, 1.0) ps = self.profile_shape if ps in (ProfileShape.SLOPE_1_1, ProfileShape.SLOPE_3_2, ProfileShape.SLOPE_2_1, ProfileShape.SLOPE_3_1): # All linear slopes share the same normalised shape z = t. # The different run:rise ratios only affect segment_length. return t.copy() elif ps == ProfileShape.CIRCULAR: # Quarter-circle: concave, tangent to horizontal at foot (t=0). # z(t) = 1 − √(1 − t²) → rises slowly, then steeply near the top. return 1.0 - np.sqrt(np.maximum(0.0, 1.0 - t ** 2)) elif ps == ProfileShape.SEMI_CIRCULAR: # Semi-circle: vertical tangent at foot (t=0), horizontal at head. # z(t) = √(1 − (1 − t)²) → rises steeply, then flattens. return np.sqrt(np.maximum(0.0, 1.0 - (1.0 - t) ** 2)) elif ps == ProfileShape.GABION: # Stepped profile: each step is gabion_step metres high (and wide). if self.bank_height <= 0.0 or self.gabion_step <= 0.0: return t.copy() step_norm = self.gabion_step / self.bank_height # Floor to nearest step boundary; cap at 1 for the topmost step. return np.minimum(1.0, np.floor(t / step_norm) * step_norm) elif ps == ProfileShape.CUSTOM and self.custom_profile: pts = sorted(self.custom_profile, key=lambda p: p[0]) ts_arr = np.array([p[0] for p in pts]) zs_arr = np.array([p[1] for p in pts]) return np.interp(t, ts_arr, zs_arr) return t.copy() # safe fallback
# ------------------------------------------------------------------ # Reference snapshot # ------------------------------------------------------------------
[docs] def freeze_reference(self, wa) -> None: """Freeze the current array as the gradient-estimation reference. Must be called once when the segment mode is activated. The frozen copy is used for *every* subsequent stroke so that progressive bank shaping never biases its own orientation estimates. """ self._ref_array = wa.array.data.copy() self._ref_null = float(wa.nullvalue)
[docs] def clear_reference(self) -> None: """Release the frozen reference (call when deactivating the mode).""" self._ref_array = None self._ref_null = None self._eikonal_dir_cache = None
# ------------------------------------------------------------------ # Gradient direction estimation # ------------------------------------------------------------------
[docs] def clear_eikonal_cache(self) -> None: """Discard any cached eikonal direction (reverts to Gaussian gradient).""" self._eikonal_dir_cache = None
[docs] def refine_direction_eikonal(self, cx: float, cy: float, wa) -> tuple[float, float]: """Compute a robust slope direction at *(cx, cy)* using the Fast-Marching (eikonal) method and store the result in the cache. A local patch of radius ``3 × gradient_search_radius`` is extracted from the *frozen* reference (or live array when no snapshot exists). The eikonal equation ``|∇T| = 1 / F`` is solved isotropically with ``F = ‖∇z‖ + ε``, so that the front advances faster where the terrain is steeper. The negative gradient of the arrival-time field ``−∇T`` at the source cell gives the steepest-ascent direction that is globally consistent with the topography — not just a local finite-difference estimate. The result is stored in ``_eikonal_dir_cache``. Subsequent calls to :meth:`_estimate_gradient_dir` return the cached direction directly when the query point lies within ``gradient_search_radius`` of the cached position. Call :meth:`clear_eikonal_cache` to revert to the Gaussian estimator. Returns ``(ux, uy)`` — a unit vector — or ``(1.0, 0.0)`` on failure. """ try: from wolfhece.eikonal import _solve_eikonal_with_data_second_order except ImportError: return self._estimate_gradient_dir(cx, cy, wa) ref = self._ref_array if self._ref_array is not None else wa.array.data null_val = self._ref_null if self._ref_null is not None else float(wa.nullvalue) dx, dy = wa.dx, wa.dy ox = wa.origx + wa.translx oy = wa.origy + wa.transly # Extract a patch 3× the search radius around the click. r = max(self.gradient_search_radius * 3.0, dx * 4, dy * 4) ix0 = int((cx - ox) / dx) iy0 = int((cy - oy) / dy) ni = int(np.ceil(r / dx)) + 1 nj = int(np.ceil(r / dy)) + 1 i_lo = max(0, ix0 - ni); i_hi = min(wa.nbx - 1, ix0 + ni) j_lo = max(0, iy0 - nj); j_hi = min(wa.nby - 1, iy0 + nj) if i_lo >= i_hi or j_lo >= j_hi: return self._estimate_gradient_dir(cx, cy, wa) patch = ref[i_lo:i_hi + 1, j_lo:j_hi + 1].astype(np.float64) valid = (patch != null_val) if not np.any(valid): return self._estimate_gradient_dir(cx, cy, wa) # Fill null cells with local mean so gradients don't blow up at edges. fill_val = float(np.nanmean(patch[valid])) patch_f = np.where(valid, patch, fill_val) # Speed function F = ‖∇z‖ + ε (steeper = faster propagation). gz_x, gz_y = np.gradient(patch_f, dx, dy) F = np.sqrt(gz_x**2 + gz_y**2) + 1e-6 # avoid F=0 F = np.ascontiguousarray(F) rows, cols = patch_f.shape # Source cell coordinates inside the local patch. src_i = int(np.clip(ix0 - i_lo, 1, rows - 2)) src_j = int(np.clip(iy0 - j_lo, 1, cols - 2)) # where_compute: propagate everywhere (valid cells only). where_c = np.ascontiguousarray(valid.astype(np.bool_)) # base_data: z values that propagate with the front. base_d = np.ascontiguousarray(patch_f.copy()) test_d = np.ascontiguousarray(patch_f.copy()) try: _solve_eikonal_with_data_second_order( sources = [(src_i, src_j)], where_compute = where_c, base_data = base_d, test_data = test_d, speed = F, dx_mesh = dx, dy_mesh = dy, ) except Exception: return self._estimate_gradient_dir(cx, cy, wa) # base_d now carries propagated z values; gradient of T not # directly returned — instead we read ∇(base_d) as a proxy for # the direction. More reliably: take ∇(base_d) from the # propagated z field in the immediate neighbourhood of src. # Use a small Gaussian around the source to get a smooth vector. sigma_cells = max(2.0, self.gradient_search_radius / (2.0 * dx)) half = int(np.ceil(sigma_cells * 2.5)) bi0 = max(0, src_i - half); bi1 = min(rows - 1, src_i + half) bj0 = max(0, src_j - half); bj1 = min(cols - 1, src_j + half) sub = base_d[bi0:bi1 + 1, bj0:bj1 + 1] gx_sub, gy_sub = np.gradient(sub, dx, dy) ii_idx = (np.arange(bi0, bi1 + 1) - src_i) * dx jj_idx = (np.arange(bj0, bj1 + 1) - src_j) * dy ii_g, jj_g = np.meshgrid(ii_idx, jj_idx, indexing='ij') w_sub = np.exp(-(ii_g**2 + jj_g**2) / (2.0 * (sigma_cells * dx)**2)) w_sum = w_sub.sum() if w_sum < 1e-12: return self._estimate_gradient_dir(cx, cy, wa) w_sub /= w_sum mean_gx = float(np.sum(w_sub * gx_sub)) mean_gy = float(np.sum(w_sub * gy_sub)) mag = math.hypot(mean_gx, mean_gy) if mag < 1e-9: return self._estimate_gradient_dir(cx, cy, wa) ux, uy = mean_gx / mag, mean_gy / mag self._eikonal_dir_cache = (cx, cy, ux, uy) return ux, uy
[docs] def _estimate_gradient_dir(self, cx: float, cy: float, wa) -> tuple[float, float]: """Return the steepest-ascent unit vector at *(cx, cy)*. If a cached eikonal direction exists (from a prior call to :meth:`refine_direction_eikonal`) and the query point is within ``gradient_search_radius`` of the cached position, that direction is returned directly without any new computation. Otherwise the gradient is computed from the *frozen* reference array (or the current array data if no snapshot exists) and spatially weighted by a Gaussian kernel of radius ``gradient_search_radius``. Returns ``(ux, uy)`` — a unit vector — or ``(1.0, 0.0)`` when the local gradient magnitude is too small to be reliable. """ # ── Eikonal cache hit ────────────────────────────────────────── if self._eikonal_dir_cache is not None: ex, ey, ux, uy = self._eikonal_dir_cache dist = math.hypot(cx - ex, cy - ey) if dist <= self.gradient_search_radius: return ux, uy ref = self._ref_array if self._ref_array is not None else wa.array.data null_val = self._ref_null if self._ref_null is not None else float(wa.nullvalue) dx, dy = wa.dx, wa.dy ox = wa.origx + wa.translx oy = wa.origy + wa.transly r = max(self.gradient_search_radius, dx, dy) ix0 = int((cx - ox) / dx) iy0 = int((cy - oy) / dy) ni = int(np.ceil(r / dx)) + 1 nj = int(np.ceil(r / dy)) + 1 i_lo = max(0, ix0 - ni); i_hi = min(wa.nbx - 1, ix0 + ni) j_lo = max(0, iy0 - nj); j_hi = min(wa.nby - 1, iy0 + nj) if i_lo >= i_hi or j_lo >= j_hi: return 1.0, 0.0 patch = ref[i_lo:i_hi + 1, j_lo:j_hi + 1].astype(np.float64) patch[patch == null_val] = np.nan if np.all(np.isnan(patch)): return 1.0, 0.0 # Fill NaN with the local mean so np.gradient does not propagate NaN. fill_val = float(np.nanmean(patch)) patch_filled = np.where(np.isnan(patch), fill_val, patch) # Gaussian weights centred at the clicked cell; sigma = r / 2. ii_idx = (np.arange(i_lo, i_hi + 1) - ix0) * dx jj_idx = (np.arange(j_lo, j_hi + 1) - iy0) * dy ii_g, jj_g = np.meshgrid(ii_idx, jj_idx, indexing='ij') sigma = r / 2.0 weights = np.exp(-(ii_g ** 2 + jj_g ** 2) / (2.0 * sigma ** 2)) weights[np.isnan(patch)] = 0.0 w_sum = weights.sum() if w_sum < 1e-12: return 1.0, 0.0 weights /= w_sum # np.gradient with explicit spacings: axis-0 → x, axis-1 → y. grad_x, grad_y = np.gradient(patch_filled, dx, dy) mean_gx = float(np.sum(weights * grad_x)) mean_gy = float(np.sum(weights * grad_y)) mag = math.hypot(mean_gx, mean_gy) if mag < 1e-9: return 1.0, 0.0 return mean_gx / mag, mean_gy / mag
# ------------------------------------------------------------------ # Apply # ------------------------------------------------------------------
[docs] def apply( self, wa, cx: float, cy: float ) -> list[tuple[int, int, float, float]] | None: """Apply the profile at the clicked world point *(cx, cy)*. Returns a list of ``(ix, iy, old_z, new_z)`` tuples for the cells that were modified, or *None* if no cells were changed. """ if wa is None: return None # ── 1. Gradient direction ───────────────────────────────────── ux, uy = self._estimate_gradient_dir(cx, cy, wa) # ── 2. Segment endpoints ────────────────────────────────────── L = self.segment_length if L <= 0.0: return None if self.click_is_foot: foot_x, foot_y = cx, cy head_x = cx + ux * L head_y = cy + uy * L else: head_x, head_y = cx, cy foot_x = cx - ux * L foot_y = cy - uy * L # ── 3. Foot elevation from the *current* (modified) array ───── dx, dy = wa.dx, wa.dy ox = wa.origx + wa.translx oy = wa.origy + wa.transly data = wa.array.data null_val = float(wa.nullvalue) ix_foot = int(np.clip((foot_x - ox) / dx, 0, wa.nbx - 1)) iy_foot = int(np.clip((foot_y - oy) / dy, 0, wa.nby - 1)) z_foot = float(data[ix_foot, iy_foot]) if z_foot == null_val: return None # ── 4. Corridor bounding box ────────────────────────────────── # Perpendicular unit vector (90° CCW from segment axis) px, py = -uy, ux chw = self.corridor_half_width corners_x = [foot_x + px * chw, foot_x - px * chw, head_x + px * chw, head_x - px * chw] corners_y = [foot_y + py * chw, foot_y - py * chw, head_y + py * chw, head_y - py * chw] ix_lo = max(0, int(np.floor((min(corners_x) - ox) / dx)) - 1) ix_hi = min(wa.nbx - 1, int(np.ceil( (max(corners_x) - ox) / dx)) + 1) iy_lo = max(0, int(np.floor((min(corners_y) - oy) / dy)) - 1) iy_hi = min(wa.nby - 1, int(np.ceil( (max(corners_y) - oy) / dy)) + 1) if ix_lo > ix_hi or iy_lo > iy_hi: return None # ── 5. Vectorised kernel ────────────────────────────────────── ix_r = np.arange(ix_lo, ix_hi + 1) iy_r = np.arange(iy_lo, iy_hi + 1) IXg, IYg = np.meshgrid(ix_r, iy_r, indexing='ij') cell_x = ox + (IXg + 0.5) * dx cell_y = oy + (IYg + 0.5) * dy vx = cell_x - foot_x vy = cell_y - foot_y t_raw = vx * ux + vy * uy # axial projection (m) d_perp = np.abs(vx * px + vy * py) # perpendicular distance (m) # Initial mask: within the corridor rectangle in_corridor = (t_raw >= 0.0) & (t_raw <= L) & (d_perp <= chw) # Exclude masked / null cells data_patch = data[ix_lo:ix_hi + 1, iy_lo:iy_hi + 1] mask_patch = np.ma.getmaskarray(wa.array)[ix_lo:ix_hi + 1, iy_lo:iy_hi + 1] in_corridor &= (data_patch != null_val) & ~mask_patch if not np.any(in_corridor): return None # Normalised parameter and profile elevation t_norm = np.where(in_corridor, np.clip(t_raw / L, 0.0, 1.0), 0.0) z_prof_norm = self.profile_z_normalized(t_norm) z_target = z_foot + z_prof_norm * self.bank_height # Cosine falloff perpendicular to segment axis (1 on axis, 0 at corridor edge) corridor_w = np.where( in_corridor, 0.5 * (1.0 + np.cos(np.pi * d_perp / chw)), 0.0, ) z_current = data_patch.astype(np.float64) z_new = z_current * (1.0 - corridor_w) + z_target * corridor_w # Apply cut-only constraint (only modify cells that would be lowered) if self.allow_cut_only: in_corridor &= (z_new < z_current) idx_i, idx_j = np.where(in_corridor) if idx_i.size == 0: return None abs_i = ix_lo + idx_i abs_j = iy_lo + idx_j changes: list[tuple[int, int, float, float]] = [ (int(abs_i[k]), int(abs_j[k]), float(z_current[idx_i[k], idx_j[k]]), float(z_new [idx_i[k], idx_j[k]])) for k in range(len(idx_i)) ] # Push undo and apply self._undo.append(changes) if len(self._undo) > self.MAX_UNDO: self._undo.pop(0) for ix, iy, __, new_z in changes: data[ix, iy] = new_z return changes
# ------------------------------------------------------------------ # Undo # ------------------------------------------------------------------
[docs] def undo(self, wa) -> list[tuple[int, int]] | None: """Restore the last applied stroke. Returns a list of ``(ix, iy)`` for the restored cells, or *None* if the undo stack is empty. """ if not self._undo: return None changes = self._undo.pop() for ix, iy, old_z, __ in changes: wa.array.data[ix, iy] = old_z return [(ix, iy) for ix, iy, __, ___ in changes]
[docs] def clear_undo(self) -> None: self._undo.clear()
# ====================================================================== # wx UI panel # ======================================================================
[docs] _MODE_CHOICES = [ ('Smooth', SculptMode.SMOOTH), ('Raise', SculptMode.RAISE), ('Lower', SculptMode.LOWER), ('Flatten', SculptMode.FLATTEN), ('Flatten plane', SculptMode.FLATTEN_PLANE), ('Noise', SculptMode.NOISE), ]
[docs] _FALLOFF_CHOICES = [ ('Constant', FalloffType.CONSTANT), ('Linear', FalloffType.LINEAR), ('Gaussian', FalloffType.GAUSSIAN), ('Sigmoid', FalloffType.SIGMOID), ('Sphere', FalloffType.SPHERE), ]
[docs] _SHAPE_CHOICES = [ ('Circle', BrushShape.CIRCLE), ('Square', BrushShape.SQUARE), ('Rectangle', BrushShape.RECTANGLE), ]
[docs] class SculptPanel(wx.Frame): """Floating panel for sculpting brush parameters. Stays on top of the viewer. The *Activate sculpting* toggle button calls :meth:`WolfMapViewer.start_action` / ``end_action`` with the ``'sculpt'`` action string so that left-click / drag on the canvas applies the brush on the active array. """ def __init__(self, parent, mapviewer): super().__init__( parent, title=_('Sculpting tools'), style=(wx.DEFAULT_FRAME_STYLE | wx.FRAME_FLOAT_ON_PARENT | wx.FRAME_NO_TASKBAR), )
[docs] self.mapviewer = mapviewer
[docs] self.brush = SculptBrush()
self._build_ui() self.brush._on_plane_fitted = self.sync_plane_ui self._update_falloff_preview() # Minimum width only — height is driven dynamically by _update_visibility. self.SetSizeHints(minW=350, minH=-1) self.Bind(wx.EVT_CLOSE, self._on_close) # -------------------------------------------------------------- # UI construction # --------------------------------------------------------------
[docs] def _build_ui(self) -> None: panel = wx.Panel(self) vbox = wx.BoxSizer(wx.VERTICAL) # ---- Mode ---- mb = wx.StaticBox(panel, label=_('Mode')) ms = wx.StaticBoxSizer(mb, wx.VERTICAL) mode_grid = wx.GridSizer(rows=3, cols=2, vgap=2, hgap=2) self._mode_btns: dict = {} for label, mode in _MODE_CHOICES: btn = wx.ToggleButton(panel, label=_(label)) btn._sculpt_value = mode btn.Bind(wx.EVT_TOGGLEBUTTON, self._on_mode) self._mode_btns[mode] = btn mode_grid.Add(btn, 0, wx.EXPAND) ms.Add(mode_grid, 0, wx.EXPAND | wx.ALL, 2) self._mode_btns[SculptMode.SMOOTH].SetValue(True) vbox.Add(ms, 0, wx.EXPAND | wx.ALL, 5) # ---- Shape ---- sb = wx.StaticBox(panel, label=_('Shape')) ss = wx.StaticBoxSizer(sb, wx.HORIZONTAL) self._shape_btns: dict = {} for label, shape in _SHAPE_CHOICES: btn = wx.ToggleButton(panel, label=_(label)) btn._sculpt_value = shape btn.Bind(wx.EVT_TOGGLEBUTTON, self._on_shape) self._shape_btns[shape] = btn ss.Add(btn, 1, wx.EXPAND | wx.ALL, 2) self._shape_btns[BrushShape.CIRCLE].SetValue(True) vbox.Add(ss, 0, wx.EXPAND | wx.ALL, 5) # ---- Rectangle parameters (width / height / angle) ---- rectb = wx.StaticBox(panel, label=_('Rectangle')) rects = wx.StaticBoxSizer(rectb, wx.VERTICAL) rect_grid = wx.FlexGridSizer(cols=3, vgap=3, hgap=6) rect_grid.AddGrowableCol(1) self._rect_sliders: dict = {} def _add_rect_slider(label, attr, lo, hi, scale, init): lbl = wx.StaticText(panel, label=_(label)) slider = wx.Slider( panel, minValue=int(lo * scale), maxValue=int(hi * scale), value =int(init * scale), style=wx.SL_HORIZONTAL, ) val_lbl = wx.StaticText(panel, label=f'{init:.1f}', size=(48, -1)) slider._attr = attr slider._scale = scale slider._val_lbl = val_lbl slider.Bind(wx.EVT_SLIDER, self._on_rect_slider) rect_grid.Add(lbl, 0, wx.ALIGN_CENTER_VERTICAL) rect_grid.Add(slider, 1, wx.EXPAND) rect_grid.Add(val_lbl, 0, wx.ALIGN_CENTER_VERTICAL) self._rect_sliders[attr] = slider _add_rect_slider(_('Half-width (m)'), 'rect_width', 0.5, 500.0, 10.0, 20.0) _add_rect_slider(_('Half-length (m)'), 'rect_height', 0.5, 500.0, 10.0, 40.0) _add_rect_slider(_('Angle (°)'), 'rect_angle', 0.0, 360.0, 1.0, 0.0) rects.Add(rect_grid, 0, wx.EXPAND | wx.ALL, 3) self._rect_si = vbox.Add(rects, 0, wx.EXPAND | wx.ALL, 5) fb = wx.StaticBox(panel, label=_('Falloff')) fs = wx.StaticBoxSizer(fb, wx.VERTICAL) falloff_grid = wx.GridSizer(rows=3, cols=2, vgap=2, hgap=2) self._falloff_btns: dict = {} for label, falloff in _FALLOFF_CHOICES: btn = wx.ToggleButton(panel, label=_(label)) btn._sculpt_value = falloff btn.Bind(wx.EVT_TOGGLEBUTTON, self._on_falloff) self._falloff_btns[falloff] = btn falloff_grid.Add(btn, 0, wx.EXPAND) fs.Add(falloff_grid, 0, wx.EXPAND | wx.ALL, 2) self._falloff_btns[FalloffType.GAUSSIAN].SetValue(True) vbox.Add(fs, 0, wx.EXPAND | wx.ALL, 5) # ---- Falloff preview (embedded matplotlib figure) ---- self._preview_canvas = None self._preview_ax = None try: from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg from matplotlib.figure import Figure as _MplFig self._preview_fig = _MplFig(figsize=(3.5, 1.2), dpi=80, facecolor='#1a1a1a') self._preview_ax = self._preview_fig.add_subplot(111) self._preview_ax.set_facecolor('#1a1a1a') self._preview_fig.subplots_adjust( left=0.10, right=0.97, top=0.92, bottom=0.28) self._preview_canvas = FigureCanvasWxAgg( panel, wx.ID_ANY, self._preview_fig) self._preview_canvas.SetMinSize((-1, 100)) vbox.Add(self._preview_canvas, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 5) except Exception: pass # ---- Parameter sliders ---- grid = wx.FlexGridSizer(cols=3, vgap=4, hgap=6) grid.AddGrowableCol(1) self._sliders: dict = {} def _add_slider(label, attr, lo, hi, scale, init): lbl = wx.StaticText(panel, label=_(label)) slider = wx.Slider( panel, minValue=int(lo * scale), maxValue=int(hi * scale), value =int(init * scale), style=wx.SL_HORIZONTAL, ) val_lbl = wx.StaticText(panel, label=f'{init:.2f}', size=(52, -1)) slider._attr = attr slider._scale = scale slider._val_lbl = val_lbl slider.Bind(wx.EVT_SLIDER, self._on_slider) grid.Add(lbl, 0, wx.ALIGN_CENTER_VERTICAL) grid.Add(slider, 1, wx.EXPAND) grid.Add(val_lbl, 0, wx.ALIGN_CENTER_VERTICAL) self._sliders[attr] = slider _add_slider(_('Radius (m)'), 'radius', 1.0, 500.0, 10.0, 20.0) _add_slider(_('Intensity'), 'intensity', 0.0, 1.0, 100.0, 0.5) _add_slider(_('Strength (m)'), 'strength', 0.0, 20.0, 100.0, 0.5) _add_slider(_('G. spread (\u03c3)'), 'gaussian_sigma', 0.1, 1.0, 100.0, 0.45) vbox.Add(grid, 0, wx.EXPAND | wx.ALL, 5) # ---- Gaussian order (super-Gaussian steepness) ---- gord_row = wx.BoxSizer(wx.HORIZONTAL) gord_row.Add( wx.StaticText(panel, label=_('G. order (1=normal, 2–5=steeper edge):')), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 4, ) self._gauss_order_spin = wx.SpinCtrl( panel, value='1', min=1, max=5, size=(52, -1)) self._gauss_order_spin.SetValue(self.brush.gaussian_order) self._gauss_order_spin.SetToolTip( _('Super-Gaussian order: 1 = standard Gaussian bell curve\n' '2–5 = flat plateau at centre + sharp drop at brush edge')) self._gauss_order_spin.Bind(wx.EVT_SPINCTRL, self._on_gauss_order) gord_row.Add(self._gauss_order_spin, 0) self._gord_si = vbox.Add(gord_row, 0, wx.EXPAND | wx.ALL, 5) # ---- Sigmoid params ---- sig_row = wx.BoxSizer(wx.HORIZONTAL) sig_row.Add( wx.StaticText(panel, label=_('S. center / steepness:')), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 4, ) self._sig_center_spin = wx.SpinCtrlDouble( panel, value='0.60', min=0.0, max=1.0, inc=0.01, size=(62, -1)) self._sig_center_spin.SetDigits(2) self._sig_center_spin.SetValue(self.brush.sigmoid_center) self._sig_center_spin.SetToolTip( _('Sigmoid transition centre in normalised radius [0..1]')) self._sig_center_spin.Bind(wx.EVT_SPINCTRLDOUBLE, self._on_sigmoid_params) sig_row.Add(self._sig_center_spin, 0, wx.RIGHT, 8) self._sig_steep_spin = wx.SpinCtrlDouble( panel, value='12.0', min=0.1, max=60.0, inc=0.5, size=(70, -1)) self._sig_steep_spin.SetDigits(1) self._sig_steep_spin.SetValue(self.brush.sigmoid_steepness) self._sig_steep_spin.SetToolTip( _('Sigmoid transition steepness (>0); higher = sharper edge')) self._sig_steep_spin.Bind(wx.EVT_SPINCTRLDOUBLE, self._on_sigmoid_params) sig_row.Add(self._sig_steep_spin, 0) self._sig_si = vbox.Add(sig_row, 0, wx.EXPAND | wx.ALL, 5) sig_preset_row = wx.BoxSizer(wx.HORIZONTAL) sig_preset_row.Add( wx.StaticText(panel, label=_('S. presets:')), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 6, ) self._sig_preset_btns: dict = {} for _label, _preset in ( ('Soft', 'soft'), ('Medium', 'medium'), ('Hard', 'hard'), ): _btn = wx.Button(panel, label=_(_label), size=(70, -1)) _btn._sig_preset = _preset _btn.Bind(wx.EVT_BUTTON, self._on_sigmoid_preset) self._sig_preset_btns[_preset] = _btn sig_preset_row.Add(_btn, 0, wx.RIGHT, 4) self._sig_pres_si = vbox.Add(sig_preset_row, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 5) # ---- Precise radius input ---- rad_exact_row = wx.BoxSizer(wx.HORIZONTAL) rad_exact_row.Add( wx.StaticText(panel, label=_('Exact radius (m):')), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 4, ) self._radius_exact = wx.TextCtrl( panel, value='20.00', size=(70, -1), style=wx.TE_PROCESS_ENTER, ) self._radius_exact.Bind(wx.EVT_TEXT_ENTER, self._on_radius_exact) self._radius_exact.Bind(wx.EVT_KILL_FOCUS, self._on_radius_exact) self._grid_hint_lbl = wx.StaticText(panel, label='', size=(140, -1)) rad_exact_row.Add(self._radius_exact, 0) rad_exact_row.Add(self._grid_hint_lbl, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 8) vbox.Add(rad_exact_row, 0, wx.EXPAND | wx.ALL, 5) # ---- Flatten target ---- flt_row = wx.BoxSizer(wx.HORIZONTAL) flt_row.Add( wx.StaticText(panel, label=_('Flatten target (m):')), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5, ) self._flatten_ctrl = wx.TextCtrl( panel, value='0.0', style=wx.TE_PROCESS_ENTER, ) self._flatten_ctrl.Bind(wx.EVT_TEXT_ENTER, self._on_flatten) self._flatten_ctrl.Bind(wx.EVT_KILL_FOCUS, self._on_flatten) flt_row.Add(self._flatten_ctrl, 1, wx.EXPAND) self._flatten_auto_cb = wx.CheckBox(panel, label=_('Auto')) self._flatten_auto_cb.SetToolTip( _('Derive target from median of a 2× radius neighbourhood')) self._flatten_auto_cb.Bind(wx.EVT_CHECKBOX, self._on_flatten_auto) self._flatten_auto_cb.SetValue(self.brush.flatten_auto) flt_row.Add(self._flatten_auto_cb, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 6) self._flt_si = vbox.Add(flt_row, 0, wx.EXPAND | wx.ALL, 5) # ---- Flatten plane parameters ---- fplb = wx.StaticBox(panel, label=_('Flatten plane')) fpls = wx.StaticBoxSizer(fplb, wx.VERTICAL) fpl_row1 = wx.BoxSizer(wx.HORIZONTAL) fpl_row1.Add(wx.StaticText(panel, label=_('dz/dx (m/m):')), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 4) self._plane_sx = wx.TextCtrl(panel, value='0.00000', style=wx.TE_PROCESS_ENTER, size=(72, -1)) fpl_row1.Add(self._plane_sx, 0) fpl_row1.Add(wx.StaticText(panel, label=_('dz/dy:')), 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT | wx.RIGHT, 4) self._plane_sy = wx.TextCtrl(panel, value='0.00000', style=wx.TE_PROCESS_ENTER, size=(72, -1)) fpl_row1.Add(self._plane_sy, 0) fpls.Add(fpl_row1, 0, wx.EXPAND | wx.ALL, 3) fpl_row2 = wx.BoxSizer(wx.HORIZONTAL) fpl_row2.Add(wx.StaticText(panel, label=_('Z ref (m):')), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 4) self._plane_zr = wx.TextCtrl(panel, value='0.000', style=wx.TE_PROCESS_ENTER, size=(72, -1)) fpl_row2.Add(self._plane_zr, 0) self._plane_auto_cb = wx.CheckBox(panel, label=_('Auto')) self._plane_auto_cb.SetValue(self.brush.plane_auto) self._plane_auto_cb.SetToolTip( _('Fit plane from 2\u00d7 radius neighbourhood at each stroke')) fpl_row2.Add(self._plane_auto_cb, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 6) self._plane_fit_btn = wx.Button(panel, label=_('Fit now'), size=(58, -1)) self._plane_fit_btn.SetToolTip( _('Fit plane from neighbourhood at current cursor position')) fpl_row2.Add(self._plane_fit_btn, 0, wx.LEFT, 4) fpls.Add(fpl_row2, 0, wx.EXPAND | wx.ALL, 3) for _ctrl in (self._plane_sx, self._plane_sy, self._plane_zr): _ctrl.Bind(wx.EVT_TEXT_ENTER, self._on_plane_param) _ctrl.Bind(wx.EVT_KILL_FOCUS, self._on_plane_param) self._plane_auto_cb.Bind(wx.EVT_CHECKBOX, self._on_plane_auto) self._plane_fit_btn.Bind(wx.EVT_BUTTON, self._on_plane_fit) self._sync_plane_ui_state() self._fpl_si = vbox.Add(fpls, 0, wx.EXPAND | wx.ALL, 5) # ---- Cut/fill reference snapshot ---- ref_row = wx.BoxSizer(wx.HORIZONTAL) self._freeze_btn = wx.Button(panel, label=_('Freeze reference')) self._clear_ref_btn = wx.Button(panel, label=_('Clear'), size=(52, -1)) self._ref_status_lbl = wx.StaticText(panel, label=_('— not frozen —')) self._ref_status_lbl.SetForegroundColour(wx.Colour(140, 140, 140)) self._freeze_btn.SetToolTip(_( 'Snapshot the current terrain as the cut/fill baseline.\n' 'The cut/fill pie charts compare every subsequent stroke\n' 'to this frozen state.' )) self._clear_ref_btn.SetToolTip(_('Release the frozen snapshot')) self._freeze_btn.Bind(wx.EVT_BUTTON, self._on_freeze_ref) self._clear_ref_btn.Bind(wx.EVT_BUTTON, self._on_clear_ref) ref_row.Add(self._freeze_btn, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 3) ref_row.Add(self._clear_ref_btn, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 6) ref_row.Add(self._ref_status_lbl, 1, wx.ALIGN_CENTER_VERTICAL) self._cutfill_cb = wx.CheckBox(panel, label=_('HUD')) self._cutfill_cb.SetValue(True) self._cutfill_cb.SetToolTip(_('Show/hide the cut/fill pie-chart overlay.\nUncheck to disable the computation and improve performance.')) self._cutfill_cb.Bind(wx.EVT_CHECKBOX, self._on_cutfill_toggle) ref_row.Add(self._cutfill_cb, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 8) vbox.Add(ref_row, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 5) # ---- Activate / Undo ---- btn_row = wx.BoxSizer(wx.HORIZONTAL) self._activate_btn = wx.ToggleButton(panel, label=_('Activate sculpting')) self._undo_btn = wx.Button(panel, label=_('Undo (Ctrl+Z)')) self._activate_btn.Bind(wx.EVT_TOGGLEBUTTON, self._on_activate) self._undo_btn.Bind(wx.EVT_BUTTON, self._on_undo) btn_row.Add(self._activate_btn, 1, wx.EXPAND | wx.ALL, 3) btn_row.Add(self._undo_btn, 0, wx.ALL, 3) vbox.Add(btn_row, 0, wx.EXPAND | wx.ALL, 5) self._panel = panel self._vbox = vbox panel.SetSizer(vbox) self._update_visibility()
# -------------------------------------------------------------- # Contextual visibility # --------------------------------------------------------------
[docs] def _update_visibility(self) -> None: """Show/hide contextual sections based on current brush settings.""" show_rect = self.brush.shape == BrushShape.RECTANGLE show_gord = self.brush.falloff == FalloffType.GAUSSIAN show_sig = self.brush.falloff == FalloffType.SIGMOID show_flt = self.brush.mode == SculptMode.FLATTEN show_fpl = self.brush.mode == SculptMode.FLATTEN_PLANE for si, show in ( (self._rect_si, show_rect), (self._gord_si, show_gord), (self._sig_si, show_sig), (self._sig_pres_si, show_sig), (self._flt_si, show_flt), (self._fpl_si, show_fpl), ): si.Show(show) self._panel.Layout() # SetClientSize bypasses wxPython's best-size cache and guarantees # the frame interior matches exactly what the sizer requires. best = self._panel.GetBestSize() self.SetClientSize(wx.Size(max(best.width, 350), best.height))
# -------------------------------------------------------------- # Event handlers # --------------------------------------------------------------
[docs] def _on_mode(self, e: wx.CommandEvent) -> None: btn = e.GetEventObject() for b in self._mode_btns.values(): if b is not btn: b.SetValue(False) btn.SetValue(True) self.brush.mode = btn._sculpt_value self._update_visibility() self._update_falloff_preview()
[docs] def _on_shape(self, e: wx.CommandEvent) -> None: btn = e.GetEventObject() for b in self._shape_btns.values(): if b is not btn: b.SetValue(False) btn.SetValue(True) self.brush.shape = btn._sculpt_value self._update_visibility() self._update_falloff_preview()
[docs] def _on_rect_slider(self, e: wx.CommandEvent) -> None: slider = e.GetEventObject() value = slider.GetValue() / slider._scale setattr(self.brush, slider._attr, value) slider._val_lbl.SetLabel(f'{value:.1f}')
[docs] def _on_gauss_order(self, e: wx.SpinEvent) -> None: self.brush.gaussian_order = int(self._gauss_order_spin.GetValue()) self._update_falloff_preview()
[docs] def _on_sigmoid_params(self, e: wx.SpinDoubleEvent) -> None: self.brush.sigmoid_center = float(self._sig_center_spin.GetValue()) self.brush.sigmoid_steepness = float(self._sig_steep_spin.GetValue()) self._update_falloff_preview()
[docs] def _on_sigmoid_preset(self, e: wx.CommandEvent) -> None: preset = getattr(e.GetEventObject(), '_sig_preset', 'medium') if preset == 'soft': c, k = 0.55, 8.0 elif preset == 'hard': c, k = 0.72, 24.0 else: # medium c, k = 0.62, 14.0 self.brush.sigmoid_center = c self.brush.sigmoid_steepness = k self._sig_center_spin.SetValue(c) self._sig_steep_spin.SetValue(k) self._update_falloff_preview()
[docs] def _on_falloff(self, e: wx.CommandEvent) -> None: btn = e.GetEventObject() for b in self._falloff_btns.values(): if b is not btn: b.SetValue(False) btn.SetValue(True) self.brush.falloff = btn._sculpt_value self._update_visibility() self._update_falloff_preview()
[docs] def _on_slider(self, e: wx.CommandEvent) -> None: slider = e.GetEventObject() value = slider.GetValue() / slider._scale setattr(self.brush, slider._attr, value) slider._val_lbl.SetLabel(f'{value:.2f}') if slider._attr in ('intensity', 'radius', 'strength', 'gaussian_sigma'): self._update_falloff_preview()
[docs] def _on_flatten(self, e) -> None: try: self.brush.flatten_value = float(self._flatten_ctrl.GetValue()) except ValueError: pass e.Skip()
[docs] def _on_freeze_ref(self, e: wx.CommandEvent) -> None: mv = self.mapviewer if mv.active_array is None: logging.warning(_('No active array — select an array first')) return self.brush.freeze_reference(mv.active_array) self._ref_status_lbl.SetLabel(_('frozen ✓')) self._ref_status_lbl.SetForegroundColour(wx.Colour(80, 200, 120)) mv._cutfill_overlay.invalidate() mv.Refresh()
[docs] def _on_clear_ref(self, e: wx.CommandEvent) -> None: self.brush.clear_reference() self._ref_status_lbl.SetLabel(_('— not frozen —')) self._ref_status_lbl.SetForegroundColour(wx.Colour(140, 140, 140)) self.mapviewer._cutfill_overlay.invalidate() self.mapviewer.Refresh()
[docs] def _on_cutfill_toggle(self, e: wx.CommandEvent) -> None: ov = self.mapviewer._cutfill_overlay ov.enabled = self._cutfill_cb.GetValue() if ov.enabled: ov.invalidate() self.mapviewer.Refresh()
[docs] def _on_activate(self, e: wx.CommandEvent) -> None: if self._activate_btn.GetValue(): self.update_grid_hint() # Auto-freeze reference when sculpting starts (if not already frozen) mv = self.mapviewer if self.brush._ref_array is None and mv.active_array is not None: self.brush.freeze_reference(mv.active_array) self._ref_status_lbl.SetLabel(_('frozen ✓ (auto)')) self._ref_status_lbl.SetForegroundColour(wx.Colour(80, 200, 120)) self.mapviewer.start_action( 'sculpt', 'Sculpting — left-click / drag to apply, Ctrl+Z to undo', ) else: if self.mapviewer.action == 'sculpt': self.mapviewer.end_action()
[docs] def _on_undo(self, e: wx.CommandEvent) -> None: mv = self.mapviewer if mv.active_array is None: logging.warning(_('No active array — select an array first')) return result = self.brush.undo(mv.active_array) if result is not None: ii, jj = result i_min, i_max = int(ii.min()), int(ii.max()) j_min, j_max = int(jj.min()), int(jj.max()) if mv.SetCurrentContext(): mv.active_array.sculpt_update_patch(i_min, i_max, j_min, j_max) mv.Refresh()
[docs] def _on_close(self, e) -> None: # Deactivate sculpting when panel is closed if self.mapviewer.action == 'sculpt': self.mapviewer.end_action() self.brush.clear_reference() self._ref_status_lbl.SetLabel(_('— not frozen —')) self._ref_status_lbl.SetForegroundColour(wx.Colour(140, 140, 140)) self.Hide()
# -------------------------------------------------------------- # Called from WolfMapViewer.end_action() when action is cleared # externally (e.g. by double right-click on canvas) # --------------------------------------------------------------
[docs] def notify_action_ended(self) -> None: self._activate_btn.SetValue(False) ov = getattr(self.mapviewer, '_cutfill_overlay', None) if ov is not None: ov.force_recompute()
[docs] def sync_angle_only(self) -> None: """Lightweight update during interactive rotation — only the angle slider.""" slider = self._rect_sliders.get('rect_angle') if slider is None: return clamped = max(0.0, min(360.0, float(self.brush.rect_angle))) slider.SetValue(int(round(clamped))) slider._val_lbl.SetLabel(f'{clamped:.1f}')
[docs] def sync_sliders(self) -> None: """Push current brush values back into all UI sliders.""" for attr, slider in self._sliders.items(): value = getattr(self.brush, attr, None) if value is None: continue lo = slider.GetMin() / slider._scale hi = slider.GetMax() / slider._scale clamped = max(lo, min(hi, float(value))) slider.SetValue(int(round(clamped * slider._scale))) slider._val_lbl.SetLabel(f'{clamped:.2f}') # Sync exact radius field (allows values outside slider range) if hasattr(self, '_radius_exact'): self._radius_exact.SetValue(f'{self.brush.radius:.4g}') if hasattr(self, '_gauss_order_spin'): self._gauss_order_spin.SetValue(int(self.brush.gaussian_order)) if hasattr(self, '_sig_center_spin'): self._sig_center_spin.SetValue(float(np.clip(self.brush.sigmoid_center, 0.0, 1.0))) if hasattr(self, '_sig_steep_spin'): self._sig_steep_spin.SetValue(float(max(0.1, self.brush.sigmoid_steepness))) self._update_falloff_preview()
[docs] def _on_radius_exact(self, e) -> None: try: val = float(self._radius_exact.GetValue()) if val > 0.0: self.brush.radius = val # Also update the slider (clamped to its range) slider = self._sliders.get('radius') if slider is not None: lo = slider.GetMin() / slider._scale hi = slider.GetMax() / slider._scale clamped = max(lo, min(hi, val)) slider.SetValue(int(round(clamped * slider._scale))) slider._val_lbl.SetLabel(f'{clamped:.2f}') self._update_falloff_preview() except ValueError: pass e.Skip()
[docs] def _on_flatten_auto(self, e: wx.CommandEvent) -> None: self.brush.flatten_auto = self._flatten_auto_cb.GetValue() self._flatten_ctrl.Enable(not self.brush.flatten_auto)
# -------------------------------------------------------------- # Flatten plane # --------------------------------------------------------------
[docs] def _sync_plane_ui_state(self) -> None: """Enable/disable plane param fields based on auto-fit state.""" if not hasattr(self, '_plane_auto_cb'): return manual = not self._plane_auto_cb.GetValue() for _ctrl in (self._plane_sx, self._plane_sy, self._plane_zr): _ctrl.Enable(manual) self._plane_fit_btn.Enable(manual)
[docs] def _on_plane_auto(self, e: wx.CommandEvent) -> None: self.brush.plane_auto = self._plane_auto_cb.GetValue() self._sync_plane_ui_state()
[docs] def _on_plane_param(self, e) -> None: try: self.brush.plane_slope_x = float(self._plane_sx.GetValue()) except ValueError: pass try: self.brush.plane_slope_y = float(self._plane_sy.GetValue()) except ValueError: pass try: v = float(self._plane_zr.GetValue()) self.brush.plane_z_ref = v self.brush.plane_ref_x = 0.0 self.brush.plane_ref_y = 0.0 except ValueError: pass e.Skip()
[docs] def _on_plane_fit(self, e: wx.CommandEvent) -> None: mv = self.mapviewer if mv.active_array is None: logging.warning(_('No active array — select an array first')) return cursor = getattr(mv, '_sculpt_cursor_xy', None) if cursor is None: logging.warning(_('No cursor position — hover over the array first')) return cx, cy = cursor result = self.brush._compute_auto_flatten_plane(mv.active_array, cx, cy) if result is not None: sx, sy, zr = result self.brush.plane_slope_x = sx self.brush.plane_slope_y = sy self.brush.plane_z_ref = zr self.brush.plane_ref_x = cx self.brush.plane_ref_y = cy self.sync_plane_ui()
[docs] def sync_plane_ui(self) -> None: """Refresh plane param UI fields from brush attributes.""" if not hasattr(self, '_plane_sx'): return self._plane_sx.ChangeValue(f'{self.brush.plane_slope_x:.5f}') self._plane_sy.ChangeValue(f'{self.brush.plane_slope_y:.5f}') self._plane_zr.ChangeValue(f'{self.brush.plane_z_ref:.3f}')
# -------------------------------------------------------------- # Falloff preview # --------------------------------------------------------------
[docs] def _update_falloff_preview(self) -> None: """Redraw the falloff weight curve in the embedded matplotlib figure. For modes that use *strength* (Raise / Lower / Noise) the Y axis shows the effective height delta in metres (strength × weight) rather than the dimensionless weight. For all other modes the Y axis remains the dimensionless weight [0, 1]. """ ax = getattr(self, '_preview_ax', None) if ax is None: return try: ax.clear() ax.set_facecolor('#1a1a1a') r = max(self.brush.radius, 1e-6) d = np.linspace(0.0, r, 200) nd = d / r ft = self.brush.falloff if ft == FalloffType.CONSTANT: w = np.ones_like(nd) * self.brush.intensity elif ft == FalloffType.LINEAR: w = (1.0 - nd) * self.brush.intensity elif ft == FalloffType.GAUSSIAN: sigma = max(self.brush.gaussian_sigma, 0.01) order = max(int(self.brush.gaussian_order), 1) exp_arg = (nd / sigma) ** (2 * order) w = np.exp(-0.5 * exp_arg) w_max = w.max() if w_max > 0.0: w /= w_max w *= self.brush.intensity elif ft == FalloffType.SIGMOID: c = float(np.clip(self.brush.sigmoid_center, 0.0, 1.0)) k = max(float(self.brush.sigmoid_steepness), 1e-3) raw = 1.0 / (1.0 + np.exp(k * (nd - c))) raw0 = 1.0 / (1.0 + np.exp(k * (0.0 - c))) raw1 = 1.0 / (1.0 + np.exp(k * (1.0 - c))) denom = max(raw0 - raw1, 1e-12) w = np.clip((raw - raw1) / denom, 0.0, 1.0) * self.brush.intensity else: # SPHERE w = np.cos(nd * (np.pi / 2.0)) * self.brush.intensity # Modes that scale by strength: multiply Y axis by strength _strength_modes = { SculptMode.RAISE, SculptMode.LOWER, SculptMode.NOISE} use_strength = self.brush.mode in _strength_modes if use_strength: scale = self.brush.strength y_vals = w * scale y_lbl = _('\u0394z (m)') else: scale = 1.0 y_vals = w y_lbl = _('weight') _MODE_HEX = { SculptMode.SMOOTH: '#66B3FF', SculptMode.RAISE: '#4DE870', SculptMode.LOWER: '#FF8020', SculptMode.FLATTEN: '#F5F280', SculptMode.FLATTEN_PLANE: '#F5C842', SculptMode.NOISE: '#CC66FF', } color = _MODE_HEX.get(self.brush.mode, '#FFFFFF') # Reference curve: Gaussian order=1 sigma=0.45, intensity=0.5 nd_ref = np.linspace(0.0, 1.0, 200) w_ref = np.exp(-0.5 * (nd_ref / 0.45) ** 2) * 0.5 ax.plot(nd_ref * r, w_ref * scale, color='#555555', lw=0.8, ls='--') ax.plot(d, y_vals, color=color, lw=1.5) ax.set_xlim(0.0, r) ax.set_ylim(0.0, max(float(y_vals.max()) * 1.10, 1e-6)) ax.set_xlabel(_('dist. (m)'), color='#888888', fontsize=7, labelpad=1) ax.set_ylabel(y_lbl, color='#888888', fontsize=7, labelpad=1) ax.tick_params(labelsize=6, colors='#888888', length=2, pad=2) for _sp in ax.spines.values(): _sp.set_edgecolor('#333333') self._preview_canvas.draw() except Exception: pass
[docs] def update_grid_hint(self) -> None: """Update the cell-size hint label next to the exact-radius field.""" if not hasattr(self, '_grid_hint_lbl'): return arr = self.mapviewer.active_array if arr is None: self._grid_hint_lbl.SetLabel('') return dx = getattr(arr, 'dx', None) dy = getattr(arr, 'dy', None) if dx is None or dy is None: self._grid_hint_lbl.SetLabel('') return if abs(float(dx) - float(dy)) < 1e-9 * max(abs(float(dx)), 1.0): self._grid_hint_lbl.SetLabel( _('grid cell: {:.4g} m').format(float(dx))) else: self._grid_hint_lbl.SetLabel( _('grid: {:.4g}×{:.4g} m').format(float(dx), float(dy)))
# ====================================================================== # ProfilePanel — wx floating panel for the segment profile brush # ======================================================================
[docs] _PROFILE_CHOICES = [ ('1:1', ProfileShape.SLOPE_1_1), ('3:2', ProfileShape.SLOPE_3_2), ('2:1', ProfileShape.SLOPE_2_1), ('3:1', ProfileShape.SLOPE_3_1), ('Circular', ProfileShape.CIRCULAR), ('Semi-circ.', ProfileShape.SEMI_CIRCULAR), ('Gabion', ProfileShape.GABION), ('Custom', ProfileShape.CUSTOM), ]
[docs] class ProfilePanel(wx.Frame): """Floating panel for the segment-based cross-section profile brush. Stays on top of the viewer. The *Activate* toggle starts the ``'profile'`` action so that left-clicks on the canvas apply the brush. """ def __init__(self, parent, mapviewer): super().__init__( parent, title=_('Profile brush'), style=(wx.DEFAULT_FRAME_STYLE | wx.FRAME_FLOAT_ON_PARENT | wx.FRAME_NO_TASKBAR), )
[docs] self.mapviewer = mapviewer
[docs] self.brush = ProfileBrush()
self._build_ui() self._update_preview() self.SetSizeHints(minW=360, minH=-1) self.Bind(wx.EVT_CLOSE, self._on_close) # ------------------------------------------------------------------ # UI construction # ------------------------------------------------------------------
[docs] def _build_ui(self) -> None: panel = wx.Panel(self) vbox = wx.BoxSizer(wx.VERTICAL) # ---- Profile shape ---- sb = wx.StaticBox(panel, label=_('Profile shape')) ss = wx.StaticBoxSizer(sb, wx.VERTICAL) shape_grid = wx.GridSizer(rows=4, cols=2, vgap=2, hgap=2) self._shape_btns: dict = {} for label, shape in _PROFILE_CHOICES: btn = wx.ToggleButton(panel, label=_(label)) btn._profile_value = shape btn.Bind(wx.EVT_TOGGLEBUTTON, self._on_shape) self._shape_btns[shape] = btn shape_grid.Add(btn, 0, wx.EXPAND) ss.Add(shape_grid, 0, wx.EXPAND | wx.ALL, 2) self._shape_btns[ProfileShape.SLOPE_2_1].SetValue(True) vbox.Add(ss, 0, wx.EXPAND | wx.ALL, 5) # ---- Profile preview ---- self._preview_canvas = None self._preview_ax = None try: from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg from matplotlib.figure import Figure as _MplFig self._preview_fig = _MplFig(figsize=(3.5, 1.4), dpi=80, facecolor='#1a1a1a') self._preview_ax = self._preview_fig.add_subplot(111) self._preview_ax.set_facecolor('#1a1a1a') self._preview_fig.subplots_adjust( left=0.10, right=0.97, top=0.92, bottom=0.28) self._preview_canvas = FigureCanvasWxAgg( panel, wx.ID_ANY, self._preview_fig) self._preview_canvas.SetMinSize((-1, 110)) vbox.Add(self._preview_canvas, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 5) except Exception: pass # ---- Parameters ---- pg = wx.FlexGridSizer(cols=3, vgap=4, hgap=6) pg.AddGrowableCol(1) def _add_spin(label, attr, lo, hi, inc, init, digits=2, tooltip=''): lbl = wx.StaticText(panel, label=_(label)) spin = wx.SpinCtrlDouble( panel, value=f'{init:.{digits}f}', min=lo, max=hi, inc=inc, size=(80, -1)) spin.SetDigits(digits) spin.SetValue(init) if tooltip: spin.SetToolTip(_(tooltip)) spin._profile_attr = attr spin.Bind(wx.EVT_SPINCTRLDOUBLE, self._on_param_spin) pg.Add(lbl, 0, wx.ALIGN_CENTER_VERTICAL) pg.Add(spin, 1, wx.EXPAND) pg.Add((0, 0)) # placeholder in column 3 setattr(self, f'_{attr}_spin', spin) _add_spin(_('Bank height (m)'), 'bank_height', 0.1, 50.0, 0.1, 2.0, tooltip=_('Total vertical rise of the bank from foot to head (m)')) _add_spin(_('Corridor width (m)'), 'corridor_half_width', 0.1, 50.0, 0.1, 1.0, tooltip=_('Half-width of the modification corridor perpendicular to the segment (m)')) _add_spin(_('Gradient radius (m)'), 'gradient_search_radius', 1.0, 200.0, 1.0, 20.0, digits=1, tooltip=_('Gaussian smoothing radius for slope direction detection (m)')) vbox.Add(pg, 0, wx.EXPAND | wx.ALL, 5) # ---- Gabion step (shown only for GABION) ---- gab_row = wx.BoxSizer(wx.HORIZONTAL) gab_row.Add(wx.StaticText(panel, label=_('Gabion step (m):')), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 4) self._gabion_spin = wx.SpinCtrlDouble( panel, value='0.50', min=0.05, max=5.0, inc=0.05, size=(72, -1)) self._gabion_spin.SetDigits(2) self._gabion_spin.SetValue(self.brush.gabion_step) self._gabion_spin.SetToolTip( _('Height (and width) of each gabion step (m)')) self._gabion_spin.Bind(wx.EVT_SPINCTRLDOUBLE, self._on_gabion_spin) gab_row.Add(self._gabion_spin, 0) self._gab_si = vbox.Add(gab_row, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 5) # ---- Options ---- opt_row = wx.BoxSizer(wx.HORIZONTAL) self._cut_only_cb = wx.CheckBox(panel, label=_('Cut only (no fill)')) self._cut_only_cb.SetValue(self.brush.allow_cut_only) self._cut_only_cb.SetToolTip( _('When checked, only lower terrain to match the profile (never raise)')) self._cut_only_cb.Bind(wx.EVT_CHECKBOX, self._on_cut_only) opt_row.Add(self._cut_only_cb, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 12) self._foot_btn = wx.ToggleButton(panel, label=_('Foot'), size=(58, -1)) self._head_btn = wx.ToggleButton(panel, label=_('Head'), size=(58, -1)) self._foot_btn.SetToolTip( _('Click point = foot of the bank (lower end)')) self._head_btn.SetToolTip( _('Click point = head of the bank (upper end)')) self._foot_btn.SetValue(True) self._foot_btn.Bind(wx.EVT_TOGGLEBUTTON, self._on_foot_head) self._head_btn.Bind(wx.EVT_TOGGLEBUTTON, self._on_foot_head) opt_row.Add(self._foot_btn, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 2) opt_row.Add(self._head_btn, 0, wx.ALIGN_CENTER_VERTICAL) vbox.Add(opt_row, 0, wx.EXPAND | wx.ALL, 5) # ---- Computed info label ---- self._info_lbl = wx.StaticText(panel, label='') self._info_lbl.SetForegroundColour(wx.Colour(180, 180, 180)) vbox.Add(self._info_lbl, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 5) # ---- Eikonal direction refinement ---- eik_row = wx.BoxSizer(wx.HORIZONTAL) self._refine_btn = wx.Button(panel, label=_('Refine direction (eikonal)')) self._refine_btn.SetToolTip(_( 'Run a Fast-Marching solve on the local topography to compute a\n' 'globally consistent slope direction. More robust than the\n' 'Gaussian gradient near flat areas or complex banks.\n' 'The result is cached — click again or Shift+click to recompute.' )) self._clear_eik_btn = wx.Button(panel, label=_('Clear'), size=(52, -1)) self._clear_eik_btn.SetToolTip(_('Discard eikonal cache — revert to fast Gaussian gradient')) self._eik_lbl = wx.StaticText(panel, label=_('— fast gradient —')) self._eik_lbl.SetForegroundColour(wx.Colour(140, 140, 140)) self._refine_btn.Bind(wx.EVT_BUTTON, self._on_refine_eikonal) self._clear_eik_btn.Bind(wx.EVT_BUTTON, self._on_clear_eikonal) eik_row.Add(self._refine_btn, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 3) eik_row.Add(self._clear_eik_btn, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 6) eik_row.Add(self._eik_lbl, 1, wx.ALIGN_CENTER_VERTICAL) self._cutfill_cb = wx.CheckBox(panel, label=_('HUD')) self._cutfill_cb.SetValue(True) self._cutfill_cb.SetToolTip(_('Show/hide the cut/fill pie-chart overlay.\nUncheck to disable the computation and improve performance.')) self._cutfill_cb.Bind(wx.EVT_CHECKBOX, self._on_cutfill_toggle) eik_row.Add(self._cutfill_cb, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 8) vbox.Add(eik_row, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 5) # ---- Activate / Undo ---- btn_row = wx.BoxSizer(wx.HORIZONTAL) self._activate_btn = wx.ToggleButton(panel, label=_('Activate profile brush')) self._undo_btn = wx.Button(panel, label=_('Undo (Ctrl+Z)')) self._activate_btn.Bind(wx.EVT_TOGGLEBUTTON, self._on_activate) self._undo_btn.Bind(wx.EVT_BUTTON, self._on_undo) btn_row.Add(self._activate_btn, 1, wx.EXPAND | wx.ALL, 3) btn_row.Add(self._undo_btn, 0, wx.ALL, 3) vbox.Add(btn_row, 0, wx.EXPAND | wx.ALL, 5) self._panel = panel self._vbox = vbox panel.SetSizer(vbox) self._update_visibility() self._update_info()
# ------------------------------------------------------------------ # Contextual visibility # ------------------------------------------------------------------
[docs] def _update_visibility(self) -> None: show_gab = self.brush.profile_shape == ProfileShape.GABION self._gab_si.Show(show_gab) self._panel.Layout() best = self._panel.GetBestSize() self.SetClientSize(wx.Size(max(best.width, 360), best.height))
[docs] def _update_info(self) -> None: """Refresh the deduced segment-length label.""" lbl = _( 'Segment length: {:.2f} m (bank {:.2f} m × run/rise {:.3g})' ).format( self.brush.segment_length, self.brush.bank_height, self.brush.run_over_rise, ) self._info_lbl.SetLabel(lbl)
# ------------------------------------------------------------------ # Event handlers # ------------------------------------------------------------------
[docs] def _on_shape(self, e: wx.CommandEvent) -> None: btn = e.GetEventObject() for b in self._shape_btns.values(): if b is not btn: b.SetValue(False) btn.SetValue(True) self.brush.profile_shape = btn._profile_value self._update_visibility() self._update_info() self._update_preview()
[docs] def _on_param_spin(self, e: wx.SpinDoubleEvent) -> None: spin = e.GetEventObject() attr = getattr(spin, '_profile_attr', None) if attr is not None: setattr(self.brush, attr, float(spin.GetValue())) self._update_info() self._update_preview()
[docs] def _on_gabion_spin(self, e: wx.SpinDoubleEvent) -> None: self.brush.gabion_step = float(self._gabion_spin.GetValue()) self._update_preview()
[docs] def _on_cut_only(self, e: wx.CommandEvent) -> None: self.brush.allow_cut_only = self._cut_only_cb.GetValue()
[docs] def _on_foot_head(self, e: wx.CommandEvent) -> None: btn = e.GetEventObject() foot = (btn is self._foot_btn) self._foot_btn.SetValue(foot) self._head_btn.SetValue(not foot) self.brush.click_is_foot = foot
[docs] def _on_activate(self, e: wx.CommandEvent) -> None: mv = self.mapviewer if self._activate_btn.GetValue(): # Freeze reference before activating so gradient uses original data if mv.active_array is not None: self.brush.freeze_reference(mv.active_array) mv.start_action( 'profile', _('Profile brush — left-click to apply, Ctrl+Z to undo'), ) else: if mv.action == 'profile': mv.end_action() self.brush.clear_reference()
[docs] def _on_undo(self, e: wx.CommandEvent) -> None: mv = self.mapviewer if mv.active_array is None: logging.warning(_('No active array — select an array first')) return result = self.brush.undo(mv.active_array) if result is not None: cells = result if cells: i_min = min(ix for ix, __ in cells) i_max = max(ix for ix, __ in cells) j_min = min(iy for __, iy in cells) j_max = max(iy for __, iy in cells) if mv.SetCurrentContext(): mv.active_array.sculpt_update_patch(i_min, i_max, j_min, j_max) mv.Refresh()
[docs] def _on_close(self, e) -> None: if self.mapviewer.action == 'profile': self.mapviewer.end_action() self.brush.clear_reference() self.Hide()
[docs] def _on_cutfill_toggle(self, e: wx.CommandEvent) -> None: ov = self.mapviewer._cutfill_overlay ov.enabled = self._cutfill_cb.GetValue() if ov.enabled: ov.invalidate() self.mapviewer.Refresh()
[docs] def _on_refine_eikonal(self, e: wx.CommandEvent) -> None: """Run the eikonal direction solver at the current cursor position.""" mv = self.mapviewer if mv.active_array is None: logging.warning(_('No active array — select an array first')) return xy = getattr(mv, '_profile_cursor_xy', None) if xy is None: logging.warning(_('Move the cursor over the canvas first')) return cx, cy = xy self._eik_lbl.SetLabel(_('Computing…')) self._eik_lbl.SetForegroundColour(wx.Colour(220, 180, 80)) self._panel.Update() wx.Yield() try: ux, uy = self.brush.refine_direction_eikonal(cx, cy, mv.active_array) import math as _math angle_deg = _math.degrees(_math.atan2(uy, ux)) self._eik_lbl.SetLabel(_( 'eikonal ✓ {:.1f}° ({:+.3f}, {:+.3f})' ).format(angle_deg, ux, uy)) self._eik_lbl.SetForegroundColour(wx.Colour(80, 200, 120)) except Exception as exc: self._eik_lbl.SetLabel(_('Error: {}').format(exc)) self._eik_lbl.SetForegroundColour(wx.Colour(220, 80, 80)) self._panel.Layout() mv.Refresh()
[docs] def _on_clear_eikonal(self, e: wx.CommandEvent) -> None: """Discard the cached eikonal direction.""" self.brush.clear_eikonal_cache() self._eik_lbl.SetLabel(_('— fast gradient —')) self._eik_lbl.SetForegroundColour(wx.Colour(140, 140, 140)) self._panel.Layout() self.mapviewer.Refresh()
# ------------------------------------------------------------------ # Called from WolfMapViewer.end_action() when action cleared externally # ------------------------------------------------------------------
[docs] def notify_action_ended(self) -> None: self._activate_btn.SetValue(False) self.brush.clear_reference() ov = getattr(self.mapviewer, '_cutfill_overlay', None) if ov is not None: ov.force_recompute()
[docs] def sync_from_brush(self) -> None: """Push current brush values back into all UI controls.""" for shape, btn in self._shape_btns.items(): btn.SetValue(shape == self.brush.profile_shape) for attr in ('bank_height', 'corridor_half_width', 'gradient_search_radius'): spin = getattr(self, f'_{attr}_spin', None) if spin is not None: spin.SetValue(float(getattr(self.brush, attr))) self._gabion_spin.SetValue(float(self.brush.gabion_step)) self._cut_only_cb.SetValue(self.brush.allow_cut_only) self._foot_btn.SetValue(self.brush.click_is_foot) self._head_btn.SetValue(not self.brush.click_is_foot) self._update_visibility() self._update_info() self._update_preview() # Refresh eikonal status label if self.brush._eikonal_dir_cache is not None: import math as _math __, __, ux, uy = self.brush._eikonal_dir_cache angle_deg = _math.degrees(_math.atan2(uy, ux)) self._eik_lbl.SetLabel(_( 'eikonal ✓ {:.1f}° ({:+.3f}, {:+.3f})' ).format(angle_deg, ux, uy)) self._eik_lbl.SetForegroundColour(wx.Colour(80, 200, 120)) else: self._eik_lbl.SetLabel(_('— fast gradient —')) self._eik_lbl.SetForegroundColour(wx.Colour(140, 140, 140))
# ------------------------------------------------------------------ # Profile preview # ------------------------------------------------------------------
[docs] def _update_preview(self) -> None: """Redraw the profile shape z(t) in the embedded matplotlib figure.""" ax = getattr(self, '_preview_ax', None) if ax is None: return try: ax.clear() ax.set_facecolor('#1a1a1a') t = np.linspace(0.0, 1.0, 200) z = self.brush.profile_z_normalized(t) # Convert to world coordinates: x-axis = horizontal distance (m), # y-axis = elevation (m above foot) x_m = t * self.brush.segment_length z_m = z * self.brush.bank_height _SHAPE_HEX = { ProfileShape.SLOPE_1_1: '#4DE870', ProfileShape.SLOPE_3_2: '#66D46A', ProfileShape.SLOPE_2_1: '#88C860', ProfileShape.SLOPE_3_1: '#AABC58', ProfileShape.CIRCULAR: '#66B3FF', ProfileShape.SEMI_CIRCULAR: '#99CCFF', ProfileShape.GABION: '#F5C842', ProfileShape.CUSTOM: '#CC66FF', } color = _SHAPE_HEX.get(self.brush.profile_shape, '#FFFFFF') # Corridor half-width guide (vertical dashed lines at x=0 and x=L) ax.axvline(0.0, color='#444444', lw=0.8, ls='--') ax.axvline(self.brush.segment_length, color='#444444', lw=0.8, ls='--') ax.plot(x_m, z_m, color=color, lw=1.8) ax.set_xlabel(_('horiz. dist. (m)'), color='#888888', fontsize=7, labelpad=1) ax.set_ylabel(_('\u0394z (m)'), color='#888888', fontsize=7, labelpad=1) ax.set_xlim(0.0, max(float(x_m.max()), 1e-3)) ax.set_ylim(0.0, max(float(z_m.max()) * 1.1, 1e-3)) ax.tick_params(labelsize=6, colors='#888888', length=2, pad=2) for sp in ax.spines.values(): sp.set_edgecolor('#333333') self._preview_canvas.draw() except Exception: pass