wolfhece.wolf_sculpt ==================== .. py:module:: wolfhece.wolf_sculpt .. autoapi-nested-parse:: 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. Module Contents --------------- .. py:data:: _SCIPY_AVAILABLE :value: True .. py:class:: SculptMode(*args, **kwds) Bases: :py:obj:`enum.Enum` .. autoapi-inheritance-diagram:: wolfhece.wolf_sculpt.SculptMode :parts: 1 :private-bases: Create a collection of name/value pairs. Example enumeration: >>> class Color(Enum): ... RED = 1 ... BLUE = 2 ... GREEN = 3 Access them by: - attribute access:: >>> Color.RED - value lookup: >>> Color(1) - name lookup: >>> Color['RED'] Enumerations can be iterated over, and know how many members they have: >>> len(Color) 3 >>> list(Color) [, , ] Methods can be added to enumerations, and members can have their own attributes -- see the documentation for details. .. py:attribute:: SMOOTH :value: 'smooth' .. py:attribute:: RAISE :value: 'raise' .. py:attribute:: LOWER :value: 'lower' .. py:attribute:: FLATTEN :value: 'flatten' .. py:attribute:: FLATTEN_PLANE :value: 'flatten_plane' .. py:attribute:: NOISE :value: 'noise' .. py:class:: FalloffType(*args, **kwds) Bases: :py:obj:`enum.Enum` .. autoapi-inheritance-diagram:: wolfhece.wolf_sculpt.FalloffType :parts: 1 :private-bases: Create a collection of name/value pairs. Example enumeration: >>> class Color(Enum): ... RED = 1 ... BLUE = 2 ... GREEN = 3 Access them by: - attribute access:: >>> Color.RED - value lookup: >>> Color(1) - name lookup: >>> Color['RED'] Enumerations can be iterated over, and know how many members they have: >>> len(Color) 3 >>> list(Color) [, , ] Methods can be added to enumerations, and members can have their own attributes -- see the documentation for details. .. py:attribute:: CONSTANT :value: 'constant' .. py:attribute:: LINEAR :value: 'linear' .. py:attribute:: GAUSSIAN :value: 'gaussian' .. py:attribute:: SIGMOID :value: 'sigmoid' .. py:attribute:: SPHERE :value: 'sphere' .. py:class:: BrushShape(*args, **kwds) Bases: :py:obj:`enum.Enum` .. autoapi-inheritance-diagram:: wolfhece.wolf_sculpt.BrushShape :parts: 1 :private-bases: Create a collection of name/value pairs. Example enumeration: >>> class Color(Enum): ... RED = 1 ... BLUE = 2 ... GREEN = 3 Access them by: - attribute access:: >>> Color.RED - value lookup: >>> Color(1) - name lookup: >>> Color['RED'] Enumerations can be iterated over, and know how many members they have: >>> len(Color) 3 >>> list(Color) [, , ] Methods can be added to enumerations, and members can have their own attributes -- see the documentation for details. .. py:attribute:: CIRCLE :value: 'circle' .. py:attribute:: SQUARE :value: 'square' .. py:attribute:: RECTANGLE :value: 'rectangle' .. py:class:: ProfileShape(*args, **kwds) Bases: :py:obj:`enum.Enum` .. autoapi-inheritance-diagram:: wolfhece.wolf_sculpt.ProfileShape :parts: 1 :private-bases: Cross-section profile shape for the segment brush. .. py:attribute:: SLOPE_1_1 :value: '1_1' .. py:attribute:: SLOPE_3_2 :value: '3_2' .. py:attribute:: SLOPE_2_1 :value: '2_1' .. py:attribute:: SLOPE_3_1 :value: '3_1' .. py:attribute:: CIRCULAR :value: 'circular' .. py:attribute:: SEMI_CIRCULAR :value: 'semi_circular' .. py:attribute:: GABION :value: 'gabion' .. py:attribute:: CUSTOM :value: 'custom' .. py:data:: _PROFILE_RUN_RISE :type: dict[ProfileShape, float] .. py: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. .. py:attribute:: MAX_UNDO :type: int :value: 20 .. py:attribute:: mode :type: SculptMode .. py:attribute:: falloff :type: FalloffType .. py:attribute:: shape :type: BrushShape .. py:attribute:: radius :type: float :value: 20.0 .. py:attribute:: rect_width :type: float :value: 20.0 .. py:attribute:: rect_height :type: float :value: 40.0 .. py:attribute:: rect_angle :type: float :value: 0.0 .. py:attribute:: intensity :type: float :value: 0.5 .. py:attribute:: strength :type: float :value: 0.5 .. py:attribute:: gaussian_sigma :type: float :value: 0.45 .. py:attribute:: gaussian_order :type: int :value: 1 .. py:attribute:: sigmoid_center :type: float :value: 0.6 .. py:attribute:: sigmoid_steepness :type: float :value: 12.0 .. py:attribute:: flatten_value :type: float :value: 0.0 .. py:attribute:: flatten_auto :type: bool :value: True .. py:attribute:: search_zone_factor :type: float :value: 2.0 .. py:attribute:: plane_slope_x :type: float :value: 0.0 .. py:attribute:: plane_slope_y :type: float :value: 0.0 .. py:attribute:: plane_z_ref :type: float :value: 0.0 .. py:attribute:: plane_ref_x :type: float :value: 0.0 .. py:attribute:: plane_ref_y :type: float :value: 0.0 .. py:attribute:: plane_auto :type: bool :value: True .. py:attribute:: _on_plane_fitted :type: object :value: None .. py:attribute:: _undo :type: list[tuple] :value: [] .. py:attribute:: _kernel_cache :type: tuple | None :value: None .. py:attribute:: _ref_array :type: numpy.ndarray | None :value: None .. py:attribute:: _ref_null :type: float | None :value: None .. py:method:: _kernel_key(dx: float, dy: float) -> tuple .. py:method:: _build_kernel(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. .. py:method:: _get_kernel(dx: float, dy: float) Return cached (di, dj, w) kernel; rebuild if params changed. .. py:method:: _compute_weights(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. .. py:method:: apply(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. .. py:method:: _apply_smooth(data, ii, jj, w, ctx) Smooth local region with a Gaussian (or box) filter, then blend. .. py:method:: _compute_auto_flatten_target(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. .. py:method:: _compute_auto_flatten_plane(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. .. py:method:: _push_undo(array, ii, jj) -> None .. py:method:: undo(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. .. py:method:: clear_undo() -> None .. py:method:: freeze_reference(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. .. py:method:: clear_reference() -> None Release the frozen reference (call when deactivating). .. py: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``. .. py:attribute:: MAX_UNDO :type: int :value: 20 .. py:attribute:: profile_shape :type: ProfileShape .. py:attribute:: bank_height :type: float :value: 2.0 .. py:attribute:: corridor_half_width :type: float :value: 1.0 .. py:attribute:: gradient_search_radius :type: float :value: 20.0 .. py:attribute:: allow_cut_only :type: bool :value: True .. py:attribute:: click_is_foot :type: bool :value: True .. py:attribute:: gabion_step :type: float :value: 0.5 .. py:attribute:: custom_profile :type: list[tuple[float, float]] :value: [] .. py:attribute:: _ref_array :type: numpy.ndarray | None :value: None .. py:attribute:: _ref_null :type: float | None :value: None .. py:attribute:: _eikonal_dir_cache :type: tuple[float, float, float, float] | None :value: None .. py:attribute:: _undo :type: list[list[tuple[int, int, float, float]]] :value: [] .. py:property:: run_over_rise :type: float Horizontal run per unit of vertical rise (dimensionless). .. py:property:: segment_length :type: float Horizontal length of the profile segment, deduced from ``bank_height`` and the slope ratio of the selected shape (m). .. py:method:: profile_z_normalized(t: numpy.ndarray) -> numpy.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). .. py:method:: freeze_reference(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. .. py:method:: clear_reference() -> None Release the frozen reference (call when deactivating the mode). .. py:method:: clear_eikonal_cache() -> None Discard any cached eikonal direction (reverts to Gaussian gradient). .. py:method:: refine_direction_eikonal(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. .. py:method:: _estimate_gradient_dir(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. .. py:method:: apply(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. .. py:method:: undo(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. .. py:method:: clear_undo() -> None .. py:data:: _MODE_CHOICES .. py:data:: _FALLOFF_CHOICES .. py:data:: _SHAPE_CHOICES .. py:class:: SculptPanel(parent, mapviewer) Bases: :py:obj:`wx.Frame` .. autoapi-inheritance-diagram:: wolfhece.wolf_sculpt.SculptPanel :parts: 1 :private-bases: 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. .. py:attribute:: mapviewer .. py:attribute:: brush .. py:method:: _build_ui() -> None .. py:method:: _update_visibility() -> None Show/hide contextual sections based on current brush settings. .. py:method:: _on_mode(e: wx.CommandEvent) -> None .. py:method:: _on_shape(e: wx.CommandEvent) -> None .. py:method:: _on_rect_slider(e: wx.CommandEvent) -> None .. py:method:: _on_gauss_order(e: wx.SpinEvent) -> None .. py:method:: _on_sigmoid_params(e: wx.SpinDoubleEvent) -> None .. py:method:: _on_sigmoid_preset(e: wx.CommandEvent) -> None .. py:method:: _on_falloff(e: wx.CommandEvent) -> None .. py:method:: _on_slider(e: wx.CommandEvent) -> None .. py:method:: _on_flatten(e) -> None .. py:method:: _on_freeze_ref(e: wx.CommandEvent) -> None .. py:method:: _on_clear_ref(e: wx.CommandEvent) -> None .. py:method:: _on_cutfill_toggle(e: wx.CommandEvent) -> None .. py:method:: _on_activate(e: wx.CommandEvent) -> None .. py:method:: _on_undo(e: wx.CommandEvent) -> None .. py:method:: _on_close(e) -> None .. py:method:: notify_action_ended() -> None .. py:method:: sync_angle_only() -> None Lightweight update during interactive rotation — only the angle slider. .. py:method:: sync_sliders() -> None Push current brush values back into all UI sliders. .. py:method:: _on_radius_exact(e) -> None .. py:method:: _on_flatten_auto(e: wx.CommandEvent) -> None .. py:method:: _sync_plane_ui_state() -> None Enable/disable plane param fields based on auto-fit state. .. py:method:: _on_plane_auto(e: wx.CommandEvent) -> None .. py:method:: _on_plane_param(e) -> None .. py:method:: _on_plane_fit(e: wx.CommandEvent) -> None .. py:method:: sync_plane_ui() -> None Refresh plane param UI fields from brush attributes. .. py:method:: _update_falloff_preview() -> 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]. .. py:method:: update_grid_hint() -> None Update the cell-size hint label next to the exact-radius field. .. py:data:: _PROFILE_CHOICES .. py:class:: ProfilePanel(parent, mapviewer) Bases: :py:obj:`wx.Frame` .. autoapi-inheritance-diagram:: wolfhece.wolf_sculpt.ProfilePanel :parts: 1 :private-bases: 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. .. py:attribute:: mapviewer .. py:attribute:: brush .. py:method:: _build_ui() -> None .. py:method:: _update_visibility() -> None .. py:method:: _update_info() -> None Refresh the deduced segment-length label. .. py:method:: _on_shape(e: wx.CommandEvent) -> None .. py:method:: _on_param_spin(e: wx.SpinDoubleEvent) -> None .. py:method:: _on_gabion_spin(e: wx.SpinDoubleEvent) -> None .. py:method:: _on_cut_only(e: wx.CommandEvent) -> None .. py:method:: _on_foot_head(e: wx.CommandEvent) -> None .. py:method:: _on_activate(e: wx.CommandEvent) -> None .. py:method:: _on_undo(e: wx.CommandEvent) -> None .. py:method:: _on_close(e) -> None .. py:method:: _on_cutfill_toggle(e: wx.CommandEvent) -> None .. py:method:: _on_refine_eikonal(e: wx.CommandEvent) -> None Run the eikonal direction solver at the current cursor position. .. py:method:: _on_clear_eikonal(e: wx.CommandEvent) -> None Discard the cached eikonal direction. .. py:method:: notify_action_ended() -> None .. py:method:: sync_from_brush() -> None Push current brush values back into all UI controls. .. py:method:: _update_preview() -> None Redraw the profile shape z(t) in the embedded matplotlib figure.