diff --git a/README.md b/README.md index 9cc692e..61644d9 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ The `tests/` directory remains prototype-only reference code and is not imported ## Installing in Krita 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 diff --git a/pykrita/advanced_erosion_generator.desktop b/pykrita/advanced_erosion_generator.desktop deleted file mode 100644 index de0e84d..0000000 --- a/pykrita/advanced_erosion_generator.desktop +++ /dev/null @@ -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. diff --git a/pykrita/advanced_erosion_generator/__init__.py b/pykrita/advanced_erosion_generator/__init__.py deleted file mode 100644 index e4d58f5..0000000 --- a/pykrita/advanced_erosion_generator/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from krita import Krita - -from .extension import AdvancedErosionGeneratorExtension - - -app = Krita.instance() -extension = AdvancedErosionGeneratorExtension(parent=app) -app.addExtension(extension) diff --git a/pykrita/advanced_erosion_generator/extension.py b/pykrita/advanced_erosion_generator/extension.py deleted file mode 100644 index abd717c..0000000 --- a/pykrita/advanced_erosion_generator/extension.py +++ /dev/null @@ -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() diff --git a/pykrita/advanced_erosion_generator/generator.py b/pykrita/advanced_erosion_generator/generator.py deleted file mode 100644 index 85c3e0c..0000000 --- a/pykrita/advanced_erosion_generator/generator.py +++ /dev/null @@ -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()