"""
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]
FLATTEN_PLANE = 'flatten_plane'
[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]
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.
"""
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``.
"""
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.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_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