prepairing for pivot to standalone rust program

This commit is contained in:
grimsace
2026-04-21 13:29:38 -05:00
parent d31650a636
commit 2b42fe7ea7
5 changed files with 1 additions and 692 deletions
+1
View File
@@ -16,6 +16,7 @@ The `tests/` directory remains prototype-only reference code and is not imported
## Installing in Krita ## Installing in Krita
Copy `pykrita/advanced_erosion_generator.desktop` and the `pykrita/advanced_erosion_generator/` folder into Krita's `pykrita` directory. Copy `pykrita/advanced_erosion_generator.desktop` and the `pykrita/advanced_erosion_generator/` folder into Krita's `pykrita` directory.
The current plugin implementation is pure Python and does not depend on `numpy` or the test virtual environment.
## Current UI ## Current UI
@@ -1,7 +0,0 @@
[Desktop Entry]
Type=Service
ServiceTypes=Krita/PythonPlugin
X-KDE-Library=advanced_erosion_generator
X-Python-2-Compatible=false
Name=Advanced Erosion Generator
Comment=Generate erosion-based terrain heightmaps at the active canvas resolution.
@@ -1,8 +0,0 @@
from krita import Krita
from .extension import AdvancedErosionGeneratorExtension
app = Krita.instance()
extension = AdvancedErosionGeneratorExtension(parent=app)
app.addExtension(extension)
@@ -1,286 +0,0 @@
import random
import traceback
from krita import Extension, Krita
from PyQt5.QtCore import QByteArray, Qt
from PyQt5.QtWidgets import (
QApplication,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QSlider,
QSpinBox,
QToolButton,
QVBoxLayout,
QWidget,
)
from .generator import GenerationSettings, generate_heightmap, heightmap_to_pixel_data
class FloatSliderInput(QWidget):
def __init__(self, minimum, maximum, step, value, decimals=2, parent=None):
super().__init__(parent)
self._factor = 10 ** decimals
self.slider = QSlider(Qt.Horizontal, self)
self.slider.setRange(int(round(minimum * self._factor)), int(round(maximum * self._factor)))
self.slider.setSingleStep(max(1, int(round(step * self._factor))))
self.slider.setPageStep(max(1, int(round(step * self._factor * 5))))
self.spin = QDoubleSpinBox(self)
self.spin.setRange(minimum, maximum)
self.spin.setDecimals(decimals)
self.spin.setSingleStep(step)
layout = QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.slider, 1)
layout.addWidget(self.spin)
self.setLayout(layout)
self.slider.valueChanged.connect(self._sync_from_slider)
self.spin.valueChanged.connect(self._sync_from_spin)
self.set_value(value)
def _sync_from_slider(self, slider_value):
spin_value = slider_value / self._factor
if abs(self.spin.value() - spin_value) > (0.5 / self._factor):
self.spin.blockSignals(True)
self.spin.setValue(spin_value)
self.spin.blockSignals(False)
def _sync_from_spin(self, spin_value):
slider_value = int(round(spin_value * self._factor))
if self.slider.value() != slider_value:
self.slider.blockSignals(True)
self.slider.setValue(slider_value)
self.slider.blockSignals(False)
def set_value(self, value):
self.spin.setValue(value)
self.slider.setValue(int(round(value * self._factor)))
def value(self):
return self.spin.value()
class IntSliderInput(QWidget):
def __init__(self, minimum, maximum, step, value, parent=None):
super().__init__(parent)
self.slider = QSlider(Qt.Horizontal, self)
self.slider.setRange(minimum, maximum)
self.slider.setSingleStep(step)
self.slider.setPageStep(max(step, step * 2))
self.spin = QSpinBox(self)
self.spin.setRange(minimum, maximum)
self.spin.setSingleStep(step)
layout = QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.slider, 1)
layout.addWidget(self.spin)
self.setLayout(layout)
self.slider.valueChanged.connect(self._sync_from_slider)
self.spin.valueChanged.connect(self._sync_from_spin)
self.set_value(value)
def _sync_from_slider(self, slider_value):
if self.spin.value() != slider_value:
self.spin.blockSignals(True)
self.spin.setValue(slider_value)
self.spin.blockSignals(False)
def _sync_from_spin(self, spin_value):
if self.slider.value() != spin_value:
self.slider.blockSignals(True)
self.slider.setValue(spin_value)
self.slider.blockSignals(False)
def set_value(self, value):
self.spin.setValue(value)
self.slider.setValue(value)
def value(self):
return self.spin.value()
class CollapsibleSection(QWidget):
def __init__(self, title, parent=None):
super().__init__(parent)
self.button = QToolButton(self)
self.button.setText(title)
self.button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
self.button.setArrowType(Qt.RightArrow)
self.button.setCheckable(True)
self.button.setChecked(False)
self.content = QWidget(self)
self.content.setVisible(False)
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.button)
layout.addWidget(self.content)
self.setLayout(layout)
self.button.toggled.connect(self._toggle)
def _toggle(self, expanded):
self.button.setArrowType(Qt.DownArrow if expanded else Qt.RightArrow)
self.content.setVisible(expanded)
def set_content_layout(self, layout):
self.content.setLayout(layout)
class TerrainDialog(QDialog):
def __init__(self, document, parent=None):
super().__init__(parent)
self.document = document
self.setWindowTitle("Advanced Erosion Generator")
self.setModal(True)
self.resize(560, 320)
main_layout = QVBoxLayout()
doc_label = QLabel(
f"Output size: {self.document.width()} x {self.document.height()} px",
self,
)
main_layout.addWidget(doc_label)
form_layout = QFormLayout()
self.generation_scale = FloatSliderInput(0.1, 10.0, 0.1, 1.0, decimals=2, parent=self)
self.erosion_scale = FloatSliderInput(0.08, 0.25, 0.01, 0.15, decimals=2, parent=self)
self.seed_input = QSpinBox(self)
self.seed_input.setRange(0, 2147483647)
self.seed_input.setValue(0)
seed_row = QWidget(self)
seed_layout = QHBoxLayout()
seed_layout.setContentsMargins(0, 0, 0, 0)
seed_layout.addWidget(self.seed_input, 1)
self.randomize_seed_button = QPushButton("Randomize", self)
self.randomize_seed_button.clicked.connect(self._randomize_seed)
seed_layout.addWidget(self.randomize_seed_button)
seed_row.setLayout(seed_layout)
form_layout.addRow("Generation Scale", self.generation_scale)
form_layout.addRow("Erosion Scale", self.erosion_scale)
form_layout.addRow("Random Seed", seed_row)
main_layout.addLayout(form_layout)
advanced_section = CollapsibleSection("Advanced", self)
advanced_layout = QFormLayout()
advanced_layout.setContentsMargins(12, 0, 0, 0)
self.erosion_strength = FloatSliderInput(0.01, 0.10, 0.01, 0.05, decimals=2, parent=self)
self.erosion_gully_weight = FloatSliderInput(0.0, 1.0, 0.01, 0.5, decimals=2, parent=self)
self.erosion_detail = FloatSliderInput(0.7, 3.0, 0.1, 1.5, decimals=2, parent=self)
self.erosion_octaves = IntSliderInput(1, 8, 1, 5, parent=self)
advanced_layout.addRow("Strength", self.erosion_strength)
advanced_layout.addRow("Gully Weight", self.erosion_gully_weight)
advanced_layout.addRow("Detail", self.erosion_detail)
advanced_layout.addRow("Octaves", self.erosion_octaves)
advanced_section.set_content_layout(advanced_layout)
main_layout.addWidget(advanced_section)
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
main_layout.addWidget(button_box)
self.setLayout(main_layout)
def _randomize_seed(self):
self.seed_input.setValue(random.randint(0, 2147483647))
def settings(self):
return GenerationSettings(
generation_scale=self.generation_scale.value(),
erosion_scale=self.erosion_scale.value(),
random_seed=self.seed_input.value(),
erosion_strength=self.erosion_strength.value(),
erosion_gully_weight=self.erosion_gully_weight.value(),
erosion_detail=self.erosion_detail.value(),
erosion_octaves=self.erosion_octaves.value(),
)
class AdvancedErosionGeneratorExtension(Extension):
def __init__(self, parent):
super().__init__(parent)
self.action = None
def setup(self):
pass
def createActions(self, window):
self.action = window.createAction(
"advanced_erosion_generator",
"Generate Advanced Erosion Terrain",
"tools/scripts",
)
self.action.triggered.connect(self.open_dialog)
def open_dialog(self):
document = Krita.instance().activeDocument()
if document is None:
QMessageBox.warning(
None,
"Advanced Erosion Generator",
"Open or create a document before generating terrain.",
)
return
parent_window = None
active_window = Krita.instance().activeWindow()
if active_window is not None:
parent_window = active_window.qwindow()
dialog = TerrainDialog(document, parent=parent_window)
if dialog.exec_() != QDialog.Accepted:
return
settings = dialog.settings()
self._generate_into_document(document, settings)
def _generate_into_document(self, document, settings):
QApplication.setOverrideCursor(Qt.WaitCursor)
try:
width = int(document.width())
height = int(document.height())
heightmap = generate_heightmap(width, height, settings)
pixel_data = heightmap_to_pixel_data(
heightmap, document.colorModel(), document.colorDepth()
)
layer_name = f"Eroded Terrain {settings.random_seed}"
layer = document.createNode(layer_name, "paintlayer")
document.rootNode().addChildNode(layer, None)
layer.setPixelData(QByteArray(pixel_data), 0, 0, width, height)
document.refreshProjection()
except Exception as exc:
QMessageBox.critical(
None,
"Advanced Erosion Generator",
f"Terrain generation failed:\n{exc}\n\n{traceback.format_exc()}",
)
finally:
QApplication.restoreOverrideCursor()
@@ -1,391 +0,0 @@
from dataclasses import dataclass
import numpy as np
try:
from numba import njit, prange
except ImportError:
def njit(*args, **kwargs):
if args and callable(args[0]) and len(args) == 1 and not kwargs:
return args[0]
def decorator(func):
return func
return decorator
prange = range
TAU = np.float32(6.28318530717959)
EROSION_LACUNARITY = np.float32(2.0)
EROSION_GAIN = np.float32(0.5)
EROSION_CELL_SCALE = np.float32(0.7)
EROSION_NORMALIZATION = np.float32(0.5)
EROSION_ROUNDING = np.array([0.1, 0.0, 0.1, 2.0], dtype=np.float32)
EROSION_ONSET = np.array([1.25, 1.25, 2.8, 1.5], dtype=np.float32)
EROSION_ASSUMED_SLOPE = np.array([0.7, 1.0], dtype=np.float32)
@dataclass(frozen=True)
class GenerationSettings:
generation_scale: float = 1.0
erosion_scale: float = 0.15
random_seed: int = 0
erosion_strength: float = 0.05
erosion_gully_weight: float = 0.5
erosion_detail: float = 1.5
erosion_octaves: int = 5
@njit(inline="always")
def fract(x):
return x - np.floor(x)
@njit(inline="always")
def mix(a, b, t):
return a + (b - a) * np.float32(t)
@njit(inline="always")
def clamp(x, lo, hi):
return min(max(x, np.float32(lo)), np.float32(hi))
@njit(inline="always")
def clamp01(x):
return clamp(x, 0.0, 1.0)
@njit(inline="always")
def hash22(p, random_seed):
seed_x = float(random_seed) * 0.7548776662466927
seed_y = float(random_seed) * 0.5698402909980532
kx, ky = 0.3183099, 0.3678794
px = (float(p[0]) + seed_x) * kx + ky
py = (float(p[1]) + seed_y) * ky + kx
val = px * py * (px + py)
f_val = val - np.floor(val)
rx = -1.0 + 2.0 * fract(16.0 * kx * f_val)
ry = -1.0 + 2.0 * fract(16.0 * ky * f_val)
return np.array([rx, ry], dtype=np.float32)
@njit(inline="always")
def noised(p, random_seed):
i = np.floor(p)
f = (p - i).astype(np.float32)
u = f * f * f * (f * (f * np.float32(6.0) - np.float32(15.0)) + np.float32(10.0))
du = np.float32(30.0) * f * f * (f * (f - np.float32(2.0)) + np.float32(1.0))
ga = hash22(i + np.array([0.0, 0.0], dtype=np.float32), random_seed)
gb = hash22(i + np.array([1.0, 0.0], dtype=np.float32), random_seed)
gc = hash22(i + np.array([0.0, 1.0], dtype=np.float32), random_seed)
gd = hash22(i + np.array([1.0, 1.0], dtype=np.float32), random_seed)
va = np.dot(ga, f - np.array([0.0, 0.0], dtype=np.float32))
vb = np.dot(gb, f - np.array([1.0, 0.0], dtype=np.float32))
vc = np.dot(gc, f - np.array([0.0, 1.0], dtype=np.float32))
vd = np.dot(gd, f - np.array([1.0, 1.0], dtype=np.float32))
val = va + u[0] * (vb - va) + u[1] * (vc - va) + u[0] * u[1] * (va - vb - vc + vd)
deriv_x = (
ga[0]
+ u[0] * (gb[0] - ga[0])
+ u[1] * (gc[0] - ga[0])
+ u[0] * u[1] * (ga[0] - gb[0] - gc[0] + gd[0])
+ du[0] * (u[1] * (va - vb - vc + vd) + (vb - va))
)
deriv_y = (
ga[1]
+ u[0] * (gb[1] - ga[1])
+ u[1] * (gc[1] - ga[1])
+ u[0] * u[1] * (ga[1] - gb[1] - gc[1] + gd[1])
+ du[1] * (u[0] * (va - vb - vc + vd) + (vc - va))
)
return val, np.array([deriv_x, deriv_y], dtype=np.float32)
@njit(inline="always")
def fractal_noise(p, freq, octaves, lacunarity, gain, random_seed):
val = np.float32(0.0)
deriv = np.zeros(2, dtype=np.float32)
nf = np.float32(freq)
na = np.float32(1.0)
for _ in range(octaves):
v, d = noised(p * nf, random_seed)
val += np.float32(v) * na
deriv += d * na * nf
na *= np.float32(gain)
nf *= np.float32(lacunarity)
return val, deriv
@njit(inline="always")
def phacelle_noise(p, norm_dir, freq, offset, normalization, random_seed):
side_dir = np.array([-norm_dir[1], norm_dir[0]], dtype=np.float32) * freq * TAU
offset_tau = np.float32(offset) * TAU
p_int = np.floor(p)
p_frac = (p - p_int).astype(np.float32)
phase_dir = np.zeros(2, dtype=np.float32)
weight_sum = np.float32(0.0)
for i in range(-1, 3):
for j in range(-1, 3):
grid_offset = np.array([float(i), float(j)], dtype=np.float32)
grid_point = p_int + grid_offset
random_offset = hash22(grid_point, random_seed) * np.float32(0.5)
vector_from_cell_point = p_frac - grid_offset - random_offset
sqr_dist = np.dot(vector_from_cell_point, vector_from_cell_point)
weight = np.exp(-sqr_dist * np.float32(2.0))
weight = max(np.float32(0.0), weight - np.float32(0.01111))
weight_sum += weight
wave_input = np.dot(vector_from_cell_point, side_dir) + offset_tau
phase_dir[0] += np.cos(wave_input) * weight
phase_dir[1] += np.sin(wave_input) * weight
if weight_sum < 1e-6:
return np.array([0.0, 0.0], dtype=np.float32), side_dir
interpolated = phase_dir / weight_sum
magnitude = np.sqrt(np.dot(interpolated, interpolated))
magnitude = max(np.float32(1.0) - np.float32(normalization), magnitude)
return interpolated / magnitude, side_dir
@njit(inline="always")
def pow_inv(t, power):
return np.float32(1.0) - np.power(np.float32(1.0) - clamp01(t), np.float32(power))
@njit(inline="always")
def ease_out(t):
v = np.float32(1.0) - clamp01(t)
return np.float32(1.0) - v * v
@njit(inline="always")
def smooth_start(t, smoothing):
if t >= smoothing:
return t - np.float32(0.5) * np.float32(smoothing)
return np.float32(0.5) * t * t / np.float32(smoothing)
@njit(inline="always")
def safe_normalize(n):
length = np.sqrt(np.dot(n, n))
if length > 1e-10:
return n / length
return n
@njit(parallel=True)
def _run_erosion(
width,
height,
generation_scale,
erosion_scale,
random_seed,
erosion_strength,
erosion_gully_weight,
erosion_detail,
erosion_octaves,
):
output = np.zeros((height, width), dtype=np.float32)
scale = np.float32(erosion_scale)
strength_base = np.float32(erosion_strength) * scale
for y in prange(height):
for x in range(width):
p = np.array([x / width, y / height], dtype=np.float32) * np.float32(
generation_scale
)
height_freq = np.float32(3.0)
height_amp = np.float32(0.125)
val, deriv = fractal_noise(
p, height_freq, 3, 2.0, 0.1, np.int64(random_seed)
)
n_height = val * height_amp + np.float32(0.5)
n_slope = deriv * height_amp
curr_height = n_height
curr_slope = n_slope
fade_target = clamp(
(curr_height - np.float32(0.5)) / (height_amp * np.float32(0.6)),
-1.0,
1.0,
)
freq = np.float32(1.0) / (scale * EROSION_CELL_SCALE)
slope_length = max(
np.sqrt(np.dot(curr_slope, curr_slope)), np.float32(1e-10)
)
rounding_for_input = (
mix(
EROSION_ROUNDING[1],
EROSION_ROUNDING[0],
clamp01(fade_target + np.float32(0.5)),
)
* EROSION_ROUNDING[2]
)
combi_mask = ease_out(
smooth_start(
slope_length * EROSION_ONSET[0],
rounding_for_input * EROSION_ONSET[0],
)
)
gully_slope = mix(
curr_slope,
curr_slope / slope_length * EROSION_ASSUMED_SLOPE[0],
EROSION_ASSUMED_SLOPE[1],
)
strength = strength_base
rounding_mult = np.float32(1.0)
total_h_delta = np.float32(0.0)
total_strength = np.float32(0.0)
for _ in range(erosion_octaves):
phacelle_vec, side_dir = phacelle_noise(
p * freq,
safe_normalize(gully_slope),
EROSION_CELL_SCALE,
0.25,
EROSION_NORMALIZATION,
np.int64(random_seed),
)
p_deriv_dir = side_dir * -freq
sloping = abs(phacelle_vec[1])
gully_slope += (
np.sign(phacelle_vec[1])
* p_deriv_dir
* strength
* np.float32(erosion_gully_weight)
)
gullies_h = phacelle_vec[0]
faded_gullies_h = mix(
fade_target,
gullies_h * np.float32(erosion_gully_weight),
combi_mask,
)
total_h_delta += faded_gullies_h * strength
total_strength += strength
fade_target = faded_gullies_h
rounding_for_octave = (
mix(
EROSION_ROUNDING[1],
EROSION_ROUNDING[0],
clamp01(phacelle_vec[0] + np.float32(0.5)),
)
* rounding_mult
)
new_mask = ease_out(
smooth_start(
sloping * EROSION_ONSET[1],
rounding_for_octave * EROSION_ONSET[1],
)
)
combi_mask = pow_inv(combi_mask, erosion_detail) * new_mask
strength *= EROSION_GAIN
freq *= EROSION_LACUNARITY
rounding_mult *= EROSION_ROUNDING[3]
terrain_height_offset = np.array([-0.65, 0.0], dtype=np.float32)
final_fade_target = clamp(
(n_height - np.float32(0.5)) / (height_amp * np.float32(0.6)),
-1.0,
1.0,
)
offset = (
mix(
terrain_height_offset[0],
-final_fade_target,
terrain_height_offset[1],
)
* total_strength
)
output[y, x] = n_height + total_h_delta + offset
return output
def generate_heightmap(width, height, settings):
return _run_erosion(
width=int(width),
height=int(height),
generation_scale=np.float32(settings.generation_scale),
erosion_scale=np.float32(settings.erosion_scale),
random_seed=np.int64(settings.random_seed),
erosion_strength=np.float32(settings.erosion_strength),
erosion_gully_weight=np.float32(settings.erosion_gully_weight),
erosion_detail=np.float32(settings.erosion_detail),
erosion_octaves=int(settings.erosion_octaves),
)
def normalize_heightmap(heightmap):
h_min = float(np.min(heightmap))
h_max = float(np.max(heightmap))
if abs(h_max - h_min) < 1e-10:
return np.zeros_like(heightmap, dtype=np.float32)
return ((heightmap - h_min) / (h_max - h_min)).astype(np.float32)
def heightmap_to_pixel_data(heightmap, color_model, color_depth):
normalized = normalize_heightmap(heightmap)
if color_depth == "U8":
max_value = 255
gray = (normalized * max_value).astype(np.uint8)
alpha_value = np.uint8(max_value)
alpha_dtype = np.uint8
elif color_depth == "U16":
max_value = 65535
gray = (normalized * max_value).astype(np.uint16)
alpha_value = np.uint16(max_value)
alpha_dtype = np.uint16
elif color_depth == "F16":
gray = normalized.astype(np.float16)
alpha_value = np.float16(1.0)
alpha_dtype = np.float16
elif color_depth == "F32":
gray = normalized.astype(np.float32)
alpha_value = np.float32(1.0)
alpha_dtype = np.float32
else:
raise ValueError(f"Unsupported document color depth: {color_depth}")
alpha = np.full(gray.shape, alpha_value, dtype=alpha_dtype)
if color_model == "RGBA":
channels = np.stack((gray, gray, gray, alpha), axis=-1)
elif color_model == "GRAYA":
channels = np.stack((gray, alpha), axis=-1)
else:
raise ValueError(
f"Unsupported document color model: {color_model}. Use an RGBA or grayscale document."
)
return channels.tobytes()